feat(particle): support continuous Birth sub-emitters#3071
Conversation
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughParticle 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. ChangesParticle system runtime
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
Possibly related PRs
Suggested labels: Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## dev/2.0 #3071 +/- ##
===========================================
+ Coverage 85.41% 85.58% +0.16%
===========================================
Files 811 815 +4
Lines 94654 95812 +1158
Branches 11510 11745 +235
===========================================
+ Hits 80849 82000 +1151
- Misses 13715 13722 +7
Partials 90 90
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
💡 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".
| if (parentWorldVelocity && this.inheritVelocity.enabled) { | ||
| const inheritFactor = this.inheritVelocity.curve.evaluate( | ||
| normalizedEmitAge, | ||
| this.inheritVelocity._curveRand.random() |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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 winReject the unsupported
Death + Systemconfiguration.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 duringaddSubEmitter.🤖 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
⛔ Files ignored due to path filters (3)
e2e/fixtures/originImage/Particle_particleRenderer-sub-emitter.jpgis excluded by!**/*.jpge2e/fixtures/originImage/Particle_particleRenderer-velocity-orbital-constant.jpgis excluded by!**/*.jpgpackages/shader/src/Shaders/Effect/ParticleFeedback.shaderis excluded by!**/*.shader
📒 Files selected for processing (17)
packages/core/src/ComponentsManager.tspackages/core/src/Engine.tspackages/core/src/particle/ParticleGenerator.tspackages/core/src/particle/ParticleRenderer.tspackages/core/src/particle/ParticleSystemManager.tspackages/core/src/particle/ParticleTransformFeedbackSimulator.tspackages/core/src/particle/enums/ParticleInheritVelocityMode.tspackages/core/src/particle/enums/ParticleRandomSubSeeds.tspackages/core/src/particle/enums/ParticleSubEmitterMode.tspackages/core/src/particle/index.tspackages/core/src/particle/modules/EmissionModule.tspackages/core/src/particle/modules/EmissionRuntimeState.tspackages/core/src/particle/modules/InheritVelocityModule.tspackages/core/src/particle/modules/SubEmitter.tspackages/core/src/particle/modules/SubEmittersModule.tstests/src/core/particle/SubEmitter.test.tstests/src/core/particle/VelocityOverLifetime.test.ts
| migrateCount > 0 && | ||
| runtimeMappings.push({ source: firstRetiredElement, target: bufferOffset, count: migrateCount }); |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Preserve the wrapped front segment when remapping runtime states.
When firstRetiredElement > firstFreeElement, the occupied ring includes both [firstRetiredElement, end) and [0, firstFreeElement). Only the tail is mapped here, so front-segment sub-emitter cursors are lost during shrink.
Proposed fix
} else {
migrateCount = this._currentParticleCount - firstRetiredElement;
bufferOffset = firstFreeElement;
+ firstFreeElement > 0 &&
+ runtimeMappings.push({ source: 0, target: 0, count: firstFreeElement });
this._firstNewElement > firstFreeElement && (this._firstNewElement -= firstFreeElement);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| migrateCount > 0 && | |
| runtimeMappings.push({ source: firstRetiredElement, target: bufferOffset, count: migrateCount }); | |
| } else { | |
| migrateCount = this._currentParticleCount - firstRetiredElement; | |
| bufferOffset = firstFreeElement; | |
| firstFreeElement > 0 && | |
| runtimeMappings.push({ source: 0, target: 0, count: firstFreeElement }); | |
| this._firstNewElement > firstFreeElement && (this._firstNewElement -= firstFreeElement); |
🤖 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 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.
There was a problem hiding this comment.
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 winNormalize the end of a non-looping emission to
1.At
sample.time === duration, modulo returns0, 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
⛔ Files ignored due to path filters (1)
e2e/fixtures/originImage/Particle_particleRenderer-velocity-orbital-constant.jpgis excluded by!**/*.jpg
📒 Files selected for processing (8)
packages/core/src/particle/ParticleGenerator.tspackages/core/src/particle/ParticleRenderer.tspackages/core/src/particle/ParticleSystemManager.tspackages/core/src/particle/index.tspackages/core/src/particle/modules/EmissionRuntimeState.tspackages/core/src/particle/modules/SubEmitter.tspackages/core/src/particle/modules/SubEmittersModule.tstests/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
There was a problem hiding this comment.
♻️ Duplicate comments (2)
packages/core/src/particle/ParticleGenerator.ts (2)
1041-1045: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winImplement
Currentmode 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.
Currentmode 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 winPreserve 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 itsruntimeMappings, 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 winAvoid per-frame object allocations in the hot path.
Creating new
Vector3andColorinstances 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
ParticleSubEmitterEmissionCommandand 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
📒 Files selected for processing (3)
packages/core/src/particle/ParticleGenerator.tspackages/core/src/particle/ParticleSystemManager.tspackages/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
left a comment
There was a problem hiding this comment.
🫧 尘小沫
完整复核 @ 0213a3c75
总结
连续 Birth 的产品语义是合理的:逐父粒子运行目标 Emission、把运行游标从配置中分离、并在相机裁剪前按依赖拓扑更新,方向与 Unity 一致。Current inherit velocity、Loop Burst、跨 Scene 校验和拓扑缓存也已在后续提交闭环。
但 Galacean 的父粒子位置由 GPU Transform Feedback 持有,当前实现为了照搬 Unity 的 CPU 逐粒子更新语义,引入了每帧同步 GPU 回读;同时 Initial 的曲线和发射入口契约仍不完整,原有 Birth Velocity flag 发生静默回归。加上此前仍未修复的 runtime-state 迁移、重建游标和稳态分配问题,当前版本仍不适合合入。
问题
-
[P1]
ParticleGenerator.ts:388-401, 1502-1522— 任意 Birth/Death slot 都把 Transform Feedback 变成每帧同步 GPU→CPU 回读_updateFeedback()刚提交本帧 TF 后立即调用_readbackFeedback(),底层Buffer.getData()直接执行gl.getBufferSubData(),并要求本帧 CPU 随后消费返回的 JS 数组;因此这里形成同帧 GPU 完成等待。旧 Death 路径只在确有粒子退休时回读一次,本 PR 的needsSubEmitterReadback则让只有 Death slot、但本帧无人死亡的系统也每帧读完整存活区间;Birth 同帧有旧粒子和新粒子时还可能读两轮,ring wrap 后每轮再拆成两次调用。Unity 的
SubModule::Update能逐父粒子访问位置,是因为 Shuriken 状态本来就在 CPU SIMD 数组中;这个成本不能直接迁移到 Galacean 的 GPU-TF 架构。至少应恢复 Death 的按需回读,并把 Birth 的“计算 emission schedule”和“获取 sample position”拆开:Rate/Burst 本帧没有 sample 时不回读,Rate Over Distance 单独走需要轨迹的位置路径。合入前请补 1k/5k 父粒子的 CPU frame time / GPU stall benchmark;若最坏场景仍需每帧同步,需明确选择异步一帧延迟、CPU 权威轨迹或 GPU 侧生成,而不是把同步点作为默认隐藏成本。 -
[P1]
ParticleGenerator.ts:1030-1066, 1304-1315/SubEmittersModule.ts:188-198—Initial的曲线时域和可用入口都不符合统一的 InheritVelocity 契约当前
Initial在 spawn 时用emissionNormalizedTime求值一次并烘进startSpeed + direction。这只适用于 Constant/TwoConstants;Unity 对 Curve/TwoCurves 会保存出生瞬间的 emitter velocity,并在子粒子生命周期中按 child normalized age 持续求曲线(InheritVelocityModule::Update/RequiresPerParticleVelocities)。例如 0→1 曲线在当前实现里会按目标 emission timeline 固定成一个值,出生后不再变化。同时
_addNewParticle()只有收到parentWorldVelocity才执行 Initial:连续 Birth 会传,Death_dispatchEvent()明确传null,普通_emit()也不提供。因此同一 public module 的Current可用于普通系统,Initial却只对 Birth 生效,Death 子发射静默无效。应建立一个 emission-entry-independent 的 velocity contract:普通 emission 取 Entity emitter velocity,所有 sub-emitter event 传父粒子世界速度;Initial Constant 可出生时烘焙,Initial Curve/TwoCurves 需保存 birth velocity + per-particle random,并按 child age 求值。请补普通 Initial、Death Initial、Initial Curve 和 TwoCurves 的公开行为测试。 -
[P1]
ParticleGenerator.ts:1392-1418— 连续 Birth 静默忽略现有ParticleSubEmitterInheritProperty.Velocity该 public flag 仍声明“Emit the sub particle along the parent's velocity direction”。改动前 Birth 经过
_dispatchEvent(),会把父粒子出生方向作为 override;现在 Birth 绕过该路径,handler 只处理 Color/Size/Rotation,并固定传eventWorldDirection = null。原有Birth Velocity inherit emits along the parent's birth emission direction回归测试也被删除,于是同一个 flag 在 Death 有效、Birth 无效且无报错。Unity 把速度继承统一放在 Inherit Velocity module,长期对齐可以选择废弃这个 Galacean flag;但当前 PR 不能保留公开枚举值并静默改变其中一种事件的语义。请保留旧行为,或显式移除/废弃并给出迁移与契约测试。
-
[P1]
ParticleGenerator.ts:643-715/SubEmittersModule.ts:362-375— wrapped shrink 仍丢失前段父粒子的 Birth runtime statewrapped ring 的存活区间是
[firstRetiredElement, capacity) + [0, firstFreeElement),shrink 分支只生成尾段 mapping;_remapParticleRuntimeStates()新建整表后只复制 mapping,前段仍存活父粒子的 emission cursor 变成undefined。应让_resizeInstanceBuffer()生成唯一的 segment plan,并由同一份 plan 驱动 CPU instance、feedback 与 runtime-state 三类迁移,而不是三处分别维护区间。请补制造 wrap 后收缩maxParticles、断言两个 segment 都延续原 cursor 的公开回归测试。 -
[P1]
SubEmittersModule.ts:260-270/EmissionModule.ts:206-225— 为存活父粒子重建 slot state 时从 0 补发全部历史 Rate Over Time重建分支把
previousParentAge设为当前父龄,却执行emission.reset(seed, 0)。下一帧_emitByRateOverTime()按playTime - frameRateTime推进,因而从 0 追赶到当前父龄;父粒子已活 0.5s、rate=10 时会突然补出约 5 个历史粒子。请先采样 start delay,再把 cursor 设为当前 delay-adjusted emission time(non-loop clamp 到 duration),并补动态给老父粒子添加 Birth slot 的反向测试。 -
[P1]
SubEmittersModule.ts:203-233,352-374/ParticleGenerator.ts:1332-1343/ParticleSystemManager.ts:60-87— 连续 Birth 的稳态生命周期仍持续创建并丢弃对象每个父粒子 Birth × slot 都创建
BirthSubEmitterRuntimeState、EmissionRuntimeState、Rand/Vector/sample pool;退休时整个 ring slot 置null。每个 emission sample 又创建 command 和最多六个 Vector/Color,manager 清 Map 后对应数组也失去复用。高 rate 配置会使分配量随父粒子出生量和子 sample 数线性增长。runtime state 应由 ring slot 长期持有,退休只 reset;command buffer 应由 manager 持有并复用,generator 只写入可复用 entry。请用稳定拓扑持续发射后的对象身份或 allocation counter 测试,证明 warm-up 后不再随 sample 数持续分配。
简化建议
EmissionRuntimeState是内部 cursor owner,不应从 public particle barrel 导出;当前测试应改走 generator/update 的公开行为,而不是为测试扩大 API。- 删除无 manager 时的同步
_emitFromSubEmitter()fallback,让ParticleSystemManager成为唯一调度 owner;测试应通过 scene/engine update。 - 父速度目前用相邻帧位置差得到整帧平均弦速度;Unity 使用当前
velocity + animatedVelocity。若保留差分,应明确它是近似,并增加不同 timestep 下轨道/加速轨迹的稳定性测试。
已关闭 / 不重复
Current inherit velocity、Loop Burst、脚本 emit() 命令丢失、跨 Scene 配置与迁移、拓扑图每帧重建,以及 orbital 积分说明已在后续提交闭环,本轮不重复提出。
GuoLei1990
left a comment
There was a problem hiding this comment.
🫧 尘小沫
结论
本轮完整复核了 0213a3c75cd431ac3e39fcf1e6c9063a68e2e198...db59b0830695aa0cd57ef66612d351b4e4fc3a64 的单 commit 增量,并回看完整 PR 数据流、历史 finding 与测试。最新 commit 正确缩小了同步回读范围,也把 Entity 速度与父粒子速度拆到不同配置 owner;但核心链路仍有多项 P1,且新拆分引入了低频采样下的轨迹速度失真,当前不适合合入。
阻塞级别:P1。实际 review 动作:REQUEST_CHANGES。目标 HEAD:db59b0830695aa0cd57ef66612d351b4e4fc3a64。当前新 HEAD 只有 labeler check,完整 build/test CI 尚未挂载,PR 也处于 DIRTY;冲突解决后需按新 HEAD 重新复核。自动 CR 不替代人工 Reviewer 的合入门禁,最终仍需人工审核确认。
已关闭问题清单
Current模式未实现:已由7dd24d0ac的 TF/shader 路径和公开行为测试修复。- Loop Burst 跨周期、脚本
emit()命令丢失:分别由be5bc7bb8和af9f5de21闭环。 - 跨 Scene 配置与迁移崩溃:
1fc541fbd在配置边界拒绝,a7f732e28删除热路径 throw、迁移后优雅跳过。 - 每帧重建依赖拓扑:
0213a3c75改为拓扑缓存、入口标脏和 scratch 复用。 - orbital 积分行为未说明:PR 描述已明确记录 orbit/radial/linear 合成顺序变化。
- 任意 Birth/Death slot 空闲帧都回读:
db59b0830已让 Death 仅在退休时回读,并让无 Rate/Burst 样本的 Birth 帧跳过回读;剩余 Rate Over Distance、同步 stall 与稀疏速度问题见下文。 - 普通系统的
Initial Constant入口缺失:db59b0830已让InheritVelocityModule捕获 Entity 位移速度并覆盖普通 emission;Curve/TwoCurves 的生命周期契约仍未闭环。
问题
-
[P1]
ParticleGenerator.ts:392-400,1361-1451/EmissionModule.ts:175-206/SubEmittersModule.ts:245-405— 选择性回读仍保留同步 stall,并把父速度降成“上次有样本以来的平均弦速度”_prepareEmissionSamples()只要rateOverDistance > 0就返回 true,因此即使父粒子静止、该帧没有 distance sample,仍会对完整存活区间执行同步gl.getBufferSubData();高频 Rate/Burst 也会在每个有样本帧同步等待 GPU。更严重的是,无样本帧不再调用_processBirthRange(),所以previousSampleParentAge和previousWorldPosition不更新;下一次样本的速度变为跨 N 帧的(P_now-P_lastSample)/Δt。例如父粒子一秒绕完整圈、Birth rate=1/s 时,弦位移接近 0,子粒子会继承近 0 速度,而发射瞬间切向速度非 0。现有 orbital 测试只覆盖 0.1s 首帧,无法证伪该回归。应保留 TF/GPU 轨迹为唯一权威 owner,明确选择可验证的速度/采样协议:由 GPU 产出完整轨迹速度或发射结果,或采用明确的一帧异步协议;不要再新增 CPU 镜像状态。至少补低频 orbital/加速轨迹的 timestep 稳定性测试,以及 1k/5k 父粒子的 CPU frame time 与 GPU stall benchmark。仅统计
_readbackFeedback调用次数不能证明热路径成本可接受。 -
[P1]
InheritVelocityModule.ts:99-114/SubEmitter.ts:15-26/SubEmittersModule.ts:166-208/ParticleGenerator.ts:1272-1319,1417-1445— 速度契约仍分裂成三份互不完整的 public truth最新 commit 正确把 Entity 速度归还给
InheritVelocityModule,把父粒子速度缩放移到SubEmitter.inheritVelocity;但三条路径仍互相矛盾:①InitialCurve/TwoCurves 仍在出生时按 systemnormalizedEmitAge求值一次并烘进初速度,Unity 的RequiresPerParticleVelocities()则保存出生时 emitter velocity、按 child normalized age 持续求曲线;② 新的 slot curve 文档对 Birth/Death 都成立,但 Death 明明已从 TF 读到 velocity,却向命令传parentWorldVelocity=null,因此该字段对 Death 静默无效;③ 旧ParticleSubEmitterInheritProperty.Velocity仍公开存在,只在 Death 覆盖方向,Birth 继续静默忽略,原 Birth 契约测试被替换成了新字段测试。应保留两个明确 owner:
InheritVelocityModule只拥有 Entity velocity,并为 Initial Curve/TwoCurves保存 birth velocity + per-particle random、按 child age 求值;SubEmitter.inheritVelocity只拥有父粒子完整速度缩放,并让 Birth/Death 都传入同一 TF 速度协议。随后删除或正式废弃ParticleSubEmitterInheritProperty.Velocity,给出inheritVelocity.constant=1的迁移并按新公开契约重写旧测试;不要用 compatibility branch 继续维护第四条路径。 -
[P1]
ParticleGenerator.ts:639-711/SubEmittersModule.ts:419-433— wrapped shrink 仍只迁移尾段,前段粒子、feedback 与 Birth cursor 一起丢失环绕存活区间是
[firstRetiredElement, capacity) + [0, firstFreeElement);当前 shrink 的firstRetiredElement > firstFreeElement分支仍只 copy/map 第一段。runtimeMappings、CPU instance copy 和 feedback copy 同时遗漏前段,存活父粒子的状态会消失或与粒子数据错配。应让
_resizeInstanceBuffer()生成一份覆盖两个 segment 的权威迁移计划,再机械驱动 instance、feedback 和 runtime-state 三类 copy,删除三处独立区间 ownership。请补 wrap 后收缩maxParticles、断言两个 segment 均保留粒子与 emission cursor 的公开回归测试。 -
[P1]
SubEmittersModule.ts:261-305/EmissionModule.ts:245-264— 动态重建 slot state 仍把 emission cursor reset 到 0,下一帧补发全部历史 Rate Over Time重建分支把
previousEmissionParentAge设为当前父龄,却仍执行state.emission.reset(seed, 0)。下一帧_emitByRateOverTime()以当前 emission time 减 0 计算 backlog;父粒子已活 0.5s、rate=10 时会单帧补出约 5 个不存在的历史样本。新两阶段 prepare/complete 只把双时钟显式化,没有修复权威 cursor。应让
EmissionRuntimeState.frameRateTime成为唯一发射游标 owner:采样 start delay 后,把重建 cursor 设为当前 delay-adjusted emission time,non-loop 时 clamp 到 duration。补“给存活父粒子动态添加 Birth slot,下一帧不补历史”的反向公开测试。 -
[P1]
SubEmittersModule.ts:212-242,410-416/ParticleGenerator.ts:1323-1359/ParticleSystemManager.ts:62-89— 稳态仍按父粒子和 emission sample 线性制造 GC每次父粒子 Birth 都新建
BirthSubEmitterRuntimeState、内部EmissionRuntimeState/Rand/Vector,退休又把 ring slot 置 null;每个 sample 仍新建 command 和最多六个 Vector/Color,managerclear()后连 per-target array 一起失去复用。最新 commit 还为每个 slot 增加独立 Rand,但没有改变热路径对象生命周期。应让 ring slot 长期持有并 reset runtime state,让
ParticleSystemManager长期持有可复用 command buffer/entry,generator 只写值;删除retire -> null -> new与逐 samplenew两条路径。请用对象身份、allocation counter 或真实 allocation benchmark证明 warm-up 后分配不再随 sample 数增长。 -
[P2]
particle/index.ts:17/EmissionRuntimeState.ts:12/RateOverTimeReplay.test.ts— 内部 cursor 仍为测试扩大公共 APIEmissionRuntimeState的生产 owner 只有 emission/sub-emitter 内部路径,却继续从 public barrel 导出,测试再直接调用_getEmissionSamples()。应删除 public export,把有效 burst 契约改由 generator/update 公开链路验证;不要保留 public wrapper 或第二采样入口喂旧测试。 -
[P2]
ParticleGenerator.ts:1352-1358— 无 manager 的同步直发仍是第二条调度状态机支持路径中的 Birth/Death 都由已注册 renderer 的 scene manager update 产生;同步 fallback 绕过拓扑顺序、命令 owner 和目标 update clock,只服务直接驱动内部
_update的非公开用法。应保留ParticleSystemManager为唯一调度 owner并删除 fallback,相关测试迁移到 scene/engine update。 -
[P2]
SubEmittersModule.ts:101-132/InheritVelocityModule.ts:11— 新公开 surface 的 TSDoc 未随契约更新完整addSubEmitter()现在返回SubEmitter,但公开 TSDoc 缺少@returns;本轮修改的 publicInheritVelocityModule类仍使用单行块。请按同包公开模块惯例补完整多行 TSDoc,并让返回值说明 slot 是后续配置inheritVelocity的入口。
架构、熵增与测试治理
沿链路上游一层,ParticleSystemManager 应唯一拥有拓扑更新与命令消费,EmissionRuntimeState 应唯一拥有 Rate/Burst/Distance cursor,InheritVelocityModule 应唯一拥有 Entity velocity,SubEmitter 应唯一拥有父粒子 velocity scale;下游一层,TF buffer 是父轨迹事实,_processBirthRange() 只应把该事实和 emission plan 组装成命令,目标 generator 只负责 spawn。
本轮把错误复用 target InheritVelocityModule 的父速度配置移回 slot,owner 方向是净减法;但系统实际从“一套 emission + 一个父采样时钟”增加为 prepare/complete 两阶段、两个 parent-age 时钟、prepared interval/flag、缓存 rate 以及第三个 velocity public control。新增状态没有替代旧 Velocity bit、同步 fallback、public runtime cursor 或逐 sample command ownership,复杂度仍向上下游扩散。应按上述 owner 删除旧路径,而不是再加同步层。
测试治理方面,新 readback spy 只锁定私有调用次数,没有覆盖 stall、Rate Over Distance 空转或低频曲线轨迹;wrapped shrink、动态重建 backlog、稳态 allocation、Death slot velocity、Initial Curve/TwoCurves 均无反向证伪。旧 Birth Velocity flag 测试被新 slot 测试替换,却未删除/迁移旧公开契约;RateOverTimeReplay 又通过 public export 锁定内部 cursor。权威契约切换后应删除失效 fixture/私有测试,按新公开链路重写,绝不能为旧测试保留 compatibility branch、fallback、wrapper、镜像状态或第二条转换/校验路径。
GuoLei1990
left a comment
There was a problem hiding this comment.
🫧 尘小沫
结论
本轮完整复核了 db59b0830695aa0cd57ef66612d351b4e4fc3a64...9f0271349b84e7140dafb87f0be84df1927a9911 的单 commit 增量,并回看完整 PR diff、未关闭 review thread、Unity Inherit Velocity 参考实现以及 TF 上下游协议。最新 commit 正确把父粒子本帧世界轨迹速度交给 GPU Transform Feedback 产出,关闭了稀疏采样跨帧弦速度失真,也恢复了 Birth Velocity 方向继承;但同步回读、Death 速度协议、Initial 曲线生命周期、环形迁移、动态游标和稳态分配仍有多项 P1,当前不适合合入。
阻塞级别:P1。实际 review 动作:REQUEST_CHANGES。目标 HEAD:9f0271349b84e7140dafb87f0be84df1927a9911。当前 PR 仍为 DIRTY,该 HEAD 仅有 labeler check,完整 build/test CI 尚未挂载;冲突解决后需按新 HEAD 重新复核。自动 CR 不替代人工 Reviewer 的合入门禁,最终仍需人工审核确认。
已关闭问题清单
Current模式未实现:7dd24d0ac已通过 TF/shader 路径和公开行为测试闭环。- Loop Burst 跨周期、脚本
emit()命令丢失:分别由be5bc7bb8和af9f5de21修复。 - 跨 Scene 配置与迁移崩溃:
1fc541fbd在配置边界拒绝,a7f732e28删除热路径 throw、迁移后优雅跳过。 - 每帧重建依赖拓扑:
0213a3c75改为拓扑缓存、入口标脏和 scratch 复用。 - orbital 积分行为未说明:PR 描述已明确记录 orbit/radial/linear 合成顺序变化。
- 任意 Birth/Death slot 空闲帧都回读、普通系统
Initial Constant入口缺失:db59b0830已分别缩小回读触发范围并补齐 Entity velocity 入口。 - 稀疏 Birth 把父速度降为跨 N 帧平均弦速度:
9f0271349新增 TF world position/trajectory velocity,按当前 TF 帧产出速度,并补 0.1s/0.05s orbital 与 Entity motion 回归测试。 - Birth 静默忽略
ParticleSubEmitterInheritProperty.Velocity:9f0271349已恢复为使用父粒子轨迹方向,且测试确认不额外继承速度大小。
问题
-
[P1]
ParticleGenerator.ts:392-400,1361-1452,1540-1560/EmissionModule.ts:175-205/ParticleBufferUtils.ts:19-25— 选择性回读仍是同步 GPU stall,最新协议还把所有 TF 系统的反馈载荷从 24B 扩到 48B_prepareEmissionSamples()只要rateOverDistance > 0就要求位置,因此静止父粒子也会每帧对完整存活区间执行Buffer.getData();高频 Rate/Burst 同样会在每个有样本帧回读。底层就是同帧gl.getBufferSubData(),CPU 必须等待刚提交的 TF 完成。最新 commit 修复了速度事实,却让每个 TF slot 从两个vec3变为四个vec3;这不仅使回读字节翻倍,也让普通 Noise/VOL/Current-inherit 系统承担额外 TF 写带宽,即使它们没有 Birth slot。应保留 TF/GPU 轨迹为唯一权威 owner,但需要选择可验证的消费协议:由 GPU 直接产出 emission 结果,或采用明确的一帧异步 readback;不要重新增加 CPU 位置/速度镜像。若继续当前同步协议,至少提供 1k/5k 父粒子的 CPU frame time、GPU stall 与 TF bandwidth benchmark,并证明 Rate Over Distance 静止场景和高频样本场景可接受;仅统计
_readbackFeedback次数不能证明热路径成本。 -
[P1]
ParticleGenerator.ts:1563-1615/SubEmittersModule.ts:163-205/SubEmitter.ts:24-26— Death 仍绕过新的权威轨迹通道,导致方向缺模块速度且 slotinheritVelocity永远无效最新 TF 已在 offset 6..11 产出权威
worldPosition + trajectoryVelocity,Birth 也直接消费它;Death 却继续读取 offset 0..5 的 simulation position /localVelocity,再用当前或出生 rotation 手工转世界。localVelocity不含以位置积分的 orbital/radial 速度,也不含 Local simulation 下 Entity Transform 的移动;因此同一父粒子的 BirthVelocity会沿完整轨迹,DeathVelocity仍沿不完整基础速度。随后_dispatchEvent()又固定把parentWorldVelocity传null,所以公开的SubEmitter.inheritVelocity对 Death slot 静默 no-op,和“when a sub particle is emitted”的 TSDoc 冲突。应保留 TF
v_FeedbackWorldPosition/v_FeedbackTrajectoryVelocity为父轨迹唯一 owner:Death 直接读取 offset 6..11,把同一 trajectory velocity 同时提供给方向投影和SubEmitter.inheritVelocity,并删除 offset 0..5 → simulation-space/spawn-rotation 的平行转换路径。补 Death orbital、Entity motion、slot curve 的公开行为测试。 -
[P1]
InheritVelocityModule.ts:99-114/ParticleGenerator.ts:1027-1061—InitialCurve/TwoCurves 仍在出生时按 emission timeline 烘焙一次,未按 child lifetime 求值_getInitialVelocity(normalizedEmitAge)对所有曲线模式都在 spawn 时求值一次并改写direction/startSpeed。Unity 只对 Constant/TwoConstants 走ApplyOnBirth();Initial Curve/TwoCurves 会保存出生时 emitter velocity,再由UpdateTpl按每个 child 的NormalizedTime持续求曲线。当前 0→1 曲线会被固定在 system/sub-emitter emission time 的一个值,子粒子出生后不再变化,普通 emission 与 sub-emitter 都受影响。应保留
InheritVelocityModule为 Entity velocity 唯一 owner:Constant/TwoConstants 可继续出生时烘焙;Curve/TwoCurves 保存 birth emitter velocity 与 per-particle random,并在 child age 更新路径求值。按公开 update 链路补 Initial Curve/TwoCurves 反向测试,不要增加只服务旧测试的 wrapper 或第二套求值入口。 -
[P1]
ParticleGenerator.ts:639-711/SubEmittersModule.ts:425-438— wrapped shrink 仍只迁移尾段,前段 instance、feedback 与 emission cursor 一起丢失环绕存活区间是
[firstRetiredElement, capacity) + [0, firstFreeElement);shrink 的firstRetiredElement > firstFreeElement分支仍只 copy/map 第一段。新 48B feedback、CPU instance 和 Birth runtime state 都机械继承了这份不完整区间判断,前段存活粒子会消失或与 cursor 错配。应让
_resizeInstanceBuffer()生成一份覆盖两个 segment 的权威迁移计划,再由该计划机械驱动 instance、feedback 和 runtime-state copy,删除三处独立区间 ownership。补 wrap 后收缩maxParticles、断言两段粒子与 emission cursor 都保留的公开回归测试。 -
[P1]
SubEmittersModule.ts:257-268/EmissionModule.ts:245-264— 动态重建 slot state 仍把权威 emission cursor reset 到 0,下一帧补发全部历史 Rate Over Time给存活父粒子新增/替换 Birth slot 时,代码把
previousEmissionParentAge设为当前父龄,却执行state.emission.reset(seed, 0)。下一帧_emitByRateOverTime()用当前 delay-adjusted emission time 减 0,父粒子已活 0.5s、rate=10 时会单帧补出约 5 个不存在的历史样本;最新 commit 删除采样位置镜像并未改变该游标。应保留
EmissionRuntimeState.frameRateTime为唯一 Rate 游标 owner:先采样 start delay,再把重建 cursor 设为当前 delay-adjusted emission time,non-loop 时 clamp 到 duration。补“给存活父粒子动态添加 Birth slot,下一帧不补历史”的公开反向测试。 -
[P1]
SubEmittersModule.ts:209-238,415-423/ParticleGenerator.ts:1323-1359/ParticleSystemManager.ts:62-90— 稳态仍按父粒子和 emission sample 线性制造 GC最新 commit 从每个 Birth state 删除了一个
Vector3和两个采样时钟,方向是净减法;但每次父粒子 Birth × slot 仍new BirthSubEmitterRuntimeState、内部EmissionRuntimeState/Rand/Vector,且还即时new Rand采样 Start Delay,退休后整个 ring slot 置 null。每个 sample 又新建 command 和最多六个 Vector/Color,managerclear()后 per-target array 也失去复用。分配量仍随父粒子出生量和子 sample 数线性增长。应让 ring slot 长期持有并 reset runtime state,让
ParticleSystemManager长期持有可复用 target buffer/command entry,generator 只写值;删除retire -> null -> new、逐 slotnew Rand与逐 samplenew command三条稳态路径。用对象身份、allocation counter 或真实 allocation benchmark证明 warm-up 后分配不再随 sample 数增长。 -
[P2]
particle/index.ts:17/EmissionRuntimeState.ts:12/RateOverTimeReplay.test.ts:149-164— 内部 cursor 仍为测试扩大公共 APIEmissionRuntimeState的生产 owner 只有 emission/sub-emitter 内部链路,却继续从 public barrel 导出,测试再直接调用_getEmissionSamples()。应删除 public export,把 Burst 边界契约改由 generator/update 的公开行为验证;不要保留 public wrapper、compat export 或第二采样入口喂旧测试。 -
[P2]
ParticleGenerator.ts:1352-1358— 无 manager 的同步直发仍是第二条调度状态机支持路径中的 Birth/Death 都由已注册 renderer 的 scene manager update 产生;同步 fallback 绕过拓扑顺序、命令 owner 和目标 update clock,只服务直接驱动内部
_update或未入 Scene 的非公开用法。应保留ParticleSystemManager为唯一命令调度 owner并删除 fallback,相关测试迁移到 scene/engine update。 -
[P2]
SubEmittersModule.ts:98-130/InheritVelocityModule.ts:11— 新公开 surface 的 TSDoc 仍未完整表达返回值与类契约addSubEmitter()返回可继续配置inheritVelocity的SubEmitter,但公开 TSDoc 缺少@returns;publicInheritVelocityModule类仍使用单行块,偏离同包公开模块的多行 TSDoc 约定。请补完整多行 TSDoc,并明确返回 slot 是后续配置 per-slot velocity scale 的入口。
架构、熵增与测试治理
沿直接链路上游一层,ParticleSystemManager 应唯一拥有拓扑更新与命令消费,EmissionRuntimeState 应唯一拥有 Rate/Burst/Distance cursor,InheritVelocityModule 应唯一拥有 Entity velocity;下游一层,TF buffer 应唯一拥有父粒子的 world position/trajectory velocity,SubEmittersModule 只把 emission plan 与该事实组装为命令,SubEmitter.inheritVelocity 只配置父轨迹速度缩放,目标 generator 只负责 spawn。
本轮从 CPU Birth state 删除 previousWorldPosition/previousSampleParentAge,改由 TF 直接产出本帧轨迹事实,并恢复 direction-only Velocity 消费,owner 方向是净减法;但同时把所有 TF slot 从 24B 扩到 48B,而 Death 仍维护 offset 0..5 + rotation 的旧转换并把新 velocity 丢弃,形成同一轨迹事实的平行协议。应保留 offset 6..11 的新权威协议,删除 Death 旧转换;不要再加同步层或 CPU 镜像。
测试治理方面,新 sparse orbital / Entity motion / Birth direction 测试能反向证伪本轮修复,但 readback spy 仍只锁定私有调用次数,不能证明 stall 与 bandwidth 可接受。Death trajectory/slot curve、Initial Curve/TwoCurves、wrapped shrink、动态重建 backlog、稳态 allocation 均无公开反向测试;RateOverTimeReplay 仍通过 public export 锁定内部 cursor。权威契约切换后应删除失效 fixture/私有测试并按公开链路重写,绝不能为了旧测试保留 compatibility branch、legacy fallback、wrapper、镜像状态或第二条转换/校验路径。
GuoLei1990
left a comment
There was a problem hiding this comment.
🫧 尘小沫
结论
本轮完整复核了 9f0271349b84e7140dafb87f0be84df1927a9911...3e5da780893278834e5262874d32db4bb0cd03d7 的增量,并以第二父提交 8e114779815954d3a62c7e8dd5758d0e076b3111...3e5da780893278834e5262874d32db4bb0cd03d7 回看合并后的实际 PR tree、未关闭 thread、Unity Inherit Velocity 参考实现以及 TF 上下游协议。本轮只有一次 dev/2.0 merge:冲突已解决,clone/#3060 上游改动已正确纳入,完整 CI 全绿;但没有 PR-owned 修复 commit,上一轮仍开放的同步回读、Death/Initial 速度协议、环形迁移、动态游标和稳态分配问题均继续成立,当前不适合合入。
阻塞级别:P1。实际 review 动作:REQUEST_CHANGES。目标 HEAD:3e5da780893278834e5262874d32db4bb0cd03d7。自动 CR 不替代人工 Reviewer 的合入门禁,最终仍需人工审核确认。
已关闭问题清单
Current模式未实现:7dd24d0ac已通过 TF/shader 路径和公开行为测试闭环。- Loop Burst 跨周期、脚本
emit()命令丢失:分别由be5bc7bb8和af9f5de21修复。 - 跨 Scene 配置与迁移崩溃:
1fc541fbd在配置边界拒绝,a7f732e28删除热路径 throw、迁移后优雅跳过。 - 每帧重建依赖拓扑:
0213a3c75改为拓扑缓存、入口标脏和 scratch 复用。 - orbital 积分行为未说明:PR 描述已明确记录 orbit/radial/linear 合成顺序变化。
- 任意 Birth/Death slot 空闲帧都回读、普通系统
Initial Constant入口缺失:db59b0830已分别缩小回读触发范围并补齐 Entity velocity 入口。 - 稀疏 Birth 跨帧弦速度失真、Birth 静默忽略
ParticleSubEmitterInheritProperty.Velocity:9f0271349已改由 TF 产出本帧轨迹速度并恢复 direction-only 继承,相关回归测试有效。 ParticleGenerator.inheritVelocity字段缺少 TSDoc:3e5da7808合并新版 clone 基础设施时已补齐字段说明;剩余 public surface 问题见下文。
问题
-
[P1]
ParticleGenerator.ts:383-390,1358-1400,1537-1557/EmissionModule.ts:171-202/ParticleBufferUtils.ts:19-25— 选择性回读仍是同步 GPU stall,且所有 TF 系统继续承担 48B feedback 协议_prepareEmissionSamples()只要rateOverDistance > 0就要求位置,因此静止父粒子也会每帧对完整存活区间执行Buffer.getData();高频 Rate/Burst 同样在有样本帧同步回读。底层GLBuffer.getData()直接调用同帧gl.getBufferSubData(),CPU 必须等待刚提交的 TF。为修复轨迹事实,feedback 又从两个vec3扩为四个vec3,普通 Noise/VOL/Current-inherit 系统即使没有 Birth slot,也要承担额外 TF 写带宽。应保留 TF/GPU 轨迹为唯一权威 owner,但改为 GPU 直接产出 emission 结果或明确的一帧异步 readback;不要增加 CPU 位置/速度镜像。若坚持同步协议,至少提供 1k/5k 父粒子的 CPU frame time、GPU stall 与 TF bandwidth benchmark,并覆盖 Rate Over Distance 静止场景和高频样本场景;私有方法调用次数不能证明热路径成本可接受。
-
[P1]
ParticleGenerator.ts:1560-1612/SubEmittersModule.ts:163-205/SubEmitter.ts:25-26— Death 仍绕过权威轨迹通道,方向缺模块速度且 slotinheritVelocity永远无效TF offset 6..11 已产出权威
worldPosition + trajectoryVelocity,Birth 直接消费;Death 仍读取 offset 0..5 的 simulation position /localVelocity,再用当前或出生 rotation 手工转世界。该localVelocity不含 orbital/radial 位置积分速度,也不含 Local simulation 下 Entity Transform 移动。随后_dispatchEvent()又固定传parentWorldVelocity=null,使公开的SubEmitter.inheritVelocity对 Death slot 静默 no-op。应保留 TF
v_FeedbackWorldPosition/v_FeedbackTrajectoryVelocity为父轨迹唯一 owner:Death 直接读取 offset 6..11,把同一 trajectory velocity 同时交给方向投影和SubEmitter.inheritVelocity,并删除 offset 0..5 → simulation-space/spawn-rotation 的平行转换。补 Death orbital、Entity motion 与 slot curve 的公开行为测试。 -
[P1]
InheritVelocityModule.ts:98-113/ParticleGenerator.ts:1019-1053—InitialCurve/TwoCurves 仍在出生时按 emission timeline 烘焙一次_getInitialVelocity(normalizedEmitAge)对所有曲线模式都在 spawn 时求值并改写direction/startSpeed。Unity 只让 Constant/TwoConstants 走ApplyOnBirth();Initial Curve/TwoCurves 保存出生时 emitter velocity,并在 child lifetime 按 particle normalized age 持续求曲线。当前 0→1 曲线会固定在 system/sub-emitter emission time 的单个值,子粒子出生后不再变化。应保留
InheritVelocityModule为 Entity velocity owner:Constant/TwoConstants 可出生时烘焙;Curve/TwoCurves 保存 birth emitter velocity 与 per-particle random,在 child update 路径求值。按公开 update 链路补 Initial Curve/TwoCurves 反向测试,不要增加只服务旧测试的 wrapper 或第二套求值入口。 -
[P1]
ParticleGenerator.ts:603-702/SubEmittersModule.ts:425-438— wrapped shrink 仍只迁移尾段,前段 instance、feedback 与 emission cursor 一起丢失环绕存活区间是
[firstRetiredElement, capacity) + [0, firstFreeElement);shrink 的firstRetiredElement > firstFreeElement分支只 copy/map 第一段。CPU instance、48B feedback 和 Birth runtime state 都机械继承这份不完整区间判断,前段存活粒子会消失或与 cursor 错配。应让
_resizeInstanceBuffer()生成一份覆盖两个 segment 的权威迁移计划,再机械驱动 instance、feedback 和 runtime-state copy,删除三处独立区间 ownership。补 wrap 后收缩maxParticles、断言两段粒子与 emission cursor 都保留的公开回归测试。 -
[P1]
SubEmittersModule.ts:257-268/EmissionModule.ts:245-264— 动态重建 slot state 仍把权威 Rate cursor reset 到 0给存活父粒子新增/替换 Birth slot 时,代码把
previousEmissionParentAge设为当前父龄,却执行state.emission.reset(seed, 0)。下一帧_emitByRateOverTime()用当前 delay-adjusted emission time 减 0,父粒子已活 0.5s、rate=10 时会单帧补发约 5 个不存在的历史样本。应保留
EmissionRuntimeState.frameRateTime为唯一 Rate 游标 owner:采样 start delay 后,把重建 cursor 设为当前 delay-adjusted emission time,non-loop 时 clamp 到 duration。补“给存活父粒子动态添加 Birth slot,下一帧不补历史”的公开反向测试。 -
[P1]
SubEmittersModule.ts:209-238,415-423/ParticleGenerator.ts:1320-1355/ParticleSystemManager.ts:62-90— 稳态仍按父粒子和 emission sample 线性制造 GC每次父粒子 Birth × slot 都新建
BirthSubEmitterRuntimeState、内部EmissionRuntimeState/Rand/Vector,退休后整个 ring slot 置 null;每个 sample 又新建 command 和最多六个 Vector/Color,managerclear()后 per-target array 也失去复用。分配量仍随父粒子出生量和子 sample 数线性增长。应让 ring slot 长期持有并 reset runtime state,让
ParticleSystemManager长期持有可复用 target buffer/command entry,generator 只写值;删除retire -> null -> new、逐 slotnew Rand与逐 samplenew command三条稳态路径。用对象身份、allocation counter 或真实 allocation benchmark证明 warm-up 后分配不再随 sample 数增长。 -
[P2]
particle/index.ts:17/EmissionRuntimeState.ts/RateOverTimeReplay.test.ts:149-164— 内部 cursor 仍为测试扩大公共 APIEmissionRuntimeState的生产 owner 只有 emission/sub-emitter 内部链路,却继续从 public barrel 导出,测试再直接调用_getEmissionSamples()。应删除 public export,把 Burst 边界契约改由 generator/update 的公开行为验证;不要保留 public wrapper、compat export 或第二采样入口喂旧测试。 -
[P2]
ParticleGenerator.ts:1349-1355— 无 manager 的同步直发仍是第二条调度状态机支持路径中的 Birth/Death 都由已注册 renderer 的 scene manager update 产生;同步 fallback 绕过拓扑顺序、命令 owner 和目标 update clock,只服务直接驱动内部
_update或未入 Scene 的非公开用法。应保留ParticleSystemManager为唯一命令调度 owner并删除 fallback,相关测试迁移到 scene/engine update。 -
[P2]
SubEmittersModule.ts:98-130/InheritVelocityModule.ts:11— 剩余 public surface 的 TSDoc 仍不完整addSubEmitter()返回可继续配置inheritVelocity的SubEmitter,但缺少@returns;publicInheritVelocityModule类仍使用单行块,偏离同包公开模块的多行 TSDoc 约定。请补完整多行 TSDoc,并明确返回 slot 是后续配置 per-slot velocity scale 的入口。
架构、熵增与测试治理
上游一层,ParticleSystemManager 应唯一拥有拓扑更新与命令消费,EmissionRuntimeState 应唯一拥有 Rate/Burst/Distance cursor,InheritVelocityModule 应唯一拥有 Entity velocity;下游一层,TF buffer 应唯一拥有父粒子的 world position/trajectory velocity,SubEmittersModule 只把 emission plan 与该事实组装为命令,SubEmitter.inheritVelocity 只配置父轨迹速度缩放,目标 generator 只负责 spawn。
本轮 merge 把新版 clone/DataObject 协议与 #3060 合入,目标分支的 clone hook 和现有 clone 回归测试能覆盖 SubEmitter 配置复制,没有新增 PR-local owner;但它也没有减少本 PR 已有的熵:Death 仍维护 offset 0..5 + rotation 的平行轨迹转换,manager fallback 仍是第二调度路径,public runtime cursor 仍把内部状态暴露给测试,逐粒子 runtime state 与逐 sample command 仍是两套一次性对象生命周期。应保留上述权威 owner,删除这些旧路径,而不是再加同步层、镜像状态或 compatibility branch。
测试方面,完整 CI 已全绿,sparse orbital / Entity motion / Birth direction 与 clone 测试能反向证伪各自修复;但 readback spy 只锁定私有调用次数,Death trajectory/slot curve、Initial Curve/TwoCurves、wrapped shrink、动态重建 backlog、稳态 allocation 均无公开反向测试,RateOverTimeReplay 仍通过 public export 锁定内部 cursor。权威契约切换后应删除失效 fixture/私有测试并按公开链路重写,绝不能为了旧测试保留 compatibility branch、legacy fallback、wrapper、镜像状态或第二条转换/校验路径。
背景
Birth 子发射器此前只在父粒子创建时固定发射一次,目标系统的发射率、距离发射、Burst 与延迟均未按父粒子独立运行。父粒子模拟又发生在 Renderer 准备渲染阶段,子发射结果会受到组件注册顺序和相机裁剪影响;同时父速度只保留了方向,无法表达绕轴、径向及其他模块共同形成的完整轨迹速度。
修改
验证
Summary by CodeRabbit
velocityOverLifetimelinear velocity integration.