You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

React TS实现随机泼墨效果遇阻:粒子可见、代码无法运行及优化需求

问题:优化Canvas泼墨效果并修复React TS代码运行问题

需求说明

  • 实现类似画家泼墨的Color Splash Cannon效果,参考某Codepen示例(点击左上角触发泼墨)
  • 原效果粒子运动轨迹不够自然,需通过贝塞尔曲线实现更随机的泼墨形状
  • 新增长按鼠标生成更大泼墨的功能
  • 已将代码重构为React TS应用,但代码无法运行,需排查问题并实现需求优化

现有代码的核心问题

  • Props设计错误:将canvas、context、particles等组件内部状态定义为Props,违反React单向数据流原则(Props只读)
  • Canvas操作逻辑混乱:未正确使用useRef获取DOM节点,直接操作props.canvas,且useEffect中引用未定义变量i
  • 变量未正确初始化:context变量未定义就直接使用,props.particles被直接赋值为空数组(Props不可修改)
  • 事件绑定错误:直接给props.canvas绑定鼠标事件,未在组件生命周期内绑定到真实DOM
  • 动画逻辑未启动:animloop函数未调用,粒子的animate和render流程未执行
  • 颜色函数逻辑错误:randomColorSet返回数组而非合法的颜色值,generate函数操作不存在的DOM元素且引用未定义变量

修复并优化后的完整代码

import React, { useRef, useEffect, useState } from "react";

// 粒子类型定义
interface Particle {
  x: number;
  y: number;
  startX: number;
  startY: number;
  controlX: number;
  controlY: number;
  endX: number;
  endY: number;
  size: number;
  life: number;
  maxLife: number;
  color: string;
}

function InkSplashCanvas() {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [particles, setParticles] = useState<Particle[]>([]);
  const [pressStartTime, setPressStartTime] = useState<number | null>(null);
  const animationFrameRef = useRef<number>(0);

  // 适配设备像素比,避免Canvas模糊
  const setupCanvas = () => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    const dpr = window.devicePixelRatio || 1;
    const rect = canvas.getBoundingClientRect();

    canvas.width = rect.width * dpr;
    canvas.height = rect.height * dpr;

    const ctx = canvas.getContext("2d");
    if (ctx) {
      ctx.scale(dpr, dpr);
    }
  };

  // 生成随机颜色(从预设色组中选取渐变或单色)
  const getRandomColor = () => {
    const colorGroups = [
      ["#FE7E9C", "#ff80df"],
      ["#93f", "#ff80df"],
      ["#d8dadb", "#53ceef"],
      ["#FE7E9C", "#F7BD8D"],
    ];
    const group = colorGroups[Math.floor(Math.random() * colorGroups.length)];
    return Math.random() > 0.5 ? group[0] : group[1];
  };

  // 生成贝塞尔曲线控制点,模拟随机泼墨轨迹
  const generateBezierPoints = (x: number, y: number) => {
    const offsetRange = 50 + Math.random() * 100;
    return {
      controlX: x + (Math.random() - 0.5) * offsetRange,
      controlY: y + (Math.random() - 0.5) * offsetRange,
      endX: x + (Math.random() - 0.5) * offsetRange * 1.5,
      endY: y + (Math.random() - 0.5) * offsetRange * 1.5,
    };
  };

  // 生成粒子,根据长按时间调整数量和大小
  const generateParticles = (x: number, y: number, pressDuration: number) => {
    const baseCount = 36;
    const countMultiplier = Math.min(3, 1 + pressDuration / 500); // 长按最长3倍数量
    const particleCount = Math.floor(baseCount * countMultiplier);

    const newParticles: Particle[] = [];
    for (let i = 0; i < particleCount; i++) {
      const { controlX, controlY, endX, endY } = generateBezierPoints(x, y);
      newParticles.push({
        x,
        y,
        startX: x,
        startY: y,
        controlX,
        controlY,
        endX,
        endY,
        size: 3 + Math.random() * 4 * countMultiplier,
        life: 200 + Math.random() * 100,
        maxLife: 200 + Math.random() * 100,
        color: getRandomColor(),
      });
    }
    setParticles(prev => [...prev, ...newParticles]);
  };

  // 更新粒子位置(基于贝塞尔曲线插值)
  const animateParticles = () => {
    setParticles(prevParticles => {
      return prevParticles.filter(p => {
        // 计算生命周期进度(0到1)
        const progress = 1 - p.life / p.maxLife;
        // 贝塞尔曲线插值计算当前位置
        p.x =
          Math.pow(1 - progress, 2) * p.startX +
          2 * (1 - progress) * progress * p.controlX +
          Math.pow(progress, 2) * p.endX;
        p.y =
          Math.pow(1 - progress, 2) * p.startY +
          2 * (1 - progress) * progress * p.controlY +
          Math.pow(progress, 2) * p.endY;

        p.life -= 16; // 约60fps的帧间隔
        p.size *= 0.98; // 缓慢缩小

        return p.life > 0 && p.size > 0.5;
      });
    });
  };

  // 渲染粒子(随机圆形/椭圆/不规则路径,模拟泼墨)
  const renderParticles = () => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    // 半透明背景叠加,实现拖尾效果
    ctx.fillStyle = "rgba(255,255,255,0.05)";
    ctx.fillRect(0, 0, canvas.width / window.devicePixelRatio, canvas.height / window.devicePixelRatio);

    particles.forEach(p => {
      ctx.fillStyle = p.color;
      ctx.beginPath();
      // 随机绘制圆形或椭圆,增强泼墨随机性
      if (Math.random() > 0.3) {
        const rx = p.size * (0.8 + Math.random() * 0.4);
        const ry = p.size * (0.8 + Math.random() * 0.4);
        ctx.ellipse(p.x, p.y, rx, ry, Math.random() * Math.PI, 0, Math.PI * 2);
      } else {
        // 不规则路径模拟墨点扩散
        ctx.moveTo(p.x, p.y);
        for (let i = 0; i < 5; i++) {
          const angle = (Math.PI * 2 * i) / 5;
          const radius = p.size * (0.5 + Math.random() * 0.5);
          ctx.lineTo(p.x + Math.cos(angle) * radius, p.y + Math.sin(angle) * radius);
        }
        ctx.closePath();
      }
      ctx.fill();
    });
  };

  // 动画循环
  const animateLoop = () => {
    animateParticles();
    renderParticles();
    animationFrameRef.current = requestAnimationFrame(animateLoop);
  };

  // 鼠标事件处理
  const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
    const rect = canvasRef.current?.getBoundingClientRect();
    if (!rect) return;
    const x = e.clientX - rect.left;
    const y = e.clientY - rect.top;
    setPressStartTime(Date.now());
    // 立即生成基础粒子
    generateParticles(x, y, 0);
  };

  const handleMouseUp = () => {
    if (!pressStartTime) return;
    const pressDuration = Date.now() - pressStartTime;
    const rect = canvasRef.current?.getBoundingClientRect();
    if (!rect) return;
    // 长按补充生成更多粒子
    if (pressDuration > 200) {
      const lastParticle = particles[particles.length - 1];
      if (lastParticle) {
        generateParticles(lastParticle.x, lastParticle.y, pressDuration);
      }
    }
    setPressStartTime(null);
  };

  useEffect(() => {
    setupCanvas();
    animateLoop();

    // 清理动画帧
    return () => {
      cancelAnimationFrame(animationFrameRef.current);
    };
  }, []);

  // 窗口大小变化时重新适配Canvas
  useEffect(() => {
    const handleResize = () => setupCanvas();
    window.addEventListener("resize", handleResize);
    return () => window.removeEventListener("resize", handleResize);
  }, []);

  return (
    <canvas
      ref={canvasRef}
      style={{ width: "100vw", height: "100vh", cursor: "crosshair" }}
      onMouseDown={handleMouseDown}
      onMouseUp={handleMouseUp}
      onMouseLeave={handleMouseUp}
    />
  );
}

export default InkSplashCanvas;

优化说明

  1. 状态管理修正:改用useState管理粒子数组和长按时间,useRef获取Canvas节点,符合React状态管理规范
  2. 自然运动轨迹:使用二次贝塞尔曲线计算粒子路径,模拟真实泼墨的随机扩散效果
  3. 长按功能实现:记录鼠标按下时长,根据时长生成不同数量和大小的粒子,长按越久泼墨范围越大
  4. Canvas适配优化:自动适配设备像素比,避免模糊,窗口大小变化时重新调整Canvas尺寸
  5. 泼墨视觉优化:随机生成圆形、椭圆、不规则路径的粒子,搭配预设渐变色彩组,更接近真实泼墨效果
  6. 性能优化:使用requestAnimationFrame实现流畅动画,自动清理动画帧避免内存泄漏

内容的提问来源于stack exchange,提问作者user18943198

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.17 21:01:02