{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "particle",
  "title": "Effects · Particle",
  "description": "Effects · Particle section.",
  "dependencies": [
    "three"
  ],
  "files": [
    {
      "path": "registry/talizen/effects/particle/particle.tsx",
      "content": "import {\n  ParticleCanvas,\n  type NeuralParticleMorphEffect,\n  type NeuralParticleMorphShape,\n  type ParticleColorMode,\n} from \"./ParticleCanvas\"\n\nexport type ParticleProps = {\n  className?: string\n  config?: Partial<ParticleEffectConfig>\n}\n\nexport type ParticleEffectConfig = {\n  effect: NeuralParticleMorphEffect\n  shape: NeuralParticleMorphShape\n  particleCount: number\n  particleSize: number\n  canvasBackgroundColor: string\n  particleColorMode: ParticleColorMode\n  particleCustomColor: string\n  imageUrl: string | null\n  modelUrl: string | null\n  interactionMode: \"auto\" | \"manual\"\n  manualControlTarget: \"camera\" | \"object\"\n}\n\nexport const DEFAULT_PARTICLE_EFFECT_CONFIG: ParticleEffectConfig = {\n  effect: \"pulse\",\n  shape: \"sphere\",\n  particleCount: 90000,\n  particleSize: 180,\n  canvasBackgroundColor: \"#000000\",\n  particleColorMode: \"original\",\n  particleCustomColor: \"#22c55e\",\n  imageUrl: null,\n  modelUrl: null,\n  interactionMode: \"auto\",\n  manualControlTarget: \"camera\",\n}\n\nconst PARTICLE_COUNT_LIMITS = {\n  min: 1000,\n  max: 150000,\n}\n\nconst PARTICLE_SIZE_LIMITS = {\n  min: 20,\n  max: 500,\n}\n\nconst PARTICLE_EFFECTS = [\n  \"default\",\n  \"scatter\",\n  \"explode\",\n  \"vortex\",\n  \"pulse\",\n  \"wave\",\n] satisfies NeuralParticleMorphEffect[]\n\nconst PARTICLE_SHAPES = [\n  \"default\",\n  \"heart\",\n  \"butterfly\",\n  \"rose\",\n  \"cube\",\n  \"pyramid\",\n  \"spiral\",\n  \"star\",\n  \"sphere\",\n  \"dna\",\n  \"infinity\",\n] satisfies NeuralParticleMorphShape[]\n\nconst PARTICLE_COLOR_MODES = [\"original\", \"custom\"] satisfies ParticleColorMode[]\nconst INTERACTION_MODES = [\"auto\", \"manual\"] as const\nconst MANUAL_CONTROL_TARGETS = [\"camera\", \"object\"] as const\n\nfunction clampNumber(value: number, min: number, max: number) {\n  if (!Number.isFinite(value)) return min\n  return Math.min(max, Math.max(min, value))\n}\n\nfunction resolveOption<T extends string>(\n  value: T | undefined,\n  fallback: T,\n  allowed: readonly T[],\n) {\n  return value && allowed.includes(value) ? value : fallback\n}\n\nfunction resolveHexColor(value: string | undefined, fallback: string) {\n  if (!value) return fallback\n  return /^#[0-9a-fA-F]{6}$/.test(value) ? value : fallback\n}\n\nfunction resolveNullableUrl(value: string | null | undefined) {\n  if (!value) return null\n  return value\n}\n\nexport function resolveParticleEffectConfig(\n  config: Partial<ParticleEffectConfig> = {},\n): ParticleEffectConfig {\n  return {\n    ...DEFAULT_PARTICLE_EFFECT_CONFIG,\n    ...config,\n    effect: resolveOption(\n      config.effect,\n      DEFAULT_PARTICLE_EFFECT_CONFIG.effect,\n      PARTICLE_EFFECTS,\n    ),\n    shape: resolveOption(\n      config.shape,\n      DEFAULT_PARTICLE_EFFECT_CONFIG.shape,\n      PARTICLE_SHAPES,\n    ),\n    particleCount: Math.round(\n      clampNumber(\n        config.particleCount ?? DEFAULT_PARTICLE_EFFECT_CONFIG.particleCount,\n        PARTICLE_COUNT_LIMITS.min,\n        PARTICLE_COUNT_LIMITS.max,\n      ),\n    ),\n    particleSize: clampNumber(\n      config.particleSize ?? DEFAULT_PARTICLE_EFFECT_CONFIG.particleSize,\n      PARTICLE_SIZE_LIMITS.min,\n      PARTICLE_SIZE_LIMITS.max,\n    ),\n    canvasBackgroundColor: resolveHexColor(\n      config.canvasBackgroundColor,\n      DEFAULT_PARTICLE_EFFECT_CONFIG.canvasBackgroundColor,\n    ),\n    particleCustomColor: resolveHexColor(\n      config.particleCustomColor,\n      DEFAULT_PARTICLE_EFFECT_CONFIG.particleCustomColor,\n    ),\n    particleColorMode: resolveOption(\n      config.particleColorMode,\n      DEFAULT_PARTICLE_EFFECT_CONFIG.particleColorMode,\n      PARTICLE_COLOR_MODES,\n    ),\n    imageUrl: resolveNullableUrl(config.imageUrl),\n    modelUrl: resolveNullableUrl(config.modelUrl),\n    interactionMode: resolveOption(\n      config.interactionMode,\n      DEFAULT_PARTICLE_EFFECT_CONFIG.interactionMode,\n      INTERACTION_MODES,\n    ),\n    manualControlTarget: resolveOption(\n      config.manualControlTarget,\n      DEFAULT_PARTICLE_EFFECT_CONFIG.manualControlTarget,\n      MANUAL_CONTROL_TARGETS,\n    ),\n  }\n}\n\nexport function Particle({ className = \"\", config }: ParticleProps) {\n  const resolvedConfig = resolveParticleEffectConfig(config)\n\n  return (\n    <section\n      className={`relative min-h-screen w-full overflow-hidden ${className}`}\n      style={{ backgroundColor: resolvedConfig.canvasBackgroundColor }}\n    >\n      <ParticleCanvas\n        effect={resolvedConfig.effect}\n        shape={resolvedConfig.shape}\n        imageUrl={resolvedConfig.imageUrl}\n        modelUrl={resolvedConfig.modelUrl}\n        particleCount={resolvedConfig.particleCount}\n        particleSize={resolvedConfig.particleSize}\n        canvasBackgroundColor={resolvedConfig.canvasBackgroundColor}\n        particleColorMode={resolvedConfig.particleColorMode}\n        particleCustomColor={resolvedConfig.particleCustomColor}\n        interactionMode={resolvedConfig.interactionMode}\n        manualControlTarget={resolvedConfig.manualControlTarget}\n        listenForKeyboardToggle={false}\n        pauseAnimationWhenHidden\n        className=\"absolute inset-0 h-full w-full\"\n        style={{\n          position: \"absolute\",\n          inset: 0,\n          width: \"100%\",\n          height: \"100%\",\n        }}\n      />\n    </section>\n  )\n}\n\nexport default Particle\n",
      "type": "registry:component"
    },
    {
      "path": "registry/talizen/effects/particle/ParticleCanvas.tsx",
      "content": "import {\n  useRef,\n  useEffect,\n  useLayoutEffect,\n  useState,\n  useCallback,\n  useImperativeHandle,\n  forwardRef,\n  type CSSProperties,\n  type MutableRefObject,\n} from 'react';\nimport * as THREE from 'three';\nimport { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';\nimport { vertexShader, fragmentShader } from './shaders';\nimport type { SceneData, TargetParticleSource } from './ParticleCanvas.types';\n\ntype MediaParticleProcessorDeps = {\n  sceneDataRef: MutableRefObject<SceneData | null>;\n  stateRef: MutableRefObject<{\n    uploadedImage: string | null;\n    uploadedModel: string | null;\n    currentShape: string | null;\n    morphFactor: number;\n  }>;\n  targetSourceRef: MutableRefObject<TargetParticleSource | null>;\n  applyCurrentTarget: () => void;\n  syncMorphUniformToState: () => void;\n};\n\n/** 仅在实际 target 数据与当前模式一致时 morph=1，避免异步加载期间仍用上一帧 target 却混到 100% 造成闪屏。 */\nfunction resolveMorphUniform(\n  state: {\n    uploadedImage: string | null;\n    uploadedModel: string | null;\n    currentShape: string | null;\n  },\n  source: TargetParticleSource | null\n): number {\n  if (state.uploadedModel) {\n    return source?.type === 'model' ? 1.0 : 0.0;\n  }\n  if (state.uploadedImage && state.uploadedImage !== 'shape') {\n    return source?.type === 'image' ? 1.0 : 0.0;\n  }\n  if (state.uploadedImage === 'shape' || (state.currentShape != null && state.currentShape !== 'default')) {\n    return source?.type === 'shape' ? 1.0 : 0.0;\n  }\n  return 0.0;\n}\n\n/** 换图/换模型开始加载前：target 与初始粒子云一致，避免短暂残留上一张图的几何。 */\nfunction primeTargetsForAsyncMediaLoad(\n  sceneData: SceneData,\n  targetSourceRef: MutableRefObject<TargetParticleSource | null>,\n  applyCurrentTarget: () => void\n): void {\n  const { targetPositions, originalPositions, targetColors, originalColors, geometry } = sceneData;\n  targetPositions.set(originalPositions);\n  targetColors.set(originalColors);\n  geometry.attributes.targetPosition.needsUpdate = true;\n  geometry.attributes.targetColor.needsUpdate = true;\n  targetSourceRef.current = {\n    positions: originalPositions,\n    colors: originalColors,\n    type: 'shape',\n  };\n  applyCurrentTarget();\n}\n\n/** 异步贴图/模型就绪前先隐藏 WebGL 画布，避免「云 → 图」一闪；显示时可带短淡入。 */\nfunction concealWebglCanvas(sceneDataRef: MutableRefObject<SceneData | null>): void {\n  const el = sceneDataRef.current?.renderer.domElement;\n  if (!el) return;\n  el.style.transition = 'none';\n  el.style.opacity = '0';\n}\n\nfunction revealWebglCanvas(sceneDataRef: MutableRefObject<SceneData | null>): void {\n  const el = sceneDataRef.current?.renderer.domElement;\n  if (!el) return;\n  el.style.transition = 'opacity 0.4s ease-out';\n  requestAnimationFrame(() => {\n    const cur = sceneDataRef.current?.renderer.domElement;\n    if (!cur) return;\n    cur.style.opacity = '1';\n  });\n}\n\nfunction ensureWebglCanvasVisible(sceneDataRef: MutableRefObject<SceneData | null>): void {\n  const el = sceneDataRef.current?.renderer.domElement;\n  if (!el) return;\n  el.style.transition = '';\n  el.style.opacity = '1';\n}\n\nconst textureDataCache = new Map<string, ImageData>();\n\nfunction getTextureData(texture: THREE.Texture): ImageData | null {\n  const image = texture.image;\n  if (!image || !image.width || !image.height) return null;\n\n  const cacheKey = (image as unknown as { src?: string }).src || (image as unknown as { id?: string }).id || texture.uuid;\n  if (textureDataCache.has(cacheKey)) return textureDataCache.get(cacheKey)!;\n\n  const canvas = document.createElement('canvas');\n  canvas.width = image.width;\n  canvas.height = image.height;\n  const ctx = canvas.getContext('2d');\n  if (!ctx) return null;\n\n  ctx.drawImage(image as CanvasImageSource, 0, 0);\n  const data = ctx.getImageData(0, 0, image.width, image.height);\n  textureDataCache.set(cacheKey, data);\n  return data;\n}\n\nfunction processImageParticles(imageUrl: string | null, deps: MediaParticleProcessorDeps): void {\n  const { sceneDataRef, stateRef, targetSourceRef, applyCurrentTarget, syncMorphUniformToState } = deps;\n  const sceneData = sceneDataRef.current;\n  stateRef.current.uploadedModel = null;\n\n  if (!imageUrl || !sceneData) {\n    if (!imageUrl && sceneData) {\n      const { targetPositions, originalPositions, targetColors, originalColors, geometry } = sceneData;\n      targetPositions.set(originalPositions);\n      targetColors.set(originalColors);\n      geometry.attributes.targetPosition.needsUpdate = true;\n      geometry.attributes.targetColor.needsUpdate = true;\n      syncMorphUniformToState();\n      ensureWebglCanvasVisible(sceneDataRef);\n    }\n    return;\n  }\n\n  concealWebglCanvas(sceneDataRef);\n  primeTargetsForAsyncMediaLoad(sceneData, targetSourceRef, applyCurrentTarget);\n  syncMorphUniformToState();\n\n  const img = new Image();\n  img.crossOrigin = 'anonymous';\n  img.src = imageUrl;\n  img.onerror = () => {\n    if (stateRef.current.uploadedImage !== imageUrl) return;\n    console.error('Failed to load source image:', imageUrl);\n    ensureWebglCanvasVisible(sceneDataRef);\n    alert(\n      'Could not load this image. For remote URLs, the server must send CORS headers allowing this site to read pixels (or use a local file instead).'\n    );\n  };\n  img.onload = () => {\n    // 丢弃过期请求：快速换 URL、clearImage、setModel 后仍可能触发旧 onload\n    if (stateRef.current.uploadedImage !== imageUrl) return;\n\n    const canvas = document.createElement('canvas');\n    const ctx = canvas.getContext('2d');\n    if (!ctx || !sceneDataRef.current) {\n      ensureWebglCanvasVisible(sceneDataRef);\n      return;\n    }\n\n    const resolution = 200;\n    const aspect = img.width / img.height;\n    const drawWidth = aspect > 1 ? resolution : resolution * aspect;\n    const drawHeight = aspect > 1 ? resolution / aspect : resolution;\n    canvas.width = resolution;\n    canvas.height = resolution;\n    const offsetX = (resolution - drawWidth) / 2;\n    const offsetY = (resolution - drawHeight) / 2;\n\n    ctx.fillStyle = 'black';\n    ctx.fillRect(0, 0, resolution, resolution);\n    try {\n      ctx.drawImage(img, offsetX, offsetY, drawWidth, drawHeight);\n    } catch (e) {\n      console.error('drawImage failed (often CORS / tainted canvas):', imageUrl, e);\n      ensureWebglCanvasVisible(sceneDataRef);\n      return;\n    }\n\n    let imgData: Uint8ClampedArray;\n    try {\n      imgData = ctx.getImageData(0, 0, resolution, resolution).data;\n    } catch (e) {\n      console.error(\n        'getImageData failed — remote image must allow cross-origin read (Access-Control-Allow-Origin):',\n        imageUrl,\n        e\n      );\n      ensureWebglCanvasVisible(sceneDataRef);\n      return;\n    }\n    const validPoints: { pos: [number, number, number]; col: [number, number, number] }[] = [];\n\n    const pushPixels = (brightnessThreshold: number) => {\n      validPoints.length = 0;\n      for (let y = 0; y < resolution; y++) {\n        for (let x = 0; x < resolution; x++) {\n          const idx = (y * resolution + x) * 4;\n          const r = imgData[idx];\n          const g = imgData[idx + 1];\n          const b = imgData[idx + 2];\n          const brightness = (r + g + b) / 3;\n          if (brightness > brightnessThreshold) {\n            validPoints.push({\n              pos: [\n                (x / resolution - 0.5) * 38,\n                (0.5 - y / resolution) * 38,\n                (brightness / 255 - 0.5) * 12,\n              ],\n              col: [r / 255, g / 255, b / 255],\n            });\n          }\n        }\n      }\n    };\n\n    pushPixels(10);\n    if (validPoints.length === 0) pushPixels(3);\n\n    if (stateRef.current.uploadedImage !== imageUrl) return;\n\n    if (validPoints.length > 0 && sceneDataRef.current) {\n      const rawPositions = new Float32Array(validPoints.length * 3);\n      const rawColors = new Float32Array(validPoints.length * 3);\n\n      for (let i = 0; i < validPoints.length; i++) {\n        const p = validPoints[i];\n        const i3 = i * 3;\n        rawPositions[i3] = p.pos[0];\n        rawPositions[i3 + 1] = p.pos[1];\n        rawPositions[i3 + 2] = p.pos[2];\n        rawColors[i3] = p.col[0];\n        rawColors[i3 + 1] = p.col[1];\n        rawColors[i3 + 2] = p.col[2];\n      }\n      targetSourceRef.current = { positions: rawPositions, colors: rawColors, type: 'image' };\n      applyCurrentTarget();\n      syncMorphUniformToState();\n      revealWebglCanvas(sceneDataRef);\n    } else {\n      ensureWebglCanvasVisible(sceneDataRef);\n    }\n  };\n}\n\nfunction processModelParticles(modelUrl: string | null, deps: MediaParticleProcessorDeps): void {\n  const { sceneDataRef, stateRef, targetSourceRef, applyCurrentTarget, syncMorphUniformToState } = deps;\n  const sceneData = sceneDataRef.current;\n  stateRef.current.uploadedImage = null;\n\n  if (!modelUrl || !sceneData) {\n    if (!modelUrl && sceneData) {\n      const { targetPositions, originalPositions, targetColors, originalColors, geometry } = sceneData;\n      targetPositions.set(originalPositions);\n      targetColors.set(originalColors);\n      geometry.attributes.targetPosition.needsUpdate = true;\n      geometry.attributes.targetColor.needsUpdate = true;\n      syncMorphUniformToState();\n      ensureWebglCanvasVisible(sceneDataRef);\n    }\n    return;\n  }\n\n  concealWebglCanvas(sceneDataRef);\n  primeTargetsForAsyncMediaLoad(sceneData, targetSourceRef, applyCurrentTarget);\n  syncMorphUniformToState();\n\n  void import('three/examples/jsm/loaders/GLTFLoader.js').then(({ GLTFLoader }) => {\n    const loader = new GLTFLoader();\n    loader.load(\n      modelUrl,\n      (gltf) => {\n        if (stateRef.current.uploadedModel !== modelUrl) return;\n\n        const positions: number[] = [];\n        const colors: number[] = [];\n        gltf.scene.updateMatrixWorld(true);\n        gltf.scene.traverse((child) => {\n        if ((child as THREE.Mesh).isMesh) {\n          const mesh = child as THREE.Mesh;\n          const geometry = mesh.geometry;\n          const posAttr = geometry.attributes.position;\n          const uvAttr = geometry.attributes.uv;\n          const colorAttr = geometry.attributes.color;\n\n          const material = (\n            Array.isArray(mesh.material) ? mesh.material[0] : mesh.material\n          ) as THREE.MeshStandardMaterial;\n          const texture = material?.map;\n          const imageData = texture ? getTextureData(texture) : null;\n          const meshColor = material?.color || new THREE.Color(0xffffff);\n\n          for (let i = 0; i < posAttr.count; i++) {\n            const v = new THREE.Vector3().fromBufferAttribute(posAttr, i);\n            v.applyMatrix4(mesh.matrixWorld);\n            positions.push(v.x, v.y, v.z);\n\n            const col = new THREE.Color(1, 1, 1);\n            if (imageData && uvAttr) {\n              const u = ((uvAttr.getX(i) % 1) + 1) % 1;\n              const vUv = ((uvAttr.getY(i) % 1) + 1) % 1;\n              const xPx = Math.floor(u * (imageData.width - 1));\n              const yPx = Math.floor((1 - vUv) * (imageData.height - 1));\n              const idx = (yPx * imageData.width + xPx) * 4;\n              col.setRGB(\n                imageData.data[idx] / 255,\n                imageData.data[idx + 1] / 255,\n                imageData.data[idx + 2] / 255\n              );\n              col.multiply(meshColor);\n            } else if (colorAttr) {\n              col.fromBufferAttribute(colorAttr, i);\n            } else {\n              col.copy(meshColor);\n            }\n            colors.push(col.r, col.g, col.b);\n          }\n        }\n      });\n\n      if (positions.length > 0 && sceneDataRef.current) {\n        const box = new THREE.Box3().setFromObject(gltf.scene);\n        const center = box.getCenter(new THREE.Vector3());\n        const size = box.getSize(new THREE.Vector3());\n        const maxDim = Math.max(size.x, size.y, size.z);\n        const scale = 30 / maxDim;\n\n        const rawPositions = new Float32Array(positions.length);\n        const rawColors = new Float32Array(colors.length);\n\n        for (let i = 0; i < positions.length / 3; i++) {\n          const i3 = i * 3;\n          rawPositions[i3] = (positions[i3] - center.x) * scale;\n          rawPositions[i3 + 1] = (positions[i3 + 1] - center.y) * scale;\n          rawPositions[i3 + 2] = (positions[i3 + 2] - center.z) * scale;\n          rawColors[i3] = colors[i3];\n          rawColors[i3 + 1] = colors[i3 + 1];\n          rawColors[i3 + 2] = colors[i3 + 2];\n        }\n        targetSourceRef.current = { positions: rawPositions, colors: rawColors, type: 'model' };\n        applyCurrentTarget();\n        syncMorphUniformToState();\n        revealWebglCanvas(sceneDataRef);\n      } else {\n        ensureWebglCanvasVisible(sceneDataRef);\n      }\n      },\n      undefined,\n      () => {\n        ensureWebglCanvasVisible(sceneDataRef);\n      }\n    );\n  }).catch(() => {\n    ensureWebglCanvasVisible(sceneDataRef);\n  });\n}\n\nconst EFFECT_MODES: Record<string, number> = {\n  default: 0,\n  scatter: 1,\n  explode: 2,\n  vortex: 3,\n  pulse: 4,\n  wave: 5,\n};\n\nexport type ParticleCanvasRef = {\n  setImage: (imageUrl: string | null) => void;\n  setModel: (modelUrl: string | null) => void;\n  clearImage: () => void;\n  setParticleSize: (size: number) => void;\n  setParticleCount: (count: number) => void;\n  setShape: (shape: string) => void;\n  setEffect: (effect: string) => void;\n  triggerExplosion: () => void;\n  getCanvas: () => HTMLCanvasElement | null;\n  getUploadedModelUrl: () => string | null;\n  setInteractionMode: (mode: 'auto' | 'manual') => void;\n  setManualControlTarget: (target: 'camera' | 'object') => void;\n};\n\nexport type ParticleColorMode = 'original' | 'custom';\n\nexport type NeuralParticleMorphEffect =\n  | 'default'\n  | 'scatter'\n  | 'explode'\n  | 'vortex'\n  | 'pulse'\n  | 'wave';\n\nexport type NeuralParticleMorphShape =\n  | 'default'\n  | 'heart'\n  | 'butterfly'\n  | 'rose'\n  | 'cube'\n  | 'pyramid'\n  | 'spiral'\n  | 'star'\n  | 'sphere'\n  | 'dna'\n  | 'infinity';\n\ntype Props = {\n  particleCount?: number;\n  particleSize?: number;\n  /** WebGL 清屏与容器底色（#RRGGBB） */\n  canvasBackgroundColor?: string;\n  particleColorMode?: ParticleColorMode;\n  /** 自定义模式下的粒子颜色（#RRGGBB） */\n  particleCustomColor?: string;\n  /**\n   * 声明式同步到内部状态；不传则仅由 ref / 初始 mount 控制（主界面 ConfigPanel 用法）。\n   */\n  effect?: NeuralParticleMorphEffect | (string & {});\n  /**\n   * 未提供 imageUrl / modelUrl 时展示该预设；声明式同步，不传则不由 props 驱动形态切换。\n   */\n  shape?: NeuralParticleMorphShape | (string & {});\n  interactionMode?: 'auto' | 'manual';\n  manualControlTarget?: 'camera' | 'object';\n  imageUrl?: string | null;\n  modelUrl?: string | null;\n  /**\n   * 是否监听全局 Space 切换交互模式。多实例嵌入时设为 false，避免快捷键冲突。\n   * @default true\n   */\n  listenForKeyboardToggle?: boolean;\n  /**\n   * 为 true 时用 IntersectionObserver 在元素不可见时暂停 rAF 与渲染，降低 CPU/GPU 占用。\n   * @default false\n   */\n  pauseAnimationWhenHidden?: boolean;\n  /** 传入 `pauseAnimationWhenHidden` 时的 rootMargin，例如 `100px` 可提前恢复动画 */\n  intersectionRootMargin?: string;\n  /**\n   * 传入时在外层包一层 `position:relative` 容器，便于嵌入式固定高度。\n   * 未传 `className` 时默认 `relative w-full`。\n   */\n  className?: string;\n  style?: CSSProperties;\n};\n\n/** sRGB 权重下的相对亮度（0～1）。与导出 HTML 中阈值保持一致。 */\nfunction perceptualLuminanceFromHex(hex: string): number {\n  const c = new THREE.Color(hex);\n  return 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b;\n}\n\n/**\n * AdditiveBlending：输出颜色会直接“加”到目标像素上。若为黑色 RGB≈0，则不会产生任何亮度增量，\n * 无论背景是黑是白都会几乎不可见（常见需求是发光感，不适合深色实体粒子）。\n */\nconst CUSTOM_DARK_LUM_BLEND_THRESHOLD = 0.45;\n\n/** 画布亮度 ≥ 该阈值时使用 NormalBlending，避免浅色底 + 采样偏暗的「原始颜色」在加法混合下消失。 */\nconst LIGHT_BG_BLEND_THRESHOLD = 0.58;\n\nfunction resolvePointsBlending(\n  canvasBgHex: string,\n  mode: ParticleColorMode,\n  particleCustomHex: string\n): THREE.Blending {\n  if (perceptualLuminanceFromHex(canvasBgHex) >= LIGHT_BG_BLEND_THRESHOLD) {\n    return THREE.NormalBlending;\n  }\n  if (mode === 'custom' && perceptualLuminanceFromHex(particleCustomHex) < CUSTOM_DARK_LUM_BLEND_THRESHOLD) {\n    return THREE.NormalBlending;\n  }\n  return THREE.AdditiveBlending;\n}\n\nfunction createDefaultGeometry(particleCount: number) {\n  const geometry = new THREE.BufferGeometry();\n  const positions = new Float32Array(particleCount * 3);\n  const targetPositions = new Float32Array(particleCount * 3);\n  const colors = new Float32Array(particleCount * 3);\n  const targetColors = new Float32Array(particleCount * 3);\n  const randomOffsets = new Float32Array(particleCount * 3);\n  const greenColor = new THREE.Color(0x00ff66);\n  const brightWhite = new THREE.Color(0xffffff);\n\n  for (let i = 0; i < particleCount; i++) {\n    const i3 = i * 3;\n    const t = (Math.random() - 0.5) * 5.0;\n    const angle = Math.random() * Math.PI * 2;\n    const radiusBase = 0.4 + Math.pow(Math.abs(t), 2.4);\n    const radius = radiusBase * (0.75 + Math.random() * 0.55);\n    let x = radius * Math.cos(angle);\n    let z = radius * Math.sin(angle);\n    const y = t * 7.5;\n    const squash = 2.9;\n    x *= squash;\n    z *= squash;\n\n    positions[i3] = x;\n    positions[i3 + 1] = y;\n    positions[i3 + 2] = z;\n    targetPositions[i3] = x;\n    targetPositions[i3 + 1] = y;\n    targetPositions[i3 + 2] = z;\n    randomOffsets[i3] = (Math.random() - 0.5) * 2;\n    randomOffsets[i3 + 1] = (Math.random() - 0.5) * 2;\n    randomOffsets[i3 + 2] = (Math.random() - 0.5) * 2;\n\n    const mixRatio = Math.random();\n    const color = mixRatio > 0.7 ? greenColor : brightWhite;\n    colors[i3] = color.r;\n    colors[i3 + 1] = color.g;\n    colors[i3 + 2] = color.b;\n    targetColors[i3] = color.r;\n    targetColors[i3 + 1] = color.g;\n    targetColors[i3 + 2] = color.b;\n  }\n\n  geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));\n  geometry.setAttribute('targetPosition', new THREE.BufferAttribute(targetPositions, 3));\n  geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));\n  geometry.setAttribute('targetColor', new THREE.BufferAttribute(targetColors, 3));\n  geometry.setAttribute('randomOffset', new THREE.BufferAttribute(randomOffsets, 3));\n\n  return {\n    geometry,\n    positions,\n    targetPositions,\n    colors,\n    targetColors,\n  };\n}\n\nexport const ParticleCanvas = forwardRef<ParticleCanvasRef, Props>(function ParticleCanvas(\n  {\n    particleCount = 90000,\n    particleSize = 200,\n    canvasBackgroundColor = '#000000',\n    particleColorMode = 'original' as ParticleColorMode,\n    particleCustomColor = '#22c55e',\n    effect,\n    shape,\n    interactionMode: interactionModeProp,\n    manualControlTarget: manualControlTargetProp,\n    imageUrl,\n    modelUrl,\n    listenForKeyboardToggle = true,\n    pauseAnimationWhenHidden = false,\n    intersectionRootMargin = '0px',\n    className,\n    style,\n  },\n  ref\n) {\n  const containerRef = useRef<HTMLDivElement | null>(null);\n  /** 确保 WebGL 在 DOM 节点已挂上后再 init（避免首帧 useEffect 时 ref 仍为空或时序导致 scene 未就绪）。 */\n  const [containerEl, setContainerEl] = useState<HTMLDivElement | null>(null);\n  const setContainerNode = useCallback((node: HTMLDivElement | null) => {\n    containerRef.current = node;\n    setContainerEl(node);\n  }, []);\n  const canvasApiRef = useRef<ParticleCanvasRef | null>(null);\n  const hadSourceFromPropsRef = useRef(false);\n  const sceneDataRef = useRef<SceneData | null>(null);\n  const animationIdRef = useRef<number>(0);\n  const suspendAnimationRef = useRef(false);\n  const targetSourceRef = useRef<TargetParticleSource | null>(null);\n  /** 与 props 同步，供 mount 时 WebGL init 在 scene 就绪后再跑 setImage/setModel/generateShape（避免对应 effect 先于 init 执行导致 process* 在 !sceneData 时直接 return、异步加载从未开始）。 */\n  const shapePropRef = useRef(shape);\n  shapePropRef.current = shape;\n  const modelUrlPropRef = useRef(modelUrl);\n  modelUrlPropRef.current = modelUrl;\n  const imageUrlPropRef = useRef(imageUrl);\n  imageUrlPropRef.current = imageUrl;\n  const effectPropRef = useRef(effect);\n  effectPropRef.current = effect;\n  const interactionModePropRef = useRef(interactionModeProp);\n  interactionModePropRef.current = interactionModeProp;\n  const manualControlTargetPropRef = useRef(manualControlTargetProp);\n  manualControlTargetPropRef.current = manualControlTargetProp;\n  const stateRef = useRef({\n    uploadedImage: null as string | null,\n    uploadedModel: null as string | null,\n    particleSize,\n    particleCount,\n    currentEffect: 'default',\n    effectIntensity: 0,\n    targetEffectIntensity: 0,\n    explosionTriggered: false,\n    explosionTime: 0,\n    currentShape: null as string | null,\n    time: 0,\n    morphFactor: 0,\n    interactionMode: (interactionModeProp ?? 'auto') as 'auto' | 'manual',\n    manualControlTarget: (manualControlTargetProp ?? 'camera') as 'camera' | 'object',\n  });\n\n  // 鼠标控制粒子对象的状态\n  const mouseStateRef = useRef({\n    isDragging: false,\n    previousX: 0,\n    previousY: 0,\n  });\n  const resetViewTransform = () => {\n    const sceneData = sceneDataRef.current;\n    if (!sceneData) return;\n\n    sceneData.points.rotation.set(0, 0, 0);\n    sceneData.points.position.set(0, 0, 0);\n    sceneData.points.scale.set(1, 1, 1);\n    mouseStateRef.current.isDragging = false;\n\n    if (sceneData.controls) {\n      sceneData.controls.reset();\n      sceneData.controls.update();\n    }\n  };\n\n  const applyCurrentTarget = () => {\n    const sceneData = sceneDataRef.current;\n    const source = targetSourceRef.current;\n    if (!sceneData || !source) return;\n\n    const { particleCount: count } = stateRef.current;\n    const { targetPositions, targetColors, geometry } = sceneData;\n    const srcPos = source.positions;\n    const srcCol = source.colors;\n    const srcCount = srcPos.length / 3;\n\n    for (let i = 0; i < count; i++) {\n      const i3 = i * 3;\n      let srcIdx: number;\n\n      if (source.type === 'model') {\n        srcIdx = Math.floor(Math.random() * srcCount) * 3;\n      } else {\n        srcIdx = (i % srcCount) * 3;\n      }\n\n      let jitterX = 0, jitterY = 0, jitterZ = 0;\n      if (source.type === 'image') {\n        jitterX = (Math.random() - 0.5) * 0.4;\n        jitterY = (Math.random() - 0.5) * 0.4;\n        jitterZ = (Math.random() - 0.5) * 1.5;\n      } else if (source.type === 'model') {\n        const jitter = 0.15;\n        jitterX = (Math.random() - 0.5) * jitter;\n        jitterY = (Math.random() - 0.5) * jitter;\n        jitterZ = (Math.random() - 0.5) * jitter;\n      } else if (source.type === 'shape') {\n        const spread = 0.3;\n        jitterX = (Math.random() - 0.5) * spread;\n        jitterY = (Math.random() - 0.5) * spread;\n        jitterZ = (Math.random() - 0.5) * spread;\n      }\n\n      targetPositions[i3] = srcPos[srcIdx] + jitterX;\n      targetPositions[i3 + 1] = srcPos[srcIdx + 1] + jitterY;\n      targetPositions[i3 + 2] = srcPos[srcIdx + 2] + jitterZ;\n\n      targetColors[i3] = srcCol[srcIdx];\n      targetColors[i3 + 1] = srcCol[srcIdx + 1];\n      targetColors[i3 + 2] = srcCol[srcIdx + 2];\n    }\n\n    geometry.attributes.targetPosition.needsUpdate = true;\n    geometry.attributes.targetColor.needsUpdate = true;\n  };\n\n  const syncMorphUniformToState = () => {\n    const state = stateRef.current;\n    const sd = sceneDataRef.current;\n    const target = resolveMorphUniform(state, targetSourceRef.current);\n    state.morphFactor = target;\n    if (sd?.material) {\n      sd.material.uniforms.uMorph.value = target;\n    }\n  };\n\n  const sourceDepsForMedia = (): MediaParticleProcessorDeps => ({\n    sceneDataRef,\n    stateRef,\n    targetSourceRef,\n    applyCurrentTarget,\n    syncMorphUniformToState,\n  });\n\n  /**\n   * useImperativeHandle(..., []) 只挂载一次 API，必须用 ref 取「当前渲染」的 deps，\n   * 否则 setImage 会永远拿着首次渲染的 applyCurrentTarget；且 scene 未就绪时提前 return 后无法再应用贴图。\n   */\n  const mediaDepsRef = useRef<MediaParticleProcessorDeps | null>(null);\n  mediaDepsRef.current = sourceDepsForMedia();\n\n  const generateShape = (shapeName: string) => {\n    const sceneData = sceneDataRef.current;\n    if (!sceneData) return;\n\n    const { particleCount: count } = stateRef.current;\n    const points: { pos: [number, number, number]; col: [number, number, number] }[] = [];\n    const greenColor = new THREE.Color(0x00ff66);\n    const brightWhite = new THREE.Color(0xffffff);\n    const butterflyPalette = [\n      new THREE.Color(0xfff38a),\n      new THREE.Color(0xf2b363),\n      new THREE.Color(0xff8faf),\n      new THREE.Color(0xd7ff83),\n      new THREE.Color(0xffffff),\n    ];\n\n    if (shapeName === 'default') {\n      for (let i = 0; i < count; i++) {\n        const t = (Math.random() - 0.5) * 5.0;\n        const angle = Math.random() * Math.PI * 2;\n        const radiusBase = 0.4 + Math.pow(Math.abs(t), 2.4);\n        const radius = radiusBase * (0.75 + Math.random() * 0.55);\n        let x = radius * Math.cos(angle);\n        let z = radius * Math.sin(angle);\n        const y = t * 7.5;\n        const squash = 2.9;\n        x *= squash;\n        z *= squash;\n        const color = Math.random() > 0.7 ? greenColor : brightWhite;\n        points.push({ pos: [x, y, z], col: [color.r, color.g, color.b] });\n      }\n      stateRef.current.uploadedImage = null;\n      stateRef.current.uploadedModel = null;\n    } else if (shapeName === 'heart') {\n      for (let i = 0; i < count; i++) {\n        const t = (i / count) * Math.PI * 2;\n        const u = Math.random() * Math.PI * 2;\n        const v = Math.random() * Math.PI;\n        const scale = 18;\n        const x = (scale * (16 * Math.pow(Math.sin(t), 3))) / 16 * Math.sin(v) * Math.cos(u);\n        const y = scale * (13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t)) / 16;\n        const z = (scale * (16 * Math.pow(Math.sin(t), 3))) / 16 * Math.sin(v) * Math.sin(u) * 0.8;\n        const spread = 3.0;\n        const color = Math.random() > 0.7 ? greenColor : brightWhite;\n        points.push({\n          pos: [x + (Math.random() - 0.5) * spread, y + (Math.random() - 0.5) * spread, z + (Math.random() - 0.5) * spread],\n          col: [color.r, color.g, color.b],\n        });\n      }\n    } else if (shapeName === 'butterfly') {\n      for (let i = 0; i < count; i++) {\n        const t = (i / count) * Math.PI * 12;\n        const scale = 5.5;\n        const exp = Math.exp(Math.cos(t)) - 2 * Math.cos(4 * t) - Math.pow(Math.sin(t / 12), 5);\n        const x = Math.sin(t) * exp * scale;\n        const y = Math.cos(t) * exp * scale;\n        const z = (Math.random() - 0.5) * 15;\n        const spread = 2.5;\n        const color = butterflyPalette[Math.floor(Math.random() * butterflyPalette.length)];\n        points.push({\n          pos: [x + (Math.random() - 0.5) * spread, y + (Math.random() - 0.5) * spread, z],\n          col: [color.r, color.g, color.b],\n        });\n      }\n    } else if (shapeName === 'rose') {\n      for (let i = 0; i < count; i++) {\n        const t = (i / count) * Math.PI * 14;\n        const k = 5;\n        const r = Math.cos(k * t) * 15;\n        const h = (Math.random() - 0.5) * 20;\n        const x = r * Math.cos(t);\n        const y = h;\n        const z = r * Math.sin(t);\n        const spread = 2.0;\n        const color = Math.random() > 0.7 ? greenColor : brightWhite;\n        points.push({\n          pos: [x + (Math.random() - 0.5) * spread, y, z + (Math.random() - 0.5) * spread],\n          col: [color.r, color.g, color.b],\n        });\n      }\n    } else if (shapeName === 'cube') {\n      const cubeSize = 18;\n      for (let i = 0; i < count; i++) {\n        const face = Math.floor(Math.random() * 6);\n        const a = (Math.random() - 0.5) * cubeSize;\n        const b = (Math.random() - 0.5) * cubeSize;\n        let x: number, y: number, z: number;\n        switch (face) {\n          case 0: x = cubeSize / 2; y = a; z = b; break;\n          case 1: x = -cubeSize / 2; y = a; z = b; break;\n          case 2: x = a; y = cubeSize / 2; z = b; break;\n          case 3: x = a; y = -cubeSize / 2; z = b; break;\n          case 4: x = a; y = b; z = cubeSize / 2; break;\n          default: x = a; y = b; z = -cubeSize / 2;\n        }\n        const spread = 2.0;\n        const color = Math.random() > 0.7 ? greenColor : brightWhite;\n        points.push({\n          pos: [x + (Math.random() - 0.5) * spread, y + (Math.random() - 0.5) * spread, z + (Math.random() - 0.5) * spread],\n          col: [color.r, color.g, color.b],\n        });\n      }\n    } else if (shapeName === 'pyramid') {\n      const pyrHeight = 25;\n      const pyrBase = 20;\n      for (let i = 0; i < count; i++) {\n        const onBase = Math.random() < 0.3;\n        let x: number, y: number, z: number;\n        if (onBase) {\n          x = (Math.random() - 0.5) * pyrBase;\n          z = (Math.random() - 0.5) * pyrBase;\n          y = -pyrHeight / 2;\n        } else {\n          const t = Math.random();\n          const baseX = (Math.random() - 0.5) * pyrBase * (1 - t);\n          const baseZ = (Math.random() - 0.5) * pyrBase * (1 - t);\n          x = baseX;\n          y = -pyrHeight / 2 + t * pyrHeight;\n          z = baseZ;\n        }\n        const spread = 2.0;\n        const color = Math.random() > 0.7 ? greenColor : brightWhite;\n        points.push({\n          pos: [x + (Math.random() - 0.5) * spread, y + (Math.random() - 0.5) * spread, z + (Math.random() - 0.5) * spread],\n          col: [color.r, color.g, color.b],\n        });\n      }\n    } else if (shapeName === 'spiral') {\n      for (let i = 0; i < count; i++) {\n        const t = (i / count) * Math.PI * 10;\n        const r = 10 + Math.sin(t * 3) * 4;\n        const x = r * Math.cos(t);\n        const y = (i / count - 0.5) * 40;\n        const z = r * Math.sin(t);\n        const spread = 2.5;\n        const color = Math.random() > 0.7 ? greenColor : brightWhite;\n        points.push({\n          pos: [x + (Math.random() - 0.5) * spread, y + (Math.random() - 0.5) * spread, z + (Math.random() - 0.5) * spread],\n          col: [color.r, color.g, color.b],\n        });\n      }\n    } else if (shapeName === 'star') {\n      const starPoints = 5;\n      const innerR = 8;\n      const outerR = 18;\n      for (let i = 0; i < count; i++) {\n        const angle = Math.random() * Math.PI * 2;\n        const pointAngle = Math.floor(angle / (Math.PI * 2 / starPoints)) * (Math.PI * 2 / starPoints);\n        const t = (angle - pointAngle) / (Math.PI / starPoints);\n        const r = t < 1 ? outerR - (outerR - innerR) * t : innerR + (outerR - innerR) * (t - 1);\n        const x = r * Math.cos(angle);\n        const z = r * Math.sin(angle);\n        const y = (Math.random() - 0.5) * 15;\n        const spread = 2.0;\n        const color = Math.random() > 0.7 ? greenColor : brightWhite;\n        points.push({\n          pos: [x + (Math.random() - 0.5) * spread, y, z + (Math.random() - 0.5) * spread],\n          col: [color.r, color.g, color.b],\n        });\n      }\n    } else if (shapeName === 'sphere') {\n      for (let i = 0; i < count; i++) {\n        const theta = Math.random() * Math.PI * 2;\n        const phi = Math.acos(2 * Math.random() - 1);\n        const r = 16;\n        const x = r * Math.sin(phi) * Math.cos(theta);\n        const y = r * Math.sin(phi) * Math.sin(theta);\n        const z = r * Math.cos(phi);\n        const spread = 2.5;\n        const color = Math.random() > 0.7 ? greenColor : brightWhite;\n        points.push({\n          pos: [x + (Math.random() - 0.5) * spread, y + (Math.random() - 0.5) * spread, z + (Math.random() - 0.5) * spread],\n          col: [color.r, color.g, color.b],\n        });\n      }\n    } else if (shapeName === 'dna') {\n      for (let i = 0; i < count; i++) {\n        const t = (i / count) * Math.PI * 8;\n        const y = (i / count - 0.5) * 45;\n        const r = 10;\n        const strand = i % 3;\n        let x: number, z: number;\n        if (strand === 0) {\n          x = r * Math.cos(t);\n          z = r * Math.sin(t);\n        } else if (strand === 1) {\n          x = r * Math.cos(t + Math.PI);\n          z = r * Math.sin(t + Math.PI);\n        } else {\n          const barPos = Math.random();\n          x = r * Math.cos(t) * (1 - barPos) + r * Math.cos(t + Math.PI) * barPos;\n          z = r * Math.sin(t) * (1 - barPos) + r * Math.sin(t + Math.PI) * barPos;\n        }\n        const spread = 2.0;\n        const color = Math.random() > 0.7 ? greenColor : brightWhite;\n        points.push({\n          pos: [x + (Math.random() - 0.5) * spread, y + (Math.random() - 0.5) * spread, z + (Math.random() - 0.5) * spread],\n          col: [color.r, color.g, color.b],\n        });\n      }\n    } else if (shapeName === 'infinity') {\n      const infScale = 25;\n      for (let i = 0; i < count; i++) {\n        const t = (i / count) * Math.PI * 6;\n        const denom = 1 + Math.sin(t) * Math.sin(t);\n        const x = infScale * Math.cos(t) / denom;\n        const y = infScale * Math.sin(t) * Math.cos(t) / denom;\n        const verticalSpread = (Math.random() - 0.5) * 20;\n        const z = (Math.random() - 0.5) * 15 + Math.sin(t * 2) * 5;\n        const thickness = 4.0;\n        const offsetX = (Math.random() - 0.5) * thickness;\n        const offsetY = (Math.random() - 0.5) * thickness + verticalSpread * 0.3;\n        const offsetZ = (Math.random() - 0.5) * thickness;\n        const color = Math.random() > 0.7 ? greenColor : brightWhite;\n        points.push({\n          pos: [x + offsetX, y + offsetY, z + offsetZ],\n          col: [color.r, color.g, color.b],\n        });\n      }\n    }\n\n    if (points.length > 0) {\n      const rawPositions = new Float32Array(points.length * 3);\n      const rawColors = new Float32Array(points.length * 3);\n\n      for (let i = 0; i < points.length; i++) {\n        const p = points[i];\n        const i3 = i * 3;\n        rawPositions[i3] = p.pos[0];\n        rawPositions[i3 + 1] = p.pos[1];\n        rawPositions[i3 + 2] = p.pos[2];\n        rawColors[i3] = p.col[0];\n        rawColors[i3 + 1] = p.col[1];\n        rawColors[i3 + 2] = p.col[2];\n      }\n\n      targetSourceRef.current = { positions: rawPositions, colors: rawColors, type: 'shape' };\n      applyCurrentTarget();\n\n      stateRef.current.uploadedImage = shapeName === 'default' ? null : 'shape';\n      stateRef.current.uploadedModel = null;\n      syncMorphUniformToState();\n    }\n  };\n\n  useImperativeHandle(ref, () => {\n    const api: ParticleCanvasRef = {\n      setImage(imageUrlV: string | null) {\n        resetViewTransform();\n        stateRef.current.uploadedModel = null;\n        stateRef.current.uploadedImage = imageUrlV;\n        stateRef.current.currentShape = null;\n        const d = mediaDepsRef.current;\n        if (d) processImageParticles(imageUrlV, d);\n      },\n      setModel(modelUrlV: string | null) {\n        resetViewTransform();\n        stateRef.current.uploadedImage = null;\n        stateRef.current.uploadedModel = modelUrlV;\n        stateRef.current.currentShape = null;\n        const d = mediaDepsRef.current;\n        if (d) processModelParticles(modelUrlV, d);\n      },\n      clearImage() {\n        stateRef.current.uploadedImage = null;\n        stateRef.current.uploadedModel = null;\n        stateRef.current.currentShape = null;\n        targetSourceRef.current = null;\n        const d = mediaDepsRef.current;\n        if (d) {\n          processModelParticles(null, d);\n          processImageParticles(null, d);\n        }\n      },\n      getUploadedModelUrl() {\n        return stateRef.current.uploadedModel;\n      },\n      setParticleSize(size: number) {\n        stateRef.current.particleSize = size;\n        if (sceneDataRef.current?.material) {\n          sceneDataRef.current.material.uniforms.uPointSize.value = size;\n        }\n      },\n      setParticleCount(count: number) {\n        const sceneData = sceneDataRef.current;\n        const state = stateRef.current;\n        if (count === state.particleCount || !sceneData) return;\n\n        state.particleCount = count;\n        const { scene, points, material } = sceneData;\n        scene.remove(points);\n        points.geometry.dispose();\n\n        const { geometry, positions, targetPositions, colors, targetColors } = createDefaultGeometry(count);\n        const newPoints = new THREE.Points(geometry, material);\n        scene.add(newPoints);\n\n        sceneDataRef.current = {\n          ...sceneData,\n          points: newPoints,\n          geometry,\n          originalPositions: positions,\n          targetPositions,\n          originalColors: colors,\n          targetColors,\n        };\n\n        if (targetSourceRef.current) {\n          applyCurrentTarget();\n        } else {\n          if (state.currentShape) {\n            generateShape(state.currentShape);\n          } else if (state.uploadedImage && state.uploadedImage !== 'shape') {\n            const d = mediaDepsRef.current;\n            if (d) processImageParticles(state.uploadedImage, d);\n          } else if (state.uploadedModel) {\n            const d = mediaDepsRef.current;\n            if (d) processModelParticles(state.uploadedModel, d);\n          }\n        }\n        syncMorphUniformToState();\n      },\n      setShape(shapeParam: string) {\n        stateRef.current.currentShape = shapeParam;\n        generateShape(shapeParam);\n      },\n      setEffect(effectName: string) {\n        stateRef.current.currentEffect = effectName;\n        if (effectName === 'default') {\n          stateRef.current.targetEffectIntensity = 0;\n          stateRef.current.explosionTriggered = false;\n        } else {\n          stateRef.current.targetEffectIntensity = 1.0;\n          if (effectName === 'explode') {\n            stateRef.current.explosionTriggered = true;\n            stateRef.current.explosionTime = 0;\n          } else {\n            stateRef.current.explosionTriggered = false;\n          }\n        }\n      },\n      triggerExplosion() {\n        if (stateRef.current.currentEffect === 'explode') {\n          stateRef.current.explosionTime = 0;\n          stateRef.current.explosionTriggered = true;\n        }\n      },\n      getCanvas() {\n        return sceneDataRef.current?.renderer.domElement ?? null;\n      },\n      setInteractionMode(mode: 'auto' | 'manual') {\n        stateRef.current.interactionMode = mode;\n        const sceneData = sceneDataRef.current;\n        if (sceneData) {\n          if (mode === 'manual') {\n            sceneData.points.rotation.set(0, 0, 0);\n          }\n          const isManualCamera = mode === 'manual' && stateRef.current.manualControlTarget === 'camera';\n          sceneData.controls.enabled = isManualCamera;\n        }\n      },\n      setManualControlTarget(target: 'camera' | 'object') {\n        stateRef.current.manualControlTarget = target;\n        if (sceneDataRef.current?.controls) {\n          const isManualCamera = stateRef.current.interactionMode === 'manual' && target === 'camera';\n          sceneDataRef.current.controls.enabled = isManualCamera;\n        }\n      },\n    };\n    canvasApiRef.current = api;\n    return api;\n  }, []);\n\n  useLayoutEffect(() => {\n    const container = containerEl;\n    if (!container) return;\n\n    stateRef.current.particleCount = particleCount;\n    stateRef.current.particleSize = particleSize;\n\n    const modeFromPropsOnMount = interactionModePropRef.current;\n    if (modeFromPropsOnMount !== undefined) {\n      stateRef.current.interactionMode = modeFromPropsOnMount;\n    }\n    const targetFromPropsOnMount = manualControlTargetPropRef.current;\n    if (targetFromPropsOnMount !== undefined) {\n      stateRef.current.manualControlTarget = targetFromPropsOnMount;\n    }\n\n    const measure = () => {\n      const rawW = container.clientWidth;\n      const rawH = container.clientHeight;\n      return {\n        width: Math.max(1, rawW || 1),\n        height: Math.max(1, rawH || 1),\n      };\n    };\n\n    let { width, height } = measure();\n\n    const scene = new THREE.Scene();\n    const camera = new THREE.PerspectiveCamera(35, width / height, 0.1, 1000);\n    camera.position.z = 45;\n\n    const renderer = new THREE.WebGLRenderer({\n      antialias: true,\n      alpha: true,\n      powerPreference: 'high-performance',\n    });\n    renderer.setSize(width, height);\n    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));\n    renderer.setClearColor(new THREE.Color(canvasBackgroundColor), 1);\n    container.appendChild(renderer.domElement);\n\n    const { geometry, positions, targetPositions, colors, targetColors } = createDefaultGeometry(particleCount);\n    targetSourceRef.current = { positions: positions, colors: colors, type: 'shape' };\n    const customParticleCol = new THREE.Color(particleCustomColor);\n    const material = new THREE.ShaderMaterial({\n      vertexShader,\n      fragmentShader,\n      transparent: true,\n      uniforms: {\n        uTime: { value: 0 },\n        uMorph: { value: 0 },\n        uPointSize: { value: particleSize },\n        uEffectMode: { value: 0 },\n        uEffectIntensity: { value: 0 },\n        uExplosionTime: { value: 0 },\n        uParticleColorMode: { value: particleColorMode === 'custom' ? 1 : 0 },\n        uCustomParticleColor: {\n          value: new THREE.Vector3(customParticleCol.r, customParticleCol.g, customParticleCol.b),\n        },\n      },\n      depthWrite: false,\n      blending: resolvePointsBlending(canvasBackgroundColor, particleColorMode, particleCustomColor),\n    });\n\n    const points = new THREE.Points(geometry, material);\n    scene.add(points);\n\n    // 初始化 OrbitControls\n    const controls = new OrbitControls(camera, renderer.domElement);\n    controls.enabled = false; // 默认禁用（自动旋转模式）\n    controls.enableDamping = true; // 启用阻尼（惯性）\n    controls.dampingFactor = 0.05;\n    controls.minDistance = 20; // 最小缩放距离\n    controls.maxDistance = 100; // 最大缩放距离\n    controls.enablePan = true; // 允许平移\n    controls.screenSpacePanning = true;\n    controls.maxPolarAngle = Math.PI; // 允许全方位旋转\n\n    sceneDataRef.current = {\n      scene,\n      camera,\n      renderer,\n      points,\n      geometry,\n      material,\n      originalPositions: positions,\n      targetPositions,\n      originalColors: colors,\n      targetColors,\n      controls,\n    };\n\n    const m = modelUrlPropRef.current;\n    const im = imageUrlPropRef.current;\n    const hasModel = m != null && m !== '';\n    const hasImage = im != null && im !== '';\n    if (hasModel || hasImage) {\n      concealWebglCanvas(sceneDataRef);\n    } else {\n      ensureWebglCanvasVisible(sceneDataRef);\n    }\n\n    /** 必须在 scene 就绪后同步调用：props 的 useEffect 可能已在「无 scene」时提前跑过且不会再触发。 */\n    const deps = mediaDepsRef.current;\n    if (deps) {\n      if (hasModel) {\n        hadSourceFromPropsRef.current = true;\n        stateRef.current.uploadedImage = null;\n        stateRef.current.uploadedModel = m;\n        stateRef.current.currentShape = null;\n        processModelParticles(m, deps);\n      } else if (hasImage) {\n        hadSourceFromPropsRef.current = true;\n        stateRef.current.uploadedModel = null;\n        stateRef.current.uploadedImage = im;\n        stateRef.current.currentShape = null;\n        processImageParticles(im, deps);\n      }\n    }\n\n    if (!hasModel && !hasImage) {\n      const shapeFromProps = shapePropRef.current;\n      if (shapeFromProps !== undefined) {\n        stateRef.current.currentShape = shapeFromProps;\n        generateShape(shapeFromProps);\n      }\n    }\n\n    syncMorphUniformToState();\n\n    /** 与 particleify applyDeclarativeProps 一致：scene 就绪后立即同步，避免首帧仍按 auto 自转 */\n    const api = canvasApiRef.current;\n    if (api) {\n      const effectFromProps = effectPropRef.current;\n      if (effectFromProps !== undefined) api.setEffect(effectFromProps);\n      const modeFromProps = interactionModePropRef.current;\n      if (modeFromProps !== undefined) api.setInteractionMode(modeFromProps);\n      const targetFromProps = manualControlTargetPropRef.current;\n      if (targetFromProps !== undefined) api.setManualControlTarget(targetFromProps);\n    }\n\n    const resizeObserver = new ResizeObserver(() => {\n      const next = measure();\n      width = next.width;\n      height = next.height;\n      if (!sceneDataRef.current) return;\n      sceneDataRef.current.camera.aspect = width / height;\n      sceneDataRef.current.camera.updateProjectionMatrix();\n      sceneDataRef.current.renderer.setSize(width, height);\n    });\n    resizeObserver.observe(container);\n\n    // 鼠标控制粒子对象的事件监听器\n    const onMouseDown = (e: MouseEvent) => {\n      const state = stateRef.current;\n      if (state.interactionMode === 'manual' && state.manualControlTarget === 'object') {\n        mouseStateRef.current.isDragging = true;\n        mouseStateRef.current.previousX = e.clientX;\n        mouseStateRef.current.previousY = e.clientY;\n      }\n    };\n\n    const onMouseMove = (e: MouseEvent) => {\n      const state = stateRef.current;\n      const mouseState = mouseStateRef.current;\n      if (state.interactionMode === 'manual' && state.manualControlTarget === 'object' && mouseState.isDragging) {\n        if (!sceneDataRef.current) return;\n        const deltaX = e.clientX - mouseState.previousX;\n        const deltaY = e.clientY - mouseState.previousY;\n\n        // 旋转粒子对象\n        const { points } = sceneDataRef.current;\n        points.rotation.y += deltaX * 0.005;\n        points.rotation.x += deltaY * 0.005;\n\n        mouseState.previousX = e.clientX;\n        mouseState.previousY = e.clientY;\n      }\n    };\n\n    const onMouseUp = () => {\n      mouseStateRef.current.isDragging = false;\n    };\n\n    renderer.domElement.addEventListener('mousedown', onMouseDown);\n    window.addEventListener('mousemove', onMouseMove);\n    window.addEventListener('mouseup', onMouseUp);\n\n    // 添加键盘监听：按 Space 切换交互模式\n    const onKeyDown = (e: KeyboardEvent) => {\n      if (e.code === 'Space' && !e.repeat) {\n        e.preventDefault();\n        const state = stateRef.current;\n        const newMode = state.interactionMode === 'auto' ? 'manual' : 'auto';\n        canvasApiRef.current?.setInteractionMode(newMode);\n        // 提示用户\n        console.log(`[交互模式] ${newMode === 'auto' ? '自动旋转' : '手动控制（拖动鼠标旋转）'}`);\n      }\n    };\n    if (listenForKeyboardToggle) {\n      window.addEventListener('keydown', onKeyDown);\n    }\n\n    function animate() {\n      animationIdRef.current = requestAnimationFrame(animate);\n      if (suspendAnimationRef.current) return;\n\n      const state = stateRef.current;\n      state.time += 0.008;\n\n      const sceneData = sceneDataRef.current;\n      if (!sceneData) return;\n\n      const { renderer, scene, camera, points, material, controls } = sceneData;\n\n      // 根据交互模式决定是否自动旋转\n      if (state.interactionMode === 'manual') {\n        // 手动模式：不自动旋转；控制相机时保持粒子朝向固定\n        if (state.manualControlTarget === 'camera') {\n          points.rotation.set(0, 0, 0);\n          controls.update();\n        }\n        // manualControlTarget === 'object' 时由鼠标拖拽控制旋转\n      } else {\n        // 自动旋转模式：执行自动旋转\n        let rotationSpeed = 0.0025;\n        if (state.currentEffect === 'vortex') {\n          rotationSpeed = 0.008 * (0.5 + state.effectIntensity);\n        } else if (state.currentEffect === 'explode' && state.explosionTriggered) {\n          rotationSpeed = 0.001;\n        }\n\n        points.rotation.y += rotationSpeed;\n        points.rotation.z += 0.001;\n        points.rotation.x = Math.sin(state.time * 0.15) * 0.12;\n      }\n\n      material.uniforms.uTime.value = state.time;\n\n      const targetMorph = resolveMorphUniform(state, targetSourceRef.current);\n      state.morphFactor = targetMorph;\n      material.uniforms.uMorph.value = targetMorph;\n\n      state.effectIntensity += (state.targetEffectIntensity - state.effectIntensity) * 0.08;\n      material.uniforms.uEffectIntensity.value = state.effectIntensity;\n      material.uniforms.uEffectMode.value = EFFECT_MODES[state.currentEffect] ?? 0;\n\n      if (state.explosionTriggered) {\n        state.explosionTime += 0.016;\n        if (state.explosionTime > 2.0) state.explosionTime = 0;\n      }\n      material.uniforms.uExplosionTime.value = state.explosionTime;\n\n      renderer.render(scene, camera);\n    }\n    animate();\n\n    return () => {\n      resizeObserver.disconnect();\n      window.removeEventListener('keydown', onKeyDown);\n      window.removeEventListener('mousemove', onMouseMove);\n      window.removeEventListener('mouseup', onMouseUp);\n      cancelAnimationFrame(animationIdRef.current);\n      if (sceneDataRef.current) {\n        const { renderer, scene, points, controls } = sceneDataRef.current;\n        renderer.domElement.removeEventListener('mousedown', onMouseDown);\n        controls.dispose();\n        points.geometry.dispose();\n        (points.material as THREE.Material).dispose();\n        scene.remove(points);\n        renderer.dispose();\n        if (container.contains(renderer.domElement)) {\n          container.removeChild(renderer.domElement);\n        }\n        sceneDataRef.current = null;\n      }\n    };\n  }, [containerEl]);\n\n  useEffect(() => {\n    if (!pauseAnimationWhenHidden) {\n      suspendAnimationRef.current = false;\n      return;\n    }\n\n    const el = containerRef.current;\n    if (!el) return;\n\n    const observer = new IntersectionObserver(\n      (entries) => {\n        const e = entries[0];\n        suspendAnimationRef.current = !(e?.isIntersecting ?? false);\n      },\n      {\n        threshold: 0,\n        root: null,\n        rootMargin: intersectionRootMargin,\n      }\n    );\n\n    observer.observe(el);\n    return () => {\n      observer.disconnect();\n      suspendAnimationRef.current = false;\n    };\n  }, [pauseAnimationWhenHidden, intersectionRootMargin]);\n\n  useEffect(() => {\n    const sd = sceneDataRef.current;\n    if (!sd) return;\n    sd.renderer.setClearColor(new THREE.Color(canvasBackgroundColor), 1);\n    if (containerRef.current) containerRef.current.style.backgroundColor = canvasBackgroundColor;\n  }, [canvasBackgroundColor]);\n\n  useEffect(() => {\n    const mat = sceneDataRef.current?.material;\n    if (!mat) return;\n    mat.uniforms.uParticleColorMode.value = particleColorMode === 'custom' ? 1 : 0;\n    mat.blending = resolvePointsBlending(canvasBackgroundColor, particleColorMode, particleCustomColor);\n  }, [canvasBackgroundColor, particleColorMode, particleCustomColor]);\n\n  useEffect(() => {\n    const mat = sceneDataRef.current?.material;\n    if (!mat) return;\n    const c = new THREE.Color(particleCustomColor);\n    mat.uniforms.uCustomParticleColor.value.set(c.r, c.g, c.b);\n  }, [particleCustomColor]);\n\n  useEffect(() => {\n    canvasApiRef.current?.setParticleCount(particleCount);\n  }, [particleCount]);\n\n  useEffect(() => {\n    canvasApiRef.current?.setParticleSize(particleSize);\n  }, [particleSize]);\n\n  useEffect(() => {\n    if (effect === undefined) return;\n    canvasApiRef.current?.setEffect(effect);\n  }, [effect]);\n\n  useEffect(() => {\n    if (shape === undefined) return;\n    canvasApiRef.current?.setShape(shape);\n  }, [shape]);\n\n  useEffect(() => {\n    if (interactionModeProp === undefined) return;\n    canvasApiRef.current?.setInteractionMode(interactionModeProp);\n  }, [interactionModeProp]);\n\n  useEffect(() => {\n    if (manualControlTargetProp === undefined) return;\n    canvasApiRef.current?.setManualControlTarget(manualControlTargetProp);\n  }, [manualControlTargetProp]);\n\n  useEffect(() => {\n    const api = canvasApiRef.current;\n    if (!api) return;\n\n    const hasModel = modelUrl != null && modelUrl !== '';\n    const hasImage = imageUrl != null && imageUrl !== '';\n\n    if (hasModel) {\n      api.setModel(modelUrl);\n      hadSourceFromPropsRef.current = true;\n      return;\n    }\n    if (hasImage) {\n      api.setImage(imageUrl as string);\n      hadSourceFromPropsRef.current = true;\n      return;\n    }\n\n    if (hadSourceFromPropsRef.current) {\n      api.clearImage();\n      api.setShape(shape ?? 'default');\n      hadSourceFromPropsRef.current = false;\n    }\n  }, [modelUrl, imageUrl, shape]);\n\n  const canvasEl = (\n    <div\n      ref={setContainerNode}\n      className=\"absolute inset-0 w-full h-full\"\n      style={{ backgroundColor: canvasBackgroundColor }}\n    />\n  );\n\n  if (className !== undefined || style !== undefined) {\n    return (\n      <div\n        className={className ?? 'relative w-full'}\n        style={{ position: 'relative', overflow: 'hidden', minHeight: 1, ...style }}\n      >\n        {canvasEl}\n      </div>\n    );\n  }\n\n  return canvasEl;\n});\n",
      "type": "registry:component"
    },
    {
      "path": "registry/talizen/effects/particle/ParticleCanvas.types.ts",
      "content": "import type * as THREE from 'three';\nimport type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';\n\nexport type TargetParticleSource = {\n  positions: Float32Array;\n  colors: Float32Array;\n  type: 'image' | 'model' | 'shape';\n};\n\nexport type SceneData = {\n  scene: THREE.Scene;\n  camera: THREE.PerspectiveCamera;\n  renderer: THREE.WebGLRenderer;\n  points: THREE.Points;\n  geometry: THREE.BufferGeometry;\n  material: THREE.ShaderMaterial;\n  originalPositions: Float32Array;\n  targetPositions: Float32Array;\n  originalColors: Float32Array;\n  targetColors: Float32Array;\n  controls: OrbitControls;\n};\n",
      "type": "registry:component"
    },
    {
      "path": "registry/talizen/effects/particle/shaders.ts",
      "content": "export const vertexShader = `\nuniform float uTime;\nuniform float uMorph;\nuniform float uPointSize;\nuniform int uEffectMode;\nuniform float uEffectIntensity;\nuniform float uExplosionTime;\nuniform float uParticleColorMode;\nuniform vec3 uCustomParticleColor;\nattribute vec3 targetPosition;\nattribute vec3 targetColor;\nattribute vec3 color;\nattribute vec3 randomOffset;\nvarying vec3 vColor;\nvarying float vDistance;\n\nvec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }\nvec2 mod289(vec2 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }\nvec3 permute(vec3 x) { return mod289(((x * 34.0) + 1.0) * x); }\n\nfloat snoise(vec2 v) {\n    const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439);\n    vec2 i  = floor(v + dot(v, C.yy));\n    vec2 x0 = v - i + dot(i, C.xx);\n    vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);\n    vec4 x12 = x0.xyxy + C.xxzz;\n    x12.xy -= i1;\n    i = mod289(i);\n    vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));\n    vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0);\n    m = m * m; m = m * m;\n    vec3 x = 2.0 * fract(p * C.www) - 1.0;\n    vec3 h = abs(x) - 0.5;\n    vec3 ox = floor(x + 0.5);\n    vec3 a0 = x - ox;\n    m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);\n    vec3 g;\n    g.x = a0.x * x0.x + h.x * x0.y;\n    g.yz = a0.yz * x12.xz + h.yz * x12.yw;\n    return 130.0 * dot(m, g);\n}\n\nvoid main() {\n    vec3 mixedBase = mix(color, targetColor, uMorph);\n    vColor = mix(mixedBase, uCustomParticleColor, uParticleColorMode);\n    vec3 pos = mix(position, targetPosition, uMorph);\n    vec3 originalPos = pos;\n    float effectMix = uEffectIntensity;\n    \n    if (uEffectMode == 0) {\n        float noise = sin(uTime * 1.5 + position.x * 0.3) * cos(uTime * 1.5 + position.y * 0.3);\n        pos += normalize(pos) * noise * (0.2 * (1.0 - uMorph));\n        pos.x += sin(uTime * 0.3 + position.z) * 0.1;\n        pos.y += cos(uTime * 0.3 + position.x) * 0.1;\n    }\n    else if (uEffectMode == 1) {\n        vec3 scatterDir = normalize(pos + randomOffset * 0.5);\n        float scatterDist = length(pos) * 0.5 + randomOffset.x * 3.0;\n        vec3 scattered = pos + scatterDir * scatterDist * effectMix * 2.5;\n        float turb = snoise(pos.xy * 0.3 + uTime * 0.5);\n        scattered += vec3(turb, turb * 0.5, turb * 0.3) * effectMix * 1.5;\n        pos = mix(originalPos, scattered, effectMix);\n    }\n    else if (uEffectMode == 2) {\n        float explodeProgress = min(uExplosionTime * 2.0, 1.0);\n        float returnProgress = max(0.0, (uExplosionTime - 0.5) * 2.0);\n        vec3 explodeDir = normalize(pos + randomOffset);\n        float explodeDist = (5.0 + randomOffset.x * 8.0) * sin(explodeProgress * 3.14159);\n        vec3 exploded = originalPos + explodeDir * explodeDist * effectMix;\n        float spin = explodeProgress * 6.28318 * (0.5 + randomOffset.y);\n        exploded.x += cos(spin) * explodeDist * 0.3;\n        exploded.z += sin(spin) * explodeDist * 0.3;\n        pos = mix(originalPos, exploded, effectMix * (1.0 - returnProgress * 0.7));\n    }\n    else if (uEffectMode == 3) {\n        float angle = atan(pos.z, pos.x);\n        float radius = length(pos.xz);\n        float height = pos.y;\n        float spiralSpeed = uTime * 2.0 + height * 0.3;\n        float newAngle = angle + spiralSpeed * effectMix;\n        float vortexPull = (1.0 - abs(height) / 20.0) * effectMix;\n        float newRadius = radius * (1.0 - vortexPull * 0.5) + sin(uTime * 3.0 + height) * effectMix;\n        float lift = effectMix * 5.0 * (1.0 - radius / 20.0);\n        pos.x = cos(newAngle) * newRadius;\n        pos.z = sin(newAngle) * newRadius;\n        pos.y = height + lift * sin(uTime + radius);\n    }\n    else if (uEffectMode == 4) {\n        float pulsePhase = uTime * 2.5;\n        float pulseFactor = 1.0 + sin(pulsePhase) * 0.4 * effectMix;\n        float waveFactor = sin(pulsePhase + length(pos) * 0.3) * 0.3 * effectMix;\n        vec3 pulsed = pos * pulseFactor;\n        pulsed += normalize(pos) * waveFactor * 3.0;\n        float colorPulse = sin(pulsePhase * 0.5) * 0.5 + 0.5;\n        vColor = mix(vColor, vec3(1.0, 0.4, 0.8), colorPulse * effectMix * 0.3);\n        pos = pulsed;\n    }\n    else if (uEffectMode == 5) {\n        float waveX = sin(pos.x * 0.5 + uTime * 2.0) * effectMix * 3.0;\n        float waveZ = cos(pos.z * 0.5 + uTime * 1.5) * effectMix * 2.0;\n        float waveY = sin(pos.x * 0.3 + pos.z * 0.3 + uTime * 2.5) * effectMix * 4.0;\n        waveY += sin(pos.x * 0.8 - uTime * 1.8) * effectMix * 1.5;\n        waveY += cos(pos.z * 0.6 + uTime * 1.2) * effectMix * 1.0;\n        pos.x += waveX * 0.3;\n        pos.y += waveY;\n        pos.z += waveZ * 0.3;\n    }\n\n    vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0);\n    float dist = length(pos);\n    vDistance = dist;\n\n    float sizeMultiplier = 1.0;\n    if (uEffectMode == 2 && effectMix > 0.1) {\n        sizeMultiplier = 1.0 + sin(uExplosionTime * 10.0) * 0.3;\n    }\n    if (uEffectMode == 4) {\n        sizeMultiplier = 1.0 + sin(uTime * 2.5) * 0.2 * effectMix;\n    }\n    \n    gl_PointSize = (uPointSize / -mvPosition.z) * (1.2 + sin(uTime * 3.0 + dist * 0.15) * 0.5) * sizeMultiplier;\n    gl_Position = projectionMatrix * mvPosition;\n}\n`;\n\nexport const fragmentShader = `\nuniform float uTime;\nvarying vec3 vColor;\nvarying float vDistance;\nvoid main() {\n    float dist = distance(gl_PointCoord, vec2(0.5));\n    if (dist > 0.5) discard;\n    float strength = pow(1.0 - dist * 2.0, 1.6);\n    vec3 finalColor = vColor * 2.0;\n    float alpha = strength * (0.8 + sin(vDistance * 0.3 + uTime) * 0.2);\n    gl_FragColor = vec4(finalColor, alpha);\n}\n`;\n",
      "type": "registry:component"
    }
  ],
  "categories": [
    "effects"
  ],
  "type": "registry:block"
}