1 điểm bởi GN⁺ 2024-11-11 | 1 bình luận | Chia sẻ qua WhatsApp
  • Trong những tình huống cần in nhanh thông điệp gỡ lỗi bên trong render pass, việc chuẩn bị font atlas là một gánh nặng, nên cách vẽ văn bản chỉ bằng hằng số fragment shader sẽ rất hữu ích
  • Glyph được biểu diễn bằng bitmap 8x16 pixel, và 96 ký tự ASCII có thể in được được lưu trong mảng uvec4 dài 1536 byte để shader tra cứu trực tiếp
  • Dữ liệu font có thể được tạo bằng cách lấy 96 glyph tiếp theo từ font terminal PSF1, sau khi bỏ qua phần header 4 byte và vùng glyph không in được 512 byte
  • Việc render dùng uint32_t theo đơn vị 4 ký tự và một struct per-instance chứa vị trí cùng tỉ lệ, để vẽ toàn bộ văn bản bằng một instanced draw call duy nhất
  • Vì đây là kỹ thuật tập trung vào xuất gỡ lỗi đơn giản, các ràng buộc như đệm 4 ký tự, ký tự điền \0, discard, và hiệu chỉnh endianness phải được xử lý phối hợp giữa ứng dụng và shader

Dùng hằng số shader thay cho font atlas

  • Cách render văn bản thông thường là render các glyph có thể dùng vào font atlas, bind nó như một texture, rồi vẽ các tam giác tương ứng với từng glyph
  • imguistb_truetype cũng dùng cách này, nhưng với việc xuất nhanh thông điệp gỡ lỗi thì phần chuẩn bị có thể khá phiền phức
  • Một phương án thay thế là lưu dữ liệu tương ứng với font atlas dưới dạng hằng số số nguyên bên trong fragment shader
  • Số nguyên có thể được dùng như bitmap, ánh xạ vị trí xy của fragment tới một vị trí bit cụ thể; nếu bit đó bật thì xuất màu tiền cảnh, nếu tắt thì xuất màu nền

Glyph bitmap 8x16 và bảng ASCII

  • Một byte chỉ biểu diễn được pixel của một dòng, nên để tạo glyph dễ đọc hơn, mỗi glyph dùng 16 byte
  • Mỗi glyph trở thành một canvas 8x16 pixel, và một uvec4 của GLSL chứa vừa đúng 16 byte cần thiết
  • Nếu lưu 96 glyph ASCII có thể in được thì tổng dữ liệu là 1536 byte
  • Mảng font_data[96] dùng chỉ số bằng giá trị ASCII trừ 0x20
    • Áp dụng cho các glyph ASCII có thể in được bắt đầu từ 0x20 SPACE
    • Để tiết kiệm chỗ, mã ví dụ chỉ hiển thị một phần các mục
  • Bảng bitmap đầy đủ có trong mã nguồn Island

Trích xuất bitmap từ font PSF1

  • Cách mã hóa bitmap cần dùng gần như trùng khớp với font terminal định dạng PSF1
  • Quy trình trích xuất dữ liệu từ font terminal PSF1 khá đơn giản
    • Mở file font bằng trình soạn thảo hex như ImHex
    • Bỏ qua header 4 byte
    • Bỏ qua phần glyph không in được dài 512 byte
    • Xuất 96 glyph tiếp theo, tức 1536 byte, bằng “Copy as → C Array”
  • Mảng char đã trích xuất có thể được chỉnh thành mảng uint rồi nhóm lại theo đơn vị uvec4
  • Nếu ghép trực tiếp raw char thành uint thì endianness sẽ bị đảo, nên khi lấy mẫu cần hiệu chỉnh lại
  • Dữ liệu gốc của font pixel được dùng lấy từ font pixel miễn phí Tamsyn của Scott Fial

Cấu hình một instanced draw call duy nhất

  • Việc render văn bản được xử lý bằng một instanced draw call
  • Draw call dùng hai luồng attribute
    • Luồng per-draw chỉ chứa thông tin cần thiết để vẽ một quad thông thường
    • Luồng per-instance chứa độ lệch vị trí trên màn hình và đoạn văn bản cần xuất
  • Phần độ lệch vị trí dùng hai số thực x, y, và khoảng trống float còn lại có thể dùng để chứa giá trị scale của font
  • Trong Vulkan, mọi component của vertex output binding phải có cùng đặc tính nội suy, nên khó trộn gọn gàng vec3uint trong cùng một binding
  • Văn bản được đóng gói vào uint32_t theo đơn vị 4 ký tự
    • Kiểu dữ liệu attribute vertex cơ bản thường có đơn vị tối thiểu là 32 bit, nên có thể chứa 4 ký tự trong một lần
    • Độ dài thông điệp phải chia hết cho 4
    • Phần thiếu sẽ được điền bằng ký tự \0
  • Dữ liệu per-instance được biểu diễn bằng struct word_data
    • pos_and_scale[3]: vị trí xy và scale
    • word: bốn ký tự cần in
  • Ứng dụng chia thông điệp thành các khối 4 ký tự, chuyển từng khối thành uint32_t, rồi tích lũy chúng cùng độ lệch vị trí vào mảng word_data
  • Khi render, mảng này được bind làm per-instance binding cho pipeline vẽ debug text, và vẽ số instance tương ứng với số quad

Truyền vị trí và ký tự trong Vertex Shader

  • Vertex shader xuất gl_Position, word cần render, và giá trị tương ứng với tọa độ texture
  • gl_Position dùng dữ liệu pos_and_scale per-instance để đặt các đỉnh tam giác lên màn hình trong hệ tọa độ NDC
  • word cần render được truyền nguyên từ attribute đầu vào kiểu uint sang fragment shader
    • Dùng qualifier flat để tránh bị nội suy
  • Tọa độ texture được tổng hợp từ gl_VertexIndex
    • 12 >> gl_VertexIndex & 1 tạo ra chuỗi 0, 0, 1, 1
    • 9 >> gl_VertexIndex & 1 tạo ra chuỗi 1, 0, 0, 1
    • Kết hợp này tạo ra tọa độ uv (0,1), (0,0), (1,0), (1,1) mà không cần phân nhánh
  • Vertex shader cũng nhận màu tiền cảnh và màu nền từ dữ liệu per-instance rồi truyền sang fragment shader

Lấy mẫu glyph trong Fragment Shader

  • Để fragment shader render được văn bản, cần ba loại thông tin
    • Tọa độ uv của fragment sau nội suy
    • Dữ liệu ký tự cần xuất in_word
    • Mảng bitmap glyph font_data
  • Tọa độ uv là dải float chuẩn hóa từ vec2(0.f,0.f) tới vec2(1.f,1.f), còn tọa độ pixel glyph là từ uvec2(0,0) tới uvec2(7,15)
  • Toàn bộ word 4 ký tự được xem như một vùng rộng 32 pixel và cao 16 pixel
    • Lượng tử hóa thành tọa độ pixel của word bằng cách floor trên uv.xy * vec2(8 * WORD_LEN, 16)
    • Giới hạn phạm vi tọa độ trong uvec2(0..31, 0..15)
    • Dùng word_pixel_coord.x / 8 để xác định đang nằm trong vùng của ký tự nào trong bốn ký tự
    • Dùng word_pixel_coord.x % 8 để lấy tọa độ x bên trong glyph
  • Mã ký tự được chuyển thành chỉ số của font_data
    • Vì glyph đầu tiên là 0x20 SPACE, nên dùng printable_character - 0x20 làm offset
    • Lấy bitmap glyph dạng uvec4 tương ứng bằng offset đó
  • Tọa độ y dùng glyph_pixel_coord.y / 4 để chọn uint cụ thể bên trong uvec4
    • uint này chứa dữ liệu pixel của 4 dòng
    • Do char trích từ ImHex được nối trực tiếp để tạo uint, thứ tự dòng bị đảo ngược
    • Cần hiệu chỉnh bằng cách đánh chỉ số từ cuối theo dạng (8*(3-(glyph_pixel_coord.y)%4))
  • Tọa độ x dùng 7-glyph_pixel_coord.x để chọn bit
    • Bit có trọng số lớn nhất của byte được lưu ở chỉ số cao nhất, nên muốn ánh xạ từ trái sang phải phải đánh chỉ số ngược
  • Cuối cùng, dùng giá trị current_pixel để áp dụng mix(background_colour, foreground_colour, current_pixel) và quyết định màu

Xử lý chuỗi ngắn và ký tự điền

  • Nếu độ dài chuỗi không chia hết cho 4, ứng dụng sẽ điền phần thiếu bằng ký tự \0
  • Fragment shader kiểm tra xem ký tự cần xuất có phải \0 hay không
  • Khi gặp ký tự điền \0, nó không vẽ cả nền mà thực hiện discard
  • Cách xử lý này giữ nguyên ràng buộc đóng gói 4 ký tự mà vẫn cho phép xuất chuỗi ngắn

Cách dùng và vị trí mã

  • Trong dự án Island, có thể gọi le::DebugPrint để xuất văn bản gỡ lỗi lên màn hình
  • Toàn bộ mã fragment shader có thể xem trên github
  • Ví dụ mã truyền dữ liệu chuỗi để hiển thị thông điệp dạng "That's all, %s" trên màn hình

1 bình luận

 
GN⁺ 2024-11-11
Ý kiến trên Hacker News
  • Nếu muốn tự thử, chỉ cần lần theo phần số học. Việc triển khai từ đầu trên ShaderToy cực kỳ dễ, và nếu bạn thích những thứ kiểu này thì cũng là một trò vui sáng thứ Bảy
    Tự làm từ đầu thì thú vị, nhưng nếu cần gợi ý khởi đầu thì đây là ví dụ tôi vừa tạo: https://www.shadertoy.com/view/Mc3cW2
    Cũng có rất nhiều mẹo xử lý chữ thông minh do người khác làm, như ví dụ Matrix dưới 300 ký tự https://www.shadertoy.com/view/llXSzj hay hiệu ứng hiển thị CRT xanh lá https://www.shadertoy.com/view/XtfSD8

    • Mỗi lần thử render văn bản theo immediate mode, tôi đều không làm cho chữ trông đẹp ở kích thước nhỏ được. Ngay cả trong ShaderToy đầu tiên, nếu đổi 30 trong vec2(30, -30) thành 300 thì sẽ thấy artifact
      Tôi tò mò không biết có mẹo nào để xử lý đúng không. Trong trường hợp của tôi, multisampling texture bên trong fragment shader là hiệu quả nhất, nhưng vẫn chưa tốt bằng mức hiện đại
    • Tôi làm phát triển game Unity, và mảng này thật sự có cảm giác đang đi lùi. Vài năm trước, Unity đã mua công cụ render phông chữ tốt nhất do một người làm ra rồi đưa vào làm tính năng mặc định; từ đó về sau việc phát triển gần như dừng lại, và thị trường cạnh tranh về render phông chữ cũng chết theo
      Cách đây không lâu tôi thử làm một ứng dụng trông như phông chữ console native, mà chỉ để đạt khoảng 90% thôi cũng phải loay hoay hơn 2 giờ
    • Hơi lạc đề nhưng là một ví dụ thú vị: hiệu ứng Matrix làm bằng HTML/CSS/JS có thể nằm gọn trong 1024 byte: https://codegolf.stackexchange.com/a/17414
  • Kỳ quặc, hacky và vui. Thật ra gần như mọi kỹ thuật render 3D đều như vậy, nhưng kết quả không hẳn là đẹp nếu mục tiêu không phải tái hiện các BBS ngày xưa
    Có thể cải thiện bằng cách thêm nhiều bit hơn, nhưng rất lâu trước khi nó trông đẹp, bạn sẽ tìm ra một cách dễ hơn để đặt tất cả các bit. Cuối cùng sẽ quay về điểm xuất phát, vì hầu như không có giải pháp nào hiệu quả hơn việc tạo các pixel đen trắng trong chương trình vẽ rồi lưu thành texture
    Nếu tò mò về cách phổ biến hơn mà các engine render 3D hiện đại dùng để vẽ chữ, hãy tìm SDF text và các kỹ thuật liên quan như MSDF. Đó là cách dùng texture atlas truyền thống ở bước tiền xử lý để tạo atlas trường khoảng cách có dấu

    • Nhắc đến “quay về điểm xuất phát”, nếu chưa xem thì bài báo năm 1968 On the Design of Display Processors rất đáng đọc: http://cva.stanford.edu/classes/cs99s/papers/myer-sutherland...
      Bài đó nói về phần cứng, nhưng phần mềm cũng có luân hồi
    • Những cách như SDF text hay MSDF giờ đã cũ ít nhất một thế hệ rồi. Ngày nay gần như ai cũng dùng cách tiếp cận như https://sluglibrary.com, raster hóa trực tiếp đường cong Bézier của phông chữ trong shader
      Trước đây tôi từng làm một phiên bản rất cơ bản của ý tưởng này: https://www.shadertoy.com/view/sdXBDs
    • Chẳng hạn trong tình huống upload texture không hoạt động đúng, cách này khá thông minh cho văn bản debug. Tuy vậy, việc bài gốc ví sprite sheet với sắp chữ thủ công thế kỷ 16 thì dễ thương đấy, nhưng thực tế một thợ phụ in ấn mất cả giờ để xếp chữ kim loại, còn upload sprite sheet lên GPU mất chưa tới 10 ms và sau đó có thể cấu hình vô hạn
      Không có ý nói đây không phải là một thủ thuật hay; thực ra nó là một thủ thuật hay
  • Cũng có lựa chọn render văn bản dưới dạng mesh. TextMeshPro còn tiến thêm một bước, dùng trường khoảng cách có dấu để xử lý tỉ lệ tùy ý
    https://docs.unity3d.com/Packages/com.unity.textmeshpro@4.0/...

    • Tiến xa hơn nữa thì cũng có thể đánh giá trực tiếp các đường cong phông chữ trên GPU, cho chất lượng cao bất kể tỉ lệ hay phối cảnh. Triển khai hiệu quả thì rất khó, nhưng vẫn khả thi
      Ví dụ: https://sluglibrary.com
      Mesh và SDF đơn giản hơn nhiều ở phía GPU, nhưng nếu phóng quá lớn thì độ chính xác có thể giảm, còn nếu thu mesh quá nhỏ thì có thể sinh aliasing
  • Rất tuyệt. Sẽ thú vị nếu có so sánh hiệu năng với cách dùng texture “truyền thống”
    Với những tác vụ đơn giản kiểu này trên GPU ngày nay, câu trả lời cho câu hỏi “hiệu năng thế nào?” dường như thường là “chạy được”

    • Câu trả lời tôi mong muốn cho “hiệu năng thế nào?” là “VSCode không còn ngốn thêm từ vài trăm MB đến vài GB VRAM của tôi nữa”
  • Sebastian Lague đã làm một video hay về nhiều kỹ thuật render phông chữ
    https://youtu.be/SO83KQuuZvg

  • Tôi từng thử một kỹ thuật tương tự, trong đó nhét toàn bộ dữ liệu phông chữ vào mã nguồn fragment shader. Như vậy có thể dùng snprintf để in trực tiếp vào bộ đệm GPU được ánh xạ trên CPU. Tôi biết đây là cách làm nguy hiểm
    Thay vì vẽ từng ký tự bằng vertex shader, tôi chỉ vẽ một tam giác toàn màn hình và dùng gl_FragCoord thay cho tọa độ UV. Đây không phải cách hiệu quả nhất, nhưng là tính năng debug và trong thực tế đủ nhanh
    Dù tên tệp là vậy, tôi dùng phông chữ từ IBM PC ROM chứ không phải NES. “NES font” và các phông chữ 8x8 pixel khác có thể tìm thấy trên web
    https://github.com/rikusalminen/triangles/blob/nesfont/shade...

    • Gói phông chữ pixel tôi thích nhất là cái này: https://int10h.org/oldschool-pc-fonts/
    • Gần đây tôi mới biết phông chữ “NES” bắt nguồn từ game arcade Quiz Show năm 1976. Phông chữ này cũng được dùng trong các game Kee/Atari đen trắng khác, và dữ liệu phông chữ nằm trong bộ ROM MAME của quizshow, nhưng không hiểu sao lại bị tách theo từng nibble
      Điều thú vị là game này lưu dữ liệu câu hỏi và câu trả lời trên băng 8-track
  • Hay đấy. Không thường thấy một thuật toán render văn bản mà tôi chưa tự thử qua. Ở startup của mình tôi đã triển khai nhiều cách khác nhau, nhưng vì tôi cần độc lập độ phân giải và khử răng cưa nên cách này có lẽ đã không giúp được gì
    Nó cũng có thể không tổng quát hóa được cho mọi tệp phông chữ dùng đường cong Bézier. Chuyển đường cong thành pixel có thể khó, đặc biệt khi glyph tự giao cắt với chính nó. Nhìn chung, render văn bản tiêu chuẩn có cảm giác là bài toán đã được giải, còn các trường hợp sử dụng phi tiêu chuẩn thì rất khắc nghiệt để thử nghiệm
    Về mặt ý tưởng, cách này trông giống phương pháp của Will Dobbie mà tôi thích, chỉ là đơn giản hơn nhiều. Cả hai đều lấy dữ liệu phông chữ thô và dùng trực tiếp trong shader. Khác biệt là cách này lưu dữ liệu pixel trong mảng, còn Will lưu dữ liệu đường dẫn SVG dưới dạng “texture vector”
    Nếu tò mò, Will có một demo rất ấn tượng: https://wdobbie.com/warandpeace/

  • Trước đây tôi từng nghĩ đến việc thử làm kiểu này, nhưng hiểu rằng GPU đặc biệt hiệu quả với render texture và tương đối chậm với thao tác bit. Dù tiết kiệm được chút bộ nhớ ở đây, tôi tự hỏi liệu thực tế nó có nhanh hơn dùng atlas không
    Có lẽ nếu bit-pack vào một texture thông thường rồi để fragment shader giải mã thì có thể được lợi cả hai phía

    • Cách hiểu đó đã khá cũ rồi. Trên GPU trong khoảng 15 năm gần đây, tra cứu texture chậm hơn phép toán bit khoảng 100 lần
  • Tôi gần như không biết gì về đồ họa máy tính hiện đại nên hỏi thuần túy thôi: chi phí upload một texture nhỏ lên GPU có lớn đến vậy không? Tôi tự hỏi liệu có thể render toàn bộ chuỗi thành texture 2D rồi hiển thị texture đó trên hai tam giác không

    • Chi phí không lớn. Kỹ thuật này thiên về việc đưa văn bản debug lên màn hình dễ nhất có thể hơn là vì hiệu năng. Chỉ cần thêm một ít dữ liệu vào shader là có chữ ngay
      Ngược lại, bạn phải viết mã tạo font atlas, hoặc tìm và tải một atlas có sẵn, mà như vậy lại cần mã tải. Hoặc phải vẽ toàn bộ thông điệp lên texture và cache kết quả đó cho đến khi thông điệp thay đổi
      Rồi còn phải quản lý tài nguyên và binding nữa, trong khi cách này không cần tài nguyên. Nói cách khác, đây không phải giải pháp văn bản thông thường mà là kỹ thuật để hiển thị văn bản debug trên màn hình
      Nhân tiện, hầu hết trình duyệt và hệ điều hành hoạt động bằng cách vẽ chữ lên texture. Chúng vẽ phông chữ động vào texture atlas, rồi dùng các glyph trong atlas đó để tạo thêm texture cho một phần cửa sổ ứng dụng. Nếu bật hiển thị ranh giới texture trong trình duyệt, bạn có thể thấy tất cả texture; Rendering->Layer borders sẽ viền từng texture bằng màu xanh lơ
    • Ngay cả hiện nay, thường vẫn nên tránh lãng phí quá nhiều bộ nhớ GPU. Một texture hộp văn bản lớn phải được truyền qua bus PCI, và tùy thời điểm upload hoặc việc tài nguyên GPU có bị đẩy ra hay không, có thể gây khựng
      Nếu CPU tương đối chậm phải render nhiều hộp văn bản texture độc lập, chi phí sẽ nhanh chóng cộng dồn và ăn vào ngân sách
      Vẽ bằng glyph atlas vẫn tốt hơn nhiều về mặt sử dụng tài nguyên. Các pipeline render văn bản hiện đại thường dùng SDF hoặc đường cong Bézier được mã hóa để tăng độ dễ đọc của glyph khi phóng to/thu nhỏ, và đây cũng là một cách tốt để tiết kiệm bộ nhớ hơn
    • Cách vẽ một hình chữ nhật bao phủ N ký tự rồi chọn glyph trong shader có lẽ sẽ nhanh hơn vẽ một hình chữ nhật riêng cho từng ký tự. Ít nhất là với phông chữ monospace. Tuy nhiên số ký tự có thể lấp đầy màn hình là có hạn, nên khác biệt thực tế nhiều khả năng không lớn
      Từ góc nhìn upload, cuối cùng vẫn có X byte glyph và bằng cách này hay cách khác chúng phải vào bộ nhớ GPU. Dù là texture, dữ liệu uniform hay hằng shader thì về hiệu năng cũng không khác biệt lớn. Ngược lại, nếu đưa vào dạng hằng shader như bài gốc, mọi khai báo hằng đều phải được trình biên dịch shader xử lý, nên chi phí phía CPU có thể còn lớn hơn
      Ở phía GPU, điều quan trọng là khi đọc dữ liệu glyph thì chạm vào tầng bộ nhớ nào. Texture fetch trên hầu hết GPU dùng cache L1 chuyên dụng, và có lẽ lớn hơn cache L1 thông thường. Thứ tự dữ liệu cũng quan trọng. Texture thường được lưu theo một biến thể của thứ tự Morton để tránh cache miss khi shading các khối pixel. Nếu là renderer văn bản dựa trên atlas dùng thực tế, có lẽ nên dùng texture
      Sửa: tôi đã đọc nhầm câu hỏi. Nếu đang so sánh việc vẽ từng glyph riêng trên GPU với việc vẽ cả khối văn bản trên CPU, thì đây là đánh đổi giữa tốc độ và không gian. Câu trả lời phụ thuộc vào việc bạn muốn dùng bao nhiêu bộ nhớ cho văn bản, văn bản có thay đổi không, có cần hiệu ứng theo từng ký tự không, v.v.
    • Có thể render toàn bộ chuỗi trước khi upload, nhưng như vậy về bản chất là dùng CPU rendering, nên sẽ chậm hơn GPU làm cùng việc
      Nói thêm, dù gọi là “không texture”, cách này cũng vẫn là texture. Chỉ là texture được lưu ở định dạng khác và vị trí khác. Render phông chữ thật sự không dùng texture là đánh giá các đường cong vector ngay tại chỗ
    • Tùy ứng dụng. Đặc biệt nếu bạn có thể gặp chữ viết từ phải sang trái, CJK, emoji, thì đó là cách dễ nhất. Phần lớn văn bản không thay đổi mỗi frame, nên rất đáng cache texture, và với chúng tôi như vậy là đủ tốt
  • Nói là sẽ không lưu bitmap trong shader, nhưng rồi lại giải thích chính xác cách lưu bitmap trong shader, nên khá dễ gây nhầm lẫn
    Tóm lại là đã nhúng phông chữ bitmap vào shader

    • Không, ý là sẽ không lưu bitmap trong texture. Việc đó khác với nhúng trực tiếp vào mã shader
      Có thể ví như sự khác nhau giữa lưu dữ liệu trong một tệp riêng cần đọc lúc runtime và đưa dữ liệu trực tiếp vào mã nguồn