Bạn biết bao nhiêu về box-shadow?
(dgerrells.com)- CSS box-shadow thường là một thuộc tính dùng để tạo cảm giác chiều sâu cho UI, nhưng nếu chồng hàng trăm đến hàng nghìn lớp lên một
divduy nhất, nó có thể hoạt động như một kiểu API vẽ - Dù bị giới hạn bởi hình hộp, vẫn có thể tạo các chấm tròn và hiệu ứng nhiều lớp bằng góc bo tròn, nhiều chuỗi bóng đổ, cùng tổ hợp màu sắc, blur và độ trong suốt
- Trong thử nghiệm, blur và độ trong suốt làm giảm mạnh số lượng phần tử có thể animate; khi bỏ chúng đi, một laptop M1 có thể vẽ hàng nghìn box-shadow
- Mô phỏng bóng, pixel ảnh, các điểm trên bề mặt khối lập phương và hình cầu, cũng như trực quan hóa phản ứng theo âm nhạc đều được triển khai bằng cách cập nhật chuỗi box-shadow ở mỗi frame
- Cuối cùng, tác giả song song hóa một ray tracer chạy trên CPU bằng Web Workers để cho thấy vẫn có thể render cảnh chỉ với một
divvà box-shadow
Dùng box-shadow như một công cụ vẽ
- CSS
box-shadowlà một dạng drop shadow, vốn ban đầu được dùng để vẽ bóng phía sau hình ảnh hoặc phần tử UI nhằm tạo cảm giác chiều sâu - Drop shadow thông thường sẽ dịch raster của hình ảnh theo trục x/y, rồi vẽ nó phía sau bản gốc bằng một màu duy nhất để tạo chiều sâu
- CSS
filter: drop-shadow()nhận offset x/y và màu sắc, đồng thời hỗ trợ thêm giá trị blur nếu cần - Khi xếp chồng nhiều bộ lọc drop shadow, có thể tăng thêm chiều sâu cho bố cục hiển thị
Đặc tính và giới hạn của box-shadow
- “Box” trong
box-shadowcó nghĩa là hình dạng bóng bị giới hạn trong bounding box của container - Giới hạn này có vẻ chật hẹp, nhưng vì phần lớn UI được cấu thành từ các khối hộp nên trong thực tế nó vẫn hữu ích
- Cách triển khai CSS box-shadow hỗ trợ các tối ưu toán học để vẽ hộp bo góc với chi phí thấp, và hộp bo góc có thể trông giống hình tròn
- Nhà thiết kế có thể tận dụng box-shadow mà không cần dùng ảnh render sẵn, từ đó tránh làm tăng dung lượng tải về
- Một
divđơn lẻ có thể gắn nhiều box-shadow theo dạng chuỗi, và ví dụ trong bài dùng chính cách này để cấu thành màu sắc và bóng đổ - Có thể thử điều chỉnh border-radius tại CSS border-radius generator
Cách dùng box-shadow theo kiểu “sai sai”
- Trong thiết kế UI thông thường, bóng đổ là yếu tố phụ trợ giúp phân biệt trạng thái và tương tác, cùng với khoảng trắng, padding, typography và accessibility
- Nhưng box-shadow cũng có thể được xem như một API painting, dùng để tô một số lượng tùy ý các hình chữ nhật lên màn hình và tùy chọn áp dụng blur
- Thử nghiệm ban đầu bắt đầu từ minimal art trong bài viết trước, nơi chỉ với thiết lập các khối màu đơn giản cũng đã tạo ra kết quả thị giác
- Sau đó tác giả tạo một công cụ trực quan để xem nhiều box-shadow hơn, cùng với blur và độ trong suốt, ảnh hưởng đến hiệu năng ra sao
- Tạo một chuỗi box-shadow rất lớn rồi gán vào một
div - Animation ban đầu thay đổi chuỗi box-shadow mỗi 300ms rồi giao cho
transition: allxử lý - Cách này bị jank và còn chậm hơn việc gán box-shadow ở mỗi frame
- Tạo một chuỗi box-shadow rất lớn rồi gán vào một
- Ví dụ 100 box-shadow cho phép remix bảng màu bằng thao tác chạm và hiển thị lịch sử 10 bảng màu gần nhất ở bên trái
- Khi áp dụng blur, số lượng phần tử có thể animate giảm xuống, và màu có độ trong suốt cũng làm giảm mạnh số lượng phần tử có thể vẽ
- Kích thước
divcũng ảnh hưởng đến hiệu năng; khi animate, có vẻ software rasterizer đã tham gia vào quá trình này - Nếu không dùng độ trong suốt và blur, một laptop M1 có thể vẽ hàng nghìn box-shadow
Tạo mô phỏng bóng bằng box-shadow
- Box-shadow không thể xoay, nhưng có thể trông như hình tròn nhờ border-radius
- Tác giả xử lý nhiều bóng tròn như các quả bóng và scale kích thước theo giá trị z để tạo cảm giác chiều sâu 3D giả
- Cách triển khai là cập nhật trạng thái trò chơi trong
requestAnimationFrame, rồi ở mỗi frame gán một chuỗi box-shadow lớn vàodiv - Quy trình render như sau
- Sắp xếp các quả bóng theo giá trị z
- Tính kích thước dựa trên giá trị z
- Chuyển vị trí x/y, spread và màu của từng quả bóng thành một mục box-shadow
- Nối các mục bằng dấu phẩy để tạo thành một chuỗi duy nhất
- Ví dụ 50 quả bóng cho phép kéo để di chuyển bóng và làm chúng nảy bên trong hộp
- Scale 3D giả có tạo được phần nào cảm giác chiều sâu, nhưng khi bóng tiến gần “camera” thì hình tròn có thể bị vỡ
- Vì
divgốc dùng để tạo box-shadow quá nhỏ so với cách scale - Có thể khắc phục bằng cách tăng kích thước container, nhưng container càng lớn thì hiệu năng càng chậm
- Vì
- Phiên bản cho phép các quả bóng va chạm với nhau dùng kiểm tra va chạm
n^2và chỉ phản xạ vận tốc khi va chạm- Nó không mô phỏng tương tác vật lý thực sự
- Để dễ quan sát, vị trí z được cố định để biến thành 2D
- Tác giả cũng làm một ví dụ trong đó các quả bóng cố quay về vị trí khởi đầu ngẫu nhiên; khi dùng lực chạm để kéo chúng, hiệu ứng trông như bứt từng mảnh ra khỏi một miếng bọt biển
Ảnh và đám mây điểm 3D
- Thử nghiệm tiếp theo là ánh xạ pixel ảnh thành các điểm trên một mặt phẳng 2D và vẽ từng điểm bằng box-shadow
- Vị trí và màu của pixel ảnh được đọc ra rồi lưu vào
state.particles, mỗi pixel trở thành một hạt - Mã nguồn có tại CodeSandbox example
- Ví dụ này render hàng nghìn box-shadow trong không gian 3D và cho phép tương tác kiểu kéo để làm hình ảnh như bị phân tán ra
- Phiên bản live tự xoay được cung cấp tại
/box-shadow/v3?width=80&size=5&autoRotate=1, kèm cảnh báo về mức tiêu hao pin khi chạy - Ví dụ số lượng cao hơn đạt khoảng 12.000 box-shadow, và ở mức này đã xuất hiện hiện tượng giật
- Trên M1, số lượng box-shadow xử lý được rất lớn, nhưng desktop, iPhone và Android cũ không đạt được mức tương tự
Đặt các điểm trên bề mặt khối lập phương và hình cầu
- Tác giả tiếp tục với thử nghiệm chiếu đều các điểm lên bề mặt mesh
- Khối lập phương được triển khai bằng cách đặt các điểm dọc theo từng mặt
- Giả định mọi cạnh đều có cùng độ dài
- Duyệt từng mặt và điền các điểm theo kích thước cho trước
- Tăng số lượng điểm sẽ nâng độ chi tiết của khối lập phương
- Ví dụ khối lập phương có thể tương tác bằng thao tác chạm và thêm một nguồn sáng nhỏ đi theo vị trí chuột để tăng cảm giác chiều sâu
- Tính toán ánh sáng không chính xác và dùng nhiều “magic constants”
- Hàm ánh xạ hạt lên khối lập phương và nhiều helper toán học được lấy nhờ gypity
- Kết quả đầu tiên là phân bố ngẫu nhiên
- Đến lần thử thứ hai mới có được phân bố đều trên bề mặt khối lập phương
- Việc phân bố đều trên bề mặt hình cầu phức tạp hơn nên dùng phương pháp spiral discretization
- Ý tưởng là đặt các điểm đều trên một đường quấn quanh hình cầu từ dưới lên trên
- Được ví như một sợi dây quấn quanh quả bóng
- Dựa trên ánh xạ tuyến tính này, tác giả còn tạo animation gắn với mảng giá trị tần số của âm nhạc
- Các điểm được áp dụng animation bảng màu gradient có nội suy theo thời gian, và phần này khó hơn các thử nghiệm trước
- Ví dụ hình cầu với số vòng quay ít cho thấy đường xoắn chỉ xấp xỉ phân bố đều và sẽ bị vỡ nếu số vòng không đủ lớn
Ray tracer bằng box-shadow
- Ý tưởng rằng nếu có thể vẽ tam giác bằng box-shadow thì gần như có thể tạo ra mọi dạng CGI đã dẫn đến thử nghiệm ray tracing
- Mục tiêu là vẽ một cảnh chỉ bằng một
divvà box-shadow - Các ví dụ sau đó kết hợp render live độ chính xác thấp và render ảnh độ chính xác cao; một số ví dụ không đặt giới hạn precision nên cần cẩn thận khi chạy
- Ray tracer và ray marcher là các cách tạo ảnh chính xác nhưng chậm, được dùng rộng rãi trong ngành CGI
- Ở đây tác giả dùng tracer chạy trên CPU thay vì GPU
- Vì dùng GPU sẽ làm mất đi tinh thần của thử nghiệm và cũng khó triển khai hơn
- Tracer ban đầu chỉ render được hình cầu, và mô hình dữ liệu cảnh gồm camera, nhiều hình cầu và thông tin vật liệu
- Đoạn mã ban đầu nhận từ gypity chạy được theo thời gian thực nhưng có vấn đề
- Mã tối ưu sampling bị thiếu phần cộng bias coefficient cần thiết
- Có comment liên quan nhưng thiếu đúng dòng code đó, nên sau khi tra lại kỹ thuật sampling thì tác giả đã sửa
progressive rendering và Three.js
- Sau đó mã được chuyển sang cấu trúc progressive rendering
- Progressive rendering chia lượng lớn phép tính ray ra nhiều frame, để quá trình render dần tiến gần hơn tới “ground truth”
- Cấu trúc này rất hợp với việc di chuyển camera theo kiểu tương tác
- Camera và orbit controls dùng thư viện Three.js
- Để khỏi phải tự triển khai toán ma trận và hỗ trợ di động
- Phiên bản đó chỉ render được hình cầu, và mọi thành phần trong cảnh đều là các hình cầu đã được scale theo mức nào đó
- Ví dụ mặc định chạy ở mức chỉ hơn khoảng 6% tổng độ phân giải đầy đủ
- Càng ra xa màn hình thì cảnh ở độ phân giải thấp trông càng rõ, còn khi nhìn gần sẽ thấy thiếu chi tiết rõ rệt hơn
- Có thể điều chỉnh scale độ phân giải,
pixelSize, số lần ray bounce và số sample tối đa qua query parameter - Tăng độ phân giải và sample sẽ cho kết quả tốt hơn nhưng cực kỳ chậm
Cách triển khai tracer và nút thắt hiệu năng
- Việc render diễn ra theo cách tiêu chuẩn: bắn ray từ camera cho từng pixel, tính màu rồi lấy trung bình giữa các frame
- Vì tracer dùng Three.js nên tạo ra nhiều object mới và chúng nhanh chóng trở thành rác
- Tác giả đã thử tái sử dụng object phần nào, nhưng nếu muốn vắt hiệu năng tối đa thì tốt hơn là không dùng Three.js
- Theo profiler, garbage collection không tạo khác biệt quá lớn nên vẫn giữ Three.js
- Ý tưởng cốt lõi của tracer là ray va vào các đối tượng trong cảnh rồi bật nảy cho đến khi chạm tới ánh sáng, sau đó trả về màu dựa trên thuộc tính của vật thể và nguồn sáng
- Lý do phải bắn rất nhiều ray là vì có ray sẽ không chạm nguồn sáng, còn có ray thì có
- Tracer này dùng mô hình chiếu sáng đơn giản
- Không có BRDF chính xác vật lý
- Không có texture
- Không có subsurface scattering
- Chỉ dùng diffuse light đơn giản và specular reflection
- Có thể tham khảo Ray Tracing in One Weekend để học về ray tracing
- Sau đó tác giả đã render được plane, và sau khi sửa lỗi thao tác bit trên giá trị floating point thì cũng render được cả hình chữ nhật phát sáng
Song song hóa bằng Web Workers
- Thay vì sửa vấn đề GC của Three.js, tác giả cải thiện hiệu năng bằng đa luồng Web Workers
- Ray tracing rất hợp để song song hóa vì mỗi phép tính chỉ trả về một kết quả đơn lẻ và không có side effect
- Worker manager tạo một worker pool và cung cấp hai phương thức
render,updateSceneđể có thể thay đổi cảnh trong runtime - Mã worker là bản sao của tracer cũ, và cần marshaling dữ liệu để thay thế cảnh
- Để giảm overhead của
postMessage, worker không render từng pixel riêng lẻ mà render cả frame - Ví dụ toàn màn hình chạy nhanh hơn rất nhiều so với trước
- Nhược điểm là khi camera hoặc cảnh thay đổi, toàn bộ kết quả ray đã tính trước đó đều mất hiệu lực nên màn hình có thể chuyển sang màu đen
- Cách xử lý là đặt cờ
isDirtytrong event input rồi để update loop quyết định có xóa frame hay không - Có trường hợp worker gửi một frame được tính theo dữ liệu cảnh cũ ngay sau khi cảnh vừa cập nhật
- Có thể kèm timestamp hoặc scene id trong
postMessageđể loại bỏ - Trong thử nghiệm này, tác giả cứ để nguyên vì dữ liệu ray sai của một frame sẽ sớm bị lấy trung bình mất đi
- Có thể kèm timestamp hoặc scene id trong
- Kết quả là ray tracing dựa trên box-shadow hoạt động đủ tốt, và mã nguồn có tại CodeSandbox
- Thống kê hiệu năng hiển thị ước lượng tổng số ray, số sample và độ phân giải render
- Mặc định sample dừng ở 1200 nhưng có thể cấu hình
- Cũng có thể bật một mô hình chiếu sáng thay thế nhanh hơn nhưng kém chính xác hơn
- Dữ liệu cảnh ở dạng JSON nên có thể chỉnh sửa trực tiếp khá dễ
Kết luận: khả thi nhưng không được khuyến nghị
- Chỉ với box-shadow của một
div, có thể vẽ mô phỏng bóng, đám mây điểm, hạt dựa trên ảnh, bề mặt khối lập phương và hình cầu, thậm chí cả cảnh ray tracing - Tuy vậy, toàn bộ thử nghiệm này gần như là một cách làm “không nên làm”, không có trường hợp sử dụng thực tế
- Vẫn còn chỗ để cải thiện nếu thêm triangle mesh loading, acceleration structure và mô hình chiếu sáng chính xác hơn
- Ban đầu gypity từng trả lời rằng box-shadow ray tracer là bất khả thi, nhưng cuối cùng ví dụ thực tế vẫn được tạo ra
- CSS không phải lúc nào cũng trực quan, nhưng đôi khi nó lại có những khả năng kỳ lạ mà rõ ràng là hoạt động được
1 bình luận
Ý kiến trên Hacker News
Dùng màu trong suốt sẽ chậm hơn vì nó hạn chế xử lý theo lô khi vẽ bằng GPU
Khi vẽ các phần tử đục, có thể dùng depth buffer để thay đổi thứ tự tùy ý và tối đa hóa việc xử lý theo lô. Với phần tử trong suốt, phải vẽ theo thứ tự painter để blending hoạt động đúng
Đặc biệt Chromium cố gắng giảm thiểu tổng số layer, render từng layer thành pixel map rồi compositing các layer đang hiển thị thành ảnh cuối cùng ở mỗi frame. Thực tế có nhiều layer pixel không đổi mà chỉ thay đổi vị trí, nên chỉ cần compositing chứ không cần rasterize lại mỗi frame
Nếu có nhiều box-shadow không có độ trong suốt, Chromium có thể rasterize toàn bộ thành một layer duy nhất một lần; nhưng nếu có nhiều box-shadow trong suốt, nó có thể tạo layer riêng cho từng cái. Trường hợp này có thể kém hiệu quả, nhưng cũng là cách cần thiết nếu các box-shadow bán trong suốt phải di chuyển độc lập trên trang
Bắt đầu với một buffer hoàn toàn trong suốt (α=0.0), duyệt từng mặt từ trước ra sau, và với mỗi pixel, blend phần
1.0-buffer.αcủa pixel mới vào buffer hiện có. Nếubuffer.α == 1.0thì có thể bỏ qua hoàn toàn như depth bufferTuy nhiên cần kiểm tra lại phần toán học khi phía sau một đối tượng trong suốt còn có đối tượng trong suốt khác, và các trường hợp các mặt chồng lấn theo vòng hoặc xuyên qua nhau thì khá rắc rối
Đây cũng là lý do nói chung vẽ nhiều tam giác hơn thường tốt hơn so với dùng texture trong suốt
Rendering đục có thể vẽ từ trước ra sau, nên chỉ cần render những gì thực sự nhìn thấy trong framebuffer cuối cùng; số pixel sau depth pass tỷ lệ với kích thước framebuffer
Rendering trong suốt phải vẽ từ sau ra trước, và phải render rất nhiều phần cảnh mà sau đó sẽ bị các đa giác khác che một phần. Vì vậy số pixel đi qua shader pipeline có thể tăng đến mức tỷ lệ với kích thước mesh
Nếu các phần tử không chồng lên nhau, độ trong suốt hầu như sẽ không làm chậm đi. Dù sao cũng phải chạm vào mỗi pixel một lần, và thứ thay đổi chỉ là công thức shader
Nếu một phép vẽ đục nằm trên các phép vẽ khác, trong trường hợp tốt nhất có thể loại bỏ toàn bộ các thao tác vẽ chồng lấn; trong trường hợp xấu nhất cũng chỉ dùng lượng băng thông tương đương từng phép vẽ riêng lẻ
Với độ trong suốt, nếu không thể gộp phép toán bằng cách nào đó thì phải đọc lại toàn bộ vùng chồng lấn, nên với mỗi phép vẽ trong suốt sẽ có lượng bitmap ít nhất gấp đôi kích thước framebuffer cuối cùng đi qua memory bus
Nghĩ đến việc nhiều thiết bị di động còn không có đủ băng thông bộ nhớ để blit toàn màn hình hai lần trong khoảng thời gian cần để giữ 60fps, đây là một vấn đề khá lớn
Đây thực sự là một cuộc khám phá thú vị
Nói layering là từ khóa quan trọng cũng đúng. Hiệu ứng vừa lố bịch vừa đôi khi trông rất ngầu trong dự án text shadow tôi làm 14 năm trước cũng có cốt lõi là layering: https://paulirish.github.io/mothereffingtextshadow/
Mãi đến đoạn cuối tôi mới nhận ra
gypitylà đang chỉ ChatGPT, thấy hơi ngượnggypTôi hoàn toàn hoan nghênh kiểu hack phi thực dụng cổ điển thú vị này. Chỉ cần nhớ là ta đã có canvas, và những việc như vậy canvas làm dễ hơn, nhanh hơn và tốt hơn
Phần trực quan hóa âm nhạc chắc chắn rất ngầu. Tôi thật sự nhớ thời Winamp ngày xưa, khi bật nhạc rồi mở visualization toàn màn hình
Ước gì các trình phát âm thanh streaming ngày nay cũng có tính năng như vậy
Nhưng đúng là các trình phát âm thanh streaming thực sự chỉ là phần mềm có các chức năng rất cơ bản
Rốt cuộc Firefox và Chrome vẫn render box-shadow 1px khác nhau ở mức phóng to trình duyệt 150%
Hy vọng vào Baseline 2025
Tôi tò mò liệu có tài liệu tham khảo nào để học thêm về “các hack toán học để vẽ những hộp bo góc mà dân UI thích với chi phí rất rẻ” không
https://www.folklore.org/Round_Rects_Are_Everywhere.html
http://wg20.criticalcodestudies.com/index.php?p=/discussion/...
Với cách tăng tốc 3D hiện đại thì có bài dùng SDF, như một người khác đã đoán
https://mortoray.com/quickly-drawing-a-rounded-rectangle-wit...
Đây là kiểu hack tôi thích
Nó gần như đối lập hoàn toàn với các bài của Josh Comeau mà tôi từng đọc về chủ đề này: https://www.google.com/search?q=josh+comeau+shadows
Một bài viết xuất sắc, có thể là hay nhất tôi đọc trong năm nay, lại kết thúc bằng your welcome thay vì
you'reCần sửa gấp. Hoặc có thể tôi không hiểu được trò đùa
Suốt 30 năm qua tôi đã trở nên khá giỏi lập trình, nhưng vì không thích game nên gần như không làm graphics
Giờ tôi xem đó là một sai lầm lớn và đã cố bắt kịp hơn một năm nay, nhưng thật sự rất khó