- โปรเจกต์ทดลองที่ เรนเดอร์ 3D DOOM ด้วย CSS เพียงอย่างเดียว โดยสร้างกำแพงและอ็อบเจ็กต์ทั้งหมดด้วย
<div> และ 3D transform
- ลอจิกของเกมทำงานด้วย JavaScript แต่ การเรนเดอร์ทำด้วย CSS ทั้งหมด เพื่อสำรวจขีดจำกัดของเบราว์เซอร์และ CSS สมัยใหม่
- ใช้ความสามารถ CSS สมัยใหม่ เช่น ตรีโกณมิติ, clip-path, @property, SVG filter, anchor positioning เพื่อสร้างทั้งกำแพง พื้น แสง สไปรต์ และเอฟเฟ็กต์ระเบิด
- เนื่องจาก CSS ไม่มีแนวคิดเรื่องกล้อง จึงจัดการมุมมองด้วยวิธี ขยับโลกแทนผู้เล่น และควบคุมการเคลื่อนที่ทั้งหมดผ่านการอัปเดต custom property
- แม้ประสิทธิภาพจะไม่ถึงระดับ WebGL แต่ก็เป็นกรณีที่พิสูจน์ ศักยภาพในการขยายความสามารถด้านการแสดงผลและการคำนวณของ CSS
การเรนเดอร์ 3D DOOM ด้วย CSS
- โปรเจกต์ทดลองที่ เรนเดอร์ DOOM ด้วย CSS เพียงอย่างเดียว โดยกำแพง พื้น และอ็อบเจ็กต์ทั้งหมดสร้างจาก
<div> และวางตำแหน่งด้วย 3D transform
- ลอจิกของเกมรันใน JavaScript แต่การเรนเดอร์ทั้งหมดทำโดย CSS
- เป้าหมายของโปรเจกต์คือ สำรวจขีดจำกัดของเบราว์เซอร์และ CSS สมัยใหม่
กลับไปสู่คณิตศาสตร์ระดับมัธยมปลาย
- ดึงข้อมูลจากไฟล์ WAD ของ DOOM ต้นฉบับ (vertices, linedefs, sidedefs, sectors) เพื่อประกอบฉากแบบสแตติกด้วย
<div> หลายพันชิ้น
- กำแพงแต่ละด้านรับพิกัดจุดเริ่มต้น-จุดสิ้นสุดและความสูงของพื้น-เพดานผ่าน CSS custom property
- ใช้ฟังก์ชัน CSS
hypot() และ atan2() เพื่อคำนวณความยาวและมุมหมุนของกำแพง
- JavaScript ส่งข้อมูลดิบให้ และ CSS เป็นฝ่ายคำนวณตรีโกณมิติเพื่อเรนเดอร์
- game loop กับ renderer แยกจากกัน โดย JS รับผิดชอบเพียงจัดการสถานะและอัปเดตพิกัด
ปัญหาการแปลงระบบพิกัด
- DOOM ใช้ระบบพิกัด 2D ที่ค่า Y เพิ่มขึ้นไปทางทิศเหนือ แต่ CSS 3D ใช้ Y ชี้ขึ้นด้านบน และ Z ชี้เข้าหาผู้ชม
- ตอนแปลงใช้รูปแบบ
translate3d(x,-z,-y) เพื่อให้ระบบพิกัดตรงกัน
- จุดเด่นคือการคำนวณ
rotateY(atan2(var(--delta-y), var(--delta-x))) ทำงานได้โดยไม่ต้องมีการแปลงเพิ่ม
ขยับโลกแทนกล้อง
- เพราะ CSS ไม่มีแนวคิดเรื่องกล้อง จึงใช้วิธี ขยับโลกในทิศตรงกันข้ามแทนผู้เล่น
- JS อัปเดตเพียง custom property 4 ตัวคือ
--player-x/y/z/angle
- ใช้
translate: 0 0 var(--perspective) เพื่อชดเชยมุมมอง และใช้ rotateY กับ translate3d สำหรับการหมุนและย้ายตำแหน่งมุมมอง
- การเคลื่อนที่ทั้งหมด จัดการผ่านการอัปเดต property เท่านั้น
พื้นคือ div ที่นอนราบ
- โดยปกติองค์ประกอบ DOM จะเป็นระนาบแนวตั้ง ดังนั้นพื้นจึงถูกวางให้นอนราบด้วย
rotateX(90deg)
- ใช้
clip-path, polygon(), path() เพื่อแสดงพื้นที่หลายเหลี่ยมที่ซับซ้อนและช่องโหว่ต่าง ๆ
- ฟังก์ชัน
shape() ใน CSS รุ่นใหม่ทำให้ใช้เส้นทางแบบอิงเปอร์เซ็นต์ร่วมกับกฎ evenodd ได้
การจัดแนวเท็กซ์เจอร์
- เพื่อไม่ให้เท็กซ์เจอร์ขาดตอนระหว่าง sector ที่อยู่ติดกัน จึงใช้
background-position อิงพิกัดโลก
- ทุก sector ใช้กริดเท็กซ์เจอร์เดียวกัน จึงเชื่อมขอบต่อกันได้อย่าง ลื่นไหล
ประตู ลิฟต์ และแอนิเมชันด้วย @property
- การเปิดประตูคือการยกเพดานของ sector ขึ้น โดยจัดการ
transform ของ <div> คอนเทนเนอร์ด้วย CSS transition
- ลิฟต์ต้องพาผู้เล่นเคลื่อนที่ไปด้วย จึงให้ JS ซิงก์ค่า
--player-z
- ใช้
@property เพื่อ ลงทะเบียน custom property เป็นค่าตัวเลข ทำให้เกิดเอฟเฟ็กต์ตกและเคลื่อนที่อย่างลื่นไหล
สไปรต์และการกลับด้าน
- สไปรต์ของศัตรูใช้วิธี billboard ที่หันเข้าหากล้องตลอดเวลา
- จาก 8 ทิศทาง ใช้ภาพจริงเพียง 5 ชุด ส่วนที่เหลือจัดการด้วย การกลับซ้ายขวา (
scaleX)
- ใช้แอนิเมชัน
steps() สำหรับสลับเฟรมเดิน โจมตี และตาย
- ปัญหาศัตรูทุกตัวเดินพร้อมกันแก้ด้วย
animation-delay แบบสุ่ม จาก JS
โปรเจกไทล์ ระเบิด และเอฟเฟ็กต์กระสุน
- จรวด ลูกไฟ และสิ่งอื่น ๆ ใช้ CSS animation เพื่อ เคลื่อนที่จาก A ไป B อัตโนมัติ
- JS ตั้งค่าเพียงพิกัดเริ่มต้น-สิ้นสุดและระยะเวลา จากนั้นเมื่อตรวจชนก็ลบองค์ประกอบและสร้างสไปรต์ระเบิด
- เอฟเฟ็กต์ระเบิดและควันกระสุนจะถูกลบอัตโนมัติหลังแอนิเมชัน 3 เฟรมแบบ
steps()
แสงและฟิลเตอร์
- กำหนดค่าความสว่างราย sector ด้วย property
--light และให้องค์ประกอบภายในสืบทอดผ่าน filter: brightness()
- แสงกระพริบทำโดยเปลี่ยนค่า
--light เป็นช่วง ๆ ด้วย @keyframes
- ศัตรูโปร่งใส (Spectre) แสดงเป็นเงาบิดเบี้ยวด้วย SVG filter (
feColorMatrix, feTurbulence, feDisplacementMap)
UI แบบ responsive และ anchor positioning
- เกมรองรับ อุปกรณ์พกพา โดย HUD ใช้
flex-wrap เพื่อขึ้นบรรทัดใหม่
- สไปรต์อาวุธปรับตำแหน่งอัตโนมัติตามความสูงของ HUD ด้วย
anchor-name / position-anchor
- ปุ่มควบคุมแบบสัมผัสก็จัดวางด้วยวิธี anchor แบบเดียวกัน
โหมดผู้ชม
- รองรับทั้ง มุมมองภาพรวมทั้งแผนที่ และ มุมมองติดตามบุคคลที่สาม
- ใช้ฟังก์ชัน CSS
sin() และ cos() เพื่อคำนวณตำแหน่งกล้องด้านหลังผู้เล่น
- แยก property
rotate และ translate ออกจากกันเพื่อให้ เปลี่ยนมุมมองได้อย่างลื่นไหล
- JS อัปเดตเพียงตำแหน่งและมุม ส่วนคณิตศาสตร์ของกล้องให้ CSS จัดการ
การคัดทิ้งและประสิทธิภาพ
- องค์ประกอบ 3D หลายพันชิ้นทำให้เกิด ภาระต่อ compositor ของเบราว์เซอร์
- การคัดทิ้งแบบ JS: ตั้งค่าองค์ประกอบที่อยู่นอกมุมมองเป็น
hidden
- การทดลองคัดทิ้งแบบ CSS: ควบคุม
visibility ด้วยค่าที่คำนวณได้ โดยใช้เทคนิค type grinding
- หากฟังก์ชัน
if() ถูกทำให้เป็นมาตรฐาน ก็จะสามารถแทนที่ด้วยเงื่อนไขที่กระชับกว่าได้
การจัดเรียงตามความลึก
- เบราว์เซอร์ จัดการลำดับความลึก (z-order) ให้อัตโนมัติ
- วัตถุที่อยู่บนระนาบเดียวกันจะใส่ offset เล็กน้อย เพื่อป้องกันการกะพริบ
“ลูกเล่นหลอกตา” ของ DOOM และการจัดการท้องฟ้า
- DOOM ต้นฉบับใช้ เทคนิคฉายภาพ โดยวาดท้องฟ้าเป็นเท็กซ์เจอร์ 2D บน “กำแพง”
- แต่ตัวเรนเดอร์ CSS ต้องวางท้องฟ้าไว้ในพื้นที่ 3D จริง จึงเกิดปัญหา มองเห็นด้านหลังของแผนที่ ในบางฉาก
- วิธีแก้คือระหว่างขั้นตอนคัดทิ้ง ให้ ตัดองค์ประกอบด้านหลังผนังท้องฟ้าออกจากการเรนเดอร์
บทสรุป — ขีดจำกัดและความเป็นไปได้ของ CSS
- game loop ทั้งหมดอยู่ใน JS ส่วนการเรนเดอร์แยกออกมาเป็น CSS ล้วน
- ใช้ความสามารถ CSS สมัยใหม่อย่าง ตรีโกณมิติ, @property, clip-path, SVG filter, anchor positioning จนถึงขีดสุด
- แม้ประสิทธิภาพจะไม่ถึงระดับ WebGL แต่ก็พิสูจน์ ศักยภาพในการขยายขอบเขตการแสดงผลของ CSS
- พบทั้งบั๊กด้าน 3D และปัญหาประสิทธิภาพจำนวนมากใน Safari และ Chrome
- บทสรุปสุดท้าย: “เราสามารถรัน DOOM ด้วย CSS ได้หรือไม่?”
→ ทำได้ Yes, it can.
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
คนประเภทที่ชอบพูดว่า “เอาสิ่งนี้ไปรัน DOOM มาแล้ว” ควรถูกจ้างเข้ากรม ระบบขับเคลื่อนอวกาศ ของรัฐบาล
พวกเขาเป็นคนที่ต้องมี โจทย์ประหลาดแบบสุด ๆ ให้ทำ จะได้ไม่ใช่แค่นั่งขยับนิ้วไปวัน ๆ
นี่ดูเป็นโปรเจกต์แนว “ทำได้ก็เลยทำ”
เดิมที CSS เป็นภาษาสำหรับจัดสไตล์แบบประกาศเจตนา แต่ตอนนี้มันค่อย ๆ กลายเป็น ระบบที่โปรแกรมได้ มากขึ้น ด้วยการมีทั้งเงื่อนไข ฟังก์ชันคณิตศาสตร์ และลูกเล่นด้านการเรนเดอร์
ประเด็นสำคัญไม่ใช่ว่า “จะรัน DOOM ด้วย CSS ได้ไหม” แต่คือเรากำลัง ยัดตรรกะเข้าไปในเลเยอร์ ที่เดิมไม่ได้ถูกออกแบบมาสำหรับสิ่งนี้มากแค่ไหน
CSS ซ่อนความอยากเป็นภาษาโปรแกรมไว้ แต่สุดท้ายมันก็กลายเป็น abstraction ที่ผิดที่ผิดทางไปหมด
เมื่อก่อนเราต้องใช้ JS สำหรับดรอปดาวน์ ทูลทิป และเลย์เอาต์ แต่ตอนนี้ใน CSS สามารถกำหนดได้ทั้ง anchor positioning และเงื่อนไขอย่าง if()
แม้แต่แอนิเมชัน การสลับรายละเอียด และเอฟเฟกต์ด้านการเข้าถึงก็ทำได้ด้วย CSS
การสร้างฉาก 3D ด้วย CSS นั้นทำได้มานานแล้ว แต่ถ้าจะให้โต้ตอบได้ก็ยังต้องพึ่ง JS
ตอนนี้มีอย่าง โครงการ x86CSS ที่สามารถ อีมูเลต CPU ด้วย CSS ล้วน ๆ โดยไม่ใช้ JS
เลยทำให้สงสัยว่า DOOM จะสามารถทำงานแบบเรียลไทม์ด้วย CSS ล้วนได้หรือไม่
กรณีนี้แสดงให้เห็นชัดว่าทำไมผู้คนถึงอยากได้ CSS ที่อิงกับ TypeScript
เพราะฟีเจอร์อย่าง if() ที่ใช้ได้เฉพาะใน Chrome ทำให้นักพัฒนาต้องใช้ลูกเล่นแบบนี้
ยกตัวอย่างเช่น ใช้
animation-delayกับ@keyframesเพื่อเลียนแบบการ สลับการมองเห็นถ้า CSS if() กลายเป็นมาตรฐาน ก็จะจัดการเงื่อนไขได้สะอาดขึ้นโดยไม่ต้องแฮ็กแบบนี้
โค้ดโกง DOOM อย่าง IDDQD และ IDKFA น่าเสียดายที่ใช้ไม่ได้
ทำให้นึกถึงสมัยก่อนที่ถ้าจะทำมุมโค้งให้ div ต้องใช้ GIF สี่ภาพ
น่าประทับใจจริง ๆ! แค่ลบ div ตัวเดียวก็ทำ wall hack ได้แล้ว
opacity: 0.7ให้.wallก็จะได้ความรู้สึกแบบ transparent wallhack ยุคเก่ากลับมาเป๊ะ ๆตอนแรกก็สงสัยว่า “มีที่ไหนให้ลองเล่นเองไหม?” แล้วก็พบว่าลองได้ที่ cssdoom.wtf
ส่วนบน Chromium กลับกระตุกกว่า และก็หา ปุ่ม strafing ไม่เจอ
ถึงอย่างนั้นโดยรวมก็ยังเป็นงานที่น่าทึ่งมาก
CSS เป็นสเปกตัวอย่างชั้นดีที่แสดงให้เห็น ข้อจำกัดของการออกแบบโดยคณะกรรมการ
มันแข่งกับ SVG ในตำแหน่ง “สเปกที่ดูประหลาดที่สุด”
ขอเสริมอีกนิดเกี่ยวกับงานที่ยอดเยี่ยมชิ้นนี้
จริง ๆ แล้ว ไม่ใช่ผู้เล่นที่กำลังเคลื่อนที่ แต่เป็นโลกต่างหากที่กำลังเคลื่อนที่
กล้องเป็นเพียงเครื่องมือเชิงแนวคิดสำหรับคำนวณมุมมองภาพ (frustum) เท่านั้น