particle-fx · 粒子效果六档
particle-fxv1.0.0
动效
MIT
页面需要一层轻量粒子:字标粒子组字、星野背景、银河盘视觉、云絮流场、散开↔组字 morph,或音频驱动的黑洞盘;要求零构建、无框架依赖
不适用场景
生产级高密度 three.js 粒子池(用 fx-engines 的 src/fx.js)、满屏背景 + bloom/ACES 后处理(用 bg-fx)、页面与文字过场(用 page-transition)
WebGL 实时预览
卡片与舞台里跑的是包内真实的预览页;WebGL 类需点“运行预览”才会建立渲染上下文。
取用方式
固定版本(CDN)
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/nakamotosai/sai-assets@pack/particle-fx@1.0.0/packs/particle-fx/css/default.css">raw 直取(跟随 main)
curl -O https://raw.githubusercontent.com/nakamotosai/sai-assets/main/packs/particle-fx/SKILL.md仓库内落地
python scripts/apply.py particle-fx --to <项目目录>整包下载
https://assets.ouuut.com/downloads/particle-fx.zip源码与规则
pack.json{
"schema": 1,
"id": "particle-fx",
"title": "particle-fx · 粒子效果六档",
"kind": "fx",
"category": "web-fx",
"version": "1.0.0",
"license": "MIT",
"attribution": [
{
"name": "sai demo 存档 · particle-text-push / starfield-drift / galaxy-disk / curl-fluid",
"license": "MIT",
"what": "四个 canvas 变体的来源实现(四份内容一致,本包收敛为一份 particle-motions.js)"
},
{
"name": "sai demo 存档 · particle-scroll-morph",
"license": "MIT",
"what": "滚动组字实现的来源(2D 近似版;生产 three 版在源项目内,未随包分发)"
},
{
"name": "sai demo 存档 · fm-void-audio",
"license": "MIT",
"what": "音频反应档的来源:黑洞盘 shader(void-fx.js)+ 能量映射(电台固定流改为 ?stream= 可选)"
},
{
"name": "three.js",
"url": "https://threejs.org",
"license": "MIT",
"what": "upstream/three.module.js(r170,保留 SPDX 头),音频反应档的 WebGL 依赖"
}
],
"origin": {
"project": "saaaai-homepage",
"path": "demos/particle-text-push",
"derived_from": "demos/fm-void-audio"
},
"entry": "SKILL.md",
"files": {
"src": [
"src/particle-motions.js",
"src/concepts/text-push.js",
"src/concepts/starfield.js",
"src/concepts/galaxy.js",
"src/concepts/curl.js",
"src/concepts/scroll-morph.js",
"src/concepts/void-fx.js",
"src/concepts/void-audio.js",
"src/entry-boot.js",
"src/util.js",
"src/param-panel.js",
"src/versions.json",
"effects/text-push/params.json",
"effects/starfield/params.json",
"effects/galaxy/params.json",
"effects/curl/params.json",
"effects/scroll-morph/params.json",
"effects/void-audio/params.json"
],
"css": [
"src/stage.css",
"src/param-panel.css"
],
"upstream": [
"upstream/three.module.js"
],
"usage": "docs/usage.md",
"assert": "scripts/assert_tokens.py",
"preview": "preview/index.html"
},
"tags": [
"particles",
"canvas",
"webgl",
"audio-reactive",
"starfield",
"galaxy",
"morph",
"zero-dependency"
],
"when": "页面需要一层轻量粒子:字标粒子组字、星野背景、银河盘视觉、云絮流场、散开↔组字 morph,或音频驱动的黑洞盘;要求零构建、无框架依赖",
"not_for": "生产级高密度 three.js 粒子池(用 fx-engines 的 src/fx.js)、满屏背景 + bloom/ACES 后处理(用 bg-fx)、页面与文字过场(用 page-transition)",
"default_variant": null,
"verified": {
"date": "2026-11-03",
"how": "python packs/particle-fx/scripts/assert_tokens.py(六档对齐+版本+sha256+参数一致性)+ preview/ 全部 7 页经本地 http 服务在 Chrome 打开(1440×900,控制台 0 报错)+ void-audio 降级路径实测",
"evidence": "PASS assert_tokens particle-fx · 6 档 · particle-motions 1.0.0/void-fx 1.0.0/param-panel 2.0.0(=fx-engines)· sha256 13 文件 · 61 字段/61 默认值 · 61 个包内引用全命中 · 7 页控制台 0 报错 · 音频档:无 ?stream= 时流按钮禁用、默认假频谱、模拟 NotAllowedError → HUD『麦克风被拒 → 假频谱』且 0 报错;截图 .private/shots/particle-fx-{starfield,galaxy,curl,text-push,scroll-morph,void-audio,void-audio-denied}.png"
},
"canonical_home": null,
"changelog": [
"1.0.0 — 首版:四档 canvas 变体(收敛为一份实现)+ 滚动组字 + 音频反应档(音源三档降级)+ 标准调参板 + 版本/sha256 验版本"
]
}
src/stage.css/* stage.css — particle-fx 全部预览页共用的舞台样式(只此一份)。
全屏 canvas + 角标 HUD + 一行提示 + entry 之间互跳的导航。 */
:root { color-scheme: dark; --ink: #e8e4dc; --dim: #8a8a84; --gold: #e8c578; --line: #ffffff1f; }
* { margin: 0; padding: 0; box-sizing: border-box }
html, body { height: 100%; overflow: hidden; background: #050505; color: var(--ink);
font: 400 13px/1.6 "Segoe UI", "PingFang SC", system-ui, sans-serif }
#stage, .void-stage { position: fixed; inset: 0; z-index: 0; width: 100%; height: 100%; display: block }
#stage canvas, .void-stage canvas { display: block; width: 100%; height: 100% }
.hud { position: fixed; z-index: 5; left: 14px; top: 12px; max-width: min(62ch, calc(100vw - 240px));
background: #000000b3; border: 1px solid var(--line); border-radius: 8px; padding: 8px 12px;
font: 500 12px/1.6 ui-monospace, SFMono-Regular, Consolas, monospace; letter-spacing: .02em }
.hud b { color: var(--gold); font-weight: 600 }
.hud a { color: var(--ink); text-decoration: none; border-bottom: 1px solid #ffffff33 }
.hud a:hover { border-bottom-color: var(--gold) }
.hud .dim { color: var(--dim) }
nav.entries { position: fixed; z-index: 6; left: 14px; bottom: 14px; display: flex; flex-wrap: wrap;
gap: 6px; max-width: min(70ch, calc(100vw - 40px)) }
nav.entries a { font-size: 12px; color: var(--dim); background: #000000a6; border: 1px solid var(--line);
border-radius: 999px; padding: 5px 12px; text-decoration: none; transition: color .15s, border-color .15s }
nav.entries a:hover { color: var(--ink); border-color: #ffffff44 }
nav.entries a.on { color: #0b0b0b; background: var(--gold); border-color: var(--gold); font-weight: 600 }
.hint { position: fixed; z-index: 5; right: 14px; top: 12px; max-width: 44ch; text-align: right;
color: var(--dim); font: 500 11px/1.6 ui-monospace, monospace; pointer-events: none }
.hint code { color: var(--ink) }
/* ── 音频 entry(void-audio)专用:频谱条 + 底部按钮组 ── */
.void-hud { position: fixed; z-index: 5; left: 50%; transform: translateX(-50%); top: 14px;
color: var(--dim); font: 500 11px/1.5 ui-monospace, monospace; letter-spacing: .1em; pointer-events: none }
.void-bars { position: fixed; z-index: 5; left: 50%; transform: translateX(-50%); bottom: 74px;
display: flex; align-items: flex-end; gap: 3px; pointer-events: none }
.void-bars i { display: block; width: 3px; height: 4px; background: #ffffffb0; border-radius: 2px; transition: height .08s linear }
.void-dock { position: fixed; z-index: 6; left: 50%; transform: translateX(-50%); bottom: 18px;
display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: center;
max-width: calc(100vw - 40px) }
.void-dock button { font: inherit; font-size: 12px; letter-spacing: .04em; color: rgba(255,255,255,.72);
background: #000000b3; border: 1px solid var(--line); border-radius: 999px; padding: 7px 14px; cursor: pointer }
.void-dock button:hover:not(:disabled) { color: #fff; border-color: #ffffff55 }
.void-dock button.on { color: #0b0b0b; background: var(--gold); border-color: var(--gold); font-weight: 600 }
.void-dock button:disabled { opacity: .38; cursor: not-allowed }
.void-meta { color: var(--gold); font: 500 11px/1.5 ui-monospace, monospace; letter-spacing: .04em }
/* WebGL2 不可用时(void-audio)由 entry 插入的可见说明 */
.gl-missing {
position: fixed; z-index: 40; left: 50%; top: 50%; transform: translate(-50%, -50%);
max-width: 46ch; text-align: center; padding: 14px 18px; border-radius: 10px;
background: #000000e6; border: 1px solid #e8c57880; color: var(--gold); font-size: 13px; line-height: 1.6;
}
@media (prefers-reduced-motion: reduce) {
.hint::after { content: " · 已开启「减少动态」:只渲染一帧静态画面"; color: var(--ink) }
}
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;
}
docs/usage.md# particle-fx · 用法(六档粒子效果)
> 六档 = 四个 canvas 2D 变体(共用 `src/particle-motions.js`)+ 滚动组字 + 音频反应黑洞盘。
> 全部**零构建、零依赖**(除 `void-audio` 需要 `upstream/three.module.js`);每档一个预览页,参数走标准调参板。
## 1 六档清单
| entry id | 实现 | 预览 | 适用 |
|---|---|---|---|
| `text-push` | `src/concepts/text-push.js` → `particle-motions.js` | `preview/text-push.html` | 字标/标题级粒子组字,光标交互 |
| `starfield` | `src/concepts/starfield.js` → `particle-motions.js` | `preview/starfield.html` | 星野背景,低干扰 |
| `galaxy` | `src/concepts/galaxy.js` → `particle-motions.js` | `preview/galaxy.html` | 银河盘视觉,可当章节封面 |
| `curl` | `src/concepts/curl.js` → `particle-motions.js` | `preview/curl.html` | 云絮流场,抽象氛围 |
| `scroll-morph` | `src/concepts/scroll-morph.js` | `preview/scroll-morph.html` | 滚动/自动循环的散开↔组字 morph |
| `void-audio`(可选) | `src/concepts/void-audio.js` + `void-fx.js` | `preview/void-audio.html` | 音频反应黑洞盘(麦克风/直播流/假频谱) |
前四档是**同尺寸轻量示意**(canvas 2D,点数 900–1400 级);要生产级密度与 three.js 粒子池,
用 `packs/fx-engines` 的 `src/fx.js`(同一套「会动」思路,6 个 fx)。
> 点数与尺度是按**卡片/区块尺寸**定的(源件本来是墙上的卡片):放全屏当英雄区会显稀
> (`galaxy` / `curl` 尤其明显)。要么把 `density` 拧到 2.0,要么直接上 `fx-engines` 的生产引擎。
## 2 接线(三种)
```js
// A. 预览页这种「整套」接法:读 params.json + 挂标准调参板 + 把值交给 mount()
import { bootEntry } from './src/entry-boot.js';
import { mount } from './src/concepts/galaxy.js';
bootEntry({ slug: 'galaxy', mount, container: document.querySelector('#stage'), corner: 'br' });
// B. 只要效果,不要面板
import { mount } from './src/concepts/curl.js';
const handle = mount(canvas, { density: 1, dotScale: 1, gold: '#e8c578' });
handle.destroy(); // 卸载
// C. 自己管参数(有 update() 的档位会原地改参,没有的就重建)
handle.update?.({ density: 1.4 });
```
- `mount(container, params)` 返回 `{ update?, destroy }`;`container` 可以是 `<canvas>`(`text-push`/`starfield`/`galaxy`/`curl`)或宿主元素(`scroll-morph` 自建 canvas、`void-audio` 自建整套壳)。
- 所有档位同时尊重 `prefers-reduced-motion`:4 个变体画一帧静态点阵,`scroll-morph` 停在字形端,`void-audio` 只渲染一帧。
- 这些是 2D 示意,不做 3D 光照/bloom;要后者请走 `bg-fx` / `fx-engines`。
## 3 音频反应档(void-audio)的音源、权限与降级
| 档位 | 触发 | 依赖 | 失败时 |
|---|---|---|---|
| **假频谱** | 默认 / 点「假频谱」 | 无(合成 ambient,零权限零网络) | —— 永远可用 |
| **麦克风** | 点「接麦克风」(`getUserMedia`) | 用户手势 + 授权;非 https 的远端页面浏览器会直接拒绝 | 拒绝(`NotAllowedError`)或环境无 `getUserMedia` → 回落假频谱,HUD 写明原因 |
| **直播流** | URL 加 `?stream=https://…` 后点「听直播流」 | 音频流必须放行 CORS,否则端口只出静谱 | 播放失败 → 回落假频谱;给的不是 `https://` 时按钮直接禁用 |
麦克风档**只做分析不接扬声器**(接上去会啸叫);「静音」只对直播流有意义。
验收降级路径的方法:不开流、不点麦克风直接开页面 = 假频谱档;点麦克风后拒绝授权 → HUD 必须显示「麦克风被拒 → 假频谱」而画面继续动。
## 4 参数 schema
- 每档一份 `effects/<entry>/params.json`:`{ slug, version, defaults, schema[] }`;面板按 `schema` 渲染控件,`onChange` 回传整份值。
- 允许的 `type`:`range` `number` `select` `checkbox` `swatch` `text`。颜色一律走 `swatch` + `swatches` 有限色板(不用自由取色)。
- 字段形状由 `packs/fx-engines/src/param-panel.schema.json` 约束;面板实现同样来自 `packs/fx-engines`。
- 参数只暴露「实现真的读了」的键:`text-push` 没有 `spin`,`starfield` 没有 `text` —— 死旋钮不进 params.json。
## 5 验版本(防漂移)
- `src/param-panel.js` 是从 `packs/fx-engines` **拷来的同一版本**(`PARAM_PANEL_VERSION` = `2.0.0`),`src/versions.json` 记 sha256;改面板请改 fx-engines 再整份替换,不要在本包手改。
- `particle-motions.js` 源库里曾有**四份内容相同的手拷副本**,所以本包只留一份并带 `PARTICLE_MOTIONS_VERSION`;`void-fx.js` 带 `VOID_FX_VERSION`。
- 自检:`python packs/particle-fx/scripts/assert_tokens.py`(版本常量 + sha256 + 参数一致性 + 预览引用 + 产物齐备)。
## 6 反模式
- ✗ 每帧 `new` 数组/对象 —— 这些都是逐帧循环,复用数组即可(实现里已经是就地累加)。
- ✗ 把点数量调到 4 万又想要 60fps —— `scroll-morph` 的 `count` 在 embed 尺寸下会被内部降到 8% 左右,别指望全量。
- ✗ 在非 https 的远端页面用麦克风 —— 浏览器直接拒;要演示就本机起个静态服务。
- ✗ 给粒子档加 bloom/色差 —— 本包是 2D 示意;后处理请走 `bg-fx`。
- ✗ 把 `void-audio` 当默认背景 —— 它是**可选 entry**:没有音频权限时只能跑假频谱,视觉信息量低于 5 个纯粒子档。
## 7 文件
| 路径 | 作用 |
|---|---|
| `src/particle-motions.js` | 四个 canvas 变体(text-push / starfield / galaxy / curl) |
| `src/concepts/*.js` | 每档入口(读参数 → 调实现 → 返回 `{update?, destroy}`) |
| `src/concepts/void-fx.js` | 黑洞盘 WebGL 渲染器(单 shader) |
| `src/concepts/void-audio.js` | 音频反应接线(音源三档 + 降级 + 面板同步) |
| `src/entry-boot.js` | 预览页共用:params.json → 调参板 → mount() |
| `src/util.js` | `hexToRgb` / `reducedMotion` / `coalesce` |
| `src/param-panel.js` `src/param-panel.css` | 标准调参板(与 fx-engines 同版本) |
| `src/stage.css` | 预览页共用舞台样式 |
| `src/versions.json` | 版本 + sha256 + 来源 + 许可 |
| `effects/<entry>/params.json` | 六份参数定义 |
| `preview/*.html` | 索引 + 六个预览页 |
| `upstream/three.module.js` | three r170(MIT,保留 SPDX 头),只给 `void-audio` 用 |SKILL.md---
name: particle-fx
description: Drop-in particle effects for web pages — glyph push, starfield drift, galaxy disk, curl flow, scroll morph, and an optional audio-reactive black-hole disk. Use when a page needs a lightweight (zero-dependency) particle layer, a cursor-reactive glyph field, a star/galaxy backdrop, or a mic/stream driven visual, without pulling in three.js or a build step.
---
# particle-fx
六档粒子效果:四档共用一份 canvas 2D 变体实现,加一个滚动组字、一个音频反应黑洞盘(可选)。
零构建、零依赖(`void-audio` 除外,它需要包内 `upstream/three.module.js`)。
## When to use
- 要一层**轻量**粒子:星野、银河盘、云絮流场、字标粒子(点数量 900–1400 级)。
- 要光标交互:推散字标粒子、加速星野。
- 要「散开 ↔ 组字」的 morph(自动循环或跟滚动)。
- 要音频驱动的画面(麦克风 / 直播流 / 无权限时的假频谱),且能接受可选依赖。
## When not to use
- 要生产级密度、three.js 粒子池(用 `packs/fx-engines` 的 `src/fx.js`,6 个 fx 同一套思路)。
- 要满屏背景 + bloom/ACES 后处理(用 `packs/bg-fx`)。
- 要页面/文字过场(用 `packs/page-transition`)。
- 要 WebGL 但不想要 three 依赖(用 `bg-fx` 的 WebGL2 引擎)。
## Default
| 档 | 默认 | 说明 |
|---|---|---|
| `text-push` | `density 1` `dotScale 1` `text "ASSETS"` | 字形采样成点阵 |
| `starfield` | `density 1` `dotScale 1` | 定种星场 |
| `galaxy` | `density 1` `spin 0.14` | 核球 + 螺线臂 + 雾 |
| `curl` | `density 1` | 正弦流场 + 5 个移动引力核 |
| `scroll-morph` | `count 13000` `text "ASSETS"` | 6.5s 一个 morph 循环 |
| `void-audio` | `react 1.05` `quality 0.8` `steps 39` | 默认假频谱(零权限) |
## Variants and axes
| 轴 | 取值 | 说明 |
|---|---|---|
| 档位 | 上表 6 个 entry id | 各自独立预览页与 params.json |
| 音源 | `fake` / `mic` / `stream` | 见 `docs/usage.md` §3;失败一律回落 `fake` |
| 尺寸 | 容器尺寸驱动 | `scroll-morph` 在窄容器里自动降到约 8% 点数 |
| 动效偏好 | `prefers-reduced-motion` | 全部档位降级为「一帧静态」或停在字形端 |
## Files
| path | use |
|---|---|
| `src/particle-motions.js` | 四档共用变体实现(+ `PARTICLE_MOTIONS_VERSION`) |
| `src/concepts/<entry>.js` | 六档入口:`mount(container, params) → {update?, destroy}` |
| `src/concepts/void-fx.js` | 黑洞盘 shader 渲染器(+ `VOID_FX_VERSION`) |
| `src/concepts/void-audio.js` | 音频反应接线(音源三档 + 降级 + 面板同步) |
| `src/entry-boot.js` | 预览页共用接线(params.json → 调参板 → mount) |
| `src/param-panel.js` `src/param-panel.css` | 标准调参板(**与 fx-engines 同版本**,sha256 校验) |
| `src/stage.css` | 预览页共用舞台样式 |
| `src/versions.json` | 版本 + sha256(验版本) |
| `effects/<entry>/params.json` | 六份参数定义 |
| `preview/index.html` + `preview/<entry>.html` | 索引 + 各档活预览 |
| `docs/usage.md` | **接线 / 音源降级 / schema / 验版本 / 反模式** |
## Agent apply steps
1. 选档(上表),读 `effects/<entry>/params.json` 拿默认值与字段;要改默认就在自己项目里覆盖,不要改包内文件。
2. 接线:预览页式用 `bootEntry({ slug, mount, container, corner })`;只要效果就直接 `mount(container, params)`。
3. 颜色只给 `swatch` 色板里的 hex;`params` 里不要塞实现不读的键。
4. `void-audio` 只在明确要音频反应时接:默认走假频谱,麦克风要用户手势 + 授权,直播流要给 `?stream=https://…`;务必保留失败回落。
5. 自检:`python packs/particle-fx/scripts/assert_tokens.py`;再用浏览器打开 `preview/<entry>.html`(需经 http 服务打开,ES module 才能加载),看控制台 0 报错。
## Hard rules
- 面板只此一份版本:要改调参板 → 改 `packs/fx-engines/src/param-panel.js` 并抬版本,再整份替换本包副本(sha256 会立刻报漂移)。
- 音源必须可降级:任何 `getUserMedia` / 流播放失败都要回落到假频谱,且把原因写进 HUD。
- 麦克风档**不许**接扬声器(只分析):接上去就是啸叫。
- 不要在本包里造第二份 `particle-motions.js`/`param-panel.js` 副本。
- `prefers-reduced-motion: reduce` 时必须能停下来(一帧静态),不许继续跑 rAF。
- `preview/*.html` 里的相对引用必须落在本包内;外链只用 `https://`。