3D โมเดลเลอร์ที่สร้างด้วย C ภายในหนึ่งสัปดาห์
(danielchasehooper.com)- ShapeUp ถือกำเนิดจากโจทย์ของ Wheel Reinvention Jam ที่ให้ “มองซอฟต์แวร์เดิมอีกครั้งด้วยมุมมองใหม่” และถูกทำจนเสร็จเป็น 3D โมเดลเลอร์ที่มีทั้งเดโมรันบนเบราว์เซอร์และการส่งออก
.obj - หัวใจสำคัญที่ทำให้สร้างเครื่องมือ 3D ได้ภายในหนึ่งสัปดาห์คือ ray marched SDF ซึ่งทำให้สร้างฉากที่มีสี เงานุ่ม และ ambient occlusion ได้เร็วกว่าตัวเรนเดอร์แบบอิงสามเหลี่ยม
- การพัฒนาถูกทำให้เรียบง่ายโดยยึด ไฟล์ C ไฟล์เดียว เป็นหลัก และเก็บโมเดลไว้ในอาร์เรย์แบบคงที่ที่บรรจุ Shape ได้สูงสุด 100 ชิ้นเพื่อลดภาระการจัดการหน่วยความจำ
- raylib ช่วยเปิดหน้าต่าง OpenGL ได้อย่างรวดเร็ว แต่เพราะ API ที่ยึด
intเป็นหลัก การไม่ตรวจสอบพารามิเตอร์ การพึ่งพา GLFW และข้อจำกัดของ raygui จึงต้องเขียน OpenGL ตรง ๆ หรือสร้างฟังก์ชันที่ต้องใช้ขึ้นมาใหม่ - ผลลัพธ์สุดท้ายมีขนาด C 2024 บรรทัด และ GLSL 250 บรรทัด รวมราว 2300 บรรทัด พร้อมรองรับการเปิด/บันทึกไฟล์ การรันหลายแพลตฟอร์ม และการส่งออก
.obj
ShapeUp เปลี่ยนมาเป็น 3D โมเดลเลอร์ได้อย่างไร
- Wheel Reinvention Jam คือ อีเวนต์เขียนโปรแกรมหนึ่งสัปดาห์ ที่ชวนให้กลับมามองระบบซอฟต์แวร์เดิมด้วยมุมมองใหม่
- เป้าหมายแรกเริ่มมาจากความไม่พอใจกับ TypeScript compiler ที่ช้า จึงตั้งใจจะสร้าง TypeScript subset ที่เร็วกว่า
tsc- เมื่อใช้ตัวแยกวิเคราะห์ TypeScript ของ
esbuildหรือBunเป็นจุดเริ่มต้น ก็ดูเหมือนจะเป็นไปได้ - แต่เดโมที่สำเร็จจะมีแค่ “คำสั่งในเทอร์มินัลอันหนึ่งจบเร็วกว่าคำสั่งอีกอัน” ซึ่งไม่น่าดึงดูดในเชิงภาพ สุดท้ายจึงเปลี่ยนทิศไปทำ 3D
- เมื่อใช้ตัวแยกวิเคราะห์ TypeScript ของ
- ShapeUp ถูกสร้างให้เป็น 3D โมเดลเลอร์ที่แก้ไขรูปทรงด้วยเมาส์
- แม้จะเคยมีประสบการณ์เขียน SDF shader มาก่อน แต่การทำโมเดลโดยต้องแก้โค้ดเองไม่ใช่วิธีที่เป็นธรรมชาติ
- เป้าหมายคือทำให้สามารถ แก้ไขรูปทรงแบบอิง SDF ได้ด้วยเมาส์
ทำไม SDF จึงทำให้โปรเจ็กต์หนึ่งสัปดาห์เป็นไปได้
- พื้นฐานการเรนเดอร์ของ ShapeUp คือ ray marched signed distance fields (SDFs)
- ฉากแบบ SDF สามารถทำได้เร็วกว่าเรนเดอร์แบบอิงสามเหลี่ยม แม้จะมีทั้งสี เงานุ่ม และ ambient occlusion
- ตัวอย่างของ Inigo Quilez ที่สร้างคาแรกเตอร์สไตล์ Pixar ด้วย SDF ภายในช่วงเวลาสั้น ๆ เป็นจุดอ้างอิงด้านเทคนิค
- ShapeUp จัดการงานโมเดลด้วย SDF ผ่านการควบคุมรูปทรงโดยตรง แทนการแก้ไขโค้ด
การเขียนด้วย C และโครงสร้างข้อมูล
- ShapeUp เขียนด้วย C และใช้ raylib เพื่อสร้างหน้าต่าง OpenGL
- เหตุผลที่เลือก C คือคอมไพล์เร็ว ไวยากรณ์ไม่ซ่อนพฤติกรรมที่ซับซ้อน คุ้นเคย และสามารถคอมไพล์ได้ทั้งแบบเนทีฟและ WebAssembly
- โมเดลประกอบขึ้นจากชุดของโครงสร้าง
Shape- Shape แต่ละชิ้นมีตำแหน่ง ขนาด มุม รัศมีมุม ระดับ blob สี แกนสำหรับ mirror และสถานะว่าทำหน้าที่ subtract หรือไม่
- รายการ Shape ถูกจัดการด้วยอาร์เรย์แบบคงที่แทนการจัดสรรแบบไดนามิก
MAX_SHAPE_COUNTคือ 100- ใช้
Shape shapes[MAX_SHAPE_COUNT],shape_count,selected_shapeในการเก็บสถานะ - วิธีนี้ตัดความเป็นไปได้ของการจัดสรรล้มเหลวและหน่วยความจำรั่ว
- ข้อจำกัดที่ 100 Shape ไม่ได้เป็นปัญหาใหญ่ในการใช้งานจริง
- เพราะยังไม่มีเวลา optimize ตัวเรนเดอร์ จึงทำให้เฟรมเรตตกก่อนจะถึง 100 ชิ้น
- ถ้ามีเวลา ผู้เขียนตั้งใจจะแบ่งโมเดลออกเป็นก้อนอิฐเล็ก ๆ แล้วทำ ray marching ภายในแต่ละก้อน
วิธีใช้หน่วยความจำ
- ShapeUp ใช้การจัดสรรหน่วยความจำแบบไดนามิกเพียง 3 จุด
- การบันทึก: จัดสรรบัฟเฟอร์เพื่อเก็บเอกสารทั้งหมด
- การส่งออก
.OBJ: จัดสรรบัฟเฟอร์เพื่อเก็บ vertex ทั้งหมด - การสร้าง GLSL shader: จัดสรรบัฟเฟอร์สำหรับซอร์สของ shader
- ในแต่ละกรณีจะ
freeเพียงครั้งเดียวตอนจบฟังก์ชัน - จะเลือก
mallocให้ Shape ทีละตัวแล้วเก็บพอยน์เตอร์ไว้ใน dynamic array ก็ได้ แต่โปรเจ็กต์นี้ไม่จำเป็นต้องใช้โครงสร้างแบบนั้น - C มีข้อดีตรงที่ควบคุมการจัดวางหน่วยความจำได้โดยตรง
- ถ้าจำเป็นต้องใช้ dynamic array หรือ hash map ก็สามารถใช้เครื่องมืออย่าง
stb_ds.hได้
วิธีทำ UI
- UI ถูกสร้างด้วยแนวทาง immediate mode user interface (IMGUI)
- จุดเด่นของ IMGUI คือ debug ง่าย และสามารถกำหนดตำแหน่งองค์ประกอบด้วยภาษาโปรแกรมจริง ๆ แทน CSS, constraints หรือ SwiftUI
- องค์ประกอบที่โฟกัสอยู่หรือการกระทำของเมาส์ถูกติดตามด้วย enum
Control- สถานะการควบคุมอย่างตำแหน่ง ขนาด มุม สี การย้าย การหมุน การสเกล การหมุนกล้อง และระดับ blob ถูกแทนด้วยค่า enum
focused_controlและmouse_actionใช้เก็บสถานะ UI ปัจจุบัน
จุดที่ติดขัดกับ raylib และ raygui
- raylib มีประโยชน์สำหรับการเปิดหน้าต่าง OpenGL อย่างรวดเร็ว แต่เมื่อเวลาผ่านไปกลับกลายเป็นปัจจัยที่ทำให้การพัฒนาช้าลง
- ส่วนที่น่าอึดอัดที่สุดของ API ของ raylib คือ การขาดข้อมูลชนิดข้อมูล
- แม้ในจุดที่ควรคาดหวัง enum type ก็ยังใช้
intทำให้คอมไพเลอร์ช่วยตรวจสอบชนิดไม่ได้ - เพียงดูจากฟังก์ชันซิกเนเจอร์ก็ไม่ค่อยรู้ว่าพารามิเตอร์แต่ละตัวหมายถึงอะไร
- ตัวอย่างเช่น
IsGestureDetected(unsigned int gesture)ทำให้gestureดูเหมือน registered gesture ID แต่จริง ๆ แล้วเป็น enumGesture - เนื่องจากเอกสารอิงไฟล์ header เป็นหลัก หากอยากรู้ว่า
intตัวไหนจริง ๆ คือ enum ก็ต้องไปดู implementation
- แม้ในจุดที่ควรคาดหวัง enum type ก็ยังใช้
- การออกแบบที่ไม่ตรวจสอบพารามิเตอร์ขั้นพื้นฐานยิ่งทำให้ปัญหารุนแรงขึ้น
LoadFileData(const char *fileName, int * dataSize)จะ segfault ถ้าdataSizeเป็นNULL- ใน header ไม่ได้บอกว่า
dataSizeเป็น output parameter หรือห้ามเป็น null - การไม่มีการตรวจสอบทำให้ไล่หาปัญหาง่าย ๆ ได้ยาก และบางกรณีก็อาจเกิดพฤติกรรมผิดปกติแบบเงียบ ๆ
- การจัดการ dependency ก็ไม่เป็นไปตามที่คาดหวัง
- มีปัญหาที่ raylib ไม่ได้หลบหรือส่งแพตช์แก้ issue ของ GLFW
- สำหรับผู้ใช้ปลายทาง สิ่งสำคัญกว่าวิธีสร้างหน้าต่างภายในคือฟังก์ชันของ raylib ต้องทำงานได้ถูกต้อง
- ไลบรารี UI อย่าง raygui มีข้อจำกัดมากเกินไปสำหรับการใช้ในโปรเจ็กต์นี้
- ไม่สามารถแสดงตัวเลขทศนิยมได้ จึงต้องสร้างฟิลด์ข้อความแบบ float เอง
- จัดการ routing ของ mouse event สำหรับองค์ประกอบที่ซ้อนกันหรือถูกตัดไม่ได้
- ไม่รองรับมุมโค้งมนที่พบได้บ่อยใน UI
- ทำสไตล์ให้ออกมาดูดีได้ยาก
- บั๊กก็รบกวนการทำงานเช่นกัน
- บั๊กของเครื่องมือ raygui ทำให้เปลี่ยนฟอนต์เริ่มต้นที่ถูกตกแต่งมากเกินไปไม่ได้
- ฟังก์ชันวาดอย่าง
DrawCircle(...)ไม่ได้แชร์ vertex ระหว่างสามเหลี่ยม จึงเกิดช่องว่างระดับพิกเซลจากความคลาดเคลื่อนของ floating point เมื่อเมทริกซ์ปัจจุบันมีการสเกลหรือหมุน
- แม้จะรายงาน issue ที่พบอยู่ช่วงหนึ่ง แต่ส่วนใหญ่ถูกปิดด้วยสถานะ “wont fix” จึงเลิกรายงานในภายหลัง
- วิธีเลี่ยงคือเรียกใช้ฟังก์ชัน OpenGL โดยตรง หรือสร้างความสามารถที่ต้องใช้ขึ้นมาเองตั้งแต่ต้น
- ต่อจากนี้มีแผนจะใช้ sokol แทน raylib
4 อย่างที่ต้องทำให้เสร็จภายใน 6 วัน
- ภายใน 6 วัน ShapeUp ต้องทำส่วนใหญ่ ๆ ให้เสร็จ 4 ส่วน
- ส่วนติดต่อผู้ใช้: 3D gizmo, คีย์ลัด, แถบด้านข้าง, game controller
- ตัวสร้าง GLSL shader และตัวเรนเดอร์แบบ ray marching
- การเลือกด้วยเมาส์บน GPU
- marching cubes สำหรับการส่งออก
- ความยากไม่ได้อยู่ที่แต่ละฟังก์ชันเพียงอย่างเดียว แต่อยู่ที่การ รักษาลำดับความสำคัญ
- ปัญหาที่ยุ่งยากหรือใช้เวลานานจะถูกเลี่ยงด้วยการเปลี่ยนแบบ หรือแก้ด้วยวิธีง่าย ๆ ที่ใช้ได้ใน 90% ของกรณี
- บางฟังก์ชันก็ได้คำตอบหลังจากปล่อยทิ้งไว้หนึ่งวัน
- วิธีทำงานคือรักษา 3D โมเดลเลอร์ที่ใช้งานได้ตลอดเวลา แล้วค่อย ๆ ปรับปรุงเพิ่มเท่าที่เวลาจะอำนวย
- ไม่ใช่การสร้างแบบที่กลายเป็นพีระมิดเฉพาะตอนเสร็จสมบูรณ์ แต่เป็นการทำให้ไม่ว่าจะหยุดตรงขั้นไหนก็ยังเป็นพีระมิดขนาดเล็กที่สมบูรณ์อยู่
ผลลัพธ์สุดท้าย
- เมื่อครบหนึ่งสัปดาห์ ShapeUp สามารถสร้างโมเดล 3D ที่มีความหมายและส่งออกเป็นไฟล์
.objได้ - รองรับการทำงานหลายแพลตฟอร์ม และมีทั้งการเปิดไฟล์กับบันทึกไฟล์
- ขนาดโค้ดคือ C 2024 บรรทัด และ GLSL 250 บรรทัด
- การสร้าง 3D โมเดลเลอร์ที่พอใช้งานได้ด้วยโค้ดราว 2300 บรรทัดถือเป็นผลลัพธ์ที่น่าสนใจ
- แม้ตัวโปรเจ็กต์เองจะค่อนข้างเรียบง่าย แต่สิ่งสำคัญคือวิจารณญาณในการเลือกว่าจะสร้างอะไร ความรู้ที่ทำให้สร้างได้ และวินัยในการทำให้เสร็จภายในหนึ่งสัปดาห์
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
เห็นด้วยกับผู้เขียนอย่างเต็มที่เรื่องข้อจำกัดของ Raylib ตอนนี้กำลังทำเกมแนวทาวเวอร์ดีเฟนส์ที่เริ่มจาก Raylib อยู่ และกำลังเจอข้อจำกัดเดียวกันกับปัญหาอื่น ๆ อีกมาก
เช่น การสลับโหมดเต็มหน้าจอ ทำงานไม่สม่ำเสมอในแต่ละแพลตฟอร์ม, ไม่สามารถไล่รายการโหมดหน้าจอได้, สลับเปิด/ปิดฟีเจอร์การเรนเดอร์ระหว่างรันได้ยาก, ปัญหาการบันทึก shader ที่คอมไพล์แล้ว ฯลฯ
ถึงอย่างนั้นก็ยังขอบคุณงานที่ Ray ทุ่มเทให้ไลบรารีนี้ และตั้งใจจะสนับสนุนต่อไป Raylib ยอดเยี่ยมสำหรับการทำ prototype อย่างรวดเร็ว แต่ถ้าจะไปไกลกว่านั้นโดยไม่ยอมรับข้อจำกัดหนัก ๆ ก็ไม่ง่าย
ได้เรียนรู้อะไรแน่นอน แต่ตอนนี้การพัฒนาไปไกลเกินกว่าจะรื้อโค้ดที่เกี่ยวกับ Raylib ทั้งหมดแล้วเปลี่ยนเป็นอย่าง SDL
Things Unexpectedly Named After People: https://notes.rolandcrosby.com/posts/unexpectedly-eponymous/
กลยุทธ์ยุคนี้คือใช้โหมดหน้าต่างไร้ขอบไปเลย แล้วทำเหมือนว่าไม่มีโหมดเต็มหน้าจอจริง ๆ
ปัญหาใหญ่สุดตอนนี้คือ การจัดการฟอนต์และการเรนเดอร์ข้อความ ดูเหมือนอาจต้องเปลี่ยนจากฟอนต์ TTF ไปใช้ฟอนต์บิตแมปที่เตรียมไว้ล่วงหน้า ซึ่งน่าจะลำบากพอสมควรตอนทำ localization ภายหลัง
ฟีเจอร์สองอย่างที่คิดถึงที่สุดหลังย้ายมาจาก Love2D คือการเรนเดอร์ข้อความหลายสีได้ง่าย และการตัด texture แล้วทำซ้ำหรือ tile ได้ง่าย ใน Raylib ต้องแบ่งข้อความเองตาม markup สี ใส่ออฟเซ็ตความกว้าง แล้วเรียกฟังก์ชันวาดให้แต่ละชิ้นโดยยังต้องคำนึงถึงการขึ้นบรรทัดด้วย
ถ้าวาดข้อความบนหน้าจอเยอะ ๆ FPS ก็ดูเหมือนตกลงมาก อาจเป็นเพราะ batching ของ draw call สำหรับข้อความแตกก็ได้ เมื่อก่อนเคยมีฟังก์ชันวาด tile texture แต่ถูกเอาออกไปด้วยเหตุผลบางอย่าง
สิ่งที่ขัดใจเสมอเกี่ยวกับ Wasm และกราฟิก 3D/2D บนเบราว์เซอร์คือมักเห็นปัญหาเล็ก ๆ อย่างการเลื่อนหน้าจอ ลองดูตัวอย่าง “Background scrolling & parallax” ที่นี่: https://www.raylib.com/examples.html
ทดสอบกับหลายอุปกรณ์แล้ว ถ้าตาผมไม่ได้เพี้ยนไปเอง มันไม่ใช่ การเลื่อนที่ลื่นไหล แน่นอน นึกไม่ออกเลยว่าในปี 2024 การเลื่อน 2D ให้ลื่นยังไม่ใช่ปัญหาที่แก้จบแล้วได้อย่างไร
“เก็บ Shape ไว้ในอาร์เรย์ที่จัดสรรแบบ static ไม่มีการจัดสรรล้มเหลว ไม่มี leak ไม่มีส่วนเกิน รักเลย ข้อจำกัด Shape 100 ชิ้นไม่ได้เป็นข้อจำกัดจริง ๆ เพราะแทบไม่มีเวลา optimize renderer ดังนั้น frame rate คงตกก่อนจะไปถึง 100 ชิ้นอยู่แล้ว”
เป็นตัวอย่างที่ดีที่สุดอย่างหนึ่งที่เพิ่งเห็นของ การหลีกเลี่ยง premature optimization
เป็นบทความที่น่าสนใจมาก และชอบที่เล่าถึงการตัดสินใจหลายอย่าง เช่น วิธีจัดการหน่วยความจำกับปัญหาที่เจอใน raylib พอดีกำลังทบทวน C ใหม่ขณะเข้าสู่พาร์ต 2 ของ Crafting Interpreters เลยดีที่ได้ระลึกอีกครั้งว่า C ทำอะไรได้ดี
เดโมแบบเรียลไทม์ ในวิดีโอดีมาก เอาแค่ทำวิดีโอนั้น ถ้าเป็นผมลองทำเองก็คงทำไม่ทันในหนึ่งสัปดาห์ด้วยซ้ำ ไม่ต้องพูดถึงการทำแอป
นานมาแล้วเคยทำระบบปฏิบัติการสำหรับโทรศัพท์ตั้งโต๊ะ มี RAM แค่ 64K จึงไม่มี การจัดการหน่วยความจำแบบ dynamic เลย และใช้ตัวแปร static จำนวนมากให้คอมไพเลอร์จัดวางทั้งหมดตอนคอมไพล์
เรามักลืมไปง่าย ๆ ว่าแอปพลิเคชันจำนวนมากอาจไม่จำเป็นต้องมีการจัดการหน่วยความจำแบบ dynamic เลย แค่จัดสรร buffer ขนาดคงที่ไว้ไม่กี่ชุด แล้วจัดการกรณียกเว้นเมื่อ buffer เต็มให้เรียบร้อยก็มักเพียงพอ
ในบริบทแบบนั้น C จริง ๆ แล้วปลอดภัยกว่ามาก ไม่มี memory leak และสิ่งที่ต้องกังวลมีแค่ buffer overflow ถ้าตัวแปรทั้งหมดถูกจัดสรรแบบ static ก็จัดการได้ด้วยการใช้
sizeofอย่างระมัดระวังไม่ได้หมายความว่า Rust กับ Go ในยุคนี้ไม่ใช่ตัวเลือกที่ยอดเยี่ยม แต่ C แบบเก่า ๆ ที่เรียบง่ายก็ยังทำงานได้ดี และไม่จำเป็นต้องซับซ้อนเหมือนฝันร้าย
อาจนอกประเด็นไปหน่อย แต่ดีใจที่ได้เห็น อินเทอร์เฟซ WebAssembly ที่ข้อความไม่ดูพร่ามัวเป็นครั้งแรก จริง ๆ คือครั้งแรกเลย
ถ้าขยายไปถึงโปรแกรมและระบบปฏิบัติการบางตัว เช่น Windows ก็เกิดปัญหาโดยรวมในช่วงไม่กี่ปีที่ผ่านมา เมื่อวิธี rasterize ข้อความแบบหนึ่งกลายเป็นกระแสร่วมและค่าเริ่มต้น
น่าเสียดายที่ผู้ใช้มักปิด anti-aliasing เพื่อให้ได้ข้อความคมชัดไม่ได้ และแม้ในกรณีหายากที่มีตัวเลือก อินเทอร์เฟซอย่างเมนูก็ยังถูกใช้ anti-aliasing อยู่ดี
ชอบโปรเจกต์แบบนี้มาก ยังชอบ ความเป็นระดับต่ำของ C อยู่ ตอนนี้ใช้ Rust กับ Elixir/Erlang เยอะ แต่ก็มักคิดถึงความเรียบง่ายและความชัดเจนของ C
เพราะงั้นเลยใช้ Zig เยอะด้วย เป็นภาษาที่รักษาปรัชญาของ C ไว้มาก แต่ปรับปรุงได้ดีมาก
เห็นด้วยจริง ๆ กับการประเมินของเขาเกี่ยวกับ C โดยเฉพาะตรงที่ว่า “ไวยากรณ์ไม่ได้ซ่อนพฤติกรรมที่ซับซ้อนไว้ มันเรียบง่ายพอจนไม่ต้องคอยค้นหาอยู่เรื่อย ๆ” และยิ่งไปกว่านั้น แม้เวลาต้องค้นหาอะไรเกี่ยวกับ C ก็ยังง่ายและได้ประโยชน์มาก
ภาษาที่เรียบง่ายและเก่าแก่ ก็มีข้อดีในแบบของมัน
ถ้าแยกจัดสรรแต่ละ Shape ด้วย
mallocแล้วเก็บพอยน์เตอร์เหล่านั้นไว้ใน dynamic array ก็แน่นอนว่าจะทำให้ตัวเองลำบากขึ้นได้ มีการพูดทำนองว่าถ้าใช้ภาษาอย่าง C# จะถูกบังคับให้ใช้โครงสร้างการจัดสรรแบบนั้น แต่สงสัยว่าอะไรคือสิ่งที่ห้ามไม่ให้ใช้ อาร์เรย์ขนาดคงที่ของ struct ใน C# เหมือนที่ผู้เขียนทำใน Cอยากให้มีใครสักคนสานต่อโปรเจกต์นี้ ถ้าขัดเกลาเพิ่มอีกไม่กี่เดือน มันอาจเป็นทางเลือกที่จริงจังแทน Blender หรือ FreeCAD สำหรับการใช้งานเฉพาะทางบางอย่างได้ และเส้นโค้งการเรียนรู้ก็ดูจะนุ่มนวลกว่ามาก
อย่างไรก็ตาม โดยพื้นฐานแล้วมันทำงานด้วย SDF ดังนั้นประสบการณ์การโมเดลจึงต่างจากเมชแบบดั้งเดิมที่ใช้สามเหลี่ยม จุดยอด ฯลฯ และข้อมูลที่จัดเก็บก็ต่างกันด้วย
การแปลง SDF เป็นเมชทำได้ด้วยวิธีอย่าง marching cubes แต่ข้อมูลแบบนั้นมีโอกาสสูงที่จะต้องนำไปจัดระเบียบต่อในแอปแนว Blender อยู่ดี
ถ้า renderer ก็อิง SDF ด้วย SDF ก็ยอดเยี่ยมมาก แต่ส่วนใหญ่ไม่ได้เป็นแบบนั้น
ถ้ารู้อยู่แล้วก็ขอโทษด้วย