Реализация Canvas-анимаций на сайте
Canvas-анимации — это прямой рендеринг через 2D или WebGL контекст браузера. В отличие от SVG и DOM-анимаций, Canvas перерисовывает весь кадр целиком каждый тик — это даёт максимальную производительность для тысяч объектов, но требует ручного управления отрисовкой. Применение: частицы, физические симуляции, процедурные эффекты, игровые механики, визуализации данных реального времени.
Архитектура Canvas-анимации
Стандартный цикл: инициализация → requestAnimationFrame → очистка кадра → отрисовка объектов → обновление состояния → следующий кадр.
// lib/canvas-engine.ts
export interface AnimationContext {
canvas: HTMLCanvasElement
ctx: CanvasRenderingContext2D
width: number
height: number
dpr: number // device pixel ratio
dt: number // delta time в секундах
}
export type RenderFn = (context: AnimationContext) => void
export class CanvasEngine {
private canvas: HTMLCanvasElement
private ctx: CanvasRenderingContext2D
private dpr: number
private rafId: number | null = null
private lastTime: number = 0
private renderFn: RenderFn
constructor(canvas: HTMLCanvasElement, renderFn: RenderFn) {
this.canvas = canvas
this.ctx = canvas.getContext('2d')!
this.dpr = window.devicePixelRatio || 1
this.renderFn = renderFn
this.resize()
}
resize() {
const { canvas, dpr } = this
const rect = canvas.getBoundingClientRect()
// Высокое разрешение для retina
canvas.width = rect.width * dpr
canvas.height = rect.height * dpr
this.ctx.scale(dpr, dpr)
}
start() {
this.lastTime = performance.now()
this.tick(this.lastTime)
}
stop() {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId)
this.rafId = null
}
}
private tick = (timestamp: number) => {
const dt = Math.min((timestamp - this.lastTime) / 1000, 0.1) // cap at 100ms
this.lastTime = timestamp
const rect = this.canvas.getBoundingClientRect()
this.renderFn({
canvas: this.canvas,
ctx: this.ctx,
width: rect.width,
height: rect.height,
dpr: this.dpr,
dt,
})
this.rafId = requestAnimationFrame(this.tick)
}
}
React-хук для Canvas
// hooks/useCanvas.ts
import { useEffect, useRef } from 'react'
import { CanvasEngine, RenderFn } from '../lib/canvas-engine'
export function useCanvas(renderFn: RenderFn) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const engineRef = useRef<CanvasEngine | null>(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const engine = new CanvasEngine(canvas, renderFn)
engineRef.current = engine
engine.start()
const handleResize = () => engine.resize()
window.addEventListener('resize', handleResize)
return () => {
engine.stop()
window.removeEventListener('resize', handleResize)
}
}, [renderFn])
return canvasRef
}
Пример: система частиц с физикой
// lib/particle-system.ts
interface Particle {
x: number
y: number
vx: number
vy: number
radius: number
color: string
life: number // 0–1
maxLife: number // секунды
}
export class ParticleSystem {
private particles: Particle[] = []
private readonly maxParticles: number
constructor(maxParticles = 500) {
this.maxParticles = maxParticles
}
emit(x: number, y: number, count = 5) {
for (let i = 0; i < count; i++) {
if (this.particles.length >= this.maxParticles) break
const angle = Math.random() * Math.PI * 2
const speed = 50 + Math.random() * 150
this.particles.push({
x, y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - 100, // начальный импульс вверх
radius: 2 + Math.random() * 4,
color: `hsl(${200 + Math.random() * 60}, 80%, 60%)`,
life: 1,
maxLife: 0.8 + Math.random() * 0.8,
})
}
}
update(dt: number) {
const gravity = 300 // px/s²
this.particles = this.particles.filter(p => {
p.x += p.vx * dt
p.y += p.vy * dt
p.vy += gravity * dt
p.vx *= 0.99 // затухание
p.life -= dt / p.maxLife
return p.life > 0
})
}
draw(ctx: CanvasRenderingContext2D) {
for (const p of this.particles) {
ctx.save()
ctx.globalAlpha = p.life * p.life // квадратичное затухание
ctx.fillStyle = p.color
ctx.beginPath()
ctx.arc(p.x, p.y, p.radius * p.life, 0, Math.PI * 2)
ctx.fill()
ctx.restore()
}
}
}
// components/ParticleCanvas.tsx
import { useRef, useCallback } from 'react'
import { useCanvas } from '../hooks/useCanvas'
import { ParticleSystem } from '../lib/particle-system'
export function ParticleCanvas() {
const systemRef = useRef(new ParticleSystem(800))
const render = useCallback(({ ctx, width, height, dt }: AnimationContext) => {
// Очистка с полупрозрачным следом (motion blur эффект)
ctx.fillStyle = 'rgba(15, 15, 25, 0.15)'
ctx.fillRect(0, 0, width, height)
systemRef.current.update(dt)
systemRef.current.draw(ctx)
// Автоматическая эмиссия в центре
if (Math.random() < 0.3) {
systemRef.current.emit(
width / 2 + (Math.random() - 0.5) * 100,
height / 2
)
}
}, [])
const canvasRef = useCanvas(render)
const handleClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
const rect = canvasRef.current!.getBoundingClientRect()
systemRef.current.emit(e.clientX - rect.left, e.clientY - rect.top, 20)
}
return (
<canvas
ref={canvasRef}
className="w-full h-full bg-[#0f0f19] cursor-crosshair"
onClick={handleClick}
/>
)
}
WebGL через Three.js: следующий уровень
Для сложных 3D-сцен на фоне сайта:
npm install three @types/three
// components/ThreeBackground.tsx
'use client'
import { useEffect, useRef } from 'react'
import * as THREE from 'three'
export function ThreeBackground() {
const mountRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const mount = mountRef.current!
const width = mount.clientWidth
const height = mount.clientHeight
// Сцена
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000)
camera.position.z = 50
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
renderer.setSize(width, height)
renderer.setPixelRatio(window.devicePixelRatio)
mount.appendChild(renderer.domElement)
// Геометрия частиц
const count = 3000
const positions = new Float32Array(count * 3)
for (let i = 0; i < count * 3; i++) {
positions[i] = (Math.random() - 0.5) * 200
}
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
const material = new THREE.PointsMaterial({
size: 0.3,
color: 0x3b82f6,
transparent: true,
opacity: 0.7,
})
const points = new THREE.Points(geometry, material)
scene.add(points)
// Анимационный цикл
let rafId: number
const animate = () => {
rafId = requestAnimationFrame(animate)
points.rotation.x += 0.0003
points.rotation.y += 0.0005
renderer.render(scene, camera)
}
animate()
const handleResize = () => {
const w = mount.clientWidth
const h = mount.clientHeight
camera.aspect = w / h
camera.updateProjectionMatrix()
renderer.setSize(w, h)
}
window.addEventListener('resize', handleResize)
return () => {
cancelAnimationFrame(rafId)
window.removeEventListener('resize', handleResize)
renderer.dispose()
mount.removeChild(renderer.domElement)
}
}, [])
return <div ref={mountRef} className="absolute inset-0 -z-10" />
}
Off-screen Canvas (Web Worker)
Для очень тяжёлых вычислений — переносим рендер в Worker через OffscreenCanvas:
// main thread
const canvas = document.getElementById('my-canvas') as HTMLCanvasElement
const offscreen = canvas.transferControlToOffscreen()
const worker = new Worker(new URL('./canvas-worker.ts', import.meta.url))
worker.postMessage({ canvas: offscreen, width: canvas.width, height: canvas.height }, [offscreen])
// canvas-worker.ts
self.onmessage = (e) => {
const { canvas, width, height } = e.data
const ctx = canvas.getContext('2d')!
// Весь рендеринг здесь, в Worker
}
Типичные сроки
Простая Canvas-анимация (частицы, волны) — 1–2 рабочих дня. Полноценная система частиц с физикой, интерактивностью и оптимизацией — 3–5 дней. Three.js сцена с шейдерами, постпроцессингом и адаптивным масштабированием — от 1 недели.







