Web特效019-Web特效的定义与边界核心算法手写一遍:先跑起来,再谈高级效果

Web特效019-Web特效的定义与边界核心算法手写一遍:先跑起来,再谈高级效果

本文围绕一个核心问题展开:面对 Web 特效的定义与边界,怎样不依赖现成库,亲手把一个最小可运行的核心算法写出来。文章将从输入数据、状态保存和逐帧更新开始,逐步实现位置计算、时间推进、边界处理、粒子创建与失活回收,再把这些结果交给 Canvas 2D 绘制。重点不是堆叠复杂效果,而是通过一条能运行、能观察、能修改的最小链路,理解算法如何从公式变成浏览器中的真实画面,并为后续加入噪声、碰撞、混合和性能优化建立可靠基础。

一、先确定最小目标:一个算法怎样才算真正跑起来

"算法跑起来"不等于代码没有报错,也不等于页面出现了一个好看的图形。对 Web 特效来说,最小可运行目标至少要满足三点:输入数据明确,算法产生了可检查的输出,输出能被浏览器绘制或通过日志观察。先建立这个闭环,后面再增加粒子数量、物理规则和视觉效果才不会失去参照。

1. 先把输入、计算和输出写成一条链

一个最小运动算法可以从位置、速度和时间间隔开始:

text 复制代码
输入:位置 position、速度 velocity、时间 deltaTime
计算:位移 = 速度 × 时间
输出:下一位置 nextPosition

用函数表达时,输入和输出都应该清楚:

js 复制代码
function movePoint(position, velocity, deltaTime) {
  return {
    x: position.x + velocity.x * deltaTime,
    y: position.y + velocity.y * deltaTime,
  };
}

const position = { x: 100, y: 80 };
const velocity = { x: 60, y: -30 };
const next = movePoint(position, velocity, 0.5);

console.log(next); // { x: 130, y: 65 }

position 和 next 的单位是 px,velocity 的单位是 px/s,deltaTime 的单位是秒。0.5 秒内,x 增加 30 px,y 减少 15 px。即使还没有 Canvas,这个函数也已经能通过数值输出验证。

2. 可观察结果比"没有报错"更重要

如果函数只执行、不返回值,调用者无法判断它是否算对。可以用断言把预期结果写出来:

js 复制代码
function assertClose(actual, expected, message) {
  const difference = Math.abs(actual - expected);
  if (difference > 0.0001) {
    throw new Error(
      message + ': expected ' + expected + ', got ' + actual,
    );
  }
}

assertClose(next.x, 130, 'x position');
assertClose(next.y, 65, 'y position');
console.log('最小运动算法验证通过');

0.0001 是浮点数比较容差,不是运动单位。断言通过表示输入、公式和输出符合预期;断言失败时,问题可以缩小到字段、单位或计算顺序。

也可以让函数返回完整的计算过程,便于调试:

js 复制代码
function explainMove(position, velocity, deltaTime) {
  const displacement = {
    x: velocity.x * deltaTime,
    y: velocity.y * deltaTime,
  };

  return {
    displacement,
    nextPosition: {
      x: position.x + displacement.x,
      y: position.y + displacement.y,
    },
  };
}

console.log(explainMove(
  { x: 100, y: 80 },
  { x: 60, y: -30 },
  0.5,
));

displacement 是本次位移,单位是 px;nextPosition 是下一时刻的位置。把中间值暴露出来,能发现把速度直接加到位置、把毫秒当成秒等错误。

3. 最小画面只需要一个点和一个绘制函数

当数值算法验证通过后,再把输出接入 Canvas。Canvas 坐标以左上角为原点,x 向右、y 向下;因此向上的速度通常使用负的 vy:

js 复制代码
function drawPoint(ctx, point) {
  ctx.fillStyle = '#50aaff';
  ctx.beginPath();
  ctx.arc(point.x, point.y, 8, 0, Math.PI * 2);
  ctx.fill();
}

const canvas = document.querySelector('#stage');
const ctx = canvas.getContext('2d');
const point = { x: 100, y: 80 };

ctx.clearRect(0, 0, canvas.width, canvas.height);
drawPoint(ctx, point);

drawPoint 只负责把 point 转换成半径为 8 px 的圆,不负责改变 point。画面中出现一个明确位置的点,就完成了最小的"数据到像素"验证。若点不出现,应先检查 Canvas 元素、上下文和坐标范围。

4. 让一个最小时间步真正改变画面

下一步是把 movePoint 接到一次更新和绘制中:

js 复制代码
function step(point, velocity, deltaTime) {
  return movePoint(point, velocity, deltaTime);
}

const startPoint = { x: 100, y: 80 };
const velocity = { x: 60, y: -30 };
const nextPoint = step(startPoint, velocity, 1 / 60);

ctx.clearRect(0, 0, canvas.width, canvas.height);
drawPoint(ctx, startPoint);
drawPoint(ctx, nextPoint);

这段代码同时画出旧位置和新位置。若两个点相距约 1 px,说明时间步长、速度和绘制坐标已经连接起来;若距离异常,优先检查 deltaTime 的单位。

可以把一次完整步骤的输入和输出集中起来:

js 复制代码
function runStep(state, deltaTime) {
  const nextPosition = movePoint(
    state.position,
    state.velocity,
    deltaTime,
  );

  return {
    ...state,
    position: nextPosition,
  };
}

const state = {
  position: { x: 100, y: 80 },
  velocity: { x: 60, y: -30 },
};

const nextState = runStep(state, 1 / 60);
console.log(nextState);

runStep 不修改旧 state,而是返回新状态。这种写法适合先验证算法,因为每一步的输入和输出都能保留;之后如果性能需要,也可以改成原地更新。

5. 用最小验收清单判断是否真的跑起来

一个核心算法可以用以下清单验收:

text 复制代码
1. 输入字段有明确名称和单位
2. 函数能够返回可观察的输出
3. 至少有一个数值案例可以手算验证
4. 输出能够被 Canvas 绘制成可见结果
5. 时间步长改变时,结果仍符合速度单位
6. 输入异常时,能够通过日志或断言定位

可以把其中几项写成运行时检查:

js 复制代码
function validateStep(state, deltaTime) {
  if (!Number.isFinite(deltaTime) || deltaTime < 0) {
    throw new Error('deltaTime 必须是非负秒数');
  }
  if (!Number.isFinite(state.position.x)
      || !Number.isFinite(state.position.y)) {
    throw new Error('position 必须是有限像素坐标');
  }
  if (!Number.isFinite(state.velocity.x)
      || !Number.isFinite(state.velocity.y)) {
    throw new Error('velocity 必须是有限的 px/s');
  }
  return true;
}

validateStep(state, 1 / 60);
console.log('状态检查通过');

当输入、公式、输出和画面都可以单独检查时,算法才算真正跑起来。这个最小闭环不追求复杂,而是给后续每一次扩展保留可比较的基准:新增一个字段时知道它影响哪里,替换一种渲染方式时知道运动数据是否仍然正确。下一节将在这个闭环上继续设计核心输入和初始状态。

二、从输入到状态:核心数据和初始条件应该怎样设计

算法能否稳定运行,首先取决于输入是否明确。鼠标位置、画布尺寸、粒子数量、初速度和随机范围都属于输入;位置、速度、寿命和激活标记则属于算法运行中的状态。把外部输入直接混进每一帧的计算,会让结果难以复现,因此应先校验并转换成统一的初始状态。

1. 先区分外部输入和内部状态

外部输入通常来自配置、用户操作或资源信息,内部状态则会随着时间改变:

text 复制代码
外部输入:画布宽高、初始位置、初速度、粒子数量
内部状态:当前位置、当前速度、剩余寿命、active

可以用工厂函数把配置转换成状态:

js 复制代码
function createParticle(config) {
  return {
    x: config.x,
    y: config.y,
    vx: config.vx,
    vy: config.vy,
    radius: config.radius,
    life: config.life,
    maxLife: config.life,
    active: true,
  };
}

const config = {
  x: 120,
  y: 80,
  vx: 60,
  vy: -30,
  radius: 6,
  life: 2,
};

const particle = createParticle(config);
console.log(particle);

config 是创建时使用的输入,particle 是后续每帧会改变的状态。maxLife 保存原始寿命,是因为 life 会持续减少,而透明度或进度计算仍然需要知道总寿命。这个边界能避免把配置对象误当成动态状态。

2. 输入必须经过范围和类型校验

如果 x、速度或寿命是字符串、NaN 或负数,算法可能不会立刻报错,却会逐帧产生错误结果。可以在状态创建前校验:

js 复制代码
function requireFinite(name, value) {
  if (!Number.isFinite(value)) {
    throw new TypeError(name + ' 必须是有限数字');
  }
  return value;
}

function validateConfig(config) {
  requireFinite('x', config.x);
  requireFinite('y', config.y);
  requireFinite('vx', config.vx);
  requireFinite('vy', config.vy);

  if (config.radius < 0) {
    throw new RangeError('radius 不能小于 0');
  }
  if (config.life <= 0) {
    throw new RangeError('life 必须大于 0');
  }

  return true;
}

validateConfig(config);

x、y 和 radius 使用 px,vx、vy 使用 px/s,life 使用秒。类型检查解决"能不能计算",范围检查解决"算出来是否有意义"。越靠近输入边界完成校验,后续 update 函数就越简单。

3. 用默认值把不完整输入补成稳定状态

真实项目中的配置经常只提供部分字段,例如只指定发射点和颜色,速度、半径和寿命使用默认值:

js 复制代码
function normalizeConfig(input, canvas) {
  const width = canvas.width;
  const height = canvas.height;

  return {
    x: input.x ?? width / 2,
    y: input.y ?? height / 2,
    vx: input.vx ?? 0,
    vy: input.vy ?? -60,
    radius: input.radius ?? 4,
    life: input.life ?? 1.5,
    color: input.color ?? '#50aaff',
  };
}

const canvas = document.querySelector('#stage');
const normalized = normalizeConfig(
  { x: 100, color: '#ffb347' },
  canvas,
);

console.log(normalized);

这里的 canvas.width 和 canvas.height 是画布内部像素尺寸,不一定等于 CSS 显示尺寸。默认发射点使用画布中心,默认向上速度为 -60 px/s。归一化后,创建函数不必在每个字段上反复判断 undefined。

4. 初始条件决定第一次画面是否可解释

同一个算法换一组初始位置和速度,第一帧之后的结果就会不同。为了排查公式,先使用固定输入,不要一开始加入随机数:

js 复制代码
function simulateSteps(state, stepCount, deltaTime) {
  const history = [structuredClone(state)];

  for (let i = 0; i < stepCount; i += 1) {
    state.x += state.vx * deltaTime;
    state.y += state.vy * deltaTime;
    history.push(structuredClone(state));
  }

  return history;
}

const history = simulateSteps(
  { x: 0, y: 100, vx: 50, vy: 0 },
  3,
  0.2,
);

console.table(history);

x 从 0 开始,速度是 50 px/s,每步 0.2 秒,所以三步后依次得到 10、20、30 px。固定初始条件让每次运行都得到同样的 history;如果结果变化,说明输入或算法中存在未控制的随机因素。

如果需要随机发射,可以把随机源独立出来:

js 复制代码
function randomBetween(min, max, random = Math.random) {
  return min + (max - min) * random();
}

const vx = randomBetween(-20, 20);
const vy = randomBetween(-80, -40);
console.log({ vx, vy });

random 参数可替换为固定函数,测试时就能复现:

js 复制代码
const fixedRandom = () => 0.5;
console.log(randomBetween(-20, 20, fixedRandom)); // 0

这样随机性属于输入生成层,而不是隐藏在核心更新公式中。

5. 把输入快照作为调试和回放依据

当特效出现偶发问题时,保存初始输入和关键状态,可以重放同一过程:

js 复制代码
function createSimulation(input) {
  const config = normalizeConfig(input, canvas);
  validateConfig(config);

  return {
    config: structuredClone(config),
    state: createParticle(config),
    time: 0,
  };
}

const simulation = createSimulation({
  x: 120,
  y: 100,
  vx: 40,
  vy: -50,
  radius: 5,
  life: 2,
});

console.log(simulation);

simulation.config 是输入快照,simulation.state 是动态状态,simulation.time 的单位是秒。调试时只要保留 config 和时间步长,就能重新构造相同的初始场景,而不必依赖用户当时是否恰好移动了鼠标或触发了某个事件。

因此,设计初始条件时应遵循一条简单链路:先接收外部输入,再校验类型和范围,随后补齐默认值,最后创建可持续更新的状态。状态一旦建立,后续公式只处理统一单位和稳定字段,算法的行为才容易解释、测试和回放。下一节将在这些初始状态上加入逐帧时间推进。

三、把公式写成循环:位置、速度与时间如何逐帧推进

核心算法真正进入动画后,关键不是"每帧移动一点",而是把时间间隔写进公式。位置由速度推进,速度又可能被加速度改变;每一帧只负责计算从当前时刻到下一时刻的变化,不能把固定的屏幕刷新次数误当成物理时间。

1. 先用速度推进位置

最简单的匀速运动公式是:

text 复制代码
位移 = 速度 × 时间间隔
新位置 = 旧位置 + 位移

对应的代码如下:

js 复制代码
function advancePosition(position, velocity, deltaTime) {
  return {
    x: position.x + velocity.x * deltaTime,
    y: position.y + velocity.y * deltaTime,
  };
}

const position = { x: 100, y: 80 };
const velocity = { x: 120, y: -60 };
const next = advancePosition(position, velocity, 0.25);

console.log(next); // { x: 130, y: 65 }

velocity 的单位是 px/s,deltaTime 的单位是秒,返回位置的单位仍然是 px。0.25 秒内 x 移动 30 px,y 移动 -15 px。只要时间间隔真实,30 FPS 和 60 FPS 在相同总时间内会得到接近的运动距离。

2. 加速度先改变速度,再改变位置

重力、风力和阻力通常不会直接改位置,而是先改变速度:

js 复制代码
function integrate(state, acceleration, deltaTime) {
  const nextVelocity = {
    x: state.vx + acceleration.x * deltaTime,
    y: state.vy + acceleration.y * deltaTime,
  };

  const nextPosition = {
    x: state.x + nextVelocity.x * deltaTime,
    y: state.y + nextVelocity.y * deltaTime,
  };

  return {
    ...state,
    x: nextPosition.x,
    y: nextPosition.y,
    vx: nextVelocity.x,
    vy: nextVelocity.y,
  };
}

const state = { x: 100, y: 80, vx: 30, vy: 0 };
const gravity = { x: 0, y: 980 };
const nextState = integrate(state, gravity, 0.1);

console.log(nextState);

acceleration 的单位是 px/s²,速度更新量是 acceleration × deltaTime,位置更新量是新速度 × deltaTime。这里 0.1 秒后 vy 增加 98 px/s,粒子开始向下移动。先更新速度再更新位置是一种半隐式 Euler 积分,通常比直接使用旧速度更稳定。

3. 每一帧都要从时间戳计算 deltaTime

requestAnimationFrame 提供的时间戳单位是毫秒,必须转换成秒:

js 复制代码
let previousTime = performance.now();

function animationFrame(currentTime) {
  const deltaTime = (currentTime - previousTime) / 1000;
  previousTime = currentTime;

  console.log(deltaTime);
  requestAnimationFrame(animationFrame);
}

requestAnimationFrame(animationFrame);

正常情况下 deltaTime 可能接近 0.016 秒,但不能假设它永远固定。切换标签页、主线程执行长任务或设备降频,都会让间隔变大。把毫秒直接当成秒会让一次位移放大 1000 倍,是动画中最危险的单位错误之一。

实际循环通常要限制异常大的时间步:

js 复制代码
function getDeltaTime(currentTime, previousTime) {
  const rawDelta = (currentTime - previousTime) / 1000;
  return Math.min(rawDelta, 0.05);
}

const deltaTime = getDeltaTime(
  performance.now(),
  performance.now() - 100,
);

console.log(deltaTime); // 最大不超过 0.05 秒

0.05 秒表示最多按 20 FPS 的单步计算,避免页面恢复后粒子瞬间穿过边界。限制时间步不能让卡顿消失,但能阻止异常输入破坏状态。

4. 固定步长和渲染帧率可以分开

当物理规则需要稳定采样时,可以用固定时间步更新,多余时间保存在 accumulator 中:

js 复制代码
const FIXED_STEP = 1 / 120;
let accumulator = 0;
let previous = performance.now();

function fixedFrame(now) {
  const elapsed = Math.min((now - previous) / 1000, 0.1);
  previous = now;
  accumulator += elapsed;

  while (accumulator >= FIXED_STEP) {
    updatePhysics(FIXED_STEP);
    accumulator -= FIXED_STEP;
  }

  renderScene();
  requestAnimationFrame(fixedFrame);
}

requestAnimationFrame(fixedFrame);

FIXED_STEP 的单位是秒,这里约为 8.33 毫秒。物理更新每次使用同一个时间步,renderScene 则按照浏览器实际帧率执行。updatePhysics 和 renderScene 是上下文函数,前者只更新状态,后者只读取状态。

如果不需要严格固定步长,也可以使用普通变步长循环。选择依据是:简单的视觉移动使用变步长更直接;碰撞、弹簧和复杂约束更适合固定步长或限制步长。

5. 把公式、更新和循环分层

为了避免动画主函数越来越大,可以把单个状态更新、数组更新和帧驱动拆开:

js 复制代码
function updateParticle(particle, deltaTime) {
  particle.vy += particle.gravity * deltaTime;
  particle.x += particle.vx * deltaTime;
  particle.y += particle.vy * deltaTime;
}

function updateParticles(particles, deltaTime) {
  for (const particle of particles) {
    if (particle.active) {
      updateParticle(particle, deltaTime);
    }
  }
}

function runFrame(particles, ctx, currentTime, previousTime) {
  const deltaTime = Math.min(
    (currentTime - previousTime) / 1000,
    0.05,
  );

  updateParticles(particles, deltaTime);
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);

  return currentTime;
}

gravity 的单位是 px/s²,vx 和 vy 是 px/s,x 和 y 是 px。runFrame 返回新的时间戳,调用者下一帧把它作为 previousTime。这样的分层让公式可以单独测试,让数组循环可以单独测量,让 Canvas 只是循环的一个输出端。

可以用累计时间检查速度是否符合预期:

js 复制代码
function simulate(state, totalTime, step) {
  let elapsed = 0;

  while (elapsed < totalTime) {
    const dt = Math.min(step, totalTime - elapsed);
    state.x += state.vx * dt;
    state.y += state.vy * dt;
    elapsed += dt;
  }

  return state;
}

console.log(simulate(
  { x: 0, y: 0, vx: 100, vy: 0 },
  1,
  1 / 60,
)); // x 接近 100

无论把 1 秒分成 60 步还是 120 步,匀速情况下最终 x 都应接近 100 px。这个累计时间测试能发现漏乘 deltaTime、重复乘时间或循环提前结束等错误。公式进入循环后,时间单位、状态更新顺序和帧率适配必须始终保持一致。下一节将处理边界、生命周期和异常值,让循环在真实运行中保持可控。

四、边界、生命周期与异常值:算法怎样保持可控

一个公式在理想条件下可以一直计算,但浏览器中的对象会离开画布、寿命会结束,输入还可能出现 NaN 或 Infinity。如果没有边界和异常处理,粒子会飞出可见区域、透明度变成负数,甚至让整个画面停止更新。可控算法不是禁止变化,而是为每种状态变化规定出口。

1. 先决定越界对象应该怎样处理

对象到达画布边缘后,常见策略有三种:限制在边界内、反弹、从另一侧绕回。三种策略对应不同的视觉效果:

text 复制代码
限制:x = clamp(x, 0, width)
反弹:到达边界后 vx = -vx
绕回:x < 0 时 x = width,x > width 时 x = 0

限制策略适合 UI 元素或拖拽对象:

js 复制代码
function clamp(value, min, max) {
  return Math.max(min, Math.min(max, value));
}

function keepInside(particle, width, height) {
  particle.x = clamp(particle.x, particle.radius, width - particle.radius);
  particle.y = clamp(particle.y, particle.radius, height - particle.radius);
}

const particle = { x: 498, y: 100, radius: 8 };
keepInside(particle, 500, 300);
console.log(particle.x); // 492

边界使用粒子中心减去半径,避免圆形只有中心点留在画布内而边缘已经被裁切。width 和 height 的单位是 px。clamp 只修正位置,不改变速度,因此对象可能下一帧再次越界,通常要和速度处理一起使用。

2. 反弹需要同时修正位置和速度

反弹不仅是把速度取反,还要把已经越过边界的距离折回:

js 复制代码
function bounceHorizontal(particle, width) {
  const right = width - particle.radius;
  const left = particle.radius;

  if (particle.x > right) {
    particle.x = right - (particle.x - right);
    particle.vx = -Math.abs(particle.vx);
  }

  if (particle.x < left) {
    particle.x = left + (left - particle.x);
    particle.vx = Math.abs(particle.vx);
  }
}

const ball = { x: 505, vx: 80, radius: 8 };
bounceHorizontal(ball, 500);
console.log(ball);

如果只把 vx 变成负数而不修正 x,粒子已经在边界外,下一帧可能继续闪烁或卡在边缘。反弹后的速度方向由边界决定:撞右边时必须向左,撞左边时必须向右。对于 y 方向可以使用同样规则。

3. 绕回适合连续流动的粒子

背景星尘、雨滴和横向跑马灯通常不需要反弹,而是从另一侧重新进入:

js 复制代码
function wrap(value, min, max) {
  const range = max - min;
  return ((value - min) % range + range) % range + min;
}

function wrapParticle(particle, width, height) {
  particle.x = wrap(particle.x, -particle.radius, width + particle.radius);
  particle.y = wrap(particle.y, -particle.radius, height + particle.radius);
}

const raindrop = { x: -12, y: 60, radius: 4 };
wrapParticle(raindrop, 500, 300);
console.log(raindrop.x); // 接近 508

双重取模可以处理负数,使 x 在越过左侧时正确回到右侧。边界扩展了 radius,意味着粒子完全离开视区后才重置,避免半个图形突然消失。wrap 不改变速度和寿命,适合无限循环的背景元素。

4. 生命周期要有明确的结束条件

粒子即使不可见,也可能继续占用数组和计算时间。因此需要用 life、alpha 或 active 标记决定是否结束:

js 复制代码
function updateLifetime(particle, deltaTime) {
  particle.life -= deltaTime;
  particle.alpha = clamp(particle.life / particle.maxLife, 0, 1);

  if (particle.life <= 0) {
    particle.active = false;
    particle.alpha = 0;
  }
}

function updateWithLifetime(particle, deltaTime) {
  if (!particle.active) return;

  updateLifetime(particle, deltaTime);
  particle.x += particle.vx * deltaTime;
  particle.y += particle.vy * deltaTime;
}

life 和 maxLife 使用秒,alpha 使用 0~1。即使 life 因为某次卡顿直接从 0.02 变成负数,clamp 也会把 alpha 限制到 0,active 则负责告诉后续循环跳过它。生命周期结束必须进入回收流程,不能只把 alpha 设为 0。

可以过滤失活对象:

js 复制代码
function removeInactive(particles) {
  return particles.filter((particle) => particle.active);
}

const alive = removeInactive([
  { active: true },
  { active: false },
  { active: true },
]);

console.log(alive.length); // 2

少量对象使用 filter 可读性很好;数量很大时可以使用对象池,复用失活对象,避免每次创建和销毁触发垃圾回收。

5. 及时拦截 NaN、Infinity 和异常范围

一次无效计算可能污染整个状态,例如 velocity 为 undefined,位置就会变成 NaN。应在更新入口检查:

js 复制代码
function assertParticle(particle) {
  const values = [
    particle.x,
    particle.y,
    particle.vx,
    particle.vy,
    particle.life,
    particle.alpha,
  ];

  if (!values.every(Number.isFinite)) {
    throw new Error('粒子状态包含 NaN 或 Infinity');
  }

  if (particle.alpha < 0 || particle.alpha > 1) {
    throw new RangeError('alpha 必须位于 0 到 1');
  }
}

function safeUpdate(particle, deltaTime) {
  assertParticle(particle);
  if (!Number.isFinite(deltaTime) || deltaTime < 0) {
    throw new RangeError('deltaTime 必须是非负有限秒数');
  }

  particle.x += particle.vx * deltaTime;
  particle.y += particle.vy * deltaTime;
}

Number.isFinite 能同时拦截 NaN、Infinity 和非数字类型。异常应尽早在更新入口暴露,而不是等到 Canvas 绘制或 WebGL 上传时才出现黑屏。生产环境可以把 throw 改为停用该对象并记录错误,避免单个坏粒子让整个动画停止。

边界、生命周期和异常值可以组成一套固定顺序:先更新公式,再处理边界;随后减少寿命并决定 active;最后检查数值是否仍然有限。这样每一帧结束时,状态都落在可绘制、可回收和可调试的范围内。下一节将把这套可控状态接入 Canvas 2D,形成从算法结果到真实画面的完整链路。

五、从计算结果到画面:如何把核心算法接入 Canvas 2D

前几节得到的是位置、速度、寿命和边界处理,浏览器还需要一个渲染层把这些状态转换成像素。Canvas 2D 的接入过程可以拆成四步:取得上下文,清理上一帧,读取当前状态绘制对象,再由 requestAnimationFrame 驱动下一帧。只要保持更新与绘制的职责分离,算法就能稳定地显示出来。

1. 先确认 Canvas 尺寸和坐标约定

Canvas 有内部绘图尺寸和 CSS 显示尺寸两个概念。绘制坐标使用内部像素,不能只看页面上元素的宽高:

js 复制代码
const canvas = document.querySelector('#stage');
const ctx = canvas.getContext('2d');

canvas.width = 640;
canvas.height = 360;
canvas.style.width = '320px';
canvas.style.height = '180px';

console.log(canvas.width, canvas.height);

这里画布内部是 640 × 360 px,页面显示为 320 × 180 CSS px。浏览器会把内部像素缩放到显示区域;如果只设置 CSS 尺寸,内部默认尺寸可能仍然是 300 × 150,导致图形模糊或坐标比例不符合预期。算法中的 x、y 应该使用 canvas.width 和 canvas.height 对应的内部坐标。

2. 把一个状态对象转换成一个可见图形

渲染函数应只读取状态,并在绘制前明确设置样式:

js 复制代码
function drawParticle(ctx, particle) {
  if (!particle.active) return;

  ctx.save();
  ctx.globalAlpha = particle.alpha;
  ctx.fillStyle = particle.color;
  ctx.beginPath();
  ctx.arc(
    particle.x,
    particle.y,
    particle.radius,
    0,
    Math.PI * 2,
  );
  ctx.fill();
  ctx.restore();
}

const particle = {
  x: 160,
  y: 120,
  radius: 10,
  color: '#50aaff',
  alpha: 0.9,
  active: true,
};

drawParticle(ctx, particle);

particle.x 和 particle.y 是 Canvas 内部像素坐标,radius 也是像素,alpha 是 0~1。save 和 restore 防止当前透明度、颜色或路径状态污染后续对象。先让一个对象稳定显示,再扩展到数组,能够把坐标、颜色和尺寸问题隔离出来。

3. 多个对象需要统一清屏和绘制顺序

动画帧不是在上一帧画面上无限叠加,而是通常先清空,再按顺序重画所有当前状态:

js 复制代码
function renderParticles(ctx, particles) {
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);

  for (const particle of particles) {
    drawParticle(ctx, particle);
  }
}

const particles = [
  { x: 100, y: 80, radius: 6, color: '#ffb347', alpha: 1, active: true },
  { x: 180, y: 120, radius: 10, color: '#50aaff', alpha: 0.7, active: true },
];

renderParticles(ctx, particles);

clearRect 使用画布内部坐标清除全部像素。数组顺序就是绘制顺序,后绘制的半透明对象会覆盖或混合到前一个对象上。如果需要背景色,应在清屏后先填充背景,而不是把背景和粒子状态混在更新函数中:

js 复制代码
function clearAndPaintBackground(ctx, color) {
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
  ctx.fillStyle = color;
  ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
}

clearAndPaintBackground(ctx, '#101827');
renderParticles(ctx, particles);

4. 把状态更新和 Canvas 绘制接进时间循环

每帧应先计算 deltaTime,再更新状态,最后清屏并绘制:

js 复制代码
function updateParticle(particle, deltaTime) {
  particle.x += particle.vx * deltaTime;
  particle.y += particle.vy * deltaTime;
}

function updateParticles(particles, deltaTime) {
  for (const particle of particles) {
    if (particle.active) updateParticle(particle, deltaTime);
  }
}

let previousTime = performance.now();

function frame(currentTime) {
  const deltaTime = Math.min(
    (currentTime - previousTime) / 1000,
    0.05,
  );
  previousTime = currentTime;

  updateParticles(particles, deltaTime);
  clearAndPaintBackground(ctx, '#101827');
  renderParticles(ctx, particles);

  requestAnimationFrame(frame);
}

requestAnimationFrame(frame);

requestAnimationFrame 的时间戳是毫秒,除以 1000 转成秒;0.05 秒上限用于避免卡顿恢复后的超大步长。updateParticles 不调用 Canvas API,renderParticles 不改变速度和位置,这样每一层都可以单独测试。

5. 加入坐标、速度和边界的调试叠加层

当粒子不动或跑出画面时,调试绘制可以把状态直接显示出来:

js 复制代码
function drawDebugInfo(ctx, particle) {
  ctx.save();
  ctx.strokeStyle = 'rgba(255, 180, 80, 0.8)';
  ctx.beginPath();
  ctx.moveTo(particle.x, particle.y);
  ctx.lineTo(
    particle.x + particle.vx * 0.1,
    particle.y + particle.vy * 0.1,
  );
  ctx.stroke();

  ctx.fillStyle = '#ffffff';
  ctx.font = '12px sans-serif';
  ctx.fillText(
    'x=' + particle.x.toFixed(1)
      + ' y=' + particle.y.toFixed(1),
    particle.x + 8,
    particle.y - 8,
  );
  ctx.restore();
}

drawDebugInfo(ctx, particles[0]);

速度向量被缩放 0.1 后作为可见线段,避免 px/s 数值过大超出画布。文字显示当前坐标,单位是 px。调试层不应修改粒子状态,正式发布时可以通过 debug 开关跳过它。

最终可以把最小接入流程组合成一个可复用控制器:

js 复制代码
function createCanvasRunner(canvas, particles) {
  const ctx = canvas.getContext('2d');
  let previousTime = performance.now();
  let running = true;

  function frame(currentTime) {
    if (!running) return;

    const deltaTime = Math.min(
      (currentTime - previousTime) / 1000,
      0.05,
    );
    previousTime = currentTime;

    updateParticles(particles, deltaTime);
    clearAndPaintBackground(ctx, '#101827');
    renderParticles(ctx, particles);

    requestAnimationFrame(frame);
  }

  requestAnimationFrame(frame);

  return {
    stop() {
      running = false;
    },
  };
}

const runner = createCanvasRunner(canvas, particles);
console.log(runner);

createCanvasRunner 把 Canvas 上下文、时间记录和粒子数组连接起来,stop 通过 running 标志停止后续帧。到这里,核心算法已经完成了从输入状态、时间更新、边界控制到 Canvas 像素的完整闭环;后续扩展时,可以继续替换粒子模型、绘制方式或数据存储,而不必破坏这条基本链路。

六、从最小实现到可扩展系统:哪些部分值得继续抽象

最小算法跑通后,下一步不是立刻建立复杂框架,而是找出真正重复、稳定且会被多处使用的部分。输入归一化、时间步长、生命周期、边界处理和渲染接口通常值得抽象;一次性公式、只使用一次的参数和还在频繁变化的实验代码,则应暂时保持直白。抽象的目标是减少重复和错误,不是增加文件数量。

1. 先把稳定的时间循环抽出来

动画循环在很多特效中结构相同:计算时间、更新状态、绘制画面。可以把这部分封装,而把具体算法作为回调传入:

js 复制代码
function createLoop({ update, render, maxDelta = 0.05 }) {
  let previousTime = performance.now();
  let running = false;
  let frameId = 0;

  function frame(currentTime) {
    if (!running) return;

    const deltaTime = Math.min(
      (currentTime - previousTime) / 1000,
      maxDelta,
    );
    previousTime = currentTime;

    update(deltaTime);
    render();
    frameId = requestAnimationFrame(frame);
  }

  return {
    start() {
      if (running) return;
      running = true;
      frameId = requestAnimationFrame(frame);
    },
    stop() {
      running = false;
      cancelAnimationFrame(frameId);
    },
  };
}

update 接收秒为单位的 deltaTime,render 不接收时间而只读取当前状态。这样暂停、恢复和最大步长限制只实现一次。具体粒子公式仍然放在 update 中,避免循环抽象反过来隐藏算法细节。

2. 把边界策略抽象成可替换规则

边界处理可能是限制、反弹或绕回,不应该把三套逻辑塞进一个难以阅读的 if:

js 复制代码
function clamp(value, min, max) {
  return Math.max(min, Math.min(max, value));
}

function keepInside(particle, width, height) {
  particle.x = clamp(particle.x, particle.radius, width - particle.radius);
  particle.y = clamp(particle.y, particle.radius, height - particle.radius);
}

function bounceInside(particle, width, height) {
  if (particle.x < particle.radius || particle.x > width - particle.radius) {
    particle.vx *= -1;
    particle.x = clamp(particle.x, particle.radius, width - particle.radius);
  }
  if (particle.y < particle.radius || particle.y > height - particle.radius) {
    particle.vy *= -1;
    particle.y = clamp(particle.y, particle.radius, height - particle.radius);
  }
}

keepInside 与 bounceInside 都接收相同的粒子和画布尺寸,但表现不同。调用者可以根据场景选择函数:

js 复制代码
function updateWithBoundary(particle, deltaTime, boundary) {
  particle.x += particle.vx * deltaTime;
  particle.y += particle.vy * deltaTime;
  boundary(particle, 640, 360);
}

updateWithBoundary(particle, 0.016, bounceInside);

边界策略被替换时,位置和速度公式不需要改动。值得抽象的是稳定的接口,不是把所有可能的效果提前写进一个超级函数。

3. 把粒子创建和回收集中管理

当系统需要不断生成粒子时,创建、失活和回收会反复出现,可以集中到一个管理器:

js 复制代码
function createParticleManager(createParticle) {
  const particles = [];

  return {
    emit(count, source) {
      for (let i = 0; i < count; i += 1) {
        particles.push(createParticle(source));
      }
    },

    update(deltaTime, updateParticle) {
      for (const particle of particles) {
        if (particle.active) {
          updateParticle(particle, deltaTime);
        }
      }
    },

    removeInactive() {
      for (let i = particles.length - 1; i >= 0; i -= 1) {
        if (!particles[i].active) {
          particles.splice(i, 1);
        }
      }
    },

    getAll() {
      return particles;
    },
  };
}

管理器只关心集合生命周期,不关心粒子是圆形、图片还是文字。createParticle 决定初始字段,updateParticle 决定运动规则。数量较小时 splice 足够;数量很大时,可以在同一个接口内部替换为对象池,不影响外部调用者。

4. 为渲染后端保留统一接口

算法状态不应该直接依赖某一种绘制 API。可以让 Canvas 和其他渲染器都实现 render 方法:

js 复制代码
function createCanvasRenderer(ctx) {
  return {
    render(particles) {
      ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);

      for (const particle of particles) {
        if (!particle.active) continue;

        ctx.globalAlpha = particle.alpha;
        ctx.fillStyle = particle.color;
        ctx.beginPath();
        ctx.arc(
          particle.x,
          particle.y,
          particle.radius,
          0,
          Math.PI * 2,
        );
        ctx.fill();
      }

      ctx.globalAlpha = 1;
    },
  };
}

function runRenderer(renderer, particles) {
  renderer.render(particles);
}

runRenderer 不知道 renderer 内部使用 Canvas 2D 还是其他后端,只要求它拥有 render 方法。这样可以先用 Canvas 验证算法,再替换为批量 WebGL 渲染;状态字段和更新规则不需要跟着重写。

5. 判断抽象是否值得:重复、稳定、可验证

可以用三个问题判断一段代码是否应该抽象:

js 复制代码
function shouldAbstract({ repeatCount, interfaceStable, easyToTest }) {
  return repeatCount >= 2 && interfaceStable && easyToTest;
}

console.log(shouldAbstract({
  repeatCount: 3,
  interfaceStable: true,
  easyToTest: true,
})); // true

console.log(shouldAbstract({
  repeatCount: 1,
  interfaceStable: false,
  easyToTest: false,
})); // false

repeatCount 表示逻辑是否至少在两个地方重复,interfaceStable 表示输入输出是否已经稳定,easyToTest 表示能否用数值或画面结果验证。如果三项都不满足,过早抽象只会把实验代码藏到额外层级中。

最终可以把最小实现扩展成以下结构:

text 复制代码
输入层:配置、用户事件、画布尺寸
状态层:位置、速度、寿命、激活标记
算法层:时间推进、边界、生命周期
集合层:创建、更新、回收多个对象
渲染层:Canvas 2D、WebGL 或其他表现
循环层:时间戳、暂停、恢复和帧调度

这条结构并不要求每一层都单独建文件,而是要求每个函数的责任清楚。先让输入能生成状态,让状态能经过公式更新,让更新结果能被 Canvas 绘制,再根据重复和性能证据抽象时间循环、对象管理和渲染接口。这样,所谓"核心算法手写一遍"就不只是做出一个 Demo,而是建立了一条可测试、可替换、可扩展的 Web 特效基础链路。

七、关于八荒启

八荒启是一家专注于交互体验产品与解决方案的品牌,持续探索交互技术在教育教学、产品展示、过程模拟、操作训练和数据可视化等场景中的应用。

我们不仅分享技术实现,也持续创作和沉淀交互动画、数字作品、开发教程、项目案例与行业解决方案,希望通过交互技术,让复杂事物变得更加容易理解、探索、操作和创造。

八荒启,专为交互动画而生

让复杂事物可探索、可操作、可反馈

如果你正在寻找交互作品、学习相关技术,或者希望把一个想法转化为可实际操作的交互项目,欢迎访问八荒启官网了解更多案例与服务。

  • 官方网站:https://bahuangqi.com
  • 主要内容:交互动画、3D 可视化、教育互动、仿真模拟与技术教程
  • 定制服务:可通过官网提交需求或联系人工客服进行评估

感谢阅读。如果本文对你有所帮助,欢迎点赞、收藏和关注,我们会继续分享更多交互作品与项目实践。

相关推荐
晨枫阳1 小时前
Keycloak 授权体系详解:资源、策略、权限
前端
志尊宝2 小时前
Vue3 零基础每日笔记(047):动态路由与路由参数——:id 传参、query 传参、props 解耦
前端·javascript·vue.js·笔记·html5
黑马程序员毕设3 小时前
基于微信小程序的二手交易平台设计与实现报告
前端·人工智能·spring boot·后端·考研
cAuth3 小时前
实现一个图形编辑器
前端·架构·计算机图形学
Nayana3 小时前
《Web 到 HarmonyOS》-- 嵌入 Web 与桥接基础
前端
一拳不是超人3 小时前
之前用 AI 两小时写的塔罗网站,我真把它做上线了,然后呢?
前端·人工智能·程序员
flash俊杰4 小时前
ASR 转写与字幕时间轴:强制对齐、CPS 约束与 SRT 工程化
前端
前端炒粉4 小时前
AI工具面经
前端