2 คะแนน โดย GN⁺ 2025-02-08 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • สร้างแอนิเมชัน 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 ความคิดเห็น

 
GN⁺ 2025-02-08
ความคิดเห็นบน Hacker News
  • ถ้าอยากได้ ขอบแบบ anti-aliasing ที่นุ่มนวลจาก SDF โดยไม่ต้องทำ supersampling pass รอบที่สอง ก็ใช้ standard derivatives ได้เลย
    โดยพื้นฐานคือเปลี่ยนฟังก์ชัน step เป็น aastep: https://github.com/glslify/glsl-aastep
  • เจ๋งมากจริง ๆ นักพัฒนา shader นี่เหมือนอยู่อีกมิติหนึ่งเลย
    เป็นวิธีทำงานที่ละเอียดแน่นและวนซ้ำปรับไปเรื่อย ๆ ซึ่งต่างจากงานพัฒนาเว็บ โปรโตคอล และแอปพลิเคชันที่หลายคนคุ้นเคยมาก
    การเปลี่ยน float ตัวเดียวแล้วกด shift-enter จากนั้นเห็นผลลัพธ์ทันทีนี่ค่อนข้างน่าพอใจ
    • สงสัยว่าการทำงานกับ JavaScript canvas หรือ abstraction อย่าง p5.js ที่อยู่บนนั้น จะให้ความรู้สึกแบบนี้ด้วยไหม
      ไม่แน่ใจว่าหมายถึงการเขียนโปรแกรมกราฟิกโดยรวม หรือเจาะจงกว่านั้นคือการทำงานกับ GPU shader
  • เป็นบทความปูพื้นฐาน GLSL ที่จัดโครงสร้างได้ดีมาก
    มีเพลย์ลิสต์ YouTube ที่เกี่ยวข้องของ Inigo Quilez ด้วย: https://www.youtube.com/watch?v=0ifChJ0nJfM&list=PL0EpikNmjs...
  • วิธีที่เอาภาพอ้างอิงมากะพริบซ้อนบนพรีวิวเพื่อเทียบกับต้นฉบับระหว่างแก้โค้ดนั้นเหมือนกับวิธีที่ใช้ใน แอนิเมชันวาดมือ เป๊ะเลย
    การเขียนโปรแกรม shader นี่เป็นคนละโลกจริง ๆ และบทความก็ยอดเยี่ยมมาก
  • โครงสร้างดีมากสำหรับการปูพื้นฐาน GLSL
    สงสัยว่าถ้าทำสิ่งนี้ใน Vulkan หรือ WebGPU/WebGL จะรู้สึกอย่างไร
    • แทบจะเหมือนกัน Vulkan และ WebGL ต่างก็เขียน GLSL ได้โดยตรง
      ถ้าพูดให้ถูก Vulkan จะเป็นรูปแบบจาก GLSL ไปเป็น SPIR-V
      WebGPU ที่รันในเบราว์เซอร์โดยทางเทคนิคแล้วเขียน GLSL โดยตรงไม่ได้ แต่ implementation ของ WebGPU แบบ native สามารถรับ GLSL ได้ และแปลงได้ด้วย
      หรือไม่ก็ใช้ WGSL ไปเลย ซึ่งแทบจะเหมือน GLSL ต่างกันแค่ไวยากรณ์ได้แรงบันดาลใจจาก Rust แทนที่จะเป็นไวยากรณ์แนว C
  • การที่ใช้เวลา 8 เดือน เพื่อสร้างแอนิเมชันนี้ แสดงให้เห็นถึงความอดทนอย่างมาก
  • สงสัยว่ากระบวนการพัฒนาเป็นการปรับค่าทศนิยมไปเรื่อย ๆ ซ้ำ ๆ หรือว่าใช้ editor แบบไหน
    การสร้าง 240 บรรทัดที่มีค่าทศนิยมเหมาะ ๆ ด้วยการลองผิดลองถูก น่าจะใช้เวลาค่อนข้างมาก
    • ใช้แค่ code editor ที่ฝังอยู่ในหน้านั้นเอง
      binary search ทำด้วยมือก็เร็ว
    • เวลาทำงานแบบนี้ ปกติมักจะตั้ง slider หรือ input สักตัวแล้วเชื่อมเข้ากับ uniform
      uniform จะถูกส่งเข้า shader และอัปเดตได้โดยไม่ต้องคอมไพล์ใหม่