underflowed
duanyuhao's space








duanyuhao's space








程序生成的山形动画组件,使用 Web Worker + OffscreenCanvas 实现高性能渲染,支持高 DPR 设备的清晰显示。
山形动画组件是一个高性能、可定制的程序化山形生成器,主要用于网页背景装饰或视觉效果展示。它基于分形噪声算法生成自然的山形轮廓,并通过滚动交互实现 3D 旋转效果。
在实现过程中,需要解决以下关键挑战:
| 目标 | 解决方案 |
| 不阻塞主线程 | Web Worker + OffscreenCanvas |
| 高分辨率清晰 | 自动 DPR 检测 + ctx.scale 变换 |
| 滚动即时响应 | ImageBitmap 预渲染缓存,O(1) 切换 |
| 代码可维护 | MVC 架构,职责分离 |
MVC 模式 + 关注点分离: - Model (Mountain 类):数据模型,负责数据生成和存储 - View (MountainRenderer 类):视图渲染器,负责渲染和动画 - Controller (Worker 调度器):控制器,负责消息路由
从最初的函数式编程到现在的 MVC 模式,架构经历了三个主要阶段的演进。
各阶段特点:
Mountain 类的 generate() 方法执行完整的数据生成流程,所有数据在这一步生成完毕。
详细步骤说明:
generatePoints: 1500+ 个原始点samplePoints: 1100-1200 个(距离过滤)scalePoints: 应用透视缩放centerPoints: 应用偏移,确定最终位置性能特征:
| 步骤 | 典型耗时 | 主要开销 |
| 计算绘制区域 | < 1ms | 简单数学计算 |
| 生成 fBm 噪声 | < 1ms | 创建函数闭包 |
| 生成点数据 | 5-10ms | 噪声采样、数组操作 |
| 预计算线段 | 10-20ms | 大量线段计算、随机数生成 |
| 初始化预渲染 | < 1ms | 创建对象 |
| 总计 | 20-30ms | 一次性开销 |
当组件首次挂载时,主线程向 Worker 发送初始化消息。
当组件的 props 发生变化时(如 seed、尺寸、偏移量),触发数据重新生成。
用户滚动页面时,主线程实时同步滚动进度到 Worker。
初始动画完成后,Worker 在空闲时逐步预渲染各个旋转角度的 ImageBitmap。
组件首次加载时,通过 RAF 循环逐点渲染,营造山形”生长”的动画效果。
关键代码片段:
根据当前状态(初始动画 vs 滚动)和缓存可用性,智能选择最优的渲染模式。
关键代码片段:
为了在高分辨率屏幕(Retina、4K 等)上保持清晰的显示效果,组件实现了完整的 DPR(Device Pixel Ratio)支持。
支持动态 DPR 变化(例如拖动窗口到不同 DPI 的显示器):
| DPR | Canvas 像素数 | 内存占用 | 渲染性能 | 视觉效果 |
| 1.0 | 1× (基准) | 1× | 最快 | 普通 |
| 2.0 | 4× | 4× | 稍慢 (~10%) | 清晰 |
| 3.0 | 9× | 9× | 较慢 (~30%) | 极清晰 |
优化策略:
- ✅ 使用 ImageBitmap 缓存,缓存命中时 DPR 对性能影响极小
- ✅ GPU 加速的 drawImage 操作,即使是高分辨率也能快速绘制
- ✅ 初始动画完成后预渲染,避免实时渲染大量像素
点距离过滤:生成点时跳过距离过近的点,减少冗余数据。
动态线条数量:根据点的深度(y 坐标)动态调整线条数量,远处少、近处多,符合透视效果。
预计算:所有复杂计算在数据生成阶段完成,渲染时只需简单绘制。
MULTIPLIER_FACTOR, BASE_FACTOR)预计算ImageBitmap 缓存:预渲染不同旋转角度为 GPU 纹理,滚动时直接复用。
性能对比:
| 渲染方式 | 时间复杂度 | 典型耗时 | 适用场景 |
| 实时渲染 | O(n) | 5-15ms | 初始动画、缓存未命中 |
| ImageBitmap 缓存 | O(1) | < 1ms | 滚动浏览(缓存命中) |
增量渲染:初始动画时,每帧只绘制新增的点,而不是重绘全部。
Web Worker + OffscreenCanvas:所有渲染在后台线程运行,不阻塞主线程用户交互。
智能重新生成:通过 needsRegeneration 判断是否需要重新生成数据,仅 speed/debug 变化时复用现有实例。
资源管理:自动清理 ImageBitmap,防止内存泄漏。
所有配置常量在 config.ts 中:
NOISE_CONFIG: 噪声参数SCALE_CONFIG: 透视缩放SHADING_CONFIG: 阴影和颜色OPTIMIZATION_CONFIG: 性能优化POINT_GENERATION_CONFIG: 点生成ROTATION_CONFIG: 旋转效果启用 debug 模式可以看到详细的性能统计:
输出包括:点生成统计、线段数量统计、初始渲染性能(FPS、最长帧)、预渲染进度和性能。
mountain/
├── README.md # 架构说明(本文件)
├── index.ts # 统一导出
│
├── canvas-mountain.tsx # React 组件(主线程)
├── mountain.worker.ts # Worker 调度器
│
├── mountain.ts # Mountain 数据模型类 ⭐️
├── mountain-renderer.ts # MountainRenderer 渲染器类 ⭐️
│
├── config.ts # 配置常量
├── types.ts # 类型定义
└── renderer.ts # Canvas 渲染函数class Mountain {
// 构造函数
constructor(config: MountainConfig)
// 生成数据(异步)
async generate(): Promise<void>
// 获取数据(只读)
getPoints(): readonly Point[]
getLines(): readonly PrecomputedLine[][]
getDrawArea(): { drawWidth, drawHeight, offsetX, offsetY }
getRotationConstants(): RotationConstants | null
getPreRenderState(): PreRenderState | null
// 清理资源
dispose(): void
}const aspectRatio = width / height;
if (aspectRatio > 16 / 9) {
// 宽度过大,裁剪左右
drawHeight = height;
drawWidth = height * (16 / 9);
offsetX = (width - drawWidth) / 2;
} else {
// 高度过大,裁剪上下
drawWidth = width;
drawHeight = width / (16 / 9);
offsetY = (height - drawHeight) / 2;
}// xNoise: 控制 X 坐标的随机偏移
const xNoise = fBmFactory({
noise2D: createNoise2D(xPrng),
frequency: 0.003, // 低频率 = 平滑曲线
persistence: 0.5, // 持续性 = 细节程度
amplitude: 100, // 振幅 = 偏移范围
});
// hNoise: 控制 Y 坐标(高度)
const hNoise = fBmFactory({
noise2D: createNoise2D(hPrng),
frequency: 0.005, // 更低频率 = 更平滑
persistence: 0.6, // 更多细节
});// 动态线条数量
const lineCount = y < threshold ? 6 : 12;
// 预计算每条线段
for (let i = 0; i < lineCount; i++) {
lines.push({
startX, startY, // 起点坐标
endX, endY, // 终点坐标
color, // 颜色(含阴影)
scale, // 旋转缩放因子
});
}this.preRenderState = {
queue: [], // 待渲染角度队列
bitmaps: new Map(), // ImageBitmap 缓存
tempCanvas: OffscreenCanvas, // 临时画布
tempCtx: 2D Context, // 临时上下文
// ... 其他配置
};class MountainRenderer {
// 静态方法:判断是否需要重新生成
static needsRegeneration(
oldConfig: MountainRendererConfig,
newConfig: MountainRendererConfig
): boolean
// 构造函数
constructor(
ctx: OffscreenCanvasRenderingContext2D,
canvasWidth: number,
canvasHeight: number,
config: MountainRendererConfig
)
// 初始化(创建 Mountain 并生成数据)
async initialize(): Promise<void>
// 开始动画
startAnimation(): void
// 停止动画
stopAnimation(): void
// 更新滚动进度
updateScrollProgress(progress: number): void
// 清理资源
dispose(): void
// 获取 Mountain 实例(调试用)
getMountain(): Mountain | null
}// 初始动画模式:逐点增量渲染
renderIncremental() {
drawMountainIncremental(ctx, lines, renderedPointsCount, ...)
}
// 高性能模式:使用 ImageBitmap 缓存
renderWithBitmapCache() {
const bitmap = preRenderState.bitmaps.get(nearestStep);
if (bitmap) {
ctx.drawImage(bitmap, 0, 0); // O(1) 操作
}
}private animationLoop = (): void => {
// 1. 增加已渲染点数
this.animationState.renderedPointsCount += this.animationState.animationSpeed;
// 2. 判断是否完成
if (this.animationState.renderedPointsCount >= totalPoints) {
this.animationState.renderedPointsCount = totalPoints;
this.animationState.isAnimating = false;
// 3. 渲染最后一帧
this.renderFrame();
this.logAnimationStats(currentTime, totalPoints);
self.postMessage({ type: "animationComplete" });
return;
}
// 4. 继续渲染
this.renderFrame();
this.animationState.animationRafId = requestAnimationFrame(this.animationLoop);
};private renderFrame(): void {
const isFullyRendered = renderedPointsCount >= totalPoints;
// 模式 1: 高性能缓存模式(最快)
if (isFullyRendered && preRenderState?.bitmaps.size > 0) {
this.renderWithBitmapCache();
}
// 模式 2: 增量渲染模式(初始动画)
else {
this.renderIncremental();
}
}
private renderWithBitmapCache(): void {
const stepSize = 1 / ROTATION_CONFIG.CACHE_STEPS;
const nearestStep = Math.round(currentScrollProgress / stepSize) * stepSize;
const bitmap = preRenderState.bitmaps.get(nearestStep);
if (bitmap) {
// O(1) 操作:直接绘制 GPU 纹理
this.ctx.drawImage(bitmap, 0, 0);
} else {
// 降级到实时渲染,并优先预渲染这个角度
this.renderFull();
prioritizeAngle(nearestStep, preRenderState);
}
// 继续预渲染剩余角度
if (!preRenderState.isPreRendering && preRenderState.queue.length > 0) {
scheduleNextPreRender(preRenderState, this.createDrawMountainCallback());
}
}// canvas-mountain.tsx
const dpr = window.devicePixelRatio || 1;
// 设置实际像素尺寸(高分辨率)
canvas.width = width * dpr;
canvas.height = height * dpr;
// 设置 CSS 显示尺寸(保持界面大小)
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
// 传递给 Worker
worker.postMessage({ type: "init", canvas: offscreen, dpr });// mountain.worker.ts
const canvas = data.canvas;
const ctx = canvas.getContext("2d");
const dpr = data.dpr;
// 应用 DPR 缩放变换
ctx.scale(dpr, dpr);
// 之后所有绘图都使用逻辑坐标
ctx.strokeStyle = "white";
ctx.moveTo(100, 100); // 逻辑坐标,自动映射到 200, 200(DPR=2)// mountain.ts - initializePreRenderState
const dpr = this.config.dpr ?? 1;
// 创建高分辨率临时 canvas
const tempCanvas = new OffscreenCanvas(width * dpr, height * dpr);
const tempCtx = tempCanvas.getContext("2d");
// 应用 DPR 缩放
tempCtx.scale(dpr, dpr);
// 渲染时使用逻辑坐标
drawMountainFull(tempCtx, lines, progress, ...);
// 转换为高分辨率 ImageBitmap
const bitmap = await createImageBitmap(tempCanvas);// mountain-renderer.ts - renderWithBitmapCache
const bitmap = preRenderState.bitmaps.get(nearestStep);
if (bitmap) {
// ⚠️ 必须指定目标尺寸为逻辑尺寸
// bitmap 是高分辨率的,需要缩放到逻辑坐标空间
this.ctx.drawImage(bitmap, 0, 0, this.canvasWidth, this.canvasHeight);
}// canvas-mountain.tsx - 参数更新时
useEffect(() => {
const dpr = window.devicePixelRatio || 1;
// 发送新的 DPR 给 Worker
worker.postMessage({
type: "updateParams",
width,
height,
dpr, // 可能变化的 DPR
...
});
}, [width, height, ...]);
// mountain.worker.ts - 处理 DPR 变化
if (newDpr !== dpr) {
dpr = newDpr;
// 调整 canvas 尺寸
canvas.width = width * dpr;
canvas.height = height * dpr;
// 重新应用缩放(调整尺寸会重置 transform)
ctx.scale(dpr, dpr);
}<CanvasMountain debug={true} ... />