Skip to content

fix(particle): make size-over-lifetime TwoCurves random mode work#3060

Open
cptbtptpbcptdtptp wants to merge 1 commit into
dev/2.0from
fix/particle-sol-random-two
Open

fix(particle): make size-over-lifetime TwoCurves random mode work#3060
cptbtptpbcptdtptp wants to merge 1 commit into
dev/2.0from
fix/particle-sol-random-two

Conversation

@cptbtptpbcptdtptp

@cptbtptpbcptdtptp cptbtptpbcptdtptp commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Problem

Size-over-lifetime's TwoCurves (random between two curves) mode has never worked since it was introduced in #1682, due to two stacked bugs:

  1. Operator precedence bug in SizeOverLifetimeModule._updateShaderData: isRandomCurveMode || separateAxes ? A : B parses as (isRandomCurveMode || separateAxes) ? A : B, so isCurveMode is always false when the mode is TwoCurves. Neither RENDERER_SOL_CURVE_MODE nor RENDERER_SOL_IS_RANDOM_TWO ever gets enabled — the module is entirely inert in TwoCurves mode (even the max curve is ignored), and the shader's random-two branch (mix(min, max, a_Random0.z) in SizeOverLifetime.glsl) was unreachable dead code.

  2. Missing per-particle random: instance-buffer slot 21 (a_Random0.z) was reserved for the SOL random factor in Add new particle renderer #1682 (left as a commented-out placeholder in _addNewParticle), and both SizeOverLifetime.glsl and NoiseModule.glsl sample it. Since feat(particle): add NoiseModule for simplex noise turbulence #2953 the CPU write is gated on noise.enabled only, so with noise disabled the factor is always 0 and size would stick to the min curve. The sub-emitter inherit-size CPU path (ParticleGenerator._getParticleColorAndSize) reads the same slot and was equally affected.

Fix

  • Fix the parenthesization so TwoCurves enables the curve-mode + random-two macros and uploads both min and max curves.
  • Add _sizeRand to SizeOverLifetimeModule — the ParticleRandomSubSeeds.SizeOverLifetime sub-seed already existed unused — plus _isRandomMode() / _resetRandomSeed(), registered in _resetGlobalRandSeed.
  • Write slot 21 when SOL is in random mode and noise is disabled. Noise keeps precedence when both are enabled: the instance vertex layout is full (stride 168 B = 42 floats), so the two modules share the slot per the existing shader layout; keeping noise first preserves the existing noise random sequence.

Tests

New tests/src/core/particle/SizeOverLifetime.test.ts with 5 cases: macro enabling + min/max curve upload for TwoCurves, single-Curve regression (curve macro without random-two), per-particle random written to a_Random0.z, slot untouched in non-random modes, and noise precedence on the shared slot (same seed with/without SOL random yields the identical noise value).

Red/green verified: with the source fix reverted, exactly the two targeted cases fail. Full particle suite (155 tests) passes with the fix. All existing e2e cases use single-Curve SOL only, whose macro/upload behavior is unchanged by this patch, so no visual baselines are affected.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved particle size-over-lifetime behavior so random sizing now stays consistent across resets and matches other lifetime effects.
    • Fixed shared particle randomness so size-over-lifetime can use the correct random value when noise is off, while still preserving noise behavior when enabled.
    • Refined shader selection for size-over-lifetime so curve and random modes are applied more reliably.

Two stacked bugs since #1682 left SOL's random-between-two-curves mode
entirely inert: an operator-precedence bug in _updateShaderData kept
RENDERER_SOL_CURVE_MODE / RENDERER_SOL_IS_RANDOM_TWO from ever being
enabled, and the per-particle random factor in instance slot 21
(a_Random0.z) was never written unless the noise module happened to be
enabled (#2953 gated the shared slot's write on noise.enabled only).

Fix the parenthesization, give SizeOverLifetimeModule its own Rand
(using the already-reserved SizeOverLifetime sub-seed), and write the
shared slot when SOL needs it; noise keeps precedence when both are on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

This PR extends SizeOverLifetimeModule with its own Rand-based random seed handling and a new _isRandomMode() helper, refactors shader curve-mode selection accordingly, wires ParticleGenerator to reset this seed and populate the shared instance buffer slot with it when noise is disabled, and adds a dedicated test suite validating the behavior.

Changes

SizeOverLifetime random seed integration

Layer / File(s) Summary
Random seed and mode detection
packages/core/src/particle/modules/SizeOverLifetimeModule.ts
Adds _sizeRand (Rand) field seeded with ParticleRandomSubSeeds.SizeOverLifetime, a new _isRandomMode() helper, a _resetRandomSeed(seed) method, and refactors _updateShaderData curve-mode logic to use _isRandomMode().
Generator wiring
packages/core/src/particle/ParticleGenerator.ts
_resetGlobalRandSeed resets sizeOverLifetime's seed; _addNewParticle writes shared slot 21 (a_Random0.z) from sizeOverLifetime._sizeRand.random() when noise is disabled and SOL is in random mode, otherwise from noise as before.
Test suite
tests/src/core/particle/SizeOverLifetime.test.ts
Adds Vitest tests validating shader macro enablement (curve-mode, random-two), curve data upload, and precedence behavior between noise and SizeOverLifetime random values for the shared a_Random0.z slot.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ParticleGenerator
  participant NoiseModule
  participant SizeOverLifetimeModule

  ParticleGenerator->>ParticleGenerator: _resetGlobalRandSeed(seed)
  ParticleGenerator->>SizeOverLifetimeModule: _resetRandomSeed(seed)
  ParticleGenerator->>ParticleGenerator: _addNewParticle()
  alt noise.enabled
    ParticleGenerator->>NoiseModule: get random strength value
    NoiseModule-->>ParticleGenerator: random value
  else SizeOverLifetimeModule._isRandomMode()
    ParticleGenerator->>SizeOverLifetimeModule: _sizeRand.random()
    SizeOverLifetimeModule-->>ParticleGenerator: random value
  end
  ParticleGenerator->>ParticleGenerator: write value into slot 21 (a_Random0.z)
Loading

Possibly related PRs

  • galacean/engine#2982: Both PRs extend ParticleGenerator._resetGlobalRandSeed to additionally reset another module's random seed (subEmitters vs sizeOverLifetime).
  • galacean/engine#3004: Both PRs modify the same _resetGlobalRandSeed wiring to reset an additional per-module random sub-seed (CustomDataModule vs SizeOverLifetime).

Suggested labels: bug, particle

Suggested reviewers: GuoLei1990

Poem

A rabbit hops through seeds so fine,
Each size now dances in random time. 🐇
Noise steps back when SOL takes lead,
Slot twenty-one gets what it needs.
Tests confirm the values true —
Hop, hop, hooray, the module's new!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main fix: enabling size-over-lifetime TwoCurves random mode for particles.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/particle-sol-random-two

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

❤️ Share

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

@codecov

codecov Bot commented Jul 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.42857% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.44%. Comparing base (721b42a) to head (2ce412e).

Files with missing lines Patch % Lines
...ore/src/particle/modules/SizeOverLifetimeModule.ts 89.65% 3 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           dev/2.0    #3060      +/-   ##
===========================================
+ Coverage    79.37%   79.44%   +0.06%     
===========================================
  Files          903      903              
  Lines       100632   100660      +28     
  Branches     11260    11274      +14     
===========================================
+ Hits         79879    79971      +92     
+ Misses       20569    20505      -64     
  Partials       184      184              
Flag Coverage Δ
unittests 79.44% <91.42%> (+0.06%) ⬆️

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

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

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
tests/src/core/particle/SizeOverLifetime.test.ts (2)

77-189: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Missing coverage for separateAxes + TwoCurves combination.

_isRandomMode() has a distinct branch requiring all three axes to be TwoCurves when separateAxes is true, but no test exercises separateAxes = true with random curves on x/y/z. This branch is part of the fixed precedence logic and would benefit from direct coverage.

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

In `@tests/src/core/particle/SizeOverLifetime.test.ts` around lines 77 - 189, Add
a test in SizeOverLifetime.test.ts that covers the _isRandomMode branch for
separateAxes=true with all three axes set to TwoCurves. Reuse
createParticleRenderer, ParticleCompositeCurve, and the sizeOverLifetime setup,
then enable separateAxes on the SOL module and assign random TwoCurves to x/y/z
so the renderer exercises the distinct precedence path. Verify the expected
SOL_CURVE_MODE_MACRO and SOL_RANDOM_TWO_MACRO behavior and that the random slot
upload occurs as intended, similar to the existing TwoCurves and noise
precedence tests.

20-21: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Avoid hardcoding the particle instance stride here.
ParticleBufferUtils isn’t part of the public API, so the test shouldn’t mirror 42 by hand; use a shared test helper or another source of the instance layout instead.

  • Add a separateAxes = true case so _isRandomMode() covers the all-axes branch.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/src/core/particle/SizeOverLifetime.test.ts` around lines 20 - 21, The
SizeOverLifetime test is hardcoding the particle instance stride and should
instead derive it from a shared test helper or the instance layout used by
particle buffers, so update the setup around FLOAT_STRIDE to avoid mirroring
ParticleBufferUtils internals. Also extend the _isRandomMode() coverage in
SizeOverLifetime by adding a separateAxes = true case so the all-axes branch is
exercised.
🤖 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.

Nitpick comments:
In `@tests/src/core/particle/SizeOverLifetime.test.ts`:
- Around line 77-189: Add a test in SizeOverLifetime.test.ts that covers the
_isRandomMode branch for separateAxes=true with all three axes set to TwoCurves.
Reuse createParticleRenderer, ParticleCompositeCurve, and the sizeOverLifetime
setup, then enable separateAxes on the SOL module and assign random TwoCurves to
x/y/z so the renderer exercises the distinct precedence path. Verify the
expected SOL_CURVE_MODE_MACRO and SOL_RANDOM_TWO_MACRO behavior and that the
random slot upload occurs as intended, similar to the existing TwoCurves and
noise precedence tests.
- Around line 20-21: The SizeOverLifetime test is hardcoding the particle
instance stride and should instead derive it from a shared test helper or the
instance layout used by particle buffers, so update the setup around
FLOAT_STRIDE to avoid mirroring ParticleBufferUtils internals. Also extend the
_isRandomMode() coverage in SizeOverLifetime by adding a separateAxes = true
case so the all-axes branch is exercised.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: f245d376-8f8f-41a4-bdca-ab213fa6d856

📥 Commits

Reviewing files that changed from the base of the PR and between 721b42a and 2ce412e.

📒 Files selected for processing (3)
  • packages/core/src/particle/ParticleGenerator.ts
  • packages/core/src/particle/modules/SizeOverLifetimeModule.ts
  • tests/src/core/particle/SizeOverLifetime.test.ts

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

总结

修复 size-over-lifetime TwoCurves(random between two curves)模式两个叠加的 latent bug,方向正确、落点在根因层、测试是链路级带反向证伪。逐条核对代码后无 P0/P1/P2,可合。

两个 bug 都真实存在且已验证:

  1. 运算符优先级 bugSizeOverLifetimeModule._updateShaderData)—— 基线代码 isRandomCurveMode || separateAxes ? A : B 因优先级解析成 (isRandomCurveMode || separateAxes) ? A : B。手工枚举四种情况确认:非 separate + TwoCurves → isCurveMode = (sizeX===Curve) = false(sizeX 是 TwoCurves);separate + all-TwoCurves → 取 then 分支 all===Curve = false。两种 random 路径下 isCurveMode 恒 false,RENDERER_SOL_CURVE_MODE / RENDERER_SOL_IS_RANDOM_TWO 都不启用,模块自 #1682 起在 TwoCurves 模式完全 inert(连 max 曲线都被忽略),shader 的 mix(min, max, a_Random0.z) 分支是不可达死码。修复 isRandomCurveMode || (separateAxes ? ... : ...) 后四种情况全部正确,等价性已逐一验证。

  2. 缺 per-particle random 写入 —— slot 21(a_Random0.z)自 #2953 起只在 noise.enabled 时写入,noise 关闭时恒为 0,导致 SOL random-two 的 mix 因子恒取 min 曲线。同一 slot 被 _getParticleColorAndSize(sub-emitter inherit-size 的 CPU 路径,ParticleGenerator.ts:1372)读取,同样受影响。

已核实的正确性要点:

  • slot 映射一致Random0 是 byte offset 76 的 Vector4 → float offset 19,.x=19/.y=20/.z=21/.w=22。逐一对照 shader:ColorOverLifetime 用 .y(slot 20)、Noise/SOL 用 .z(slot 21)、RotationOverLifetime 用 .w(slot 22),全部对齐。
  • 共享 slot 优先级是既有 Vector4 布局约束(stride 168B = 42 floats 已满),非本 PR 引入;noise 优先保留既有 noise 随机序列,代码注释与 PR 描述都显式说明了这个取舍——同意这是合理设计,不是 bug。
  • _sizeRand / _resetRandomSeed / _isRandomMode() 模式与 NoiseModule 逐字一致_noiseRand = new Rand(0, ParticleRandomSubSeeds.Noise) + 同形 _resetRandomSeed),ParticleRandomSubSeeds.SizeOverLifetime = 0x591bc05c 原本预留未用,此处正好接上。@ignoreClone 与其它 rand 字段惯例一致。
  • 无 e2e baseline 影响git grep 所有启用 SOL 的 e2e case(force / burst-cycles / rateOverDistance / fire / dream / sub-emitter…)全部用 ParticleCurveMode.Curve 单曲线,_isRandomMode() 返回 false,新 else if 分支不触发、slot 21 保持不变,宏/上传行为零改动。sub-emitter case 走 CPU inherit-size 路径同样单曲线(忽略随机因子),无漂移。
  • 反向证伪成立:revert 修复后,Test 1(isEnable(SOL_CURVE_MODE_MACRO) 断言 true)与 Test 3(verts[21] > 0 断言)恰好各转红,其余 3 个 guard 测试两态皆过——与 PR 描述"exactly the two targeted cases fail"一致。测试从公开 API 输入、断言 macro/buffer 输出,是链路级测试。

问题

  • [P3] tests/src/core/particle/SizeOverLifetime.test.ts — 缺 separateAxes = true + 三轴全 TwoCurves 的用例。_isRandomMode() 的 separate 分支(要求三轴都是 TwoCurves)目前无直接覆盖,与非 separate 分支对称、逻辑简单,补一条更完整(CodeRabbit 已提同一点)。不阻塞。

简化建议

无。fix 精准、无多余抽象,_isRandomMode() 抽取消除了 _updateShaderData_addNewParticle 两处对"是否 random 模式"判断的潜在双源,反而更干净。

@hhhhkrx

hhhhkrx commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

建议这里不要让 SizeOverLifetime 的 random 和 Noise 的 random 共用同一个 a_Random0.z

现在的实现里,Noise 和 SOL TwoCurves 都读 attributes.a_Random0.z,并且两个模块同时开启时 slot 里写的是 noise random。这会导致 SOL 的曲线插值因子和 noise strength 的随机因子完全相关,两个本应独立的模块在视觉分布上被耦合了。

从 Unity / Unreal 的语义看,更常见的是“同一个粒子有可复现 seed”,但不同属性/模块从 seed 派生各自的随机值;只有用户显式创建共享 random attribute 时,多个属性才共用同一个随机因子。这里的共用更像是 instance buffer slot 不够时的实现取舍,不太适合作为默认行为。

建议优先考虑:

  1. 给 SOL TwoCurves 使用独立 random slot;或
  2. 从 particle seed / module sub-seed 派生一个独立 deterministic random,避免占用新的 attribute;或
  3. 如果现阶段必须复用这个 slot,至少在代码/PR 描述里明确这是临时取舍,并补测试覆盖 “Noise + SOL 同开时 SOL 会使用 noise random” 这个耦合行为。

这个点不影响本 PR 修复 “noise 关闭时 SOL random 恒为 0” 的方向,但我建议不要把共用 random 固化成最终设计。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants