fx-engines · 粒子引擎 + 悬浮调参板(底座)
fx-enginesv1.0.0
引擎
MIT
需要持续仿真的粒子效果(径向波前/吸引子/Curl 流场/活字尘/银河盘/喷泉环)、或需要一块 schema 驱动的悬浮调参板;项目里已有多份 param-panel 副本在漂移时换成本包唯一副本
不适用场景
只要静态背景或满屏 bloom/ACES 后处理链(用 bg-fx)、页面与文字过场(用 page-transition)、React/框架组件化封装
WebGL 实时预览
卡片与舞台里跑的是包内真实的预览页;WebGL 类需点“运行预览”才会建立渲染上下文。
取用方式
固定版本(CDN)
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/nakamotosai/sai-assets@pack/fx-engines@1.0.0/packs/fx-engines/css/default.css">raw 直取(跟随 main)
curl -O https://raw.githubusercontent.com/nakamotosai/sai-assets/main/packs/fx-engines/SKILL.md仓库内落地
python scripts/apply.py fx-engines --to <项目目录>整包下载
https://assets.ouuut.com/downloads/fx-engines.zip源码与规则
pack.json{
"schema": 1,
"id": "fx-engines",
"title": "fx-engines · 粒子引擎 + 悬浮调参板(底座)",
"kind": "fx",
"category": "fx-engines",
"version": "1.0.0",
"license": "MIT",
"attribution": [
{
"name": "sai demo 存档 · demos/shared-fx-engine",
"license": "MIT",
"what": "粒子引擎 fx.js 的来源实现:6 个「会动」fx(Beast 涟漪/吸引子/Curl/活字尘/银河盘/喷泉环)+ FX_PARAM_DEFAULTS"
},
{
"name": "sai 组件库 · components/param-panel(v2)",
"license": "MIT",
"what": "悬浮调参板 js/css/schema 的来源实现;旧库被拷 12 份并各自漂移,本包收敛为唯一权威副本并加版本常量"
},
{
"name": "three.js",
"url": "https://threejs.org",
"license": "MIT",
"what": "仅作为生产接线示例里 Points/BufferGeometry 的用法参照;本包不含 three 源码"
}
],
"origin": {
"project": "saaaai-homepage",
"path": "demos/shared-fx-engine",
"derived_from": "components/param-panel"
},
"entry": "SKILL.md",
"files": {
"src": [
"src/fx.js",
"src/param-panel.js",
"src/param-panel.schema.json",
"src/versions.json",
"src/engine-demo.js",
"src/panel-demo.js",
"effects/fx-engine/params.json"
],
"css": [
"src/param-panel.css",
"preview/stage.css"
],
"usage": "docs/usage.md",
"assert": "scripts/assert_tokens.py",
"preview": "preview/index.html"
},
"tags": [
"particles",
"engine",
"param-panel",
"schema",
"versioned",
"canvas",
"webgl"
],
"when": "需要持续仿真的粒子效果(径向波前/吸引子/Curl 流场/活字尘/银河盘/喷泉环)、或需要一块 schema 驱动的悬浮调参板;项目里已有多份 param-panel 副本在漂移时换成本包唯一副本",
"not_for": "只要静态背景或满屏 bloom/ACES 后处理链(用 bg-fx)、页面与文字过场(用 page-transition)、React/框架组件化封装",
"default_variant": null,
"verified": {
"date": "2026-11-03",
"how": "python packs/fx-engines/scripts/assert_tokens.py(版本+sha256+params schema+预览引用)+ preview/index.html、preview/param-panel.html 经本地 http 服务在 Chrome 打开(1440×900,控制台 0 报错)",
"evidence": "PASS assert_tokens fx-engines · fx.js 1.0.0/param-panel.js 2.0.0 · sha256 6 文件 · 6 fx(0..5)· FX_PARAM_DEFAULTS 18 键 · 4 个包内引用全命中 · 预览控制台 0 报错;截图 .private/shots/fx-engines-engine.png、fx-engines-panel.png"
},
"canonical_home": null,
"changelog": [
"1.0.0 — 首版:fx.js 引擎(6 fx)+ param-panel v2 + 版本常量/sha256 验版本 + 两个活预览"
]
}
src/param-panel.css/* Sai Theme Lab · floating param panel v2
* 顶标签 / 中参数 / 底动作 · 可拖可缩放 · 无可见滚动条 · gruvbox 壳
*/
.tl-param-panel {
--tl-bg: #1d2021;
--tl-bg-2: #282828;
--tl-fg: #ebdbb2;
--tl-muted: #a89984;
--tl-accent: #fe8019;
--tl-border: #504945;
--tl-swatch-ring: #fbf1c7;
position: fixed;
z-index: 99999;
width: min(420px, calc(100vw - 24px));
height: min(72vh, 640px);
min-width: 340px;
min-height: 320px;
max-width: min(560px, calc(100vw - 16px));
max-height: min(90vh, 900px);
display: flex;
flex-direction: column;
background: var(--tl-bg);
color: var(--tl-fg);
border: 1px solid var(--tl-border);
border-radius: 12px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
font: 14px/1.45 "Noto Sans SC", "Segoe UI", system-ui, sans-serif;
backdrop-filter: blur(12px);
user-select: none;
overflow: hidden;
}
.tl-param-panel[data-corner="br"] { right: 14px; bottom: 14px; }
.tl-param-panel[data-corner="bl"] { left: 14px; bottom: 14px; }
.tl-param-panel[data-corner="tr"] { right: 14px; top: 14px; }
.tl-param-panel[data-corner="tl"] { left: 14px; top: 14px; }
/* Wall / iframe embed: compact + default collapsed */
.tl-param-panel.is-embed {
width: min(280px, calc(100vw - 16px));
height: auto;
max-height: min(48vh, 360px);
min-width: 200px;
min-height: 0;
font-size: 13px;
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.45);
}
.tl-param-panel.is-embed .tl-param-panel__resize {
display: none;
}
/* dock FAB: whole panel collapses to bottom-right square */
.tl-param-panel.is-collapsed {
width: 56px !important;
height: 56px !important;
min-width: 56px !important;
min-height: 56px !important;
max-width: 56px !important;
max-height: 56px !important;
right: 14px !important;
bottom: 14px !important;
left: auto !important;
top: auto !important;
border-radius: 14px;
padding: 0;
overflow: hidden;
cursor: pointer;
opacity: 0.96;
}
.tl-param-panel.is-collapsed:hover {
opacity: 1;
border-color: var(--tl-accent);
}
.tl-param-panel.is-collapsed .tl-param-panel__head,
.tl-param-panel.is-collapsed .tl-param-panel__tabs,
.tl-param-panel.is-collapsed .tl-param-panel__body,
.tl-param-panel.is-collapsed .tl-param-panel__foot,
.tl-param-panel.is-collapsed .tl-param-panel__resize {
display: none !important;
}
.tl-param-panel__fab {
display: none;
width: 100%;
height: 100%;
appearance: none;
border: 0;
background: linear-gradient(145deg, #3c3836, #1d2021);
color: var(--tl-accent);
cursor: pointer;
align-items: center;
justify-content: center;
padding: 0;
font: inherit;
}
.tl-param-panel.is-collapsed .tl-param-panel__fab {
display: flex;
}
.tl-param-panel__fab-mark {
font-size: 18px;
font-weight: 800;
letter-spacing: 0.04em;
color: var(--tl-fg);
}
.tl-param-panel__fab:hover .tl-param-panel__fab-mark {
color: var(--tl-accent);
}
.tl-param-panel__head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
background: var(--tl-bg-2);
border-bottom: 1px solid var(--tl-border);
cursor: grab;
flex: 0 0 auto;
}
.tl-param-panel__head:active { cursor: grabbing; }
.tl-param-panel__title {
flex: 1;
font-weight: 700;
font-size: 15px;
letter-spacing: 0.03em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tl-param-panel__tabs {
display: flex;
flex-wrap: nowrap;
gap: 4px;
padding: 8px 10px 0;
flex: 0 0 auto;
overflow-x: auto;
scrollbar-width: none;
}
.tl-param-panel__tabs::-webkit-scrollbar { width: 0; height: 0; display: none; }
.tl-param-panel__tab {
appearance: none;
border: 1px solid transparent;
background: transparent;
color: var(--tl-muted);
border-radius: 8px 8px 0 0;
padding: 8px 12px;
font: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
.tl-param-panel__tab:hover {
color: var(--tl-fg);
background: color-mix(in srgb, var(--tl-bg-2) 80%, transparent);
}
.tl-param-panel__tab.is-active {
color: var(--tl-fg);
background: var(--tl-bg-2);
border-color: var(--tl-border);
border-bottom-color: var(--tl-bg-2);
}
.tl-param-panel__btn {
appearance: none;
border: 1px solid var(--tl-border);
background: transparent;
color: var(--tl-fg);
border-radius: 8px;
padding: 7px 12px;
font: inherit;
font-size: 13px;
font-weight: 600;
cursor: pointer;
line-height: 1.35;
}
.tl-param-panel__btn:hover {
border-color: var(--tl-accent);
color: var(--tl-accent);
}
.tl-param-panel__btn--accent {
border-color: color-mix(in srgb, var(--tl-accent) 55%, var(--tl-border));
background: color-mix(in srgb, var(--tl-accent) 14%, transparent);
color: var(--tl-accent);
}
.tl-param-panel__btn:disabled {
opacity: 0.4;
cursor: not-allowed;
pointer-events: none;
}
.tl-param-panel__btn--icon {
min-width: 34px;
padding-left: 8px;
padding-right: 8px;
}
.tl-param-panel__body {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: 12px 14px 14px;
display: flex;
flex-direction: column;
gap: 14px;
background: var(--tl-bg-2);
scrollbar-width: none;
-ms-overflow-style: none;
}
.tl-param-panel__body::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
}
.tl-param-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 6px 10px;
align-items: center;
}
.tl-param-row label {
grid-column: 1 / -1;
color: var(--tl-muted);
font-size: 13px;
font-weight: 600;
}
.tl-param-row input[type="range"] {
width: 100%;
accent-color: var(--tl-accent);
height: 22px;
}
.tl-param-row input[type="number"],
.tl-param-row input[type="text"],
.tl-param-row select {
width: 100%;
grid-column: 1 / -1;
background: var(--tl-bg);
border: 1px solid var(--tl-border);
color: var(--tl-fg);
border-radius: 8px;
padding: 8px 10px;
font: inherit;
font-size: 14px;
}
.tl-param-row select {
cursor: pointer;
}
.tl-param-row input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--tl-accent);
justify-self: start;
}
.tl-param-val {
color: var(--tl-accent);
font-variant-numeric: tabular-nums;
min-width: 3.6em;
text-align: right;
font-size: 13px;
font-weight: 600;
}
/* gruvbox swatch grid — no free color picker */
.tl-param-swatches {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tl-param-swatch {
width: 28px;
height: 28px;
border-radius: 7px;
border: 2px solid color-mix(in srgb, var(--tl-border) 80%, #000);
cursor: pointer;
padding: 0;
appearance: none;
background: var(--sw, #282828);
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.25);
}
.tl-param-swatch:hover {
transform: scale(1.06);
}
.tl-param-swatch.is-active {
border-color: var(--tl-swatch-ring);
box-shadow:
0 0 0 2px color-mix(in srgb, var(--tl-accent) 70%, transparent),
inset 0 0 0 1px rgba(0, 0, 0, 0.2);
}
.tl-param-swatch-meta {
grid-column: 1 / -1;
font-size: 12px;
color: var(--tl-muted);
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
}
.tl-param-panel__foot {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 14px 14px;
border-top: 1px solid var(--tl-border);
background: var(--tl-bg);
}
.tl-param-panel__foot-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tl-param-panel__foot-row .tl-param-panel__btn {
flex: 1 1 auto;
min-width: 6.5em;
text-align: center;
}
.tl-param-panel__foot-row--primary .tl-param-panel__btn {
min-height: 38px;
}
.tl-param-panel__resize {
position: absolute;
right: 2px;
bottom: 2px;
width: 18px;
height: 18px;
cursor: nwse-resize;
background:
linear-gradient(
135deg,
transparent 0 46%,
var(--tl-muted) 46% 54%,
transparent 54% 70%,
var(--tl-muted) 70% 78%,
transparent 78%
);
opacity: 0.75;
}
.tl-param-panel__resize:hover {
opacity: 1;
}
/* select + option contrast — Windows native dropdown needs explicit option colors */
.tl-param-row select,
.tl-param-board select {
background: var(--tl-bg, #1d2021) !important;
color: var(--tl-fg, #ebdbb2) !important;
color-scheme: dark;
}
.tl-param-row select option,
.tl-param-row select optgroup,
.tl-param-board select option,
.tl-param-board select optgroup {
background-color: #1d2021;
color: #ebdbb2;
}
preview/stage.css/* stage.css — fx-engines 全部预览页共用的舞台样式(只此一份)。
全屏 canvas + 角标 HUD + 一行提示 + 入口导航;不做「假预览」,效果本身全在 src/fx.js 里。 */
:root { color-scheme: dark; --ink: #d8d3c8; --dim: #8f8b84; --accent: #fe8019; --line: #3a3a38; }
* { margin: 0; padding: 0; box-sizing: border-box }
html, body { height: 100%; overflow: hidden; background: #05060a; color: var(--ink);
font: 400 13px/1.6 "Noto Sans SC", "Segoe UI", system-ui, sans-serif }
#stage { position: fixed; inset: 0; width: 100%; height: 100%; display: block }
.hud { position: fixed; left: 14px; top: 12px; z-index: 5; max-width: min(60ch, calc(100vw - 220px));
background: #0009; padding: 8px 12px; border-radius: 8px; border: 1px solid #ffffff14;
font: 500 12px/1.5 ui-monospace, SFMono-Regular, Consolas, monospace; letter-spacing: .03em }
.hud b { color: var(--accent); font-weight: 600 }
.hud a { color: var(--ink); text-decoration: none; border-bottom: 1px solid #ffffff33 }
.hud a:hover { border-bottom-color: var(--accent) }
.hud .ver { color: var(--dim) }
.picker { position: fixed; z-index: 6; left: 14px; bottom: 14px; display: flex; flex-wrap: wrap; gap: 6px;
max-width: min(64ch, calc(100vw - 40px)) }
.picker button { font: inherit; font-size: 12px; color: var(--dim); background: #000000a6;
border: 1px solid #ffffff1f; border-radius: 999px; padding: 5px 12px; cursor: pointer;
transition: color .15s, border-color .15s }
.picker button:hover { color: var(--ink); border-color: #ffffff44 }
.picker button.on { color: #0b0b0b; background: var(--accent); border-color: var(--accent); font-weight: 600 }
.hint { position: fixed; right: 14px; bottom: 14px; z-index: 5; max-width: 46ch; text-align: right;
color: var(--dim); font: 500 11px/1.6 ui-monospace, monospace; pointer-events: none }
.hint code { color: var(--ink) }
@media (prefers-reduced-motion: reduce) {
.hint::after { content: " · 已开启「减少动态」:引擎只渲染一帧静态画面"; color: var(--ink) }
}
docs/usage.md# fx-engines · 用法(底座:粒子引擎 + 悬浮调参板)
> 这是**底座包**:`src/fx.js`(6 个「会动」粒子 fx 的仿真引擎)与 `src/param-panel.js`(悬浮调参板标准件)。
> 这两件在本仓**只有这一份权威副本**;`packs/particle-fx`、`packs/bg-fx` 等消费方按下节两种方式之一接进来。
## 1 引用方式:拷贝 vs 引入
| 方式 | 做法 | 适用 |
|---|---|---|
| **拷贝式(生产项目)** | 把 `src/fx.js`(或 `src/param-panel.js` + `src/param-panel.css`)拷进项目,**原样保留文件头**(版本常量 + 来源行),然后在项目 `design-lock.json` 的 `assets[]` 记一行 `{ "pack": "fx-engines", "version": "1.0.0", "sha256": "<versions.json 里的值>" }` | 项目要零运行时依赖、要固定版本 |
| **引入式(同仓/多包)** | 直接 `import { makeFx } from "../../fx-engines/src/fx.js"` 相对引入(路径按自己的目录算),**不要**再拷第二份 | 本仓内的包、预览页、共享 demo |
**禁令**:禁裸拷无版本副本。旧库里 `param-panel.js` 被拷了 12 份、各自手改到互不相同(壳行为分叉、`patchParams` 只有其中一份有),漂移就是这么发生的 —— 拷可以,但必须**带着版本常量拷**,并用 §4 的方式验。
## 2 引擎接线(生产:three.js Points)
```js
import * as THREE from './three.module.js'; // 或项目自己的 three
import { FX_META, makeFx, pickFxParams } from './fx.js';
const pack = makeFx(4, 6000, 'ASSETS', { galSpin: 0.2, galArms: 2 }); // id=4 银河盘
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(pack.buf, 3));
const points = new THREE.Points(geo, new THREE.PointsMaterial({ color: 0xe8c578, size: 1.2 }));
scene.add(points);
// 每帧(rAF):
pack.tick(t, dt); // dt 单位秒;引擎内部不 clamp,调用方 clamp 到 ≤ 0.033
geo.attributes.position.needsUpdate = true;
```
口径(踩过的坑):
- `pack.buf` 是**世界坐标 xyz**(`Float32Array`,长度 `n*3`),不是屏幕坐标;相机/正交尺度由调用方定(银河盘半径 ≈ `galRadius`,默认 36)。`makeFx` 已 `tick` 过一帧,所以首帧即有位置。
- 引擎**不做**材质与后处理(bloom / 径向色差 / 颗粒 / 暗角 / ACES 都在 `bg-fx` 的 `engine.js`)。
- `pack.getGlow()` 返回银河核球的逐点亮度(`Float32Array`)或 `null`;有值就乘进点大小/透明度,核球才有层次。
- 6 个 fx:`0 Beast 涟漪` `1 吸引子星云` `2 Curl 流体` `3 活字尘` `4 银河盘` `5 喷泉环`(`FX_META` 里有名字与英文名)。`id=3` 需要传入字标 `label`。
- 省电:降 `n`、Curl 用 `curlStride` 隔点;`document.hidden` 时别 tick;`prefers-reduced-motion` 时只画一帧。
## 3 参数 schema 用法
- **默认值权威在引擎侧**:`fx.js` 的 `FX_PARAM_DEFAULTS`(gal* / curl* 共 20 键)。UI 侧不复制这些默认值的语义,只挑要暴露给用户的那部分。
- **UI 侧 schema** = `effects/fx-engine/params.json`:`{ slug, version, defaults, schema[] }`;每个 field `{ key, label, type, tab?, min?, max?, step?, options?, swatches? }`。
- 允许的 `type`:`range` `number` `select` `checkbox` `swatch` `text`。`color` **已废弃**(面板会降级成 `text`);要收色就用 `swatch` + `swatches: ["#rrggbb", ...]`。
- 这份 params.json 的形状由 `src/param-panel.schema.json`(JSON Schema draft 2020-12)约束,可给 agent / CI 校验。
- 面板只回传 `onChange(values)`;`values` 里可能有 `n` 之类非引擎键,交给 `pickFxParams()` 过滤后再进引擎(未知键直接忽略)。
- 挂板判据:生产只在预览页(`preview.html`)或 `?lab=1` 时挂(`shouldMountLabPanel()`),线上不挂。
## 4 验版本(防漂移)
- 两个文件各自带版本常量:`FX_ENGINE_VERSION`(`src/fx.js`)、`PARAM_PANEL_VERSION`(`src/param-panel.js`)。
- `src/versions.json` 记每个交付文件 `{ file, version, sha256, source, license }`。
- 自检:`python packs/fx-engines/scripts/assert_tokens.py` —— 重算 sha256 比对 `versions.json`、比对版本常量、校验 params.json 合 schema、校验预览页引用都在包内。任一不符立即 FAIL(拷走以后手改过、或拿了旧版,都跑不过)。
- 抬版本规则:`fx.js` 行为/接口改动 → minor;修 bug → patch;删 fx / 改 `FX_PARAM_DEFAULTS` 默认值 → major。参数面板同理。
## 5 反模式
- ✗ 在项目里再拷一份 `param-panel.js` 却不记 version/hash —— 这正是旧库漂移的成因。
- ✗ 把 `pack.buf` 当屏幕坐标直接画 —— 尺度/朝向全错(银河盘半径是 36 世界单位,不是 36 像素)。
- ✗ 每帧 `new Float32Array` / 建对象 —— 每帧分配会抖;复用 `pack.buf` 与 `pack.state`。
- ✗ 用 `color` 自由取色字段 —— 面板已废弃,色板失控;用 `swatch`。
- ✗ 把后处理(bloom/ACES/颗粒)塞进 `fx.js` —— 引擎只管「动」,后处理在 `bg-fx`。
- ✗ 一个项目同时引两份引擎(一份拷贝 + 一份 `../fx-engines/src/fx.js`) —— 同页两份仿真,粒子会互相打架。
## 6 文件
| 路径 | 作用 |
|---|---|
| `src/fx.js` | 粒子引擎唯一实现:6 个 fx + `FX_META` + `FX_PARAM_DEFAULTS` + `makeFx()` |
| `src/param-panel.js` | 悬浮调参板 v2:`mountParamPanel()` / `shouldMountLabPanel()` |
| `src/param-panel.css` | 调参板样式(自带 `--tl-*` 变量,不污染宿主) |
| `src/param-panel.schema.json` | params.json 的 JSON Schema |
| `src/versions.json` | 版本 + sha256 + 来源 + 许可(验版本用) |
| `src/engine-demo.js` | 参考消费者:6 个 fx 的 2D 正交预览(只为验证引擎) |
| `src/panel-demo.js` | 调参板全字段示例 + `apply()` 样张 |
| `effects/fx-engine/params.json` | 引擎预览用的 params.json(schema + defaults) |
| `preview/index.html` | 活预览:6 个 fx + 挂板 |
| `preview/param-panel.html` | 活预览:调参板全字段 |
| `scripts/assert_tokens.py` | 断言:版本/hash/schema/引用/产物 |
SKILL.md---
name: fx-engines
description: Reuse the shared particle-fx engine (6 simulated effects) and the floating param panel component in any web project. Use when a page needs continuous particle simulation (beast ripple, attractors, curl flow, living glyph, galaxy disk, fountain rings) or a schema-driven floating tuning panel, and when you must avoid forking those files per project.
---
# fx-engines
底座包:**粒子引擎**(`src/fx.js`,6 个持续仿真的 fx)+ **悬浮调参板**(`src/param-panel.js`,schema 驱动)。
本仓里这两件只有这一份权威实现;拷贝可以,**必须带版本常量拷**,并用 sha256 验。
## When to use
- 要做「会动」的粒子背景/舞台:径向波前、吸引子星云、Curl 流场、活字尘、银河盘、喷泉环。
- 要给 demo/工具页挂一块**schema 驱动**的悬浮调参板(range/number/select/checkbox/swatch/text + 分页 + 拖拽 + 缩放)。
- 项目里已经散着几份 `param-panel.js` 副本、行为各不相同(漂移)时:换成本包这一份。
## When not to use
- 只要一层静态点阵/星点(用 `page-transition` 的 `starfield-bg.js` 更便宜)。
- 要的是「背景满屏 + bloom/ACES 后处理链」(用 `bg-fx`)。
- 要的是页面/文字过场(用 `page-transition`)。
- 要 Three 粒子字(用 `page-transition` 的 `text-three`)。
## Default
`makeFx(id, n, label, params)` —— 默认 `n` 由调用方给(6000 是预览值);fx id:`0 Beast` `1 吸引子` `2 Curl` `3 活字尘` `4 银河盘` `5 喷泉环`。
| 键 | 值 |
|---|---|
| `FX_ENGINE_VERSION` | `1.0.0` |
| `PARAM_PANEL_VERSION` | `2.0.0` |
| 输出 | `pack.buf` = 世界坐标 xyz(`Float32Array`,长 `n*3`) |
| 每帧 | `pack.tick(t, dt)`(dt 秒,调用方 clamp ≤ 0.033) |
## Variants and axes
| 轴 | 取值 | 说明 |
|---|---|---|
| fx | `0..5` | 见上;`makeFx`/`fxById` 轮转取模 |
| 粒子数 | `n` | 200–20000;`curlStride` 隔点省 CPU |
| 银河参数 | `FX_PARAM_DEFAULTS` 的 `gal*` | `galTilt/galYaw/galLift/galRadius/galSpin/galArms/galPitch/galScatter/galBulge/galCoreBright` |
| Curl 参数 | `FX_PARAM_DEFAULTS` 的 `curl*` | `curlSpeed/curlScale/curlStride/curlBound/curlBright/curlTurb/curlDensity` |
| 面板定位 | `corner` | `tl/tr/bl/br`;`is-collapsed` 时收成右下角方块 |
## Files
| path | use |
|---|---|
| `src/fx.js` | 引擎唯一实现(6 fx + `makeFx` + `FX_PARAM_DEFAULTS`) |
| `src/param-panel.js` / `src/param-panel.css` | 调参板 js/css |
| `src/param-panel.schema.json` | params.json 的 JSON Schema |
| `src/versions.json` | 版本 + sha256(验版本权威) |
| `src/engine-demo.js` / `src/panel-demo.js` | 参考消费者(预览用) |
| `effects/fx-engine/params.json` | 引擎预览的 params.json |
| `preview/index.html` / `preview/param-panel.html` | 活预览 |
| `docs/usage.md` | **引用方式 / 接线 / schema / 验版本 / 反模式** |
## Agent apply steps
1. 读 `src/versions.json` 拿 `version` 与 `sha256`;决定**拷贝式**还是**引入式**(见 `docs/usage.md` §1)。
2. 拷贝式:把 `src/fx.js`(或 `param-panel.js` + `param-panel.css`)拷进项目,**保留文件头**;在项目 `design-lock.json` 的 `assets[]` 记 `pack/version/sha256`。
3. 引擎接线:`makeFx(id, n, label, params)` → `BufferAttribute(pack.buf, 3)` → 每帧 `pack.tick(t, dt)` + `needsUpdate = true`;`pack.getGlow()` 有值就乘进点大小。
4. 调参板接线:`fetch('./params.json')` → `mountParamPanel({ params, corner, onChange })`;`onChange` 里把值交给 `pickFxParams()` 再进引擎。
5. 自检:`python packs/fx-engines/scripts/assert_tokens.py`(版本/hash/schema/引用/产物),再跑一次预览页(`preview/index.html`、`preview/param-panel.html`)看控制台 0 报错。
## Hard rules
- **禁裸拷无版本副本**:拷贝必须带 `FX_ENGINE_VERSION` / `PARAM_PANEL_VERSION`,并在项目里记 sha256;`src/versions.json` 是比对基准。
- 引擎只管「动」:bloom/色差/颗粒/ACES 等后处理**不进** `fx.js`(那属于 `bg-fx`)。
- `pack.buf` 是世界坐标,不是屏幕坐标;尺度/相机由调用方定。
- 每帧零分配:复用 `pack.buf`/`pack.state`,不要在 tick 路径里 `new`。
- 颜色走 `swatch` 有限色板,不用 `color` 自由取色(面板已废弃该类型)。
- `prefers-reduced-motion: reduce` 时只渲染一帧静态画面,不要继续跑 rAF。