Skip to content

feat(particle): support continuous Birth sub-emitters - #3071

Open
hhhhkrx wants to merge 115 commits into
galacean:dev/2.0from
hhhhkrx:fix/particle-orbital-linear-order
Open

hhhhkrx wants to merge 115 commits into
galacean:dev/2.0from
hhhhkrx:fix/particle-orbital-linear-order

Conversation

@hhhhkrx

@hhhhkrx hhhhkrx commented Jul 17, 2026 •

Copy link
Copy Markdown
Contributor

背景

Birth 子发射器此前只会在父粒子创建时固定触发一次,无法让目标粒子系统在父粒子的整个生命周期内独立运行自己的发射时间线;父子系统的更新还依赖 Renderer 注册顺序与相机裁剪,父粒子的完整 GPU 轨迹也无法直接传递给子粒子。

本 PR 将 Birth 扩展为连续子发射模式,并补齐父子粒子系统的调度、轨迹传递、速度继承、Ring 生命周期与 Bounds 管理。

功能行为

  • 每个存活父粒子、每个 Birth 槽位分别持有一条目标系统的发射时间线。
  • Birth 使用目标系统自身的 Start Delay、Duration、Loop、Rate over Time 和 Burst 配置。
  • Death 在父粒子生命周期结束时,按 deathEmitCount 一次性产生子粒子。
  • emitProbability 按父粒子、子发射器槽位独立采样。
  • 颜色、尺寸、旋转和速度继承继续由 inheritProperties 控制。
  • 新增 InheritVelocityModule,支持:
    • Initial:粒子出生时采样发射器速度,并按曲线作用于粒子生命周期。
    • Current:粒子存活期间持续使用发射器当前速度。

实现思路

独立的 Birth 发射状态

  • SubEmittersModule 为每个父粒子 Ring 槽位和 Birth 子发射器槽位维护独立 EmissionState。
  • 状态只保存发射游标与随机流;EmissionModule 仍是发射配置和 Rate/Burst 算法的唯一所有者。
  • CPU 只生成包含事件时间、数量、父粒子 Ring 索引和继承配置的轻量 Birth/Death Command,由目标 ParticleGenerator 统一创建粒子。
  • State、Command 和内部请求对象均按高水位复用,并随 Ring 扩容、迁移、退休和清空同步维护。

GPU 轨迹 Gather

flowchart LR
  A[父系统 Transform Feedback Buffer] --> B[SubEmitterTrajectoryGather Pass]
  C[CPU Birth / Death Command] --> B
  B --> D[与子粒子槽位对齐的 Spawn State Buffer]
  D --> E[子系统 Transform Feedback 或 Forward Pass]
Loading
  • 父系统 Transform Feedback 持续输出粒子的世界位置和完整轨迹速度。
  • ParticleSubEmitterSpawnState 根据 Command 中的父 Ring 索引与子粒子数量,在 GPU 上复制、展开并重排父轨迹,使其与新生子粒子槽位一一对应。
  • Spawn State Buffer 直接作为子系统 Transform Feedback 或 Forward Pass 的 Attribute 输入。
  • 发射时刻位于两帧之间时,Shader 使用父轨迹速度和时间偏移还原对应的父世界坐标。
  • 当前实现不使用 Fence、getBufferSubData 或任何 GPU→CPU 轨迹回读,CPU 不等待 GPU。

父子系统调度

  • ParticleSystemManager 维护场景内的粒子系统依赖图,并按父系统到子系统的拓扑顺序更新。
  • 每个粒子系统每帧最多更新一次;拓扑只在槽位或组件关系变化时重建。
  • 父系统可见且仍可能向下游发射时,会继续驱动其子发射依赖;独立播放且可见的目标系统也可以作为更新根节点。
  • 配置阶段拒绝直接或间接循环依赖,父子 Renderer 必须属于同一 Scene。

Transform Feedback 与 Bounds

  • 新旧粒子在同一个 Feedback Pass 中更新,避免为同一帧拆分额外 Feedback Draw。
  • Gather 会合并父索引连续、子粒子数量一致且目标槽位连续的范围,并在连续输出区间复用同一 Transform Feedback Scope。
  • 同帧退休的容量可以立即用于新粒子;Ring 扩容、缩容和跨界时同步重映射 Feedback 与 Spawn State。
  • ParticleBounds 独立管理历史发射 Bounds、继承速度位移和退休清理,既保持保守裁剪,也避免一次极端位移永久放大 Bounds。

API 与兼容性

  • 新增 ParticleGenerator.inheritVelocity: InheritVelocityModule。
  • 新增 ParticleInheritVelocityMode.Initial / Current。
  • SubEmittersModule.addSubEmitter(...) 返回创建的 SubEmitter。
  • SubEmitter.emitCount 更名为 deathEmitCount,明确该数量只作用于 Death;这是不兼容 API 变更。

限制

  • SubEmitter 功能依赖 WebGL2;WebGL1 下模块保持停用。
  • 连续 Birth 当前支持 Rate over Time 和 Burst,不支持 Rate over Distance。
  • Rate over Distance 的发射数量依赖 GPU 中的父粒子轨迹;本版本选择保持轨迹纯 GPU 化,不引入 GPU→CPU 回读。
  • InheritVelocityMode.Current 仅在 World Simulation 且 WebGL2 环境下生效。

性能与 GC

  • 无同步或异步 GPU Readback,也没有 CPU/GPU Fence 轮询。
  • 稳态热路径不创建临时对象、Map、Set 或排序容器。
  • Birth State、Birth/Death Command、TypedArray 与 GPU Buffer 均池化或按高水位复用。
  • 不需要轨迹反馈的目标系统继续使用公式模拟;Spawn State 按需创建和释放。
  • 场景级调度为一次线性遍历,Gather 仅处理实际产生的子粒子请求。

验证

  • 单测覆盖:逐父粒子 Rate over Time / Burst / Start Delay / Loop、Death、概率、属性和速度继承、时间边界、容量复用、Ring 跨界与迁移、共享目标、多层拓扑、循环检测、裁剪恢复、设备状态恢复、Bounds 与 GPU Gather。
  • E2E 覆盖连续子发射器与 Inherit Velocity 的渲染结果。
  • 当前 HEAD 的 Lint、Windows/macOS/Linux Build、4 组 E2E 与 Codecov 检查均通过。

@hhhhkrx
hhhhkrx requested a review from GuoLei1990 July 17, 2026 15:58
@coderabbitai

coderabbitai Bot commented Jul 17, 2026 •

Copy link
Copy Markdown

Review Change Stack

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

Particle simulation now uses scene-level scheduling, queued sub-emitter commands, pooled emission runtime state, Birth/Death lifecycle tracking, transform-feedback synchronization, and configurable parent-velocity inheritance. Public particle exports and coverage tests are updated.

Changes

Particle system runtime

Layer / File(s) Summary
Emission sampling and velocity contracts
packages/core/src/particle/modules/*, packages/core/src/particle/enums/*, packages/core/src/particle/index.ts, packages/core/src/particle/ParticleGenerator.ts
Emission cursors use pooled runtime state, and generators expose configurable inherited velocity with deterministic random seeding.
System sub-emitter lifecycle
packages/core/src/particle/modules/SubEmitter.ts, packages/core/src/particle/modules/SubEmittersModule.ts, packages/core/src/particle/ParticleGenerator.ts
Birth and Death sub-emitters track per-particle state, sample parent motion, enqueue commands, and preserve timing and inherited spawn data.
Scene particle scheduling
packages/core/src/ComponentsManager.ts, packages/core/src/Engine.ts, packages/core/src/particle/ParticleSystemManager.ts, packages/core/src/particle/ParticleRenderer.ts
Scene managers register particle renderers and update them in dependency order while passing queued sub-emitter commands.
Feedback preservation and validation
packages/core/src/particle/ParticleTransformFeedbackSimulator.ts, tests/src/core/particle/*
Partial transform-feedback passes preserve untouched slots, with tests covering scheduling, timing, inheritance, resizing, and velocity integration.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Engine
  participant ParticleSystemManager
  participant ParentParticleRenderer
  participant ChildParticleRenderer
  participant ParticleGenerator
  Engine->>ParticleSystemManager: update(deltaTime)
  ParticleSystemManager->>ParentParticleRenderer: update parent particles
  ParentParticleRenderer->>ParticleGenerator: process births and deaths
  ParticleGenerator->>ParticleSystemManager: enqueue child emission command
  ParticleSystemManager->>ChildParticleRenderer: update with queued command
  ChildParticleRenderer->>ParticleGenerator: spawn inherited child particles
Loading

Possibly related PRs

Suggested labels: enhancement, particle

Suggested reviewers: cptbtptpbcptdtptp, guolei1990

🚥 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
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.
Title check ✅ Passed The title clearly and concisely describes the PR's main change: adding support for continuous Birth sub-emitters.
✨ 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

I’m a rabbit watching particles fly,
Through queued little sparks across the sky.
Birth follows birth in ordered streams,
Velocities dance through feedback dreams.
Hop, hop—new trails now bloom! 🐇

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

@codecov

codecov Bot commented Jul 17, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.60574% with 130 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.18%. Comparing base (4d1451c) to head (30cc378).

Files with missing lines Patch % Lines
packages/core/src/particle/ParticleBounds.ts 94.02% 58 Missing ⚠️
packages/core/src/particle/ParticleGenerator.ts 97.64% 20 Missing and 1 partial ⚠️
...ges/core/src/particle/modules/SubEmittersModule.ts 94.72% 20 Missing ⚠️
.../core/src/particle/ParticleSubEmitterSpawnState.ts 97.04% 8 Missing ⚠️
...src/particle/modules/VelocityOverLifetimeModule.ts 90.74% 5 Missing ⚠️
...src/particle/ParticleTransformFeedbackSimulator.ts 96.77% 3 Missing ⚠️
...ges/core/src/graphic/TransformFeedbackSimulator.ts 94.59% 2 Missing ⚠️
...ackages/core/src/particle/ParticleSystemManager.ts 98.63% 2 Missing ⚠️
...ckages/core/src/particle/modules/EmissionModule.ts 98.63% 2 Missing ⚠️
...core/src/particle/modules/InheritVelocityModule.ts 99.23% 2 Missing ⚠️
... and 4 more
Additional details and impacted files
@@             Coverage Diff             @@
##           dev/2.0    #3071      +/-   ##
===========================================
+ Coverage    86.84%   87.18%   +0.34%     
===========================================
  Files          809      819      +10     
  Lines        94679    96853    +2174     
  Branches     12121    12573     +452     
===========================================
+ Hits         82225    84446    +2221     
+ Misses       12372    12324      -48     
- Partials        82       83       +1     
Flag Coverage Δ
unittests 87.18% <96.60%> (+0.34%) ⬆️

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.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 406c0ca819

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1042 to +1045
if (parentWorldVelocity && this.inheritVelocity.enabled) {
const inheritFactor = this.inheritVelocity.curve.evaluate(
normalizedEmitAge,
this.inheritVelocity._curveRand.random()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Honor Current inherit-velocity mode

When child.generator.inheritVelocity.mode is set to ParticleInheritVelocityMode.Current, this path still applies the sampled parent velocity only once to the child's initial direction and speed, exactly like Initial; mode is never read elsewhere. Consequently, a living child does not react when its parent velocity subsequently changes, despite the exported enum documenting that behavior for Current. Either implement the per-frame parent-velocity linkage/update or avoid exposing Current as supported.

Useful? React with 👍 / 👎.

@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: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/core/src/particle/modules/SubEmitter.ts (1)

45-66: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject the unsupported Death + System configuration.

System processing only handles Birth slots, while event dispatch excludes every System slot. The currently accepted combination therefore never emits.

  • packages/core/src/particle/modules/SubEmitter.ts#L45-L66: validate the type/mode invariant in both setters.
  • packages/core/src/particle/modules/SubEmittersModule.ts#L106-L123: reject the invalid pair during addSubEmitter.
🤖 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/SubEmitter.ts` around lines 45 - 66,
Reject the unsupported Death + System combination. In
packages/core/src/particle/modules/SubEmitter.ts lines 45-66, enforce the
type/mode invariant in both the mode and type setters; in
packages/core/src/particle/modules/SubEmittersModule.ts lines 106-123, apply the
same validation when addSubEmitter accepts a slot so invalid configurations
cannot be registered.
🤖 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/EmissionModule.ts`:
- Around line 295-319: Update _emitByBurst to iterate through the interval from
lastPlayTime to playTime, splitting emission segments at each actual duration
boundary. Handle updates within later cycles instead of limiting the fallback to
the first cycle, and ensure ranges such as 0.1–1.1 stop at 1.0 rather than
processing the next full cycle. Reset state.currentBurstIndex only after
reaching a cycle boundary, preserving _emitBySubBurst for each segment.

In `@packages/core/src/particle/modules/SubEmittersModule.ts`:
- Around line 270-280: In the recreated-state branch of the SubEmittersModule
update, evaluate and assign startDelay before resetting state.emission. Reset
the emission to the parent’s current emission time after applying the delay,
clamping that time to target.duration for non-looping targets, instead of
resetting it to zero; preserve the existing previousParentAge and position
initialization.

In `@packages/core/src/particle/ParticleGenerator.ts`:
- Around line 1042-1069: Update the inherited-velocity handling in the particle
generation path to branch on inheritVelocity.mode: retain the existing one-time
combination for Initial mode, while Current mode must preserve the inheritance
configuration on the particle and apply the parent’s current velocity during its
lifetime. Use the existing particle update/inheritance symbols and avoid baking
Current-mode velocity into the initial direction and speed.
- Around line 709-710: Update the runtime remapping logic around the
migrateCount/runtimeMappings push to also map the wrapped front segment when
firstRetiredElement is greater than firstFreeElement. Preserve both occupied
ranges—[firstRetiredElement, end) and [0, firstFreeElement)—so front-segment
sub-emitter cursors remain mapped during shrink.

In `@packages/core/src/particle/ParticleSystemManager.ts`:
- Around line 55-59: Update ParticleSystemManager.update so commands enqueued
before the scheduler runs, including Birth sub-emitter commands from onUpdate,
remain available for consumption. Replace the unconditional
this._commands.clear() with logic that preserves the existing entry queue and
removes only commands genuinely consumed during processing, while retaining
filtering of destroyed or disabled renderers.

---

Outside diff comments:
In `@packages/core/src/particle/modules/SubEmitter.ts`:
- Around line 45-66: Reject the unsupported Death + System combination. In
packages/core/src/particle/modules/SubEmitter.ts lines 45-66, enforce the
type/mode invariant in both the mode and type setters; in
packages/core/src/particle/modules/SubEmittersModule.ts lines 106-123, apply the
same validation when addSubEmitter accepts a slot so invalid configurations
cannot be registered.
🪄 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: 1c585c35-e3a5-41c4-8466-e90ac922918d

📥 Commits

Reviewing files that changed from the base of the PR and between 9fe48c6 and 406c0ca.

⛔ Files ignored due to path filters (3)
  • e2e/fixtures/originImage/Particle_particleRenderer-sub-emitter.jpg is excluded by !**/*.jpg
  • e2e/fixtures/originImage/Particle_particleRenderer-velocity-orbital-constant.jpg is excluded by !**/*.jpg
  • packages/shader/src/Shaders/Effect/ParticleFeedback.shader is excluded by !**/*.shader
📒 Files selected for processing (17)
  • packages/core/src/ComponentsManager.ts
  • packages/core/src/Engine.ts
  • packages/core/src/particle/ParticleGenerator.ts
  • packages/core/src/particle/ParticleRenderer.ts
  • packages/core/src/particle/ParticleSystemManager.ts
  • packages/core/src/particle/ParticleTransformFeedbackSimulator.ts
  • packages/core/src/particle/enums/ParticleInheritVelocityMode.ts
  • packages/core/src/particle/enums/ParticleRandomSubSeeds.ts
  • packages/core/src/particle/enums/ParticleSubEmitterMode.ts
  • packages/core/src/particle/index.ts
  • packages/core/src/particle/modules/EmissionModule.ts
  • packages/core/src/particle/modules/EmissionRuntimeState.ts
  • packages/core/src/particle/modules/InheritVelocityModule.ts
  • packages/core/src/particle/modules/SubEmitter.ts
  • packages/core/src/particle/modules/SubEmittersModule.ts
  • tests/src/core/particle/SubEmitter.test.ts
  • tests/src/core/particle/VelocityOverLifetime.test.ts

Comment thread packages/core/src/particle/modules/EmissionModule.ts Outdated
Comment thread packages/core/src/particle/modules/SubEmittersModule.ts Outdated
Comment thread packages/core/src/particle/ParticleGenerator.ts Outdated
Comment thread packages/core/src/particle/ParticleGenerator.ts Outdated
Comment thread packages/core/src/particle/ParticleSystemManager.ts Outdated
GuoLei1990

This comment was marked as outdated.

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/core/src/particle/modules/SubEmittersModule.ts (1)

333-333: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Normalize the end of a non-looping emission to 1.

At sample.time === duration, modulo returns 0, causing end-of-duration curves such as inherited velocity to evaluate at their beginning.

Proposed fix
-            const emissionNormalizedTime = duration > 0 ? (sample.time % duration) / duration : 0;
+            const emissionNormalizedTime =
+              duration > 0
+                ? target.main.isLoop
+                  ? (sample.time % duration) / duration
+                  : Math.min(sample.time / duration, 1)
+                : 0;
🤖 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` at line 333, Update
the emissionNormalizedTime calculation in the SubEmittersModule sampling logic
so a non-looping emission at sample.time === duration normalizes to 1 instead of
wrapping to 0; preserve modulo-based looping behavior for times within or beyond
looping emissions.
🤖 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.

Outside diff comments:
In `@packages/core/src/particle/modules/SubEmittersModule.ts`:
- Line 333: Update the emissionNormalizedTime calculation in the
SubEmittersModule sampling logic so a non-looping emission at sample.time ===
duration normalizes to 1 instead of wrapping to 0; preserve modulo-based looping
behavior for times within or beyond looping emissions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 9d5e48ed-ad5f-4fe5-ad4e-71f7a502df84

📥 Commits

Reviewing files that changed from the base of the PR and between d30870d and af9f5de.

⛔ Files ignored due to path filters (1)
  • e2e/fixtures/originImage/Particle_particleRenderer-velocity-orbital-constant.jpg is excluded by !**/*.jpg
📒 Files selected for processing (8)
  • packages/core/src/particle/ParticleGenerator.ts
  • packages/core/src/particle/ParticleRenderer.ts
  • packages/core/src/particle/ParticleSystemManager.ts
  • packages/core/src/particle/index.ts
  • packages/core/src/particle/modules/EmissionRuntimeState.ts
  • packages/core/src/particle/modules/SubEmitter.ts
  • packages/core/src/particle/modules/SubEmittersModule.ts
  • tests/src/core/particle/SubEmitter.test.ts
💤 Files with no reviewable changes (1)
  • packages/core/src/particle/index.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • packages/core/src/particle/ParticleSystemManager.ts
  • packages/core/src/particle/modules/EmissionRuntimeState.ts
  • packages/core/src/particle/ParticleRenderer.ts
  • packages/core/src/particle/ParticleGenerator.ts

GuoLei1990

This comment was marked as outdated.

@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.

♻️ Duplicate comments (2)
packages/core/src/particle/ParticleGenerator.ts (2)

1041-1045: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Implement Current mode instead of always baking inherited velocity at spawn.

This logic evaluates the inherit curve once and bakes the result into the particle's initial direction and speed. Current mode should not be baked here, as it is designed to continuously follow the parent's velocity during the particle's lifetime.

Gate this initialization step on this.inheritVelocity.mode === ParticleInheritVelocityMode.Initial.

🐛 Proposed fix
-    if (parentWorldVelocity && this.inheritVelocity.enabled) {
+    if (parentWorldVelocity && this.inheritVelocity.enabled && this.inheritVelocity.mode === ParticleInheritVelocityMode.Initial) {
       const inheritFactor = this.inheritVelocity.curve.evaluate(
         normalizedEmitAge,
         this.inheritVelocity._curveRand.random()
🤖 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 1041 - 1045,
Gate the inherited-velocity initialization block in ParticleGenerator on
this.inheritVelocity.mode === ParticleInheritVelocityMode.Initial, alongside the
existing parentWorldVelocity and enabled checks. Ensure Current mode skips
baking inherited velocity into the particle’s initial direction and speed.

692-718: 🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

Preserve the wrapped front segment data and mappings during buffer shrink.

When firstRetiredElement > firstFreeElement, the occupied ring wraps around and includes both [firstRetiredElement, _currentParticleCount) and [0, firstFreeElement). The current logic only copies the tail segment into the new buffer. This leaves the front segment [0, firstFreeElement) filled with zeros and fails to push its runtimeMappings, causing data loss and broken sub-emitter cursors in this range.

Copy the front segment data for both the instance and feedback buffers, and include it in runtimeMappings.

🐛 Proposed fix
         } else {
           migrateCount = this._currentParticleCount - firstRetiredElement;
           bufferOffset = firstFreeElement;
+          if (firstFreeElement > 0) {
+            instanceVertices.set(new Float32Array(lastInstanceVertices.buffer, 0, firstFreeElement * floatStride), 0);
+            runtimeMappings.push({ source: 0, target: 0, count: firstFreeElement });
+          }
           this._firstNewElement > firstFreeElement && (this._firstNewElement -= firstFreeElement);
           this._firstActiveElement > firstFreeElement && (this._firstActiveElement -= firstFreeElement);
           firstRetiredElement > firstFreeElement && (this._firstRetiredElement -= firstFreeElement);
         }
 
         instanceVertices.set(
           new Float32Array(
             lastInstanceVertices.buffer,
             firstRetiredElement * floatStride * 4,
             migrateCount * floatStride
           ),
           bufferOffset * floatStride
         );
         migrateCount > 0 &&
           runtimeMappings.push({ source: firstRetiredElement, target: bufferOffset, count: migrateCount });
 
         if (useFeedback) {
+          if (firstRetiredElement > firstFreeElement && firstFreeElement > 0) {
+            this._feedbackSimulator.copyOldBufferData(0, 0, firstFreeElement * ParticleBufferUtils.feedbackVertexStride);
+          }
           this._feedbackSimulator.copyOldBufferData(
             firstRetiredElement * ParticleBufferUtils.feedbackVertexStride,
             bufferOffset * ParticleBufferUtils.feedbackVertexStride,
🤖 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 692 - 718,
Update the buffer-shrink migration around the existing instanceVertices.set and
_feedbackSimulator.copyOldBufferData calls to handle the wrapped case where
firstRetiredElement > firstFreeElement: after migrating the tail segment, copy
the front range [0, firstFreeElement) into the destination immediately after the
migrated tail for both instance and feedback buffers, and append a corresponding
runtimeMappings entry with the front source, destination, and count. Preserve
the current non-wrapped migration behavior.
🧹 Nitpick comments (1)
packages/core/src/particle/ParticleGenerator.ts (1)

1332-1343: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid per-frame object allocations in the hot path.

Creating new Vector3 and Color instances for every sub-emitter command generates significant garbage, especially for continuous Birth sub-emitters that run per parent particle every frame.

Consider using an object pool for ParticleSubEmitterEmissionCommand and its vector/color properties to eliminate these per-frame allocations during the render 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 `@packages/core/src/particle/ParticleGenerator.ts` around lines 1332 - 1343,
Update the sub-emitter command creation near ParticleSubEmitterEmissionCommand
to reuse pooled command, Vector3, and Color instances instead of allocating them
with new for each emission. Ensure pooled objects are reset and populated with
the current values, and are returned to their pools after command processing
while preserving null behavior for absent inherited properties.
🤖 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.

Duplicate comments:
In `@packages/core/src/particle/ParticleGenerator.ts`:
- Around line 1041-1045: Gate the inherited-velocity initialization block in
ParticleGenerator on this.inheritVelocity.mode ===
ParticleInheritVelocityMode.Initial, alongside the existing parentWorldVelocity
and enabled checks. Ensure Current mode skips baking inherited velocity into the
particle’s initial direction and speed.
- Around line 692-718: Update the buffer-shrink migration around the existing
instanceVertices.set and _feedbackSimulator.copyOldBufferData calls to handle
the wrapped case where firstRetiredElement > firstFreeElement: after migrating
the tail segment, copy the front range [0, firstFreeElement) into the
destination immediately after the migrated tail for both instance and feedback
buffers, and append a corresponding runtimeMappings entry with the front source,
destination, and count. Preserve the current non-wrapped migration behavior.

---

Nitpick comments:
In `@packages/core/src/particle/ParticleGenerator.ts`:
- Around line 1332-1343: Update the sub-emitter command creation near
ParticleSubEmitterEmissionCommand to reuse pooled command, Vector3, and Color
instances instead of allocating them with new for each emission. Ensure pooled
objects are reset and populated with the current values, and are returned to
their pools after command processing while preserving null behavior for absent
inherited properties.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: fbce0b5c-54d3-4c1b-a0d4-89676ebb864f

📥 Commits

Reviewing files that changed from the base of the PR and between af9f5de and 36c64b6.

📒 Files selected for processing (3)
  • packages/core/src/particle/ParticleGenerator.ts
  • packages/core/src/particle/ParticleSystemManager.ts
  • packages/core/src/particle/modules/EmissionModule.ts
💤 Files with no reviewable changes (1)
  • packages/core/src/particle/modules/EmissionModule.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/core/src/particle/ParticleSystemManager.ts

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.

Comment thread packages/core/src/Engine.ts Outdated
if (!scene.isActive || scene.destroyed) continue;
scene._componentsManager._particleSystemManager.update(deltaTime);
scene._componentsManager.callRendererOnUpdate(deltaTime);
scene._updateShaderData();

@cptbtptpbcptdtptp cptbtptpbcptdtptp Sep 16, 2026 •

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.

  • 这段代码有点割裂,感觉是先特殊处理粒子,然后泛化更新 renderer ,最后再更新 shaderData,能不能封装成 _updateBeforeRender(deltaTime)
  • 把 _componentsManager 从循环里抽出来

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.

已在 9dee105a8 处理:将原来的 Scene._updateShaderData() 扩为 Scene._updateRendering(deltaTime),统一承担粒子、Renderer 和场景 ShaderData 的更新;Engine 只调用这个入口,_componentsManager 在 Scene 方法内只取一次。

没有另加转发层。命名表达更新渲染相关状态的职责;原有粒子 → Renderer → ShaderData 的调用顺序,以及所有 Scene 更新完成后再渲染相机的时序均保持不变。

* @internal
*/
export class ParticleSystemManager {
private readonly _renderers: ParticleRenderer[] = [];

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.

_renderers 不用 DisorderedArray 的考虑是什么,感觉和 ComponentManager 中的 _renderers 是一样的作用,但是却使用了普通的 Array

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.

已在 d9f96f724 改用 DisorderedArray,由 ParticleRenderer._particleSystemIndex 记录注册位置,使用 deleteByIndex() 并修正被交换元素的索引,注册表删除为 O(1)。拓扑重建按有效长度遍历;逐帧更新仍使用原来的拓扑队列,没有新增排序或逐帧分配。

这里保留一个明确的取舍:只保证父系统先于子系统,不保证并列父系统的优先级。交换删除可能改变并列父系统的顺序;若它们共享同一子系统且容量不足,先占用容量的父系统可能变化。我们接受这个未承诺的顺序,与普通 Renderer 注册表一样不额外维护稳定注册顺序。

补充了交换删除、重新启用,以及多个父系统共享目标容量的回归测试。

Move particle, renderer and scene shader updates into Scene._updateRendering.

Preserve execution order and document compileVariant's Transform Feedback limitation.
Use DisorderedArray with renderer-owned indices for constant-time registry removal.

Preserve parent-before-child topology without imposing priority between peer systems.

Cover swapped removal, re-enabling, and shared-target capacity allocation.
@GuoLei1990

Copy link
Copy Markdown
Member

@cptbtptpbcptdtptp Shader.compileVariant() 的文档建议已在 9dee105a8 处理:@remarks 注明跳过需要运行时输出布局的 Transform Feedback Pass;@returns 注明所有 Pass 均被跳过时返回 false。只补文档,没有调整实现或新增接口。

另外,普通发射与 Birth 的非零 Burst 边界 P1已在 b5989bdd4 修复:在共享的 EmissionModule 中,将绝对事件时间与窗口边界统一到 Float32 域比较,保留半开区间契约,移除重复 Burst 的 epsilon 判定,没有增加 Birth 专用分支。补了 10 组公开链路测试,覆盖 Burst.time = 0.1、重复/无限 Burst、Loop 及边界前后,逐帧核对普通发射和 Birth 的结果一致。

以上改动均已推送;当前 HEAD 为 d9f96f724。本地全量构建(含类型检查)、生产代码 ESLint、格式检查及 18 个文件 / 308 项相关测试通过。

@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.

🫧 尘小沫

结论

本轮复核了 0f8a6a4ea78350c5b024cde2a9fd7ece660704e0...8e6fdc74bde57b469d83c3a56bcd0cc722421483 的 20 提交增量:16 个为已进入 dev/2.0 的上游提交,3 个 PR 独有提交改动 7 个文件(+173/-43),最后一个为合并提交。结合当前实际 merge-base a7d2e74472cd0ad740ff81f5783237ef59653535 起算的 PR diff(58 文件,+7935/-2097),复核了历史 review、新增讨论、Burst 时间线、场景更新入口、注册/迁移生命周期及直接上下游契约。

上一轮 Burst.time=0.1 的首帧普通/Birth 分叉已修复,新增的 10 组对称边界用例也有效;但同一精度问题在 Loop 周期定位与游标切换处仍有残留,会永久漏掉一个 Burst。此残留在上一 HEAD 也存在,本轮没有把它描述为新引入的回归。Scene 更新收口、DisorderedArray 注册表及合并适配未发现其他可确认的 P0/P1/P2。

阻塞级别:P1。实际 review 动作:REQUEST_CHANGES。目标 HEAD:8e6fdc74bde57b469d83c3a56bcd0cc722421483。自动 CR 不替代人工 Reviewer 的合入门禁,最终 APPROVE 仍需人工 Reviewer 审核确认。

目标 SHA 的 CI 全绿:131 个测试文件、1856 条测试通过,SubEmitter 104/104;三平台 build、lint、四组 E2E、codecov/patch、codecov/project 与 labeler 均通过。本地验证为直接执行目标 SHA 的 Burst 方法做数值复核,并与上一 HEAD 对照;没有重跑完整浏览器构建/测试。下述长期跨周期组合不在新增 10 组用例中,绿色 CI 不能关闭该残留。

已关闭问题清单

  • b5989bdd4 关闭了上一轮明确复现的首帧 100ms、Burst.time=0.1 普通/Birth 分叉:共享 EmissionModule 对绝对事件时间与窗口执行 Float32 比较,保留半开区间,并删除重复 Burst 的 epsilon。10 组新用例覆盖单次、重复、无限 Burst、短 Loop 和边界前后;下面只报告尚未统一的 Loop 周期定位。
  • 多级 Birth 精确 0.1s 的 Rate 漏发、1/s 在 0.9999995s 提前发射继续由既有 Float32 parent-age 修复及无 epsilon 的 Rate 比较守住,没有回退。
  • Current、脚本 emit、跨 Scene、轨迹/设备恢复、容量/退休/bounds,以及历史 readback/fence/deferred/wrapper 等已关闭问题没有重新出现。Transform Feedback varying cache 的合理解释、Rate over Distance 暂不支持和 deathEmitCount 的人工接受范围继续有效,不重复提出。
  • 新增人工建议已落实:Scene._updateRendering() 统一原有渲染更新入口;注册表改用 DisorderedArray;Shader.compileVariant() 文档明确跳过 TF pass 及全跳过时返回 false。并列父系统不承诺优先级的取舍已有明确解释与共享容量测试。

问题

  • [P1] EmissionModule.ts:302-313 — Loop 周期仍在 Double 域定位,普通路径沿用上一周期游标并永久漏发

    共享 Burst 只在 _emitBySubBurst() 内归一窗口,外层仍用 Math.floor(segmentStart / duration)、segmentEnd < nextCycleTime 的 Double 判断决定周期与 cursor reset。普通 generator 输入 Double _playTime,Birth 输入 Float32 parent age,因此事件比较虽已统一,周期切换协议仍然分叉。

    可从公开配置触发:普通系统与 Birth 目标均设 duration=0.3、Loop=true、Rate over Time=0、Burst(0.1, count=1, cycles=1),父粒子 bornTime=0,父/子/普通粒子寿命设 100 秒、容量足够;父系统与普通系统 simulationSpeed=11。将 engine.time.maximumDeltaTime=0.1,以 200ms 实际帧推进,保证每帧模拟增量精确为 0.1*11=1.1。

    第 9 帧末普通 _playTime=9.899999999999999,尚小于第 32 周期终点 9.9,因此保留已耗尽的 currentBurstIndex=1。第 10 帧重新计算时,Math.floor(9.899999999999999 / 0.3) 却得到 33,直接进入下一周期而没有重置该游标,漏掉 10.0 秒事件;本帧只产生 10.3、10.6、10.9 三个 Burst,累计 36 次。Birth 的起点为 Math.fround(9.899999999999999)=9.899999618530273,周期计算为 32,先完成旧周期并 reset,再产生 10.0、10.3、10.6、10.9 四个 Burst,累计 37 次。直接执行目标源码得到上述事件列表;上一 HEAD 同组合也分叉,说明原精度 P1 尚未完整闭环。此处漏发不会在下一帧补回,并会改变 Burst 随机流和下游子发射链。

    保留 ParticleGenerator/shader 的 Float32 simulation clock 为数值域 owner,保留 EmissionModule + EmissionState 为唯一 Burst 周期/游标 owner。应在共享 _emitByBurst() 入口先将时间窗口归一到权威数值域,再从同一窗口派生周期、分段与 reset;删除“内层 Float32 事件比较、外层 raw Double 周期切换”的混合协议。不要新增持久 cycle 镜像、Birth 专用分支、epsilon 或 fallback。补普通/Birth 公开链路测试,覆盖上述十帧跨多个周期的配置并断言逐帧计数及累计 37;保留现有 10 组边界和 Rate/多级链用例。

架构、熵增与测试治理

沿 Engine Time → Engine._render → Scene._updateRendering → ParticleSystemManager → ParticleGenerator clock → SubEmittersModule parent age/EmissionState → EmissionModule → Birth Command → target ring/GPU Gather 核对了直接上下游。Scene 拥有每场景渲染更新入口,manager 拥有注册索引与从 slot 配置派生的拓扑顺序,SubEmittersModule 拥有 per-parent state,EmissionModule 拥有发射算法,source TF 拥有轨迹事实,target generator 拥有容量和物化;Command 只传递同帧请求。

Scene 重构替换原入口并保留“粒子 → Renderer → ShaderData、所有 Scene 更新后再渲染相机”的顺序,没有添加 wrapper 或平行调度。注册表只增加一个 @ignoreClone 的索引,写入/交换修正/退休均由同一 manager 完成,按 DisorderedArray 有效长度建图;这是有明确 O(1) 删除用途的索引,不是另一份成员关系真相。并列父系统排序变化已明确接受,没有为保持旧顺序增加排序、同步层或逐帧分配。上游 declaration 适配还删除了 GLBuffer 的临时 owner 类型/转发 helper 与失效 ts-expect-error。

增量净删除重复 Burst epsilon 和接口包装,没有新增周期状态机、兼容分支或测试专用生产入口;剩余熵增是 finding 中同一时间窗仍有两套数值解释。应保留共享 emission owner,并把周期机械派生收回同一数值协议,删除局部混合精度解释。

新增测试使用公开配置,验证行为而非私有游标;交换删除、重新启用与共享目标容量覆盖有效,没有改 fixture/mock/snapshot 来迁就实现。现有 10 组 Burst 测试保护的契约仍有效,应保留并补长期跨周期反向用例。不得为旧测试增加或保留 compatibility branch、legacy fallback、wrapper、镜像状态或第二条转换/校验路径。

luzhuang

This comment was marked as duplicate.

this._bounds.resetEmissionRecords();
}
// Reset play time when is not playing and no active particles to avoid potential precision problems in GPU
this._playTime -= emission._shiftTimeOrigin(Math.floor(this._playTime / duration) * duration);

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.

非阻塞建议:MainModule.duration 是可直接赋值的公开字段,目前允许设为 0。当系统停止且没有活跃粒子时,这里的 Math.floor(_playTime / 0) * 0 会得到 NaN;_shiftTimeOrigin() 随后通过 Math.min(frameRateTime, NaN) 把发射游标和 _playTime 都写成 NaN,普通后续更新无法自行恢复。

建议明确 duration 必须是有限正数,并在配置入口校验,补一条 duration = 0、停止后再次播放的回归测试。仅在这里加除零保护还覆盖不到 EmissionModule 中速率曲线和循环 Burst 对 duration 的除法。这个边界问题在 base 代码中也存在,本条仅作健壮性建议,不作为本 PR 的阻塞项。

@galacean galacean deleted a comment from luzhuang Oct 9, 2026
@cptbtptpbcptdtptp

Copy link
Copy Markdown
Collaborator

建议补充 Birth 子粒子的「寿命继承」(非阻塞)

当前实现里,Birth 子粒子的寿命完全由目标系统自己的 startLifetime 决定。这意味着父粒子生命末期出生的孩子仍然会活满自己的完整寿命,表现上就是"父粒子已经消失,孩子还在飞"的收尾拖尾。

这类收尾效果在 Unity 和 UE 里都有专门能力覆盖:

引擎 能力 语义
Unity ParticleSystemSubEmitterProperties.InheritLifetime "New particles will have a shorter lifespan, the closer their parent particles are to death"(Unity 文档)→ 按父的剩余寿命截断孩子寿命
UE Niagara 生成路径上有一个"继承父粒子 Age"的选项 子粒子直接继承父的 Age + Lifetime,效果是 "make sure that the children die, whenever the parent particle dies"(Real Time VFX 讨论;该选项的确切名称我没能在官方文档核实到,官方页是 JS 渲染抓不到正文)

典型用途:尾焰在父粒子末段衰减、火花随父粒子临近死亡而变弱、拖尾收尾——本质都是"孩子要跟着父一起收"。

补起来确实便宜

原料已经在手,不需要新增任何 GPU 数据或回读:

  • BirthSubEmitterCommand.reset() 已经带了 lifetime、_frameStartParentAge、_frameDuration(BirthSubEmitterCommand.ts:46-51);
  • _consumeBirthSubEmitterCommand() 已经算出了父的归一化年龄 parentNormalizedAge = parentAge / command.lifetime(ParticleGenerator.ts:1297),并作为 normalizedEmitAge 传进了 _addNewParticle(...)(:1309 调用,:1034 签名)。

所以改动集中在 ParticleGenerator.ts:1095 这一行,乘一个系数即可,例如:

const startLifetime = main.startLifetime.evaluate(undefined, main._startLifeTimeRand.random());
// 新增:按父剩余寿命比例截断(inheritLifetime 来自 inheritProperties 新增的 flag)
const lifetimeScale = inheritLifetime ? Math.max(1 - normalizedEmitAge, 0) : 1;
instanceVertices[offset + ParticleBufferUtils.startLifeTimeOffset] = startLifetime * lifetimeScale;

顺带一处:同函数 :1097-1103 里 inheritedBounds 也是按 startLifetime 放大的,寿命被截断后建议同样用截断后的值,否则包围盒会偏保守。

API 建议

建议做成 ParticleSubEmitterInheritProperty 的一个新 flag(如 Lifetime = 0x10),而不是固化成默认行为:

  • Unity 也是把它做成可选 flag 的,这个先例值得跟;
  • 能自然并入现有 inheritProperties 位掩码,clone / 序列化路径不需要额外处理;
  • 也能避免又出现一处像 InheritDuration 那样"语义固定、无法选择"的行为(参考前面关于 Birth 发射时间线挂在父年龄时钟上的讨论)。

一点取舍说明

上面这个是 Unity 式"截断寿命":孩子仍然按自己的生命周期播曲线,只是活得短。实现上不动时间轴,风险低。

Niagara 那种是"相位对齐"(孩子一出生就已经是父粒子的年龄,曲线从父的 normalized age 开始采样),需要把孩子的 bornTime 往前挪,会牵动 playTime - bornTime 的年龄计算以及 Birth 发射时间线,不适合在这个 PR 里顺手做,可以后续单独评估。


以上是建议、不是阻塞项,当前 PR 的 Birth / Death 子发射功能本身是完整的。

@cptbtptpbcptdtptp

Copy link
Copy Markdown
Collaborator

建议补充 Birth 子发射的「时间轴语义」说明(非阻塞)

Birth 子发射会借用目标系统的发射时间线(MainModule.duration / isLoop / startDelay + EmissionModule.rateOverTime / Burst),但把它挂在父粒子的年龄轴上求值。这个语义在 PR 里没有任何文字说明,而它会导致三个反直觉的结论。

一、三个反直觉点

  1. duration / startDelay / isLoop / rateOverTime / Burst 的 time,量纲都是「父系统的模拟秒」,不是目标自己的秒。
  2. 目标系统自身的 MainModule.simulationSpeed 不参与出生调度 —— 改它不会让子粒子生得更快或更慢。要让子粒子生快点,只能在父系统上调 simulationSpeed(父年龄走得快 → 时间线刻度被更快跨过 → 同样批数在更短的墙钟时间里发完)。
  3. 一个子系统身上同时挂着两套时间流速:出生调度走父的,出生后的演化(startLifetime / 速度积分 / 各模块曲线)走自己的。所以调目标的 simulationSpeed 会改变孩子与父之间的相对节奏。

二、代码依据

SubEmittersModule.ts:183-184 —— 父年龄区间来自父的 _playTime(其中已经乘过父的 simulationSpeed):

const frameStartParentAge = MathUtil.clamp(Math.fround(Math.fround(frameLastPlayTime) - bornTime), 0, lifetime);
const currentParentAge    = MathUtil.clamp(Math.fround(Math.fround(framePlayTime)     - bornTime), 0, lifetime);

SubEmittersModule.ts:221-227 —— 目标的时间线配置直接与父年龄比较,中间没有任何按目标流速换算的步骤:

const main = targetGenerator.main;
const duration = main.duration;
let lastEmissionTime = Math.max(frameStartParentAge - state.startDelay, 0);
let emissionTime     = Math.max(currentParentAge     - state.startDelay, 0);
if (!main.isLoop) {
  lastEmissionTime = Math.min(lastEmissionTime, duration);
  emissionTime     = Math.min(emissionTime,     duration);
}

SubEmittersModule.ts:233,241 —— 用目标的 emission 在父年龄轴上排出生请求:

const emission = targetGenerator.emission;
emission._prepareBirthTimedRequests(lastEmissionTime, emissionTime, state, command);

对照目标自身的时间推进(ParticleGenerator.ts:302)—— 目标的 simulationSpeed 只在这里生效:

let deltaTime = elapsedTime * main.simulationSpeed;

出生时刻则通过「帧内归一化比例」桥接到目标自己的时间轴(BirthSubEmitterCommand.ts:81-88 → ParticleGenerator.ts:1489-1495):

// 父年龄 -> 父本帧模拟区间比例 -> 整帧比例
getFrameTime(emissionTime: number): number {
  const frameDuration = this._frameDuration;
  const activeFrameTime =
    frameDuration > MathUtil.zeroTolerance
      ? MathUtil.clamp((emissionTime + this.startDelay - this._frameStartParentAge) / frameDuration, 0, 1)
      : 1;
  const frameSimulationStart = this._frameSimulationStart;
  return frameSimulationStart + activeFrameTime * (1 - frameSimulationStart);
}

// 整帧比例 -> 目标自己的 [lastPlayTime, _playTime] 区间(this = 目标系统)
private _getFramePlayTime(frameTime: number, lastPlayTime: number, frameSimulationStart: number): number {
  ...
  return lastPlayTime + (this._playTime - lastPlayTime) * simulationProgress;
}

三、现有文档的覆盖情况

位置 现有措辞 缺口
ParticleSubEmitterType.Birth JSDoc "Evaluates the target system's emission timeline independently for each living parent particle." 指明了用目标的时间线,但没说这条时间线跑在谁的时钟上
MainModule.duration "The duration of the Particle Generator in seconds." 没说谁的秒
MainModule.startDelay "Start delay in seconds." 同上
MainModule.simulationSpeed "Override the default playback speed of the Particle Generator." 没说它在 Birth 子发射调度中不生效
EmissionModule._prepareBirthTimedRequests 只解释了为何排除 Rate over Distance 无时钟说明

四、建议

(1)给 MainModule 的这几个字段补一句作用域说明,例如:

/**
 * The duration of the Particle Generator in seconds.
 * @remarks When this generator is used as a Birth sub-emitter target, this duration (together with
 * startDelay / isLoop / emission rate and bursts) is measured on the **parent system's** simulation
 * clock -- it is compared against the parent particle's age -- and this generator's own
 * `simulationSpeed` does not affect the emission schedule.
 */
duration = 5.0;

(2)给 ParticleSubEmitterType.Birth 的 @remarks 补一句时钟说明:

/**
 * Evaluates the target system's emission timeline independently for each living parent particle.
 * @remarks Supports Rate over Time and Burst, but not Rate over Distance. Birth evaluates the
 * target's timeline (duration / start delay / rate over time / bursts) on the **parent particle's
 * age axis**, so the target's own `simulationSpeed` does not change the emission schedule. The
 * birth instant is then mapped onto the target's own timeline by frame proportion, and the
 * particle evolves on the target's clock after birth.
 */
Birth = 0,

(3)若暂不改 API 注释,至少在这两处补一行内注释:

  • SubEmittersModule.ts:221(「目标的时间线配置,量纲是父秒」)
  • BirthSubEmitterCommand.getFrameTime()(跨时间线桥的语义,目前只有实现没有说明)

五、取舍说明

纯文档补充,不改任何行为,也不影响现有表现。之所以建议非阻塞地提,是因为这个语义很容易被误用 —— 使用者看到 MainModule.duration("in seconds")和 simulationSpeed,几乎必然会认为改子系统的 simulationSpeed 能影响子发射节奏,而这恰恰是本 PR 引入的新语义。


以上是建议、不是阻塞项,当前 PR 的 Birth 子发射功能本身是完整的。

@cptbtptpbcptdtptp

cptbtptpbcptdtptp commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

建议:SubEmittersModule 的三个对象池可考虑做成引擎级共享(非阻塞)

SubEmittersModule 目前为每个粒子系统各持三个对象池:

// SubEmittersModule.ts:38-42
private readonly _birthStatePool: BirthSubEmitterState[] = [];
private readonly _birthCommandPool: BirthSubEmitterCommand[] = [];
private readonly _deathCommandPool: DeathSubEmitterCommand[] = [];

由于各系统之间天然错峰,改成引擎级(或场景级)共享可以有可观的内存收益。但共享之前有一个必须先修的正确性问题,见第二节。

一、为什么值得共享

设 o_i(t) 为系统 i 在时刻 t 的占用数:

方案 需要持有的对象总数
各系统独立池 Σ_i max_t o_i(t)
共享池 max_t Σ_i o_i(t)

两者之差完全由错峰程度决定:从 0(所有系统同帧峰值)到 (n-1)/n(完全不重叠)。共享池永远不会比独立池差。

三个池的收益差别很大:

池 对象寿命 共享收益
_birthCommandPool / _deathCommandPool 一帧 ✅ 大
_birthStatePool 一个父粒子的整个生命周期 ⚠️ 看内容类型
  • 命令对象只活一帧(SubEmittersModule.ts:240 借出 → 目标的环节 3 release() 归还),池水位 ≈ 该系统的每帧发射峰值。不同特效在不同帧触发是常态,错峰收益最明显。
  • 状态对象活一个父粒子的一生(SubEmittersModule.ts:401 在 _retireParticle 时回收),所以:一次性爆发特效(爆完全部还池)收益大;循环稳态系统(人口常年占满)收益很小。

二、⚠️ 共享前必须先修:命令对象永久绑定了创建它的系统

// BirthSubEmitterCommand.ts:28-31
constructor(
  readonly source: ParticleGenerator,          // ← readonly,reset() 里不重置它
  private readonly _pool: BirthSubEmitterCommand[]
) {}

reset() 只写 startDelay / inheritProperties / ringIndex / bornTime / lifetime 和帧信息,完全不碰 source。于是共享池下会出现:

  1. 系统 A 创建的命令归还到共享池
  2. 系统 B 从池里借走它(SubEmittersModule.ts:240),并推进目标队列
  3. 目标消费时执行 command.source._feedbackSimulator.readBinding
  4. → 读到的是 A 的父轨迹缓冲,而不是 B 的

同理,_emitParticles 里还会用 command.source._evaluateOverLifetime(...) 采父的 Color / Size / Rotation,同样会采错系统。

这是不会报错的静默错误(表现为子粒子的位置/颜色偶发异常),极难排查。

DeathSubEmitterCommand.ts:16-19 结构完全一样,同样需要修。

修法很简单 —— 把 source 从构造函数移到 reset(),每次借出时重新绑定:

constructor(private readonly _pool: BirthSubEmitterCommand[]) {}

reset(source: ParticleGenerator, startDelay, ...) {
  this.source = source;          // ← 重新绑定
  ...
}

这样命令对象就变成「系统无关」的了,共享池才安全。

三、_birthStatePool 不需要重新绑定

BirthSubEmitterState 继承自 EmissionState,而 EmissionState 只持有游标和随机流,没有任何 generator 引用(EmissionState.ts:8-13),且 reset() 会完整重置:

// BirthSubEmitterState
reset(seed: number, startDelay: number, initialEmissionTime: number): void {
  this.startDelay = startDelay;
  this.resetRandomSeed(seed);
  this.resyncTimeCursors(initialEmissionTime);
}

所以这个池可以直接共享,不需要额外处理。

补充一点:EmissionState 的 _rateRand / _burstRand 是惰性创建的(EmissionState.ts:22,26),所以复用 state 同时也复用了这两个随机流对象,收益还不止省一个对象。

四、池放在哪一层

建议放在 Engine(或 ParticleSystemManager,per-Scene)上,不要用静态字段 —— 静态字段是模块级单例,会跨 Engine 实例存活,引擎销毁后不释放,多 Engine / 多测试场景还会互相污染。

位置 共享范围 回收时机
Engine 跨场景 Engine 销毁
ParticleSystemManager 单场景 场景销毁(内存上界更紧)

实现上注意:SubEmittersModule 在 ParticleGenerator 构造函数里就创建了(早于注册到 PSM),所以池需要惰性获取(真正用到时再从 this._generator._renderer._particleSystemManager 拿)。

五、补充:共享不解决「单系统大爆发后永久占用」

共享池的水位 = 全局峰值。如果内存压力来自「某一个系统爆发一次 10 万、之后一直占着」,共享池照样是 10 万 —— 这种情况更适合给池加缩容。

但注意一个坑:_birthStatePool 必须保留跨帧的周转量,不能简单砍到 0。因为一帧内的顺序是「先借后还」(环节 5.1 Birth 借 → 环节 5.2 处理死亡时还),池里那部分存量正是下帧要用的,裁干净反而会制造每帧分配。

六、顺带:这三个池本身可以直接用 ReturnableObjectPool

现在三个池是裸数组加 pop() / push(),和引擎里已有的 ReturnableObjectPool<T> 行为完全等价(都是栈式 LIFO),可以统一过去 —— 已有 Engine._charRenderInfoPool、PrimitiveChunk.areaPool / subChunkPool / subMeshPool 在用它。

但有两个前提:

(1)ReturnableObjectPool 要求零参数构造

// ReturnableObjectPool.get()
if (this._lastElementIndex < 0) return new this._type();   // ← 零参数
  • BirthSubEmitterState 没有构造函数 → ✅ 可以直接换
  • BirthSubEmitterCommand / DeathSubEmitterCommand 的构造函数带 source 和 _pool → ❌ 必须先按第二节把 source 移到 reset()

(2)⚠️ ReturnableObjectPool.garbageCollection() 目前有缺陷

// ObjectPool(基类)
garbageCollection(): void {
  for (let i = elements.length - 1; i >= 0; i--) elements[i].dispose?.();
  elements.length = 0;                 // ← 只清数组
}

它没有重置 ReturnableObjectPool 自己的 _lastElementIndex:

get(): T {
  if (this._lastElementIndex < 0) return new this._type();   // 游标还是旧值 → 跳过
  return this._elements[this._lastElementIndex--];           // _elements 已空 → 返回 undefined
}

→ GC 之后再 get() 会拿到 undefined。

代码库里也印证了这个路径没人走:Engine._gc()(Engine.ts:728)只对两个 ClearableObjectPool 调了 garbageCollection(),而 _charRenderInfoPool(ReturnableObjectPool)和 PrimitiveChunk 的三个静态池都是只 get() / return()、从不 GC。

(3)dispose() 正好是清理内部数组的位置

ReturnableObjectPool<T extends IPoolElement> 的元素可以实现可选的 dispose?(): void:

export interface IPoolElement { dispose?(): void; }

顺便说一个当前的小问题:BirthSubEmitterCommand.requests(:16)在 release() 里只重置了 requestCount(:99-101),数组长度从不收缩;而命令对象会被池永久复用,于是每条命令都长期持有「历史最多请求数」那点内存。放进 dispose() 里正好:

dispose(): void { this.requests.length = 0; }

这一节的结论:换成 ReturnableObjectPool 是统一化的好事,而且「把 source 移进 reset()」这个前置改动和第二节的需求正好重合。
但要注意:换池本身不省内存(栈式复用行为与现状等价);真正的"能放掉峰值"要靠先修好 garbageCollection(),再把它接进 Engine._gc()。

小结

项 结论
_birthCommandPool / _deathCommandPool ✅ 值得共享,但必须先把 source 移到 reset()
_birthStatePool ✅ 可直接共享(无绑定);收益取决于内容是否错峰
池的位置 Engine 或 ParticleSystemManager(不要 static)
缩容 可作为补充,但状态池要保住周转量
池的实现 可统一到 ReturnableObjectPool(与 PrimitiveChunk 一致),但需先修 garbageCollection();且前置改动与上面重合

以上是建议、不是阻塞项。当前实现(每系统独立池)在正确性上没有发现问题,只是内存上界是各系统峰值之和。

@cptbtptpbcptdtptp

Copy link
Copy Markdown
Collaborator

小建议:TransformFeedbackPrimitive 的私有字段顺序(nit,非阻塞)

本 PR 给 _transformFeedback 和 _byteStride 加上了 readonly(TransformFeedbackPrimitive.ts:19-24),但两者被 _bindingA / _bindingB 隔开了:

private _platformPrimitive: IPlatformTransformFeedbackPrimitive;
private readonly _transformFeedback: TransformFeedback;
private _bindingA: VertexBufferBinding;
private _bindingB: VertexBufferBinding;
private readonly _byteStride: number;
private _readIsA = true;

这里有一点语义上的小割裂:_bindingA / _bindingB / _readIsA 是同一个 ping-pong 状态机的三件套(endDraw() 里会把 _readIsA 取反,正是切换它俩的读写身份),却被 _byteStride 插在中间。

建议把 _byteStride 往上挪一行 —— 两个 readonly 相邻,ping-pong 三件套也相邻:

private _platformPrimitive: IPlatformTransformFeedbackPrimitive;
private readonly _transformFeedback: TransformFeedback;
private readonly _byteStride: number;

private _bindingA: VertexBufferBinding;
private _bindingB: VertexBufferBinding;
private _readIsA = true;

另外四个字段都不可能是 readonly:_platformPrimitive 在 _rebuild() 里重建,_bindingA / _bindingB 在 resize() 里重建,_readIsA 在 endDraw() 里翻转。所以只能这样挪。

补充一句:我翻了下代码库,readonly 放在可变字段之前/之后两种写法大约各占一半(ParticleBounds、ParticleSubEmitterSpawnState、ParticleSystemManager、GLTransformFeedbackPrimitive 是 readonly 在前;ParticleRenderer、EmissionModule、InheritVelocityModule、SubEmittersModule 相反),也没有 lint 规则约束。所以这纯粹是局部整洁度偏好,不是规范问题,不改也完全可以。


以上是纯 nit,不影响功能,也不阻塞合并。

@cptbtptpbcptdtptp

Copy link
Copy Markdown
Collaborator

小建议:BirthSubEmitterCommand.requests 可改成共享的扁平 scratch(重构建议,非阻塞)

这是上一条评论里「requests 长度从不收缩」那个问题的更彻底的解法。上一条提的 dispose() 里 requests.length = 0 只在 GC 时生效、且会丢掉元素复用;这里给一个结构性的方案。

一、现状

// BirthSubEmitterCommand.ts:5-8, 16
interface EmissionRequest { time: number; count: number; }
readonly requests: EmissionRequest[] = [];

// :52-57
addRequest(time: number, count: number): void {
  const request = (this.requests[this.requestCount] ??= { time, count });
  request.time = time;
  request.count = count;
  this.requestCount++;
}

// :99-102
release(): void {
  this.requestCount = 0;        // ← 只重置计数器
  this._pool.push(this);        // ← 数组原样带回池
}

二、两个问题

① 长度只增不减:??= 只复用槽位、不会缩容;release() 也不截断。

② 更关键:每条命令最终都会涨到「全局最大值」
命令对象被 _birthCommandPool 永久复用,会在不同帧服务不同的父粒子。于是同一个命令会被反复撑大 —— 长期看每条命令的 requests.length 都会收敛到"曾经出现过的最大请求数"。

所以总量是:

公式
现状 池高水位 × 全局最大请求数 × ~56 B(Σ)
理论下界 整帧总请求数峰值 × 8 B(max)

(后者才是真实需要的信息量:一帧内所有父粒子一共要发多少个子粒子。)

三、建议:请求数据挪到 SubEmittersModule 的共享 scratch,命令只记区间

// SubEmittersModule —— 一次分配,帧首游标归零
private _requestTimes: Float32Array;
private _requestCounts: Uint32Array;
private _requestCursor = 0;

// BirthSubEmitterCommand —— 只记区间,不再持有数组
requestStart: number;
requestCount: number;

消费方 ParticleGenerator._consumeBirthSubEmitterCommand(:1181)按区间读:

// 现在
const requests = command.requests;
for (let i = 0; i < command.requestCount; i++) {
  const request = requests[i];
  ... request.time ... request.count ...
}

// 改后
const times = command.source.subEmitters._requestTimes;
const counts = command.source.subEmitters._requestCounts;
for (let i = 0; i < command.requestCount; i++) {
  const idx = command.requestStart + i;
  ... times[idx] ... counts[idx] ...
}

sortRequests()(:59)仍然可行 —— 只是把插入排序作用在 flat 区间上(比较 times[],交换 times[] 与 counts[])。

四、收益对比

维度 现状 扁平化后
内存量级 池高水位 × 全局最大请求数 整帧总请求数峰值
分配 每条命令各自增长(??=) 启动时一次
回收 requestCount = 0(假回收) _requestCursor = 0
回收 {time, count} 对象 靠 ??= 复用 不需要(数据在 typed array 里)
与现有代码一致性 独立小数组 与 _instanceVertices / _emissionRecords 同风格

五、⚠️ 一个必须保证的生命周期前提

scratch 必须在生产者产出前归零,但必须活到消费者读完之后:

父的环节 5:往 scratch 写请求(游标 +n),命令入目标队列
子的环节 3:按 [requestStart, requestCount) 读

PSM 是拓扑序、父先子后,所以同帧内成立。但改造时要显式确认这两个不变量:

  1. 消费者不会被跳过 —— 现在父会设 target._subEmitterDependencyFrame = frameCount(ParticleSystemManager.ts),保证子在本帧一定执行;这是 scratch 能安全复用一整帧的关键
  2. 命令不会跨帧残留 —— 命令若在队列里活过一帧,requestStart 索引就失效了

这两点目前都成立,但属于当前实现的隐含前提,建议在注释里写明。

六、为什么不用「一行修复」

最简单的替代是在 release() 里加一句 this.requests.length = 0。但它是有代价的:

  • ??= 的元素复用会失效 —— 每次复用命令都要重新分配 { time, count } 对象
  • length = 0 在 V8 上会释放底层存储,于是下次重新 push 要从空数组重新增长

实测(Node v24,--trace-gc 计数):

场景 耗时 Scavenge
N=10000,每轮 length = 0 后重新 push × 5000 轮 160 ms 344 次
同上,预分配后只覆盖索引 43 ms 0 次
N=2,反复截断 × 500 万轮 185 ms 725 次
同上,不截断 5 ms 0 次

所以「截断」是用每帧分配换内存;扁平 scratch 则两头都不欠。


以上是重构建议、不是阻塞项。当前实现正确性没有问题,只是内存上界偏大(Δ 取决于"最大请求数"和"平均请求数"差多少 —— 请求数分布越不均匀,收益越明显)。

@cptbtptpbcptdtptp

Copy link
Copy Markdown
Collaborator

小建议:Engine._particleBufferUtils 补一个 /** @internal */(nit,非阻塞)

// Engine.ts:67-75
  /** @internal */
  _renderingStatistics: RenderingStatistics = new RenderingStatistics();
  /** @internal */
  _isDeviceLost: boolean = false;
  /** @internal */
  _batcherManager: BatcherManager;

  _particleBufferUtils: ParticleBufferUtils;      // ← 少了标记

  /** @internal */
  _frameInProcess = false;

Engine 里 24 个 _ 前缀字段,这是唯一一个没有 /** @internal */ 的(整个文件共 39 处 @internal,是明确的约定)。

补上即可:

  /** @internal */
  _particleBufferUtils: ParticleBufferUtils;

补充两点,方便作者判断要不要在这个 PR 里顺手改:

  • 这个字段是 Add new particle renderer #1682(Add new particle renderer) 引入的,不是本 PR 新增 —— 所以严格说属于"顺手改",不改也完全合理。
  • 仓库里没有 typedoc.json,scripts/typedoc.sh 也没传 --excludeInternal,所以没有工具强制这件事,纯粹是代码一致性。

以上是纯 nit,不改也不影响任何东西。

@GuoLei1990

GuoLei1990 commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

@cptbtptpbcptdtptp 本轮修改已推送,当前 HEAD 为 613a7ccad。

  • Loop Burst 遗留的精度问题:8f743f1fd 将周期定位与事件比较统一到 Float32 时间域,并删除可由时间区间推导的 Burst 游标。补测了跨周期漏发和删除前序 Burst 后的行为。
  • duration 边界:9a52c65ea 在配置入口拒绝非有限值及非正数,抛出 RangeError 并保留原值;补了非法赋值后停止、重新播放的回归测试。
  • Birth 时间轴说明已在 463b62102 简明写到模块注释中;Engine._particleBufferUtils 的 @internal 也已补上。
  • 请求存储:ff3b8d1d8 改为每个源系统共享一个惰性创建的数值数组,命令只记录区间,避免各命令分别保留历史峰值请求对象;保持数值精度、排序和消费时序。

寿命继承属于新增功能,本 PR 先不扩展,子粒子继续使用目标系统自身的 startLifetime。

另外两项核对后暂保留现有实现:

  • 字段排序:使用仓库的 SWC ES5 / loose 配置对照,JavaScript 输出完全相同,仅改善源码组织,没有运行收益。
  • 全局共享对象池:有错峰复用价值,但收益取决于系统寿命和并发方式。局部池能随特效一起回收,全局池则会继续持有峰值缓存;还要同步处理命令的 source / requests 重绑定与归还时解除引用。它需要连同回收策略一起评估,暂不作为当前 PR 已证实的净优化。

生产修改完成后,全量构建(含类型)和 17 个文件 / 407 项粒子及 Clone 测试通过。本次单行注释通过格式检查、ESLint(无错误,4 条原有警告)及 diff 检查,去注释后的 JavaScript 输出不变。以上为本地验证;合并冲突和最新提交的完整 CI 仍待处理。

…al-linear-order

# Conflicts:
#	packages/core/src/particle/modules/MainModule.ts
#	packages/core/src/particle/modules/VelocityOverLifetimeModule.ts
…al-linear-order

# Conflicts:
#	packages/core/src/particle/ParticleGenerator.ts
#	packages/core/src/particle/ParticleRenderer.ts
#	packages/core/src/particle/ParticleTransformFeedbackSimulator.ts
#	packages/core/src/particle/modules/EmissionModule.ts
#	packages/core/src/particle/modules/SubEmitter.ts
#	packages/rhi-webgl/src/GLTransformFeedbackPrimitive.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants