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.

This closes the Motion & Scenes arc. {Phần này khép lại nhánh Motion & Scenes.} Some motion has a beginning and an end. {Có chuyển động có đầu và cuối.} Ambient motion has neither — drifting hills, falling snow, floating fireflies, a gentle background that simply lives behind your content. {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.} The two skills are seamless looping and a cheap particle system, both built to run forever without melting the CPU. {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.}

Toggle parallax, drag the firefly count, and pause it. {Bật/tắt parallax, kéo số đom đóm, và tạm dừng.}

Open the full demo {Mở demo đầy đủ}: /tools/svg-ambient-scene-demo/.

Seamless loops: translate a tile by its own width {Lặp liền mạch: dịch một tile bằng đúng chiều rộng của nó}

The trick to an infinite drift with no visible seam: lay down two identical tiles side by side, then scroll the layer left and wrap the offset by exactly one tile width. {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)`);

When the layer has shifted a full W, the modulo snaps it back to 0 — and because tile 2 is pixel-identical to tile 1, the jump is invisible. {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.} Give each parallax layer a different speed and you get depth for free (Part 24). {Cho mỗi lớp parallax một speed khác nhau là có chiều sâu miễn phí.}

A particle system that stays cheap {Một hệ hạt luôn rẻ}

Naively spawning and destroying nodes thrashes the DOM and the GC. {Sinh và hủy node ngây thơ làm DOM và GC vật vã.} The senior pattern is a fixed pool: create N nodes once, then recycle each one when it leaves the stage. {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
}

Each particle carries its own vy, phase, and drift, so identical code produces organic, non-synchronized motion. {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ộ.} The twinkle is just a sine wave on opacity — the cheapest life you can fake. {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ả.}

One clock, delta time {Một đồng hồ, delta time}

Drive everything from a single requestAnimationFrame loop and multiply motion by delta time (dt, seconds since last frame), so speed is consistent on 60 Hz, 120 Hz, or a throttled tab: {Đ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);
}

Clamping dt prevents a giant “teleport” when the tab regains focus after being backgrounded. {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.}

Budget it — ambient must never be the bottleneck {Đặt ngân sách — nền không bao giờ được là nút cổ chai}

Ambient motion is decoration; it must cost almost nothing (Part 19). {Chuyển động nền là trang trí; nó phải gần như không tốn gì.} Keep the pool small (tens, not thousands), animate transform/cx/opacity only, and pause the loop when the scene is offscreen: {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

If you ever need thousands of particles, this is your cue to switch to <canvas> (Part 19). {Nếu cần hàng nghìn hạt, đó là tín hiệu chuyển sang <canvas>.}

The master’s warnings {Lời cảnh báo của sư phụ}

  • Duplicate the tile, or you’ll see the seam. A single tile leaves a gap when it wraps. {Nhân đôi tile, không sẽ thấy mối nối.}
  • Recycle, never spawn/destroy per frame. A fixed node pool keeps cost flat and the GC quiet. {Tái dùng, đừng sinh/hủy mỗi frame.}
  • Always use dt. Frame-count-based motion runs at different speeds on different displays. {Luôn dùng dt.}
  • prefers-reduced-motion is non-negotiable for ambient motion. Constant background movement is exactly what motion-sensitive users disable — render one static frame and stop the loop. {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.}

Practice {Thực hành}

  1. Swap fireflies for snow {Đổi đom đóm thành tuyết}: reverse vy to fall, add horizontal sway, and recycle at the top. {đảo vy để rơi, thêm lắc ngang, tái dùng ở đỉnh.}
  2. Add a third parallax layer {Thêm lớp parallax thứ ba}: a slow cloud band at a different speed for more depth. {một dải mây chậm tốc độ khác.}
  3. Offscreen pause {Dừng khi ngoài màn hình}: wire an IntersectionObserver so the loop stops when the scene scrolls away. {nối IntersectionObserver để vòng dừng khi cảnh cuộn đi.}

The end of the Motion & Scenes arc {Kết thúc nhánh Motion & Scenes}

Five parts ago, animation meant moving one element. {Năm phần trước, animation nghĩa là di chuyển một phần tử.} Now you can direct — sequence a scene with SMIL syncbase timing, send objects banking along motion paths, rig and walk a character, hand the timeline to a scrolling reader, and let an ambient world loop forever within a strict performance budget. {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.} Combined with the core twenty parts, you don’t just draw with SVG — you make it move with intent. {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.} Now go animate something worth watching. {Giờ hãy đi animate thứ gì đó đáng xem.}