บันทึกการพัฒนา "Machine" ของ xkcd
แนวคิดเริ่มต้น
- ใช้เวลาคิดไอเดียจนถึงปลายเดือนมีนาคม แล้วตัดสินใจเลือกไอเดียในช่วงต้นเดือนเมษายน
- "จะสร้างอุปกรณ์ขนาดยักษ์แบบแบ่งเป็นแผ่นย่อยได้ไหม คล้าย GIF ลูกบอลสีน้ำเงินที่ผู้ใช้ Something Awful ช่วยกันทำ โดยทุกคนช่วยกันคนละสี่เหลี่ยมเล็ก ๆ หนึ่งช่อง"
- ตอนแรกเหมือนกับว่าไอเดียนี้เป็นรูปเป็นร่างครบถ้วนแล้ว แต่พอเริ่มคุยกันจริง ๆ ก็พบว่ายังมีเรื่องที่ต้องตัดสินใจอีกมาก
- แต่ละคนมีภาพในหัวไม่เหมือนกันในประเด็นสำคัญ เช่น ลูกบอลมาจากไหน ทุกคนดูเครื่องเดียวกันหรือไม่ จุดประสงค์คืออะไร และผู้เล่นจะโต้ตอบอย่างไร
สิ่งที่เรียนรู้จากความพยายามก่อนหน้า
- เคยมีประสบการณ์ทำคอมิกแบบอินเทอร์แอ็กทีฟที่เน้นคอนเทนต์จากผู้ใช้
- Lorenz: เอ็กซ์ควิซิตคอร์ปัสที่ให้ผู้อ่านเขียนข้อความในพาเนลเพื่อช่วยต่อยอดมุกและเนื้อเรื่อง (สนุกมาก)
- Collector's Edition: เกมที่ให้ผู้อ่านค้นหาสติกเกอร์ที่ซ่อนอยู่ในคลัง xkcd แล้วนำไปติดถาวรบนผืนผ้าใบร่วมกัน (ผลลัพธ์ไม่เป็นไปตามที่ตั้งใจ)
- ถ้าเริ่มจากแผนที่ตรงกลางที่ว่างเปล่าในช่วงแรก จะนำไปสู่ความสับสน
- แรงจูงใจในการวางสติกเกอร์มีไม่มาก ทำให้การกระทำรายบุคคลผลักดันโครงเรื่องได้ยาก และสุดท้ายก็มีแค่ลวดลายเรียบ ๆ
- ไม่มีเนื้อเรื่องหรือเป้าหมายโดยรวม และความสัมพันธ์ระหว่างสติกเกอร์ก็ไม่ชัดเจน
- ถ้าอยากให้ผืนผ้าใบแบบรวมหมู่ประสบความสำเร็จ ต้องมีตัวอย่างให้เห็นว่าสร้างอะไรได้บ้าง และต้องมีบริบทกับจุดมุ่งหมายร่วมกัน
การออกแบบข้อจำกัด
- หลังจากตัดสินใจว่าจะสร้างเครื่องลูกบอลตกขนาดใหญ่ ก็ต้องเผชิญกับตัวเลือกมากเกินไป
- ตัดสินใจให้ประกอบด้วยกริดขนาด 100x100
- การจำลอง 10,000 ไทล์แบบเรียลไทม์ฝั่งไคลเอนต์ดูเสี่ยงเกินไป
- ไม่แน่ใจว่าผู้เล่นจะสร้างส่วนย่อยของเครื่องที่ซับซ้อนโดยไม่สื่อสารกันโดยตรงได้อย่างไร และเมื่อรวมไทล์ที่แยกกันเข้าด้วยกันแล้วจะยังทำงานได้หรือไม่
- หลังการทดลองทางความคิดหลายครั้ง จึงตั้งหลักการสำคัญไว้ 3 ข้อ:
1. เพิ่มอิสระในการแสดงออกของผู้เล่นให้สูงสุด แม้ต้องแลกกับความแม่นยำ
- เครื่องควรคาดเดาได้มากแค่ไหน?
- เคยพิจารณาทางเลือกอย่างการรันทั้งหมดบนเซิร์ฟเวอร์หรือการตรวจสอบทีละไทล์ แต่ยืนยันได้จากเอดิเตอร์ต้นแบบว่าทำรูปแบบการชนของลูกบอลที่วุ่นวายได้ง่ายมาก
- ถ้าลูกบอลไม่เคลื่อนเป็นเส้นตรงโดยไม่มีสิ่งรบกวน ก็สร้างเครื่องที่คาดเดาไม่ได้ได้ง่าย
- การเพิ่มความคาดเดาได้ของเครื่องขัดกับการเพิ่มอิสระของผู้เล่น
- ระยะเวลาพัฒนาที่กระชั้นชิดก็ทำให้เอนเอียงไปทางแนวทางที่พึ่งการคาดการณ์/การจำลองน้อยกว่า
- จึงตัดสินใจมอบอิสระในการสร้างอย่างยืดหยุ่นมากให้ผู้เล่น แม้จะรวมถึงเครื่องที่ไม่เป็นเชิงกำหนดอย่างรุนแรงหรือเครื่องที่พังด้วยก็ตาม
- ต้องอาศัยการตรวจทานเชิงรุกเพื่อเช็กว่าตรงตามข้อจำกัดและลบคอนเทนต์ที่ไม่เหมาะสม
2. กำหนดข้อจำกัดที่เข้มงวดเพื่อส่งเสริมเครื่องที่เข้ากันได้และแทนที่กันได้
- การยอมรับงานผ่านการตรวจทานและการมีเครื่องของผู้เล่นที่คาดเดาไม่ได้ กลับทำให้ต้องการความเป็นระเบียบมากขึ้น
- ตอนแรกเคยคิดให้ช่องรับเข้า/ส่งออกเป็นรูปแบบอิสระทั้งหมด แต่ในขั้นตรวจทานก็พบว่าถ้าจำเป็นต้องเปลี่ยนไทล์ต้นทาง อาจทำให้เกิดความเสียหายเป็นวงกว้าง
- จึงออกแบบข้อจำกัดที่แข็งแรงพอให้ผู้เล่นหลายคนสร้างแบบที่เข้ากันได้ในพื้นที่ไทล์เดียวกัน
- ใช้หลัก Robustness: "สิ่งที่ส่งออกให้เคร่งครัด สิ่งที่รับเข้าให้ยืดหยุ่น"
- เพื่อกำหนดข้อจำกัดด้านอินพุต/เอาต์พุต จำเป็นต้องมีแผนที่ของเครื่องทั้งหมดตั้งแต่ต้น
- ใช้การสร้างแผนที่เพื่อปรับระดับความยากของเครื่อง (ตั้งแต่แบบ 1 อินพุต 1 เอาต์พุตง่าย ๆ ไปจนถึงการรวม 4 อินพุต 4 เอาต์พุตที่ซับซ้อน)
- เพื่อให้มีฟีดแบ็กแบบเรียลไทม์ จึงจำกัดให้ไทล์ปล่อยลูกบอลออกด้วยอัตราใกล้เคียงกับที่รับเข้ามา
- จำกัดเครื่องที่กลืนลูกบอลหรือหน่วงลูกบอล
- ทดสอบความโกลาหลของไทล์ด้วยอัตราอินพุตแบบสุ่ม
- จึงตั้งหลักไว้ว่า "ปล่อยให้เครื่องทำงานไปสักระยะ แล้วดูว่าโดยเฉลี่ยแล้วมันยังตรงตามข้อจำกัดหรือไม่"
3. เครื่องต้องเข้าสู่สภาวะคงตัวภายใน 30 วินาทีแรก
- มีคำถามว่าผู้ตรวจทานต้องเฝ้าดูนานแค่ไหน
- คำนวณเวลาในการตรวจทั้งเครื่อง (83.3 ชั่วโมงสำหรับ 10,000 ไทล์)
- จึงตัดสินใจแบบตามดุลยพินิจว่าให้เข้าสู่สภาวะคงตัวภายใน 30 วินาที
- ตั้งให้ลูกบอลหายไปหลังผ่านไป 30 วินาที
- ตอนแรกยังไม่มีเวลาหมดอายุ ทำให้ระหว่างที่ผู้เล่นเรียนรู้เกม ลูกบอลสะสมจนเต็มหน้าจอ
- เมื่อ rigid body ที่ทำงานอยู่มีจำนวนมากขึ้น ความเร็วของการจำลองฟิสิกส์ก็ลดลง
- ลูกบอลกลายเป็นตัวเกะกะมากกว่าจะสนุก
- การหมดอายุของลูกบอลยังช่วยให้เครื่องไม่สะสมความผิดพลาดไปเรื่อย ๆ ตามเวลา
- ผู้ตรวจทานจึงดูเพียง 30 วินาทีก็พอจะเข้าใจได้ว่าลูกบอลส่วนใหญ่จะไปทางไหนบ้าง
การจำลองและความเหนือจริง
- มีความท้าทายใหญ่ 2 อย่างในสถาปัตยกรรมของ Machine:
- ภายใต้ข้อจำกัดการออกแบบข้างต้น การเชื่อมไทล์ที่ต่างกันให้กลายเป็นเครื่องเดียวทั้งชุดจะใช้งานได้จริงหรือไม่?
- จึงสร้างและแก้แผนที่ขนาดเล็กสองสามชุดเพื่อพิสูจน์
- ถ้าไม่สามารถรันเครื่องขนาดมหึมาแบบเรียลไทม์บนเซิร์ฟเวอร์หรือไคลเอนต์ได้ จะจะแสดงผลมันอย่างไร?
เป้าหมายคือทำให้สามารถเลื่อนหน้าจอเพื่อตามดูลูกบอลเพียงลูกเดียวได้
- แม้จะไม่ได้จำลองทั้งเครื่องทั้งหมด พื้นที่รอบบริเวณที่ผู้เล่นมองอยู่ก็ยังต้องถูกจำลอง
- ในช่วงแรกได้ทดสอบการจำลองเฉพาะพื้นที่ที่มองเห็นบนแผนที่ไร้ขอบเขต
- มันทำงานได้ค่อนข้างดี แต่เมื่อเลื่อนหน้าจอ ไทล์จะเข้ามาในระบบจำลองด้วยสภาพเริ่มต้นว่างเปล่า ทำให้การไหลมีช่วงขาดหาย
- แทนที่จะเป็นไทล์ว่าง มันต้องดูเหมือนมีการทำงานอยู่ก่อนแล้ว
ความท้าทายข้อที่สอง: ถ่ายสแนปช็อตของไทล์หลังจากเข้าสู่สภาวะคงตัวแล้วเท่านั้น เพื่อให้มันมีอยู่ก่อนจะถูกเห็นจากการเลื่อนหน้าจอไม่นาน
- ในคอมิกเวอร์ชันสุดท้าย มุมมองที่ปิด display clipping (ปิด
CSS overflow:hidden, contain:paint):
- สังเกตเห็นสแนปช็อตไหม? ถ้าไม่ได้ตั้งใจดูมากเป็นพิเศษก็แทบไม่ทันสังเกต
- มีเพียงไทล์ที่ถูกเรนเดอร์เท่านั้นที่มีอยู่ในระบบจำลองฟิสิกส์
- การปรับแต่งการแสดงผล: มองเห็นเฉพาะลูกบอลในพื้นที่มุมมอง แต่จำลองบนช่วงไทล์ทั้งหมด
- เพื่อทำให้ดูเหมือนด้านบนของเครื่อง จึงสร้างและป้อนลูกบอลที่แถวบนสุดของการจำลอง โดยอิงจากอัตราที่คาดไว้ในข้อจำกัดอินพุต
- ผนวกการสร้างสแนปช็อตเข้ากับ UI สำหรับการตรวจทาน
- ผู้ตรวจทานต้องรออย่างน้อย 30 วินาทีก่อนอนุมัติไทล์
- เมื่อกดปุ่มอนุมัติ ก็จะสร้างสแนปช็อต
- ผู้ตรวจทานสามารถใช้ดุลยพินิจรอเพิ่มอีกเล็กน้อยจนกว่าเครื่องจะอยู่ในสภาพที่ดูดี
- วิธีสแนปช็อตทำงานได้ดีกว่าที่คาดไว้มาก
- มันช่วยรีเซ็ตความผิดพลาดที่สะสมอยู่ในเครื่องได้ด้วย
- ความประทับใจแรกของไทล์ที่เห็นระหว่างเลื่อนหน้าจอคือสภาพที่สะอาดและดีซึ่งผู้ตรวจทานพอใจแล้ว
- ในความเป็นจริง ถ้าดูนาน ๆ เครื่องหลายตัวอาจพังหรือเสียรูปได้มาก แต่เมื่อสำรวจต่อไปก็จะเข้าสู่สแนปช็อตใหม่ จึงไม่มีทางได้เห็น
- เครื่องที่เลื่อนดูในคอมิกไม่ใช่ของจริง มันคือความเหนือจริง
- ทั้งหมดไม่ได้ถูกจำลองพร้อมกันในคราวเดียว แต่กลับให้ผลลัพธ์ที่ดีกว่า
การเรนเดอร์ลูกบอลหลายพันลูกด้วย React และ DOM
- สร้างบนเอนจินฟิสิกส์ Rapier
- เอกสารยอดเยี่ยม, API ที่สะอาดขององค์ประกอบพื้นฐานที่มีประโยชน์, และการ implement ด้วย Rust (รันเป็น WASM ในเบราว์เซอร์) ทำให้ได้ประสิทธิภาพที่น่าประทับใจ
- ตอนแรกสนใจการรับประกันความเป็นเชิงกำหนดของ Rapier มาก แต่สุดท้ายไม่ได้ทำการจำลองฝั่งเซิร์ฟเวอร์
- เขียน React context แบบกำหนดเอง
<PhysicsContext> ครอบบน Rapier
- สร้างวัตถุฟิสิกส์ของ Rapier และจัดการมันภายใน lifecycle ของ React component
- ทำให้พัฒนา component แบบ "widget" สำหรับวัตถุแต่ละชิ้นที่วางได้และมีฟิสิกส์หรือพื้นผิวชนได้ง่าย
- React ทำหน้าที่เป็น scene graph แบบง่าย ๆ และลุย ๆ
- ทำให้การโหลด/ยกเลิกโหลดไทล์ตอนเลื่อนมุมมองง่ายขึ้น: เมื่อ unmount ไทล์ ฟิสิกส์และ DOM ทั้งหมดจะถูกเก็บกวาด
- เป็นโบนัส ยังเชื่อม hot reloading เข้ากับ fast refresh ได้ง่าย (มีประโยชน์มากตอนจูนรูปร่างการชน)
- ข้อดีอีกอย่างของแนวทาง React context:
- ถ้าใช้ physics hook นอก
<PhysicsContext> มันจะกลายเป็น noop
- ใช้สิ่งนี้กับการเรนเดอร์พรีวิวไทล์แบบคงที่ใน UI ตรวจทาน
- มองย้อนกลับไป น่าจะใช้ component แทน hook สำหรับการสร้างวัตถุ Rapier (แบบที่ react-three-rapier เลือกใช้)
- เข้ากับ React diffing ได้ดีกว่า (เมื่อ dependency เปลี่ยน
useEffect จะลบอินสแตนซ์เดิมแล้วสร้างใหม่)
- Machine ถูกเรนเดอร์ด้วย DOM ทั้งหมด
- ระหว่างพัฒนาในช่วงแรก เคยกังวลว่าอาจชนเพดานประสิทธิภาพของการเรนเดอร์ด้วย DOM
- ถ้ามันช้าเกินไปก็คาดว่าจะเปลี่ยนไปใช้ PixiJS หรือ canvas แต่ก็อยากเห็นก่อนว่าจะดัน DOM ไปได้ไกลแค่ไหน
- การปรับแต่งประสิทธิภาพการเรนเดอร์:
- frame loop จะนำสไตล์ไปใช้กับ widget ที่มีการจำลองฟิสิกส์โดยตรง
- React diff จะทำงานเฉพาะเวลาที่มีการเปลี่ยนแปลงเชิงโครงสร้างใน scene graph
- ตอนแรก เรนเดอร์ลูกบอลด้วย React
1 ความคิดเห็น
ความเห็นจาก Hacker News
เมื่อรวบรวมความเห็นต่าง ๆ แล้ว สามารถสรุปได้ดังนี้:
Rapierแต่ก็เคยเกิดการแครชจากข้อผิดพลาดแบบเรียกซ้ำ