jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

SVG from Zero to Senior · Part 25 — Ambient Looping & Particle Scenes

The motion arc finale: ambient scenes that just live. Seamless parallax loops (translate a tile by its width, then wrap) and a lightweight requestAnimationFrame particle pool that recycles nodes. With a live demo.

Phần này khép lại nhánh Motion & Scenes. Có chuyển động có đầu và cuối. Chuyển động nền thì không — đồi trôi, tuyết rơi, đom đóm bay, một nền dịu đơn giản sống phía sau nội dung. Hai kỹ năng là lặp liền mạch và một hệ hạt rẻ, đều dựng để chạy mãi mà không nung CPU.

Bật/tắt parallax, kéo số đom đóm, và tạm dừng.

Mở demo đầy đủ:

Lặp liền mạch: dịch một tile bằng đúng chiều rộng của nó

Mẹo cho một dòng trôi vô tận không thấy mối nối: đặt hai tile giống hệt cạnh nhau, rồi cuộn lớp sang trái và gói offset đúng một chiều rộng tile.

let x = (x - speed * dt) % W;                 // W = one tile width
layer.setAttribute('transform', `translate(${x} 0)`);

Khi lớp đã dịch trọn W, modulo bật nó về 0 — và vì tile 2 giống hệt tile 1 từng pixel, cú nhảy là vô hình. Cho mỗi lớp parallax một speed khác nhau là có chiều sâu miễn phí.

Một hệ hạt luôn rẻ

Sinh và hủy node ngây thơ làm DOM và GC vật vã. Mẫu senior là một pool cố định: tạo N node một lần, rồi tái dùng mỗi cái khi nó rời sân khấu.

// create the pool once
for (let i = 0; i < N; i++) particles.push({ el: makeCircle(), x, y, vy, phase, drift });

// each frame: move, twinkle, and recycle — node count never changes
for (const p of particles) {
  p.y -= p.vy * dt;                                   // rise
  p.x += Math.sin(t + p.phase) * p.drift * dt;        // gentle sway
  if (p.y < -4) { p.y = 244; p.x = Math.random() * W; } // wrap to bottom
  p.el.setAttribute('cx', p.x);  p.el.setAttribute('cy', p.y);
  p.el.setAttribute('opacity', 0.35 + 0.6 * Math.abs(Math.sin(t * 1.5 + p.phase))); // twinkle
}

Mỗi hạt mang vy, phase, drift riêng, nên cùng một đoạn code sinh chuyển động hữu cơ, không đồng bộ. Cái lấp lánh chỉ là một sóng sin trên opacity — sự sống rẻ nhất có thể giả.

Một đồng hồ, delta time

Điều khiển mọi thứ từ một vòng requestAnimationFrame và nhân chuyển động với delta time (dt), để tốc độ nhất quán ở 60 Hz, 120 Hz, hay tab bị bóp:

function frame(now) {
  const dt = Math.min(0.05, (now - last) / 1000);   // clamp to survive tab-switch gaps
  last = now;
  // ...update layers + particles by dt...
  requestAnimationFrame(frame);
}

Kẹp dt ngăn một cú “dịch chuyển” khổng lồ khi tab lấy lại tiêu điểm sau khi bị nền.

Đặt ngân sách — nền không bao giờ được là nút cổ chai

Chuyển động nền là trang trí; nó phải gần như không tốn gì. Giữ pool nhỏ, chỉ animate transform/cx/opacity, và dừng vòng khi cảnh ngoài màn hình:

const io = new IntersectionObserver(([e]) => { running = e.isIntersecting; });
io.observe(svg);   // stop burning frames when nobody's looking

Nếu cần hàng nghìn hạt, đó là tín hiệu chuyển sang <canvas>.

Lời cảnh báo của sư phụ

  • Nhân đôi tile, không sẽ thấy mối nối.
  • Tái dùng, đừng sinh/hủy mỗi frame.
  • Luôn dùng dt.
  • prefers-reduced-motion là bắt buộc với chuyển động nền. Hãy vẽ một khung tĩnh và dừng vòng lặp.

Thực hành

  1. Đổi đom đóm thành tuyết: đảo vy để rơi, thêm lắc ngang, tái dùng ở đỉnh.
  2. Thêm lớp parallax thứ ba: một dải mây chậm tốc độ khác.
  3. Dừng khi ngoài màn hình: nối IntersectionObserver để vòng dừng khi cảnh cuộn đi.

Kết thúc nhánh Motion & Scenes

Năm phần trước, animation nghĩa là di chuyển một phần tử. Giờ bạn biết đạo diễn — dàn cảnh bằng định thời syncbase SMIL, cho vật nghiêng theo motion path, rig và cho nhân vật bước đi, trao timeline cho người đọc cuộn, và để một thế giới nền lặp mãi trong ngân sách hiệu năng nghiêm ngặt. Kết hợp với hai mươi phần cốt lõi, bạn không chỉ vẽ bằng SVG — bạn khiến nó chuyển động có chủ đích. Giờ hãy đi animate thứ gì đó đáng xem.