1 điểm bởi GN⁺ 2024-03-09 | 1 bình luận | Chia sẻ qua WhatsApp
  • Làm mượt hàm mũ là một kỹ thuật đơn giản có thể dùng rộng rãi cho các animation cần bám theo giá trị mục tiêu một cách mượt mà, như nút toggle, camera, phần tử UI hay âm lượng audio
  • Công thức cốt lõi là position += (target - position) * (1 - exp(- speed * dt)); chỉ với vị trí hiện tại và vị trí mục tiêu, nó phản ứng tự nhiên trước thay đổi mục tiêu
  • Di chuyển tuyến tính và easing thông thường cần xử lý thêm các vấn đề như nhảy, rung hoặc hàng đợi input khi người dùng click giữa chừng, nhập camera nhanh hay dt lớn; còn làm mượt hàm mũ giảm độ phức tạp với cùng một cấu trúc
  • 1 - exp(- speed * dt) xuất phát từ nghiệm của phương trình vi phân mà công thức cập nhật tỉ lệ giải; với dt nhỏ thì gần như giống công thức cũ, còn với dt lớn vẫn tránh được overshoot
  • Về mặt toán học nó không chạm chính xác đến giá trị mục tiêu, nhưng do giới hạn độ chính xác số thực dấu phẩy động và ngưỡng thay đổi mà người dùng có thể cảm nhận, trong animation thực tế nó hoạt động như thể đã hoàn tất đủ mức

Vấn đề lộ ra ở nút toggle

  • Vị trí công tắc của nút toggle có thể được tính đơn giản như turned_on ? max_x : min_x, nhưng khi trạng thái thay đổi, vị trí lập tức dịch chuyển tức thời nên thiếu sức sống
  • Animation tuyến tính được triển khai bằng cách cập nhật vị trí hiện tại với tốc độ cố định rồi giới hạn trong phạm vi
position.x += (turned_on ? 1 : -1) * speed * dt;
position.x = clamp(position.x, min_x, max_x);
  • Di chuyển với tốc độ cố định khiến vị trí trở thành hàm tuyến tính theo thời gian, nên chuyển động có thể trông đơ
  • Thêm hàm easing có thể làm chuyển động mượt hơn
    • Cubic smoothstep cổ điển: 3t² - 2t³
    • Easing căn bậc hai: sqrt(t)
  • smoothstep có tính đối xứng 1 - f(t) = f(1 - t), nên có thể dùng cùng mã cho animation tiến và lùi
  • sqrt cần công thức khác nhau tùy hướng
    • Khi bật: sqrt(t)
    • Khi tắt: 1 - sqrt(1 - t)
  • sqrt di chuyển nhanh lúc bắt đầu và chậm lại mượt mà khi gần mục tiêu, nhưng ngay cả với toggle 2 trạng thái đơn giản vẫn cần quản lý trạng thái như t, hướng và tính toán easing
  • Nếu người dùng click lại giữa lúc animation đang chạy, cách easing hiện có có thể tạo ra sự gián đoạn khiến vị trí nhảy đột ngột

Công thức làm mượt hàm mũ

  • Làm mượt hàm mũ hoạt động bằng cách đặt vị trí mục tiêu rồi kéo vị trí hiện tại từng chút một về phía mục tiêu
target = (state.value ? max_x : min_x);
position.x += (target - position.x) * (1 - exp(- dt * speed));
  • Lượng cập nhật được quyết định dựa trên chênh lệch giữa vị trí hiện tại và vị trí mục tiêu, tức target - position.x
  • 1 - exp(- dt * speed)hệ số nội suy quyết định mỗi frame sẽ di chuyển bao nhiêu về phía mục tiêu
  • Ngay cả khi mục tiêu thay đổi giữa chừng, chuyển động vẫn tiếp tục hướng thẳng đến mục tiêu mới mà không cần thời gian tiến triển hay trạng thái hướng animation riêng
  • Trong ví dụ nút toggle, nó bắt đầu nhanh và chậm lại gần mục tiêu như easing sqrt, đồng thời giảm vấn đề nhảy khi click giữa chừng

Lợi ích càng rõ hơn khi di chuyển camera

  • Camera di chuyển trên bản đồ cũng gặp vấn đề tương tự
  • Nếu triển khai nội suy tốc độ cố định một cách đơn giản, ta dùng dấu của hướng đến mục tiêu theo từng trục
position.x += sign(target.x - position.x) * speed * dt;
position.y += sign(target.y - position.y) * speed * dt;
  • Tại điểm kết thúc animation, dấu của target - position có thể dao động giữa dương và âm, gây ra rung
  • Để ngăn điều này, cần một hàm cập nhật riêng giới hạn delta trong phạm vi max_delta
float update(float & value, float target, float max_delta)
{
    float delta = target - value;
    delta = min(delta,  max_delta);
    delta = max(delta, -max_delta);
    value += delta;
}
  • Muốn áp dụng cubic easing cho di chuyển camera, cần đưa các sự kiện di chuyển được yêu cầu vào hàng đợi rồi xử lý từng cái, làm cấu trúc phức tạp hơn
  • Cách bỏ qua input của người dùng trong lúc animation chạy sẽ khiến người dùng cảm thấy rất bực bội
  • Dùng làm mượt hàm mũ cho phép xử lý di chuyển camera với mã gần như giống nút toggle
position.x += (target.x - position.x) * (1.0 - exp(- speed * dt));
position.y += (target.y - position.y) * (1.0 - exp(- speed * dt));
  • Khi người dùng click nhanh, chênh lệch giữa mục tiêu và vị trí hiện tại tăng lên, nên camera tự nhiên di chuyển nhanh hơn

Vì sao là 1 - exp(- speed * dt)?

  • Cập nhật tỉ lệ đơn giản có thể viết như sau
position += (target - position) * speed * dt;
  • Công thức này di chuyển nhanh hơn khi chênh lệch giữa mục tiêu và vị trí hiện tại lớn hơn, và không cần trạng thái bổ sung ngoài vị trí hiện tại và vị trí mục tiêu
  • Viết bằng lerp thì cũng là cùng một công thức
position = lerp(position, target, speed * dt);
  • Khi speed * dt gần 0, nó di chuyển chậm; khi gần 1, nó nhanh chóng tiến đến mục tiêu
  • Nếu speed * dt lớn hơn 1, nội suy sẽ vượt qua mục tiêu, gây overshoot
    • Trong ví dụ, khi speed = 220, dt = 1 / 125, speed * dt lớn hơn 1 nên xuất hiện rung
    • Nếu speed * dt < 2 thì chênh lệch tuyệt đối có thể giảm, nhưng nếu speed * dt > 2 thì nó không còn tạo ra hành vi hữu ích
  • Có thể giới hạn hệ số nội suy bằng min(1, speed * dt), nhưng đó không phải là lời giải xử lý mượt cả các tình huống dt tăng lớn
    • Mã có thể chậm khiến frame rate giảm
    • Người dùng có thể chuyển sang tab hoặc cửa sổ khác, khiến mã dừng rồi thức dậy với dt kéo dài nhiều giây
  • Trong mô phỏng vật lý, có thể giới hạn dt lớn hoặc chia thành nhiều lần cập nhật, nhưng với animation, để camera và nút hoạt động tự nhiên ngay cả khi dt lớn sẽ tốt hơn cho trải nghiệm người dùng

Lời giải nhìn từ phương trình vi phân

  • Dạng A += B * dt nói chung tương ứng với việc giải số phương trình vi phân dA/dt = B
  • Công thức cập nhật đơn giản là dạng giải phương trình sau
d(position) / dt = (target - position) * speed
  • Đặt biến x = position, a = target, c = speed thì ta có
dx / dt = (a - x) * c
  • Giải trực tiếp phương trình này sẽ cho dạng sau
x = x0 + (a - x0) * (1 - exp(-c * t))
  • Vì vậy nếu công thức đúng với dt nhỏ là position += (target - position) * speed * dt, thì công thức có thể dùng với dt bất kỳ là
position += (target - position) * (1 - exp(- speed * dt));
  • Theo khai triển Taylor, exp(x) ≈ 1 + x, nên với dt nhỏ, 1 - exp(-speed * dt) ≈ speed * dt, trở thành giống công thức đơn giản ban đầu
  • Ngay cả khi speed * dt rất lớn, exp(-speed * dt) sẽ tiến gần 0 và 1 - exp(...) tiến gần 1, nên vị trí di chuyển ổn định đến giá trị gần mục tiêu
  • Cũng có thể viết cùng công thức bằng lerp
position = lerp(position, target, 1 - exp(- speed * dt));
position = lerp(target, position, exp(- speed * dt));

Chọn giá trị tốc độ

  • Animation thông thường dễ được nghĩ theo thời lượng, như “di chuyển trong 0,125 giây”, nhưng về mặt kỹ thuật làm mượt hàm mũ cần thời gian vô hạn để chạm chính xác đến mục tiêu
  • exp(- speed * time) nhỏ dần theo thời gian nhưng không trở thành 0, nên nếu giá trị ban đầu và mục tiêu khác nhau, về mặt toán học position cũng sẽ không hoàn toàn bằng target
  • Trên thực tế, nó sẽ chạm giới hạn độ chính xác số thực dấu phẩy động, hoặc chênh lệch vị trí camera trở nên nhỏ đến mức người dùng không nhìn thấy, khiến animation trông như đã kết thúc
  • Ý nghĩa của speed là trong khoảng thời gian 1 / speed, position sẽ tiến gần target hơn đúng e = 2.71828... lần
  • Trong thực tế thường đặt speed trong khoảng 5..50; với tốc độ animation tuyến tính hoặc cubic cho cảm giác tương tự, khoảng 2 * speed của làm mượt hàm mũ có thể cho cảm giác phù hợp

Liên hệ với xử lý tín hiệu

  • Tìm kiếm “exponential smoothing” hoặc “exponential moving average” sẽ thấy tài liệu Exponential smoothing trên Wikipedia
  • Khi dt cố định và target thay đổi ở mỗi vòng lặp, giá trị được cập nhật gần như sau theo chỉ số lặp
    • factor = 1 - exp(- speed * dt)
    • Thông thường factor được đặt trực tiếp là một giá trị giữa 0 và 1
  • Làm mượt hàm mũ rời rạc là tương tự rời rạc của cách dùng trong animation
  • Trong xử lý tín hiệu, nó cũng được dùng vì chỉ cần giá trị trung bình hiện tại, không phải duy trì danh sách các giá trị trước đó hay trạng thái phức tạp
  • Trong audio số, dt thường cố định là nghịch đảo của tần số lấy mẫu, tức 1 / freq
    • Ví dụ: 1/44100, 1/48000

1 bình luận

 
GN⁺ 2024-03-09
Ý kiến trên Hacker News
  • Có vẻ điểm cốt lõi ở đây chưa được bàn đủ. Đây không chỉ là một đường cong easing khác hay smoothstep() giữa 0 và 1, mà là một cách làm không trạng thái cực kỳ hữu ích vì xử lý đều đặn gần như mọi đầu vào
    Nếu từng dùng chuyển tiếp CSS thì hẳn bạn đã quen với vấn đề này. Nếu đặt thời lượng là 400ms thì tại sao lại là 400ms? Chẳng phải nó nên thay đổi theo quãng đường cần di chuyển sao?
    Như người khác đã nói, làm mượt theo hàm mũ có vấn đề là chỉ tiến gần đích một cách tiệm cận chứ không bao giờ chạm tới. Cách giải quyết hiển nhiên là dừng hoạt ảnh khi bước nhỏ hơn ngưỡng, nhưng như vậy không được thanh lịch lắm
    Khi dùng cách tương tự cho cuộn quán tính, việc thêm một hạng ma sát giả sẽ rất hữu ích. Hạng này bù trừ cho hạng mũ nên về thực chất hoạt động như một vận tốc tối thiểu. Ví dụ Desmos ở đây: https://www.desmos.com/calculator/98ufbuzxhj

    • Có thể giải quyết bằng cách diễn giải làm mượt theo hàm mũ như một phương trình vi phân thường theo khoảng cách D tới mục tiêu. Làm mượt theo hàm mũ là phép cập nhật Euler của dD/dt=-C*D, với nghiệm D(t)=A*exp(-C*t), nên nó tiệm cận về 0 nhưng không bao giờ tới nơi
      Ta có thể đổi nó thành một công thức đi về 0 trong thời gian hữu hạn, chẳng hạn dD/dt=-C*sqrt(D). Nghiệm sẽ di chuyển như một nửa parabol bậc hai rồi khi chạm 0 thì đứng nguyên tại đó. Phép cập nhật Euler của công thức này cũng không trạng thái đúng như mong muốn
    • Tôi không hiểu vì sao việc dừng hoạt ảnh khi bước giảm dưới ngưỡng lại không thanh lịch. Nó đơn giản, dễ triển khai, dễ kiểm chứng, và vẫn cho ra hiệu ứng thị giác mong muốn, nên có vẻ đủ gọn gàng rồi
    • Nếu chuyển tiếp CSS có vấn đề 400ms, thì tham số tốc độ của hàm mũ chẳng phải cũng gặp đúng vấn đề đó sao
  • Từ góc nhìn của một nhà phát triển game, tôi nghĩ với phần lớn UI thì easing tween có thời lượng định trước vẫn tốt hơn. Nhưng khi bạn muốn làm mượt những chuyển động liên tục, khó đoán, không có điểm đầu và điểm cuối rõ ràng, thì kiểu hoạt ảnh này lại cực kỳ hữu ích
    Ví dụ như khi người chơi kéo các ô trên lưới bằng chuột để chúng snap vào lưới, hoặc di chuyển camera như ví dụ trong bài
    Trong các trường hợp như vậy, mẹo nội suy theo hàm mũ rất hữu dụng nhưng lại không được biết đến rộng rãi. Nhiều game dùng nội suy tuyến tính kém chính xác hơn, rồi gặp cảnh hoạt ảnh cho cảm giác hoàn toàn kỳ quặc khi ai đó chạy nó trên màn hình 240Hz, khác với thời 60fps còn là chuẩn chung
    Nên tôi rất mừng vì bài này. Những kiến thức quá chuyên biệt kiểu này thường chỉ được truyền miệng theo kiểu thầy truyền trò trong nội bộ nhóm, nên rất khó tiếp cận

  • Tôi thích bài viết, nhưng muốn nói rằng nhận định của tác giả cho rằng sqrt tốt hơn hàm bậc ba trong công tắc gạt là sai một cách khách quan. Nếu nhìn vào cách công tắc gạt ngoài đời thực thường hoạt động, thì hàm bậc ba là lựa chọn tốt hơn cho trường hợp này
    Cứ nghĩ tới công tắc aptomat trong nhà, hay những công tắc thường thấy trên synth analog và thiết bị âm thanh. Đó là các thiết bị nhắm tới một cảm giác thẩm mỹ nhất định, và ngay cả chiếc ampli guitar Hughes & Kettner nhỏ tôi đang có cũng có hai công tắc bấm rất đã tay
    Những công tắc như vậy ban đầu có chút lực cản, rồi nhờ cơ chế lò xo mà bất ngờ bật mạnh sang vị trí mới. Chuyển động đó được mô hình hóa bằng hàm bậc ba tốt hơn so với sqrt hay làm mượt theo hàm mũ
    Ngoài góp ý nhỏ đó ra thì bài viết rất hay. Nó cho thấy rằng nếu dùng hoạt ảnh tốt, như với các hàm easing phù hợp, thì trải nghiệm người dùng sẽ tốt hơn; còn nếu triển khai bất cẩn như ví dụ nội suy tuyến tính thì nó có thể gây khó chịu và làm hỏng trải nghiệm
    [0] Tất nhiên còn tùy loại công tắc gạt. Nhưng loại bạn thấy trên các thiết bị như Minimoog cũng cho cảm giác “chống lại rồi bật phắt sang vị trí mới”, nên thao tác rất thích. Mà nói trước là đây không phải khoe đồ, tôi không có Minimoog đâu

    • Theo trực giác thì hàm bậc ba cũng cho cảm giác tốt hơn. Công tắc cơ khí có một mức quán tính nào đó, và điều đó dường như thể hiện trong cách tiếp cận bằng hàm bậc ba
  • Tôi vẫn luôn ngạc nhiên về việc những mẹo phi tuyến đơn giản lại thường xuyên thêm được sự thú vị cho tương tác trực tuyến đến thế nào. Với nhận thức màu sắc, chúng còn là chìa khóa để hiểu vì sao hai màu có thể không đủ khác biệt đối với một số người
    Điều kỳ lạ là con người không phải lúc nào cũng hiểu tốt về gia tốc. Bạn không thể tin rằng lửa sẽ lan với tốc độ gần như không đổi trên đồi như trên mặt phẳng rồi chạy lên dốc để trốn. Lửa tăng tốc khi leo dốc
    Trẻ con học khá nhanh về tốc độ của một quả bóng ném đang di chuyển dọc mặt đất, nhưng lại không phải lúc nào cũng hiểu được nó sẽ lao nhanh đến mức nào khi đập vào tay do trọng lực

  • Điều thú vị là phần lớn bài viết này rốt cuộc đều quy về easing. Có cảm giác như mỗi thế hệ mới lại phải tự khám phá lại nó từ đầu
    Tôi nhớ mình từng mê mẩn các website thử nghiệm của Yugo Nakamura vào cuối thập niên 90. Đó là một trong những website đầu tiên tôi thấy dùng easing thoải mái để tạo cảm giác hữu cơ: https://www.youtube.com/watch?v=NLt7Gwnt3WY

    • Tôi nhớ đã ngồi ngay hàng đầu trong một hội nghị nơi Yugo thuyết trình, và khi ông ấy lướt qua các bản demo thì những chuyên gia Flash khác đã phát cuồng hoàn toàn
  • Không hiểu sao tôi lại muốn có một kiểu công tắc như thế này. Khi đang chạm hoặc nhấp giữ thì nó chậm rãi di chuyển tới khoảng 75% mục tiêu, rồi khi thả ra mới bật nốt phần còn lại
    Tôi không chắc xét về UX thì điều đó sẽ mang ý nghĩa gì. Có thể nó ám chỉ rằng cài đặt chỉ thực sự được áp dụng hoặc lưu ở bước cuối cùng
    Hoặc nó có thể là một phần của hộp thoại “Bạn có chắc không?”. Trong lúc đang giữ thì cài đặt được áp dụng, nhưng trước khi nó bật hẳn vào vị trí thì vẫn có thể hoàn tác bằng Escape

    • https://jsfiddle.net/u1vybhqg/
      input:checked + .slider:active:before { transform: translateX(8px); transition: 1s; }
      input:not(:checked) + .slider:active:before { transform: translateX(18px); transition: 1s; }
    • Tôi đã thấy nhiều người dùng lớn tuổi cố đẩy công tắc nhưng chẳng có gì xảy ra. Vậy nên nếu làm theo kiểu đó thì cũng phải cho phép công tắc thực sự bị đẩy. Không thì tốt hơn cứ dùng checkbox thôi
  • Tôi thích bài này. Tôi đã viết gần như đúng kỹ thuật này khoảng 10 năm trước, khi đó gọi nó là lazy-easy, và đến giờ vẫn còn dùng
    Đôi khi bạn chỉ muốn hoạt ảnh mượt mà mà không cần quản lý toàn bộ trạng thái: https://www.hailpixel.com/articles/lazy-animation-with-lazy-...

  • Bản thân bài viết thực sự rất hay. Có vẻ demo chạy tốt trên Chrome, nhưng trên Firefox thì nó bị đứng trong lúc cuộn và việc render trang bị dừng hoàn toàn

  • Đây thực sự là một cách tiếp cận khá tốt, và cũng là một minh chứng khái niệm hay cho các kỹ thuật hoạt ảnh và easing. Nó làm tôi nhớ khá nhiều đến Flickity
    https://metafizzy.co/blog/initial-demos/
    https://metafizzy.co/blog/math-time-resting-position/
    https://metafizzy.co/blog/particle-to-slider/
    https://metafizzy.co/blog/flickity-begins/
    Đặc biệt là demo này: https://codepen.io/desandro/pen/myXdej
    Kỹ thuật này không chỉ hữu ích cho công tắc. Trên thực tế, người ta cũng sẽ không dùng kiểu phần tử này cho công tắc, và cũng sẽ không chạy song song 20 vòng lặp requestAnimationFrame trên toàn bộ trang. Cũng sẽ không cố tình đưa vào các phần tử bị lỗi
    Ngoài ra cũng chưa có tối ưu hóa dừng render khi delta đã đủ nhỏ, và rất có thể còn hàng chục tinh chỉnh nhỏ nữa để biến nó thành mức production
    Nhìn phản ứng ở đây, có vẻ mọi người либо không đọc bài, либо chỉ thấy cây mà không thấy rừng, hoặc quá thiên kiến và cay nghiệt đến mức chia sẻ những nhận định thiếu chuyên môn chỉ để trông có vẻ thông minh
    Từ bao giờ HN lại trở nên giống Reddit vậy?

    • Trong trường hợp này thì nhiều người thực sự không thể đọc được bài. Vì khi cuộn xuống khoảng một phần ba trang, hầu hết các trình duyệt không phải Chromium đều bị treo
    • Nếu hỏi “Từ bao giờ HN lại trở nên giống Reddit vậy?” thì ít nhất hãy đọc đoạn cuối của https://news.ycombinator.com/newsguidelines.html
    • Sự thay đổi đó đã diễn ra một thời gian, nhưng vì theo hướng dẫn của HN thì bị cấm nói trực tiếp về chuyện đó nên mọi người không nói ra. Thật kỳ lạ
    • Trong trường hợp này, sự cay nghiệt có vẻ không hẳn là công bằng
      Còn về chuyện HN biến thành Reddit, có lẽ trong toàn bộ lĩnh vực công nghệ đang tồn tại một bầu không khí cay nghiệt mơ hồ. Bên dưới là sự ngờ vực về cách chính phủ và doanh nghiệp định sử dụng những công nghệ mạnh mẽ đang xuất hiện, rằng họ có thể đang xây dựng chúng với mục đích hạn chế và kiểm soát cá nhân thay vì mở rộng tự do cá nhân
  • Gần như là tinh túy của thiết kế cảm xúc(https://en.wikipedia.org/wiki/Emotional_Design). Có rất nhiều điều để nói ngay cả đằng sau một hoạt ảnh rất nhỏ