บันทึกวิจัย

เข้าใจ donut.c ตั้งแต่ศูนย์

โค้ดก้อนนี้ถูกแชร์ต่อกันมาสิบกว่าปี ทุกคนบอกว่าสวย แต่คำอธิบายที่หาเจอทุกอันเริ่มต้นที่คำว่าเมทริกซ์หมุนสามชั้น บทนี้เริ่มจากศูนย์จริง ๆ ปูไซน์กับโคไซน์ด้วยสามเหลี่ยมมุมฉาก ปูเปอร์สเปกทีฟด้วยสามเหลี่ยมคล้าย แล้วต่อขึ้นไปทีละชั้นจนครบหกชั้น อ่านจบแล้วคุณจะเขียนมันออกมาเองได้ทุกบรรทัด


โค้ดที่หน้าตาเหมือนโดนัทและไม่มีใครอ่านออก

โดนัทที่หมุนอยู่ข้างบนนี้ไม่ใช่ภาพวิดีโอ ทุกเฟรมมันคำนวณใหม่หมดว่าแต่ละช่องบนจอควรเป็นตัวอักษรตัวไหน ด้วยเลขชุดเดียวกับโค้ดข้างล่าง โปรแกรม C ตัวจริงพิมพ์ลงจอ 80 คูณ 22 ช่อง ส่วนหน้านี้ย่อกริดลงครึ่งหนึ่งเมื่อจอแคบ ตัวอักษรจะได้ยังอ่านออก

อ่านจบบทนี้ คุณจะเขียนสิ่งที่อยู่ข้างบนนี้ออกมาเองได้ทุกบรรทัด

donut.c ของ Andy Sloane (2006)
             k;double sin()
         ,cos();main(){float A=
       0,B=0,i,j,z[1760];char b[
     1760];printf("\x1b[2J");for(;;
  ){memset(b,32,1760);memset(z,0,7040)
  ;for(j=0;6.28>j;j+=0.07)for(i=0;6.28
 >i;i+=0.02){float c=sin(i),d=cos(j),e=
 sin(A),f=sin(j),g=cos(A),h=d+2,D=1/(c*
 h*e+f*g+5),l=cos      (i),m=cos(B),n=s\
in(B),t=c*h*g-f*        e;int x=40+30*D*
(l*h*m-t*n),y=            12+15*D*(l*h*n
+t*m),o=x+80*y,          N=8*((f*e-c*d*g
 )*m-c*d*e-f*g-l        *d*n);if(22>y&&
 y>0&&x>0&&80>x&&D>z[o]){z[o]=D;;;b[o]=
 ".,-~:;=!*#$@"[N>0?N:0];}}/*#****!!-*/
  printf("\x1b[H");for(k=0;1761>k;k++)
   putchar(k%80?b[k]:10);A+=0.04;B+=
     0.02;}}/*****####*******!!=;:~
       ~::==!!!**********!!!==::-
         .,~~;;;========;;;:~-.
             ..,--------,*/

คอมไพล์ด้วย gcc -o donut donut.c -lm แล้วรันบนเทอร์มินัลที่รองรับ ANSI ได้เลย

คุณน่าจะเคยเลื่อนผ่านมันมาแล้ว โค้ด C ที่จัดหน้าเป็นรูปโดนัท พอคอมไพล์แล้วรัน มันพ่นโดนัทที่หมุนอยู่จริง ๆ ออกมาบนเทอร์มินัล มีเงา มีด้านสว่างด้านมืด ทั้งที่ทั้งจอมีแต่ตัวอักษร แล้วคุณก็กดเข้าไปอ่านคำอธิบาย พอถึงบรรทัดที่สามที่เขียนว่า "หลังจากคูณเมทริกซ์หมุนสามตัวเข้าด้วยกัน จะได้ว่า" คุณก็ปิดแท็บ

ผมอยากบอกเรื่องหนึ่งก่อน ของยากในโค้ดนี้คือการบีบให้มันสั้นและจัดหน้าให้เป็นรูปโดนัท ส่วนคณิตศาสตร์ข้างในเป็นของ ม.ต้นเกือบทั้งหมด สิ่งที่ต้องใช้จริง ๆ มีสี่อย่าง คือสามเหลี่ยมมุมฉาก สามเหลี่ยมคล้าย พีทาโกรัส และการคูณเลขบวกกัน ที่เหลือเป็นการเรียงของให้ถูกลำดับ

ที่มันดูอ่านไม่ออก เพราะตัวแปรทุกตัวถูกตั้งชื่อเหลือตัวอักษรเดียว และหกชั้นความคิดที่แยกกันชัดเจนถูกยัดรวมกันอยู่ในบรรทัดเดียวกัน บทนี้จะแกะมันกลับออกมาเป็นหกชั้น แล้วต่อกลับทีละชั้น ท้ายทุกชั้นจะมีหน้าต่างโค้ดที่ยาวขึ้นเรื่อย ๆ พอถึงชั้นที่หก คุณจะเห็นว่าตัวเองประกอบมันมาครบแล้ว

หกชั้นที่จะต่อขึ้นมา

แกะคำศัพท์

ASCII อ่านว่า "แอสกี" ย่อมาจาก American Standard Code for Information Interchange คือตารางรหัสตัวอักษรมาตรฐานที่คอมพิวเตอร์ใช้ ตัวอักษรอังกฤษ ตัวเลข และเครื่องหมายพื้นฐาน ภาพที่วาดด้วยตัวอักษรพวกนี้เลยถูกเรียกว่าภาพ ASCII

ทอรัส (torus) อ่านว่า "ทอรัส" คือชื่อทางเรขาคณิตของ ทรงห่วงยาง มาจากภาษาละติน torus ที่แปลว่าหมอนอิงหรือสันนูน คำว่าโดนัทในบทนี้กับคำว่าทอรัสหมายถึงของชิ้นเดียวกัน

เรนเดอร์ (render) อ่านว่า "เรนเดอร์" แปลว่า ปั้นภาพออกมา คือขั้นตอนที่โปรแกรมเอาข้อมูลตัวเลขของวัตถุมาแปลงเป็นภาพที่คนดูได้

เข้า θ , φ ชั้นที่ 1 วงกลม ชั้นที่ 2 โดนัท ชั้นที่ 3 หมุน ชั้นที่ 4 ฉายลงจอ ชั้นที่ 5 อะไรบังอะไร ชั้นที่ 6 แสง ออก '@' ทุกจุดบนผิวโดนัทเดินเส้นทางนี้เส้นเดียวกัน
ทั้งไฟล์คือท่อส่งเส้นเดียว มุมสองตัวเข้าทางซ้าย ตัวอักษรหนึ่งตัวออกทางขวา ที่เหลือคือหกด่านระหว่างทาง

ชั้นที่ 1 จาก 6 · วาดวงกลมด้วยมุมเดียว

เริ่มจากของที่เล็กที่สุดในภาพ โดนัทหนึ่งใบคือวงกลมเล็ก ๆ วงหนึ่งที่ถูกลากไปรอบ ๆ แกน ถ้าวาดวงกลมวงเดียวไม่ได้ ก็ไปต่อไม่ได้ คำถามคือ ถ้าผมบอกคุณว่าเอาจุดหนึ่งจุดไปวางบนวงกลมที่มุม 30 องศา คุณจะรู้ได้ยังไงว่าจุดนั้นอยู่ตรงพิกัดไหน

ลากดูได้ครับ ตัวเลขสองตัวที่ขยับตามคือความยาวของเงาที่จุดนั้นทอดลงบนแกนนอนกับแกนตั้ง

วาดวงกลมรัศมี 1 โดยให้จุดศูนย์กลางอยู่ที่ศูนย์ แล้วเลือกจุดหนึ่งจุดบนเส้นวงกลม ลากเส้นตรงจากจุดนั้นดิ่งลงมาชนแกนนอน จะได้สามเหลี่ยมมุมฉากมาหนึ่งรูป ด้านเฉียงคือรัศมี ยาว 1 เสมอ ด้านนอนคือระยะจากศูนย์กลางไปทางขวา ด้านตั้งคือความสูงของจุดนั้น

ความยาวของด้านนอนคือค่าที่เราเรียกว่าโคไซน์ ความยาวของด้านตั้งคือค่าที่เราเรียกว่าไซน์ แค่นั้นเลยครับ มันไม่ใช่ปุ่มลึกลับบนเครื่องคิดเลข มันคือ เงา ของจุดที่ตกลงบนแกนนอนกับแกนตั้ง จุดบนวงกลมรัศมี 1 ที่มุม θ จึงอยู่ที่

ลองแทนค่าดู ที่มุม 0 องศา จุดอยู่ขวาสุด เงาบนแกนนอนยาวเต็ม 1 เงาบนแกนตั้งเป็น 0 ก็คือ cos(0) = 1 กับ sin(0) = 0 พอหมุนไปถึง 90 องศา จุดขึ้นไปอยู่บนสุด เงาบนแกนนอนหดเหลือ 0 เงาบนแกนตั้งยืดเป็น 1 พอถึง 180 องศา จุดไปอยู่ซ้ายสุด เงาบนแกนนอนกลายเป็น -1 เพราะมันอยู่ฝั่งซ้ายของศูนย์

x y θ cos θ sin θ จุดที่มุม θ รัศมี 1 0° 90° 180°
โคไซน์กับไซน์คือเงาของจุดเดียวกันที่ตกลงบนสองแกน ไม่ใช่ปุ่มลึกลับบนเครื่องคิดเลข

ถ้าอยากได้วงกลมรัศมีอื่น ก็คูณเข้าไปตรง ๆ วงกลมรัศมี R1 คือ

แกะคำศัพท์

ไซน์ (sine) มีที่มาที่สนุกที่สุดในบทนี้ ต้นทางคือคำสันสกฤต jyā ที่แปลว่า สายธนู เพราะเส้นคอร์ดในวงกลมกับสายธนูหน้าตาเหมือนกัน คนอาหรับถอดเสียงมาเป็น jiba แต่ภาษาอาหรับเขียนโดยละสระ เหลือแค่พยัญชนะ jb คนรุ่นหลังที่มาอ่านจึงอ่านเป็น jaib ซึ่งเป็นคำอาหรับจริงที่แปลว่า อ่าว พอนักแปลยุคกลางเอาไปแปลเป็นละติน เขาจึงแปลตามความหมายว่าอ่าว ได้คำว่า sinus แล้วกลายมาเป็น sine ในภาษาอังกฤษ (คนที่ถูกอ้างถึงบ่อยที่สุดว่าเป็นคนแปลคือ Robert of Chester อ่านว่า "โรเบิร์ต ออฟ เชสเตอร์" แต่ตำราคณิตศาสตร์ยังเถียงกันอยู่ว่าเป็นเขาหรือ Gerard of Cremona)

แปลว่าคำว่าไซน์ที่เราท่องกันมาตั้งแต่ ม.ปลาย จริง ๆ แล้วแปลว่า อ่าว เพราะมีคนอ่านผิดเมื่อพันปีก่อน ส่วน โคไซน์ (cosine) คือไซน์ของ complementary angle คือมุมที่บวกกับมุมนี้แล้วได้ 90 องศาพอดี

ในโค้ดจริง มุมถูกเดินทีละ 0.07 ไปจนครบ 6.28 ซึ่งก็คือรอบเต็มหนึ่งรอบในหน่วยเรเดียน ตัวเลข 6.28 คือ 2π ปัดเศษ

โค้ดของคุณตอนนี้ · ชั้นที่ 1 จาก 6
// ชั้นที่ 1: จุดบนวงกลมรัศมี R1 ตำแหน่งเดียว ใช้มุมเดียว
for (float theta = 0; theta < 6.28; theta += 0.07) {
  float circlex = 1 * cos(theta);
  float circley = 1 * sin(theta);
}
รู้ได้ยังไงว่าจุดนี้ไม่มีวันหลุดออกนอกวงกลม

เราบอกว่าจุดที่มุม θ คือ (cos θ, sin θ) แล้วมันอยู่บนวงกลมรัศมี 1 จริงไหม พิสูจน์ด้วยพีทาโกรัสได้ในบรรทัดเดียว ระยะจากศูนย์กลางถึงจุดนั้นคือด้านเฉียงของสามเหลี่ยมมุมฉากที่มีด้านนอนยาว cos θ และด้านตั้งยาว sin θ

แล้วทำไมผลบวกของกำลังสองถึงเท่ากับ 1 พอดี เพราะด้านเฉียงคือรัศมี ซึ่งเรากำหนดให้ยาว 1 ตั้งแต่แรก สมการนี้จึงไม่ใช่กฎใหม่ที่ต้องท่อง มันคือพีทาโกรัสที่เขียนใหม่บนวงกลมรัศมี 1

ลองแทนตัวเลขจริงที่มุม 40 องศา cos 40° = 0.766 และ sin 40° = 0.643 ยกกำลังสองแล้วบวกกันได้ 0.587 + 0.413 = 1.000 พอดี

ทำไมโค้ดเดินมุมทีละ 0.07 แทนที่จะเดินทีละ 1 องศา

เพราะโค้ดคิดมุมเป็น เรเดียน (radian) ไม่ใช่องศา หนึ่งรอบเต็มเท่ากับ 2π หรือราว 6.28 เรเดียน ซึ่งคือเลขที่คุณเห็นในเงื่อนไขลูปทุกตัวในไฟล์

เรเดียนไม่ใช่หน่วยที่คิดขึ้นมาให้ยุ่งยาก มันคือหน่วยที่นิยามจาก ความยาวส่วนโค้ง โดยตรง มุม 1 เรเดียนคือมุมที่ทำให้ส่วนโค้งยาวเท่ากับรัศมีพอดี ผลที่ตามมาคือความยาวส่วนโค้งเท่ากับมุมคูณรัศมี ตรง ๆ ไม่ต้องมีตัวแปลงหน่วยมาคั่น

ข้อนี้มีผลจริงกับโค้ดนี้ วงกลมหน้าตัดมีรัศมี R1 = 1 การเดินมุมทีละ 0.07 เรเดียนจึงแปลว่าเดินไปบนผิวจริงทีละ 0.070 หน่วย ถ้าใช้องศา ตัวเลข 0.07 จะไม่ได้บอกอะไรเกี่ยวกับระยะบนผิวเลย

ถ้าจุดเดินไปบนวงกลม มันกำลังเคลื่อนที่ไปทางไหน (ระดับแคลคูลัส)

กล่องนี้เกินระดับ ม.ต้น ข้ามไปได้โดยไม่กระทบอะไรกับการอ่านโค้ดครับ

ถ้าจุดของเราเดินบนวงกลมโดยที่มุมเพิ่มขึ้นเรื่อย ๆ ความเร็วของมัน ณ ขณะหนึ่ง คืออนุพันธ์ของตำแหน่งเทียบกับมุม

ประโยคที่ว่า (−sin θ, cos θ) คือ (cos θ, sin θ) ที่หมุนไป 90 องศา ฟังแล้วเหมือนต้องเชื่อตามไปก่อน แต่มันตรวจได้ด้วยของที่เพิ่งอ่านจบไปสองบรรทัด จุดที่มุม θ อยู่ที่ (cos θ, sin θ) ก็แปลว่าจุดที่มุม θ + 90° อยู่ที่ (cos(θ + 90°), sin(θ + 90°)) ตรงตามนิยามเดิมของวงกลม ไม่มีกฎใหม่

ทีนี้ลองเดินไปทีละมุมบนวงกลมดู มุม 0 อยู่ขวาสุด บวกไปอีก 90 องศาจะไปอยู่บนสุด นั่นคือ (1, 0) กลายเป็น (0, 1) มุม 90 อยู่บนสุด บวกอีก 90 ไปอยู่ซ้ายสุด คือ (0, 1) กลายเป็น (−1, 0) สองกรณีนี้เข้าสูตรเดียวกันพอดี ช่องแรกกลายเป็นช่องที่สองติดลบ ช่องที่สองกลายเป็นช่องแรก เขียนเป็นสูตรคือ (a, b) หมุนไป 90 องศาแล้วได้ (−b, a) เอา (cos θ, sin θ) ยัดเข้าไปก็ได้ (−sin θ, cos θ) ซึ่งคืออนุพันธ์ข้างบนเป๊ะ

ผลที่ตามมาคือ ทิศที่จุดกำลังเคลื่อนที่ ตั้งฉากกับรัศมีเสมอ เพราะการหมุนไป 90 องศาก็คือการทำให้ตั้งฉากนั่นเอง ลากมุมในของเล่นข้างล่างดูได้ครับ ลูกศรเหลืองที่งอกออกจากจุด กับลูกศรเหลืองจาง ๆ ที่ลากจากจุดกำเนิด เป็นลูกศรอันเดียวกันที่ย้ายที่มาวางเฉย ๆ และมันคือลูกศรรัศมีสีเขียวที่ถูกหมุนไปข้างหน้าอีกหนึ่งในสี่รอบ

ลูกศรเหลืองจาง ๆ ที่ลากจากจุดกำเนิดไปจบบนวงกลมเสมอ และจบที่มุมที่มากกว่ามุมของลูกศรเขียวอยู่ 90 องศาพอดีทุกค่าที่ลาก นี่คือความหมายของคำว่าหมุนไป 90 องศา

ข้อเท็จจริงตัวเดียวกันนี้จะกลับมาใช้อีกครั้งตอนหาสูตรหมุนในชั้นที่ 3 ตอนที่ต้องตอบว่าแกนที่สองไปโผล่ที่ไหน

ในตำราเรื่องนี้เรียกว่า เวกเตอร์สัมผัส (tangent vector) มันคือเหตุผลที่ของที่หมุนอยู่แล้วหลุดมือ จะเหวี่ยงออกไปด้านข้างตามแนวเส้นสัมผัส ไม่ใช่พุ่งออกตามแนวรัศมี และเป็นบันไดขั้นแรกไปสู่เรขาคณิตเชิงอนุพันธ์


ชั้นที่ 2 จาก 6 · วงกลมกลายเป็นโดนัท

ผ่าห่วงยางในแนวตั้งตรงไหนก็ได้ หน้าตัดที่ได้คือวงกลมวงหนึ่ง โดนัทคือวงกลมวงเดียวที่ถูกลากไปรอบแกนจนครบวง ชั้นนี้จึงมีสองขั้น เลื่อนวงกลมออกจากแกนก่อน แล้วค่อยกวาด

ก่อนอื่นต้องตกลงมุมมองกันก่อน เพราะชั้นนี้ต้องสลับไปมาระหว่างสองมุม

มองจากด้านข้าง แกนที่ใช้กวาด y x R2 เห็นเป็นวงกลม มองจากด้านบน แกนกลายเป็นจุด y x z R2 เห็นเป็นขีดสั้น
วงกลมหน้าตัดวงเดียวกัน มองจากด้านข้างเห็นเป็นวงกลม มองจากด้านบนเห็นเป็นขีดสั้น ทุกภาพต่อจากนี้จะบอกไว้ว่ากำลังมองจากมุมไหน

ขั้นแรกคือ เลื่อนวงกลมออกจากแกนก่อน ถ้าไม่เลื่อน จะได้ของคนละอย่างกันเลย

R2 = 0 คร่อมแกนอยู่ ตัน ไม่มีรู x z มองจากด้านบน R2 = 2 เลื่อนออกไปก่อน รู
ระยะ R2 คือสิ่งเดียวที่ตัดสินว่าจะได้ลูกบอลหรือได้ห่วง ถ้าวงกลมยังคร่อมแกนอยู่ กวาดไปเท่าไรก็ไม่มีรู

ค่าในโค้ดคือ R1 = 1 กับ R2 = 2 เขียนออกมาได้สองตัว ระยะจากแกน กับความสูง

ขั้นที่สองคือกวาดไปรอบแกน และตรงนี้คือหัวใจของทั้งชั้น

มองจากด้านบน x z แกน ปลายนอก ปลายใน φ รัศมีของวงนี้ คือ circlex ของปลายนอก วงในเล็กกว่า เพราะ circlex น้อยกว่า
circlex เข้าไปสวมบทบาทรัศมีของวงกลมในชั้นที่ 1 พอดี จุดที่ห่างแกนมากเดินวงใหญ่ จุดที่ห่างน้อยเดินวงเล็ก สูตรกวาดจึงเป็นสูตรเดิมที่เปลี่ยนรัศมี (ภาพนี้มองจากด้านบน แกน x กับแกน z อยู่ในระนาบของภาพ ส่วนแกน y ชี้ขึ้นออกมาหาคนดู)

ความสูงไม่เกี่ยวกับการกวาด จึงอยู่ที่เดิม ส่วนแกนที่สามโผล่มาตรงนี้เอง คือแกน z หรือแกนความลึก

มองจากด้านข้าง · เลือกด้วย θ x y θ = 0° θ = 90° θ = 200° มองจากด้านบน · เลือกด้วย φ x z φ = 0° φ = 110° φ = 250°
เลือก θ หนึ่งค่ากับ φ หนึ่งค่า ได้จุดบนผิวหนึ่งจุดเสมอ θ เลือกว่าอยู่ตรงไหนของหน้าตัด ส่วน φ เลือกว่าหน้าตัดนั้นถูกกวาดไปถึงไหนแล้ว

ทำไมคู่ 1 กับ 2 ถึงพอดีกับจอขนาดนี้

รูต้องใหญ่พอจะเห็นบนจอที่มีแค่ 80 ตัวอักษร ถ้า R1 โตขึ้นจนเท่าหรือเกิน R2 ขอบด้านในของหน้าตัดจะเลยแกนเข้าไปชนกันเอง รูหายไปเลย แล้วโดนัทจะกลายเป็นลูกฟักทองที่บุ๋มตรงกลาง ลากดูได้ในของเล่นข้างล่างครับ

เส้นประสองวงคือขอบนอกกับขอบในจากภาพเมื่อกี้ ลาก R2 ลงมาที่ 0 แล้วดูว่ารูหายไปตอนไหน
โค้ดของคุณตอนนี้ · ชั้นที่ 2 จาก 6
// ชั้นที่ 2: เลื่อนวงกลมออกไป R2 แล้วกวาดรอบแกน y ด้วยมุม phi
for (float theta = 0; theta < 6.28; theta += 0.07) {
  float circlex = 2 + 1 * cos(theta);   // <-- ใหม่: บวก R2
  float circley = 1 * sin(theta);

  for (float phi = 0; phi < 6.28; phi += 0.02) {  // <-- ใหม่: มุมที่สอง
    float x = circlex * cos(phi);
    float y = circley;
    float z = circlex * sin(phi);
  }
}
แก้สองบรรทัดนี้ แล้วได้ทรงอื่นทั้งตระกูล

โค้ดทั้งไฟล์ไม่มีตรงไหนที่รู้จักคำว่าโดนัทเลย มันรู้แค่ว่า มุมสองตัวแปลงเป็นจุดในสามมิติได้ยังไง ถ้าเปลี่ยนสองบรรทัดนั้น ทั้งเรื่องแสง เรื่องการบัง เรื่องการฉายภาพ ทำงานต่อได้เหมือนเดิมทุกอย่าง

กดสลับทรงดูได้เลยครับ ภาพ บรรทัดตั้งค่า และโค้ดจะเปลี่ยนพร้อมกันทั้งสามอย่าง

ทุกทรงใช้ตัวเรนเดอร์ตัวเดียวกับโดนัทในหน้านี้ ต่างกันแค่สูตรตำแหน่งกับสูตรทิศตั้งฉาก
โค้ดของคุณตอนนี้ · โดนัท
// ชั้นที่ 2: เลื่อนวงกลมออกไป R2 แล้วกวาดรอบแกน y ด้วยมุม phi
for (float theta = 0; theta < 6.28; theta += 0.07) {
  float circlex = 2 + 1 * cos(theta);   // <-- ใหม่: บวก R2
  float circley = 1 * sin(theta);

  for (float phi = 0; phi < 6.28; phi += 0.02) {  // <-- ใหม่: มุมที่สอง
    float x = circlex * cos(phi);
    float y = circley;
    float z = circlex * sin(phi);
  }
}

จุดที่พังง่ายที่สุดคือทิศตั้งฉาก เพราะสูตรทางลัดในบทนี้อาศัยความสมมาตรของโดนัทโดยเฉพาะ ทรงกลมยังใช้ทางลัดเดิมได้ ทรงกระบอกเปลี่ยนเป็นสูตรสั้น ๆ ของตัวเอง ส่วนเมอบิอุสหาด้วยทางลัดไม่ได้เลย ต้องครอสโปรดักต์ของอนุพันธ์ย่อยสองทิศ ซึ่งเป็นวิธีทั่วไปที่ตำรากราฟิกใช้กับผิวทุกชนิด

ทำไมจุดถึงกระจุกกันแน่นด้านในรู แต่ห่างกันด้านนอก (ระดับแคลคูลัส)

กล่องนี้เกินระดับ ม.ต้น ข้ามได้ครับ

ลูปเดินมุม φ ทีละเท่ากันตลอด แต่ระยะจริงบนผิวที่เดินได้ไม่เท่ากัน เพราะระยะจากแกนกลางไม่เท่ากัน ขอบด้านนอกอยู่ห่างแกน R2 + R1 = 3 ส่วนขอบด้านในรูอยู่ห่างแค่ R2 − R1 = 1

เดินมุมเท่ากันหนึ่งก้าวคือ 0.02 เรเดียน ขอบนอกจึงเดินได้ ส่วนขอบในเดินได้แค่ ต่างกัน 3 เท่า จุดจึงกองกันแน่นกว่าที่ขอบใน

ในตำราเรื่องนี้เรียกว่า จาโคเบียน (Jacobian) คือตัวคูณที่บอกว่าพื้นที่ในโลกของมุม ถูกยืดหรือหดไปเท่าไรเมื่อแปลงเป็นพื้นที่จริงบนผิว เดินต่อจากตรงนี้จะเจอเรื่องการสุ่มจุดบนผิวให้กระจายเท่ากัน ซึ่งเป็นปัญหาพื้นฐานของงานกราฟิกสายเรนเดอร์ด้วยการสุ่ม

ถึงตรงนี้เรายังไม่ได้วาดอะไรลงจอเลยสักตัว เรากำลังทำแค่รายชื่อจุดที่ลอยอยู่กลางอากาศ


ชั้นที่ 3 จาก 6 · หมุนทั้งก้อน

ชั้นที่ 2 ทำให้เรามีจุดครบทุกจุดบนผิวแล้ว แต่ทั้งก้อนยังลอยนิ่งอยู่กับที่ ถ้าจะหมุนมัน ต้องตอบคำถามเดียวให้ได้ก่อน จุดหนึ่งจุด พอถูกหมุนแล้วไปอยู่ตรงไหน เครื่องมือที่ตอบคำถามนี้มีสองชิ้น ต้องยืมมาก่อนถึงจะเขียนสูตรหมุนได้

ของชิ้นแรก ลูกศรที่เรียกว่าเวกเตอร์

เวกเตอร์ (vector) คือ ลูกศร มีทิศกับความยาว จบ บวกกันคือเดินตามอันแรกแล้วต่อด้วยอันที่สอง คูณด้วยเลขคือยืดหรือหด ส่วนลูกศรที่ยาว 1 พอดีเรียกว่า เวกเตอร์หนึ่งหน่วย คือลูกศรที่เก็บแต่ทิศ

ของที่ตำราไทยแทบไม่พูดคือ จุดกับทิศเป็นคนละเรื่องกัน

ไฟดวงที่ 1 ไฟดวงที่ 2 ไฟดวงที่ 3 x y ผิวสว่างเท่านี้ ผิวก็ยังสว่างเท่าเดิม #$@$# #$@$#
ไฟสามดวงวางคนละที่ แต่แสงมาถึงผิวในทิศเดียวกัน ผิวจึงสว่างเท่ากันทั้งสามกรณี ทิศเป็นของที่ไม่ต้องมีที่อยู่ (มองจากด้านข้าง แกน x นอน แกน y ตั้ง)

ของชิ้นที่สอง เมทริกซ์

ก่อนจะไปดูว่ามันคืออะไร ขอวางปัญหาที่มันเกิดมาแก้ก่อนครับ โค้ดนี้หมุนโดนัทสองรอบต่อกัน รอบแกน x ด้วยมุม A แล้วรอบแกน z ด้วยมุม B ถ้าเราไม่มีอะไรเลยนอกจากสูตรสองบรรทัดจากชั้นที่ 1 เราต้องเขียนพิกัดหลังหมุนรอบแรกออกมาเป็นตัวหนังสือ แล้วเอาผลนั้นไปแทนลงในสูตรของรอบที่สองด้วยมือ พอทำเสร็จได้พจน์ยาว ๆ มาชุดหนึ่ง แล้ววันไหนอยากสลับลำดับหรือเพิ่มการหมุนรอบที่สาม ก็ต้องนั่งแทนค่าใหม่ทั้งชุดอีกรอบ

ปัญหาที่หนักกว่านั้นคือปริมาณ โดนัทหนึ่งเฟรมมีจุดที่ต้องหมุน 28,350 จุด (ที่มาของเลขตัวนี้อยู่ในชั้นที่ 5) การหมุนชุดเดิมถูกใช้ซ้ำกับทุกจุด ของที่เราอยากได้จึงเป็นวิธีเก็บ "การหมุนชุดนี้" ไว้เป็นก้อนเดียว คิดครั้งเดียว แล้วเอาไปใช้กับจุดทั้งกองซ้ำ ๆ โดยไม่ต้องแทนค่าใหม่ทุกครั้ง

ก่อนจะไปดูหน้าตาของมัน ขอเดินไปหามันสามช่องครับ ช่องแรกเขียนจุดหนึ่งจุดใหม่ให้เป็น การเดินตามแกน จุด (2, 1) คือเดินตามแกนแรก 2 ก้าว แล้วเดินตามแกนที่สองอีก 1 ก้าว ช่องที่สองหมุนทั้งระนาบ ลูกศรของแกนทั้งสองย้ายไปที่ใหม่ แต่ จำนวนก้าวยังเป็น 2 กับ 1 เท่าเดิม เพราะจุดนั้นถูกหมุนไปพร้อมแกนทั้งแผ่น ช่องที่สามจึงเหลืองานเดียว คือจดปลายทางของสองแกนลงตาราง เพราะจำนวนก้าวไม่เปลี่ยน รู้แค่ปลายทางของแกนก็ประกอบจุดใหม่ได้ทุกจุด

1 · จุดคือจำนวนก้าว (2, 1) 2 ก้าว 1 ก้าว 2 · หมุนแกน ไม่หมุนจำนวนก้าว จุดเดิมหลังหมุน ยัง 2 ก้าว ยัง 1 ก้าว 3 · จดปลายทางของสองแกน แกนแรก แกนที่สอง 0.87 -0.50 0.50 0.87 ตารางใบนี้คือเมทริกซ์ ตัวเลขชุดนี้คือกรณีหมุน 30 องศา เขียนจุดใหม่เป็นก้าว แกนย้าย ก้าวไม่ย้าย เก็บแค่ปลายทางก็พอ
จำนวนก้าวไม่เคยเปลี่ยน เปลี่ยนแค่ว่าแกนที่ก้าวตามนั้นย้ายไปอยู่ที่ไหน ตารางสองคอลัมน์จึงเก็บการหมุนทั้งชุดไว้ได้ครบ

เมทริกซ์ไม่ใช่ตารางตัวเลข มันคือใบบันทึกว่าแกนเดิมไปโผล่ที่ไหนหลังการแปลง

M = (
)

คอลัมน์ซ้ายสีเขียวคือปลายทางของแกน x คอลัมน์ขวาสีเหลืองคือปลายทางของแกน y กดปุ่มลัดแล้วดูว่าตัวเลขในเมทริกซ์เปลี่ยนตามลูกศรยังไง

ที่ต้องเห็นให้ได้คือ คอลัมน์แรกคือตำแหน่งใหม่ของแกน x คอลัมน์ที่สองคือของแกน y ที่เหลือตามมาเอง เพราะการคูณเมทริกซ์กับเวกเตอร์คือ การผสมคอลัมน์ตามน้ำหนัก

ถ้าคุณเคยเรียนเมทริกซ์มาแล้ว ตรงนี้อาจสะดุด เพราะในห้องเรียนเราคูณแบบ แถวคูณคอลัมน์ คือไล่ทีละช่องผลลัพธ์ เอาแถวของเมทริกซ์คูณกับเวกเตอร์แล้วบวกกัน ส่วนบทความนี้พูดว่า ผสมคอลัมน์ตามน้ำหนัก สองอันนี้คือการคูณอันเดียวกันเป๊ะ ต่างกันแค่ลำดับที่เราเก็บตัวเลข วิธีแรกอ่านตามแนวนอนทีละช่องผลลัพธ์ วิธีที่สองอ่านตามแนวตั้งทีละก้าว พอบวกครบทั้งสองวิธีก็ได้เลขชุดเดียวกัน

แบบที่เรียนมา · แถวคูณคอลัมน์ แบบที่บทความนี้ใช้ · ผสมคอลัมน์ M = (1 2 ; 3 4) v = (1, 2) M = (1 2 ; 3 4) v = (1, 2) 1*1 + 2*2 = 5 3*1 + 4*2 = 11 หยิบทีละแถว ได้ผลลัพธ์ทีละช่อง 1*(1, 3) = (1, 3) 2*(2, 4) = (4, 8) เดินตามคอลัมน์แรก 1 ก้าว ตามคอลัมน์ที่สอง 2 ก้าว (5, 11) (1+4, 3+8) = (5, 11) ผลลัพธ์เดียวกัน
สองวิธีนี้หยิบตัวเลขคนละลำดับ แต่บวกครบแล้วได้ตัวเลขชุดเดียวกัน วิธีผสมคอลัมน์ถูกเลือกมาเล่าเพราะมันตอบตรง ๆ ว่าแกนเดิมไปโผล่ที่ไหน
แกนที่ 1 แกนที่ 2
แกนที่ 1 แกนที่ 2
แกนที่ 1 แกนที่ 2
แกนที่ 1 แกนที่ 2
แกนที่ 1 แกนที่ 2
แกนที่ 1 แกนที่ 2

กดปุ่มหมุน 30 องศา แล้วเลขสี่ตัวนั้นมาจากไหน

ปุ่มลัดในของเล่นเปลี่ยนตัวเลขให้เลย ซึ่งดูเหมือนเวทมนตร์ ทั้งที่มันคือการตอบคำถามสองข้อเท่านั้น แกน x ไปโผล่ที่ไหน และ แกน y ไปโผล่ที่ไหน ทั้งสองข้อตอบด้วยสูตรวงกลมจากชั้นที่ 1 ล้วน ๆ กดเดินทีละขั้นดูได้ครับ ขั้นสุดท้ายคือการตรวจคำตอบด้วยวิธีที่ไม่ใช้เมทริกซ์เลย

ตอนนี้ตอบคำถามที่ค้างไว้ตอนต้นหัวข้อได้แล้ว ที่เมทริกซ์ช่วยคือ มันแยกงานสองอย่างออกจากกัน งานแรกคือคิดว่าการหมุนชุดนี้พาแกนไปไหน ซึ่งคิดครั้งเดียวต่อเฟรม งานที่สองคือเอาผลนั้นไปใช้กับจุดแต่ละจุด ซึ่งเหลือแค่การคูณกับบวก โค้ดจริงก็ทำแบบนี้ มันคำนวณไซน์กับโคไซน์ของ A และ B ไว้ก่อนนอกลูป แล้วในลูปเหลือแต่การคูณกับบวกล้วน ๆ

ครั้งเดียวต่อเฟรม · นอกลูป ทุกจุด · ในลูป คิดไซน์กับโคไซน์ของ A และ B cosA=0.540 sinA=0.841 cosB=0.825 sinB=0.565 เมทริกซ์หนึ่งใบ 9 ช่อง เก็บการหมุนทั้งชุดไว้ครบ 4 ครั้งต่อเฟรม จุดหนึ่งจุดจากชั้นที่ 2 (0.194, 0.516, 1.126) คูณ 9 ครั้ง บวก 6 ครั้ง จุดเดิมหลังหมุนด้วย A แล้ว B (0.538, -0.443, 1.042) ทำซ้ำแบบนี้ 28,350 รอบ ด้วยเมทริกซ์ใบเดิม ไม่มีไซน์โคไซน์เหลืออยู่ในลูปเลย
ฝั่งซ้ายทำครั้งเดียวต่อเฟรม ฝั่งขวาทำซ้ำ 28,350 ครั้ง เมทริกซ์คือเส้นแบ่งระหว่างสองฝั่งนี้ ของที่คิดครั้งเดียวจึงไม่ไหลเข้าไปอยู่ในลูป

แกะคำศัพท์

เมทริกซ์ (matrix) อ่านว่า "เมทริกซ์" มาจากละติน matrix ที่แปลว่า แม่พิมพ์ หรือมดลูก คือของที่ใช้ปั๊มของอย่างอื่นออกมา ซึ่งตรงกับหน้าที่ของมันพอดี คือเป็นตัวแปลงที่รับของเข้าไปแล้วปั๊มของใหม่ออกมา

พอรู้สองอย่างนี้ สูตรหมุนก็ไม่ต้องท่อง

เริ่มจากคอลัมน์แรกก่อน แกน x คือลูกศรยาว 1 ที่ชี้ไปทางขวา ซึ่งก็คือจุดบนวงกลมรัศมี 1 ที่มุม 0 พอดี พอเห็นว่ามันเป็นจุดบนวงกลมอยู่แล้ว การหมุนมันไป θ จึงแปลว่าเลื่อนมันไปที่มุม θ ตรง ๆ คือ (cos θ, sin θ) ตามสูตรวงกลมของชั้นที่ 1 ลองกดปุ่มหมุน 30 องศาข้างบน แล้วเทียบคอลัมน์แรกกับ cos 30° = 0.87 และ sin 30° = 0.50 ดูครับ

คอลัมน์ที่สองใช้ข้อเท็จจริงอีกข้อ คือ แกน y ก็คือแกน x ที่หมุนไปแล้ว 90 องศา พอหมุนทั้งระนาบไปอีก θ มันจึงไปนั่งที่มุม θ บวก 90 ซึ่งบนวงกลมวงเดิมคือ (−sin θ, cos θ) ข้อนี้ไม่ต้องใช้แคลคูลัสเลย ใครอยากเห็นเป็นภาพ ของเล่นเส้นสัมผัสในชั้นที่ 1 แสดงไว้ให้แล้ว

เอาสองคอลัมน์นี้ไปผสมตามน้ำหนัก ก็ได้สูตรหมุนที่จดไปใช้ได้เลยสองบรรทัด ขอตั้งชื่อจุดหลังหมุนว่า x' กับ y' อ่านว่า เอ็กซ์ไพรม์ กับ วายไพรม์ ขีดเดียวข้างบนแปลว่า ตัวเดิมที่ผ่านการหมุนมาแล้ว ส่วน x กับ y เปล่า ๆ คือจุดก่อนหมุน

บรรทัดบนคือ เดินตามคอลัมน์แรก x ก้าว แล้วเดินตามคอลัมน์ที่สองอีก y ก้าว แล้วอ่านเฉพาะค่าตามแนวนอน บรรทัดล่างคืออันเดียวกันแต่อ่านตามแนวตั้ง เครื่องหมายลบบรรทัดบนมาจาก −sin θ ในคอลัมน์ที่สอง ไม่ได้มาจากที่ไหนอื่น

เหลืออีกสองข้อที่ต้องรู้ก่อนไปเจอสูตรจริง ข้อแรก ลำดับการหมุนสลับกันไม่ได้ ข้อสอง การหมุนไม่ยืดไม่หดอะไรเลย ระยะทุกระยะกับมุมทุกมุมเท่าเดิม ซึ่งเป็นใบอนุญาตให้เราหมุนทิศตั้งฉากของผิวตามไปด้วยแบบดื้อ ๆ ในชั้นที่ 6

หมุน A ก่อน แล้วค่อย B

หมุน B ก่อน แล้วค่อย A

มุมสองตัวเท่ากันทั้งสองฝั่ง ต่างกันแค่ว่าใครมาก่อน ตัวเลขใต้ภาพคือเมทริกซ์ที่ได้จริงของแต่ละฝั่ง
สองข้อที่ต้องพิสูจน์ก่อนจะเชื่อสูตรหมุน

ข้อแรก พิกัดใหม่หลังหมุนมาจากไหน

ขอปักหมุดก่อนว่ากล่องนี้ยืนอยู่บนอะไร ของที่เราถือว่าจริงมีข้อเดียว คือ จุดที่มุม θ บนวงกลมรัศมี 1 อยู่ที่ (cos θ, sin θ) ซึ่งเป็นนิยามของโคไซน์กับไซน์ตั้งแต่ชั้นที่ 1 ทุกอย่างที่เหลือในกล่องนี้งอกออกมาจากข้อเดียวนี้ รวมทั้งสูตรผลรวมมุมที่หลายคนเคยท่องด้วย

เอาเรื่องแกนไปโผล่ที่ไหนมาเขียนเป็นสูตร จุด (x, y) คือเดินตามแกนแรก x ก้าว แล้วเดินตามแกนที่สอง y ก้าว พอหมุนไป θ แกนแรกย้ายไปอยู่ที่ (cos θ, sin θ) ตรงตามของที่เราถือว่าจริง ส่วนแกนที่สองคือแกนแรกที่หมุนไปแล้วอีก 90 องศา ซึ่งชั้นที่ 1 แสดงให้เห็นด้วยของเล่นไปแล้วว่ามันคือ (−sin θ, cos θ) เดินสูตรเดิมบนแกนใหม่ทั้งสอง ก็ได้พิกัดใหม่ออกมา

ทีนี้เอาเครื่องมือชิ้นนี้ไปหาสูตรผลรวมมุม วางจุดที่มุม β บนวงกลมรัศมี 1 พิกัดของมันคือ (cos β, sin β) แล้วหมุนทั้งระนาบไปอีก α ด้วยกฎที่เพิ่งเขียน จะได้พิกัดใหม่ชุดหนึ่ง แต่จุดนั้นก็คือจุดที่มุม α + β บนวงกลมวงเดิม ซึ่งพิกัดของมันคือ (cos(α + β), sin(α + β)) ตามของที่เราถือว่าจริงอีกที จับสองฝั่งมาเทียบทีละแกน ก็ได้สองบรรทัดนี้

x y จุดที่มุม β (cos β, sin β) จุดเดิมหลังถูกหมุน = จุดที่มุม α + β หมุนไปอีก α แกนแรกไปโผล่ที่ (cos α, sin α) แกนที่สองไปโผล่ที่ (−sin α, cos α)
จุดจุดเดียวถูกเรียกชื่อสองแบบ แบบแรกคือจุดที่มุม β ที่ถูกหมุนไปอีก α แบบที่สองคือจุดที่มุม α + β บนวงกลมวงเดิม เทียบสองชื่อนี้ทีละแกน ก็ได้สูตรผลรวมมุมออกมา

สังเกตทิศทางของกล่องนี้ให้ดี เราเดินจากกฎที่ว่าแกนไปโผล่ที่ไหน ไปหาสูตรผลรวมมุม ไม่ใช่ทางกลับกัน แปลว่า สูตรผลรวมมุมไม่ใช่ของที่ต้องยืมมาก่อนถึงจะหมุนเป็น มันเป็นผลพลอยได้ของการหมุน ใครท่องมันมาตั้งแต่มัธยมโดยไม่รู้ว่ามาจากไหน คำตอบอยู่ตรงนี้

ลองด้วยตัวเลขจริงที่ไม่ตกมุมพิเศษ ใช้ α = 30° กับ β = 20°

  • บรรทัดโคไซน์ ฝั่งซ้าย cos 50° = 0.6428 ฝั่งขวา cos30 cos20 − sin30 sin20 = 0.8660 × 0.9397 − 0.5000 × 0.3420 = 0.8138 − 0.1710 = 0.6428
  • บรรทัดไซน์ ฝั่งซ้าย sin 50° = 0.7660 ฝั่งขวา sin30 cos20 + cos30 sin20 = 0.5000 × 0.9397 + 0.8660 × 0.3420 = 0.4698 + 0.2962 = 0.7660

ที่ต้องเลี่ยงคือคู่ที่บวกกันได้ 90 พอดี เช่น 30 กับ 60 เพราะทั้งสองฝั่งจะเป็นศูนย์ แล้วสูตรที่ผิดก็ผ่านการตรวจแบบนั้นได้เหมือนกัน ลองสลับลำดับพจน์เป็น sin α sin β − cos α cos β ดูก็ได้ครับ ที่คู่ 30 กับ 60 มันยังได้ศูนย์อยู่ดี แต่ที่คู่ 30 กับ 20 มันได้ 0.1710 − 0.8138 = −0.6428 ซึ่งผิดทันที


ข้อที่สอง หมุนแล้วทำไมความยาวถึงไม่เปลี่ยน

ข้อนี้เป็นใบอนุญาตให้เราหมุนทิศตั้งฉากของผิวตามตัวโดนัทไปดื้อ ๆ ในชั้นที่ 6 โดยไม่ต้องคำนวณอะไรใหม่ พิสูจน์ตรง ๆ ได้จากสูตรหมุนที่เพิ่งได้มา

พจน์ไขว้ตัดกันหมดพอดี เหลือแค่ cos²θ + sin²θ ซึ่งเท่ากับ 1 จากกล่องพีทาโกรัสในชั้นที่ 1 ระยะจากจุดกำเนิดจึงเท่าเดิมทุกจุด และเมื่อระยะทุกระยะเท่าเดิม มุมระหว่างสองทิศก็ต้องเท่าเดิมด้วย

ข้อควรระวังคือมันจริงเฉพาะกับการหมุน ถ้าเผลอใส่การยืดที่ไม่เท่ากันทุกแกนเข้าไปด้วย ทิศที่เคยตั้งฉากกับผิวจะเลิกตั้งฉาก แล้วแสงจะเพี้ยนทั้งภาพ นี่คือกับดักคลาสสิกของงานสามมิติ

การหมุนยังมีภาษาอื่นให้พูดอีกหลายภาษา (ระดับมหาวิทยาลัย)

กล่องนี้เป็นทางแยกออกจากบทความ ไม่ต้องอ่านก็เขียนโดนัทได้ครับ

เมทริกซ์เป็นแค่ภาษาหนึ่งที่ใช้พูดถึงการหมุน ยังมีภาษาอื่นที่พูดเรื่องเดียวกัน การคูณด้วยจำนวนเชิงซ้อน cos θ + i sin θ ก็คือการหมุนในระนาบ และถ้าคูณสองตัวเข้าด้วยกัน มุมจะบวกกันเอง ซึ่งก็คือสูตรผลรวมมุมในกล่องข้างบนนั่นเอง

พอขึ้นสามมิติ ภาษาที่วงการเกมใช้จริงคือ ควอเทอร์เนียน (quaternion) เพราะเก็บการหมุนด้วยเลขสี่ตัว ต่อการหมุนเข้าด้วยกันได้ถูกกว่าเมทริกซ์ และไม่เจอปัญหาแกนล็อกกันเองแบบที่การหมุนสามมุมต่อกันเจอ

เดินต่อจากตรงนี้ในตำราจะเจอคำว่า กลุ่มลี (Lie group) กับรูปเลขชี้กำลังของเมทริกซ์ ซึ่งเป็นวิธีมองการหมุนว่าเป็นการสะสมของการหมุนเล็ก ๆ ต่อกัน และเรื่องนั้นจะกลับมาโผล่อีกครั้งในหัวข้อของแถมท้ายบท

สูตรหมุนที่อยู่ในโค้ดจริง

โค้ดจริงหมุนสองรอบ คือรอบแกน x ด้วยมุม A แล้วรอบแกน z ด้วยมุม B ทุกเฟรมมันบวก A เพิ่ม 0.04 และ B เพิ่ม 0.02 โดนัทเลยหมุนแบบส่ายไปด้วยแทนที่จะหมุนอยู่แกนเดียว

เส้นสามเส้นที่ลากผ่านกลางโดนัทคือแกนทั้งสาม เขียวคือแกน x ม่วงคือแกน y เหลืองคือแกน z พอขยับมุมไหน แกนที่มุมนั้นหมุนรอบจะสว่างขึ้นพร้อมเครื่องหมาย ↻ ลากบนภาพเพื่อหมุนก็ได้ครับ ลากขึ้นลงคือหมุนรอบแกน x ลากซ้ายขวาคือหมุนรอบแกน z

ทีนี้เขียนการหมุนสองรอบนี้ออกมาเป็นเมทริกซ์จริง ๆ ด้วยคำถามเดิมข้อเดียว แกนแต่ละแกนไปโผล่ที่ไหน เริ่มจากจุดที่เราได้มาจากชั้นที่ 2 เขียนเป็นเวกเตอร์คอลัมน์ก่อน ขอเรียกจุดตั้งต้นจุดนี้ว่า p และเรียกพิกัดสามตัวของมันว่า x₀, y₀, z₀ เลขศูนย์ห้อยท้ายแปลว่ายังไม่ผ่านการหมุนอะไรเลย

ตรงนี้มีการกระโดดหนึ่งจังหวะที่ต้องพูดให้ชัด ของทุกอย่างที่เล่ามาเป็นตาราง 2 คูณ 2 ในระนาบเดียว แต่โดนัทอยู่ในสามมิติ ท่าที่ใช้ข้ามคือ หมุนรอบแกนหนึ่ง ก็คืออีกสองแกนหมุนกันเองในระนาบของมัน ด้วยเมทริกซ์ 2 คูณ 2 ตัวเดิมที่เพิ่งเรียนไป แล้วเอาตารางเล็กใบนั้นฝังลงในตาราง 3 คูณ 3 ถ้าหลับตามองทะลุลงไปตามแกน x ภาพที่เห็นคือรูปสองมิติอันเดิมเป๊ะ มีแค่ชื่อแกนที่เปลี่ยนจาก x กับ y เป็น y กับ z

มองทะลุลงไปตามแกน x แกน x พุ่งเข้าหาตา y z มุม A ภาพนี้คือรูปสองมิติอันเดิม ฝังลงในตาราง 3 คูณ 3 1 0 0 0 cos A -sin A 0 sin A cos A แถวกับคอลัมน์ของ x คือ 1 กับ 0 ล้วน กรอบเขียวคือตาราง 2 คูณ 2 ใบเดิม
แถวกับคอลัมน์ของแกนที่อยู่นิ่งเป็น 1 กับ 0 ล้วน เพราะแกนนั้นไม่ได้ไปไหนและไม่มีใครเดินตามมันเพิ่ม ที่เหลือคือตาราง 2 คูณ 2 ใบเดิมที่ฝังอยู่ตรงกลาง

การหมุนรอบแกน x ด้วยมุม A ไม่แตะแกน x เลย มันจึงยังอยู่ที่เดิม นั่นคือคอลัมน์แรก ส่วนแกน y กับแกน z หมุนกันเองในระนาบที่เหลือ ด้วยสูตรวงกลมชุดเดิมเป๊ะ แกน y ไปโผล่ที่ (cos A, sin A) ของระนาบนั้น แล้วแกน z คือแกน y ที่หมุนต่อไปอีก 90 องศา จึงไปโผล่ที่ (−sin A, cos A) จดสามคอลัมน์นี้ลงตาราง ได้ใบบันทึกของการหมุนรอบแกน x ขอเรียกใบนี้ว่า R ห้อย A ตามชื่อมุมของมัน

การหมุนรอบแกน z ด้วยมุม B เล่าด้วยประโยคเดียวกันทุกคำ แค่ย้ายแกนที่อยู่นิ่งจาก x ไปเป็น z ใบบันทึกของมันเรียกว่า R ห้อย B

เอาสองใบมาต่อกัน หมุน A ก่อน แล้วค่อย B เขียนเป็นการคูณเมทริกซ์สองใบ ได้ใบเดียวที่เก็บการหมุนทั้งชุดไว้ครบ

ลำดับตรงนี้ชวนสะดุดตา เนื้อความบอกว่า A มาก่อน แต่ในสมการ R ห้อย B กลับไปอยู่ซ้ายมือ เหตุผลคือเวกเตอร์ของจุดถูกวางไว้ขวาสุดเสมอ การคูณจึงเดินจากขวาไปซ้าย ใบที่ติดกับจุดมากที่สุดคือใบที่ทำงานก่อน ตัวที่ทำทีหลังจึงอยู่ซ้าย อ่านออกเสียงว่า เอาจุดไปเข้าใบ A ก่อน แล้วค่อยเอาผลไปเข้าใบ B

ถึงตรงนี้เหลืองานเดียว คือเอาใบนี้ไปคูณกับเวกเตอร์คอลัมน์ของจุด ซึ่งแปลว่าผสมสามคอลัมน์ตามน้ำหนัก x₀, y₀, z₀ เหมือนที่เดินไปทีละก้าวในตัวเดินขั้นข้างบน

คูณออกมาแล้วจัดพจน์ที่มี circlex ไว้ด้วยกัน ก็ได้บรรทัดยาวสามบรรทัดที่ทำให้คนปิดแท็บกันมาสิบกว่าปี

เทียบดูได้ตรง ๆ ครับ แถวบนของเมทริกซ์คือ cos B, −sin B cos A, sin B sin A เอาไปคูณกับ circlex cos φ, circley, circlex sin φ ตามลำดับ แล้วบวกกัน ก็คือบรรทัด x ข้างบนเป๊ะ แถวล่างสุดว่างไปหนึ่งช่อง เพราะการหมุนรอบแกน z ไม่ยุ่งกับความลึกเลย บรรทัด z จึงสั้นกว่าเพื่อน

มันยาวก็จริง แต่ไม่มีอะไรใหม่อยู่ในนั้นเลยแม้แต่ตัวเดียว ทุกพจน์คือ circlex หรือ circley จากชั้นที่ 2 คูณกับไซน์หรือโคไซน์ของมุมหมุน มันยาวเพราะเราคูณเมทริกซ์ออกมาแล้วเขียนผลลัพธ์แบบกางออก ไม่ใช่เพราะมันซับซ้อน

อยากเห็นการคูณกางออกทีละบรรทัด แทนที่จะเป็นตารางเดียว

กล่องนี้เดินเรื่องเดียวกับข้างบน แต่ไม่ใช้ตารางเลย ใช้การแทนค่าตัวหนังสือทีละรอบแทน ทุกขั้นมีแค่การแทนค่าของที่รู้อยู่แล้วกลับเข้าไป ไม่มีกฎใหม่เพิ่ม

ตั้งต้น มุมสองตัวที่หยิบมาหนึ่งจุด θ = 2.600 · φ = 1.400
ชั้นที่ 1 จุดบนวงกลมหน้าตัด circlex = 1.143 · circley = 0.516
ชั้นที่ 2 กวาดรอบแกนแล้วได้แกนที่สาม (0.194, 0.516, 1.126)
ชั้นที่ 3 หมุนด้วย A แล้ว B แล้วถอยออกไป K2 (0.538, -0.443, 6.042)
ชั้นที่ 4 หารด้วยระยะ แล้วปัดลงช่องบนจอ 1/z = 0.165 · ช่อง (42, 13)
ชั้นที่ 5 เทียบความลึกกับของเดิมในช่องนั้น ช่องยังว่าง จึงได้ที่ไป
ชั้นที่ 6 ความสว่าง แล้วแปลงเป็นตัวอักษร L = 0.757 · ลำดับที่ 6 · "="

จุดเดียวจากทั้งหมด 28,350 จุดในเฟรมนั้น เดินครบทั้งท่อตั้งแต่มุมสองตัวจนกลายเป็นตัวอักษรหนึ่งตัว

มีของแปลกอยู่ตัวเดียวในบรรทัด z คือเลข K2 = 5 ที่โผล่มาบวกอยู่ข้างหน้า มันคือระยะที่เราดันโดนัททั้งใบให้ถอยห่างออกไปจากตา เหตุผลอยู่ในชั้นถัดไป

โค้ดของคุณตอนนี้ · ชั้นที่ 3 จาก 6
// ชั้นที่ 3: หมุนทั้งก้อนด้วยมุม A (รอบแกน x) และ B (รอบแกน z)
float sinA = sin(A), cosA = cos(A), sinB = sin(B), cosB = cos(B);

for (float theta = 0; theta < 6.28; theta += 0.07) {
  float circlex = 2 + 1 * cos(theta);
  float circley = 1 * sin(theta);

  for (float phi = 0; phi < 6.28; phi += 0.02) {
    float cosPhi = cos(phi), sinPhi = sin(phi);

    // <-- ใหม่ทั้งสามบรรทัด: พิกัดหลังหมุนแล้ว
    float x = circlex * (cosB * cosPhi + sinA * sinB * sinPhi) - circley * cosA * sinB;
    float y = circlex * (sinB * cosPhi - sinA * cosB * sinPhi) + circley * cosA * cosB;
    float z = 5 + cosA * circlex * sinPhi + circley * sinA;
  }
}

ถึงตรงนี้โดนัทมีจริงแล้ว หมุนได้แล้ว แต่ยังไม่มีใครมองเห็นมัน เพราะเรายังไม่ได้ตัดสินใจเลยว่าจะมองมันจากตรงไหน


ชั้นที่ 4 จาก 6 · ของไกลต้องดูเล็ก

จอเทอร์มินัลเป็นตาราง 80 คูณ 22 ช่อง มันแบน มีแค่สองแกน ส่วนโดนัทของเรามีสามแกน ต้องมีวิธีบีบสามให้เหลือสอง และวิธีนั้นต้องบีบแล้วยังดูเป็นสามมิติอยู่

ยืนห่างจากเสาไฟฟ้าต้นหนึ่งสิบเมตร แล้วเดินถอยหลังไปอีกจนห่างยี่สิบเมตร เสาต้นเดิมสูงเท่าเดิมทุกประการ แต่มันดูเตี้ยลงครึ่งหนึ่ง ระยะเพิ่มเป็นสองเท่า ขนาดที่เห็นลดลงครึ่งหนึ่ง คือมันแปรผกผันกันตรง ๆ

เหตุผลคือสามเหลี่ยมคล้าย ลากเส้นจากตาไปยังยอดเสา เส้นนั้นจะพาดผ่านจอที่อยู่ตรงหน้าเราที่ความสูงหนึ่ง ยิ่งเสาอยู่ไกล เส้นยิ่งเอียงน้อย จุดที่มันตัดจอยิ่งต่ำลง สามเหลี่ยมสองรูปคือรูปเล็กจากตาถึงจอ กับรูปใหญ่จากตาถึงเสา มันคล้ายกัน อัตราส่วนด้านจึงเท่ากัน ผลลัพธ์คือหารด้วยระยะ

z ยิ่งขวายิ่งไกลตา y ตา จอ เสาต้นใกล้ เสาต้นไกล ระยะของต้นใกล้ ระยะของต้นไกล ภาพของต้นใกล้ สูงกว่า ภาพของต้นไกล เตี้ยกว่า
เสาสองต้นสูงเท่ากันเป๊ะ ต่างกันแค่ระยะ ภาพบนจอของต้นที่ไกลกว่าจึงสั้นกว่าตามอัตราส่วนของสามเหลี่ยมสองรูป

การหารด้วยระยะคือทั้งหมดของคำว่าเปอร์สเปกทีฟ เกมสามมิติทุกเกมที่คุณเคยเล่นใช้บรรทัดนี้บรรทัดเดียวกัน

ของเล่นชิ้นนี้ซูมเข้าตามระยะให้ขนาดบนจอคงที่ คุณจะได้เห็นรูปทรงเปลี่ยน ไม่ใช่แค่ของเล็กลง ลองดัน K2 ออกไปไกล ๆ แล้วเทียบกับตอนปิดสวิตช์

เขียนออกมาเป็นสูตรได้สองบรรทัด ขอเรียกช่องที่จุดไปตกบนจอว่า xp กับ yp ตัว p ย่อมาจาก projection ที่แปลว่า การฉาย คือเงาของจุดสามมิติที่ทาบลงบนจอแบน ๆ ชื่อเดียวกันนี้อยู่ในโค้ดจริงด้วย

เลข 40 กับ 12 คือจุดกลางจอ เพราะจอกว้าง 80 สูง 22 ส่วนเครื่องหมายลบหน้า 15 มีเหตุผลที่ไม่เกี่ยวกับคณิตศาสตร์เลย แกน y ของจอคอมพิวเตอร์ชี้ลง แถวที่ 0 อยู่บนสุด แถวที่ 21 อยู่ล่างสุด ถ้าลืมใส่ลบ โดนัทจะกลับหัว

ส่วน K2 = 5 จากชั้นที่แล้วคือระยะที่ผลักโดนัทออกไปจากตา จำเป็นเพราะจุดที่ไกลสุดของโดนัทอยู่ที่ R1 + R2 = 3 ถ้า K2 น้อยกว่านั้น จะมีจุดที่มี z เป็นศูนย์หรือติดลบ แปลว่ากล้องจมอยู่ในเนื้อโดนัท แล้วการหารด้วย z จะพังทันที ค่า 5 มากกว่า 3 อยู่พอสมควร จึงปลอดภัย

ทำไมโค้ดเก็บ 1 ส่วน z ไว้ก่อน

ในโค้ดจะเห็นตัวแปรชื่อ ooz ซึ่งย่อมาจาก one over z อ่านว่า "วัน โอเวอร์ ซี" แปลตรงตัวว่า หนึ่งส่วน z มันเก็บค่า 1/z ไว้ตั้งแต่แรก แล้วใช้คูณสองที ทั้งแกน x และแกน y เหตุผลตรงไปตรงมา การหารช้ากว่าการคูณมาก หารครั้งเดียวแล้วคูณสองครั้ง เร็วกว่าหารสองครั้ง และค่าตัวนี้จะถูกเอาไปใช้ต่อในชั้นที่ 5 อีกด้วย

เกร็ดที่ทำให้โดนัทกลม ไม่ใช่รี

สังเกตว่าตัวคูณสองแกนไม่เท่ากัน แกนนอนคูณ 30 แกนตั้งคูณ 15 ต่างกัน 2 เท่าพอดี ไม่ใช่ความสะเพร่า แต่เพราะช่องตัวอักษรบนเทอร์มินัลไม่ใช่สี่เหลี่ยมจัตุรัส มันสูงประมาณสองเท่าของความกว้าง ถ้าใช้ตัวคูณเท่ากันทั้งสองแกน โดนัทจะออกมารีสูงเป็นสองเท่า ตัวเลขนี้คือจุดที่โลกจริงเข้ามาแทรกในคณิตศาสตร์ที่สวยงาม และเป็นบรรทัดที่คำอธิบายส่วนใหญ่ข้ามไป

โค้ดของคุณตอนนี้ · ชั้นที่ 4 จาก 6
    // ...พิกัด x, y, z จากชั้นที่ 3...

    // <-- ใหม่: ของไกลต้องดูเล็ก คือหารด้วยระยะ
    float ooz = 1 / z;                  // เก็บส่วนกลับไว้ก่อน จะได้ไม่ต้องหารซ้ำ
    int xp = (int)(40 + 30 * ooz * x);       // 40 คือกลางจอในแนวนอน
    int yp = (int)(12 - 15 * ooz * y);       // ลบ เพราะแกน y ของจอชี้ลง
เลข 30 กับ 15 เลือกมายังไงให้โดนัทพอดีจอ

เริ่มจากสามเหลี่ยมคล้ายในหัวข้อนี้ ถ้าตั้งตัวคูณไว้เป็น K1 ภาพบนจอของจุดที่อยู่ห่างไป z คือ

ทีนี้ต้องเลือก K1 ให้จุดที่ไกลออกจากแกนที่สุดยังไม่ตกขอบจอ Andy เขียนกฎที่เขาใช้ไว้ในหน้าอธิบายของเขาว่า

คือเล็งให้ขอบโดนัทกินราวสามในแปดของความกว้างจอ แทนค่าจอกว้าง 80 ช่อง กับ K2 = 5 และ R1 + R2 = 3 จะได้ K1 = 50.000

แต่โค้ดที่ส่งประกวดจริงใช้ 30 กับ 15 ซึ่งเล็กกว่านั้น เพราะตัวที่บีบจริงไม่ใช่ความกว้างจอ แต่คือความสูงแค่ 22 แถว ผมไล่หมุนโดนัทดูทุกมุมแล้ววัดพื้นที่ที่มันกินจริง ได้ 45 ช่องจาก 80 ช่องในแนวนอน แต่ 22.5 แถวจาก 22 แถวในแนวตั้ง คือแนวตั้งล้นจอไปนิดเดียว

ตรงนี้เองที่อธิบายเงื่อนไข 22 > y && y > 0 ในโค้ด ที่มุมหมุนบางมุม ขอบบนกับขอบล่างของโดนัทเลยขอบจอไปจริง ๆ เงื่อนไขนั้นจึงไม่ใช่การกันพลาดเฉย ๆ แต่มันทำงานทุกเฟรม ส่วนแนวนอนยังเหลือที่ว่างอีกเกือบครึ่งจอ ซึ่งก็สมเหตุสมผล เพราะจอเทอร์มินัลกว้างกว่าสูงอยู่แล้ว

เกมสามมิติที่คุณเคยเล่นใช้บรรทัดนี้บรรทัดเดียวกันจริงหรือ

จริงครับ ต่างกันแค่ชื่อที่เรียก ในเอนจินเกม ตัวคูณ K1 ถูกเก็บในรูปของ มุมมองภาพ (field of view) คือแทนที่จะบอกตัวคูณตรง ๆ เขาบอกเป็นมุมว่ากล้องเห็นกว้างแค่ไหน แล้วคำนวณตัวคูณจากมุมนั้นอีกที มุมกว้างเห็นของได้เยอะแต่ของแต่ละชิ้นเล็กลง เหมือนกล้องมุมกว้าง

ส่วน K2 ที่กันไม่ให้อะไรมาอยู่ใกล้ตาเกินไปจนหารพัง คือของชิ้นเดียวกับที่เอนจินเรียกว่า ระนาบใกล้ (near plane) ถ้าเคยเล่นเกมแล้วเดินชนกำแพงจนทะลุเข้าไปเห็นข้างใน นั่นคือการที่กำแพงเข้ามาใกล้กว่าระนาบใกล้ จนถูกตัดทิ้งไปทั้งชิ้น

สิ่งที่การ์ดจอทำจริงคือเก็บทั้งหมดนี้ไว้ในเมทริกซ์ใบเดียวแล้วคูณทีเดียว แต่ตัวคณิตศาสตร์ข้างในคือการหารด้วยระยะที่คุณเพิ่งอ่านจบไปเมื่อกี้

ถ้าดันวัตถุออกไปไกลไม่รู้จบ ภาพจะหน้าตาเป็นยังไง (ระดับ ม.ปลาย)

นี่คือสวิตช์ที่คุณเพิ่งกดเล่นในของเล่นข้างบนนั่นเอง แต่คราวนี้ดูด้วยลิมิต

ความลึกของจุดคือ z = K2 + (ส่วนที่ต่างกันของแต่ละจุด) ส่วนที่ต่างกันนั้นมีค่ามากสุดเท่ากับ R1 + R2 = 3 ดังนั้นเมื่อ K2 โตขึ้นเรื่อย ๆ อัตราส่วนความลึกระหว่างจุดหน้าสุดกับหลังสุดจะเข้าใกล้ 1 ทุกจุดจึงถูกย่อด้วยตัวเลขเดียวกันหมด ความลึกเลิกมีผลต่อขนาด

ตัวเลขจริง ที่ K2 = 5 อัตราส่วนนั้นคือ 4.000 พอดัน K2 ไปที่ 14 เหลือ 1.545 คือเข้าใกล้ 1 เข้าไปทุกที

ในตำราการฉายภาพแบบนี้เรียกว่า ออร์โทกราฟิก (orthographic) เป็นวิธีที่แบบแปลนสถาปัตยกรรมกับภาพเขียนแบบวิศวกรรมใช้ เพราะของขนาดเท่ากันต้องวาดออกมาเท่ากันเสมอ ไม่ว่าจะอยู่ตรงไหนของภาพ


ชั้นที่ 5 จาก 6 · อะไรบังอะไร

ตอนนี้เรามีสูตรที่บอกได้แล้วว่าจุดบนผิวโดนัทจะไปตกที่ช่องไหนบนจอ แล้วก็เจอปัญหาทันที โดนัทมีผิวสองด้าน และผิวด้านหลังก็ตกลงบนจอเหมือนกัน จุดที่อยู่ด้านหลังกับจุดที่อยู่ด้านหน้าตกลงในช่องเดียวกันเป๊ะ แต่ช่องตัวอักษรหนึ่งช่องพิมพ์ตัวอักษรได้แค่ตัวเดียว

ตา z y @ ช่องบนจอ ผิวด้านหน้า 1/z มากกว่า จึงได้ช่องไป ผิวด้านหลัง 1/z น้อยกว่า จึงถูกปฏิเสธ
สองจุดบนเส้นเล็งเดียวกันแย่งช่องเดียวกัน ค่าที่ตัดสินคือ 1 ส่วน z ใครมากกว่าคนนั้นอยู่ใกล้ตากว่า

ถ้าปล่อยให้ใครมาทีหลังทับได้เลย ผิวด้านหลังจะทะลุออกมาข้างหน้าแบบสุ่ม ๆ ภาพจะดูเหมือนโดนัทที่ทำจากแก้ว

ปิดสวิตช์แล้วหมุนดูสักพัก จะเห็นว่าผิวที่ควรอยู่ด้านหลังโผล่มาทับด้านหน้า

วิธีแก้คือตั้งคนเฝ้าประตูไว้ทุกช่อง ทำตารางอีกใบขนาดเท่าจอเป๊ะ ๆ ช่องละหนึ่งตัวเลข เก็บว่า ตอนนี้ของที่ยึดช่องนี้อยู่ อยู่ใกล้ตาแค่ไหน พอมีจุดใหม่มาขอลง ก็เทียบกัน ใกล้กว่าของเดิมถึงจะได้เขียนทับ ไกลกว่าก็ถูกปฏิเสธไปเงียบ ๆ ตารางใบนี้เรียกว่า z-buffer

แกะคำศัพท์

z-buffer อ่านว่า "ซีบัฟเฟอร์" แปลว่า ตารางพักค่าความลึก คำว่า buffer แปลว่ากันชน คือที่พักของกลางทาง ส่วน z คือชื่อแกนความลึก มารวมกันเป็นตารางที่พักค่าความลึกไว้ชั่วคราวระหว่างวาดหนึ่งเฟรม การ์ดจอทุกใบในโลกมีของชิ้นนี้อยู่ในฮาร์ดแวร์

รายละเอียดที่คมคือมันเก็บ 1/z ไม่ใช่ z แล้วเทียบด้วยเครื่องหมายมากกว่า เพราะ z เป็นบวกเสมอ (ขอบคุณ K2 จากชั้นที่แล้ว) ของที่ใกล้กว่ามี z น้อยกว่า จึงมี 1/z มากกว่า และค่านี้เราคำนวณไว้แล้วตั้งแต่ชั้นที่ 4 ไม่ต้องหารเพิ่มเลยสักครั้ง

38, 6 39, 6 40, 6 41, 6
38, 7 39, 7 40, 7 41, 7
38, 8 39, 8 40, 8 41, 8

จุดที่วิ่งเข้ามาในหน้าต่างเล็ก ๆ นี้เป็นจุดจริงจากเฟรมจริง เรียงตามลำดับที่ลูปคำนวณมันออกมา และในชุดนี้มีจุดที่ถูกปฏิเสธไป 7 จุด

ตัวเลขที่ทำให้เห็นสเกลของงานนี้

ลูปนอกเดินมุม θ ทีละ 0.07 ได้ 90 รอบ ลูปในเดินมุม φ ทีละ 0.02 ได้ 315 รอบ คูณกันคือ 28,350 จุดต่อหนึ่งเฟรม ส่วนจอมีแค่ 80 คูณ 22 เท่ากับ 1,760 ช่อง

แปลว่าจุดราว 93.8% ที่คำนวณมาถูกทิ้ง ฟังดูเปลืองมาก แต่มันตอบสองคำถามพร้อมกัน อย่างแรกคือทำไมต้องมี z-buffer เพราะทุกช่องมีคนแย่งกันหลายจุดจริง ๆ อย่างที่สองคือทำไมภาพถึงไม่เป็นรูพรุน เพราะเรายิงจุดถี่กว่าความละเอียดของจอมากพอที่ทุกช่องจะมีคนมาลงครบ

ทำไมไม่เรียงจุดตามความลึกแล้ววาดทับเอา

วิธีนั้นทำได้และมีชื่อว่า painter's algorithm คือวาดของไกลก่อน ของใกล้ทีหลัง แต่มันแพงกว่าตรงนี้ครับ การเรียงของ 28,350 ชิ้นทุกเฟรมมีต้นทุนสูงกว่าการเทียบเลขทีละครั้งตอนจุดมาถึง และยังต้องมีที่เก็บจุดทั้งกองไว้ก่อนด้วย

ส่วน z-buffer ตัดสินทีละช่องโดยไม่สนใจว่าจุดอื่นมาถึงหรือยัง จุดมาเมื่อไรก็ตัดสินเมื่อนั้น ไม่ต้องรอ ไม่ต้องเก็บ ใช้ที่แค่หนึ่งตารางเท่าขนาดจอ นี่คือเหตุผลเดียวกับที่การ์ดจอสมัยใหม่เลือกวิธีนี้

โค้ดของคุณตอนนี้ · ชั้นที่ 5 จาก 6
    // ...xp, yp จากชั้นที่ 4...

    // <-- ใหม่: ช่องหนึ่งช่องรับได้ตัวเดียว ใครใกล้กว่าคนนั้นได้
    if (ooz > zbuffer[xp + 80 * yp]) {
      zbuffer[xp + 80 * yp] = ooz;
      output[xp + 80 * yp] = '#';   // ยังเป็นตัวเดิมทุกจุด
    }
กลับเศษส่วนแล้วทำไมเครื่องหมายถึงไม่พลิกมั่ว

การกลับเศษส่วนพลิกลำดับก็จริง แต่พลิกอย่างเป็นระเบียบเมื่อทั้งสองตัวเป็นบวก

เงื่อนไขที่ห้ามลืมคือ ทั้งสองค่าต้องเป็นบวก ถ้ามีตัวใดตัวหนึ่งติดลบ ความสัมพันธ์นี้พังทันที และตรงนี้แหละที่ K2 จากชั้นที่แล้วทำงาน จุดที่ไกลจากแกนที่สุดอยู่ห่างออกไป R1 + R2 = 3 ส่วน K2 = 5 ความลึกที่น้อยที่สุดที่เป็นไปได้จึงเท่ากับ 5 − 3 = 2 ซึ่งเป็นบวกเสมอ

แปลว่าโค้ดเทียบด้วยเครื่องหมายมากกว่าบนค่า 1/z ได้อย่างปลอดภัย โดยไม่ต้องหารกลับมาเป็น z เลยสักครั้ง ตัวเลขจริงจากตัวเดินทีละขั้นข้างบน จุดที่ชนะช่องมี 1/z เท่ากับ 0.30468 ส่วนจุดที่แพ้มี 0.30273 ซึ่งแปลว่าอยู่ลึกกว่าจริง

เดินมุมทีละเท่านี้ ทำไมภาพถึงไม่เป็นรูพรุน

เงื่อนไขคือ ระยะระหว่างจุดข้างเคียงสองจุด หลังฉายลงจอแล้ว ต้องน้อยกว่าหนึ่งช่องตัวอักษร ไม่งั้นจะมีช่องที่ไม่มีใครมาลง แล้วกลายเป็นรู

คิดที่ระยะกลาง ๆ คือ z = K2 = 5 การเดินมุม θ หนึ่งก้าวได้ระยะจริงบนผิว R1 × 0.07 = 0.070 หน่วย คูณด้วยตัวคูณแนวนอนแล้วหารด้วยความลึก ได้ 0.420 ช่อง ส่วนการเดินมุม φ หนึ่งก้าวที่ขอบนอกสุด ได้ 0.360 ช่อง

ทั้งสองค่าน้อยกว่า 1 อยู่พอสมควร แปลว่าจุดข้างเคียงตกช่องเดียวกันเป็นส่วนใหญ่ ภาพจึงตัน นี่คืออีกด้านของเลข 28,350 จุดต่อเฟรมที่ดูเปลืองในกล่องข้างบน มันเปลืองเพราะจงใจให้ทึบ

ถ้าเพิ่มระยะก้าวของมุมเป็นสิบเท่า ตัวเลขแรกจะกลายเป็น 4.200 ช่อง ซึ่งเกินหนึ่งช่องไปไกล แล้วรูจะโผล่ทันที ข้อนี้คือโจทย์ฝึกข้อสุดท้ายท้ายบทพอดี

การ์ดจอจริงก็ทำแบบนี้ใช่ไหม

ใช่ครับ และทำในฮาร์ดแวร์ด้วย การ์ดจอทุกใบมีหน่วยความจำก้อนหนึ่งที่ทำหน้าที่เป็น z-buffer ให้ทุกพิกเซลบนจอ ตัดสินด้วยกฎเดียวกันคือใครใกล้กว่าคนนั้นได้เขียน

รายละเอียดที่เหมือนกันจนน่าตกใจคือ การ์ดจอก็เก็บค่าในรูป 1/z เหมือนกัน เหตุผลไม่ใช่แค่เรื่องความเร็ว แต่เพราะค่า 1/z เปลี่ยนแบบเป็นเส้นตรงบนจอ ทำให้ประมาณค่าระหว่างจุดยอดของสามเหลี่ยมได้ตรง ๆ

ผลข้างเคียงที่นักทำเกมทุกคนเคยเจอคือ z-fighting อ่านว่า "ซีไฟติง" คือเวลาผิวสองชิ้นอยู่ลึกใกล้กันมากจนความละเอียดของตัวเลขแยกไม่ออก ภาพจะกะพริบสลับไปมาระหว่างสองผิว ในบทความนี้คุณเพิ่งเห็นตัวเลขสองค่าที่ต่างกันแค่ทศนิยมตำแหน่งที่สี่ในตัวเดินทีละขั้น นั่นคืออาการเดียวกันในระดับที่ยังแยกออก

ห้าชั้นผ่านไป สิ่งที่ได้คือเงาทึบรูปโดนัทหนึ่งใบ รูปร่างถูกต้องทุกอย่าง แต่ยังไม่มีใครดูออกว่ามันเป็นสามมิติ


ชั้นที่ 6 จาก 6 · แสงตกกระทบ

ลองเอามือถือส่องไฟไปที่ลูกบอลในห้องมืด ส่วนที่หันเข้าหาไฟจะสว่างที่สุด ส่วนที่หันเฉียงจะสลัวลง ส่วนที่หันหนีไปเลยจะมืดสนิท ความสว่างขึ้นกับว่าผิวตรงนั้นหันเข้าหาแสงมากแค่ไหน ไม่ได้ขึ้นกับว่ามันอยู่ใกล้ไฟแค่ไหน

แปลว่าเราต้องรู้สองอย่างต่อหนึ่งจุด คือทิศที่ผิวตรงนั้นหันไป กับทิศของแสง แล้วต้องมีวิธีวัดว่าสองทิศนี้ตรงกันแค่ไหน

ทิศที่ผิวหันไป เรียกว่า นอร์มอล (normal) คือลูกศรที่ตั้งฉากกับผิวตรงจุดนั้น ข่าวดีคือของชิ้นนี้เราได้มาฟรี เพราะผิวโดนัทตรงจุดใด ๆ ตั้งฉากกับรัศมีของวงกลมหน้าตัดที่ลากผ่านจุดนั้นพอดี ทิศตั้งฉากจึงเป็นสูตรเดียวกับสูตรตำแหน่งในชั้นที่ 1 และ 2 แค่ตัดตัว R1 กับ R2 ทิ้งไป เหลือแต่ทิศ ขอเรียกลูกศรนอร์มอลนี้ว่า N ตามตัวแรกของคำ และเรียกสามช่องของมันว่า Nx, Ny, Nz

แล้วมันต้องถูกหมุนตามด้วยมุม A และ B ชุดเดียวกับที่หมุนตัวโดนัท ซึ่งทำได้ตรง ๆ เพราะการหมุนไม่ยืดไม่หด ตามที่คุยกันไว้ในหัวข้อเวกเตอร์

แกะคำศัพท์

นอร์มอล (normal) อ่านว่า "นอร์มอล" ในบริบทนี้แปลว่า เส้นตั้งฉากกับผิว ไม่ได้แปลว่าปกติ มาจากละติน norma ที่แปลว่า ฉากไม้ของช่าง คือเครื่องมือวัดมุมฉากนั่นเอง ส่วนความหมายว่าปกติมาทีหลัง จากการที่ของที่ได้ฉากคือของที่ถูกต้องตามมาตรฐาน

ดอทโปรดักต์ คือเงาที่ลูกศรหนึ่งทอดลงบนอีกลูกศร

เหลือคำถามเดียว มีลูกศรสองอัน จะวัดยังไงว่ามันหันไปทางเดียวกันแค่ไหน

ค่อย ๆ หมุนจนสองลูกศรตั้งฉากกัน แล้วดูว่าตัวเลขข้างล่างทำอะไรตอนนั้น

เอาลูกศรอันแรกไปทอดเงาลงบนอีกอันหนึ่ง แล้ววัดว่าเงายาวเท่าไร ถ้าสองอันหันทางเดียวกันเป๊ะ เงาจะยาวเต็มที่ ถ้าตั้งฉากกัน เงาจะเป็นศูนย์พอดี และถ้าหันสวนทางกัน เงาจะติดลบ ค่านี้เรียกว่าดอทโปรดักต์ (dot product)

วิธีคำนวณคือคูณทีละช่องแล้วบวกกัน ช่อง x คูณช่อง x ช่อง y คูณช่อง y ช่อง z คูณช่อง z แล้วบวกทั้งสามเข้าด้วยกัน แค่นั้น ที่ตำราชอบทำคือให้สูตรนี้ก่อนโดยไม่บอกว่ามันแปลว่าอะไร

ทิศแสงในโค้ดคือ (0, 1, -1) แปลว่าแสงมาจากด้านบนและด้านหน้าเยื้องมาทางคนดู ดอทโปรดักต์ของมันกับนอร์มอลจึงสั้นมาก ค่าที่ได้คือความสว่างของจุดนั้น ขอเรียกมันว่า L ตามคำว่า luminance ที่แปลว่า ความสว่าง ชื่อเดียวกับตัวแปรในโค้ดจริง

ช่อง x คูณกับศูนย์ จึงหายไปทั้งพจน์ เหลือแค่เอาค่าในช่อง y ลบด้วยค่าในช่อง z ของนอร์มอล

เครื่องหมายมีความหมายจริง ๆ ถ้า L ติดลบ แปลว่าผิวตรงนั้นหันหลังให้แสง คือมันเป็นด้านที่เรามองไม่เห็นอยู่แล้ว โค้ดจึงมีเงื่อนไข L > 0 คอยกรองทิ้ง ในวงการกราฟิกท่านี้มีชื่อว่า backface culling

ค่า L มากที่สุดได้เท่าไร นอร์มอลยาว 1 ส่วนทิศแสงยาวเท่านี้

ค่า L จึงอยู่ระหว่าง -1.414 ถึง 1.414 โค้ดคูณมันด้วย 8 แล้วเอาไปใช้เป็นลำดับที่ในแถบตัวอักษร

แถบตัวอักษรคือ .,-~:;=!*#$@ เรียงจากจางไปเข้ม จุดที่หันเข้าหาแสงเต็ม ๆ ได้ @ ที่ทึบที่สุด จุดที่หันเฉียงมาก ๆ ได้ . ที่บางที่สุด และ 1.414 คูณ 8 ได้ประมาณ 11.3 ซึ่งตกที่ตำแหน่งสุดท้ายของแถบยาว 12 ตัวพอดี

ทิศแสง ผิวโดนัท x y มองจากด้านข้าง หันรับแสงเฉียง หันรับแสงเต็มหน้า หันหนีแสง ลูกศรตั้งฉากกับผิว เรียกว่านอร์มอล .,-~:;=!*#$@ จาง ทึบ
มุมระหว่างผิวกับแสงถูกแปลงเป็นตัวอักษรตรง ๆ ผิวที่หันเข้าหาแสงเต็มหน้าได้ตัวที่ทึบที่สุดในแถบ

ค่าตั้งต้นที่ศูนย์องศาคือทิศแสงจริงในโค้ด สังเกตว่าพอไฟย้ายที่ ตัวอักษรที่ถูกใช้ก็เปลี่ยนชุดตามไปด้วย
โค้ดของคุณตอนนี้ · ชั้นที่ 6 จาก 6
    // <-- ใหม่: ทิศตั้งฉากของผิว แล้วเทียบกับทิศแสง
    float Nx = cos(theta) * cos(phi);
    float Ny = sin(theta);
    float Nz = cos(theta) * sin(phi);
    // หมุน N ด้วยมุม A และ B ชุดเดียวกับที่หมุนตัวโดนัท แล้วเอามาทำดอทโปรดักต์กับ (0, 1, -1)
    float L = ...;

    if (L > 0 && ooz > zbuffer[xp + 80 * yp]) {
      zbuffer[xp + 80 * yp] = ooz;
      int index = (int)(8 * L);            // <-- ใหม่: แปลงความสว่างเป็นตัวอักษร
      output[xp + 80 * yp] = ".,-~:;=!*#$@"[index > 0 ? index : 0];
    }
ทำไมทิศตั้งฉากของผิวถึงได้มาฟรี

เพราะรูปทรงของโดนัทช่วยไว้ ผิวโดนัทตรงจุดใด ๆ คือผิวของวงกลมหน้าตัดที่ลากผ่านจุดนั้น และ ผิวของวงกลมตั้งฉากกับรัศมีของมันเสมอ ข้อนี้เป็นเรขาคณิต ม.ต้น เส้นสัมผัสวงกลมตั้งฉากกับรัศมี ณ จุดสัมผัส

แปลว่าทิศตั้งฉากของผิว คือทิศของรัศมีวงกลมหน้าตัด ซึ่งชี้จากจุดศูนย์กลางของวงกลมหน้าตัดออกมาหาจุดนั้น และเราเขียนมันไว้แล้วตั้งแต่ชั้นที่ 1 คือ (cos θ, sin θ) เพียงแต่คราวนี้ทิ้ง R1 กับ R2 ไป เพราะเราต้องการแค่ทิศ ไม่ต้องการความยาว

จากนั้นมันถูกกวาดรอบแกนด้วยมุม φ ชุดเดียวกับตัวโดนัท และหมุนด้วย A กับ B ชุดเดียวกันอีกที ซึ่งทำได้เพราะการหมุนไม่ยืดไม่หด ตามที่พิสูจน์ไว้ในกล่องของหัวข้อเวกเตอร์

ข้อจำกัดที่ต้องพูดให้ชัด ทางลัดนี้ใช้ได้เพราะโดนัทสมมาตรพอดี ผิวรูปอื่นต้องหาทิศตั้งฉากด้วยวิธีทั่วไป ซึ่งในตำรากราฟิกคือการนำเวกเตอร์สัมผัสสองทิศมาทำครอสโปรดักต์กัน

เอาสองทิศมาคูณกันแล้วได้ความสว่างได้ยังไง (ระดับ ม.ปลาย)

เรานิยามดอทโปรดักต์ด้วยการคูณทีละช่องแล้วบวกกัน แล้วอ้างว่ามันคือเงา ข้อนี้ต้องพิสูจน์ สิ่งที่ต้องแสดงคือ

โดยที่ γ คือมุมระหว่างสองลูกศร วิธีที่สั้นที่สุดคือใช้กฎของโคไซน์กับสามเหลี่ยมที่มีสองลูกศรนั้นเป็นด้าน

กางฝั่งซ้ายออกด้วยการคูณทีละช่อง จะได้ |a|² + |b|² − 2(a·b) จับมาเทียบกับฝั่งขวา พจน์กำลังสองตัดกันหมด เหลือ a·b = |a||b|cos γ พอดี

ตัวเลขจริงสามกรณี ถ้าสองทิศตรงกันเป๊ะ cos 0° = 1 ได้ค่าเต็ม ถ้าตั้งฉาก cos 90° = 0 ได้ศูนย์ ถ้าหันสวนกัน cos 180° = −1 ได้ค่าติดลบเต็ม ซึ่งตรงกับตัวเลขที่ขยับในของเล่นข้างบนทุกกรณี

ทำไมคูณ 8 แล้วอักขระ 12 ตัวถึงพอดี

ค่าความสว่างมากที่สุดที่เป็นไปได้คือตอนที่ทิศตั้งฉากของผิวหันตรงเข้าหาแสงพอดี ตอนนั้นดอทโปรดักต์เท่ากับความยาวของทิศแสงคูณความยาวของนอร์มอล คือ 1.414 × 1

คูณด้วย 8 ได้ 11.314 ปัดลงเป็นจำนวนเต็มได้ 11 ซึ่งคือลำดับสุดท้ายของแถบยาว 12 ตัวพอดี (ลำดับเริ่มนับจาก 0) ตัวคูณ 8 จึงไม่ใช่เลขสุ่ม มันคือเลขที่ทำให้ช่วงค่าที่เป็นไปได้เต็มแถบพอดีโดยไม่ล้น

ตรวจอีกทางก็ได้ จะไปถึงตัว @ ได้ต้องมีความสว่างอย่างน้อย 11 ÷ 8 = 1.375 ซึ่งน้อยกว่า 1.414 อยู่นิดเดียว แปลว่ามีแค่ผิวที่หันเข้าหาแสงเกือบตรงเท่านั้นที่ได้ตัวทึบที่สุด ซึ่งตรงกับที่เห็นในภาพว่าจุดขาวจัดมีอยู่แถบเดียวแคบ ๆ

ส่วนโค้ดยังกันฝั่งล่างไว้ด้วยการเขียน N > 0 ? N : 0 เผื่อกรณีที่ค่าติดลบเล็ดลอดเข้ามา จะได้ไม่ไปอ่านนอกแถบ

ถึงตรงนี้ผมอยากชี้ให้เห็นอะไรอย่างหนึ่ง ตลอดหกชั้นที่ผ่านมา ไม่มีตรงไหนเลยที่โค้ด "วาดโดนัท" สิ่งที่มันทำคือคำนวณว่าตัวอักษรแต่ละช่องบนจอควรเข้มแค่ไหน ส่วนโดนัทเป็นผลข้างเคียงที่โผล่ออกมาเอง


ถอดชิ้นส่วนโดนัทเล่น

หกชั้นครบแล้ว ทีนี้ลองถอดออกทีละชิ้นดูครับ ปิดชั้นไหนแล้วภาพพังยังไง เป็นวิธีที่เร็วที่สุดที่จะรู้ว่าชั้นนั้นทำหน้าที่อะไรจริง ๆ

ปิดทีละอันแล้วดูว่าหายอะไรไป ปิดพร้อมกันหลายอันก็ได้ครับ

โค้ดจริงทั้งไฟล์

ตรงนี้คือปลายทางของหน้าต่างโค้ดที่งอกมาทั้งหกชั้น เอามาต่อกันเป็นไฟล์เดียวที่รันได้จริง ตัวแปรชื่อยาว คอมเมนต์ไทย และกำกับไว้ว่าก้อนไหนคือชั้นไหน

donut.c เวอร์ชันอ่านออก
#include <stdio.h>
#include <string.h>
#include <math.h>

/* ---- รูปทรง ---- */
const float R1 = 1;              /* รัศมีวงกลมหน้าตัด */
const float R2 = 2;              /* ระยะจากแกนกลางถึงจุดกลางวงกลมหน้าตัด */
/* ---- กล้องกับจอ ---- */
const float K2 = 5;              /* ระยะจากตาถึงโดนัท ต้องมากกว่า R1 + R2 */
const int   SCREEN_W = 80, SCREEN_H = 22;
const int   CENTER_X = 40, CENTER_Y = 12;
const float MULT_X = 30, MULT_Y = 15;  /* ต่างกันเท่าตัว เพราะช่องอักษรสูงราวสองเท่าของความกว้าง */
/* ---- ความละเอียดของการกวาด ---- */
const float THETA_SPACING = 0.07, PHI_SPACING = 0.02;
/* ---- แถบอักขระ เรียงจากจางไปเข้ม ---- */
const char *CHARS = ".,-~:;=!*#$@";

int main(void) {
  float A = 0, B = 0;                        /* มุมหมุนสองตัว เพิ่มขึ้นทุกเฟรม */
  char  output[SCREEN_W * SCREEN_H];
  float zbuffer[SCREEN_W * SCREEN_H];

  printf("\x1b[2J");                        /* ล้างจอครั้งเดียวตอนเริ่ม */
  for (;;) {
    memset(output, ' ', sizeof(output));
    memset(zbuffer, 0, sizeof(zbuffer));
    float cosA = cos(A), sinA = sin(A), cosB = cos(B), sinB = sin(B);

    for (float theta = 0; theta < 6.28; theta += THETA_SPACING) {
      float costheta = cos(theta), sintheta = sin(theta);

      /* ชั้นที่ 1 จุดบนวงกลม  ชั้นที่ 2 เลื่อนออกไปให้ห่างแกน R2 */
      float circlex = R2 + R1 * costheta;
      float circley = R1 * sintheta;

      for (float phi = 0; phi < 6.28; phi += PHI_SPACING) {
        float cosphi = cos(phi), sinphi = sin(phi);

        /* ชั้นที่ 2 กวาดรอบแกน แล้วชั้นที่ 3 หมุนด้วย A ตามด้วย B และถอยออกไป K2 */
        float x = circlex * (cosB*cosphi + sinA*sinB*sinphi) - circley*cosA*sinB;
        float y = circlex * (sinB*cosphi - sinA*cosB*sinphi) + circley*cosA*cosB;
        float z = K2 + cosA*circlex*sinphi + circley*sinA;

        /* ชั้นที่ 4 หารด้วยระยะ แล้วปัดลงเป็นช่องบนจอ */
        float ooz = 1 / z;
        int xp = (int)(CENTER_X + MULT_X * ooz * x);
        int yp = (int)(CENTER_Y - MULT_Y * ooz * y);   /* ลบ เพราะแกน y ของจอชี้ลง */
        if (xp < 0 || xp >= SCREEN_W || yp < 0 || yp >= SCREEN_H) continue;

        /* ชั้นที่ 6 ทิศตั้งฉากของผิว หมุนด้วยมุมชุดเดียวกัน แล้วทำดอทโปรดักต์กับทิศแสง (0, 1, -1) */
        float L = cosphi*costheta*sinB - cosA*costheta*sinphi - sinA*sintheta
                + cosB*(cosA*sintheta - costheta*sinA*sinphi);
        if (L <= 0) continue;                 /* หันหลังให้แสง ไม่ต้องวาด */

        /* ชั้นที่ 5 ช่องหนึ่งช่องรับได้ตัวเดียว ใครใกล้ตากว่าคนนั้นได้ */
        int idx = xp + SCREEN_W * yp;
        if (ooz > zbuffer[idx]) {
          zbuffer[idx] = ooz;
          int c = (int)(L * 8);               /* ความสว่างกลายเป็นลำดับที่ในแถบอักขระ */
          output[idx] = CHARS[c > 0 ? (c < 11 ? c : 11) : 0];
        }
      }
    }

    printf("\x1b[H");                       /* เลื่อนเคอร์เซอร์กลับมุมซ้ายบน ไม่ล้างจอ ภาพจะได้ไม่กะพริบ */
    for (int k = 0; k < SCREEN_W * SCREEN_H; k++)
      putchar(k % SCREEN_W == SCREEN_W - 1 ? '\n' : output[k]);

    A += 0.04;
    B += 0.02;
  }
  return 0;
}

คอมไพล์ด้วย gcc -o donut donut.c -lm ได้เลยครับ ผมคอมไพล์แล้วรันจริง แล้วเทียบเฟรมที่ได้กับโดนัทที่หมุนอยู่ในหน้านี้

โปรแกรมเดียวกันสองเครื่อง ให้ผลต่างกันได้หนึ่งช่อง

ผมเทียบทีละช่องทั้ง 1,760 ช่อง ที่มุมหมุน 7 คู่ ผลคือ ตรงกันทุกช่องอยู่ 6 คู่ ส่วนอีกคู่หนึ่งต่างกันหนึ่งช่อง ที่ A = 3.14159 กับ B = 1.0 แถวที่ 8 ช่องที่ 31 ฝั่ง C ได้ ! ส่วนฝั่งเบราว์เซอร์ได้ *

สาเหตุอยู่ที่ความละเอียดของเลขทศนิยม โค้ด C ประกาศตัวแปรเป็น float ซึ่งเก็บเลขได้ราวเจ็ดหลัก ส่วนจาวาสคริปต์ในเบราว์เซอร์ใช้ double ซึ่งเก็บได้ราวสิบหกหลัก ค่าความสว่างของจุดนั้นตกลงบน รอยต่อระหว่างอักขระสองตัวพอดี ความคลาดระดับหลักที่แปดจึงพอทำให้การปัดลงตกคนละฝั่ง

เกร็ดนี้ไม่ได้แปลว่าฝั่งใดฝั่งหนึ่งผิด ทั้งคู่ทำตามกฎของชนิดข้อมูลที่ตัวเองใช้ถูกต้อง มันคือเหตุผลเดียวกับที่การจำลองฟิสิกส์เดิมรันสองเครื่องแล้วผลค่อย ๆ แยกจากกัน และเป็นเหตุผลที่เกมออนไลน์ที่ต้องการให้ทุกเครื่องเห็นภาพตรงกันเป๊ะ ต้องล็อกชนิดข้อมูลกับลำดับการคำนวณให้เหมือนกันทุกเครื่อง

เทียบกับต้นฉบับที่จัดหน้าเป็นรูปโดนัทตอนต้นบท มันคือโปรแกรมเดียวกันเป๊ะ ต่างแค่ชื่อตัวแปรกับการขึ้นบรรทัด ถ้ากลับไปอ่านก้อนนั้นอีกรอบตอนนี้ คุณน่าจะชี้ได้แล้วว่าตัวไหนคือ circlex และวงเล็บก้อนไหนคือการหมุน

ลองแก้ค่าคงที่ดูเอง

พิมพ์อักขระของคุณเองลงช่องล่างสุดได้เลยครับ เรียงจากจางไปเข้ม จะใส่กี่ตัวก็ได้
บรรทัดกรองแสงติดลบ มีชื่อเรียกในวงการกราฟิก

แบบจำลองแสงที่โค้ดนี้ใช้มีชื่อว่า แสงแบบแลมเบิร์ต (Lambertian) ตั้งตามชื่อ Johann Heinrich Lambert อ่านว่า "โยฮันน์ ไฮน์ริช แลมเบิร์ต" นักคณิตศาสตร์ในศตวรรษที่ 18 ใจความคือผิวด้านที่ไม่สะท้อนเงาวาว จะสว่างตามโคไซน์ของมุมระหว่างผิวกับแสง ซึ่งก็คือดอทโปรดักต์ที่คุณเพิ่งอ่านจบ

ส่วนบรรทัดที่ทิ้งจุดที่ได้ค่าติดลบ ในวงการเรียกว่า backface culling อ่านว่า "แบ็กเฟซ คัลลิง" แปลว่าการคัดผิวด้านหลังทิ้ง คำว่า cull แปลว่าคัดออก เอนจินเกมทำแบบเดียวกันนี้กับสามเหลี่ยมทุกชิ้นก่อนส่งเข้าการ์ดจอ เพราะผิวที่หันหลังให้เรามองไม่เห็นอยู่แล้ว การคำนวณต่อจึงเปล่าประโยชน์

ข้อสังเกตเล็ก ๆ ที่ทำให้โค้ดนี้ต่างจากเอนจินจริง คือมันคัดด้วยทิศของ แสง ไม่ใช่ทิศของ ตา ผลที่ได้จึงเป็นเงาที่ทึบสนิทในฝั่งที่แสงส่องไม่ถึง แทนที่จะเป็นการประหยัดงาน

โปรแกรมแปลงรูปถ่ายเป็นตัวอักษร ใช้ตารางเดียวกันนี้

แถบอักขระ .,-~:;=!*#$@ เรียงตามปริมาณหมึกที่ตัวอักษรแต่ละตัวกินบนจอ จุดกินหมึกน้อยที่สุด แอตกินมากที่สุด ตารางแบบนี้มีอีกหน้าที่หนึ่งที่ใช้กันมานาน คือแปลงรูปถ่ายให้เป็นภาพตัวอักษร

วิธีทำคือแบ่งรูปเป็นตารางเท่าจำนวนช่องตัวอักษรที่ต้องการ หาความสว่างเฉลี่ยของแต่ละช่อง แล้วเอาความสว่างนั้นไปเลือกตัวอักษรจากแถบเดียวกันนี้ ต่างจากโดนัทตรงที่ความสว่างมาจากพิกเซลในรูป ไม่ได้มาจากดอทโปรดักต์

ข้อควรระวังที่ทั้งสองงานเจอเหมือนกัน คือช่องตัวอักษรบนเทอร์มินัลสูงราวสองเท่าของความกว้าง ถ้าลืมชดเชย รูปที่ได้จะยืดสูงผิดสัดส่วน เหมือนที่โดนัทจะออกมารีถ้าใช้ตัวคูณเท่ากันทั้งสองแกน


โดนัทป่วยสี่ใบ

สี่ใบข้างล่างนี้เขียนผิดไปคนละที่ ทั้งสี่ใบยังคอมไพล์ผ่านและรันได้ ไม่มีใครแจ้งข้อผิดพลาดอะไรเลย ลองทายดูครับว่าแต่ละใบพังตรงชั้นไหน

ใบที่ 1 ผิดตรงไหน

ใบที่ 2 ผิดตรงไหน

ใบที่ 3 ผิดตรงไหน (ใบนี้ต้องเทียบกับภาพที่ถูกต้องข้างบนถึงจะเห็น)

ใบที่ 4 ผิดตรงไหน


คนที่เขียนโค้ดก้อนนี้

คนเขียนชื่อ Andy Sloane อ่านว่า "แอนดี้ สโลน" ใช้ชื่อบนเน็ตว่า a1k0n เขาโพสต์โดนัทตัวแรกเมื่อวันที่ 15 กันยายน 2006 และเขียนกำกับไว้เองว่า "This was my first attempt at obfuscated C and I feel it's pretty amateurish" แปลว่านี่เป็นงาน obfuscated C ชิ้นแรกของเขา และเขารู้สึกว่ามันยังมือสมัครเล่นอยู่มาก

ห้าวันต่อมาเขาโพสต์ภาคต่อ ชื่อโพสต์คือ Embellishing the donut: an old-school CG cliche คราวนี้เป็นโดนัทหมุนอยู่เหนือกระดานหมากรุกที่ทอดยาวไม่รู้จบ และตัวนี้เองที่เขาส่งเข้าประกวด IOCCC ปี 2006 แล้วได้รางวัลในสาขาชื่อ Homer's favorite

สิ่งที่ผมอยากให้สังเกตคือชื่อโพสต์ของเขาเอง คำว่า cliche แปลว่าของที่ทำกันจนเฝือ เขาบอกเองว่าโดนัทหมุนไม่ใช่ของใหม่ตั้งแต่ปี 2006 แล้ว มันเป็นแบบฝึกหัดที่วงการเดโมซีนทำกันมานานก่อนหน้านั้น เขาไม่ได้นั่งคิดทุกอย่างขึ้นมาจากศูนย์ เขายืนอยู่บนกองของที่คนอื่นทำไว้ก่อน สิ่งที่เราขาดเวลาเทียบตัวเองกับคนแบบนี้จึงไม่ใช่สมอง แต่คือการเคยเห็นของพวกนี้ผ่านตามาก่อนหลายรอบ

แล้วเรื่องที่ผมชอบที่สุดคือสิ่งที่เกิดขึ้นหลังจากนั้น

สิบเก้าปีกับโดนัทใบเดิม รอบล่าสุดคือการออกแบบวงจรที่ไล่วาดภาพตามลำแสงของจอ VGA ไปสด ๆ โดยไม่มีหน่วยความจำเก็บภาพเลยสักไบต์ ใช้เกตประมาณเจ็ดพันตัว

ระหว่างนั้นเขาก็ทำอย่างอื่นด้วย ตัวเล่นไฟล์เพลง FastTracker 2 บนจาวาสคริปต์ในปี 2015 การเรนเดอร์สามมิติบนเครื่องเล่นเกมจิ๋ว Arduboy ในปี 2017 และหุ่นยนต์เดินตามเส้นความเร็วสูงในปี 2018 เว็บของเขาไม่มีหน้าแนะนำตัว ไม่บอกว่าเรียนอะไรมา ทำงานที่ไหน และไม่มีที่ไหนเลยที่เขาเล่าว่าทำไมวันนั้นถึงเริ่มเขียนโดนัท


ของแถม ตัด sin กับ cos ทิ้งทั้งไฟล์

ปี 2021 Andy กลับมาเขียนภาคบีบโค้ด เขาบอกว่าสิ่งที่ค้างคาใจมาตลอดคือการเรียกไซน์กับโคไซน์เยอะเกินไป ทั้งเพราะมันบังคับให้ต้องลิงก์ไลบรารีคณิตศาสตร์ตอนคอมไพล์ และเพราะมันกินซีพียูมากกว่าที่งานนี้ควรจะกิน ผลคือเวอร์ชันที่หมุนโดยไม่เรียกตรีโกณเลยสักครั้ง และเวอร์ชันที่ใช้เลขจำนวนเต็มล้วนจนรันบนไมโครคอนโทรลเลอร์ที่มีแรมสี่กิโลไบต์ได้

หมุนทีละนิดโดยไม่เรียก sin กับ cos เลย ทำได้ยังไง

กุญแจคือสูตรผลรวมมุมที่พิสูจน์ไว้แล้วในหัวข้อเวกเตอร์ ทุกเฟรมมุม A เพิ่มขึ้นทีละ 0.04 เท่ากันเสมอ เรียกส่วนที่เพิ่มนี้ว่า δ แล้วเขียนมุมใหม่เป็นมุมเดิมบวก δ

ฝั่งขวามีแต่ค่าที่เรารู้อยู่แล้วทั้งนั้น คือไซน์กับโคไซน์ของมุมเดิมที่เก็บไว้จากเฟรมที่แล้ว กับไซน์และโคไซน์ของ δ ซึ่งเป็นค่าคงที่ตัวเดียวตลอดทั้งโปรแกรม คำนวณครั้งเดียวตอนเริ่มก็พอ

แปลว่าการหมุนไปข้างหน้าหนึ่งเฟรมเหลือแค่การคูณกับการบวกไม่กี่ครั้ง ไม่ต้องเรียกฟังก์ชันตรีโกณอีกเลย และเมื่อไม่เรียก ก็ไม่ต้องลิงก์ไลบรารีคณิตศาสตร์ตอนคอมไพล์ ซึ่งคือสิ่งที่ Andy บอกว่าค้างคาใจเขามาตลอด

แต่หมุนสะสมไปเรื่อย ๆ แล้วเวกเตอร์จะยืดออก ต้องดึงกลับยังไง (ระดับ ม.ปลาย)

ปัญหาคือคอมพิวเตอร์เก็บทศนิยมได้จำกัด ทุกครั้งที่หมุน ค่าที่ได้จะคลาดจากค่าจริงนิดหนึ่ง สะสมไปหลายพันเฟรม คู่ค่า (c, s) ที่ควรอยู่บนวงกลมรัศมี 1 ตลอด จะค่อย ๆ ลอยออกนอกวงกลม โดนัทจะค่อย ๆ พองหรือแฟบ

ทางแก้คือหารด้วยความยาวของมันเองเพื่อดึงกลับมาที่ 1 แต่การหารากที่สองแพง Andy จึงใช้ทางลัด

ที่มาคือ ความยาวกำลังสอง m อยู่ใกล้ 1 มากอยู่แล้ว การหาค่า 1/√m ตรงจุดนั้นจึงประมาณด้วยเส้นตรงที่สัมผัสกราฟตรงจุด m = 1 ได้ ซึ่งก็คือหนึ่งก้าวของวิธีนิวตัน

ลองตัวเลขจริง ถ้าเวกเตอร์ยืดไปจน m = 1.02 ค่าจริงของ 1/√m คือ 0.990 ส่วนสูตรลัดให้ 0.990 ต่างกันในระดับหนึ่งในหมื่น ซึ่งเล็กกว่าความคลาดที่กำลังจะแก้เสียอีก

ในตำราวิธีนี้เรียกว่า วิธีนิวตัน (Newton's method) และเป็นญาติสนิทของสูตรลือชื่อในเกมยุคเก้าศูนย์ที่ใช้คำนวณ 1/√x แบบเร็วสุดขีด

ความคลาดที่ต้องคอยดึงกลับ มีชื่อในวิชาอะไร (ระดับมหาวิทยาลัย)

การหมุนทีละนิดแล้วสะสมไปเรื่อย ๆ คือการแก้สมการเชิงอนุพันธ์ด้วยวิธีที่ง่ายที่สุด ซึ่งเรียกว่า วิธีออยเลอร์ (Euler method) คือเดินไปข้างหน้าทีละก้าวเล็ก ๆ ตามความชันที่จุดปัจจุบัน

วิธีนี้มีความคลาดติดตัวเสมอ และความคลาดนั้นสะสม จุดที่ควรวิ่งเป็นวงกลมจึงค่อย ๆ วิ่งเป็นเกลียวออก อาการนี้มีชื่อว่า numerical drift อ่านว่า "นิวเมอริคัล ดริฟต์" แปลว่าการลอยเลื่อนเชิงตัวเลข

การดึงเวกเตอร์กลับให้ยาว 1 ทุกเฟรมจึงไม่ใช่ลูกเล่นเพื่อความสวยงาม มันคือการบังคับให้คำตอบอยู่บนเงื่อนไขที่โจทย์กำหนดไว้ ในตำราเรื่องนี้อยู่ในหัวข้อวิธีเชิงตัวเลข และตัวปัญหาที่ต้องรักษาปริมาณบางอย่างไว้ตลอดการคำนวณ เป็นหัวใจของการจำลองฟิสิกส์ทั้งวงการ


โจทย์ฝึกต่อ

สามข้อสำหรับคนที่อยากลองแก้โค้ดเอง

ทั้งสามข้อแก้ได้ด้วยของที่อยู่ในบทความนี้ล้วน ๆ ลองคิดก่อนแล้วค่อยแตะดูเฉลยครับ

ข้อ 1 อยากเปลี่ยนโดนัทเป็นทรงกระบอก ต้องแก้ตรงไหนบ้าง

แก้ที่ชั้นที่ 1 ตรงเดียว วงกลมหน้าตัดถูกแทนด้วยเส้นตรง คือให้ระยะจากแกนคงที่เท่ากับ R2 ตลอด แล้วให้ความสูงเดินเป็นเส้นตรงตามพารามิเตอร์แทน เขียนเป็น circlex = R2 และ circley = R1 * (theta / 3.14 − 1) ก็ได้ทรงกระบอกกลวงที่สูง 2 × R1

แต่มีอีกที่ที่ต้องแก้ตามด้วย คือทิศตั้งฉากในชั้นที่ 6 ผิวข้างของทรงกระบอกตั้งฉากออกด้านข้างเสมอ ทิศตั้งฉากจึงเป็น (cos φ, 0, sin φ) ไม่มีส่วนประกอบในแนวตั้งเลย ถ้าลืมข้อนี้ แสงจะยังคำนวณด้วยสูตรของโดนัท แล้วเงาจะไม่ตรงกับรูปทรงที่เห็น

ข้อ 2 อยากให้แสงมาจากด้านล่างแทนด้านบน ต้องแก้อะไร

เปลี่ยนทิศแสงจาก (0, 1, −1) เป็น (0, −1, −1) ซึ่งในโค้ดคือกลับเครื่องหมายของพจน์ที่มาจากช่อง y ของนอร์มอล ความยาวของทิศแสงยังเท่าเดิมคือ 1.414 ตัวคูณ 8 กับแถบอักขระ 12 ตัวจึงยังพอดีเหมือนเดิม ไม่ต้องแก้ตาม

ถ้าอยากได้แสงจากด้านล่างตรง ๆ โดยไม่เยื้องมาทางคนดูเลย ใช้ (0, −1, 0) ก็ได้ แต่คราวนี้ความยาวเหลือ 1 ไม่ใช่ 1.414 ค่าความสว่างสูงสุดจึงเหลือ 1 คูณ 8 ได้ 8 ซึ่งไปไม่ถึงตัวที่ทึบที่สุดในแถบ ภาพจะดูจืดลงทันที ต้องขยับตัวคูณเป็น 11 ถึงจะใช้แถบได้เต็ม

ข้อ 3 ทำไมเพิ่มค่า THETA_SPACING แล้วโดนัทถึงกลายเป็นรูพรุน

เพราะระยะระหว่างจุดข้างเคียงหลังฉายลงจอ ใหญ่กว่าหนึ่งช่องตัวอักษร ที่ค่าเดิม 0.07 ระยะนั้นคือ 0.420 ช่อง จุดข้างเคียงจึงตกช่องเดียวกันเป็นส่วนใหญ่ พอเพิ่มค่าขึ้นสิบเท่า ระยะกลายเป็น 4.200 ช่อง แปลว่ามีช่องที่ไม่มีใครมาลงคั่นอยู่ตลอด

จุดที่สังเกตได้คือรูจะไม่ได้กระจายเท่ากันทั้งใบ ตรงขอบด้านนอกจะพรุนก่อน เพราะระยะจริงบนผิวต่อหนึ่งก้าวมุมยาวกว่าด้านในรู ตามที่คำนวณไว้ในกล่องเรื่องความหนาแน่นของจุด และคุณเลื่อนดูอาการนี้สด ๆ ได้จากแถบเลื่อนในเกมถอดชิ้นส่วนข้างบน


ขอบคุณที่อ่านมาจนถึงตรงนี้

คำว่า amateur ที่ Andy ใช้เรียกงานตัวเองในปี 2006 อ่านว่า "แอมะเชอร์" ภาษาไทยแปลกันว่ามือสมัครเล่น ซึ่งฟังดูเหมือนคำด้อยค่า แต่รากของมันคือคำละติน amator ที่แปลว่า คนที่ทำเพราะรัก ไม่ได้แปลว่าทำไม่เก่ง แปลว่าทำโดยไม่มีใครจ้าง

คุณเพิ่งอ่านบทความยาวเรื่องโค้ดที่พ่นโดนัทออกมาบนเทอร์มินัล มันไม่ได้ทำให้ได้เงิน ไม่ได้อยู่ในข้อสอบ ไม่มีใครสั่งให้เข้าใจ

ของที่เดินผ่านมาทั้งบท

ถ้าอยากเดินต่อ ปากทางอยู่ตรงนี้

และถ้าชอบแนวสอนจากศูนย์พร้อมของให้เล่นแบบนี้ ผมเขียนไว้อีกบทที่ แบ่งช่วงแล้วคิดจากข้างในออกมา ครับ


แหล่งอ้างอิง

  1. Andy Sloane, "Donut math: how donut.c works" · a1k0n.net (เผยแพร่ 20 ก.ค. 2011 · สืบค้น 27 ส.ค. 2026) แหล่งของสูตรและค่าคงที่ทุกตัวในบทความนี้ เรียบเรียงและอธิบายใหม่ด้วยคำตัวเอง
  2. Andy Sloane, "Have a donut." · a1k0n.net (เผยแพร่ 15 ก.ย. 2006 · สืบค้น 27 ส.ค. 2026) ที่มาของประโยค amateurish
  3. Andy Sloane, "Embellishing the donut: an old-school CG cliche" · a1k0n.net (เผยแพร่ 20 ก.ย. 2006 · สืบค้น 27 ส.ค. 2026)
  4. IOCCC 2006, "sloane · Homer's favorite" · ioccc.org (สืบค้น 27 ส.ค. 2026) หน้าผลรางวัลที่ยืนยันสาขา
  5. Andy Sloane, "Donut math, optimized" · a1k0n.net (เผยแพร่ 13 ม.ค. 2021 · สืบค้น 27 ส.ค. 2026) ที่มาของคำอธิบายเรื่องการเลี่ยงไลบรารีคณิตศาสตร์และเวอร์ชันจำนวนเต็ม
  6. Andy Sloane, "From ASCII to ASIC: Porting donut.c to a tiny slice of silicon" · a1k0n.net (เผยแพร่ 10 ม.ค. 2025 · สืบค้น 27 ส.ค. 2026)