จำลองอนุภาค 20 ล้านตัวด้วย JavaScript
(dgerrells.com)- เริ่มจากการจำลองอนุภาคด้วย JavaScript ที่ใช้ CPU ล้วน ตั้งเป้าให้ อนุภาค 1 ล้านตัวบนมือถือที่ 60fps และขยายตัวอย่างสุดท้ายไปได้ถึง 20 ล้านตัว
- หัวใจของประสิทธิภาพคือการหลีกเลี่ยงอาร์เรย์ของอ็อบเจ็กต์ และเก็บข้อมูลอนุภาคไว้ในหน่วยความจำต่อเนื่องด้วย TypedArray และ SharedArrayBuffer พร้อมแบ่งงานตามคอร์ CPU ด้วย Web Worker
- คอขวดที่แท้จริงเห็นชัดกว่าในการวาดภาพบนหน้าจอและการเข้าถึงหน่วยความจำ มากกว่าการคำนวณตำแหน่งอนุภาค โดยการเขียนลง pixel buffer แบบสุ่มทำให้เกิด CPU cache miss
- ลดคอขวดลงทีละขั้นผ่าน pixel buffer แยกตาม Worker, การซิงก์ด้วย postMessage, double buffering, กริดนับจำนวนอนุภาคต่อพิกเซล และการเรนเดอร์ texture ด้วย GPU
- โครงสร้างสุดท้ายคือ CPU ใช้จำลอง ส่วน GPU ใช้เรนเดอร์ grid texture ตามขนาดความละเอียดหน้าจอ และบน M1 Mac สามารถประมวลผล 20 ล้านตัวได้ราว 20fps
เป้าหมายและแนวทางพื้นฐาน
- โจทย์ท้าทายคือจำลองอนุภาค 1 ล้านตัวบนมือถือที่ 60fps โดยใช้เพียง JavaScript ล้วน และ CPU โดยไม่พึ่ง WebAssembly
- มองว่าอาร์เรย์ของอ็อบเจ็กต์แบบ JavaScript ธรรมดามีข้อจำกัดทั้งด้านประสิทธิภาพ single-core บนมือถือและการจัดวางหน่วยความจำ จึงเลือกแนวทางจัดการข้อมูลเป็น อาร์เรย์ต่อเนื่อง
- ในงานคำนวณขนาดใหญ่ อาร์เรย์หน่วยความจำต่อเนื่องที่จัดแน่นเพื่อให้ดึงข้อมูลขึ้น CPU cache ได้มากที่สุดเป็นสิ่งสำคัญ
- อาร์เรย์และอ็อบเจ็กต์ทั่วไปของ JavaScript ยากจะรับประกันได้ว่าถูกจัดวางแน่นเหมือนอาร์เรย์หรือ
structในภาษาระดับล่าง TypedArrayสามารถสร้างอาร์เรย์ไบต์ต่อเนื่องขนาดคงที่ได้ จึงทำให้จัดการหน่วยความจำระดับค่อนข้างต่ำใน JavaScript ได้
การติดตั้งครั้งแรก: SharedArrayBuffer และ Web Worker
- ข้อมูลอนุภาคถูกเก็บโดยวาง view แบบ
Float32ArrayบนSharedArrayBuffer- อนุภาคหนึ่งตัวแทนด้วยค่า floating point 32 บิต 4 ค่า คือ
x,y,dx,dy - ใช้
stride = 4,byte_stride = stride * 4เพื่อ index บน buffer แบบแบน
- อนุภาคหนึ่งตัวแทนด้วยค่า floating point 32 บิต 4 ค่า คือ
- การจำลองเริ่มจากการอัปเดตตำแหน่งอย่างง่ายในแต่ละเฟรมด้วย
x += dx,y += dy - การทำงานหลายเธรดประกอบด้วย Web Worker และ
SharedArrayBuffer- main thread แบ่งอนุภาคเป็น chunk แล้วส่งให้แต่ละ Worker
- Worker อ่านและเขียนเฉพาะ chunk ที่ได้รับมอบหมาย เพื่อหลีกเลี่ยงการให้หลายเธรดเขียนลงตำแหน่งหน่วยความจำเดียวกัน
- การใช้
SharedArrayBufferในเบราว์เซอร์ต้องมี header ต่อไปนี้Cross-Origin-Embedder-Policy: require-corpCross-Origin-Opener-Policy: same-origin
- การซิงก์ในช่วงแรกใช้ SharedArrayBuffer สำหรับส่งสัญญาณ แยกต่างหาก
- main thread เขียนสัญญาณให้ Worker เริ่มทำงาน
- Worker ตรวจสัญญาณด้วย
setInterval(..., 1)และเมื่อทำงานเสร็จจะบันทึกสถานะพร้อม
- การเรนเดอร์ใช้
ImageDataและทำงานคล้าย rasterizer บน CPU- วาดอนุภาคหนึ่งตัวเป็นหนึ่งพิกเซลบนหน้าจอ
- ข้ามอนุภาคที่อยู่นอกหน้าจอเพื่อหลีกเลี่ยงการเข้าถึงเกินขอบเขตอาร์เรย์
- ใช้
putImageDataวาดผลลัพธ์ลง Canvas
- จากการโปรไฟล์เบื้องต้น งานของ Worker ใช้เวลาเพียงไม่กี่ ms และเวลาส่วนใหญ่หมดไปกับ การเรนเดอร์บน main thread
- หน่วยความจำถูกจัดสรรครั้งเดียวตอนเริ่ม และไม่สร้างอ็อบเจ็กต์ใหม่ในลูปจำลอง จึงแทบไม่เกิด garbage
การติดตั้งครั้งที่สอง: อินพุตและปฏิสัมพันธ์คล้ายแรงโน้มถ่วง
- ข้อมูลอินพุตถูกเพิ่มลงใน
SharedArrayBufferสำหรับสถานะการจำลองที่มีอยู่เดิม- เก็บ delta time, mouse x/y, สถานะการสัมผัส, ความกว้าง/ความสูงหน้าจอ
- สถานะการสัมผัสมีลักษณะใกล้เคียง boolean แต่ใช้ 4 ไบต์
- เพิ่มเอฟเฟกต์ให้เมื่อกดหน้าจอ อนุภาคจะถูกดึงเข้าหาจุดสัมผัส
- อ้างอิงรูปแบบ
1 / r²ของสมการแรงโน้มถ่วงจริง แต่ปรับให้เน้นความสนุกของปฏิสัมพันธ์มากกว่าความแม่นยำทางฟิสิกส์ - เพิ่มแรงหน่วงคล้ายแรงเสียดทานเพื่อป้องกันไม่ให้อณูเร่งไม่สิ้นสุดจนหลุดออกนอกจอ
- อ้างอิงรูปแบบ
- สีในการเรนเดอร์ถูกปรับให้เปลี่ยนตามตำแหน่งของอนุภาค
- ใช้ค่า
x / width,y / heightเพื่อปรับค่าของช่อง RGB - หากเก็บสีแยกต่ออนุภาคจะทำให้ขนาดข้อมูลต่ออนุภาคใหญ่ขึ้น จึงใช้สีตามตำแหน่งเพื่อลดขนาดข้อมูล
- ใช้ค่า
- ทดลองได้ถึง 2 ล้านอนุภาค และสามารถเปลี่ยนจำนวนอนุภาคผ่าน query parameter
countใน URL - ที่ 10 ล้านอนุภาค เฟรมเรตเริ่มลดลง และการโปรไฟล์ชี้ว่า การเขียนพิกเซลลง ImageData ช้ากว่า Worker
- เมื่อเปลี่ยนจากการเขียน RGB ครบทุกช่อง ไปเขียนเฉพาะช่องสีน้ำเงินและอัลฟา เวลาการเรนเดอร์ในบางสภาพแวดล้อมลดจากราว 80~90ms เหลือ 16~18ms
- ความต่างนี้ขึ้นกับ CPU และเห็นชัดบน Ryzen เดสก์ท็อปมากกว่า M1 MacBook Air
การติดตั้งครั้งที่สาม: ให้ Worker เขียน pixel buffer
- เพื่อใช้คอร์ CPU ได้มากขึ้น จึงเปลี่ยนให้ Worker รับผิดชอบไม่ใช่แค่การจำลอง แต่รวมถึง การวาดพิกเซล ด้วย
- ใช้ pixel buffer แยกตาม Worker เพื่อไม่ให้หลาย Worker เขียนลง buffer เดียวกันพร้อมกัน
- ใช้
SharedArrayBufferขนาดCPU_CORES * window.innerWidth * window.innerHeight * 3 - แต่ละ Worker มี RGB buffer ของทั้งหน้าจอหนึ่งชุดเป็นของตัวเอง
- ใช้
- main thread รวม pixel buffer ของแต่ละ Worker เข้าด้วยกัน แล้วเขียนลง
ImageData - เวอร์ชันนี้เกิดอาการหน้าจอกะพริบ
- ระหว่างที่ Worker ล้าง pixel buffer ของตัวเองด้วย
fill(0)main thread กำลังอ่าน buffer เดียวกันอยู่ - การเรนเดอร์ก่อนหน้านี้ก็อาจอ่านข้อมูลเก่าได้เช่นกัน แต่ความต่างระหว่างเฟรมไม่ชัดเจน ขณะที่คราวนี้การล้างของ Worker ทำให้เห็นเป็นการกะพริบบางส่วนบนหน้าจอ
- ระหว่างที่ Worker ล้าง pixel buffer ของตัวเองด้วย
- แนวทางแก้ที่พิจารณาคือการซิงก์ด้วย
Atomics, การรอ Worker เสร็จผ่านpostMessage, และ double buffering
การติดตั้งครั้งที่สี่: การซิงก์ด้วย postMessage
- เพื่อกำจัดอาการกะพริบ จึงเลิกใช้ signal array แล้วเปลี่ยนให้ Worker แจ้งงานเสร็จด้วย
postMessage - main thread จัดการค่าของ
activeWorkers- ทุกครั้งที่ Worker เสร็จจะส่งข้อความมาและทำให้
activeWorkersลดลง - เมื่อ Worker ทุกตัวเสร็จแล้วจึงเรนเดอร์และขอ
requestAnimationFrameถัดไป
- ทุกครั้งที่ Worker เสร็จจะส่งข้อความมาและทำให้
- วิธีนี้แก้อาการกะพริบได้
- แต่เวลาเฟรมไม่ได้ดีขึ้นมาก และอาจแย่ลงด้วยซ้ำ
- เพราะระหว่างที่ main thread รอให้ Worker เสร็จ มันไม่ได้ทำงานอย่างอื่นเลย
- ที่ 2 ล้านอนุภาค มีกรณีที่ลดได้ราว 7ms หาก Worker ไม่ต้องรอ main thread เรนเดอร์
- เพื่อลดคอขวดนี้ จึงนำ double buffering แบบที่ใช้กันทั่วไปในกราฟิกไดรเวอร์มาใช้
การติดตั้งครั้งที่ห้า: double buffering และคอขวดจาก cache
- double buffering คือการสร้าง pixel buffer สองชุด แล้วสลับ buffer ที่ใช้งานในแต่ละเฟรม
- ระหว่างที่ main thread วาดจาก buffer หนึ่ง Worker จะเตรียมเฟรมถัดไปลงอีก buffer หนึ่ง
- หลังใช้งาน Worker ไม่ต้องรอให้ main thread เรนเดอร์อีกต่อไป และบางเฟรมที่เคยเกิน 50ms ก็ใกล้ 16ms มากขึ้น
- โค้ดที่ยังช้าต่อเนื่องคือสามบรรทัดที่บวกค่าเข้าไปในช่อง RGB ของ pixel buffer
- สาเหตุคือแม้ข้อมูลจะถูกเก็บต่อเนื่อง แต่ลำดับการเข้าถึงไม่ต่อเนื่อง
pixelIndexที่คำนวณจากx,yของอนุภาคไม่ได้เรียงตามลำดับของอาร์เรย์อนุภาค- แต่ละรอบของลูปจึงกระโดดไปตำแหน่งสุ่มของ pixel buffer กลายเป็นแพตเทิร์นที่ใกล้เคียง การเข้าถึงแบบสุ่ม
- รูปแบบการเข้าถึงนี้ทำให้เกิด cache miss เมื่อข้อมูลอนุภาคและข้อมูลพิกเซลไม่สามารถอยู่ใน cache พร้อมกันได้
- ตัวอย่างการคำนวณสำหรับ iPhone มีดังนี้
- สูตร:
screen width * screen height * bytes per pixel + bytes per particle * total particles / number of workers 2532 * 1170 * 3 + 16 * 2,000,000 / 5- ผลลัพธ์เกิน 15MB ซึ่งใหญ่เกินกว่าจะใส่ใน L1 cache
- สูตร:
- ในเวอร์ชันนี้ บนชิป M1 การจำลอง 2 ล้านอนุภาคใช้เวลาราว 4ms, การเข้าถึงข้อมูลพิกเซลราว 3.5ms และการเขียน pixel buffer ราว 7ms
- บนมือถือยังรักษา 60fps ที่ 1 ล้านอนุภาคได้ แต่บนเดสก์ท็อปที่มีมากกว่า 20 เธรด main thread ต้องใช้ 30ms ในการรวม pixel buffer มากกว่า 20 ชุดทุกเฟรม
การติดตั้งครั้งที่หก: แรงดึงกลับสู่ตำแหน่งเริ่มต้น
- เอฟเฟกต์ใหม่คือทำให้อณูยิ่งอยู่ไกลจากตำแหน่งเริ่มต้น ก็ยิ่งถูกดึงกลับแรงขึ้น
- เพื่อทำเช่นนี้ จึงเพิ่มค่า
sx,syซึ่งเป็นตำแหน่งเริ่มต้นของอนุภาค- อนุภาคหนึ่งตัวมี float 6 ค่า คือ
x,y,dx,dy,sx,sy - ทำให้
particleStride = 6
- อนุภาคหนึ่งตัวมี float 6 ค่า คือ
- ในโค้ดของ Worker เพิ่มฟังก์ชันคำนวณแรง และใช้ cache object เพื่อหลีกเลี่ยงการสร้าง garbage
- ผลลัพธ์ทำให้เกิดเอฟเฟกต์ภาพคล้ายเจลลี่หรือของไหล และเห็นการหมุนวนคล้ายกระดาษหรือผ้าที่ยับย่น
- ระหว่างกระบวนการนี้ ได้แนวคิดว่าแทนที่จะเก็บ pixel buffer แยกตาม Worker อาจเปลี่ยนเป็นกริดที่นับเฉพาะจำนวนอนุภาคต่อพิกเซล
การติดตั้งครั้งที่เจ็ด: กริดนับจำนวนอนุภาคต่อพิกเซล
- แทนที่จะเก็บค่า RGB หากเก็บเพียง จำนวนอนุภาค ที่อยู่ในแต่ละตำแหน่งพิกเซล จะลดการใช้หน่วยความจำลงเหลือ 1/3
- เนื่องจากสีคำนวณจากตำแหน่ง
x,yไม่ได้มาจากข้อมูลเฉพาะของอนุภาค จึงเรนเดอร์ได้จากเพียง count ต่อพิกเซล - เปลี่ยนให้ Worker ทุกตัวแชร์กริดจำนวนอนุภาคเพียงชุดเดียว
- งานรวม buffer ของแต่ละ Worker บน main thread หายไป
- แก้ปัญหาที่ต้นทุนการรวมผลเรนเดอร์เพิ่มขึ้นตามจำนวน Worker
- สูตรคำนวณ cache แบบใหม่คือ
screen width * screen height + bytes per particle * total particles / number of workers- สำหรับ iPhone คือ
2532 * 1170 + 16 * 2,000,000 / 5 - ได้ราว 9.3MB ลดลงจากเดิมประมาณ 30% แต่ก็ยังใหญ่สำหรับ L1 cache
- การเรนเดอร์จะวนครบทุกพิกเซล อ่านจำนวนอนุภาคของพิกเซลนั้น คำนวณค่า RGB แล้วรีเซ็ตกริดด้วย
fill(0) - หลังจำลองเสร็จ Worker จะคำนวณ
pCountIndexของอนุภาคที่ยังอยู่ในหน้าจอ แล้วทำactiveGrid[pCountIndex]++ - การเปลี่ยนนี้ช่วยได้มากโดยเฉพาะเมื่อมี Worker จำนวนมาก และทำให้บนเดสก์ท็อปสามารถใช้ครบทั้ง 24 เธรดได้
- ถึงอย่างนั้น M1 MacBook Air ก็ยังเร็วกว่า Ryzen 9 3900X เดสก์ท็อปในบางกรณี
- Ryzen 9 3900X มี L3 cache 64MB แต่ L1 มีเพียง 64KB
- M1 รุ่นแรกมี L1 cache 320KB โดยในนั้น 128KB สำหรับข้อมูล และ 192KB สำหรับคำสั่ง
- คาดว่า L1 data cache ที่ใหญ่กว่าช่วยลดเวลาในการรอหน่วยความจำได้ดีกว่า
ข้อจำกัดของการปรับแต่งแบบ CPU ล้วน
- จากการปรับแต่งหลายรอบ ได้ความเร็วเพิ่มขึ้นราว 2 เท่าเมื่อเทียบกับเวอร์ชัน multi-thread แรก
- เป้าหมายการประมวลผลอนุภาค 1 ล้านตัวบนมือถือที่ 60fps ด้วย JavaScript บน CPU ล้วนทำสำเร็จแล้ว
- ผลรวมทั้งหมดไม่ได้ถึงกับน่าทึ่งมาก และถ้าเป็นภาษาคอมไพล์ก็คาดว่าน่าจะเร็วกว่าได้ 10 เท่า
- ถ้าสามารถใช้คำสั่ง SIMD ใน tight loop ได้ ก็ยังมีโอกาสเร็วขึ้นอีก
- ขั้นต่อไปจึงลองคงการจำลองไว้บน CPU แล้วโยกเฉพาะการเรนเดอร์ไปยัง GPU pipeline แบบดั้งเดิมเพื่อเปรียบเทียบ
20 ล้านอนุภาคและการเรนเดอร์ด้วย GPU
- การเปรียบเทียบการเรนเดอร์ด้วย GPU ครั้งแรกใช้ GPU instancing ของ threejs โดยวาด plane/quad หนึ่งชิ้นต่อหนึ่งอนุภาค
- วิธีนี้ให้ประสิทธิภาพต่ำกว่าที่คาด
- instancing ของ threejs ต้องอัปเดตเมทริกซ์ตามตำแหน่งของแต่ละอนุภาค และงานนี้ทำบน CPU แบบ single-thread
- ต้องอัปโหลดข้อมูลการแปลงของอนุภาคไปยัง GPU ทุกเฟรม และการส่งข้อมูลนี้ช้า
- บนชิป M1 เมื่อจำลอง 4 ล้านอนุภาค เวลาของเฟรมกว่า 80% หมดไปกับการส่งข้อมูลไปยัง GPU
- บนเดสก์ท็อป สัดส่วนการส่งข้อมูลอยู่ที่ 30% แต่ก็ยังเป็นคอขวดที่ใหญ่ที่สุด
- ทางเลือกคือใช้ กริดนับจำนวนอนุภาค ที่สร้างไว้ก่อนหน้าเป็น texture แล้วเรนเดอร์ full-screen quad
- ปริมาณข้อมูลที่ส่งไป GPU จะผูกกับความละเอียดหน้าจอ ไม่ได้ผูกกับจำนวนอนุภาค
- สำหรับการจำลองขนาดใหญ่จะลดปริมาณการส่งข้อมูล แต่สำหรับการจำลองขนาดเล็กอาจต้องส่งข้อมูลมากกว่า เป็นการแลกเปลี่ยนกัน
- เมื่อลองทำอย่างรวดเร็วด้วย threejs และ custom pixel shader พบว่าเวลาเรนเดอร์บน main thread เหลือเพียงไม่กี่ ms โดยไม่ขึ้นกับขนาดของการจำลอง
- ในโครงสร้างนี้ Worker จะทำงานเร็วที่สุดเท่าที่ทำได้ และการจำลองจะผูกตรงกับจำนวนคอร์ CPU ทั้งในงานขนาดใหญ่และเล็ก
- แต่ละ Worker ใช้เวลาราว 50% ไปกับการคำนวณตัวเลข และอีกราว 50% ไปกับการอัปเดตจำนวนในกริดของอนุภาค
- ประสิทธิภาพของเวอร์ชันสุดท้ายเป็นดังนี้
- บน M1 Mac ที่ใช้แบตเตอรี่ สามารถรัน 20 ล้านอนุภาคได้ราว 20fps
- เดสก์ท็อปประมวลผลได้ราว 30 ล้านอนุภาคที่ fps ใกล้เคียงกัน
- จากการทดสอบของเพื่อนที่มี CPU 32 คอร์ ไปได้ถึงราว 40 ล้านอนุภาค
- เดโมที่เกี่ยวข้อง:
สรุป
- CPU และ GPU สามารถประมวลผลงานเชิงตัวเลขได้เร็วมาก
- ส่วนที่ช้าคือการย้ายข้อมูล และโดยเฉพาะอย่างยิ่ง การเข้าถึงหน่วยความจำแบบสุ่ม
- หากอยากเขียนโค้ดให้เร็ว ต้องเข้าใจว่าฮาร์ดแวร์ cache และเคลื่อนย้ายข้อมูลอย่างไร
SharedArrayBufferและ Web Worker มีประโยชน์สำหรับการทดลองใช้หลายคอร์ใน JavaScript- หาก WebGPU ถูกนำมาใช้อย่างแพร่หลายมากขึ้น ก็มีแผนจะลองใช้ compute shader ต่อไป
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
น่าจะดีถ้า ฝังซิมูเลชันให้เล่นได้โดยตรง ไว้ในบล็อก
ต้องเลื่อนขึ้นลงอยู่นานกว่าจะเจอลิงก์ไปยังซิมูเลชันจริง
อาจเลือกค่าที่รันได้พอเหมาะแม้บนมือถือเก่า ๆ หรือทำให้ปรับตามเฟรมเรต หรือไม่ก็วางลิงก์ไว้สักสองสามอันที่ด้านบนของบทความก็ได้
https://ciechanow.ski/ เป็นตัวอย่างระดับสุดยอดของโลกที่แสดงให้เห็นว่าการใส่ซิมูเลชันไว้ในบทความนั้นเจ๋งได้แค่ไหน
สมัยก่อนเว็บไซต์ทุกเว็บเคยฝังองค์ประกอบอินเทอร์แอ็กทีฟเจ๋ง ๆ แบบนี้กัน
และผมคิดว่าสามารถรัน ซิมูเลชันอนุภาคบน GPU ได้แม้ไม่มี WebGPU
ตัวอย่าง: https://news.ycombinator.com/item?id=19963640
อยากให้มาตรฐานของการแบ่งปันข้อมูลเป็นแบบนี้ ถ้าสิ่งนั้นมีค่าพอจะแชร์ ก็มีค่าพอที่จะทำให้เข้าใจง่ายด้วย
เคยคิดว่าจะฝังแต่ละเวอร์ชัน แต่กังวลว่าจะมี worker รันพร้อมกันมากเกินไป
ตั้งใจจะอัปเดตให้ฝังเวอร์ชันสุดท้ายไว้ท้ายบทความ
บล็อกนั้นตัวอย่างแต่ละอันทำมาเนี้ยบมากและดีจริง ๆ
แก้ไข: ลองเพิ่มเวอร์ชันแบบฝังแล้ว แต่ header ที่ต้องใช้เข้ากันได้ไม่ดีกับ embed อื่น ๆ และเวอร์ชันเก่า ๆ ก็ยังผูกอยู่กับ codesandbox
ลองเล่นเดโมบนมือถือ [0] แล้วค่อนข้างน่าทึ่ง
[0] https://dgerrells.com/sabby
น่าทึ่งที่ทำได้ขนาดนี้ด้วย JavaScript ล้วน ๆ และพอนิ้วเยอะขึ้นดีเลย์ก็เพิ่มขึ้น ดูเหมือนว่ามีข้อมูลส่งข้ามไปมาระหว่างเธรดมากขึ้น
สงสัยล้วน ๆ ว่าแทนที่จะแทนแต่ละอนุภาคด้วย เลขทศนิยมแบบ 32 บิต สี่ตัวคือ x, y, dx, dy จะสามารถเข้ารหัสข้อมูลนี้ลงในตัวเลข JS ตัวเดียวได้ไหม
ช่วงจำนวนเต็มที่ปลอดภัยของ JS คือ
2^53 - 1 = 9,007,199,254,740,991ส่วนช่วงของFloat32Arrayที่บทความใช้คือ-3.4e38ถึง3.4e38เช่น ถ้าตำแหน่งบนหน้าจอเป็นแคนวาส 1000x1000 ก็แทนด้วยตัวเลข 0~1,000,000 ได้ และต่อให้เพิ่มความละเอียดระดับซับพิกเซลอีก 10 ขั้น ก็เป็น 100,000,000 ค่า ยังอยู่ในช่วงของ JS แบบเหลือ ๆ
ความเร็ว
dx,dyก็ถูกคำนวณเป็นเลขทศนิยมสุ่มระหว่าง -10 ถึง +10 ด้วย(Math.random()*2-1)*10แต่ถ้าจำกัดให้เหลือทศนิยมหนึ่งตำแหน่งแล้วยังพอ ก็เปลี่ยนเป็นช่วงจำนวนเต็ม -100~+100 ได้ และต้องการค่าที่เป็นไปได้แค่ 10,000 ค่ารวมสองอย่างเข้าด้วยกันจะได้
10,000 * 100,000,000 = 1,000,000,000,000หรือหนึ่งล้านล้านตัวเลขเพื่อแทนอนุภาคหนึ่งตัว ซึ่งยังอยู่ในMAX_SAFE_INTEGERของ JSดังนั้นน่าจะบรรจุข้อมูลของอนุภาคเดี่ยวลงใน
MAX_SAFE_INTEGERหนึ่งตัวหรือองค์ประกอบหนึ่งตัวของFloat32Arrayได้ แบบนี้ก็ไม่ต้องมี stride และความสอดคล้องของข้อมูลก็น่าจะแน่นอนขึ้นแต่ต้นทุนการเข้ารหัส/ถอดรหัสอาจสูงกว่าประโยชน์ด้านการประหยัดหน่วยความจำ จนอาจไม่มีคุณค่าด้านประสิทธิภาพเลยก็ได้
ใน ecosystem ของ
@thi.ngมี implementation ที่เป็นประโยชน์อยู่ และมี implementation แบบ WASM SIMD ด้วย[0]: https://github.com/thi-ng/umbrella/tree/develop/packages/vec...
[1]: https://github.com/thi-ng/umbrella/tree/develop/packages/vec...
[2]: https://github.com/thi-ng/umbrella/tree/develop/packages/bit...
[3]: https://github.com/thi-ng/umbrella/tree/develop/packages/sim...
ยังมี Structura ที่ implement โครงสร้างข้อมูลประสิทธิภาพสูงไว้หลายอย่างด้วย
[4]: https://github.com/zandaqo/structurae/blob/master/README.md#...
ถ้าใช้
Float16Arrayก็ลดความต้องการหน่วยความจำลงครึ่งหนึ่งได้ทันทีอีกวิธีคือแยก array ตามระดับความแม่นยำ
เช่น x, y ใช้
Float16Arrayส่วน dx/dy ใช้Int8Arrayได้ แต่ทั้งสองกรณีอาจเกิดอาร์ติแฟกต์ในการเคลื่อนไหว และโดยเฉพาะInt8Arrayอาจสังเกตเห็นได้จากการ clamp และ aliasing ของ dx/dyเจ๋งมากจริง ๆ แต่สงสัยตรงส่วนนี้
Javascript does support an Atomics API but it uses promises which are gross. Eww sick.ถ้าไม่นับ
waitAsync[1] แล้ว Atomics API ดูเหมือนจะไม่ได้ใช้ Promiseเมื่อก่อนเคยลองใช้ Atomics แต่ก็ไม่จำเป็นต้องจัดการโค้ดแบบ async/Promise
เลยสงสัยว่ามันใช้ Promise ภายในหรือเปล่า หรือมีส่วนไหนที่ผมพลาดไป
[1] https://developer.mozilla.org/en-US/docs/Web/JavaScript/Refe...
มีซิมูเลชันที่จัดการกับ เกือบ 20 ล้านอนุภาค ด้วย
รันและเขียนโปรแกรมได้ที่: https://tinlizzie.org/~ohshima/shadama2/
https://tinlizzie.org/~ohshima/shadama2/live2017/
https://www.youtube.com/watch?v=tqBO_5GMfWw
วิดีโอนั้นเจ๋ง แต่ลิงก์ codesandbox ใน
try it out hereใช้งานไม่ได้บน เดสก์ท็อป MacOS Chromeมี CORS error อย่าง
Uncaught ReferenceError: SharedArrayBuffer is not definedและERR_BLOCKED_BY_RESPONSE.NotSameOriginAfterDefaultedToSameOriginByCoepขึ้นมาถ้าใช้เบราว์เซอร์ที่เน้นความเป็นส่วนตัวก็อาจถูกบล็อกได้
ต่อไปจะลองใส่ตัวอย่างแบบ embed ไว้ด้วย
เวอร์ชันสุดท้ายลองได้ที่นี่: https://dgerrells.com/sabby
ยอดเยี่ยม
ใช้เวลาไปหนึ่งชั่วโมงเพื่อทำความเข้าใจและลองเล่นกับโค้ด แล้วทำ implementation แบบเรียลไทม์ ไว้ที่นี่: https://particules.kapochamo.com/index.html
สวยมาก และแทบไม่น่าเชื่อว่าจะจำลองอนุภาคได้มากขนาดนี้ใน JS
น่าประทับใจที่เจาะลึกโค้ดไปขนาดนั้น
น่าลองดูว่า
chrome://tracingจะให้ insight เพิ่มขึ้นหรือไม่รู้มาจากที่นี่: https://youtu.be/easvMCCBFkQ?t=114
อยากรีบส่งสิ่งนี้ให้ทีม UI ที่ทำแอป React ช้า ๆ ดู
JS นั้นเร็วจริง ๆ และโดยเฉพาะถ้าเขียนดี ๆ ก็ เร็วมาก
JS ที่เร็วจะดูเหมือนภาษาถิ่น C แย่ ๆ และต้องไม่ทำ การจัดสรรอ็อบเจ็กต์ที่ต้องพึ่ง garbage collection แต่ตัวภาษาก็ขาดทางเลือกดี ๆ ที่ไม่ต้อง allocate
ในแอป JS ที่ซับซ้อน แทบไม่มีใครจะจัดการ allocation ของหน่วยความจำทั้งหมดเป็น pointer ภายใน
ArrayBufferขนาดยักษ์ และถ้าจะทำแบบนั้น ย้ายไป WebAssembly น่าจะง่ายกว่าที่น่าเสียดายคือเราไม่สามารถพึ่ง JS อย่างเดียวได้ และต้องผสมการจัดการ DOM เข้าไปเยอะ ซึ่งส่วนที่มักช้าใน UI ที่เราทำกันก็คือการจัดการ DOM นั่นเอง