sai-assets可复用网页资产库

particle-fx · 粒子效果六档

particle-fx v1.0.0 MIT 网页动效 33 文件 · 1437 KB
新窗口 ↗
载入预览…

页面需要一层轻量粒子:字标粒子组字、星野背景、银河盘视觉、云絮流场、散开↔组字 morph,或音频驱动的黑洞盘;要求零构建、无框架依赖

不适用场景

生产级高密度 three.js 粒子池(用 fx-engines 的 src/fx.js)、满屏背景 + bloom/ACES 后处理(用 bg-fx)、页面与文字过场(用 page-transition)

元数据

类目
网页动效
版本
v1.0.0
许可
MIT
规模
33 文件 · 1437 KB
来源
saaaai-homepage · demos/particle-text-push

标签

  • particles
  • canvas
  • webgl
  • audio-reactive
  • starfield
  • galaxy
  • morph
  • zero-dependency

验证证据

2026-11-03 · python packs/particle-fx/scripts/assert_tokens.py(六档对齐+版本+sha256+参数一致性)+ preview/ 全部 7 页经本地 http 服务在 Chrome 打开(1440×900,控制台 0 报错)+ void-audio 降级路径实测

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

归属与许可

  • sai demo 存档 · particle-text-push / starfield-drift / galaxy-disk / curl-fluid · MIT
    四个 canvas 变体的来源实现(四份内容一致,本包收敛为一份 particle-motions.js)
  • sai demo 存档 · particle-scroll-morph · MIT
    滚动组字实现的来源(2D 近似版;生产 three 版在源项目内,未随包分发)
  • sai demo 存档 · fm-void-audio · MIT
    音频反应档的来源:黑洞盘 shader(void-fx.js)+ 能量映射(电台固定流改为 ?stream= 可选)
  • three.js · 来源 · MIT
    upstream/three.module.js(r170,保留 SPDX 头),音频反应档的 WebGL 依赖

取用

固定版本(CDN)
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/nakamotosai/sai-assets@pack/[email protected]/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://`。

← 回索引