Skip to content

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

Open
cptbtptpbcptdtptp wants to merge 2 commits 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 2 commits 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 random sizing to remain consistent after resets.
    • Corrected per-particle shared randomness handling so size-over-lifetime uses the proper random value when noise is disabled, while noise still takes precedence when enabled.
    • Refined size-over-lifetime shader mode selection for more reliable curve vs random behavior.
  • Tests
    • Added comprehensive Vitest coverage for size-over-lifetime shader macros, instance random-slot behavior, noise precedence, and mesh render mode with separate axes.

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

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: c4d5dcb8-ffba-444a-8764-31461cb807c5

📥 Commits

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

⛔ Files ignored due to path filters (1)
  • packages/shader/src/ShaderLibrary/Particle/Module/SizeOverLifetime.glsl is excluded by !**/*.glsl
📒 Files selected for processing (1)
  • tests/src/core/particle/SizeOverLifetime.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/src/core/particle/SizeOverLifetime.test.ts

Walkthrough

This PR adds dedicated SizeOverLifetime random-seed handling, updates shader curve-mode selection, writes SizeOverLifetime random values to the shared particle slot when noise is disabled, and adds deterministic coverage for shader and particle-buffer behavior.

Changes

SizeOverLifetime random seed integration

Layer / File(s) Summary
Random seed and mode detection
packages/core/src/particle/modules/SizeOverLifetimeModule.ts
Adds _sizeRand, _isRandomMode(), and _resetRandomSeed(), and refactors shader curve-mode selection.
Generator wiring
packages/core/src/particle/ParticleGenerator.ts
Resets the SizeOverLifetime seed and writes its random value to shared slot 21 when noise is disabled and random mode is active.
Shader and particle-buffer tests
tests/src/core/particle/SizeOverLifetime.test.ts
Tests shader macros, curve uploads, random-slot values, noise precedence, and separate-axis mesh rendering.

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 update ParticleGenerator._resetGlobalRandSeed to reset module-specific particle random sub-seeds.

Suggested labels: bug, shader

Suggested reviewers: GuoLei1990

Poem

A rabbit hops through seeds so fine,
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 accurately reflects the main change: fixing size-over-lifetime TwoCurves random mode behavior in 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 97.14286% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 79.75%. Comparing base (721b42a) to head (3447163).
⚠️ Report is 3 commits behind head on dev/2.0.

Files with missing lines Patch % Lines
packages/core/src/particle/ParticleGenerator.ts 83.33% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           dev/2.0    #3060      +/-   ##
===========================================
+ Coverage    79.37%   79.75%   +0.37%     
===========================================
  Files          903      904       +1     
  Lines       100632   101275     +643     
  Branches     11260    11385     +125     
===========================================
+ Hits         79879    80770     +891     
+ Misses       20569    20323     -246     
+ Partials       184      182       -2     
Flag Coverage Δ
unittests 79.75% <97.14%> (+0.37%) ⬆️

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

This comment was marked as outdated.

@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 固化成最终设计。

computeParticleSizeMesh was gated on four macros (RENDERER_SOL_CURVE,
RENDERER_SOL_RANDOM_CURVES, RENDERER_SOL_CURVE_SEPARATE,
RENDERER_SOL_RANDOM_CURVES_SEPARATE) that no TS module ever enables —
the random branch even referenced a nonexistent uniform
(u_SOLSizeGradientMax) — so the module was entirely inert for mesh-mode
particles. Rewrite it against the macro set the billboard path and
SizeOverLifetimeModule already use, extended to all three axes.

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

@cptbtptpbcptdtptp cptbtptpbcptdtptp left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

CR 结论

逐条核对了 diff 与既有评审意见。billboard 路径的修复本身验证无误:宏四态枚举正确、slot 21 写入条件与宏启用条件一致(enabled && _isRandomMode())、curveMin 裸调与 Force/Noise/Rotation/Velocity 等模块的既有 GIGO 约定一致、与 #3049 无 instance-slot 交叠、_resetGlobalRandSeed 各子种子独立顺序无关。

新发现:P2 — mesh 渲染模式下 SOL 整体失效(已修,3447163a7)

computeParticleSizeMesh(SizeOverLifetime.glsl)gate 在 RENDERER_SOL_CURVE / RENDERER_SOL_RANDOM_CURVES / RENDERER_SOL_CURVE_SEPARATE / RENDERER_SOL_RANDOM_CURVES_SEPARATE 四个宏上——全仓 TS 侧从未定义过任何一个(SizeOverLifetimeModule 只启用 RENDERER_SOL_CURVE_MODE / IS_SEPARATE / IS_RANDOM_TWO),random 分支还引用了不存在的 uniform u_SOLSizeGradientMax。即 mesh 粒子的 SOL 自 #1682 起完全 inert(不止 TwoCurves,单 Curve 也一样)——与本 PR 修的 billboard 优先级 bug 是同一次移植的另一半遗留。base 里 _evaluateOverLifetime 的 CPU inherit-size 路径按 "shader gates on RENDERER_SOL_CURVE_MODE" 假设工作,mesh 死宏还造成 CPU/GPU 不一致。

修复:按 billboard 路径的宏体系重写 mesh 分支(扩展到三轴)。uniform 声明块与 TS 上传侧本来就齐备(X/Y/Z min/max),纯 shader 侧修复。

Baseline 影响:9 个 mesh e2e case 虽解构了 sizeOverLifetime 变量但没有一个启用它,修复不影响任何现有 e2e baseline。

验证:

  • 新增用例 mesh + separateAxes + 三轴 TwoCurves:宏(CURVE_MODE/IS_RANDOM_TWO/IS_SEPARATE)、三轴 min/max 曲线上传、slot 21 写入全断言通过;粒子套件 157/157 过。
  • 真机像素级验证(examples + WebGL readPixels,双系统对比):SOL 关的对照组粒子横截面恒定 33px;SOL TwoCurves 组尺寸随生命周期 7px→77px 增长且个体间随机抖动,shader 编译零错误。修复前该组与对照组行为相同(恒定)。

对既有意见的处理

  • GuoLei1990 / CodeRabbit 的 P3(缺 separateAxes+TwoCurves 覆盖):新增的 mesh 用例即 separateAxes=true + 三轴 TwoCurves,_isRandomMode() 的 separate 分支已获直接覆盖。
  • CodeRabbit 的 stride 硬编码(Trivial):维持现状——ParticleBufferUtils.instanceVertexFloatStride 非公开 API,42 与 stride 168B 的注释已互为印证,引入访问私有工具类的 helper 收益低于成本。

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

总结

新增 commit 3447163a7("make size-over-lifetime work in mesh render mode")把 computeParticleSizeMesh 从一套没人启用的旧宏(RENDERER_SOL_CURVE / RENDERER_SOL_RANDOM_CURVES / RENDERER_SOL_CURVE_SEPARATE / RENDERER_SOL_RANDOM_CURVES_SEPARATE,且随机分支引用了不存在的 uniform u_SOLSizeGradientMax)重写到 billboard 路径与 SizeOverLifetimeModule 已在用的新宏集(RENDERER_SOL_CURVE_MODE / RENDERER_SOL_IS_RANDOM_TWO / RENDERER_SOL_IS_SEPARATE),并扩到三个轴。方向正确、在本 PR scope 内(首个 commit 修好了 billboard 却漏了 mesh,这次补齐),逐行核对后 shader 逻辑无 P0/P1,可合。

核对要点:

  • 旧 mesh 分支确是死码:全仓(TS/core)无任何模块设置那四个旧宏——只有 base 树上残留的 .glsl 文本引用它们;u_SOLSizeGradientMax 在别处零声明,若那条 RENDERER_SOL_RANDOM_CURVES 分支曾被启用会是编译错误。所以 mesh 模式在 TwoCurves / 甚至普通 Curve 下此前完全 inert,重写是修真 bug 不是重构。
  • 与 billboard 路径逐字对齐:新 mesh 函数结构与 computeParticleSizeBillboard 完全一致,差异仅在 mesh 处理 Z 轴(vec3)而 billboard 只处理 X/Y(vec2)——这是 3D vs 2D 的正确区分。非 separate 分支 size *= lifeSizeX 对 vec3 三分量等比缩放,与旧 RENDERER_SOL_CURVE 的标量广播语义等价。
  • CPU 路径与 bounds 一致_getParticleColorAndSize(sub-emitter inherit-size,ParticleGenerator.ts:1375)separate 模式已按 sizeX/Y/Z.evaluate 逐轴、非 separate 单因子,与新 shader 语义一致;_updateBounds(:1595)separate 模式已 Math.max(sizeX/Y/Z._getMax()) 取每轴最大做保守包围盒,覆盖新的逐轴 mesh 行为。两处均为 base commit 既有代码,本 commit 未动,语义吻合。
  • 宏集与 SizeOverLifetimeModule._updateShaderData 声明的三个宏一一对应;renderer_SOLMaxCurveY/Zrenderer_SOLMinCurveY/Z 仅在 RENDERER_SOL_IS_SEPARATE guard 内声明/使用,无越界。
  • CI 全绿(build 三平台 + e2e 4/4 + lint + codecov)。

问题

  • [P2] tests/src/core/particle/SizeOverLifetime.test.ts:193 — 新测试无法反向证伪本次 shader 修复。它断言的三项——macros(SOL_CURVE_MODE/SOL_IS_RANDOM_TWO/SOL_IS_SEPARATE)、六条 curve 数组、slot 21——全部由 TS 侧 (_updateShaderData / _addNewParticle) 产出,且在 separate 模式下本就正确;把本 commit 的 .glsl 改动整体 revert,这个测试仍全绿。也就是说真正被修的 computeParticleSizeMesh 死宏问题没有任何自动化守护.glsl 在 jsdom 里跑不了、CodeRabbit 也按 !**/*.glsl 排除,而 e2e 里无一 case 组合 mesh 模式 + SOL(10 个 mesh e2e 全部没 sizeOverLifetime.enabled = true,仅从 generator 解构未启用;6 个启用 SOL 的 e2e 全是 billboard/stretched 单 Curve)。建议补一个 mesh + SOL 的 e2e baseline(哪怕单 Curve),让这条 shader 路径真正进回归网——否则下次误改 mesh 宏又会静默 inert。这条不阻塞合并,但 PR 描述里"新增 test 覆盖 mesh separateAxes"的措辞应校准为"覆盖 TS 宏/上传/slot 写入",shader 执行本身仍靠 code review 而非测试保障。

简化建议

无。mesh 与 billboard 现在共享同一套宏和同构写法,双源真相被消除(旧 mesh 那套 4 宏 + 幽灵 uniform 彻底删除),是净收敛。若想更进一步,billboard 与 mesh 两个函数体除 vec2/vec3 与 Z 轴外几乎逐行相同,未来可考虑抽公共 helper,但当前重复度低、GLSL 无泛型,不值得为此引入宏拼接,保持现状即可。

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.

3 participants