Skip to content

feat(particle): support custom particle shaders with custom data#3004

Merged
cptbtptpbcptdtptp merged 29 commits into
galacean:dev/2.0from
hhhhkrx:feat/particle-custom-fragment
Jun 2, 2026
Merged

feat(particle): support custom particle shaders with custom data#3004
cptbtptpbcptdtptp merged 29 commits into
galacean:dev/2.0from
hhhhkrx:feat/particle-custom-fragment

Conversation

@hhhhkrx
Copy link
Copy Markdown
Contributor

@hhhhkrx hhhhkrx commented May 13, 2026

概述

允许用户自定义粒子 shader 覆写 vert / frag,同时复用引擎的粒子模拟;并新增 CustomDataModule,向这些 shader 暴露用户命名的、renderer 级自定义 uniform(同一 draw call 内所有粒子共享)。

改动内容

  • ParticleMaterial 构造函数现在接受一个可选的 Shader,用户可传入通过 Shader.create(...) 构建的 shader,同时保留原有默认行为。
  • Effect/Particle.shader 重构为与 PBR.shader 一致的模式:.shader 文件作为用户可见层,内联声明 vert / frag,调用 include 带入的 helper。include 文件 ShaderLibrary/Particle/ParticleVert.glsl 提供:
    • 所有引擎 uniform / Attributes / Varyings 结构体 / 8 个粒子模块 include
    • 可复用 helper:computeParticleCentercomputeParticleColorcomputeParticleVaryingUV
  • CustomDataModule —— 用户命名的 renderer 级自定义数据流(共享 uniform):
    • addCurve(name, ParticleCompositeCurve) 注册一个标量流。4 种曲线模式(Constant / TwoConstants / Curve / TwoCurves)各自映射到一组 renderer_<name>... uniform(如 float renderer_<name>MaxConstvec2 renderer_<name>MaxGradient[4])。
    • addGradient(name, ParticleCompositeGradient) 注册一个颜色流。支持全部 4 种渐变模式(Constant / TwoConstants / Gradient / TwoGradients),uniform 命名约定相同。完整 uniform 表见方法 TSDoc。
    • 可注册任意数量的流;用户声明的 name 成为 uniform 名的一部分,用户 shader 直接按名读取,无需代码生成步骤。
    • removeCurve(name) / removeGradient(name) 会将对应 uniform 清零。
    • 作用范围 —— 这些是共享 uniform,不是真正的逐粒子数据。 每个 renderer_<name>... uniform 在一次 draw call 内只有一个值,所有粒子 instance 读到的都是同一份数据。只有当用户 shader 用它已有的逐粒子输入(normalizedAgea_Random0 等)去采样时,才会出现逐粒子差异 —— 即“随生命周期 / 随机参数化”,与 ColorOverLifetimeModule 同一档次。这不等同于 Unity 的 ParticleSystem.SetCustomParticleData(后者把任意值写入逐粒子 instance 流);这里没有逐 instance 的顶点属性通道。引擎刻意不提供 sampleParticleCustom_<Name> helper —— 用户 shader 直接读 uniform。
  • E2E 用例 particleRenderer-customShader:注册 addGradient("Tint", …)addCurve("OffsetX", …),然后写一个自定义粒子 shader 直接读 renderer_TintMaxConst(颜色 tint)和 renderer_OffsetXMaxConst(位置偏移)—— 两者均为 Constant 模式,因此 tint/offset 对所有粒子一致(该用例验证的是 uniform 链路的往返,而非逐粒子差异)。验证 TS 端 customData.addX(name, …)_updateShaderData → GPU uniform → shader 读取的端到端链路。

用户如何自定义粒子 shader

复制 Effect/Particle.shader 作为起点,编辑内联的 vert / frag

// TS 侧
customData.enabled = true;
customData.addGradient("Tint",    new ParticleCompositeGradient(new Color(1, 0.3, 0.1, 1)));
customData.addCurve   ("OffsetX", new ParticleCompositeCurve(0.5));
// Shader 侧 —— uniform 按用户选定的流名命名
uniform vec4  renderer_TintMaxConst;
uniform float renderer_OffsetXMaxConst;

Varyings vert(Attributes attr) {
    Varyings v;
    float age = renderer_CurrentTime - attr.a_DirectionTime.w;
    float normalizedAge = age / attr.a_ShapePositionStartLifeTime.w;
    if (normalizedAge >= 0.0 && normalizedAge < 1.0) {
        vec3 center = computeParticleCenter(attr, age, normalizedAge, v);
        center.x += renderer_OffsetXMaxConst;                          // 自定义运动
        gl_Position = camera_ProjMat * camera_ViewMat * vec4(center, 1.0);
        v.v_Color = computeParticleColor(attr, attr.a_StartColor, normalizedAge);
        v.v_Color.rgb *= renderer_TintMaxConst.rgb;                    // 自定义 tint
    } else {
        gl_Position = vec4(2.0, 2.0, 2.0, 1.0);
    }
    return v;
}

测试计划

  • packages/coree2etsc 通过
  • npm run precompile 在新 include 布局下干净产出所有 shader
  • tests/src/core/particle/ —— 107/107 单测通过(含 14 个 CustomData.test.ts 用例,覆盖 add/remove、name 校验、深拷贝、全部 4 种曲线模式、全部 4 种渐变模式、uniform 清零)
  • E2E Particle.customShader 产出预期的橙色 tint、右移粒子
  • 其余 Particle.* e2e 用例的视觉回归(CI 上跑)

- ParticleMaterial accepts an optional user-built Shader
- Split Effect/Particle.shader into a thin top-level shader and a
  ShaderLibrary/Particle/ParticleVert.glsl include exposing helpers:
  computeParticleCenter, computeParticleColor, computeParticleVaryingUV
- Add CustomDataModule with two per-particle vec4 streams; modes:
  Constant / TwoConstants / Curve / TwoCurves (per-particle random
  factors derived from birth-time hash)
- Add ShaderLibrary/Particle/Module/CustomData.glsl exposing
  sampleParticleCustomData0 / sampleParticleCustomData1 helpers
- Add e2e case verifying TS -> uniform -> shader round-trip
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 13, 2026

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

Adds CustomDataModule exposing two per-particle vec4 streams (data0, data1); wires it into ParticleGenerator and shaders; updates shader library and compiled metadata; adds unit tests and an E2E custom-shader test with config.

Changes

Particle Custom Data Streams

Layer / File(s) Summary
Data structures and public API
packages/core/src/particle/modules/CustomDataModule.ts, packages/core/src/particle/enums/ParticleRandomSubSeeds.ts, packages/core/src/particle/ParticleMaterial.ts, packages/core/src/particle/index.ts
Adds CustomDataStream, new random sub-seed constants, makes ParticleMaterial accept an optional shader parameter, and re-exports the new types.
ParticleGenerator wiring
packages/core/src/particle/ParticleGenerator.ts
Adds readonly customData: CustomDataModule, initializes it in the constructor, and invokes its _updateShaderData and _resetRandomSeed at the appropriate lifecycle points.
CustomDataModule implementation
packages/core/src/particle/modules/CustomDataModule.ts
Implements CustomDataModule with data0/data1 streams, shader macro/property bindings, per-stream upload logic for constant or curve modes, component-mode validation, and per-stream random seed handling.
Shader library & compiled shader
packages/shader/src/ShaderLibrary/index.ts, packages/shader/compiledShaders/Effect/Particle.shaderc
Registers Particle_Module_CustomData and Particle_ParticleVert in shader library and regenerates Effect/Particle.shaderc instruction metadata.
E2E test and config
e2e/case/particleRenderer-customShader.ts, e2e/config.ts
Adds an E2E test that compiles an inline custom shader using renderer_CustomData* inputs, configures generator constants for tint/offset, runs the simulation, captures a screenshot, and registers the test in config.
Unit tests
tests/src/core/particle/CustomData.test.ts
Adds vitest coverage validating default state, enable/disable, constant/two-constants/curve/two-curves modes, mixed-mode validation, seed reset, and runtime update behavior.

Sequence Diagram

sequenceDiagram
  participant Test as E2E Test
  participant Engine as Engine
  participant Material as ParticleMaterial
  participant Generator as ParticleGenerator
  participant CustomData as CustomDataModule
  participant ShaderData as ShaderData
  Test->>Engine: create engine with ShaderCompiler
  Test->>Material: new ParticleMaterial(engine, compiledCustomShader)
  Test->>Generator: configure generator and enable customData streams
  Test->>Generator: set customData constants for tint and offset
  Generator->>CustomData: _updateShaderData(shaderData)
  activate CustomData
  CustomData->>ShaderData: upload data0 constants/gradients
  CustomData->>ShaderData: upload data1 constants/gradients
  CustomData->>ShaderData: enable mode macros
  deactivate CustomData
  Test->>Engine: updateForE2E (simulate frames)
  Engine->>Material: render particles with custom shader
  Test->>Test: initScreenshot (capture output)
Loading

🎯 3 (Moderate) | ⏱️ ~25 minutes

🐰 I stitched two vec4 streams so bright,
Constants and curves leap into light,
Shaders sip colors, offsets take flight,
Particles shimmer through the night.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding support for custom particle shaders with a custom data module for per-particle data.
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

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

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

❤️ Share

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

@codecov
Copy link
Copy Markdown

codecov Bot commented May 13, 2026

Codecov Report

❌ Patch coverage is 87.36842% with 48 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.52%. Comparing base (0d29d9a) to head (79413a3).
⚠️ Report is 2 commits behind head on dev/2.0.

Files with missing lines Patch % Lines
e2e/case/particleRenderer-customShader.ts 0.00% 40 Missing and 1 partial ⚠️
e2e/config.ts 0.00% 6 Missing ⚠️
packages/core/src/particle/ParticleMaterial.ts 75.00% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           dev/2.0    #3004      +/-   ##
===========================================
- Coverage    78.43%   77.52%   -0.92%     
===========================================
  Files          903      914      +11     
  Lines        99951   101723    +1772     
  Branches     10314    10395      +81     
===========================================
+ Hits         78400    78863     +463     
- Misses       21376    22676    +1300     
- Partials       175      184       +9     
Flag Coverage Δ
unittests 77.52% <87.36%> (-0.92%) ⬇️

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

☔ View full report in Codecov by Sentry.
📢 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.

CI runner showed 1.296% diff against a baseline captured locally —
expected platform-driven AA / float-precision variance for an
untextured large-quad particle case. Bumping diffPercentage to 1.5
with the same headroom precedent as particleFire / horizontalBillboard.
@hhhhkrx hhhhkrx self-assigned this May 13, 2026
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.

hhhhkrx added 2 commits May 18, 2026 16:48
…om-fragment

# Conflicts:
#	packages/shader/compiledShaders/Effect/Particle.shaderc
Covers CustomDataStream defaults, enabled toggle, constant/two-constants/
curve/two-curves upload paths, mixed-mode error, random-seed reset and
engine-update integration. Raises codecov patch coverage for the new
custom data module above target. Also regenerates Particle.shaderc after
the dev/2.0 merge.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

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

86-150: ⚡ Quick win

Strengthen assertions: current tests verify no-throw, not data correctness.

These mode tests pass even if uniforms are wrong but no exception is thrown. Assert uploaded shader values/macros for each mode to catch functional regressions in _updateShaderData.

🤖 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/CustomData.test.ts` around lines 86 - 150, Tests
currently only assert _updateShaderData doesn't throw; strengthen them to verify
particleRenderer.shaderData actually received the correct macros and uniform
values for each mode. After calling
customData._updateShaderData(particleRenderer.shaderData) in each it block,
assert that shaderData reflects customData.enabled and the proper CUSTOMDATA
mode flag (reference: customData.enabled and _updateShaderData), then check the
uploaded uniform arrays/values for data0 and data1 match the expected constants
or curve samples derived from the ParticleCompositeCurve/ParticleCurve/CurveKey
inputs (use the ParticleCompositeCurve instances you construct to compute
expected numbers for constant, two-constant ranges, curve samples, and min/max
samples for two-curves). Use particleRenderer.shaderData (the same object passed
in) to read back macro flags and uniform buffers and assert equality to catch
regressions.
🤖 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 `@tests/src/core/particle/CustomData.test.ts`:
- Around line 23-40: The test's beforeAll creates and runs a real WebGLEngine
but never tears it down; add an afterAll that stops and destroys the engine to
prevent resource leakage by calling engine.run(false)/engine.stop if applicable
and await engine.destroy() (or engine.destroy?.() if optional), then
null/undefined the engine reference and any created canvas; target the engine
variable created in beforeAll and the WebGLEngine.create usage so the cleanup
runs after the suite.
- Around line 193-199: Save the original performance.now and any engine-internal
fields you mutate before the test, then restore them after the loop (preferably
in a finally block or test afterEach). Specifically, capture the original
performance.now reference, and any engine properties changed around where
engine.update() is called (e.g., the engine timing/state fields you modified),
run the performance.now override and the for-loop of engine.update(), and then
restore performance.now and those saved engine fields to their originals so
later tests aren’t affected.

---

Nitpick comments:
In `@tests/src/core/particle/CustomData.test.ts`:
- Around line 86-150: Tests currently only assert _updateShaderData doesn't
throw; strengthen them to verify particleRenderer.shaderData actually received
the correct macros and uniform values for each mode. After calling
customData._updateShaderData(particleRenderer.shaderData) in each it block,
assert that shaderData reflects customData.enabled and the proper CUSTOMDATA
mode flag (reference: customData.enabled and _updateShaderData), then check the
uploaded uniform arrays/values for data0 and data1 match the expected constants
or curve samples derived from the ParticleCompositeCurve/ParticleCurve/CurveKey
inputs (use the ParticleCompositeCurve instances you construct to compute
expected numbers for constant, two-constant ranges, curve samples, and min/max
samples for two-curves). Use particleRenderer.shaderData (the same object passed
in) to read back macro flags and uniform buffers and assert equality to catch
regressions.
🪄 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: 374961bc-6381-4b7d-bf93-4b4d2bb2e938

📥 Commits

Reviewing files that changed from the base of the PR and between ada8712 and 4e2f916.

📒 Files selected for processing (2)
  • packages/shader/compiledShaders/Effect/Particle.shaderc
  • tests/src/core/particle/CustomData.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/shader/compiledShaders/Effect/Particle.shaderc

Comment thread tests/src/core/particle/CustomData.test.ts
Comment thread tests/src/core/particle/CustomData.test.ts Outdated
GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

…onfig

`rootEntity.createChild()` invokes `ParticleRenderer._onEnable` synchronously,
which calls `generator.play()` while `useAutoRandomSeed` is still its default
`true`. The `Math.random()` seed picked then sticks for the rest of the run
and defeats deterministic screenshot capture, forcing a 1.5% diff headroom.

Switch to the detached-entity pattern (cf. `particleRenderer-emissive`):
create with `new Entity(...)`, configure the generator, then `addChild`. The
lifecycle hook now fires after `useAutoRandomSeed = false` is in place, so
the run is reproducible and `diffPercentage` can drop to 0 — CI will report
the residual platform variance and we tighten the threshold to match.
GuoLei1990

This comment was marked as outdated.

…d value

CI on ubuntu reports a deterministic 1.31895833333% visual regression
against the baseline (captured locally) across all 3 retries — the residual
is platform AA / float-precision drift, not nondeterminism, now that
useAutoRandomSeed is in effect. Set the threshold to 1.3191 (CI value
+ 0.0001 margin), which is meaningfully tighter than the prior 1.5
headroom and will catch any real regression beyond platform variance.
GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

…istic run

The original baseline was captured when `useAutoRandomSeed = true` was
still in effect — `Math.random()` had picked some non-reproducible seed
at capture time. After the lifecycle fix the generator runs from the
deterministic default seed `0`, so the particle layout is now stable
but unrelated to the prior baseline (≈1.32% pixel delta — not platform
AA, an entirely different particle distribution).

Replace the baseline with the CI-captured frame from commit b451e80
so the truth source matches the deterministic run, then drop
diffPercentage back to 0. Same headroom (and same enforcement strength)
as other simple particle cases like particleEmissive / shapeTransform.
GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

- removeCurve / removeGradient now use Array.findIndex for the swap-remove
  lookup (cold path; the explicit for-loop bought nothing here).
- New Gradient/TwoGradients positive test shares one gradient fixture between
  the single-mode and two-mode streams, drops redundant mode-eq and
  not-to-throw wrappers, and asserts only the meaningful KeysCount axes.
Copy link
Copy Markdown
Member

@GuoLei1990 GuoLei1990 left a comment

Choose a reason for hiding this comment

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

增量审查(2026-06-01,终轮)

两个新 commit(339606f1 / 42df9ff)把上一轮 review 的 P1 + 全部 P2/P3 落实了,逐条核对均正确。

已关闭问题汇总

级别 问题 状态
P1 补全 Gradient / TwoGradients 模式(API 形态承诺 4 mode,实现只 2 mode) ✅ 已实现(339606f1
P2 hot path for...in → 数组 for(_curveStreams/_gradientStreamsT[] + index loop) ✅ 已实现
P2 setColor 直接传 Color,删 Vector4 cache ✅ 已实现
P3 删空 constructor + 多余 import ✅ 已实现(b9a8b90
P3 _cloneTo 简化(@ignoreClone 全字段 + for...in + 删防御性 reset) ✅ 已实现
P3 命名/注释清理(…StreamMeta…Stream、Register→Add、error 人话化) ✅ 已实现
P3 _validateName 错误消息与正则不一致 ✅ 已修(统一为"letters, digits, or underscores")
P1 ghost data(remove 不清零 ShaderData) ✅ 前轮已修,本轮 remove 路径扩展到清 7 个 gradient slot

核对要点(逐项验证通过)

  1. Gradient/TwoGradients KeysCount 打包顺序ColorOverLifetimeModule._updateShaderData:72-77 字符级一致——(colorMin, alphaMin, colorMax, alphaMax) 最后一个 key 的 time,xy=min / zw=max。新增测试 g4.y(min keys)、g4.z(max keys) 断言正确锁住这条契约。
  2. 零缓冲尺寸对齐_getColorTypeArray() 返回 Float32Array(16) 对应 _zeroGradientColorArray = Float32Array(16)_getAlphaTypeArray() 返回 Float32Array(8) 对应复用的 _zeroCurveArray = Float32Array(8)removeGradient 清 7 个 slot(MaxConst/MinConst 用 setColor(zeroColor) + 4 个 gradient array + KeysCount vec4),与 add 时注册的 ShaderProperty 一一对应。
  3. ShaderProperty 类型一致性:每个 prop handle 全程单一类型——gradient 的 const slot 始终走 setColor(add + remove),gradient array 始终走 setFloatArray,无 setVector4/setColor 在同一 prop 上混用。curve 与 gradient 跨容器同名被 _validateName 拦截,不会出现 renderer_FooMaxConst 既是 float 又是 Color。
  4. 设计取舍正确:CustomDataModule 不像 ColorOverLifetimeModule 用 macro 区分 Gradient/TwoGradients——因为用户自写 shader、自行声明并消费 uniform,无需引擎管理编译变种。与本 PR 整体「用户写 shader 读 renderer_<name><suffix>」的设计一致。
  5. CI:build / 4×e2e / lint / codecov-patch 全绿,diffPercentage: 0 实测通过;仅 codecov/project(全项目覆盖率阈值)fail,非本 patch 问题,不阻塞。

遗留 Minor(不阻塞,历史多轮提及,可后续独立处理)

  • _validateName 未拦 renderer_/material_ 等引擎保留前缀——addCurve("renderer_Foo", …) 拼出 renderer_renderer_FooMaxConst,不破坏内置 uniform 但调试混淆。
  • curve.mode / gradient.mode 运行时热切换缺测试。注:热切换后旧 mode 的 slot 会残留 stale 值(如 Curve→Constant 后 propMaxGradient 数组未清),但用户自写 shader 按当前 mode 读对应 uniform,stale slot 不被读取——属设计后果而非 bug,与引擎其他模块靠 macro 切换的机制等价(这里把读哪个 uniform 的责任交给了用户 shader)。补一条测试守住 upload 分支即可。

总结

上一轮唯一阻塞项(Gradient/TwoGradients 补全)已实现且与引擎 precedent 字符级对齐,配套正向测试。热路径数组化、setColor 简化、_cloneTo 简化、命名清理全部落实,无新问题。可合入。 两个 Minor 不阻塞。

Copy link
Copy Markdown
Member

@GuoLei1990 GuoLei1990 left a comment

Choose a reason for hiding this comment

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

最新两轮 polish(339606f1 + 42df9ffd + c78ab57 + b9a8b90)大方向都对了 —— 数组化 / @ignoreClone / setColor / 4 模式补全 / Add/Remove 命名 / _cloneTo 简化 / 删 pure-forwarding constructor,都到位了 👍

不过在"再精简"的过程中有几处走过头,列一下:


P1 — _uploadCurveStream / _uploadGradientStream 签名退步

c78ab57 删掉了 CurveStream.curve / GradientStream.gradient 字段,调用点改成:

for (let i = 0, n = curveStreams.length; i < n; i++) {
  const stream = curveStreams[i];
  this._uploadCurveStream(shaderData, this._curves[stream.name], stream);
  //                                  ^^^^^^^^^^^^^^^^^^^^^^^^^
  //                                  每 stream 一次 Record lookup, 每帧 N 次
}

三个问题:

  1. hot-path Record lookup:stream 数组化本来就是为了消除 for...in 的 Record 迭代开销,现在又把 lookup 搬回 callback 里 —— 自我抵消。
  2. 语义拆分uploadCurveStream(shaderData, curve, stream) 把"必然配对的两个对象"硬拆成两个参数,调用点要读两眼才能理解 curve 是怎么对上 stream 的。
  3. "canonical source" 是伪问题:让 stream 持有 curve 引用(不复制)就行,customData.curves["foo"].constantMax = 1 修改的对象就是 stream.curve 指向的对象,单一真相源已经成立。

建议改回:

interface CurveStream {
  name: string;
  curve: ParticleCompositeCurve;   // 持有引用,不复制
  propMaxConst: ShaderProperty;
  ...
}

// _updateShaderData (热路径无 Record 查询)
for (let i = 0, n = curveStreams.length; i < n; i++) {
  this._uploadCurveStream(shaderData, curveStreams[i]);
}

private _uploadCurveStream(shaderData: ShaderData, stream: CurveStream): void {
  const { curve } = stream;
  ...
}

GradientStream 同样恢复 gradient 字段。


P1 — removeCurve/removeGradientfindIndex 引入项目风格分裂

42df9ffd 改成:

const idx = streams.findIndex((s) => s.name === name);
if (idx < 0) return;

commit message 说 cold path 不需要 explicit loop。但 grep 整个 packages/core/src/particle/

  • 0 处 findIndex
  • 20+ 处 explicit for (let i = 0, n = arr.length; i < n; i++)

ParticleGenerator / EmissionModule / ParticleCurve / ParticleGradient 全部 explicit loop。cold path 不是引入异类风格的理由,项目一致性优先级更高。

建议改回:

removeCurve(name: string): void {
  const streams = this._curveStreams;
  let idx = -1;
  for (let i = 0, n = streams.length; i < n; i++) {
    if (streams[i].name === name) {
      idx = i;
      break;
    }
  }
  if (idx < 0) {
    return;
  }
  ...
}

P2 — if (idx < 0) return; 单行没花括号

if (idx < 0) return;

Galacean 整个 codebase 的 if/for/while/else 单行 body 都带花括号 —— 不要省。按上面 P1 改回 explicit for-loop 后这条自然消失。


P2 — removeGradient 内部局部 alias + 删 _zeroGradientAlphaArray 静态字段

42df9ffd 删掉了 _zeroGradientAlphaArray 静态字段,复用 _zeroCurveArray(都是 Float32Array(8)),然后在 removeGradient 里起局部 alias 来"恢复语义":

const zeroColor = CustomDataModule._zeroColor;
const zeroAlphaArray = CustomDataModule._zeroCurveArray;   // ← 借 curve buffer
const zeroColorArray = CustomDataModule._zeroGradientColorArray;
shaderData.setColor(stream.propMaxConst, zeroColor);
...

三个问题:

  1. cold path 又 alias 静态字段 —— 跟"cold path 不需要 explicit loop"的逻辑自相矛盾。
  2. 静态字段层失去 alpha 语义 —— _zeroCurveArray 在 gradient 上下文中读起来像"curve 的,不是 alpha 的",读者会怀疑"是不是该有专门的 alpha buffer,作者偷懒复用了?"
  3. 局部 alias 不解决问题反而加深疑惑 —— zeroAlphaArray = _zeroCurveArray 让上面的"复用"看起来像 patch,而不是设计。

建议恢复 _zeroGradientAlphaArray 静态字段(即便底层是 Float32Array(8)_zeroCurveArray 同形态,语义独立),删掉所有局部 alias:

private static readonly _zeroGradientAlphaArray = new Float32Array(8);

removeGradient(name: string): void {
  ...
  shaderData.setColor(stream.propMaxConst, CustomDataModule._zeroColor);
  shaderData.setColor(stream.propMinConst, CustomDataModule._zeroColor);
  shaderData.setFloatArray(stream.propMaxGradientColor, CustomDataModule._zeroGradientColorArray);
  shaderData.setFloatArray(stream.propMaxGradientAlpha, CustomDataModule._zeroGradientAlphaArray);
  shaderData.setFloatArray(stream.propMinGradientColor, CustomDataModule._zeroGradientColorArray);
  shaderData.setFloatArray(stream.propMinGradientAlpha, CustomDataModule._zeroGradientAlphaArray);
  shaderData.setVector4(stream.propKeysCount, CustomDataModule._zeroVector4);
  ...
}

P3 — 同文件 /** @internal */ 单行 vs 多行不一致

/** @internal */
_cloneTo(target: CustomDataModule): void {

/**
 * @internal
 */
_updateShaderData(shaderData: ShaderData): void {

同文件两种写法。统一一下 —— 短的更紧凑,建议两个都 /** @internal */


P3 — _zeroVector4 = new Vector4() 隐式默认 vs _zeroColor = new Color(0, 0, 0, 0) 显式不统一

private static readonly _zeroColor = new Color(0, 0, 0, 0);
private static readonly _zeroVector4 = new Vector4();

统一显式更好(reader 不用查 Vector4 ctor 默认是不是 0):

private static readonly _zeroVector4 = new Vector4(0, 0, 0, 0);

小结

# 优先级 关联 commit
_uploadCurveStream/_uploadGradientStream 签名 + Record lookup P1 c78ab57
findIndex 引入项目风格分裂 P1 42df9ff
单行 if return 没花括号 P2 42df9ff
局部 alias + 删 _zeroGradientAlphaArray P2 42df9ff
同文件 /** @internal */ 单行 / 多行不统一 P3 339606f
_zeroVector4 隐式 / _zeroColor 显式不统一 P3 339606f

P1 两条建议改,P2/P3 看你判断。其他大方向都已到位 🚀

hhhhkrx added 3 commits June 1, 2026 15:09
…explicit for

Two reversals from the latest review:

1. CurveStream/GradientStream regain a `curve`/`gradient` field that holds a
   reference (not a copy) to the entry stored in `_curves`/`_gradients`. The
   "dual reference" worry from c78ab57 was a non-issue: the field is the same
   object as the Record entry, so user mutations like
   `customData.curves["foo"].constantMax = 1` are visible through both. The
   hot path now reads the curve straight off the stream, no per-frame Record
   lookup — which is what array iteration was for in the first place.

2. removeCurve / removeGradient revert to the explicit
   `for (let i = 0, n = arr.length; i < n; i++)` lookup. Cold path or not,
   `findIndex` is the only such use across packages/core/src/particle (vs.
   16 explicit-loop sites), and project consistency outweighs the brevity
   win here.
- `new Vector4()` → `new Vector4(0, 0, 0, 0)` so the reader doesn't have to
  remember the ctor default.
- Restore a dedicated `_zeroGradientAlphaArray` static (same shape as
  `_zeroCurveArray` but semantically distinct: gradient alphas, not curve
  values). The local-alias trick in `removeGradient` is gone — the call sites
  now reference the statics directly.
Copy link
Copy Markdown
Member

@GuoLei1990 GuoLei1990 left a comment

Choose a reason for hiding this comment

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

增量审查(2026-06-01,终轮)

最新 commit 0152a73d6 把上一轮我提的两条 P1 都按建议落地了,逐条核对正确。

已关闭问题汇总

级别 问题 状态
P1 _uploadCurveStream/_uploadGradientStream 签名退步 + 热路径 Record lookup ✅ 已修(CurveStream/GradientStream 恢复 curve/gradient 引用字段,upload 函数只收 stream,热路径零 Record 查询)
P1 removeCurve/removeGradientfindIndex 引入项目风格分裂 ✅ 已修(恢复 explicit for (let i = 0, n; i < n; i++)
P2 单行 if (idx < 0) return; 没花括号 ✅ 已修(随 for-loop 恢复,现为 if (idx < 0) { return; }

核对要点(逐项验证通过)

  1. 单一真相源成立addCurve push 的 stream curve 字段与 _curves[name] 是同一对象引用(不复制),用户 customData.curves["foo"].constantMax = 1 改的就是 stream.curve 指向的对象,无双源漂移。commit message 的判断正确。
  2. clone 链路无回归_cloneTotarget.addCurve(name, clonedCurve)target._curves[name] 与新 stream 的 curve 指向同一 cloned 对象,仍是单源。revert 没有重新引入 dual-reference bug。
  3. 零缓冲尺寸对齐_getTypeArray()Float32Array(8) 对应 removeCurvepropMaxGradient/propMinGradient 用的 _zeroCurveArray(8)_getColorTypeArray()Float32Array(16) 对应 _zeroGradientColorArray(16)_getAlphaTypeArray()Float32Array(8) 对应复用的 _zeroCurveArray(8)。remove 清 7 个 gradient slot 与 add 注册的 ShaderProperty 一一对应。
  4. CI:build / 4×e2e / lint 全绿,codecov 非阻塞。

遗留(上一轮 P2/P3,作者本轮 scope 在两条 P1,未处理;不阻塞)

  • [P2] removeGradient 内局部 alias(zeroAlphaArray = _zeroCurveArray)+ 未恢复 _zeroGradientAlphaArray 静态字段——上轮已说明语义独立性考虑,作者酌情,不阻塞。
  • [P3] _cloneTo/** @internal */ 单行 vs _updateShaderData 多行不统一;_zeroVector4 = new Vector4() 隐式 vs _zeroColor = new Color(0,0,0,0) 显式不统一。纯风格 polish。

总结

两条 P1 已按建议正确落地,clone 链路、零缓冲尺寸、ShaderProperty 类型一致性全部复核通过,无新问题。可合入。 遗留三条 P2/P3 纯 polish,不阻塞。

Copy link
Copy Markdown
Member

@GuoLei1990 GuoLei1990 left a comment

Choose a reason for hiding this comment

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

增量审查(2026-06-01,终轮)

两个新 commit(d53d6917b + 3d4b08ca3)把上一轮我提的 1 条 P2 + 2 条 P3 全部按建议落地,逐条核对正确。本轮范围仅 CustomDataModule.ts 11/11 行改动,无其他文件变更。

已关闭问题汇总

级别 问题 状态
P2 removeGradient 局部 alias + 删 _zeroGradientAlphaArray 静态字段 ✅ 已修(3d4b08ca3,恢复 _zeroGradientAlphaArray = Float32Array(8),删全部局部 alias,直接引用静态字段)
P3 同文件 /** @internal */ 单行 vs 多行不统一 ✅ 已修(d53d6917b_cloneTo_updateShaderData 统一为多行块)
P3 _zeroVector4 = new Vector4() 隐式 vs _zeroColor 显式不统一 ✅ 已修(3d4b08ca3,改为 new Vector4(0, 0, 0, 0) 显式)

(更早各轮:upload 签名/热路径 Record lookup、findIndex 风格分裂、单行 if 花括号、ghost data、Gradient/TwoGradients 补全、命名/TSDoc/clone/e2e 阈值——均已在历史轮次关闭,不再列出。)

核对要点(逐项独立验证通过)

  1. 零缓冲尺寸与 ShaderProperty 类型一一对齐(本轮重点,已读 ParticleCurve/ParticleGradient 实现确认):
    • _getTypeArray()Float32Array(4*2)=8removeCurvepropMaxGradient/propMinGradient_zeroCurveArray(8)
    • _getColorTypeArray()Float32Array(4*4)=16removeGradient*GradientColor_zeroGradientColorArray(16)
    • _getAlphaTypeArray()Float32Array(4*2)=8removeGradient*GradientAlpha 用恢复后的 _zeroGradientAlphaArray(8)
    • propKeysCount(vec4) ↔ _zeroVector4propMaxConst/propMinConst(Color) ↔ _zeroColor
    • remove 清的 slot 与 add 注册的 ShaderProperty 完全对应,无遗漏、无类型混用。
  2. _zeroGradientAlphaArray 语义独立性成立:虽与 _zeroCurveArray 同为 Float32Array(8),但 gradient alpha 上下文下独立命名读起来不再像"借 curve buffer",符合上轮建议意图。
  3. @internal 统一方向:选了多行块(与我建议的"统一为单行"相反),但同文件内部一致即可,无异议。
  4. CI:build(3 OS)/ 4×e2e / lint / codecov-patch 全绿;仅 codecov/project(全项目覆盖率阈值)fail,非本 patch 问题,不阻塞。

遗留 Minor(不阻塞,历史多轮已说明,仅备查)

  • _validateName 未拦 renderer_/material_ 等引擎保留前缀——不破坏内置 uniform,仅调试混淆。
  • curve.mode / gradient.mode 运行时热切换缺测试——热切换后旧 mode slot 残留 stale 值属设计后果而非 bug(用户自写 shader 按当前 mode 读对应 uniform,stale slot 不被读取),与引擎其他模块靠 macro 切换等价。

一条新增的轻微观察(P3,不阻塞)

  • PR 描述与实现已不一致 — PR body 描述的是早期"两路固定 data0/data1 vec4 流"设计,但当前实现是 addCurve/addGradient 任意命名流设计。代码与 class TSDoc("any number of named per-particle data channels")一致且正确,是 PR 描述文本过时。合入前顺手把 PR 描述更新为当前 API 形态即可,避免 changelog/读者误解。不影响代码。

总结

上一轮 1×P2 + 2×P3 已全部正确落地,零缓冲尺寸/类型对齐、clone 链路、ShaderProperty 一致性复核通过,无新代码问题。可合入。 仅建议合入前修正过时的 PR 描述文本。

@cptbtptpbcptdtptp
Copy link
Copy Markdown
Collaborator

关于 CustomDataModule 的 "per-particle" 语义

PR 描述写的是 "feeding per-particle business data" / "user-named per-particle data streams",但实现上 CustomDataModule 上传的全部是 uniformrenderer_<name>MaxConstrenderer_<name>MaxGradient[4] 等),同一个 draw call 里所有粒子 instance 读到的是同一份数据。模块本身不产生任何逐粒子差异

是否体现 per-particle,完全取决于用户 shader 怎么采样:

  • e2e 用例 particleRenderer-customShader 用的是 Constant 模式 + 直接 center.x += renderer_OffsetXMaxConst / v.v_Color.rgb *= renderer_TintMaxConst.rgb所有粒子同值,没有任何逐粒子差异 —— 这恰好与 "per-particle" 的描述相矛盾。
  • 只有当用户自己用逐粒子的 normalizedAge / a_Random0 去采样(evaluateParticleCurve(renderer_<name>MaxGradient, normalizedAge)mix(min, max, a_Random0.x)),才会有随生命周期 / 随机的变化;而引擎并没有提供对应的采样 helper(早期 commit 里的 sampleParticleCustom_<Name> auto helper 在后续 review 中已移除,改为用户裸读 uniform)。

即便如此,这也只是「参数化的 per-particle」(随 age / random 变化,与 ColorOverLifetimeModule 同档次),并不是 Unity SetCustomParticleData 那种「脚本给每个粒子写一个任意指定值」的真·逐粒子数据 —— 因为数据通道是 uniform,而非 per-particle 的 instance 顶点属性。

建议二选一:

  1. 改文案(成本低):把 "per-particle business data" 改成更准确的说法,例如「renderer 级共享的自定义 shader uniform / 自定义数据流」,避免用户误以为能逐粒子赋值。
  2. 补能力(成本高):若确实想要名副其实的 per-particle,需要在 generator._primitive 的 instance buffer 上增加自定义顶点属性,而不是加 uniform。可作为后续迭代。

其它非阻塞 minor:

  • enabled = false_updateShaderData 直接 return,上一帧上传的 uniform 不会被清零,shader 会继续读到残留值(本模块没有 macro,不像其它模块靠 disableMacro 切分支)。建议在 enabled 的 JSDoc 里点明「disable 仅停止上传、不清零」。
  • _cloneTo 目前是 module-local 的 deep-clone workaround(已在 commit 922a55ee 的说明里承认),proper fix 是给 CloneManager@recordClone 之类的容器克隆装饰器。建议建个 follow-up issue 跟踪,别让 TODO 漂着。
  • CustomData.test.ts 中 "handles all curve modes" 对 Curve / TwoCurves 只断言 not throw,未校验 setFloatArray 实际写入的数组内容,可顺手补一条断言。

@cptbtptpbcptdtptp
Copy link
Copy Markdown
Collaborator

有两个会直接影响渲染的问题,建议合并前处理:

1. 与引擎保留 uniform 名冲突(无校验)

CustomDataModule 生成的 uniform 名是 renderer_<name><suffix>,而引擎内置模块(VOL/FOL/SOL/COL/ROL/TSA/LVL)使用的就是 renderer_VOLMaxConstrenderer_COLMaxGradientColor 等同前缀的命名空间。

如果用户调用:

customData.addCurve("VOLMaxConst", new ParticleCompositeCurve(0.5));

ShaderProperty.getByName("renderer_VOLMaxConst") 会拿到和 VelocityOverLifetime 模块同一个 _uniqueId,两者共用 _propertyValueMap 里的同一个槽位,后写入覆盖先写入 —— 直接破坏粒子模拟。_validateName 目前完全没拦这一类名字。

建议:在 _validateName 里加保留前缀黑名单,至少包含 VOL/FOL/SOL/COL/ROL/TSA/LVL,或者文档强制要求用户给 name 加项目前缀。

2. 运行时切换 curve.mode / gradient.mode 留下幽灵 uniform

ParticleCompositeCurve.mode 是公开 setter。考虑这个场景:

const c = new ParticleCompositeCurve(0.5);   // Constant
customData.addCurve("OffsetX", c);
// 上传 renderer_OffsetXMaxConst = 0.5

c.mode = ParticleCurveMode.Curve;
c.curveMax = someCurve;
// _uploadCurveStream 走 Curve 分支:
//   写 renderer_OffsetXMaxGradient[4]
//   不写 renderer_OffsetXMaxConst

ShaderData._setPropertyValue 是按引用存值的,不会清理旧值。renderer_OffsetXMaxConst = 0.5 会一直保留在 _propertyValueMap 里、每帧上传到 GPU。如果用户 shader 仍声明 uniform float renderer_OffsetXMaxConst;,会读到陈旧值;反向(Curve → Constant)则 MaxGradient[4] 数组保留旧值。Gradient 路径同样问题。

建议三选一:

  • 每帧 upload 把"另一组 mode 的 uniform"显式写 0;
  • 检测 mode 变化时做一次性清理;
  • 最低:TSDoc 显式声明"mode 是注册时锁定的,运行时切换不会清理另一组 uniform",并配合 mode 监听给出 warning。

—— 测试覆盖也建议补:保留字撞名一例、mode 切换后旧 uniform 残留一例,锁住当前行为。

@cptbtptpbcptdtptp
Copy link
Copy Markdown
Collaborator

补充一条 _cloneTo 的小问题。

CustomDataModule._cloneTo 当前每个 entry 都 new Map() 作为 deepInstanceMap

for (const name in sourceCurves) {
  const clonedCurve = new ParticleCompositeCurve(0);
  CloneManager.deepCloneObject(sourceCurves[name], clonedCurve, new Map()); // ← 每 entry 新建
  target.addCurve(name, clonedCurve);
}
for (const name in sourceGradients) {
  const clonedGradient = new ParticleCompositeGradient(new Color());
  CloneManager.deepCloneObject(sourceGradients[name], clonedGradient, new Map()); // ← 又新建
  target.addGradient(name, clonedGradient);
}

CloneManager.clonePropertydefault 分支,deepInstanceMap 的作用不只是循环引用保护,更关键的是保持 reference sharing 语义

// CloneManager.ts:218
if (deepInstanceMap.has(sourceProperty)) {
  target[k] = deepInstanceMap.get(sourceProperty);
  return;
}
...
deepInstanceMap.set(sourceProperty, targetPropertyD);

每个 entry 一份 Map 意味着:如果用户跨 entry 共享同一个子对象(比如两条 curve 的 curveMax 指向同一个 ParticleCurve 实例),clone 后会被拆成两份独立副本,原本"改 shared 同时影响两边"的语义就断了。

建议:两个 for 共用同一份 Map:

_cloneTo(target: CustomDataModule): void {
  const sharedMap = new Map<Object, Object>();
  for (const name in this._curves) {
    const clonedCurve = new ParticleCompositeCurve(0);
    CloneManager.deepCloneObject(this._curves[name], clonedCurve, sharedMap);
    target.addCurve(name, clonedCurve);
  }
  for (const name in this._gradients) {
    const clonedGradient = new ParticleCompositeGradient(new Color());
    CloneManager.deepCloneObject(this._gradients[name], clonedGradient, sharedMap);
    target.addGradient(name, clonedGradient);
  }
}

零成本零风险:两个字典存的是不同类型,不会冲突;同字典内同一实例多 name 注册的场景,clone 后保持共享正好符合用户本意;addCurve 里 new 出来的 clonedCurve 不会进 map(map 存的是 source 这一级及更深的子对象)。

—— 顺带提一下更深的限制(不在本 PR 范围内,仅 FYI):ICustomClone._cloneTo 接口签名是 (target, srcRoot, targetRoot)没有 deepInstanceMap 参数。这意味着 CustomDataModule._cloneTo 拿不到 cloneProperty 上层在用的那份 map,跨模块的引用共享(比如同一个 ParticleCompositeCurve 同时被引擎其他模块和 customData 引用)目前没法保持。这是引擎接口层面的系统性限制,所有实现了 _cloneTo 的类都有这毛病。如果未来打算重构,可以考虑把 _cloneTo 签名扩展成 (target, srcRoot, targetRoot, deepInstanceMap?),让所有自定义 clone 都能加入上层的 reference-sharing 体系。

private static readonly _zeroGradientColorArray = new Float32Array(16);
private static readonly _zeroGradientAlphaArray = new Float32Array(8);
private static readonly _zeroColor = new Color(0, 0, 0, 0);
private static readonly _zeroVector4 = new Vector4(0, 0, 0, 0);
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.

可选优化:

 private static readonly _zeroCurveArray = Object.freeze(new Float32Array(8)) as Float32Array;

@cptbtptpbcptdtptp
Copy link
Copy Markdown
Collaborator

还有一条结构性建议,可以一并考虑。

数据结构:Record + ArrayMap<string, Stream>

当前 CustomDataModule 维护两份索引:

```ts
private _curves: Record<string, ParticleCompositeCurve> = {};
private _curveStreams: CurveStream[] = []; // CurveStream 里又冗余存了 name
```

字典是为了按 name 查 / 重名校验,数组是为了 _updateShaderData 顺序遍历。改成 Map<string, CurveStream> 同时满足两个需求,并一次性消掉一片小问题

问题 当前 改 Map 后
addCurve("toString", ...) 触发"'toString' in {}"永远 true 的误报 真问题 map.has("toString") 严格按显式 key
addCurve("__proto__", ...) 修改 dict 对象自身的原型 真问题 Map 不走原型链
_cloneTofor...in 没 hasOwnProperty 保护 潜在隐患 for...of map.entries() 无此问题
removeCurve 线性查找 + 维护两份索引 O(n) + 易错 map.delete(name) O(1),单一索引
CurveStream.name 冗余存储 多一个字段 去掉,name 就是 map 的 key

代价是公开 getter 类型从 Readonly<Record<string, T>> 变成 ReadonlyMap<string, T>,用户从 customData.curves["X"] 改成 customData.curves.get("X")

关键时间点:API 还没合并是零成本,合并后再改就是 breaking change。如果觉得这条值得做,现在是唯一的窗口。


顺带几个文档/测试建议

  1. renderer_<name>KeysCount vec4 编码语义未文档化 —— TSDoc 表格只写"vec4 renderer_<name>KeysCount",没说四个分量分别是 (colorMinKeys.last.time, alphaMinKeys.last.time, colorMaxKeys.last.time, alphaMaxKeys.last.time),用户读不出来该怎么用。建议补一行说明或一个 shader 调用例子。

  2. 装配顺序陷阱写进 TSDoc —— E2E 用例的注释提到 "build the particle detached from the scene so the ParticleRenderer _onEnable lifecycle hook does not fire until the generator + custom shader are fully configured"。这是用户最容易踩的坑("我加了 customData 但好像没生效"),应该搬到 CustomDataModuleParticleMaterial 的类级 TSDoc,而不是只活在 E2E 注释里。

  3. PR 描述里关于"不等于 Unity SetCustomParticleData"的免责声明应进 TSDoc —— 这是对 Unity 背景用户的关键认知校准(Galacean 这里是 per-drawcall uniform,不是 per-particle attribute;逐粒子差异化需要 shader 用 a_Random* / normalizedAge 二次参数化)。PR body 合并后没人会回头看,必须写进 CustomDataModule 类级 TSDoc 顶部。

  4. 测试覆盖建议补

    • 保留字撞名(addCurve("VOLMaxConst", ...))—— 锁定本评论 Chore/workflow #1 修复后行为;
    • 内建键名(addCurve("toString") / addCurve("__proto__"))—— 同上;
    • mode 切换后旧 uniform 残留 —— 先写 case 锁住当前行为,Fix/particle renderer #2 修复时再改断言;
    • E2E 补一个 Curve mode + normalizedAge 采样的用例,验证"参数化-按生命周期"链路(现有 E2E 都是 Constant 模式,只验证了 uniform 上传链路)。

Copy link
Copy Markdown
Member

@GuoLei1990 GuoLei1990 left a comment

Choose a reason for hiding this comment

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

审查(2026-06-02,增量 @ 3d4b08ca3

已关闭问题清单(我上一轮 922a55ee2 的遗留)

问题 状态
P2: Gradient/TwoGradients 模式静默 no-op、无 Logger 已解决 — 两种模式现已完整实现(CustomDataModule.ts:243-264),并配了 renderer_<name>KeysCount 的 vec4 编码 + 单测(CustomData.test.ts:203-229)断言 min/max keys 的 xy/zw 拆分
P2: ParticleGenerator JSDoc 遗留旧设计 "two per-particle vec4 streams" 已解决 — 现为 /** Custom data module. */
P2: e2e diffPercentage: 0 过严 不再成立 — 当初 420c259f 实测的 1.31% 漂移来自已被移除的 GPU sin() hash 设计;当前是确定性的 uniform passthrough(Constant 模式),且同目录 textureSheetAnimation / emissive / emit-billboard-stretched / noise 等确定性 case 本就用 0,是本套件惯例。撤回此条

总体评价

设计自上一轮已收敛得很好:ParticleVert.glsl 把引擎 vert helper 抽出、.shader 内联 vert/frag 直接对标 PBR 模式,方向正确且干净;clone 路径(@deepClone + _cloneTo 重建内部 cache)经核对工作正常,并有链路级 clone 测试守护;注释合规、_renderer 生命周期保证 removeCurve/removeGradient 直写 shaderData 无 NPE。

关于 @cptbtptpbcptdtptp 已提出的 open 项

我复核了代码,下面几条在当前 HEAD 仍未落地,均归属 @cptbtptpbcptdtptp 06-01 的 thread,不在此重复展开分析,仅确认状态以便作者收口:

  • [仍未修复 · 阻塞] 保留前缀撞名 → 破坏粒子模拟_validateNameCustomDataModule.ts:273-285)仍只校验合法标识符 + 重名,未拦 renderer_<MODULE> 命名空间。addCurve("VOLMaxConst", …) 会与 VelocityOverLifetime 共用同一 ShaderProperty._uniqueId 槽位、后写覆盖先写。这是本 PR 唯一的数据正确性级遗留,我上一轮以 Minor 提过、@cptbtptpbcptdtptp 已升级为合并前必处理,仍是合并的主要 blocker
  • 运行时切换 curve.mode/gradient.mode 留下幽灵 uniform(_uploadCurveStream/_uploadGradientStream 只写当前 mode 的槽位)—— 仍存在。
  • _cloneTo 两个 fornew Map(),跨 entry 的引用共享会被拆成独立副本 —— 仍存在。

以上无需我再给方案,@cptbtptpbcptdtptp 的评论已附完整修复建议。

结论

我自己上一轮的遗留已全部闭环,本轮无新增问题。PR 当前 CHANGES_REQUESTED 由保留前缀撞名(P1 数据正确性)+ 上述 open 项支撑,建议作者把保留前缀黑名单 + 配套撞名测试补上后即可推进。

hhhhkrx added 3 commits June 2, 2026 14:20
…ale uniforms

Two correctness fixes around how user-supplied stream names map to ShaderData slots:

1. Reserved-prefix collision. `addCurve("VOL", …)` would produce
   `renderer_VOLMaxConst` — the same name (and the same `ShaderProperty._uniqueId`,
   hence the same `_propertyValueMap` slot) that VelocityOverLifetime already
   writes to, silently corrupting the engine's own particle simulation.
   `_validateName` now rejects any name starting with VOL/FOL/SOL/COL/ROL/TSA/LVL —
   the seven engine-internal particle module prefixes, found by grepping
   `packages/core/src/particle/`. Cross-substring matches like "MyVOL" are still
   accepted; only the leading prefix is reserved.

2. Mode-transition ghost uniforms. `ParticleCompositeCurve.mode` /
   `ParticleCompositeGradient.mode` are public setters: a user can flip a Constant
   curve to Curve at runtime. The old `_uploadCurveStream` only wrote the uniforms
   the new mode demanded, so the old mode's last value persisted in
   `_propertyValueMap` and kept being uploaded to the GPU — anything still reading
   `uniform float renderer_<name>MaxConst` would see the stale Constant value
   under a Curve-mode stream.
   Each Stream now carries `lastMode`. On the rare transition, the upload path
   zeros all of the stream's uniforms once, updates `lastMode`, and proceeds.
   In the steady-state (no mode change) it's a single equality check.

The remove path's zero-out logic is now shared with the mode-transition path via
`_zeroCurveUniforms` / `_zeroGradientUniforms` helpers; `removeCurve` /
`removeGradient` delegate to them instead of duplicating the literal sequence
of zero writes.

Tests:
- `addCurve / addGradient` reject "VOL", "FOLSpeed", "COL", "TSAFrame" but accept
  "MyVOL" (substring, not prefix).
- After `curve.mode` flips Constant → Curve, `renderer_FlipScalarMaxConst` reads 0.
- After `gradient.mode` flips Constant → Gradient, `renderer_FlipColorMaxConst` reads 0.
The previous storage kept the user data in a plain-object `Record` and the
upload metadata in a parallel `CurveStream[]` / `GradientStream[]`. Switching
both to `Map<string, …>` closes two real bugs and a duplication smell:

- **Object.prototype name collisions.** `name in this._curves` on a plain
  `{}` returns true for `toString`, `hasOwnProperty`, `constructor` …
  inherited via the prototype chain — so `addCurve("toString", …)` was
  silently rejected as a duplicate even when no entry existed. `Map.has`
  only sees explicitly-set keys.
- **`__proto__` prototype pollution.** `obj["__proto__"] = curve` mutates
  the container's prototype chain, and `for…in` in `_cloneTo` would then
  walk through the curve's own enumerable properties. `Map.set("__proto__", …)`
  treats `__proto__` as a literal key with no special behaviour.
- **Single index.** `removeCurve` / `removeGradient` now do
  `map.delete(name)` instead of a linear `for` to find the array slot and
  swap-remove, and `CurveStream` / `GradientStream` no longer carry a
  redundant `name` field — the Map key is the name.

Public API: `customData.curves` / `customData.gradients` go from
`Readonly<Record<string, T>>` to `ReadonlyMap<string, T>`. Pre-merge, so a
zero-cost change for downstream code; the same change post-merge would have
been breaking.

Class TSDoc also gains two paragraphs that the E2E case had buried in its
comments:
- "per-drawcall, not per-particle" — Galacean's uniform model is not Unity's
  `ParticleSystem.SetCustomParticleData`; particle-level differentiation
  inside the shader requires mixing in `a_Random*` / `normalizedAge` / etc.
- "attachment order" — configure the generator BEFORE attaching the entity
  to the scene, otherwise `ParticleRenderer._onEnable` fires too early and
  streams registered after attachment miss the first frame.

The `addGradient` JSDoc also documents what the four `KeysCount` lanes hold:
`(colorMin, alphaMin, colorMax, alphaMax)` last keyframe times — the shader
uses these to normalize its sample `t`.

Tests: + Object.prototype name acceptance (`toString` / `hasOwnProperty` /
`constructor`); + `__proto__` accepted without touching `Map.prototype`. The
clone-deep test now asserts `[...map.keys()]` instead of inspecting the old
array `name` field.
- Class TSDoc per-drawcall / attachment-order paragraphs collapsed from
  ~6 lines each to single sentences.
- Drop the 3-line block comment on `_reservedPrefixPattern`; the regex
  literal already lists the prefixes.
- `addGradient` KeysCount note shrinks from a 3-line paragraph to one
  inline line under the uniform table.
- `_cloneTo` deep-instance-map note: 4 lines → 1.
- `_uploadCurveStream` mode-flip note: 2 lines → 1.
Copy link
Copy Markdown
Member

@GuoLei1990 GuoLei1990 left a comment

Choose a reason for hiding this comment

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

增量审查(2026-06-02,@ 12d371fcf

两个新 commit(ca2a1459f + 12d371fcf)把上一轮 @cptbtptpbcptdtptp 的全部 blocking 项 + 我历史遗留项一次性闭环,逐条核对正确。本轮范围仅 CustomDataModule.ts + CustomData.test.ts

已关闭问题汇总

级别 问题 状态
P1(数据正确性) 保留前缀撞名 → 破坏内置粒子模拟(addCurve("VOL", …) 与 VelocityOverLifetime 共用 renderer_VOLMaxConst 槽位) ca2a1459f_reservedPrefixPattern 黑名单(VOL/FOL/SOL/COL/ROL/TSA/LVL),配撞名测试
P2 运行时切 curve.mode/gradient.mode 留幽灵 uniform ca2a1459f lastMode 跟踪 + flip 时 _zero*Uniforms 清零;2 条反向证伪测试(0.8 → flip → 断言 0)守护
P3 _cloneTo 两个 fornew Map(),跨 entry 共享引用被拆成独立副本 12d371fcf 改为共享同一 deepInstanceMap
P2 'toString' in {} 永远 true 的 dup 误报 + addCurve("__proto__") 污染容器原型 + for...in 缺 hasOwnProperty 12d371fcf Record → Map,配 2 条专项测试(toString/hasOwnProperty/constructor + __proto__ + getPrototypeOf === Map.prototype
P3 CurveStream.name 冗余字段 + removeCurve O(n) 线性查找 + 维护两份索引 12d371fcf name 即 Map key,remove 走 map.delete O(1),单一索引
doc renderer_<name>KeysCount vec4 四分量编码语义未文档化 12d371fcf 补 TSDoc((colorMin/alphaMin/colorMax/alphaMax).last.time,单 Gradient 模式 min 镜像 max)
doc 装配顺序陷阱(_onEnable 在入树时触发,之后注册的 stream 漏首帧)未文档化 12d371fcf 补 class TSDoc "Attachment order" 段

核对要点(逐项独立验证通过)

  1. 保留前缀确为真实命名空间:grep 全部 7 个引擎粒子模块,uniform 确实用 renderer_VOL*/renderer_FOL*/renderer_COL* 等前缀;name="VOL" 生成的 renderer_VOLMaxConst/renderer_VOLMinConst 与 VelocityOverLifetime 字符级撞名,黑名单方向正确。
  2. mode flip 清零逻辑正确:flip 时先 _zero*Uniforms 清掉该 stream 全部槽位,再按新 mode 写子集——无双清、无漏清;新旧 mode 槽位不重叠的部分被正确归零。
  3. clone 链路无回归target.addCurve(name, clonedCurve) 新建 stream 时 lastMode: clonedCurve.mode,fresh stream 拿到当前 mode,不会误触发首帧多余清零。
  4. Map 迁移测试完整.size/.get()/.keys() 全量迁移,新增 Object.getPrototypeOf(curves) === Map.prototype 断言锁住原型不被污染。
  5. CI:build(3 OS)/ 4×e2e / lint / codecov-patch 全绿;仅 codecov/project(全项目阈值)fail,非本 patch,不阻塞。

本轮新增问题

[P2] CustomDataModule.ts:57 — 保留前缀黑名单过宽,误拒大量不会撞名的常用名

_reservedPrefixPattern = /^(?:VOL|FOL|SOL|COL|ROL|TSA|LVL)/纯前缀匹配,拦的是"name 以这 3 字母开头",而真正的撞名条件是"renderer_<name><CustomData后缀> 字符级等于某个引擎 uniform"。两者不等价——黑名单误拒了一批实际不撞名的合法名。

我把引擎全部 7 模块的 uniform 集与 CustomData 会拼的 9 个后缀(MaxConst/MinConst/MaxGradient/MinGradient/MaxGradientColor/MaxGradientAlpha/MinGradientColor/MinGradientAlpha/KeysCount)做了交叉验证:

输入 name 黑名单拒? 真撞名? 判定
VOL / FOL / COL / ROL 正确拒(renderer_VOLMaxConst 等真撞)
SOL 误拒(SOL 只有 MaxCurveX/Y/Z,无 SOLMaxConst
LVL / TSA 误拒(无 LVLMaxConst/TSAMaxConst 这种拼法)
COLOR 误拒renderer_COLORMaxConst 非任何引擎 uniform)
VOLUME / SOLAR / ROLL / FOLIAGE 误拒
FOLSpeed / TSAFrame 误拒(测试注释称"collides with FOL/TSA space",但 renderer_FOLSpeedMaxConst 并不是引擎 uniform——注释的事实判断有误)

问题在于:

  1. 连 bare SOL/LVL/TSA 本身都不真撞名(它们的后缀是 MaxCurveX/SpeedMaxConst/FrameMaxCurve,跟 CustomData 拼的后缀对不上),黑名单却拒了。
  2. COLOR 几乎是用户给颜色流起名的第一选择VOLUME 给体积/强度流也很自然——这些被静默拒掉,用户只看到 "starts with a reserved prefix" 的报错,会困惑"我这名字明明没撞任何东西"。
  3. 测试 CustomData.test.ts:131-133 的注释"FOLSpeedMaxConst collides with FOL's existing uniform space"是错的——该 uniform 不存在,是黑名单的过宽行为顺带把这个 case 拒了,不是真撞名。

这是 API 友好性问题,不是正确性问题——保守拦截不会拼出坏 shader,只是把一批安全名也挡了。但 COLOR/VOLUME 这种高频名被挡,摩擦真实。

修复方向(按精度/成本递增):

  • 精确版(推荐):校验"生成的完整 uniform 名"而非"name 前缀"。CustomData 只拼固定 9 个后缀,对每个后缀检查 renderer_<name><suffix> 是否落在引擎保留集即可。引擎保留集可以是一份 static readonly Set<string>(~50 个名,add 时 cold path 查一次 O(1)),或更彻底地由引擎侧导出。这样 VOL/FOL/COL/ROL 仍被拒,COLOR/VOLUME/SOL/LVL/FOLSpeed 全部放行——零误拒、零漏拒。
  • 折中版:若不想引入保留集,至少把正则收窄到真撞名的 4 个 bare 前缀边界(/^(?:VOL|FOL|COL|ROL)(?:MaxConst|MinConst|MaxGradient|MinGradient|...)$/ 风格的全名锚定),不要用开放式 /^PREFIX/。但这条路本质是手工模拟"生成名 vs 保留集"的匹配,仍是双源真相,长期会漂移(引擎加新模块 / 改后缀,正则漏改)——所以首选精确版

附带:修完把 CustomData.test.ts:131-133 的注释订正(FOLSpeed/TSAFrame 不是"撞 FOL/TSA space",而是"按精确校验应放行"),并把 case 移到"接受"分支。


总结

上一轮全部 blocking(保留前缀撞名 / mode 热切幽灵 uniform / _cloneTo 共享 Map)+ cptbtptpbcptdtptp 的 Map 重构建议 + 两条文档项已全部正确落地,测试覆盖完整(含反向证伪 + __proto__ 原型守护),CI 全绿。

本轮唯一新增是 1 条 P2:保留前缀黑名单方向对、但实现过宽,误拒了 COLOR/VOLUME/SOL/LVL 等大量不真撞名的常用名。改成"按生成的完整 uniform 名查保留集"即可零误拒。不阻塞合入,但 COLOR 被挡的摩擦建议合入前修掉。

@cptbtptpbcptdtptp
Copy link
Copy Markdown
Collaborator

追加一条 perf-tier 建议,是我之前 Map 重构那条意见没考虑周到的地方。

_curveStreams / _gradientStreams 应改回 Array

_updateShaderData 是每帧热路径,本地 Chrome (V8) 实测 for...of map.values()for (let i = 0; ...)稳定 2.5×

size Array for-i Map for-of values() ratio
10 8 ns 21 ns 2.64×
100 68 ns 178 ns 2.60×
1,000 668 ns 1,674 ns 2.51×
10,000 6,760 ns 16,580 ns 2.45×

绝对值在单个粒子系统上不大(13 ns/帧),但作为引擎基础设施会被使用方放大 —— N 个粒子系统 × M 个场景 × 全平台用户。这种每帧执行的代码应该走最快路径。

建议方案:双索引

_curves / _gradients 保留 Map —— 它们承担两个职责:

  1. 给 public get curves() / get gradients()ReadonlyMap 返回(API 已合并,不能动);
  2. _validateName 做 O(1) 重名检查(addCurve 时一次)。

_curveStreams / _gradientStreams 改回 CurveStream[] / GradientStream[] —— 这俩只服务热路径遍历,不需要按 name 查。removeCurve / removeGradient 退回 O(n) 线性查找 + swap-pop,但这不在热路径,几百纳秒可接受。

@ignoreClone
private _curves: Map<string, ParticleCompositeCurve> = new Map();
@ignoreClone
private _gradients: Map<string, ParticleCompositeGradient> = new Map();

@ignoreClone
private _curveStreams: CurveStream[] = [];          // ← 热路径专用
@ignoreClone
private _gradientStreams: GradientStream[] = [];    // ← 热路径专用

代价:

  • CurveStream / GradientStream 重新加回 name 字段(给 removeCurve 反查用);
  • addCurve / removeCurve 各维护两个结构(add Map.set + array.push,remove Map.delete + array swap-pop);
  • 每条流多 ~16 字节(name 字段),10 条流 = 160 字节,可忽略。

收益:热路径 _updateShaderData 恢复到 Array for-i 的最快路径,公开 API 不动。

顺带 perf nit

Map.prototype.forEach 同 benchmark 测下来比 for...of values() 又慢 3× 左右(每次迭代回调调用开销)。如果引擎其他模块里有 someMap.forEach(cb) 的热路径写法,建议一并审视换成 for-i over Array 或至少 for...of values()

`_updateShaderData` is per-frame. Local V8 microbenchmarks show
`for...of map.values()` is ~2.5× slower than array-index iteration across
sizes from 10 to 10k. Single particle system gains are ns-scale, but as
engine infrastructure the cost compounds (N systems × M scenes × all users)
and the rest of the engine's hot paths all run the explicit-for-index
shape; Map iteration was the odd one out.

Split-role storage:
- `_curves` / `_gradients` stay `Map` — they serve the public
  `ReadonlyMap` getter and the O(1) `Map.has` duplicate check in
  `_validateName`. The prototype-chain safety against `toString` /
  `__proto__` / `hasOwnProperty` etc. is preserved.
- `_curveStreams` / `_gradientStreams` revert to `CurveStream[]` /
  `GradientStream[]`. Stream interfaces regain `name: string` so
  `removeCurve` / `removeGradient` can locate the slot via an explicit
  `for` then swap-pop in O(n) — cold path, ~100s of ns at typical N.

Tests: the clone-deep test's internal cache assertion goes back to
`.map((s) => s.name)` over the array; everything else stays Map-shaped
because it reads the public surface.
Copy link
Copy Markdown
Collaborator

@cptbtptpbcptdtptp cptbtptpbcptdtptp left a comment

Choose a reason for hiding this comment

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

+1

@cptbtptpbcptdtptp cptbtptpbcptdtptp added particle shader Shader related functions labels Jun 2, 2026
@cptbtptpbcptdtptp cptbtptpbcptdtptp merged commit dbc693e into galacean:dev/2.0 Jun 2, 2026
11 of 12 checks passed
cptbtptpbcptdtptp pushed a commit to cptbtptpbcptdtptp/engine that referenced this pull request Jun 2, 2026
After merging dev/2.0 (custom particle data galacean#3004), adapt its clone-using code
to the opt-in @Property model:
- CustomDataModule: drop @ignoreClone on its fields — its `_cloneTo` deep-clones
  the curve/gradient maps, so the walker must not touch them.
- ParticleGenerator.customData: @deepClone -> @Property.

Build + clone/custom-data tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

particle shader Shader related functions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants