Skip to content

feat(particle): add SubEmittersModule #2982

Merged
GuoLei1990 merged 83 commits into
galacean:dev/2.0from
hhhhkrx:feat/particle-sub-emitter
Jun 18, 2026
Merged

feat(particle): add SubEmittersModule #2982
GuoLei1990 merged 83 commits into
galacean:dev/2.0from
hhhhkrx:feat/particle-sub-emitter

Conversation

@hhhhkrx

@hhhhkrx hhhhkrx commented May 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Add SubEmittersModule to ParticleGenerator: parent particles can fire additional ParticleRenderers on Birth/Death lifecycle events, mirroring Unity's ParticleSystem.SubEmittersModule semantics.

  • New module: subEmitters on every ParticleGenerator
  • Public API: SubEmittersModule / SubEmitter / ParticleSubEmitterType / ParticleSubEmitterProperty
  • Per slot: target ParticleRenderer, trigger type (Birth / Death), inheritProperties bitmask (Color | Size | Rotation), emitProbability, emitCount

Behavior

Aspect Behavior
Position Sub particles always fire at the parent particle's event position (Birth = emission pos; Death = ballistic-approx end pos)
Birth dispatch Hooks the end of _addNewParticle
Death dispatch Hooks _retireActiveParticles before each particle's slot is freed
Death position approximation a_ShapePos + dir·speed·lifetime + ½·gravity·r0·lifetime² — does NOT include VOL/FOL/Noise contributions; documented in code
Per-event emit count Slot's emitCount (defaults to 1); the target renderer's own Emission module is independent
Color inherit parent.startColor multiplied into child.startColor
Size inherit parent.startSize multiplied into child.startSize (componentwise)
Rotation inherit parent.startRotation0 added to child.startRotation0 (3D = XYZ; 2D = Z only)
Lifetime modulation Birth inherit: parent value with COL/SOL/ROL applied at normalizedAge = 0. Death inherit: parent value with COL/SOL/ROL applied at the actual normalizedAge at time of death
Self-reference Silently bailed (no infinite recursion)
Nested sub-emit Supported; per-generator-instance scratch buffers prevent recursive temp clobbering

Out of scope (follow-ups)

  • Collision / Trigger / Manual event types
  • Lifetime / Duration inherit flags
  • Accurate Death position for VOL/FOL/Noise-driven systems (would require CPU sim or TF readback)
  • Editor-side preview UX: when a child renderer is referenced as a sub-emitter target, selecting it alone should walk up to play the driver parent (editor-team responsibility)

Usage

const parent = entity.addComponent(ParticleRenderer);
const child = childEntity.addComponent(ParticleRenderer);

parent.generator.subEmitters.enabled = true;
const slot = parent.generator.subEmitters.addSubEmitter();
slot.emitter = child;
slot.type = ParticleSubEmitterType.Death;
slot.inheritProperties =
  ParticleSubEmitterProperty.Color |
  ParticleSubEmitterProperty.Size;
slot.emitCount = 10;
slot.emitProbability = 1;

Test plan

  • npm run b:module — all 12 packages build cleanly
  • vitest run tests/src/core/particle — 87 / 87 (77 existing + 10 new in SubEmitter.test.ts)
  • New tests cover: Birth/Death emit counts, sub system not double-firing on auto-play, emitProbability = 0 skip, module disabled skip, Color/Size/Rotation inherit value correctness (incl. parent's COL/SOL applied to inherit), self-reference no-recurse
  • e2e visual: particleRenderer-sub-emitter case + baseline image

Summary by CodeRabbit

  • New Features

    • Sub-emitter support: child particles can spawn on parent Birth or Death with per-slot emit counts and probabilities, optional inheritance of parent color/size/rotation, and approximate death-position handling. Nested/self-emits are suppressed to prevent recursion. Public APIs expose sub-emitter slots and related types.
  • Tests

    • Added deterministic tests validating Birth/Death triggers, inheritance effects, probabilities, position handling, recursion safety, and enable/disable behavior.

Review Change Stack

Sub Emitters fire additional ParticleRenderers on parent particle lifecycle
events. Each slot references a target renderer, the trigger event (Birth or
Death), an inherit-properties bitmask (Color / Size / Rotation), and a
per-event probability. Emit count per event comes from the sub renderer's
own Emission module (sum of t=0 burst counts; defaults to 1), aligning with
Unity's SubEmittersModule semantics.

Birth fires at the parent particle's emission position. Death fires at an
approximate end position computed via ballistic formula
(a_ShapePos + dir·speed·lifetime + ½·gravity·r0·lifetime²); VOL/FOL/Noise
contributions are not accounted for and will introduce drift when those
modules are enabled — documented in code.

Self-reference is silently bailed; nested sub-emit chains use per-instance
scratch buffers so static temps cannot clobber outer dispatches.
@coderabbitai

coderabbitai Bot commented May 8, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

This PR introduces particle sub-emitters, enabling child particle generators to spawn on parent Birth or Death events with optional color, size, and rotation inheritance. The implementation includes lifecycle event dispatch, probability gating, ballistic death-position estimation, and recursive emission prevention.

Changes

Particle Sub-Emitters: Lifecycle Event-Driven Spawning

Layer / File(s) Summary
Data Contracts & Enums
packages/core/src/particle/enums/ParticleSubEmitterType.ts, packages/core/src/particle/enums/ParticleSubEmitterProperty.ts, packages/core/src/particle/enums/ParticleRandomSubSeeds.ts
New ParticleSubEmitterType enum (Birth/Death), ParticleSubEmitterProperty bitmask (Color/Size/Rotation), and extended ParticleRandomSubSeeds with Sub-Emitter identifier.
SubEmitter Core Classes
packages/core/src/particle/modules/SubEmitter.ts, packages/core/src/particle/modules/SubEmittersModule.ts
SubEmitter configuration class holds target emitter, trigger type, inheritance flags, and emit probability. SubEmittersModule manages lifecycle dispatch via _onParticleBirth() and _onParticleDeath(), applies probability gating, prevents self-recursion, computes per-trigger emit count, derives parent-based overrides, and calls target generator _emitFromSubEmitter().
ParticleGenerator Integration
packages/core/src/particle/ParticleGenerator.ts
Generator now owns subEmitters module, applies inherited overrides to newly spawned particles in _addNewParticle(), dispatches Birth events, provides _emitFromSubEmitter() for world-to-local position transformation and override application, pre-scans Death slots during retirement, dispatches Death events via _dispatchDeathEvent() with ballistic position estimation and rotation reconstruction, and resets sub-emitter RNG seeds.
Public API Exports
packages/core/src/particle/index.ts
Barrel file re-exports ParticleSubEmitterType, ParticleSubEmitterProperty, SubEmitter, and SubEmittersModule.
Test Suite
tests/src/core/particle/SubEmitter.test.ts
Validates Birth/Death dispatch, t=0 burst multiplicity, emitProbability gating, disabled module behavior, color inheritance via instance vertex data inspection, and self-reference safety. Helper utilities ensure deterministic engine timing and consistent renderer configuration.

Sequence Diagram(s)

sequenceDiagram
  participant Parent as ParticleGenerator
  participant SubModule as SubEmittersModule
  participant Child as Target ParticleGenerator

  Parent->>Parent: _addNewParticle() (Birth)
  Parent->>SubModule: _onParticleBirth(worldPos, payload)
  SubModule->>SubModule: check probability & compute bursts
  SubModule->>Child: _emitFromSubEmitter(worldPos, count, overrides)
  Child->>Child: transform world->local, set overrides, _addNewParticle()
  Parent->>Parent: _retireActiveParticles()
  Parent->>Parent: _dispatchDeathEvent() -> SubModule._onParticleDeath
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 A rabbit hops through particle trails,
Sub-emitters burst when life prevails,
Birth sparks children, Death sends more,
Colors borrowed from parent store,
Gentle gates prevent a looping chore.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The pull request title accurately summarizes the primary change: adding a SubEmittersModule to the particle system. It is concise, clear, and directly reflects the main objective.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@hhhhkrx hhhhkrx self-assigned this May 8, 2026
@hhhhkrx hhhhkrx changed the title feat(particle): add SubEmittersModule with Birth/Death triggers feat(particle): add SubEmittersModule May 8, 2026
@codecov

codecov Bot commented May 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.20393% with 153 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.28%. Comparing base (5d74c1d) to head (c33a07d).

Files with missing lines Patch % Lines
e2e/case/particleRenderer-sub-emitter.ts 0.00% 105 Missing and 1 partial ⚠️
packages/core/src/particle/ParticleGenerator.ts 95.60% 13 Missing ⚠️
...ages/core/src/particle/modules/ParticleGradient.ts 81.25% 12 Missing ⚠️
...ore/src/particle/modules/ParticleCompositeCurve.ts 66.66% 7 Missing ⚠️
e2e/config.ts 0.00% 6 Missing ⚠️
.../src/particle/modules/ParticleCompositeGradient.ts 50.00% 6 Missing ⚠️
...ges/core/src/particle/modules/SubEmittersModule.ts 98.42% 3 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           dev/2.0    #2982      +/-   ##
===========================================
+ Coverage    79.17%   79.28%   +0.11%     
===========================================
  Files          914      919       +5     
  Lines       101666   102452     +786     
  Branches     11209    11351     +142     
===========================================
+ Hits         80489    81225     +736     
- Misses       20990    21039      +49     
- Partials       187      188       +1     
Flag Coverage Δ
unittests 79.28% <81.20%> (+0.11%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (4)
tests/src/core/particle/SubEmitter.test.ts (1)

50-83: ⚡ Quick win

Cleanup leaks on assertion failure across tests.

Each test relies on inline parent.entity.destroy() / child.entity.destroy() at the bottom. If any earlier expect fails, those destroy calls never run and the entities remain on the shared scene, where engine.update() will keep simulating them. Subsequent tests' _getAliveParticleCount() assertions can then pick up emissions from leaked entities and report misleading failures. Move teardown into an afterEach (or wrap in try/finally) so destruction is guaranteed.

♻️ Suggested test scaffolding
 describe("SubEmitter", () => {
   let engine: Engine;
+  const createdEntities: any[] = [];

   beforeAll(async function () {
     engine = await WebGLEngine.create({ canvas: document.createElement("canvas") });
     // ...
     engine.run();
   });
+
+  afterEach(() => {
+    while (createdEntities.length) createdEntities.pop()?.destroy();
+  });

Then push every created entity into createdEntities and drop the inline .destroy() calls.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/src/core/particle/SubEmitter.test.ts` around lines 50 - 83, Tests leak
entities when assertions fail because parent.entity.destroy() and
child.entity.destroy() are called only at the end of the test; change teardown
to guarantee destruction by either (preferred) collecting created entities from
calls to createParticleRenderer (e.g., push returned parent and child into a
createdEntities array) and removing inline .destroy() calls, then add an
afterEach that iterates createdEntities calling .entity.destroy() (and clearing
the array), or wrap each test body in try/finally and destroy parent/child in
finally; update the test file's lifecycle hooks (replace per-test inline
destroys in the "SubEmitter" spec with an afterEach cleanup) so leaked entities
cannot affect subsequent _getAliveParticleCount() assertions.
packages/core/src/particle/ParticleGenerator.ts (1)

1230-1240: 💤 Low value

!_suppressSubEmitterDispatch guard here is effectively unreachable.

_retireActiveParticles is only called from _update, never from inside _emitFromSubEmitter, so _suppressSubEmitterDispatch is always false when this pre-flight runs. The guard is harmless but adds noise; consider dropping it (or adding a comment that it's defensive against future call sites) to keep the intent of the flag focused on Birth recursion.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/particle/ParticleGenerator.ts` around lines 1230 - 1240, In
_retireActiveParticles, the !this._suppressSubEmitterDispatch check is
effectively unreachable because _retireActiveParticles is only called from
_update (not from _emitFromSubEmitter); remove the !_suppressSubEmitterDispatch
clause so the conditional reads just on subEmitters.enabled, or if you want to
keep a defensive note, add a one-line comment above the check explaining it
exists solely for future call sites to avoid confusing readers; the change
should be applied in the block that scans subEmitters.subEmitters for
ParticleSubEmitterType.Death inside the ParticleGenerator class (method
_retireActiveParticles) and must preserve the existing loop and hasDeathSlot
behavior.
packages/core/src/particle/modules/SubEmittersModule.ts (2)

110-114: 💤 Low value

Self-reference guard only catches direct cycles.

targetGen === this._generator blocks A → A, but A → B → A (or longer chains, especially through Death slots) will still produce a per-frame feedback loop that grows unbounded as long as both systems can spawn each other. Consider either documenting the limitation (sub-emitter cycles are the user's responsibility) or tracking an _inDispatch set during a top-level dispatch tick to break indirect cycles.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/particle/modules/SubEmittersModule.ts` around lines 110 -
114, The current guard only prevents direct self-reference (target.generator ===
this._generator) but allows indirect cycles like A→B→A; modify
SubEmittersModule's dispatch logic (the code referencing target.generator and
this._generator) to track active generators during a top-level dispatch using a
set (e.g., _inDispatch) keyed by generator instance or id and bail if a target
generator is already in the set, then remove it when that dispatch branch
completes; alternatively, if you prefer not to change runtime behavior, add a
clear comment in SubEmittersModule explaining that indirect sub-emitter cycles
(A→B→A) are not prevented and are the user's responsibility to avoid.

119-128: ⚡ Quick win

Burst count uses the same RNG as the probability gate.

_probabilityRand is consumed conditionally in the probability check (only when emitProbability < 1) and unconditionally here for burst.count.evaluate(...). That couples two semantically independent random streams, so flipping emitProbability from 1 to 0.999 shifts every subsequent burst-count sample. Consider using a dedicated Rand (e.g. seeded from ParticleRandomSubSeeds.SubEmitter ^ 0x...) for burst count evaluation, or simply pass Math.random()/an existing per-renderer rand to keep the probability stream pristine.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/particle/modules/SubEmittersModule.ts` around lines 119 -
128, The burst count evaluation currently uses the shared RNG _probabilityRand
(in SubEmittersModule) which couples it to the probability gate; change
burst.count.evaluate(...) to use a dedicated RNG instead of
_probabilityRand—either create/obtain a specific rand seeded for sub-emitter
burst sampling (e.g. derive from ParticleRandomSubSeeds.SubEmitter ^ 0x... and
use that rand) or call a renderer-local RNG/Math.random() when computing
burst.count.evaluate(undefined, ...); update the loop that calls
burst.count.evaluate to pass this new rand so the probability stream
(_probabilityRand) remains untouched.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/core/src/particle/ParticleGenerator.ts`:
- Around line 1280-1320: The death-event world position can read a stale gravity
modifier because instanceVertices[particleOffset + 19] is only set for
Constant/TwoConstants in _addNewParticle, so either ensure the slot is
initialized there for Curve/TwoCurves (set instanceVertices[offset + 19] to 0 or
the evaluated curve value) or, in the death-position calculation inside
_computeDeathWorldPos (where gravityMod is read), fall back to evaluating
main.gravityModifier at (playTime - particleAge) when the stored value is
invalid; update the doc comment near the death-event handling to mention
curve-mode gravity as a caveat if you choose initialization rather than
runtime-evaluation.
- Around line 1099-1130: The Prettier errors come from multi-line conditionals
and .set(...) calls in ParticleGenerator.ts; collapse the long if condition
starting with "!this._suppressSubEmitterDispatch && subEmitters.enabled &&
subEmitters.subEmitters.length > 0" into a single line, and collapse the
multi-line calls to parentSize.set(...) and parentRotation.set(...) into
single-line invocations; locate these by the symbols
_suppressSubEmitterDispatch, subEmitters, Vector3.transformByQuat,
birthWorldPos, parentColor (reading instanceVertices[offset + N]),
parentSize.set, parentRotation.set, and main.startRotation3D and reformat those
expressions to a single-line style to satisfy Prettier.
- Around line 1145-1188: _emitFromSubEmitter is converting worldPosition into
local emission space but skips scaling; after computing localPos
(ParticleGenerator._tempVector30) and applying inverse rotation
(Quaternion.invert/world transformByQuat), fetch the position scale from
main._getPositionScale() and apply its inverse to localPos (i.e., divide or
multiply by reciprocal per-component) so the emitted shape positions match the
scaled simulation space; ensure this occurs before calling _addNewParticle and
does not mutate the original worldPosition variable.

In `@tests/src/core/particle/SubEmitter.test.ts`:
- Around line 16-29: The updateEngine helper overwrites global performance.now
and never restores it; capture the original (e.g., const origNow =
performance.now) before replacing and restore it after running the frames (or
switch to vi.spyOn/performance.get or vi.useFakeTimers which auto-cleans between
tests). Update the updateEngine function to save performance.now, replace it for
deterministic timing, run the engine.update loop, then restore the saved
original so other tests aren’t affected; reference updateEngine and its use of
performance.now when making the change.

---

Nitpick comments:
In `@packages/core/src/particle/modules/SubEmittersModule.ts`:
- Around line 110-114: The current guard only prevents direct self-reference
(target.generator === this._generator) but allows indirect cycles like A→B→A;
modify SubEmittersModule's dispatch logic (the code referencing target.generator
and this._generator) to track active generators during a top-level dispatch
using a set (e.g., _inDispatch) keyed by generator instance or id and bail if a
target generator is already in the set, then remove it when that dispatch branch
completes; alternatively, if you prefer not to change runtime behavior, add a
clear comment in SubEmittersModule explaining that indirect sub-emitter cycles
(A→B→A) are not prevented and are the user's responsibility to avoid.
- Around line 119-128: The burst count evaluation currently uses the shared RNG
_probabilityRand (in SubEmittersModule) which couples it to the probability
gate; change burst.count.evaluate(...) to use a dedicated RNG instead of
_probabilityRand—either create/obtain a specific rand seeded for sub-emitter
burst sampling (e.g. derive from ParticleRandomSubSeeds.SubEmitter ^ 0x... and
use that rand) or call a renderer-local RNG/Math.random() when computing
burst.count.evaluate(undefined, ...); update the loop that calls
burst.count.evaluate to pass this new rand so the probability stream
(_probabilityRand) remains untouched.

In `@packages/core/src/particle/ParticleGenerator.ts`:
- Around line 1230-1240: In _retireActiveParticles, the
!this._suppressSubEmitterDispatch check is effectively unreachable because
_retireActiveParticles is only called from _update (not from
_emitFromSubEmitter); remove the !_suppressSubEmitterDispatch clause so the
conditional reads just on subEmitters.enabled, or if you want to keep a
defensive note, add a one-line comment above the check explaining it exists
solely for future call sites to avoid confusing readers; the change should be
applied in the block that scans subEmitters.subEmitters for
ParticleSubEmitterType.Death inside the ParticleGenerator class (method
_retireActiveParticles) and must preserve the existing loop and hasDeathSlot
behavior.

In `@tests/src/core/particle/SubEmitter.test.ts`:
- Around line 50-83: Tests leak entities when assertions fail because
parent.entity.destroy() and child.entity.destroy() are called only at the end of
the test; change teardown to guarantee destruction by either (preferred)
collecting created entities from calls to createParticleRenderer (e.g., push
returned parent and child into a createdEntities array) and removing inline
.destroy() calls, then add an afterEach that iterates createdEntities calling
.entity.destroy() (and clearing the array), or wrap each test body in
try/finally and destroy parent/child in finally; update the test file's
lifecycle hooks (replace per-test inline destroys in the "SubEmitter" spec with
an afterEach cleanup) so leaked entities cannot affect subsequent
_getAliveParticleCount() assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: b496579f-b5cf-4dce-99ce-a3e5edb8cf82

📥 Commits

Reviewing files that changed from the base of the PR and between e19b764 and 1c26e15.

📒 Files selected for processing (8)
  • packages/core/src/particle/ParticleGenerator.ts
  • packages/core/src/particle/enums/ParticleRandomSubSeeds.ts
  • packages/core/src/particle/enums/ParticleSubEmitterProperty.ts
  • packages/core/src/particle/enums/ParticleSubEmitterType.ts
  • packages/core/src/particle/index.ts
  • packages/core/src/particle/modules/SubEmitter.ts
  • packages/core/src/particle/modules/SubEmittersModule.ts
  • tests/src/core/particle/SubEmitter.test.ts

Comment thread packages/core/src/particle/ParticleGenerator.ts Outdated
Comment on lines +1145 to 1188
_emitFromSubEmitter(
count: number,
worldPosition: Vector3,
inheritColor: Color,
inheritSize: Vector3,
inheritRotation: Vector3
): void {
if (count <= 0) return;

const main = this.main;
const notRetired = this._getNotRetiredParticleCount();
const available = main.maxParticles - notRetired;
if (available <= 0) return;
if (count > available) count = available;

const transform = this._renderer.entity.transform;
const worldPos = transform.worldPosition;
const worldRot = transform.worldRotationQuaternion;

// Convert event world position into local emission space for a_ShapePos
const localPos = ParticleGenerator._tempVector30;
Vector3.subtract(worldPosition, worldPos, localPos);
const invRot = ParticleGenerator._tempQuat0;
Quaternion.invert(worldRot, invRot);
Vector3.transformByQuat(localPos, invRot, localPos);

const direction = ParticleGenerator._tempVector31;
direction.set(0, 0, -1);

this._subEmitColorOverride = inheritColor;
this._subEmitSizeOverride = inheritSize;
this._subEmitRotationOverride = inheritRotation;
this._suppressSubEmitterDispatch = true;

const playTime = this._playTime;
for (let i = 0; i < count; i++) {
this._addNewParticle(localPos, direction, transform, playTime);
}

this._subEmitColorOverride = null;
this._subEmitSizeOverride = null;
this._subEmitRotationOverride = null;
this._suppressSubEmitterDispatch = false;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# See how positionScale is applied elsewhere so the inverse path can mirror it.
ast-grep --pattern '_getPositionScale() { $$$ }'
rg -nP -C3 'positionScale' --type=ts -g 'packages/core/src/particle/**'

Repository: galacean/engine

Length of output: 4352


🏁 Script executed:

rg -nA10 '_getPositionScale\(\)' packages/core/src/particle/modules/MainModule.ts

Repository: galacean/engine

Length of output: 427


🏁 Script executed:

rg -nA5 '_emitFromSubEmitter' packages/core/src/particle/ParticleGenerator.ts

Repository: galacean/engine

Length of output: 546


🏁 Script executed:

# Check the full context of _emit to understand positionScale application
sed -n '300,330p' packages/core/src/particle/ParticleGenerator.ts

Repository: galacean/engine

Length of output: 1304


🏁 Script executed:

sed -n '1145,1188p' packages/core/src/particle/ParticleGenerator.ts

Repository: galacean/engine

Length of output: 1561


🏁 Script executed:

rg -i 'subemit|sub.emit' packages/core/src/particle --type=ts -l

Repository: galacean/engine

Length of output: 429


🏁 Script executed:

# Check if there are any tests for sub-emitters
fd -i 'test|spec' packages/core --type f -path '*particle*' | head -20

Repository: galacean/engine

Length of output: 228


🏁 Script executed:

# Look for any scale-related sub-emitter logic or comments
rg -n -C2 'sub.*scale|scale.*sub' packages/core/src/particle --type=ts

Repository: galacean/engine

Length of output: 41


🏁 Script executed:

rg -n -B5 -A10 '_emitFromSubEmitter' packages/core/src/particle/modules/SubEmittersModule.ts

Repository: galacean/engine

Length of output: 563


🏁 Script executed:

# Also check if positionScale is referenced anywhere in sub-emitter logic
rg -n 'positionScale' packages/core/src/particle/modules/SubEmitter*.ts

Repository: galacean/engine

Length of output: 41


Sub-emitter position conversion missing positionScale scaling.

_emit applies position.multiply(positionScale) via main._getPositionScale() to scale shape positions into the correct simulation space. _emitFromSubEmitter converts the event's world position to local space via translation and inverse rotation only—it never accounts for scale. When the target generator has non-unit scale (e.g., via ScaleMode.World, ScaleMode.Local, or ScaleMode.Shape with scaled parent), the emitted particle's shape position will be off by the scale factor, causing visible offset when particles move under velocity-over-lifetime, force-over-lifetime, or in Local simulation space.

Fix: Apply inverse of main._getPositionScale() to localPos after the rotation transformation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/particle/ParticleGenerator.ts` around lines 1145 - 1188,
_emitFromSubEmitter is converting worldPosition into local emission space but
skips scaling; after computing localPos (ParticleGenerator._tempVector30) and
applying inverse rotation (Quaternion.invert/world transformByQuat), fetch the
position scale from main._getPositionScale() and apply its inverse to localPos
(i.e., divide or multiply by reciprocal per-component) so the emitted shape
positions match the scaled simulation space; ensure this occurs before calling
_addNewParticle and does not mutate the original worldPosition variable.

Comment thread packages/core/src/particle/ParticleGenerator.ts Outdated
Comment on lines +16 to +29
function updateEngine(engine: Engine, frames: number, deltaTime = 100) {
//@ts-ignore
engine._vSyncCount = Infinity;
//@ts-ignore
engine._time._lastSystemTime = 0;
let times = 0;
performance.now = function () {
times++;
return times * deltaTime;
};
for (let i = 0; i < frames; i++) {
engine.update();
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find any tests that read performance.now and might be affected by the global override.
rg -nP -C2 'performance\.now' --type=ts -g '!**/dist/**' -g '!**/build/**'

Repository: galacean/engine

Length of output: 10866


Restore performance.now after test execution or use test framework mocking.

The updateEngine function overwrites the global performance.now without restoration. If particle tests run before other time-sensitive tests (e.g., shader compilation benchmarks, engine timing), those tests will receive stale deterministic values instead of real timestamps, silently breaking their behavior. Capture the original and restore it in cleanup, or use vi.spyOn(performance, 'now', 'get') or vi.useFakeTimers() which automatically cleans up between tests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/src/core/particle/SubEmitter.test.ts` around lines 16 - 29, The
updateEngine helper overwrites global performance.now and never restores it;
capture the original (e.g., const origNow = performance.now) before replacing
and restore it after running the frames (or switch to vi.spyOn/performance.get
or vi.useFakeTimers which auto-cleans between tests). Update the updateEngine
function to save performance.now, replace it for deterministic timing, run the
engine.update loop, then restore the saved original so other tests aren’t
affected; reference updateEngine and its use of performance.now when making the
change.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

…bursts

Previously each slot read its target renderer's t=0 bursts to derive per-event
count. That path had two problems:

- If the target renderer also plays on its own (playOnEnabled, manual play),
  its own EmissionModule fires those bursts independently — the sub-emit path
  re-fires them, double-counting.
- The same `_probabilityRand` was consumed for both the probability roll and
  the burst-count lerp factor, so adding/removing a burst shifted the entire
  downstream RNG stream (seed-unstable).

Replace with an explicit `emitCount: number = 1` per slot. The target
renderer's EmissionModule is left fully alone (bursts / rate / playOnEnabled
co-exist with sub-emit driving with no overlap). `_probabilityRand` now serves
only the probability roll.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (1)
tests/src/core/particle/SubEmitter.test.ts (1)

16-29: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Restore performance.now after the helper completes.

updateEngine permanently overwrites the global performance.now. Any later test in the same Vitest run that depends on real wall-clock time will silently see the deterministic stub, and once this test file finishes the global stays in its last state. Capture and restore the original within the helper (or use vi.spyOn(globalThis, 'performance', 'get') / vi.useFakeTimers()), and pair the override with afterEach cleanup.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/src/core/particle/SubEmitter.test.ts` around lines 16 - 29, The helper
updateEngine in SubEmitter.test.ts replaces global performance.now permanently;
capture the original (e.g. const originalNow = performance.now) before
overwriting and restore it after the helper runs (or switch to
vi.spyOn(globalThis, 'performance', 'get')/vi.useFakeTimers()), and ensure tests
include an afterEach cleanup that restores performance.now so later tests see
the real clock; update the updateEngine function and add an afterEach restore to
fix this in the test file.
🧹 Nitpick comments (5)
packages/core/src/particle/modules/SubEmittersModule.ts (2)

121-122: 💤 Low value

| 0 is a sharp truncation; prefer Math.trunc or Math.floor.

Using sub.emitCount | 0 coerces via a 32-bit signed conversion: any emitCount ≥ 2³¹ wraps to a negative value and is silently dropped by the count <= 0 guard, and any fractional value above 2³¹ − 1 similarly explodes. For a user-facing field, Math.trunc(sub.emitCount) (or Math.max(0, Math.floor(sub.emitCount))) gives more predictable semantics with effectively the same cost. Not a release blocker, but worth tightening since emitCount is a public field.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/particle/modules/SubEmittersModule.ts` around lines 121 -
122, Replace the 32-bit bitwise coercion on sub.emitCount with a safer numeric
truncation and non-negative guard: instead of using "sub.emitCount | 0" compute
a truncated integer (e.g. Math.trunc(sub.emitCount)) and ensure it's clamped to
zero or above (e.g. Math.max(0, Math.trunc(sub.emitCount))). Update the count
assignment in SubEmittersModule.ts where "const count = sub.emitCount | 0" is
set so fractional values and very large numbers are handled predictably and
won't wrap into negatives.

30-32: 💤 Low value

Redundant constructor.

The explicit constructor only forwards to super(generator) without adding behavior; it can be removed and the base-class constructor will be used directly. Pure nit.

♻️ Proposed cleanup
-  constructor(generator: ParticleGenerator) {
-    super(generator);
-  }
-
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/particle/modules/SubEmittersModule.ts` around lines 30 -
32, Remove the redundant constructor in the SubEmittersModule class: the
explicit constructor that only calls super(generator) is unnecessary because the
base class constructor will be used automatically; delete the constructor
definition in SubEmittersModule so the class relies on the inherited constructor
(referencing SubEmittersModule, constructor, ParticleGenerator, and super).
tests/src/core/particle/SubEmitter.test.ts (3)

50-61: ⚡ Quick win

Missing afterAll to destroy the engine.

The shared engine is created in beforeAll but never disposed. With WebGLEngine, this leaks the canvas/GL context across the test runner's lifetime and, when combined with other particle suites that also create engines, can exhaust browser/jsdom resources or cross-contaminate global state (audio context, render frame loops, etc.). Add an afterAll(() => engine.destroy()).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/src/core/particle/SubEmitter.test.ts` around lines 50 - 61, The test
creates a shared WebGLEngine in beforeAll but never disposes it, leaking
GL/canvas resources; add an afterAll hook that calls engine.destroy() to clean
up. Specifically, in the SubEmitter test suite where beforeAll creates the
engine via WebGLEngine.create and assigns it to the engine variable, add
afterAll(() => engine.destroy()) (or await engine.destroy() if destroy is async)
to ensure the created engine, canvas and GL context are torn down after tests.

207-211: 💤 Low value

Magic offsets into _instanceVertices couple the test to the GPU buffer layout.

Indexing buf[8], buf[9], buf[10] for the color components is brittle — any reshuffle of the particle vertex stride/attribute order silently breaks this test (and the failure mode is "wrong number" rather than "wrong attribute"). Consider naming the offsets via a small local constant tied to a documented stride, or asserting via a higher-level read path (e.g., a debug accessor on ParticleGenerator) so the assertion describes intent.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/src/core/particle/SubEmitter.test.ts` around lines 207 - 211, The test
is using magic indices into child.generator._instanceVertices (buf[8], buf[9],
buf[10]) which couples the test to the GPU buffer layout; add a stable,
self-documenting way to read color components instead: either introduce local
constants in the test (e.g., PARTICLE_VERTEX_STRIDE and COLOR_OFFSET) and
compute base = particleIndex * PARTICLE_VERTEX_STRIDE + COLOR_OFFSET before
asserting buf[base + 0/1/2], or (preferred) add a debug/read accessor on
ParticleGenerator (e.g., ParticleGenerator.getInstanceColor(instanceIndex) or
getInstanceAttribute(instanceIndex, "color")) that returns the RGBA components
and use that in the test (reference symbols: _instanceVertices,
ParticleGenerator, child.generator). Ensure the test uses the new
constants/accessor so it asserts intent rather than raw indices.

53-61: ⚡ Quick win

Clearer engine initialization: avoid relying on vSync settings to suppress the rAF loop.

engine.run() starts a rAF loop that calls engine.update() each tick. The updateEngine() function mitigates double-ticking by setting _vSyncCount = Infinity (line 18), which prevents the rAF-driven updates. However, this implicit workaround is fragile and makes the test harder to understand.

For clarity, either omit engine.run() and drive everything via manual engine.update(), or explicitly call engine.pause() before entering updateEngine() to halt the rAF loop.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/src/core/particle/SubEmitter.test.ts` around lines 53 - 61, The test
currently calls engine.run() which starts a rAF loop and relies on setting
_vSyncCount = Infinity (in updateEngine) to prevent double updates; instead,
stop the rAF loop explicitly by either removing the engine.run() call and
driving frames only via updateEngine(), or call engine.pause() immediately after
engine.run() before entering updateEngine(); update the test setup to use
WebGLEngine.create(...) and then either omit engine.run() or call engine.pause()
so updateEngine() no longer depends on mutating _vSyncCount to suppress rAF
updates.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/core/src/particle/modules/SubEmittersModule.ts`:
- Around line 103-114: Move the self-reference and early-exit guards so they run
before consuming the probability RNG: in SubEmittersModule, check target (const
target = sub.emitter), ensure target !== null && !target.destroyed, then check
target.generator !== this._generator (the self-reference guard) and the
emitCount <= 0 guard (where emitCount is used) before calling
this._probabilityRand.random() or evaluating sub.emitProbability; this prevents
wasting calls to this._probabilityRand.random() for slots that are going to be
skipped.
- Around line 106-108: The current check in SubEmittersModule uses
this._probabilityRand.random() > sub.emitProbability which can allow a rare
firing when emitProbability is 0 because random() can return 0; update the logic
to explicitly handle non-positive probabilities before calling the RNG—e.g.,
return immediately if sub.emitProbability <= 0, otherwise keep the existing
probabilistic branch (or alternatively change the comparison to >= to treat 0 as
never emit). Ensure you reference SubEmittersModule and the
_probabilityRand.random() call and avoid calling random() when emitProbability
is non-positive.
- Around line 111-114: The current guard in SubEmittersModule only blocks direct
self-reference (targetGen === this._generator) but not indirect cycles; fix by
tracking visited generators per emission chain: add a visited Set (e.g.,
Set<ParticleGenerator> or WeakSet) passed into the sub-emitter dispatch path
(methods in SubEmittersModule that perform emission, the block containing
targetGen and _suppressSubEmitterDispatch) and before recursing check if
visited.has(targetGen) — if so, bail early; otherwise add targetGen to visited
before invoking its emission and remove after, or use a depth counter with a
maxDepth fallback; ensure you reference and update _suppressSubEmitterDispatch
behavior consistently so Birth/Death suppression still applies.

---

Duplicate comments:
In `@tests/src/core/particle/SubEmitter.test.ts`:
- Around line 16-29: The helper updateEngine in SubEmitter.test.ts replaces
global performance.now permanently; capture the original (e.g. const originalNow
= performance.now) before overwriting and restore it after the helper runs (or
switch to vi.spyOn(globalThis, 'performance', 'get')/vi.useFakeTimers()), and
ensure tests include an afterEach cleanup that restores performance.now so later
tests see the real clock; update the updateEngine function and add an afterEach
restore to fix this in the test file.

---

Nitpick comments:
In `@packages/core/src/particle/modules/SubEmittersModule.ts`:
- Around line 121-122: Replace the 32-bit bitwise coercion on sub.emitCount with
a safer numeric truncation and non-negative guard: instead of using
"sub.emitCount | 0" compute a truncated integer (e.g. Math.trunc(sub.emitCount))
and ensure it's clamped to zero or above (e.g. Math.max(0,
Math.trunc(sub.emitCount))). Update the count assignment in SubEmittersModule.ts
where "const count = sub.emitCount | 0" is set so fractional values and very
large numbers are handled predictably and won't wrap into negatives.
- Around line 30-32: Remove the redundant constructor in the SubEmittersModule
class: the explicit constructor that only calls super(generator) is unnecessary
because the base class constructor will be used automatically; delete the
constructor definition in SubEmittersModule so the class relies on the inherited
constructor (referencing SubEmittersModule, constructor, ParticleGenerator, and
super).

In `@tests/src/core/particle/SubEmitter.test.ts`:
- Around line 50-61: The test creates a shared WebGLEngine in beforeAll but
never disposes it, leaking GL/canvas resources; add an afterAll hook that calls
engine.destroy() to clean up. Specifically, in the SubEmitter test suite where
beforeAll creates the engine via WebGLEngine.create and assigns it to the engine
variable, add afterAll(() => engine.destroy()) (or await engine.destroy() if
destroy is async) to ensure the created engine, canvas and GL context are torn
down after tests.
- Around line 207-211: The test is using magic indices into
child.generator._instanceVertices (buf[8], buf[9], buf[10]) which couples the
test to the GPU buffer layout; add a stable, self-documenting way to read color
components instead: either introduce local constants in the test (e.g.,
PARTICLE_VERTEX_STRIDE and COLOR_OFFSET) and compute base = particleIndex *
PARTICLE_VERTEX_STRIDE + COLOR_OFFSET before asserting buf[base + 0/1/2], or
(preferred) add a debug/read accessor on ParticleGenerator (e.g.,
ParticleGenerator.getInstanceColor(instanceIndex) or
getInstanceAttribute(instanceIndex, "color")) that returns the RGBA components
and use that in the test (reference symbols: _instanceVertices,
ParticleGenerator, child.generator). Ensure the test uses the new
constants/accessor so it asserts intent rather than raw indices.
- Around line 53-61: The test currently calls engine.run() which starts a rAF
loop and relies on setting _vSyncCount = Infinity (in updateEngine) to prevent
double updates; instead, stop the rAF loop explicitly by either removing the
engine.run() call and driving frames only via updateEngine(), or call
engine.pause() immediately after engine.run() before entering updateEngine();
update the test setup to use WebGLEngine.create(...) and then either omit
engine.run() or call engine.pause() so updateEngine() no longer depends on
mutating _vSyncCount to suppress rAF updates.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 79635f31-7e0d-4a9b-ac6b-aa7618fed8e7

📥 Commits

Reviewing files that changed from the base of the PR and between 3b52b72 and 9e9b2a3.

📒 Files selected for processing (3)
  • packages/core/src/particle/modules/SubEmitter.ts
  • packages/core/src/particle/modules/SubEmittersModule.ts
  • tests/src/core/particle/SubEmitter.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/core/src/particle/modules/SubEmitter.ts

Comment thread packages/core/src/particle/modules/SubEmittersModule.ts Outdated
Comment thread packages/core/src/particle/modules/SubEmittersModule.ts Outdated
Comment thread packages/core/src/particle/modules/SubEmittersModule.ts Outdated
hhhhkrx added 2 commits May 11, 2026 14:35
…bability roll

target null/destroyed / self-reference / emitCount<=0 are static slot filters
that previously sat after `_probabilityRand.random()`. With useAutoRandomSeed
off, adding or toggling a no-op slot would consume one rand and shift every
downstream probability check, breaking deterministic playback.

Move all non-RNG filters above the roll so only slots that actually participate
in the event consume rand.
…ributes

Replace the test's direct `_instanceVertices[8..10]` poke (which required
`@ts-ignore` and hardcoded the buffer's float-offset layout) with three
@internal accessors on ParticleGenerator: `_readParticleStartColor`,
`_readParticleStartSize`, `_readParticleStartRotation`. Same conventional
pattern as `_getAliveParticleCount` — underscore-prefixed, @internal-tagged.

Color inherit test updated; Size / Rotation inherit tests can use the
matching accessors when added.
GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

@hhhhkrx

hhhhkrx commented May 11, 2026

Copy link
Copy Markdown
Contributor Author

@GuoLei1990 P0 这个 _suppressSubEmitterDispatch 已经覆盖了,不只是直接自引用。

关键:_emitFromSubEmitter 进入时把 target generator 的 flag 置 true(不是 caller 的),target 内部的 _addNewParticle 在 Birth dispatch 守卫里检查这个 flag,所以 target 在被 fire 的当前栈帧里不会触发自己的 sub-emitter。

A→B→A 走一遍:

  1. A 自播 → A._addNewParticle → A 的 flag = false,进 dispatch → B._emitFromSubEmitter(...)
  2. B._emitFromSubEmitterB 的 flag 置 true
  3. B._addNewParticle → B 的 flag = true,跳过 Birth dispatch,不会 fire 回 A
  4. B 清 flag,返回

调用栈深度 = 1。A↔B↔C 同理,每层 _emitFromSubEmitter 都设 target 自己的 flag,链上没有递归回环。

你说"B 的 flag 为 false"那段——在 A fire B 期间 B 的 flag 是 true,是动态栈标志不是静态属性。

跨帧 B 自播再 fire A 是独立路径不是递归调用,每帧一次没有指数增长。

Unity 全局 Set 是因为它"重新实例化子系统"会同栈帧再跑 EmissionModule;我们的 _emitFromSubEmitter 是一次性写入,没有这个回环。

Engine bitmask enums (Layer / PointerButton / CameraClearFlags / ColorWriteMask
/ SpriteMaskLayer / ActiveChangeFlag) all use 0x1, 0x2, 0x4 ... convention.
Align ParticleSubEmitterProperty to the same style — 1 << n was an outlier.
GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

* @internal
*/
override _onEnable(): void {
this.generator.subEmitters._bindSlots();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const generator = this.generator;

@ignoreClone
_module: SubEmittersModule = null;

private _emitter: ParticleRenderer = null;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

私有变量放到 public 变量之后

@cptbtptpbcptdtptp cptbtptpbcptdtptp left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

@GuoLei1990 GuoLei1990 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

增量审查(2026-06-17 @ a43fdbdca

3345da454(上轮基线)是 HEAD 的祖先(git merge-base --is-ancestor 核对通过,线性推进非 force-push)。自上轮新增 5 个 commit,触碰 5 个文件,净 +127/-11:

  • 67fedab6b fix: _onEnable 里 reconcile transform-feedback(覆盖反序列化路径)+ _setTransformFeedbackisWebGL2 && 门控
  • b98de15e3 fix: 把 SubEmitter.emitter / type 从公开字段改成 reactive setter,路由进 validation
  • 1a1eee6b8 style: trim 三个 @internal 方法的注释
  • c1de57577 style: _onEnable 缓存 generator 局部变量
  • a43fdbdca style: SubEmitter public 字段排到 private 之前

总结

b98de15e3 正落地了我上轮 P3 的剩余诉求(sub.emitter 公开可写字段绕过环检测)——把 emitter/type 改成 reactive setter,set emitter 路由进 _validateEmitter(环检测),set type 路由进 _refreshTransformFeedback67fedab6b_setTransformFeedbackisWebGL2 && 门控并在 _onEnable reconcile,正确兜住反序列化路径(_enabled 被直接还原、绕过 setter 的 WebGL1 守卫时不再在 WebGL1 上误建 TF simulator)。逐链核对后,绝大部分正确。唯一新问题是这轮"路由进 validation"只补齐了一半——set type 把 slot 翻成 Death 时缺了 addSubEmitter 已有的 Death+WebGL2 守卫,在 WebGL1 上可静默触发 _onParticleDeath 读 null _feedbackReadback 的崩溃(P1,见下)。

本轮变更核对

67fedab6b isWebGL2 && 门控 + _onEnable reconcile — 正确,兜住反序列化 ✓

_setTransformFeedbackneededVOL || noise || Death-slot 改成 isWebGL2 && (VOL || noise || Death-slot)。逐链核对其必要性:

  • VOL(LimitVelocityOverLifetimeModule set enabled,:240)与 Noise(NoiseModule set enabled,:206)的 setter 本就 if (value && !isWebGL2) return 静默拒绝——运行时 setter 路径下 _enabled 在 WebGL1 永不为 true。
  • 但反序列化把 _enabled / Death slot 直接还原(绕过 setter),所以没有这道门控时,_onEnable_setTransformFeedback 会在 WebGL1 上 new ParticleTransformFeedbackSimulator → 后续 draw/program-link 触碰 WebGL2-only API 崩溃。门控落在 _setTransformFeedback 这个收口点(所有入口都过它)是正确落点。
  • _onEnable 新增 _bindSlots() + _setTransformFeedback()_bindSlots 把每个 slot 的 _module 重新绑定(clone/反序列化后 _module@ignoreClone → null,需重绑才能让 setter 路由生效);_setTransformFeedback 从当前 config 重算 TF。两者顺序对(先绑定再重算),配回归测试 "Death slot reconciles transform-feedback on enable (deserialize path)"(手动把 _useTransformFeedback=false/_feedbackSimulator=null 模拟反序列化,断言 enable 后重建)。

b98de15e3 reactive setter — clone 路径逐链验证无双克隆/无误触发 setter ✓

逐链核对 clone 与 setter 的交互(这是最 subtle 的一处):

  • SubEmitter@deepClone _subEmitters 数组深拷贝。CloneManager.clonePropertydefault 分支用 for (let k in sourceProperty) 遍历——ES class field(loose 模式 babel 编译成构造器内 this._emitter = null 赋值)是 own enumerable,accessor(get/set emitter)在 prototype 上 非枚举。故 for...in 只遍历 _emitter/_type/_module/三个 public 字段,不会emitter/type setter,也不会二次克隆 accessor。
    • _emitterParticleRenderer(Component)带 _remapcloneProperty 优先走 _remap(CloneManager.ts:109,先于 cloneMode),跨层级 remap,直接写 _emitter 不经 setter——此时 _module 仍 null,即便经 setter 也是 no-op,无误触发 validation。
    • _type:primitive → 直接赋值,不经 setter。
    • _module@ignoreCloneCloneMode.Ignore → skip,clone 后为 null,靠 _onEnable_bindSlots 重绑。
  • addSubEmittersub.emitter=emitter / sub.type=type 都在 sub._module=this 之前执行,故 setter 里的 this._module?._validateEmitter / _refreshTransformFeedback 全是 no-op(optional chaining 短路);addSubEmitter 自己做完整的 Death+WebGL2 检查(:73)、环检测(:77)、_setTransformFeedback(:88)。无双重 validation、无premature。绑定 _module 后的后续 slot.emitter=x/slot.type=y 才路由进 validation——正是预期的封装。
  • set emitter_validateEmitter(value)this._emitter = value,throw 时 _emitter 不变(测试 "Changing a slot's emitter to form a cycle throws" 断言 slot.emitter === c 守住)。✓
  • setter 职责拆分正确:set emitter 只验环(改 emitter 不改 _hasSubEmitterOfType(Death),无需刷 TF);set type 只刷 TF(翻进/出 Death 改变 _hasSubEmitterOfType(Death),需刷;但不涉及环)。

1a1eee6b8 trim 注释 — 符合本文件惯例 ✓

删的是 _bindSlots/_refreshTransformFeedback/_validateEmitter 三个 @internal 方法的机制描述 + _module 的 doc body。本文件其他 @internal 私有方法(_dispatchEvent/_resetRandomSeed/_hasSubEmitterOfType)一律裸 /** @internal */ 无 body——这次 trim 与既有惯例一致。删的是 WHAT/机制描述,非保护非显然不变量的 load-bearing WHY(不同于我历轮提的 >= 概率注释),不再单列。

c1de57577 / a43fdbdca style — 行为中性 ✓

generator 局部缓存(getter 调 1 次 vs 4 次,等价);字段排序(_module @internal 在前、private _emitter/_type 在后,符合 member-ordering 惯例,纯 whitespace)。

问题

[P1] SubEmitter.ts:44 set type 缺 Death+WebGL2 守卫 — 在 WebGL1 上把 slot 翻成 Death 可触发 _onParticleDeath 读 null _feedbackReadback 崩溃

这轮 b98de15e3 的核心目标是"route slot mutations through validation",但只路由了一半set emitter 复制了 addSubEmitter 的环检测(经 _validateEmitter),set type没有复制 addSubEmitter 的 Death+WebGL2 守卫(addSubEmitter :73 if (type === Death && !isWebGL2) throw)。

逐链核对崩溃路径(WebGL1):

  1. addSubEmitter(child, Birth) 在 WebGL1 合法(不抛),slot 落地、_module 绑定。
  2. slot.type = ParticleSubEmitterType.Deathset type → 不抛 → _type=Death + _refreshTransformFeedback_setTransformFeedbackneeded = isWebGL2(false) && ... = false_feedbackSimulator 保持 null。
  3. 父粒子退役:_retireActiveParticlesParticleGenerator.ts:1226,在 _update无条件跑,不受 instancedArrays/WebGL2 gate)。hasDeathSlot = _hasSubEmitterOfType(Death) = true
  4. :1247 if (this._feedbackSimulator && !feedbackLoaded) —— _feedbackSimulator 为 null → _readbackFeedback 被跳过_feedbackReadback 仍是初始 null(:142)。
  5. :1251 _onParticleDeath(activeParticleOffset) 仍被调用 → :1295 const feedbackData = this._feedbackReadback(null)→ :1296 feedbackData[feedbackOffset]TypeError: Cannot read properties of null

即新的"受支持的 validated 变更入口"(PR 把 set type 做成 reactive 并配测试 "Changing a slot's type to Death reconciles transform-feedback",明确把它当一等 API)破坏了 addSubEmitter 立下的 fail-fast 契约:本该立刻抛 "Death sub-emitter requires WebGL2",却静默什么都不建、推迟到 _onParticleDeath 深处一个晦涩的 null-deref 崩溃。这正违反"不支持应尽早报错,不应静默跳过"——比 addSubEmitter 的契约严格更差

注意这与上轮 P3 不是同一问题:上轮是 emitter 字段绕过环检测(已由 set emitter 关闭),本条是 set typeDeath+WebGL2 检测——不同 setter、不同守卫、且是本轮新代码引入的不对称。

修法(任选):

  • 首选set typevalue === Death && !isWebGL2throw new Error("Death sub-emitter requires WebGL2"),与 addSubEmitter 对称。把 Death+WebGL2 检查抽成 _module?._validateType(value)_validateEmitter 并列,两个 setter 各调各的,封装一致。
  • 或:补一条 WebGL1 回归测试(mock isWebGL2=false)覆盖 set type = Death,先让崩溃显形再决定守卫落点。

WebGL2 上无此问题(set type=Death 正确建 TF,测试已覆盖);触发面是 WebGL1 + 经 set type(非 addSubEmitter)翻成 Death,narrow 但是真崩溃路径,且恰好落在本 commit 主打的"validated mutation"语义里——这是该补的对称守卫。

已关闭问题清单(全部维持)

问题 状态
历史全部 P0/P1/🔴(A→B→A 环递归、多级链 instance scratch、World 出生旋转、Birth/Death velocity 继承、emitCount 不截断、enum 重命名、e2e 时序、setKeys 4-key、emitProbability=0 根因 >=、COL/SOL/ROL 调制、cumulative-curve 搬移、空 gradient →0、Birth dispatch 门控、override 转 args、死 @ignoreClone 删除、间接环 throw 测试、Death readback 窗口收窄、clamp 区间积分) ✅ 全部闭环,本轮零差异
上轮 P3 sub.emitter 公开可写字段绕过环检测 本轮关闭——emitter 改 reactive setter,路由进 _validateEmitter,配 "Changing a slot's emitter to form a cycle throws" 回归
上轮 P3 subEmitters getter 单行 JSDoc 维持(纯格式,不再单列;本轮新 emitter/type getter 单行 doc 与本文件 public 字段惯例一致)
长期遗留 P2:单 key + clamp 区间 CPU/GPU 分叉 / 测试裸 buffer offset 维持;本轮未触碰 ParticleCurve.ts / 测试断言裸 offset

结论

5 个 commit:reactive setter 封装(关闭上轮 P3 的 emitter 绕过、clone 路径经 for...in own-enumerable 逐链验证不双克隆/不误触发 setter)+ isWebGL2 && TF 门控(正确兜住反序列化路径,落点在收口的 _setTransformFeedback)+ 3 个行为中性的 style。绝大部分逐链核对正确。 唯一新问题:b98de15e3 的"route slot mutations through validation"只补了一半——set typeaddSubEmitter 已有的 Death+WebGL2 守卫,在 WebGL1 上经 set type=Death 可触发 _onParticleDeath 读 null _feedbackReadback 的崩溃(P1,narrow 但真实,建议 set type 对称补守卫或补 WebGL1 回归)。CI 实质全绿(lint / build×3 / e2e×4 / codecov project+patch 本轮均 pass)。补上对称守卫后即可合入。

@GuoLei1990 GuoLei1990 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

复审 @ a43fdbdca(P2/P3 已闭环,上轮 P1 仍未真正修复,维持 request-changes)

自上轮(1a1eee6b8)以来推了一批 commit,其中三个针对我上轮提的三条:

  • [P2 已闭环] subEmitters getter 返回内部活数组623cc47d5 改为 get subEmitters(): readonly SubEmitter[]SubEmittersModule.ts:50),并把 mutation 收口到 addSubEmitter/removeSubEmitterByIndex。关闭。
  • [P3 已闭环] slot 直接 mutate 绕过校验b98de15e3 把 slot 改动路由进 _validateEmitter/addSubEmitter 的环检测 + WebGL2 守卫。关闭。
  • [P1 未真正修复] WebGL1 反序列化加载 Death slot 的 null-deref67fedab6b_setTransformFeedbackneeded 加了 isWebGL2 门控,并补了一条 "deserialize 重建 TF" 的回归测试——但那条测试跑在 WebGL2 上、只断言 WebGL2 行为,WebGL1 的崩溃路径仍未覆盖也未修isWebGL2 门控恰恰是把 WebGL1 暴露出来的那一步。

仍开放(阻塞)

[P1] WebGL1 反序列化含 Death sub-emitter 的场景,父粒子一死即 _onParticleDeath 解引用 null _feedbackReadback → TypeError 崩溃。

逐链核对 a43fdbdca 实际源码(每一环都读了真实代码,非推断):

  1. _subEmitters@deepClone privateSubEmittersModule.ts:46-47),反序列化/clone 直接还原,绕过 addSubEmitter 的 WebGL1+Death throw 守卫SubEmittersModule.ts:72-73)。能力门控只挂在配置入口,序列化字段天然绕过它。
  2. ParticleRenderer._onEnablegenerator._setTransformFeedback();WebGL1 下 needed = isWebGL2 && (...) = falseParticleGenerator.ts:684)→ 不进 if (needed) 分支 → _feedbackSimulator / _feedbackReadback 保持 nullParticleGenerator.ts:134/142)。
  3. _retireActiveParticles(每帧从 _update 跑,isWebGL2ParticleGenerator.ts:354)里 hasDeathSlot = subEmitters._hasSubEmitterOfType(Death):1232)只看 enabled + slot 类型,_useTransformFeedback/_feedbackSimulator 无关 → WebGL1 下仍为 true
  4. :1246-1252
    if (hasDeathSlot) {
      if (this._feedbackSimulator && !feedbackLoaded) {   // ← readback 被守住了
        this._readbackFeedback(...);
        feedbackLoaded = true;
      }
      this._onParticleDeath(activeParticleOffset);          // ← 但这行无条件调
    }
  5. _onParticleDeath:1288):const feedbackData = this._feedbackReadback;(null)→ :1298 local.set(feedbackData[feedbackOffset], ...) 读 null 的下标 → TypeError: Cannot read properties of null:1295:1298 之间无任何 null 检查。

零用户错误:正常的 WebGL2 编辑 → WebGL1 运行时降级加载即触发,父粒子越过 lifetime 的第一帧就崩。

修法(能力门控下沉到消费侧,与 _readbackFeedback 的守卫口径统一):把 :1246if (hasDeathSlot) 收紧为 if (hasDeathSlot && this._feedbackSimulator)(或 && this._useTransformFeedback),这样 WebGL1 下 Death 派发整体跳过,_readbackFeedback 那层守卫也随之冗余可清。或在 _onParticleDeath 入口 if (!this._feedbackReadback) return;。配一条真正跑在 WebGL1 上的回归测试(reconcile 后 _feedbackSimulator===null,父粒子死亡不抛)守住——现有 SubEmitter.test.ts:143 那条只验证 WebGL2,revert 修复也不会 fail。

与上轮 [P3]「运行时 mutate 绕过守卫」是同类(能力门控只挂配置入口)但更严重:那条是用户误用、doc 为 unsupported,这条是零用户错误的正常降级加载路径。

不阻塞 / 已记录

  • 同步 readback stall(@cptbtptpbcptdtptp 提):0f931fab 已收窄到「仅退役帧 × 仅活跃区间」,残留同步 getBufferSubData 是无 fence 基建的固有取舍、读上一帧 buffer、可接受降级,follow-up,不阻塞。
  • SubEmitterexportnewindex.ts),但唯一受支持入口是 addSubEmitter;标 @internal 可消除不一致 + 崩溃向量。P3,不阻塞。

结论

request-changes:P2/P3 已正确闭环;唯一阻塞仍是 WebGL1 反序列化 Death slot 的 null-deref(P1,未真正修复)——67fedab6b 修的是 WebGL2 重建路径,WebGL1 崩溃路径的能力门控需下沉到 _retireActiveParticles/_onParticleDeath 的消费侧,并配 WebGL1 回归测试。

@GuoLei1990 GuoLei1990 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

复审 @ a43fdbdca(P2/P3 已闭环,上轮 P1 仍未真正修复,维持 request-changes)

自上轮(1a1eee6b8)以来推了一批 commit,其中三个针对我上轮提的三条:

  • [P2 已闭环] subEmitters getter 返回内部活数组623cc47d5 改为 get subEmitters(): readonly SubEmitter[]SubEmittersModule.ts:50),并把 mutation 收口到 addSubEmitter/removeSubEmitterByIndex。关闭。
  • [P3 已闭环] slot 直接 mutate 绕过校验b98de15e3 把 slot 改动路由进 _validateEmitter/addSubEmitter 的环检测 + WebGL2 守卫。关闭。
  • [P1 未真正修复] WebGL1 反序列化加载 Death slot 的 null-deref67fedab6b_setTransformFeedbackneeded 加了 isWebGL2 门控,并补了一条 "deserialize 重建 TF" 的回归测试——但那条测试跑在 WebGL2 上、只断言 WebGL2 行为,WebGL1 的崩溃路径仍未覆盖也未修isWebGL2 门控恰恰是把 WebGL1 暴露出来的那一步。

仍开放(阻塞)

[P1] WebGL1 反序列化含 Death sub-emitter 的场景,父粒子一死即 _onParticleDeath 解引用 null _feedbackReadback → TypeError 崩溃。

逐链核对 a43fdbdca 实际源码(每一环都读了真实代码,非推断):

  1. _subEmitters@deepClone privateSubEmittersModule.ts:46-47),反序列化/clone 直接还原,绕过 addSubEmitter 的 WebGL1+Death throw 守卫SubEmittersModule.ts:72-73)。能力门控只挂在配置入口,序列化字段天然绕过它。
  2. ParticleRenderer._onEnablegenerator._setTransformFeedback();WebGL1 下 needed = isWebGL2 && (...) = falseParticleGenerator.ts:684)→ 不进 if (needed) 分支 → _feedbackSimulator / _feedbackReadback 保持 nullParticleGenerator.ts:134/142)。
  3. _retireActiveParticles(每帧从 _update 跑,isWebGL2ParticleGenerator.ts:354)里 hasDeathSlot = subEmitters._hasSubEmitterOfType(Death):1232)只看 enabled + slot 类型,_useTransformFeedback/_feedbackSimulator 无关 → WebGL1 下仍为 true
  4. :1246-1252
    if (hasDeathSlot) {
      if (this._feedbackSimulator && !feedbackLoaded) {   // ← readback 被守住了
        this._readbackFeedback(...);
        feedbackLoaded = true;
      }
      this._onParticleDeath(activeParticleOffset);          // ← 但这行无条件调
    }
  5. _onParticleDeath:1288):const feedbackData = this._feedbackReadback;(null)→ :1298 local.set(feedbackData[feedbackOffset], ...) 读 null 的下标 → TypeError: Cannot read properties of null:1295:1298 之间无任何 null 检查。

零用户错误:正常的 WebGL2 编辑 → WebGL1 运行时降级加载即触发,父粒子越过 lifetime 的第一帧就崩。

修法(能力门控下沉到消费侧,与 _readbackFeedback 的守卫口径统一):把 :1246if (hasDeathSlot) 收紧为 if (hasDeathSlot && this._feedbackSimulator)(或 && this._useTransformFeedback),这样 WebGL1 下 Death 派发整体跳过,_readbackFeedback 那层守卫也随之冗余可清。或在 _onParticleDeath 入口 if (!this._feedbackReadback) return;。配一条真正跑在 WebGL1 上的回归测试(reconcile 后 _feedbackSimulator===null,父粒子死亡不抛)守住——现有 SubEmitter.test.ts:143 那条只验证 WebGL2,revert 修复也不会 fail。

与上轮 [P3]「运行时 mutate 绕过守卫」是同类(能力门控只挂配置入口)但更严重:那条是用户误用、doc 为 unsupported,这条是零用户错误的正常降级加载路径。

不阻塞 / 已记录

  • 同步 readback stall(@cptbtptpbcptdtptp 提):0f931fab 已收窄到「仅退役帧 × 仅活跃区间」,残留同步 getBufferSubData 是无 fence 基建的固有取舍、读上一帧 buffer、可接受降级,follow-up,不阻塞。
  • SubEmitterexportnewindex.ts),但唯一受支持入口是 addSubEmitter;标 @internal 可消除不一致 + 崩溃向量。P3,不阻塞。

结论

request-changes:P2/P3 已正确闭环;唯一阻塞仍是 WebGL1 反序列化 Death slot 的 null-deref(P1,未真正修复)——67fedab6b 修的是 WebGL2 重建路径,WebGL1 崩溃路径的能力门控需下沉到 _retireActiveParticles/_onParticleDeath 的消费侧,并配 WebGL1 回归测试。

GuoLei1990

This comment was marked as outdated.

@GuoLei1990 GuoLei1990 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 复核 @ a43fdbdca — WebGL1 反序列化 Death slot 仍会崩溃(67fedab6b 未真正修复)

逐链核对当前 HEAD 源码(非推断,每环都读了真实代码),确认这个 P1 仍然存在

崩溃链

  1. Death 才要 WebGL2,Birth 支持 WebGL1(设计正确)—— addSubEmitter 只对 type === Death && !isWebGL2 throw(SubEmittersModule.ts:72-73)。Birth 不读 feedback buffer,WebGL1 能跑;Death 要 TF 读回死亡位置,仅 WebGL2。

  2. 但这道 throw 只挂在 addSubEmitter 配置入口 —— _subEmitters@deepClone privateSubEmittersModule.ts:46-47)。反序列化/clone(场景从 WebGL2 编辑器导出、含 Death slot,在 WebGL1 运行时加载)直接还原数组,根本不经过 addSubEmitter → 绕过 Death+WebGL1 的 throw → WebGL1 上活了一个本该被拒的 Death slot。

  3. WebGL1 下 _feedbackReadback 为 null —— _setTransformFeedbackneeded = isWebGL2 && (...)ParticleGenerator.ts:683-684),WebGL1 下 false → 不进 if(needed)_feedbackSimulator/_feedbackReadback 保持 null(:134/:142)。67fedab6b 加的这个 isWebGL2 门恰恰把 WebGL1 暴露出来。

  4. 但 Death 派发不受 TF 状态门控 —— hasDeathSlot = _hasSubEmitterOfType(Death):1232)只看 enabled + slot 类型,与 _feedbackSimulator 无关 → WebGL1 下仍 true
    ```ts
    if (hasDeathSlot) { // :1246 WebGL1 下为 true
    if (this._feedbackSimulator && !feedbackLoaded) { // :1247 readback 被守住
    this._readbackFeedback(...);
    }
    this._onParticleDeath(activeParticleOffset); // :1251 ← 但这行无条件调
    }
    ```

  5. _onParticleDeath 入口无 null 守卫 —— :1295 const feedbackData = this._feedbackReadback;(null)→ :1298 local.set(feedbackData[feedbackOffset], ...)TypeError: Cannot read properties of null

触发场景零用户错误:WebGL2 编辑器创建含 Death sub-emitter 的特效 → WebGL1 设备运行时降级加载 → 父粒子越过 lifetime 的第一帧即崩。

67fedab6b 为什么没修上

它给 _setTransformFeedback 加了 isWebGL2 门 + 一条 "deserialize 重建 TF" 回归测试——但那条测试跑在 WebGL2 上、只断 WebGL2 行为。WebGL1 的崩溃路径既没修也没覆盖。isWebGL2 门本身是对的,但它只解决了"WebGL2 下反序列化要重建 TF",没解决"WebGL1 下反序列化的 Death slot 该怎么办"。

修法(二选一,建议 fail-loud)

能力门控只挂配置入口、序列化字段天然绕过——根治要么在加载侧校验、要么在消费侧下沉守卫:

  • 方案 A(消费侧守卫,最小改动):1246 收紧为 if (hasDeathSlot && this._feedbackSimulator),WebGL1 下 Death 派发整体跳过,:1247 那层守卫随之冗余可清。但纯静默跳过会让 WebGL1 上的 Death 悄悄不工作,用户对着"没效果"摸不着头脑 → 建议补一条 Logger.warn
  • 方案 B(加载侧校验,fail-loud,更对):反序列化重建/_onEnable reconcile 时,WebGL1 下检测到 Death slot 就 Logger.error + 丢弃该 slot,跟 addSubEmitter 配置入口的 throw 口径一致。

按"不支持应尽早报错、不静默跳过"原则,B 优于 A。无论哪个,都要配一条真正跑在 WebGL1 上的回归测试(reconcile 后 _feedbackSimulator === null、父粒子死亡不抛)——现有 SubEmitter.test.ts 那条只验证 WebGL2,revert 修复也不会 fail,守不住这个回归。

结论

request-changes:这是零用户错误的正常降级加载路径上的必现崩溃,必须修。其余 commit(subEmitters 收口 623cc47d5/b98de15e3、style 三连)都干净。3345da454 的 CPU/GPU cumulative 分叉是已知 P2(前轮已记),不阻塞。

@GuoLei1990

Copy link
Copy Markdown
Member

补充:P1 的推荐修法 —— 整个 sub-emitter 模块统一要求 WebGL2,别按 Birth/Death 割裂

上一条 review 给的两个修法(消费侧下沉守卫 / 加载侧校验)都是在保留 Birth/Death 能力分级的前提下补 P1。但更根本的方向是消除这个分级本身:

为什么"整个模块 WebGL2"优于"Death 才要 WebGL2"

  1. 分级是架构泄漏,用户预期不到 —— "Birth 能用、Death 不能"的边界纯粹源于内部实现(Death 要 TF 读回、Birth 出生方向 CPU 闭式可得)。用户脑子里 sub-emitter 是一个功能,凭什么 Birth/Death 一个能用一个不能。这把引擎的实现约束泄漏成了 API 的能力分级。

  2. 割裂制造"半残"陷阱状态 —— 用户在 WebGL1 配 Birth + Death,Birth 工作、Death 静默失效或崩,看到的是"sub-emitter 一半好使",比"整个不支持"更难排查。完全不支持是诚实的,半支持是陷阱。 这正是 P1 的土壤。

  3. 一句契约 vs 两句分级 —— "sub-emitter 需要 WebGL2" 用户记得住、引擎守得住;"Birth 任意、Death 要 WebGL2、且 WebGL1 加载含 Death 会崩" 记不住也守不住。

  4. P1 顺带消失,不是修补而是消除土壤 —— 把 WebGL2 门从 addSubEmitter(配置入口,反序列化绕过)上移到 SubEmittersModule.enabled setter / _onEnable(所有进入路径的收口,含反序列化/clone)。WebGL1 下模块整个不启用 → _hasSubEmitterOfType 恒 false → _onParticleDeath 永不被调 → null-deref 无从发生。addSubEmitter 里那道 Death && !isWebGL2 的精确 throw 删掉,换成模块级一道门。运行层 :1247_feedbackSimulator && 守卫也因"有 Death slot ⇒ 必 WebGL2 ⇒ simulator 必在"而可简化。

代价(诚实说明)

砍掉 WebGL1 上的 Birth sub-emitter(现在它能跑且零成本)。但 sub-emitter 是高级特效功能,用它的项目大概率不卡 WebGL1 长尾;且"统一要 WebGL2"比"WebGL1 上半残"对用户更友好。

这是产品决策,建议团队拍板

它改变了功能支持矩阵(WebGL1 失去 Birth sub-emitter),不是纯技术修复。但从"别割裂、消除架构泄漏 + 陷阱状态 + P1 一锅端"的角度,倾向整个模块统一 WebGL2。无论选哪个方向,都要配一条真跑在 WebGL1 上的回归测试守住(现有测试只跑 WebGL2,revert 修复也不 fail)。

@GuoLei1990 GuoLei1990 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 阻塞 @ a43fdbdca — WebGL1 下含 Death sub-emitter 会崩溃;建议整个模块统一 WebGL2

逐链核对当前 HEAD 源码(非推断),确认一个零用户错误的必现崩溃,且 67fedab6b 未真正修复。其余新 commit(subEmitters 收口 623cc47d5/b98de15e3、style 三连)干净;3345da454 的 CPU/GPU cumulative 分叉是已知 P2,不阻塞。

[P1] 崩溃链

设计上 Death 才要 WebGL2,Birth 支持 WebGL1(Birth 出生方向 CPU 闭式可得,Death 要 TF 读回死亡位置)。但这道能力门只挂在 addSubEmitter 配置入口:

  1. addSubEmitter 仅对 type === Death && !isWebGL2 throw(SubEmittersModule.ts:72-73)。
  2. _subEmitters@deepClone private:46-47)——反序列化/clone(WebGL2 编辑器导出含 Death slot 的特效、WebGL1 设备降级加载)直接还原数组,不经过 addSubEmitter → 绕过这道 throw。
  3. WebGL1 下 _setTransformFeedbackneeded = isWebGL2 && (...)ParticleGenerator.ts:683-684)为 false → _feedbackReadback 保持 null。
  4. hasDeathSlot = _hasSubEmitterOfType(Death):1232)只看 enabled+slot 类型 → WebGL1 下仍 true → :1251 _onParticleDeath() 无条件调(:1247_feedbackSimulator && 只守住 readback,没守住 dispatch)。
  5. _onParticleDeath 入口无 null 守卫 → :1298 local.set(feedbackData[feedbackOffset], ...) 读 null → TypeError

触发零用户错误:WebGL2 编辑器做的含 Death 特效 → WebGL1 设备运行时加载 → 父粒子越过 lifetime 第一帧即崩。67fedab6b 加的 isWebGL2 门 + 回归测试只覆盖 WebGL2 路径,WebGL1 崩溃路径未修未测。

推荐修法:整个 sub-emitter 模块统一要求 WebGL2,别按 Birth/Death 割裂

可以只在消费侧下沉守卫(:1246if (hasDeathSlot && this._feedbackSimulator))补这个 P1,但那保留了 Birth/Death 能力分级。更根本的方向是消除分级本身

  1. 分级是架构泄漏 —— "Birth 能用、Death 不能"纯源于内部实现,用户脑子里 sub-emitter 是一个功能,预期不到这个边界。
  2. 割裂制造半残陷阱 —— WebGL1 上 Birth 工作、Death 失效/崩,"一半好使"比"整个不支持"更难排查。完全不支持是诚实的,半支持是陷阱——这正是 P1 的土壤。
  3. 一句契约 > 两句分级 —— "sub-emitter 需要 WebGL2" 记得住守得住。
  4. P1 顺带消失(消除土壤而非修补) —— 把 WebGL2 门从 addSubEmitter 上移到 enabled setter / _onEnable(所有进入路径的收口,含反序列化/clone)。WebGL1 下模块整个不启用 → _onParticleDeath 永不被调。addSubEmitter 的 Death throw 删除,运行层 :1247_feedbackSimulator && 守卫也因"有 Death slot ⇒ 必 WebGL2 ⇒ simulator 必在"可简化。

代价:砍掉 WebGL1 上的 Birth sub-emitter(现在它能跑且零成本)。但 sub-emitter 是高级特效,用它的项目大概率不卡 WebGL1 长尾,"统一要 WebGL2"比"WebGL1 半残"更友好。这改变功能支持矩阵,属产品决策,建议团队拍板。

行数上两个方案半斤八两(都 ±几行),统一方案的价值在心智一致与消除陷阱,不在省代码。

回归测试(无论哪个方向都必须)

配一条真跑在 WebGL1 上的测试:reconcile 后 _feedbackSimulator === null、父粒子死亡不抛(或模块在 WebGL1 整个不启用)。现有 SubEmitter.test.ts 那条只验证 WebGL2,revert 修复也不会 fail,守不住这个回归。

@GuoLei1990

Copy link
Copy Markdown
Member

澄清:统一的是「准入条件」,不是「内部实现」—— Birth 零改动

补一句防误解:上面建议的「整个模块统一 WebGL2」统一的只是模块准入门,不是 Birth/Death 的内部实现。两者实现本来就不同、也不该改成一样:

  • Birth_onParticleBirth 纯 CPU 几何:出生位置 = position 经 worldRotation 变换 + worldPosition;发射方向 = direction 经 worldRotation 变换;色/尺/旋 = _evaluateOverLifetime(offset, 0, ...) 闭式求值。全程不碰 feedback buffer(注释 Birth emission direction is known directly 已说明)。
  • Death_onParticleDeath_feedbackReadback(TF 积分结果),这才是 WebGL2 的技术必需。

所以统一 WebGL2 后:

  • Birth 实现一行不改 —— 继续纯 CPU 几何,不要因为「现在模块要 WebGL2 了」就把 Birth 也改成读 TF buffer,那是无谓的过度实现(Birth 技术上根本不需要 TF,它在 WebGL1 都能跑)。
  • ✅ Death 实现也不变 —— 继续读 buffer。
  • ✅ 改的只有一处:把 addSubEmitterDeath && !isWebGL2 throw 上移成模块级准入门(enabled/_onEnable),覆盖反序列化/clone 路径。

换句话说:砍掉 WebGL1 Birth 是准入门顺带挡的(为了不割裂),不是 Birth 技术上做不到。统一的是「谁能进这个模块」这一道门,Birth/Death 各自的实现路径维持不变。

A deserialized / cloned Death sub-emitter crashed on WebGL1: the WebGL2 gate
lived only in addSubEmitter, which deserialization and clone bypass, so on WebGL1
_setTransformFeedback left the feedback buffer null while _retireActiveParticles
still entered _onParticleDeath and dereferenced a null _feedbackReadback.

Move the gate to the module's enabled getter — the chokepoint every path reads,
including deserialize/clone — so the whole sub-emitter module is inert on WebGL1
and _onParticleDeath is never reached. Drop the per-method Death throw; the
Birth/Death capability split is gone (the module simply requires WebGL2).

@GuoLei1990 GuoLei1990 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Approve @ b6dced39c — P1 真闭环,整个 sub-emitter 模块统一要求 WebGL2

自上轮 P1 阻塞(a43fdbdca)以来线性推进 1 个 commit b6dced39c fix(particle): require WebGL2 for the whole sub-emitter module,只改 SubEmittersModule.ts(-5/+2)+ 回归测试(+32),正是上轮推荐的「整个模块统一 WebGL2」方向。逐链核对源码(非信 commit message),P1 崩溃路径已从控制流上彻底封死。

P1 [已闭环] WebGL1 反序列化/clone Death slot null-deref

修法 = 把 WebGL2 门从被绕过的配置入口(addSubEmitter)上移到所有路径的收口(enabled getter):

override get enabled(): boolean {
  return this._enabled && this._generator._renderer.engine._hardwareRenderer.isWebGL2;
}

崩溃路径已断(逐链验证):

  1. WebGL1 ⇒ enabled getter 恒 false
  2. _hasSubEmitterOfType() 首行 if (!this.enabled) return falseSubEmittersModule.ts)⇒ WebGL1 下对任意 type 恒返回 false。它是 Death 路径(_setTransformFeedbackneeded_retireActiveParticleshasDeathSlot)和 Birth 路径(_onParticleBirth 派发判定)的唯一判定函数。
  3. _retireActiveParticleshasDeathSlot 恒 false ⇒ if (hasDeathSlot){ … _onParticleDeath() } 整块不进 ⇒ _onParticleDeath 永不被调 ⇒ 不可能读到 null _feedbackReadback

收口点选得准:反序列化/clone 还原的 @deepClone _subEmitters 数组也必须过 _hasSubEmitterOfType 才会被消费,所以这道门覆盖了上轮指出的"绕过 addSubEmitter"路径。addSubEmitter 里那段 per-method Death throw 已删——Birth/Death 能力割裂消失,一句契约「sub-emitter 需要 WebGL2」取代两句分级。这正是上轮分析的"消除土壤而非修补补丁"。

Birth 实现零改动确认:本 commit 未触碰 ParticleGenerator.ts_onParticleBirth/_feedbackReadback 均无 diff)。统一的只是准入条件,不是 Birth 执行逻辑——Birth 仍是纯 CPU 闭式几何、不读 TF buffer,只是 WebGL1 下随整个模块一并 inert。砍掉 WebGL1 上零成本的 Birth sub-emitter 是这个统一方向的预期代价,属已同意的产品决策。

回归测试 [守得住]

新增 Sub-emitter stays inert on WebGL1 instead of crashing on Death(+32)反向证伪成立:

  • startLifetime = 0.5supdateEngine(engine, 10) = 10 帧 × 100ms = 1.0s > 0.5s ⇒ 父粒子必然死亡,确实跑到 _retireActiveParticleshasDeathSlot 判定点。
  • 关键断言 expect(() => updateEngine(engine, 10)).to.not.throw() 命中崩溃点:fix 在位时短路不进 _onParticleDeath若 revert(enabled 改回 return this._enabled),WebGL1 下 hasDeathSlot 恢复 true ⇒ 进 _onParticleDeath ⇒ 读 null _feedbackReadback ⇒ TypeError ⇒ 测试真红。不是假绿。
  • 另两条 enabled === false / _feedbackSimulator 不存在 / child._getAliveParticleCount() === 0 正交验证模块在 WebGL1 整个 inert。

仍开放(均非阻塞,沿用历轮定性)

  • [P2] CPU _evaluateCumulative vs GLSL evaluateParticleCurveCumulative 实现分叉3345da454 起):仅当曲线 key 落在 [0,1] 域外才有可见差异,而那是编辑器禁止的配置。运行时信任协议下不阻塞。本 commit 未触及。
  • 可选简化(非必须):消费层 if (hasDeathSlot) 块内 _feedbackSimulator && 守卫,现在因「有 Death slot ⇒ enabled ⇒ 必 WebGL2 ⇒ simulator 必在」逻辑上恒真,可删;留着是无害的额外防御,不影响合并。

唯一阻塞项 P1 已真修真测,无新增问题。LGTM ✅

GuoLei1990

This comment was marked as duplicate.

@GuoLei1990 GuoLei1990 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

复审 @ 22d19d5f5(P1 已闭环,--comment 可合并)

自上轮 --request-changesa43fdbdca)以来推进到 22d19d5f5,核心是 b6dced39c —— 作者采纳了我历轮收敛的处方:把 WebGL2 能力门从配置入口下沉到 enabled 这个所有路径都读的 chokepoint,整个 sub-emitter 模块在 WebGL1 上统一 inert。上轮阻塞的 WebGL1 反序列化 Death null-deref P1 已真正修复并被回归测试守住。

上轮 P1 已闭环(WebGL1 反序列化/clone Death slot null-deref)

逐链亲读源码确认修复正确且最小:

  • SubEmittersModule.enabled getter 改为 return this._enabled && ...isWebGL2(SubEmittersModule.ts:96-97),删掉 addSubEmitter 里只挡配置入口的 Death requires WebGL2 throw。
  • 关键传导:_hasSubEmitterOfType 首行 if (!this.enabled) return false(:160-161)→ WebGL1 下对 Death/Birth 一律返回 false。
  • 因此 WebGL1 下:① _setTransformFeedback(PG.ts:687)的 needed 为 false → _feedbackSimulator/_feedbackReadback 不建(保持 null);② _retireActiveParticles(PG.ts:1232)hasDeathSlot=false → 整个 if (hasDeathSlot) 块(含 _onParticleDeath被跳过 → 不再裸读 null feedbackData[feedbackOffset]。崩溃路径从根上消失(消除而非修补)。
  • 反序列化/clone 绕过问题随之消解:门挂在 getter 上,@deepClone _subEmitters 还原后照样读这个 getter,不存在"add API 守了、反序列化绕过"的割裂。

回归测试真覆盖崩溃平台(上轮的硬要求已满足)

新增 Sub-emitter stays inert on WebGL1 instead of crashing on Death(SubEmitter.test.ts)从一开始就 hardwareRenderer._isWebGL2 = false,断言 enabled===false_useTransformFeedback===false_feedbackSimulator 不存在,并 expect(() => updateEngine(engine, 10)).to.not.throw()(父粒子死亡帧不崩)。revert enabled 门 → hasDeathSlot 复为 true → _onParticleDeath 读 null → 测试 fail,是守得住的真回归测试,不是只跑 WebGL2 的恒过桩。这正补上了上轮"测试只跑 WebGL2 等于没守 WebGL1"的缺口。

方向肯定:统一 WebGL2 > Birth/Death 能力分级

砍掉 Birth(WebGL1 可跑)/Death(仅 WebGL2)的割裂,整模块统一"要 WebGL2、否则静默 inert",与引擎既有惯例一致(LimitVelocityOverLifetimeModule@remarks This module requires WebGL2, On WebGL1, enabling will be silently ignored)。一句契约胜过两句分级,也消除了"WebGL1 上一半好使"的半残陷阱。

其余 commit(纯文档,已核对合规)

e4e8f375e/69d08c825/22d19d5f5SubEmitter.emitter/type getter 的单行 /** */ 展开为多行 JSDoc 并加句号、删掉描述内部 remap 的 Remapped on clone 备注 —— 符合公开 getter 多行 JSDoc 规范,无功能变化。

结论

P1 闭环,无新 P0/P1/P2,--comment,建议合并。 历史已采纳清单(readonly subEmitters getter / slot mutate 收口 / 环检测 / Birth velocity 继承等)不再复述。同步 readback stall 是无 fence 基建的固有取舍(仅退役帧 × 仅活跃区间,读上一帧),属 follow-up 不阻塞,维持上轮结论。

chenmo.gl added 4 commits June 17, 2026 21:19
…_onEnable

Cloned slots lose their @ignoreClone _module back-pointer, which _onEnable
re-set on every enable via _bindSlots. Move the re-link into _cloneTo so it
runs once at clone time (addSubEmitter already wires _module at birth). Add a
clone regression test covering the back-pointer.

@GuoLei1990 GuoLei1990 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

增量审查(2026-06-17 @ c33a07daa

自我上轮 --comment22d19d5f5,P1 已闭环)以来线性推进 4 个 commitgit merge-base --is-ancestor 22d19d5f5 c33a07daa 通过,非 force-push):

  • e18de0662 / 70772d059 docs: subEmitters getter JSDoc 多行化后又收成 "The configured sub-emitters."
  • af6b4f913 refactor: ParticleGeneratorModule._generator protected@internal,内联删掉 _refreshTransformFeedback() 一行 passthrough
  • c33a07daa refactor: 把 slot 的 _module 重链从 _onEnable._bindSlots() 挪到 _cloneTo()

总结

af6b4f913 的两处改动都对:_generator 必须 @internalSubEmitter.type setter 要跨类读 this._module._generatorprotected 读不到,与 MainModule.ts:338 跨实例读 target._generator 一致),_refreshTransformFeedback 单调用者内联是干净简化。doc 改动合规。

c33a07daa_module 重链从「每次 enable」收窄到「仅 clone」,丢掉了反序列化路径的覆盖 —— 这恰是作者自己在引入 _bindSlots 时(b98de15e3)注释里点名要覆盖的场景。见下 P2。逐链核对正确,无 P0/P1。

问题

[P2] SubEmittersModule.ts c33a07daa — 把 _module 重链从 _onEnable 挪到 _cloneTo,丢掉了反序列化路径的 _module 绑定(回退了 b98de15e3 显式覆盖的场景);当前不可达,但 serialization 一落地即成潜伏崩溃

SubEmitter._module 是 slot setter 的唯一拐杖:set type 用它触发 _setTransformFeedback()set emitter 用它触发 _validateEmitter() 环检测,两者都走 this._module?.… 的可选链——_module 为 null 时静默跳过

逐链核对 _module 的三条赋值路径(全仓 grep _module = 仅两处赋值点):

  1. addSubEmittersub._module = this(:84)✓
  2. clone_cloneTo 重链到 target(:178)✓(核对 CloneManager:_subEmitters 数组在 for k in sourceProperty 里先被 @deepClone 完,_cloneTo?.(target) 后调,slot 已就位,重链正确)
  3. 反序列化ReflectionParser.parsePropsinstance[key] = v / new Class() 直填字段,不过 addSubEmitter、也不调 _cloneTo → 每个反序列化 slot 的 _module 恒为 null

git 历史佐证这是回退一个刻意的修复b98de15e3 引入 _bindSlots() 时注释原文写的是

Slots restored by deserialization / clone arrive unbound, so re-bind on renderer enable.

——明确点名 deserialization 和 clone 都需要重链,并刻意挂在 _onEnable(每次 enable 都跑、与配置构造方式无关)。c33a07daa_cloneTo只覆盖 clone)替换它,反序列化路径的 _module 不再有任何重链入口。

后果链(反序列化一个 Birth-only 配置 → enable → 运行时把某 slot 翻成 Death):

  • set type(Death)this._module?._generator._setTransformFeedback()_module===null 静默 no-op → TF 不建(_feedbackSimulator 仍 null)
  • 下一帧父粒子死亡:_retireActiveParticleshasDeathSlot===true(WebGL2 下 enabled)→ _feedbackSimulator===null 跳过 _readbackFeedback_onParticleDeath 仍跑,裸读 this._feedbackReadback(null)→ feedbackData[feedbackOffset] null-deref 崩溃
  • 同理 set emitter 静默跳过 _validateEmitter → 可后门拼出运行时环 → 栈溢出

为什么是 P2 不是 P1/P0

  1. 当前不可达 —— 全仓 grep subEmitters/SubEmitter/addSubEmitter 在 loader 包零命中,sub-emitter 的 serialization schema 还没写,所以今天没有任何反序列化 slot 能产生(_module 当前必由 addSubEmitter/clone 赋值,永不为 null)。
  2. 上轮 P1 的崩溃没回退 —— _onEnable._setTransformFeedback()67fedab6b 加、本轮保留)仍从裸 type 字段 reconcile TF,与 _module 无关,所以「反序列化 Death 配置 enable 即崩」这条已闭环路径不受影响。本条只影响 enable 之后的运行时 slot 突变

但这是个会随 serialization 落地即激活的潜伏崩溃,且回退了一个有注释佐证的刻意修复,记此 P2。

修法(任选)

  • 首选_onEnable 里恢复 generator.subEmitters._bindSlots()(幂等、O(n)、与 _setTransformFeedback() 并列)——_cloneTo_bindSlots 不互斥,clone 走 _cloneTo、deserialize+其它走 _onEnable,两路都盖住,正是 b98de15e3 的原设计;或
  • 让 setter 不依赖 _module:把 _generator 反向指针直接挂到 SubEmitter(构造/反序列化时由模块回填),但那本质和 _module 同一个回填问题,不更省。

顺带(不单列):新增的 Death slot reconciles transform-feedback on enable (deserialize path) 测试名不副实——它先调 addSubEmitter(已把 _module 设好)再手动 null 掉 _useTransformFeedback/_feedbackSimulator,复现的是「TF 状态丢失」而非反序列化的真实特征「_module===null」。真正的反序列化 slot(_module 恒 null)无任何测试覆盖。若按首选修法恢复 _bindSlots,建议补一条 honest 测试:构造一个 _module=null 的 slot 推进 _subEmitters,enable 后断言 _module 被重链、且 slot.type=Death 能驱动 TF。

已关闭问题清单(全部维持)

问题 状态
历史全部 P0/P1/🔴 + clamp 区间积分 + 反序列化 Death 崩溃 P1(b6dced39cenabled 门 + _onEnable reconcile) ✅ 全部闭环
上轮 P3 subEmitters slot 可变绕过环检测(.push() + sub.emitter ✅ 本轮前已关闭(623cc47d5 readonly 视图 + b98de15e3 setter 收口 _validateEmitter/_setTransformFeedback
subEmitters getter 单行→多行 JSDoc(上轮 P3 格式项) ✅ 关闭(e18de0662/70772d059
长期遗留 P2(gravityMod slot 19 死 case / 测试裸 offset 42 / CPU-GPU clamp 分叉) 维持,本轮未触碰

结论

4 个 commit:_generator @internal 化 + _refreshTransformFeedback 内联(正确简化)、doc 多行化、_module 重链挪到 _cloneToaf6b4f913 与 doc 改动干净;c33a07daa_module 重链从 _onEnable(覆盖 deserialize+clone)收窄到 _cloneTo(仅 clone),回退了 b98de15e3 注释明确覆盖的反序列化场景——当前因 serialization schema 未落地而不可达(P2、不阻塞),但会随 serialization 即成潜伏 null-deref,且「deserialize path」测试名不副实未覆盖真实 _module===null。建议恢复 _onEnable._bindSlots()(与 _cloneTo 并存)并补 honest 测试。本轮 --comment,无 P0/P1。reviewDecisionAPPROVED,TS 编译干净、CI 全绿。

@GuoLei1990 GuoLei1990 merged commit 78664f0 into galacean:dev/2.0 Jun 18, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants