说在前面
🎨 最近网上有这样一个很火的图片踩点效果:一张照片一开始是黑白的,然后从某个点开始,颜色像涟漪一样向外扩散,慢慢地整张图片就变成了彩色,配上音乐节拍,一张接一张地上色,视觉冲击力拉满。
效果很有趣但是我又懒得手动去剪辑,所以就写了个工具网站来一键生成
今天就来给大家分享一下这个工具,拆解一下具体效果是怎么实现的~
效果展示
主要特性:
- 🎯 点击画布任意位置设置扩散起点,支持多个扩散点
- 🌊 扩散边缘带有涟漪光波效果,像水波纹一样
- 🏀 可选弹跳小球动画,小球在相邻图片的扩散点之间跳跃
- 🎵 支持音乐踩点模式,每到一个节拍自动切换下一张图片
- ⚙️ 扩散时长、边缘柔和度、停留时间等参数可调
- 📹 支持导出 WebM 视频
在线体验
在线网站
🔗 在线体验:jyeontu.xyz/flika/#/tra…
桌面版
也提供了桌面版安装包下载,有兴趣的也可以试试,选择合适的版本,下载后直接安装即可
🔗下载地址:github.com/yongtaozhen…
实现思路
整个效果的核心思路其实就一句话:
对每个像素,计算它到最近扩散点的距离;距离小于当前扩散半径的像素显示原色,否则显示灰度。
听起来简单对吧?但要做到丝滑、好看、不卡顿,还得处理不少细节。我们一步一步来~
1、灰度转换——给照片"去色"
第一步是把原始彩色图片转成灰度版本,后面渲染的时候用于"还没扩散到"的区域。
export function toGrayscale(data: Uint8ClampedArray): Uint8ClampedArray {
const gray = new Uint8ClampedArray(data.length)
for (let i = 0; i < data.length; i += 4) {
const lum = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]
gray[i] = gray[i + 1] = gray[i + 2] = lum
gray[i + 3] = data[i + 3] // Alpha 通道不变
}
return gray
}
这里用的是经典的 ITU-R BT.601 亮度公式:
灰度 = 0.299 × R + 0.587 × G + 0.114 × B
为什么不是简单的 (R + G + B) / 3?因为人眼对绿色最敏感,对蓝色最不敏感,加权之后的灰度图看起来更自然
这一步我们直接预计算好整张图片的灰度数据,存成 Uint8ClampedArray,后面渲染时直接查表,不用每帧重复算。
2、距离场计算——效果的核心
距离场(Distance Field)是这个效果的灵魂。简单来说就是:对画布上的每一个像素,算出它到最近那个扩散点的距离。
export function computeDistanceField(
width: number,
height: number,
points: { x: number; y: number }[], // 归一化坐标 0~1
): { field: Float32Array; cosAngle: Float32Array; sinAngle: Float32Array; maxDist: number } {
const field = new Float32Array(width * height)
const cosAngle = new Float32Array(width * height)
const sinAngle = new Float32Array(width * height)
let maxDist = 0
// 把归一化坐标转成像素坐标
const pxPoints = points.map((p) => ({ x: p.x * width, y: p.y * height }))
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let minDist = Infinity
let nearDx = 0
let nearDy = 0
// 找最近的扩散点
for (const pt of pxPoints) {
const dx = x - pt.x
const dy = y - pt.y
const dist = Math.sqrt(dx * dx + dy * dy)
if (dist < minDist) {
minDist = dist
nearDx = dx
nearDy = dy
}
}
const idx = y * width + x
field[idx] = minDist
// 顺便记录方向角,后面涟漪波纹要用
const angle = Math.atan2(nearDy, nearDx)
cosAngle[idx] = Math.cos(angle)
sinAngle[idx] = Math.sin(angle)
if (minDist > maxDist) maxDist = minDist
}
}
return { field, cosAngle, sinAngle, maxDist }
}
来解释一下这段代码做了什么:
(1)双重循环遍历每个像素
对于 1280×720 的画布,那就是 921,600 个像素,每个都要算一遍。听起来很暴力,但这是一次性的预计算,算完之后整个动画播放期间都不用再算了。
(2)对每个像素找最近的扩散点
如果有多个扩散点,用的是最简单的暴力搜索——遍历所有点取最小值。扩散点数量一般就 1~3 个,所以这里不需要什么 KD 树之类的优化。
(3)顺便记录方向角
cosAngle 和 sinAngle 记录了每个像素指向最近扩散点的方向。这个信息后面做涟漪波纹的像素位移时会用到,所以在算距离场的时候一并算了,一石二鸟
(4)记录最大距离
maxDist 是距离场中的最大值,也就是离所有扩散点最远的那个像素的距离。后面渲染时会用它来做归一化:扩散半径 = 进度 × maxDist,当进度到 1 时刚好覆盖整张图。
3、逐帧渲染——让颜色"扩散"起来
有了距离场和灰度数据,就可以逐帧渲染扩散动画了。核心渲染逻辑如下:
// 扩散进度 0~1(随时间推进)
const spreadProgress = Math.min(1, imageTime / effectiveSpreadDuration)
// 当前扩散半径(像素)
const currentRadius = spreadProgress * data.maxDist
// 边缘柔和宽度(用户可调,默认 30px)
const edgeWidth = config.edgeWidth
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = y * width + x
const dist = distanceField[idx]
const px = idx * 4
if (dist <= currentRadius - edgeWidth) {
// 完全在扩散范围内 → 显示原色
output[px] = originalData[px]
output[px + 1] = originalData[px + 1]
output[px + 2] = originalData[px + 2]
} else if (dist <= currentRadius) {
// 在边缘过渡带 → 灰度和原色混合
const t = smoothstep((currentRadius - dist) / edgeWidth)
output[px] = grayscaleData[px] + (originalData[px] - grayscaleData[px]) * t
output[px + 1] = grayscaleData[px + 1] + (originalData[px + 1] - grayscaleData[px + 1]) * t
output[px + 2] = grayscaleData[px + 2] + (originalData[px + 2] - grayscaleData[px + 2]) * t
} else {
// 还没扩散到 → 显示灰度
output[px] = grayscaleData[px]
output[px + 1] = grayscaleData[px + 1]
output[px + 2] = grayscaleData[px + 2]
}
output[px + 3] = originalData[px + 3] // Alpha
}
}
逻辑用一张图来表示就是:
← edgeWidth →
┌──────────────┐
─────────┤ 过渡混合带 ├──────────────
原色区 │ (smoothstep) │ 灰度区
─────────┤ ├──────────────
└──────────────┘
← currentRadius →
每帧 currentRadius 都在增大(因为 spreadProgress 从 0 涨到 1),所以每帧都有更多的像素"被上色"。
4、Smoothstep——让边缘不生硬
如果直接用线性插值(t = (currentRadius - dist) / edgeWidth),边缘过渡会很硬、很突兀。可以用 smoothstep 函数来让过渡更丝滑:
export function smoothstep(t: number): number {
const x = Math.max(0, Math.min(1, t))
return x * x * (3 - 2 * x)
}
smoothstep 的数学公式是 f(x) = 3x² - 2x³,它有一个非常好的特性——在 0 和 1 处的导数都是 0,也就是说起步和收尾都是平滑的,没有突变。
对比一下:
| 插值方式 | 过渡效果 |
|---|---|
线性 t | 边缘有明显分界线,过渡不自然 |
smoothstep 3t²-2t³ | 边缘柔和自然,像水彩晕染 |
这个函数在图形学里用得非常多,从 shader 到 CSS 动画曲线都能看到它的身影。就三行代码,效果提升巨大。
5、涟漪光波
光有上色扩散还不够,在扩散的前沿加了涟漪波纹效果——扩散边缘的像素会产生类似水波纹的位移,让整个效果更有动感。
// 涟漪常量
const WAVE_COUNT = 3 // 3 层波纹
const WAVE_WIDTH = 60 // 每层波纹宽度 60px
const WAVE_AMPLITUDE = 18 // 最大位移 18px
const WAVE_ZONE = WAVE_WIDTH * WAVE_COUNT // 波纹影响区域 = 180px
// 淡入淡出系数:扩散刚开始和快结束时波纹弱,中间最强
const animFade = Math.sin(spreadProgress * Math.PI)
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = y * width + x
const dist = distanceField[idx]
let srcX = x // 实际采样的源坐标
let srcY = y
// 只在扩散前沿的 180px 范围内产生波纹
const relDist = currentRadius - dist
if (relDist > 0 && relDist < WAVE_ZONE) {
// 正弦波 × 指数衰减 × 淡入淡出
const wavePhase = (relDist / WAVE_WIDTH) * Math.PI * 2
const amplitude = WAVE_AMPLITUDE
* Math.sin(wavePhase) // 正弦波形
* Math.exp(-relDist / WAVE_ZONE * 2) // 越远衰减越强
* animFade // 整体淡入淡出
// 沿"像素→扩散点"方向位移
srcX = x + cosAngle[idx] * amplitude
srcY = y + sinAngle[idx] * amplitude
}
// 用位移后的坐标采样像素...
}
}
来拆解一下这个波纹的原理:
(1)波纹范围
波纹只出现在扩散前沿后方 180px 的范围内(WAVE_ZONE = 3 × 60),也就是刚刚被扩散到的区域。太远的区域已经稳定了,不需要波纹。
(2)正弦波 × 指数衰减
Math.sin(wavePhase) 产生周期性的正弦波,Math.exp(...) 让波纹随距离衰减。两者结合就是一个逐渐消失的水波纹效果。
(3)方向性位移
还记得前面算距离场时顺便存的 cosAngle 和 sinAngle 吗?这里用它们来确定每个像素的位移方向——沿着从像素到扩散点的方向做径向波纹,而不是简单的横向或纵向位移,这样视觉上更像真实的水波 🌊
(4)淡入淡出
animFade = Math.sin(spreadProgress × π) 是一个在 0→1→0 之间变化的系数。扩散刚开始(progress≈0)和快结束(progress≈1)时波纹很弱,中间最强。避免了波纹突然出现或消失的违和感。
渲染完像素后还有一层高光环叠加:
// 在 putImageData 之后,用 Canvas API 画高光环
if (rippleActive && image.points.length > 0 && currentRadius > 4) {
ctx.save()
ctx.globalAlpha = 0.25 * animFade
for (const pt of image.points) {
const cx = pt.x * width
const cy = pt.y * height
for (let w = 0; w < WAVE_COUNT; w++) {
const ringRadius = currentRadius - w * WAVE_WIDTH
if (ringRadius > 0) {
ctx.beginPath()
ctx.arc(cx, cy, ringRadius, 0, Math.PI * 2)
ctx.strokeStyle = `rgba(150, 200, 255, ${0.3 - w * 0.08})`
ctx.lineWidth = 1.5
ctx.stroke()
}
}
}
ctx.restore()
}
以扩散点为圆心,画 3 个淡蓝色的同心圆环,半径就是当前的扩散半径。这些环跟着扩散前沿一起向外推进,配合底层的像素位移,就形成了非常好看的涟漪效果
6、弹跳小球
为了让多张图片之间的切换更有趣,我加了一个可选的弹跳小球动画——一个发光的小球停在当前扩散点上,在图片切换时弹跳到下一张图片的扩散点。
小球的运动轨迹是一个经典的抛物线弹跳:
const BOUNCE_DURATION = 500 // 弹跳动画 500ms
const BALL_RADIUS = 12 // 小球半径 12px
const BOUNCE_HEIGHT_RATIO = 0.40 // 弹跳高度 = 两点距离 × 0.4
if (ball.isBouncing) {
const t = Math.max(0, Math.min(1, (elapsedMs - ball.bounceStartMs) / ball.bounceDuration))
// 线性插值位置
let bx = ball.fromX + (ball.toX - ball.fromX) * t
let by = ball.fromY + (ball.toY - ball.fromY) * t
// 叠加抛物线弧线:-4t(1-t) 是经典的抛物线公式
const dist = Math.sqrt((ball.toX - ball.fromX) ** 2 + (ball.toY - ball.fromY) ** 2)
const bounceH = Math.max(0.10, dist * BOUNCE_HEIGHT_RATIO)
by += -bounceH * 4 * t * (1 - t) // 抛物线!
// 挤压拉伸(squash & stretch)
if (t < 0.15) {
// 起跳:横向压扁
scaleX = 1 + 0.2 * (1 - t / 0.15)
scaleY = 1 - 0.2 * (1 - t / 0.15)
} else if (t > 0.85) {
// 落地:横向压扁
scaleX = 1 + 0.2 * ((t - 0.85) / 0.15)
scaleY = 1 - 0.2 * ((t - 0.85) / 0.15)
} else {
// 空中:纵向拉长
const speed = Math.abs(2 * t - 1)
scaleX = 1 - 0.15 * (1 - speed)
scaleY = 1 + 0.15 * (1 - speed)
}
}
这里有几个动画细节值得说一下:
(1)抛物线轨迹
-4t(1-t) 是一个经典的抛物线公式,在 t=0 和 t=1 时值为 0(在地面),在 t=0.5 时达到最大值 -1(在空中最高点)。乘以弹跳高度就得到了一个自然的弹跳弧线
(2)Squash & Stretch(挤压拉伸)
这是动画十二原则中最重要的原则之一!起跳和落地的瞬间小球会被"压扁"(scaleX 变大、scaleY 变小),空中飞行时会被"拉长"。就这一个细节,让小球瞬间从"机械移动"变成了"有弹性的活物"
(3)落地波纹
小球落地时还会触发一个扩散波纹,进一步增强弹跳的"重量感":
// 小球落地时
ball.landingRippleStart = elapsedMs
// 每帧检查波纹状态
const rt = (elapsedMs - ball.landingRippleStart) / LANDING_RIPPLE_DURATION
if (rt >= 0 && rt < 1) {
const rippleR = BALL_RADIUS + (LANDING_RIPPLE_MAX_R - BALL_RADIUS) * rt
const rippleAlpha = 0.5 * (1 - rt)
ctx.beginPath()
ctx.arc(ball.landingX, ball.landingY, rippleR, 0, Math.PI * 2)
ctx.strokeStyle = `rgba(120, 180, 255, ${rippleAlpha})`
ctx.lineWidth = 2 * (1 - rt)
ctx.stroke()
}
(4)小球本体渲染
小球用径向渐变 + 高光点来模拟玻璃球质感:
// 发光效果
ctx.shadowColor = 'rgba(100, 160, 255, 0.6)'
ctx.shadowBlur = 16
// 径向渐变:白 → 浅蓝 → 蓝
const grad = ctx.createRadialGradient(0, -2, 0, 0, 0, BALL_RADIUS)
grad.addColorStop(0, 'rgba(255, 255, 255, 0.95)')
grad.addColorStop(0.4, 'rgba(180, 210, 255, 0.85)')
grad.addColorStop(1, 'rgba(100, 160, 255, 0.6)')
ctx.arc(0, 0, BALL_RADIUS, 0, Math.PI * 2)
ctx.fillStyle = grad
ctx.fill()
// 高光点
ctx.arc(-3, -4, 3, 0, Math.PI * 2)
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)'
ctx.fill()
不弹跳时小球还有一个微弱的呼吸浮动(Math.sin(elapsedMs / 600) * 0.003),让它看起来是"活"的,而不是死死钉在那里。
7、音乐踩点模式
单纯按固定时间扩散已经很好看了,但配上音乐节拍更带感!Flika 的扩散着色支持踩点模式——每到一个节拍就切换到下一张图片开始扩散。
节拍检测用的是基于 RMS 能量的峰值检测算法:
// 解码音频 → 计算能量 → 平滑 → 峰值检测
const audioContext = new AudioContext()
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer)
const channelData = audioBuffer.getChannelData(0)
// 10ms 一块计算 RMS 能量
const blockSize = Math.floor(sampleRate * 0.01)
for (let i = 0; i < channelData.length; i += blockSize) {
let sum = 0
for (let j = i; j < Math.min(i + blockSize, channelData.length); j++) {
sum += channelData[j] * channelData[j]
}
energyBlocks.push(Math.sqrt(sum / blockSize))
}
// 峰值检测:能量超过局部平均 × 阈值 → 标记为节拍
const threshold = 1.2 + (1 - sensitivity) * 1.5
检测到节拍后,踩点模式下的图片切换逻辑是这样的:
export function resolveBeatSync(
images: PrecomputedImageData[],
beats: BeatPure[],
effectiveTimeMs: number,
): ResolvedSlot {
const effectiveTimeSec = effectiveTimeMs / 1000
// 在已有节拍段内查找当前是第几拍
for (let i = 0; i < beats.length - 1; i++) {
const segStart = beats[i].time
const segEnd = beats[i + 1].time
if (effectiveTimeSec < segEnd) {
return {
imageIndex: i % images.length, // 第几张图
imageTime: (effectiveTimeSec - segStart) * 1000, // 本张图已播放时间
effectiveSpreadDuration: (segEnd - segStart) * 1000, // 本拍间隔 = 扩散时长
}
}
}
// 节拍不够用?用平均间隔外推!
const intervalSec = avgBeatInterval(beats)
const lastBeatSec = beats[beats.length - 1].time
const timePast = effectiveTimeSec - lastBeatSec
const extraIdx = Math.floor(timePast / intervalSec)
return {
imageIndex: (beats.length - 1 + extraIdx) % images.length,
imageTime: (effectiveTimeSec - lastBeatSec - extraIdx * intervalSec) * 1000,
effectiveSpreadDuration: intervalSec * 1000,
}
}
巧妙的地方在于:每个节拍段的时长就是扩散时长。也就是说,如果两个节拍间隔 800ms,那这张图的扩散就要在 800ms 内完成。节拍快的时候扩散就快,节拍慢的时候扩散就慢,完美跟上音乐的节奏感
还有一个细节——如果图片数量比节拍数多,节拍不够用怎么办?用平均间隔外推。取所有节拍间隔的平均值,在最后一个节拍之后按这个间隔继续切换,保证每张图都能被展示到。
8、Web Worker 导出
渲染一帧扩散效果需要遍历 92 万+ 个像素(1280×720),如果用 30fps 导出一个 10 秒的视频,那就是 300 帧 × 92 万像素 = 2.76 亿次像素计算。放在主线程的话,导出期间整个页面都会卡死,解决方案是把导出渲染丢到 Web Worker 里:
// 主线程
const handle = createWorkerExport({
canvas,
images: workerImages, // 预计算好的图片数据
config: { ...config },
beats: [...beats],
totalDurationMs: totalDuration,
fps: 30,
step: 1, // 1 = 全质量
onProgress, // 进度回调
})
const blob = await handle.start()
// Worker 线程
self.onmessage = (e) => {
const result = renderFramePure(ctx, elapsedMs, step)
// Transferable 零拷贝传输
self.postMessage(
{ buffer: result.buffer },
[result.buffer.buffer] // 转移所有权,避免拷贝
)
}
关键优化点:
| 优化 | 说明 |
|---|---|
| Web Worker | 渲染在后台线程,UI 完全不卡 |
| 预计算 | 距离场、灰度数据提前算好传给 Worker |
| Transferable | 零拷贝传输像素数据,省掉序列化开销 |
| 有音频时 | 改用主线程渲染(音频时间驱动,保证踩点同步) |
注意最后一点——有音频的踩点模式下,Flika 选择在主线程渲染。因为 MediaRecorder 需要同时录制 Canvas 视频流和 Audio 音频流,音频播放时间就是渲染时钟,必须在主线程同步进行。
9、扩散点编辑
用户可以在画布上点击任意位置添加扩散起点,这里用了归一化坐标来做适配:
function handleCanvasClick(e: MouseEvent) {
const rect = canvas.getBoundingClientRect()
const scaleX = canvas.width / rect.width
const scaleY = canvas.height / rect.height
// 鼠标位置 → Canvas 像素坐标
const pixelX = (e.clientX - rect.left) * scaleX
const pixelY = (e.clientY - rect.top) * scaleY
// Canvas 像素坐标 → 归一化坐标 (0~1)
const nx = pixelX / canvas.width
const ny = pixelY / canvas.height
img.points.push({ id: uuidv4(), x: nx, y: ny })
// 重新预计算距离场
engine.precomputeImage(img)
// 立即刷新预览
engine.renderStaticFrame(selectedImageIndex)
}
用归一化坐标(0~1)而不是像素坐标的好处是——不管画布切换横屏还是竖屏、放大还是缩小,扩散点的相对位置始终不变
还有一个便捷的功能——一键添加。对于懒得手动点的用户,一键给所有没有扩散点的图片随机添加一个(坐标在 15%~85% 之间,避免太靠边缘):
function autoAddPoints() {
for (const img of images) {
if (img.points.length === 0) {
const x = 0.15 + Math.random() * 0.7
const y = 0.15 + Math.random() * 0.7
img.points.push({ id: uuidv4(), x, y })
}
}
}
总结
最后梳理一下扩散着色效果涉及到的核心技术点:
| 步骤 | 技术 | 作用 |
|---|---|---|
| 1️⃣ | 灰度转换(BT.601) | 生成"未上色"的底图 |
| 2️⃣ | 距离场预计算 | 确定每个像素的扩散顺序 |
| 3️⃣ | smoothstep 混合 | 让边缘过渡柔和自然 |
| 4️⃣ | 正弦波 × 指数衰减 | 涟漪光波像素位移 |
| 5️⃣ | 抛物线 + squash/stretch | 弹跳小球物理动画 |
| 6️⃣ | RMS 能量峰值检测 | 自动识别音乐节拍 |
| 7️⃣ | 节拍段映射 | 让扩散时长跟上节奏 |
| 8️⃣ | Web Worker + Transferable | 导出不卡 UI |
全程纯 Canvas 2D + 原生像素操作,没用任何图形库和 shader,代码量约 1200 行(引擎) + 500 行(渲染工具函数),完全用前端技术栈就能搞定
Flika 还有踩点转场、胶片放映、墨水渲染、碎片拼贴、粒子重组等很多动画模式,后面有机会再一一拆解~
源码地址
Gitee
Github
🌟 觉得有帮助的可以点个 star~
🖊 有什么问题或错误可以指出,欢迎 pr~
📬 有什么想要实现的功能或想法可以联系我~
公众号
关注公众号『 前端也能这么有趣 』,获取更多有趣内容。
发送 加群 还可以加入群聊,一起来学习(摸鱼)吧~
说在后面
🎉 这里是 JYeontu,现在是一名前端工程师,有空会刷刷算法题,平时喜欢打羽毛球 🏸 ,平时也喜欢写些东西,既为自己记录 📋,也希望可以对大家有那么一丢丢的帮助,写的不好望多多谅解 🙇,写错的地方望指出,定会认真改进 😊,偶尔也会在自己的公众号『前端也能这么有趣』发一些比较有趣的文章,有兴趣的也可以关注下。在此谢谢大家的支持,我们下文再见 🙌。