- สร้างแอนิเมชัน Rick ด้วย โค้ด GLSL เพียง 240 บรรทัด โดยไม่ใช้รูปภาพหรือไลบรารี ให้ GPU shader คำนวณสีของแต่ละพิกเซลตามเวลา
- แกนหลักคือ
color_for_pixel(pixel, time) ที่ถูกเรียกในทุกพิกเซล และ signed distance field (SDF) ซึ่งเป็นโครงสร้างที่แทนด้านใน/ด้านนอกและขอบของรูปทรงด้วยค่าระยะทาง
- ใบหน้าประกอบขึ้นจากรูปทรง SDF เช่น
round_rect(), circle(), star(), bezier(), parabola() โดยนำมาย้ายตำแหน่ง สเกล มิเรอร์ ทำซ้ำ และรวมแบบยูเนียน
- คลื่นของผม การทำซ้ำของฟัน เส้นขอบริมฝีปาก การกะพริบตา การเคลื่อนของรูม่านตา และการส่ายหัว ถูกเพิ่มทีละขั้นด้วย domain warping,
mod(), sin(time), noise()
- เวอร์ชันสมบูรณ์รวมพื้นหลังพอร์ทัลด้วย แต่ในเอดิเตอร์ไม่มีการ export วิดีโอ จึงต้องใช้ workflow แยกบน macOS ด้วย
glslviewer และ ffmpeg
โครงสร้าง shader ที่คำนวณสีรายพิกเซล
- แอนิเมชันเขียนด้วย OpenGL Shading Language (GLSL)
- สามารถรันและแก้ตัวอย่างได้ทันทีใน live coding editor บนหน้าเว็บ
- entry point พื้นฐานคือฟังก์ชัน
color_for_pixel(vec2 pixel, float time)
- GPU จะเรียกฟังก์ชันนี้กับทุกพิกเซลใน preview
- คำถามที่ฟังก์ชันตอบคือ “ณ เวลานี้ พิกเซลนี้ควรเป็นสีอะไร”
- ตัวอย่างง่าย ๆ ใส่ค่า
pixel.x, pixel.y, time ลงในช่องสี เพื่อ visualize ตำแหน่งพิกเซลและการเปลี่ยนแปลงตามเวลา
time คือจำนวนวินาทีที่ผ่านไปหลังการแก้ไขครั้งล่าสุด และจะเพิ่มขึ้นต่อเนื่อง
วิธีวาดรูปทรงด้วย SDF
- วาดวงกลมได้โดยใช้
length(pixel) หาระยะจากจุดกำเนิดถึงพิกเซล แล้วนำไปเทียบกับรัศมี
- ฟังก์ชัน
circle() ไม่ได้คืนค่า bool ที่บอกแค่ด้านใน/ด้านนอก แต่คืนค่า ระยะถึงเส้นรอบวง
- ด้านในรูปทรงเป็นค่าลบ
- ด้านนอกรูปทรงเป็นค่าบวก
- ขอบมีค่าใกล้ 0
- ฟังก์ชันแบบนี้คือฟังก์ชัน signed distance field (SDF)
- เมื่อใช้ค่าระยะทาง จะจัดการการถมสีและเส้นขอบได้ด้วยวิธีเดียวกัน
- ถ้า
dist < -0.01 ให้เป็นสีด้านใน
- ถ้า
dist < 0.0 ให้เป็นเส้นขอบสีดำ
- รูปทรง SDF หลายชิ้นรวมกันได้ด้วย
min()
- หากทำเส้นขอบหัวและหูของ Rick แยกกัน จะเกิดเส้นที่ไม่จำเป็นระหว่างสองส่วน
- เมื่อรวมค่าระยะทางทั้งสองด้วย
min() จะเหลือเฉพาะ เส้นขอบของยูเนียน ระหว่างหัวกับหู
- ยังมีวิธีรวมแบบอื่น ๆ และมีลิงก์อ้างอิงไปยัง 2D distance functions ของ Inigo Quilez และเอกสารเรื่องการผสาน primitive
รูปทรงที่ประกอบเป็นใบหน้า Rick
- รูปศีรษะสร้างด้วย
round_rect() และเพิ่มหูด้วย round_rect() อีกชิ้น
- ปรับตัวเลขโดยซ้อนภาพ Rick จากโปสเตอร์ซีซัน 1 ให้กะพริบบน preview แล้วเทียบให้ตรง
- ตัวเลขหลายค่ามาจากกระบวนการลองผิดลองถูกนี้
- ค่าสีหาโดยใช้เครื่องมือ eyedropper ในโปรแกรมแก้ไขภาพ
- ดวงตาประกอบจาก SDF วงกลมและ
star() แบบ 6 แฉก
- ลูกตาถูกยืดเล็กน้อยในแนวตั้งด้วยการแปลงพิกัด เช่น
pixel.y *= .93
- รูม่านตาใช้ SDF ดาว 6 แฉก แล้วลบค่าระยะเล็กน้อยเพื่อทำให้มุมมน
- ดวงตาทั้งสองข้างสร้างด้วยการ มิเรอร์ซ้ายขวา โดยใช้
pixel.x = abs(pixel.x) แทนการคัดลอกโค้ด
- จมูก ปาก และคิ้ววาดด้วย
bezier()
- ผมเริ่มจาก
star() 11 แฉก ที่ยืดในแนวตั้ง
- ลำดับของโค้ดที่วาดรูปทรงก็ส่งผลต่อผลลัพธ์เช่นกัน
- สีที่ return ก่อนจะเป็นรูปทรงที่อยู่ด้านหน้า
- ฟันและลิ้นถูกวาดเฉพาะภายในเงื่อนไขของรูปปาก จึงไม่ล้นออกนอกปาก
เทคนิคปรับแต่งผม ฟัน และเส้นตกแต่ง
- ผมรูปดาวแข็ง ๆ ถูกเปลี่ยนให้ดูเป็นคลื่นด้วย domain warping
- domain warping คือวิธีเขย่าพิกัดด้วย offset สุ่มที่อิงตำแหน่งพิกเซล
- ตำแหน่งเดียวกันจะได้รับ offset เดิม ทำให้เกิดการบิดเบี้ยวที่สอดคล้องกันตามเวลา
- มีลิงก์อ้างอิงไปยัง domain warping ของ Inigo Quilez
- ฟันสร้างหนึ่งซี่ด้วย SDF
parabola() แล้วทำซ้ำแนวนอนด้วย mod()
mod(pixel.x, width) ทำให้พิกัด x เริ่มนับจาก 0 ใหม่ทุกช่วงคงที่ จึงทำซ้ำรูปทรงเดิมได้
- ใช้
pixel.y = abs(pixel.y)-.06 เพื่อมิเรอร์ฟันบนและล่าง
- ใช้ offset แกน y ที่อิง
pow(pixel.x, 2.) เพื่อให้ฟันเข้ากับเส้นโค้งรอยยิ้ม
- จำกัดการทำซ้ำไม่ให้เป็นอนันต์ด้วยเงื่อนไขอย่าง
abs(pixel.x+.06) < .194
- ริมฝีปากและเส้นใต้ตาวาดโดยดันเส้นขอบ SDF ออกไปด้านนอก
- เส้นขอบทั่วไปคือ
abs(distance_to_shape) < thickness
- เส้นขอบที่เว้นออกด้านนอกคือ
abs(distance_to_shape - outset) < thickness
- เส้นใต้ตาเพิ่มเงื่อนไขตำแหน่งเพื่อให้แสดงเฉพาะบริเวณใต้ตาบางส่วน
วิธีสร้างการเคลื่อนไหวด้วยเวลา
- แอนิเมชันที่ง่ายที่สุดคือใส่
sin(time) ลงในโค้ด
sin() จะห่อค่า time ที่เพิ่มขึ้นเรื่อย ๆ ให้อยู่ระหว่าง -1 ถึง 1 เพื่อสร้างแอนิเมชันแบบวนซ้ำ
- ปรับช่วงค่าด้วยสเกลและ offset เช่น
sin(time)*.5 + .5
- การหมุนศีรษะของ Rick การหมุนลิ้น และระดับความสูงของคิ้วขยับด้วยวิธีนี้
- มีการเพิ่มฟังก์ชัน
rotateAt() สำหรับคำนวณการหมุน
- การกะพริบตาทำโดย เปลี่ยนสิ่งที่จะวาดตามเวลา
- ถ้า
mod(time, 2.) < .09 จะวาดตาปิด
- ไม่เช่นนั้นจะวาดตาเปิดและรูม่านตา
- การเคลื่อนที่แบบไม่สม่ำเสมอของรูม่านตาใช้
noise()
- ใช้
round(time) ก่อนส่งเข้า noise() เพื่อไม่ให้ตาเคลื่อนที่อย่างลื่นไหลต่อเนื่องตลอดเวลา
- ผมเคลื่อนไหวได้ยืดหยุ่นขึ้นด้วย time domain warping
- แทนที่จะบิดเบี้ยวพื้นที่ จะหน่วงค่าเวลาตามตำแหน่ง
- ยิ่งใกล้ปลายผม การหน่วงเวลาจะต่างกัน ทำให้ทั้งรูปไม่หมุนแข็ง ๆ แต่โค้งงอได้
พื้นหลังพอร์ทัลและการปิดงาน
- เวอร์ชันสุดท้ายเพิ่มเอฟเฟกต์พอร์ทัลด้านหลัง Rick จนเสร็จสมบูรณ์
- เอฟเฟกต์พอร์ทัลอิงจากเอฟเฟกต์ที่สร้างโดยผู้ใช้ ShaderToy valena และมีเชิงอรรถระบุว่าเป็นเวอร์ชันที่ FabriceNeyret2 ย่อให้สั้นลง
- โค้ดให้ความสำคัญกับ ความอ่านง่าย มากกว่าประสิทธิภาพ
- ผลลัพธ์ทั้งหมดรวบรวมการผสานรูปทรง, SDF, warping, การทำซ้ำ, การสลับตามเวลา และการเคลื่อนไหวด้วย noise ที่จำเป็นสำหรับแอนิเมชัน shader 2D ไว้ในตัวอย่างเดียว
Workflow สำหรับ export วิดีโอ
- เอดิเตอร์บนหน้าเว็บยังไม่สามารถ export แอนิเมชันเป็นวิดีโอได้
- วิธีชั่วคราวที่เสนอคือ workflow บน macOS โดยใช้ glslviewer และ ffmpeg
- ตัวอย่างการติดตั้ง dependency มีดังนี้
brew install glslviewer ffmpeg
- สคริปต์ export จะสร้างไดเรกทอรีชั่วคราวและรัน
glslViewer ในโหมด headless
- ความละเอียด:
1920x1080
- sequence: ตั้งแต่
0 วินาทีถึง 7 วินาที
- frame rate:
60
- ไฟล์ผลลัพธ์:
animation.mp4
- ตัวอย่าง live coding บนเครื่องใช้คำสั่งต่อไปนี้
glslViewer shader.frag -w 575 -h 324 --noncurses -x 0 -y 0
Supersampling และจุดเริ่มต้นของบทความ
- เหตุผลที่ขอบรูปทรงเรียบเนียนคือมีการใช้ supersampling อยู่เบื้องหลัง
- supersampling ทำงานโดยเรียก
color_for_pixel() ที่ตำแหน่ง 9 จุดภายในหนึ่งพิกเซลหน้าจอ แล้วแสดงค่าเฉลี่ย
- หากใช้
#version 300 es โหมด “pro” ของเอดิเตอร์จะถูกเปิด และ supersampling อัตโนมัติจะถูกเอาออก
- จุดเริ่มต้นของบทความคือวิดีโอที่เผยแพร่เมื่อ 8 เดือนก่อนชื่อ I Made a 3D Modeler, in C, in a Week
- วิดีโอนั้นมีแอนิเมชันอธิบายอัลกอริทึม marching cubes
- ผู้เขียนรู้สึกว่าการทำให้แม่นยำและรวดเร็วด้วยโปรแกรมแอนิเมชันทั่วไปทำได้ยาก จึงเริ่มทำด้วยโค้ด
- หลังจากนั้นมีคนถามถึงวิธีสร้างแอนิเมชัน จึงสรุปออกมาเป็นบทความนี้
1 ความคิดเห็น
ความคิดเห็นบน Hacker News
โดยพื้นฐานคือเปลี่ยนฟังก์ชัน
stepเป็นaastep: https://github.com/glslify/glsl-aastepเป็นวิธีทำงานที่ละเอียดแน่นและวนซ้ำปรับไปเรื่อย ๆ ซึ่งต่างจากงานพัฒนาเว็บ โปรโตคอล และแอปพลิเคชันที่หลายคนคุ้นเคยมาก
การเปลี่ยน
floatตัวเดียวแล้วกดshift-enterจากนั้นเห็นผลลัพธ์ทันทีนี่ค่อนข้างน่าพอใจcanvasหรือ abstraction อย่าง p5.js ที่อยู่บนนั้น จะให้ความรู้สึกแบบนี้ด้วยไหมไม่แน่ใจว่าหมายถึงการเขียนโปรแกรมกราฟิกโดยรวม หรือเจาะจงกว่านั้นคือการทำงานกับ GPU shader
มีเพลย์ลิสต์ YouTube ที่เกี่ยวข้องของ Inigo Quilez ด้วย: https://www.youtube.com/watch?v=0ifChJ0nJfM&list=PL0EpikNmjs...
การเขียนโปรแกรม shader นี่เป็นคนละโลกจริง ๆ และบทความก็ยอดเยี่ยมมาก
สงสัยว่าถ้าทำสิ่งนี้ใน Vulkan หรือ WebGPU/WebGL จะรู้สึกอย่างไร
ถ้าพูดให้ถูก Vulkan จะเป็นรูปแบบจาก GLSL ไปเป็น SPIR-V
WebGPU ที่รันในเบราว์เซอร์โดยทางเทคนิคแล้วเขียน GLSL โดยตรงไม่ได้ แต่ implementation ของ WebGPU แบบ native สามารถรับ GLSL ได้ และแปลงได้ด้วย
หรือไม่ก็ใช้ WGSL ไปเลย ซึ่งแทบจะเหมือน GLSL ต่างกันแค่ไวยากรณ์ได้แรงบันดาลใจจาก Rust แทนที่จะเป็นไวยากรณ์แนว C
การสร้าง 240 บรรทัดที่มีค่าทศนิยมเหมาะ ๆ ด้วยการลองผิดลองถูก น่าจะใช้เวลาค่อนข้างมาก
binary search ทำด้วยมือก็เร็ว
uniform จะถูกส่งเข้า shader และอัปเดตได้โดยไม่ต้องคอมไพล์ใหม่