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 可视化、教育互动、仿真模拟与技术教程
- 定制服务:可通过官网提交需求或联系人工客服进行评估
感谢阅读。如果本文对你有所帮助,欢迎点赞、收藏和关注,我们会继续分享更多交互作品与项目实践。