เทคโนโลยี UE5 Nanite บน WebGPU
(github.com/Scthe)- Nanite WebGPU เป็นโปรเจ็กต์ที่นำการเรนเดอร์แบบ Nanite ของ UE5 มาทำงานในเบราว์เซอร์ด้วย WebGPU โดยมี meshlet LOD hierarchy, ซอฟต์แวร์แรสเตอร์ไรเซอร์, billboard impostor และการคัลลิงระดับ instance/meshlet
- การพัฒนามุ่งเน้นทั้งความเรียบง่ายในการพรีโปรเซสอินพุต OBJ ภายในเบราว์เซอร์ และการ ทดลองดูความเปลี่ยนแปลงของประสิทธิภาพ ด้วยการปรับค่าผ่าน checkbox และ slider
- เดโมมีฉาก Jinx ขนาด 640M triangles และฉากอย่าง Lucy/Dragons ที่มากสุด 1.7B triangles โดยสามเหลี่ยมสีขาวใน Jinx คือผลจากการซอฟต์แวร์แรสเตอร์ไรซ์ที่บีบอัดเป็น 32 บิตเพื่อรองรับข้อจำกัดของ WebGPU
- เนื่องจาก WebGPU ไม่มี
atomic<u64>จึงยากที่จะนำซอฟต์แวร์แรสเตอร์ไรเซอร์แบบอิง visibility buffer ที่มีประสิทธิภาพมาใช้ตรง ๆ ได้ และเวอร์ชันปัจจุบันต้องเก็บ depthu16กับ octahedron normal2*u8ไว้ใน 32 บิต ทำให้ต้องยอมรับ precision artifact - ผู้เขียนระบุว่าความท้าทายหลักของการทำระบบแบบ Nanite จริง ๆ ไม่ใช่แค่จำนวนสามเหลี่ยม แต่คือ mesh simplification และ error metric โดยการทำเองนี้ยังไม่มีส่วนอย่าง simplification, streaming, compression, visibility buffer หรือ 2-pass occlusion culling แบบ UE5
ภาพรวมโปรเจ็กต์
- Nanite WebGPU เป็นโปรเจ็กต์ที่ใช้ WebGPU เพื่อทำการเรนเดอร์แบบ Nanite ในเว็บเบราว์เซอร์
- องค์ประกอบหลักที่รวมไว้คือ meshlet LOD hierarchy, ซอฟต์แวร์แรสเตอร์ไรเซอร์, billboard impostor และการคัลลิงระดับ instance และ meshlet
- รองรับโมเดลที่มี texture และ per-vertex normal พร้อมมี slider และ checkbox สำหรับแต่ละการตั้งค่า
- WebGPU ใช้งานได้เฉพาะใน Chrome
- แอปทั้งหมดสามารถรันแบบออฟไลน์ได้ด้วย Deno และ unit test ของ shader ก็เขียนด้วยวิธีนี้เช่นกัน
ฉากเดโมและขนาด
- Jinx เดโม มี instance ขนาด 120×120 รวมเป็น 640M triangles
- โมเดล Jinx เดี่ยวถูกย่อจาก 44k triangles เหลือ 3k triangles และมี root meshlets 59 ตัว
- สามเหลี่ยมสีขาวคือสามเหลี่ยมที่ถูกซอฟต์แวร์แรสเตอร์ไรซ์ อยู่ระหว่างพื้นที่ที่ฮาร์ดแวร์แรสเตอร์ไรซ์กับ impostor ของฉากหลังที่อยู่ไกล
- Lucy and dragons วางวัตถุสองชนิดแบบ instance อย่างละ 70×70 รวมเป็น 1.7B triangles
- ในฉากตัวอย่าง เกือบ 98% ของสามเหลี่ยมถูกซอฟต์แวร์แรสเตอร์ไรซ์ และผู้เขียนระบุว่าเส้นทางนี้เร็วกว่า hardware มาก
- Lucy เป็นฉาก 110×110 instance ขนาด 1.2B triangles
- รูปปั้น Lucy เดี่ยวถูกย่อจาก 100k triangles เหลือ root meshlet เดียวที่มี 86 triangles
- Dragons เป็นฉาก 70×70 instance ขนาด 1.2B triangles
- มังกรเดี่ยวถูกย่อจาก 250k triangles เหลือ root meshlet เดียวที่มี 102 triangles
- Bunnies เป็นฉาก 500×500 instance ขนาด 1.2B triangles
- bunny เดี่ยวถูกย่อจาก 5k triangles เหลือ root meshlet เดียวที่มี 96 triangles
- เนื่องจาก bunny มีขนาดเล็ก จึงถูก frustum culling เป็นส่วนใหญ่
ฟีเจอร์ที่พัฒนาแล้ว
-
Meshlet LOD hierarchy
- mesh preprocessing ทำงานในเบราว์เซอร์
- ใช้ meshoptimizer และ METIS ผ่าน WebAssembly
- มี file exporter ให้ด้วย เพื่อไม่ต้องรอการพรีโปรเซสทุกครั้งระหว่างการรีเฟรชหน้า
-
Software rasterizer
- WebGPU ไม่มี
atomic<u64>ที่จำเป็นต่อการทำเวอร์ชันประสิทธิภาพสูง - เวอร์ชันปัจจุบันแพ็ก depth
u16และ octahedron-encoded normals2*u8ไว้ใน 32 บิต - ด้วยข้อจำกัด 32 บิต จึงต้องลดความแม่นยำลงมาก และเป้าหมายคือการแสดงให้เห็นว่าแรสเตอร์ไรเซอร์ทำงานได้
- ข้อจำกัดนี้ยังกระทบ depth pyramid ที่ใช้กับ occlusion culling ด้วย
- WebGPU ไม่มี
-
Billboard impostors
- ใช้ภาพ 12 ภาพรอบแกน UP และ blend ด้วย dithering ตามตำแหน่งกล้อง
- ไม่รองรับมุมมองจากด้านบน/ด้านล่าง
- มีทั้ง diffuse และ normal เพื่อทำ shading ตอนรันไทม์
- Impostors preview เป็นเดโมที่เพิ่มขนาด impostor texture
การคัลลิงและเส้นทางการเรนเดอร์
- การคัลลิงประกอบด้วยหลายขั้น
- Per-instance: frustum culling และ occlusion culling
- Per-meshlet: frustum culling และ occlusion culling
- Per-triangle: hardware backface culling และ z-buffer
- WebGPU ไม่มี early-z
- occlusion culling ใช้เพียง depth pyramid ที่สร้างจาก depth buffer ของเฟรมก่อนหน้า
- ไม่มี reprojection และไม่มีวิธีแบบ two-pass
- ผู้เขียนมองว่าแค่การทำแบบปัจจุบันก็เพียงพอที่จะคัลลิงสามเหลี่ยมจำนวนมากและประเมินผลด้านประสิทธิภาพได้แล้ว
- สามารถสลับระหว่าง GPU-driven rendering กับ implementation แบบ CPU ที่เรียบง่ายได้
- เวอร์ชัน CPU ยังไม่ได้ใส่ optimization มากนัก แต่สามารถไล่ดีบักทีละขั้นด้วย debugger ได้
- ฟังก์ชัน “Freeze culling” ช่วยให้ขยับกล้องแล้วตรวจดูเฉพาะสิ่งที่ถูกวาดในเฟรมล่าสุดได้
การใช้งานและการดีบัก
- การเคลื่อนที่ใช้
[W, S, A, D], ขึ้น/ลงใช้[Z, SPACEBAR], และเร่งความเร็วใช้[Shift] - หากเห็นผลลัพธ์แปลก ๆ สามารถลองเปิดและปิดตัวเลือกการคัลลิงเพื่อตรวจสอบได้
- ใน implementation ยังมีบั๊กเล็กน้อยบางจุด
- สามเหลี่ยมสีขาวคือสามเหลี่ยมที่ถูกซอฟต์แวร์แรสเตอร์ไรซ์
- เพราะ WebGPU ไม่มี
atomic<u64>จึงต้องบีบอัด depth และ normal ให้อยู่ใน 32 บิต - depth แบบ 16-bit อาจทำให้เกิด artifact จำนวนมาก เช่น z-fighting หรือการรั่วของภาพ
- หากปิดซอฟต์แวร์แรสเตอร์ไรเซอร์ จะดู raw Nanite meshlets ได้ง่ายขึ้น แต่ประสิทธิภาพอาจลดลงมาก
- เพราะ WebGPU ไม่มี
- ค่า FPS อาจแกว่งจาก forced VSync ของเบราว์เซอร์ และควรใช้ปุ่ม “Profile” เพื่อตรวจสอบ timing
เป้าหมายของการออกแบบ
- เป้าหมายแรกของโปรเจกต์คือ ความเรียบง่าย
- เริ่มจากไฟล์ OBJ และทำทุกขั้นตอนภายในแอป
- ไม่ต้องมีขั้นตอน preprocess แยกต่างหากอย่างการ export จาก Blender
- มุ่งให้สามารถตั้ง breakpoint ที่
loadObjFile()แล้วไล่ดูไปจนจบเฟรมแรกได้
- เป้าหมายที่สองคือ ความเป็นงานทดลอง
- เพราะทำเป็นหน้าเว็บแทนที่จะใช้ Rust และ Vulkan จึงสามารถคลิกลิงก์แล้วปรับ checkbox หรือ slider เพื่อดูการเปลี่ยนแปลงของประสิทธิภาพได้
- สามารถเห็นได้ด้วยตัวเองว่า option ไหนสำคัญ เช่น แค่เปลี่ยนการตั้งค่าเดียว FPS ก็ตกลงอย่างมาก
- โค้ดหลายส่วนยังปรับแต่งประสิทธิภาพได้อีก แต่ผู้เขียนมองว่าก่อนแก้ปัญหา simplification ได้ การ optimize เหล่านั้นยังไม่ใช่ประเด็นสำคัญ
ความแตกต่างจาก UE5 Nanite
- ใช้ error metric แบบ projected simplification error ที่เรียบง่าย
- meshlet simplification ใช้วิธีที่ค่อนข้างง่าย
- ไม่มี two-pass occlusion culling
- จะเพิ่มก็ไม่ยาก แต่ทำให้การ debug ยุ่งยากและมีปฏิสัมพันธ์กับการตั้งค่า GUI
- จึงไม่สอดคล้องกับเป้าหมายด้านความอ่านง่ายของโค้ด
- ไม่มี visibility buffer
- ทำไม่ได้เพราะข้อจำกัดของ
atomic<u64>
- ทำไม่ได้เพราะข้อจำกัดของ
- ไม่มี built-in shadows และ multiview
- ไม่มี work queue ภายใน shader
- สำหรับ meshlet culling และ LOD selection จะ dispatch thread ต่อ meshlet
- ไม่มีการ eviction และ streaming ของ LOD ที่ไม่ได้ใช้งานออกจาก VRAM
- ไม่มี compression
- ขยายไปยังวัตถุที่แตกต่างกันจำนวนมากได้ไม่ดีนัก
- โฟกัสที่การใช้หน่วยความจำที่คาดการณ์ได้ในเคสเดโม
- ต้องรู้เพดานบนของ drawn meshlets ล่วงหน้า เพื่อเตรียม buffer สำหรับข้อมูลระหว่าง stage
- ไม่มี BVH หรือโครงสร้างแบบลำดับชั้นสำหรับ instance
- ทำ frustum และ occlusion culling กับทุก instance
เงื่อนไขที่จำนวนสามเหลี่ยมสื่อความหมาย
- แค่ตัวเลข “หลายหมื่นล้าน triangles” อย่างเดียวตัดสินประสิทธิภาพได้ยาก และประสิทธิภาพขึ้นกับหลายปัจจัย
-
Dense meshes
- ถ้ามี dense mesh จำนวนมากอยู่ใกล้กล้อง อาจส่งผลลบต่อประสิทธิภาพ
- แต่ถ้าอยู่ใกล้จนกินพื้นที่บนหน้าจอมาก occlusion culling ก็จะเริ่มทำงาน
- geometry ที่หนาแน่นทำให้ meshlet มีขนาดเล็ก กินพื้นที่บนหน้าจอน้อย และถูก occlusion/cone culling ได้ง่าย
-
Instance count
- แต่ละ instance มี transform matrix แบบ
mat4x3จึงใช้ VRAM - ระหว่างเฟรมยังต้องเก็บรายการสิ่งที่จะ render ด้วย
- ในกรณีเลวร้ายที่สุด แต่ละ instance จะ render meshlet ที่หนาแน่นที่สุด
- ใน implementation นี้จะจัดสรร
instanceCount * bottomLevelMeshletsCount * sizeof(vec2u)bytes - ขีดจำกัดเริ่มต้นของ WebGPU storage buffer ใน Chrome คือ 128MB และฉากเดโมถูกปรับให้สอดคล้องกับข้อจำกัดนี้
- แต่ละ instance มี transform matrix แบบ
-
Scene arrangement
- ฉากในแอปจะจัดวางวัตถุเป็นรูปสี่เหลี่ยมจัตุรัส
- วัตถุที่ไกลจะมองเห็นได้เพียงบางส่วน แต่ coarse meshlet LOD ครอบคลุมพื้นที่กว้างกว่า ทำให้เกิด overdraw
- การวางแบบ dense grid ทำให้ render สามเหลี่ยมใกล้กล้องมากขึ้น แต่เพราะความต่างของความลึกน้อย จึงเป็นผลดีกับ occlusion culling
- วัตถุที่อยู่ห่างกันมากอาจทำให้พิกเซลไกลแบบสุ่มไปปนเปื้อน depth pyramid ได้
ทำไมฉาก Jinx ถึงยาก
- ตัวละคร Jinx มีรูปร่างผอมและมีช่องว่างระหว่างแขนกับลำตัว ทำให้ occlusion culling ยาก
- โมเดลถูกทำให้เรียบง่ายได้ไม่ดี ทำให้แม้แต่ coarse LOD ที่สุดก็ยังมี 3k triangles
- เกิดสถานการณ์ที่สามเหลี่ยมขนาด 1 พิกเซลจำนวนมากสะสมกัน
- ซอฟต์แวร์แรสเตอร์ไรเซอร์ช่วยได้มาก แต่เพราะการจัดวางฉาก instance ส่วนใหญ่จึงถูก render เป็น impostor
- ในบริเวณใกล้กล้องจะเป็นหน้าที่ของ hardware rasterizer และทั้ง hardware rasterizer, software rasterizer และ impostor ต่างก็มีจุดแข็งคนละแบบ
จุดที่น่าประหลาดใจใน implementation ของ Nanite
- เป้าหมายของ DAG ไม่ใช่แค่ใช้สามเหลี่ยมน้อยลงกับวัตถุไกล แต่คือการรักษาความสม่ำเสมอให้ใกล้เคียง 1 pixel == 1 triangle ทั่วทั้งหน้าจอ
- discrete LOD mesh มี geometry แยกกันในแต่ละ LOD จึงรับมือได้ยากเมื่อจำเป็นต้องมีระดับกึ่งกลาง และต้องใช้ continuous LOD
- meshlet hierarchy ช่วยให้ sample geometry ได้ตามระดับรายละเอียดที่เลือก
- เวลาส่วนใหญ่หมดไปกับ culling และงานเกี่ยวกับ meshlet มากกว่าตัว Nanite เอง
- meshlet LOD hierarchy ทำให้พอใช้งานได้ไม่ยาก แต่ทำให้มีประสิทธิภาพนั้นยาก
- หาก mesh ถูก simplification ได้ไม่สะอาด ก็จะเกิดกรณีแบบ Jinx ที่มีประมาณ 3,000 triangles มาทับพิกเซลเดียว
- ถ้าต้องการ triangles ขนาดระดับพิกเซล ก็จำเป็นต้องมีซอฟต์แวร์แรสเตอร์ไรเซอร์ และ billboard impostor ก็มีประโยชน์ในฐานะ fallback ที่เน้นความเสถียร
ปัญหา Mesh simplification
- simplification ไม่ใช่ปัญหาแบบ “รับ mesh มาแล้วลดให้เหลือ X% triangles” แต่เป็นปัญหาที่ต้องทำในบริบทของ meshlet และ METIS
- UE5 ใช้ mesh simplification code ของตัวเองเป็นขั้นตอนแรกใน asset pipeline
- ต้นทุนที่ลดได้ตรงนี้จะส่งผลดีต่อเนื่องไปทั้งระบบถัดจากนั้น
- ในเอกสาร SIGGRAPH ของ Brian Karis ระบุว่า LOD graph ลงท้ายด้วย root cluster เดียว และทุกโมเดลสามารถ simplification ลงไปถึง 128 triangles ได้
- โมเดล Jinx มีปัญหาที่ simplification หยุดค้างอยู่ที่จุดหนึ่ง
- ใส่ X triangles เข้าไป แต่ได้ X triangles เดิมกลับมา
- อัลกอริทึม crash ด้วย assertion
- หลังจากนั้นจึงเปลี่ยนให้ส่วน mesh ที่ลดลงได้ไม่พอสามารถมีหลาย root ใน DAG ได้
- ตอนแรกโมเดล bunny ถูก simplification เป็น meshlet เดียวขนาด 128 tris แต่เมื่อเปลี่ยนเงื่อนไขก็พบปัญหาว่าไม่ลงไปถึง single root
- meshlet จำนวนมากลดจำนวน triangle ได้ไม่มากพอ และยังมี meshlet ที่ “ไม่เต็ม” จำนวนมากซึ่งมีน้อยกว่า 128 triangles
- ประเด็นที่เกี่ยวข้อง:
เมตริกข้อผิดพลาด
- เมื่อเรนเดอร์เมช 20,000,000 triangles เป็นลำดับชั้น meshlet จำเป็นต้องเลือกให้ได้ว่า meshlet ใดคือ meshlet ที่ “ถูกต้อง”
- ปัญหาการเลือกนี้คือแก่นหลักของ Nanite และ simplification, meshlet DAG, software rasterizer ก็แทบจะเป็นเงื่อนไขเบื้องต้นเพื่อใช้จัดการปัญหานี้
- การติดตั้งใช้งานนี้ใช้ projected simplification error ที่ meshoptimizer มีให้
- ผู้เขียนมองว่า metric นี้ไม่ใช่ metric ที่ดีสำหรับ Nanite และควรใส่ vertex attribute อื่น ๆ เข้าไปในฟังก์ชันด้วย
- ควรกำหนด weight แยกตามแต่ละ attribute ได้
- normal ของใบหน้า Jinx เป็นปัญหาใหญ่
- เอกสารอ้างอิง:
เหตุผลที่ software rasterizer ไม่เขียน texture ออกมา
- ใน hardware rasterizer สามารถเขียน depth texture, color และ normal ไปพร้อมกันได้ตามผลของ depth test
- ใน software rasterizer หากหลาย thread เขียนลงพิกเซลเดียวกัน จะเกิด race condition
- วิธีแก้โดยทั่วไปคือ visibility buffer
- เขียน
sceneUniqueTriangleIdของ triangle ที่อยู่ใกล้ที่สุดลงในแต่ละ pixel - นำค่านี้ไปรวมกับ depth 32-bit ให้เป็นค่า 64-bit และใช้ 64-bit atomic operation
- จากนั้นใน pass แยกต่างหาก จะ rasterize triangle อีกครั้ง คำนวณ barycentric coordinates แล้วทำ shading
- เขียน
- WebGPU ไม่มี 64-bit atomics จึงใช้วิธีนี้ไม่ได้
- เป้าหมายของโปรเจกต์นี้คือการแสดงให้เห็นว่า software rasterize สามารถทำงานได้ ดังนั้นโมเดลที่ software rasterize ในพื้นหลังจึงเป็นสีขาวและให้เพียง shading ในระดับที่สมเหตุสมผล
มุมมองต่อการทำ Nanite ขึ้นเอง
- คำตอบที่ง่ายที่สุดคือใช้ UE5
- หากต้องการใส่เทคโนโลยีนี้ลงในเอนจินที่มีอยู่ ผู้เขียนมองว่าต้องเริ่มจากการทำ compute-based graphics pipeline และ GPU-driven rendering pipeline ก่อน
- จากนั้นปัญหาอย่าง multi-step culling, scene/world chunk management และ animated meshes จะตามมาก่อน
- หลังจากทำให้ขั้นตอนเหล่านี้เสถียรแล้ว ค่อยลองทำ software rasterizer และค่อยเพิ่มเทคโนโลยีแบบ Nanite ต่อเป็นลำดับ
- ใน toy renderer การทำ meshlet hierarchy พื้นฐานอาจเป็นโปรเจกต์ช่วงสุดสัปดาห์ได้ แต่ของจริงต้องรับมือกับปัญหา simplification และ error metric
เครื่องมือและแหล่งข้อมูลอ้างอิงที่ใช้
- meshoptimizer คือฐานหลักสำคัญของการติดตั้งใช้งานโปรเจกต์นี้
- meshoptimizer เวอร์ชันใหม่กว่าจะมี
meshopt_SimplifySparseสำหรับทำ Nanite clone - โปรเจกต์นี้ไม่ได้อัปเดตไปใช้เวอร์ชันนั้น เพื่อคงสถานะที่ใช้ทดสอบระหว่างการพัฒนาไว้
- meshoptimizer เวอร์ชันใหม่กว่าจะมี
- ใช้ METIS
- Emscripten ถูกใช้เพื่อรัน meshoptimizer และ METIS บนเบราว์เซอร์
- โมเดล Jinx ใช้ Arcane - Jinx จาก Sketchfab และมีการรวม texture, ปรับ UV และนำอาวุธออก
ยังไม่มีความคิดเห็น