แอป WebGPU แอปแรกของคุณ

แอป WebGPU แรก

เกี่ยวกับ Codelab นี้

subjectอัปเดตล่าสุดเมื่อ เม.ย. 15, 2025
account_circleเขียนโดย Brandon Jones, François Beaufort

1 บทนำ

โลโก้ WebGPU ประกอบด้วยสามเหลี่ยมสีน้ำเงินหลายรูปที่ประกอบกันเป็น "W" สไตล์

WebGPU เป็น API ใหม่ที่ทันสมัยสําหรับการเข้าถึงความสามารถของ GPU ในเว็บแอป

Modern API

ก่อนหน้า WebGPU มี WebGL ซึ่งให้บริการฟีเจอร์บางส่วนของ WebGPU เทคโนโลยีนี้ช่วยให้เว็บมีเนื้อหาที่สมบูรณ์แบบไปอีกระดับ และนักพัฒนาซอฟต์แวร์ก็สร้างสิ่งต่างๆ ที่ยอดเยี่ยมด้วยเทคโนโลยีนี้ แต่อิงตาม API ของ OpenGL ES 2.0 ที่เปิดตัวในปี 2007 ซึ่งอิงตาม OpenGL API ที่เก่ากว่า GPU พัฒนาไปอย่างมากในช่วงเวลาดังกล่าว และ API เดิมที่ใช้เพื่อติดต่อกับ GPU ก็ได้พัฒนาไปเช่นกันด้วย Direct3D 12, Metal และ Vulkan

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

การแสดงผล

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

Compute

นอกจากการแสดงผลแล้ว WebGPU ยังปลดล็อกศักยภาพของ GPU เพื่อทำงานแบบขนานสูงที่มีไว้ใช้งานทั่วไป Compute Shader เหล่านี้สามารถใช้แบบสแตนด์อโลนโดยไม่มีคอมโพเนนต์การแสดงผล หรือใช้เป็นส่วนที่ผสานรวมอย่างแน่นหนาในไปป์ไลน์การแสดงผลก็ได้

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

สิ่งที่คุณจะสร้าง

ใน Codelab นี้ คุณจะได้สร้าง Conway's Game of Life โดยใช้ WebGPU แอปของคุณจะทำสิ่งต่อไปนี้

  • ใช้ความสามารถในการแสดงผลของ WebGPU เพื่อวาดกราฟิก 2 มิติแบบง่าย
  • ใช้ความสามารถในการประมวลผลของ WebGPU เพื่อทำการจำลอง

ภาพหน้าจอของผลิตภัณฑ์ขั้นสุดท้ายของ Codelab นี้

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

สิ่งที่จะได้เรียนรู้

  • วิธีตั้งค่า WebGPU และกำหนดค่า Canvas
  • วิธีวาดเรขาคณิต 2 มิติอย่างง่าย
  • วิธีใช้ Vertex และ Fragment Shader เพื่อแก้ไขสิ่งที่วาด
  • วิธีใช้ Shader แบบประมวลผลเพื่อทำการจำลองอย่างง่าย

Codelab นี้มุ่งเน้นที่การแนะนำแนวคิดพื้นฐานของ WebGPU บทความนี้ไม่ได้มีไว้เพื่อทบทวน API อย่างครอบคลุม และไม่ครอบคลุม (หรือกำหนด) หัวข้อที่เกี่ยวข้องบ่อยครั้ง เช่น คณิตศาสตร์เมทริกซ์ 3 มิติ

สิ่งที่ต้องมี

  • Chrome เวอร์ชันล่าสุด (113 ขึ้นไป) ใน ChromeOS, macOS หรือ Windows WebGPU เป็น API แบบข้ามเบราว์เซอร์และข้ามแพลตฟอร์ม แต่ยังไม่พร้อมให้บริการในบางพื้นที่
  • ความรู้เกี่ยวกับ HTML, JavaScript และ เครื่องมือสำหรับนักพัฒนาเว็บใน Chrome

ไม่จําเป็นต้องคุ้นเคยกับ Graphics API อื่นๆ เช่น WebGL, Metal, Vulkan หรือ Direct3D แต่หากมีประสบการณ์เกี่ยวกับ API เหล่านี้ คุณอาจสังเกตเห็นความคล้ายคลึงกับ WebGPU จำนวนมาก ซึ่งอาจช่วยเริ่มต้นการเรียนรู้ได้

2 ตั้งค่า

รับรหัส

Codelab นี้ไม่มีทรัพยากรที่ต้องพึ่งพา และจะนำคุณทําตามทุกขั้นตอนที่จําเป็นในการสร้างแอป WebGPU คุณจึงไม่ต้องเขียนโค้ดใดๆ เพื่อเริ่มต้นใช้งาน อย่างไรก็ตาม ตัวอย่างที่ใช้งานได้บางส่วนซึ่งใช้เป็นจุดตรวจสอบได้มีอยู่ที่นี่ https://glitch.com/edit/#!/your-first-webgpu-app คุณสามารถดูตัวอย่างและอ้างอิงได้ขณะดำเนินการหากพบปัญหา

ใช้คอนโซลนักพัฒนาซอฟต์แวร์

WebGPU เป็น API ที่ซับซ้อนพอสมควรและมีกฎจำนวนมากที่บังคับใช้การใช้งานอย่างเหมาะสม ยิ่งไปกว่านั้น การทำงานของ API ยังทำให้ยกข้อยกเว้น JavaScript ทั่วไปสำหรับข้อผิดพลาดหลายรายการไม่ได้ ซึ่งทำให้ระบุแหล่งที่มาของปัญหาได้ยากขึ้น

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

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

3 เริ่มต้น WebGPU

เริ่มต้นด้วย <canvas>

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

สร้างเอกสาร HTML ใหม่ที่มีองค์ประกอบ <canvas> รายการเดียว รวมถึงแท็ก <script> ที่เราใช้ค้นหาองค์ประกอบ Canvas (หรือใช้ 00-starter-page.html จาก Glitch)

  • สร้างไฟล์ index.html ด้วยโค้ดต่อไปนี้

index.html

<!doctype html>

<html>
  <head>
    <meta charset="utf-8">
    <title>WebGPU Life</title>
  </head>
  <body>
    <canvas width="512" height="512"></canvas>
    <script type="module">
      const canvas = document.querySelector("canvas");

      // Your WebGPU code will begin here!
    </script>
  </body>
</html>

ขออะแดปเตอร์และอุปกรณ์

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

  1. หากต้องการตรวจสอบว่ามีออบเจ็กต์ navigator.gpu ซึ่งทำหน้าที่เป็นจุดแรกเข้าของ WebGPU หรือไม่ ให้เพิ่มโค้ดต่อไปนี้

index.html

if (!navigator.gpu) {
 
throw new Error("WebGPU not supported on this browser.");
}

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

เมื่อทราบว่าเบราว์เซอร์รองรับ WebGPU แล้ว ขั้นตอนแรกในการเริ่มต้นใช้งาน WebGPU สําหรับแอปของคุณคือการขอ GPUAdapter คุณอาจคิดว่าอะแดปเตอร์เป็นการแสดงฮาร์ดแวร์ GPU เฉพาะในอุปกรณ์ของ WebGPU

  1. หากต้องการรับอะแดปเตอร์ ให้ใช้เมธอด navigator.gpu.requestAdapter() ซึ่งจะแสดงผลเป็นพรอมต์ จึงเหมาะที่จะเรียกใช้ด้วย await

index.html

const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
  throw new Error("No appropriate GPUAdapter found.");
}

หากไม่พบอะแดปเตอร์ที่เหมาะสม ค่า adapter ที่แสดงผลอาจเป็น null คุณจึงต้องจัดการกับกรณีดังกล่าว กรณีนี้อาจเกิดขึ้นหากเบราว์เซอร์ของผู้ใช้รองรับ WebGPU แต่ฮาร์ดแวร์ GPU ไม่มีฟีเจอร์ที่จำเป็นทั้งหมดในการใช้ WebGPU

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

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

  1. รับอุปกรณ์โดยเรียกใช้ adapter.requestDevice() ซึ่งจะแสดงผลลัพธ์เป็นสัญญา

index.html

const device = await adapter.requestDevice();

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

กำหนดค่า Canvas

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

  • โดยก่อนอื่นให้ขอ GPUCanvasContext จาก Canvas โดยเรียก canvas.getContext("webgpu") (การเรียกนี้เหมือนกับที่คุณใช้เพื่อเริ่มต้นคอนテキスト Canvas 2D หรือ WebGL โดยใช้ประเภทคอนテキスト 2d และ webgl ตามลำดับ) จากนั้น context ที่แสดงผลจะต้องเชื่อมโยงกับอุปกรณ์โดยใช้เมธอด configure() ดังนี้

index.html

const context = canvas.getContext("webgpu");
const canvasFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({
  device: device,
  format: canvasFormat,
});

มีตัวเลือก 2-3 รายการที่ส่งผ่านไปได้ แต่ตัวเลือกที่สำคัญที่สุดคือ device ที่คุณจะใช้กับบริบท และ format ซึ่งเป็นรูปแบบพื้นผิวที่บริบทควรใช้

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

แต่ไม่ต้องกังวลไปเพราะ WebGPU จะบอกคุณว่าควรใช้รูปแบบใดสำหรับ Canvas ในเกือบทุกกรณี คุณจะต้องส่งค่าที่แสดงผลโดยการเรียกใช้ navigator.gpu.getPreferredCanvasFormat() ดังที่แสดงด้านบน

ล้าง Canvas

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

หากต้องการดำเนินการดังกล่าวหรือการดำเนินการอื่นๆ ใน WebGPU คุณจะต้องส่งคำสั่งบางอย่างไปยัง GPU เพื่อบอกให้ดำเนินการ

  1. โดยให้อุปกรณ์สร้าง GPUCommandEncoder ซึ่งจะเป็นอินเทอร์เฟซสำหรับการบันทึกคำสั่ง GPU

index.html

const encoder = device.createCommandEncoder();

คำสั่งที่คุณต้องการส่งไปยัง GPU เกี่ยวข้องกับการแสดงผล (ในกรณีนี้คือ การล้างแคนวาส) ขั้นตอนถัดไปจึงต้องใช้ encoder เพื่อเริ่ม Render Pass

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

  1. รับพื้นผิวจากบริบทของ Canvas ที่คุณสร้างไว้ก่อนหน้านี้โดยการเรียกใช้ context.getCurrentTexture() ซึ่งจะแสดงผลพื้นผิวที่มีความกว้างและความสูงของพิกเซลตรงกับแอตทริบิวต์ width และ height ของ Canvas และ format ที่ระบุเมื่อคุณเรียกใช้ context.configure()

index.html

const pass = encoder.beginRenderPass({
  colorAttachments: [{
     view: context.getCurrentTexture().createView(),
     loadOp: "clear",
     storeOp: "store",
  }]
});

พื้นผิวจะแสดงเป็นพร็อพเพอร์ตี้ view ของ colorAttachment พาสการเรนเดอร์กำหนดให้คุณระบุ GPUTextureView แทน GPUTexture ซึ่งจะบอกให้ระบบทราบว่าต้องเรนเดอร์พื้นผิวส่วนใด การดำเนินการนี้สำคัญกับ Use Case ขั้นสูงเท่านั้น ดังนั้นคุณจึงเรียกใช้ createView() ที่นี่โดยไม่มีอาร์กิวเมนต์ในพื้นผิว ซึ่งบ่งบอกว่าคุณต้องการให้พาสการเรนเดอร์ใช้พื้นผิวทั้งหมด

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

  • ค่า loadOp ของ "clear" บ่งบอกว่าคุณต้องการล้างพื้นผิวเมื่อเริ่มการผ่านการแสดงผล
  • ค่า storeOp ของ "store" บ่งบอกว่าเมื่อผ่านการแสดงผลแล้ว คุณต้องการบันทึกผลลัพธ์ของการวาดภาพระหว่างการแสดงผลลงในพื้นผิว

เมื่อการผ่านการแสดงผลเริ่มต้นขึ้นแล้ว คุณไม่ต้องทำอะไรเลย อย่างน้อยก็ตอนนี้ การเริ่มการผ่านการแสดงผลด้วย loadOp: "clear" ก็เพียงพอที่จะล้างมุมมองพื้นผิวและผืนผ้าใบ

  1. จบการผ่านการแสดงผลโดยเพิ่มการเรียกใช้ต่อไปนี้ต่อจาก beginRenderPass()

index.html

pass.end();

โปรดทราบว่าการเรียกใช้เหล่านี้ไม่ได้ทําให้ GPU ทํางาน เป็นเพียงการบันทึกคําสั่งเพื่อให้ GPU ทําในภายหลัง

  1. หากต้องการสร้าง GPUCommandBuffer ให้เรียกใช้ finish() ในโปรแกรมเข้ารหัสคำสั่ง บัฟเฟอร์คําสั่งเป็นแฮนเดิลแบบทึบของคําสั่งที่บันทึกไว้

index.html

const commandBuffer = encoder.finish();
  1. ส่งบัฟเฟอร์คำสั่งไปยัง GPU โดยใช้ queue ของ GPUDevice คิวจะดำเนินการกับคําสั่ง GPU ทั้งหมดเพื่อให้การดําเนินการมีลําดับและซิงค์อย่างถูกต้อง เมธอด submit() ของคิวจะรับอาร์เรย์ของบัฟเฟอร์คําสั่ง แต่ในกรณีนี้คุณมีเพียงรายการเดียว

index.html

device.queue.submit([commandBuffer]);

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

index.html

// Finish the command buffer and immediately submit it.
device.queue.submit([encoder.finish()]);

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

  1. โหลดหน้าเว็บซ้ำ สังเกตว่าภาพพิมพ์แคนวาสเป็นสีดํา ยินดีด้วย ซึ่งหมายความว่าคุณได้สร้างแอป WebGPU รายการแรกเรียบร้อยแล้ว

ภาพพิมพ์แคนวาสสีดําที่ระบุว่าใช้ WebGPU ล้างเนื้อหาแคนวาสเรียบร้อยแล้ว

เลือกสี

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

  1. ในคอล encoder.beginRenderPass() ให้เพิ่มบรรทัดใหม่ที่มี clearValue ไปยัง colorAttachment ดังนี้

index.html

const pass = encoder.beginRenderPass({
  colorAttachments: [{
    view: context.getCurrentTexture().createView(),
    loadOp: "clear",
    clearValue: { r: 0, g: 0, b: 0.4, a: 1 }, // New line
    storeOp: "store",
  }],
});

clearValue จะบอกพาสการแสดงผลว่าควรใช้สีใดเมื่อดำเนินการ clear ที่จุดเริ่มต้นของพาส พจนานุกรมที่ส่งผ่านมี 4 ค่า ได้แก่ r สำหรับสีแดง g สำหรับสีเขียว b สำหรับสีน้ำเงิน และ a สำหรับอัลฟ่า (ความโปร่งใส) แต่ละค่าอยู่ในช่วง 0 ถึง 1 และค่าเหล่านี้จะอธิบายค่าของช่องสีนั้น เช่น

  • { r: 1, g: 0, b: 0, a: 1 } เป็นสีแดงสด
  • { r: 1, g: 0, b: 1, a: 1 } เป็นสีม่วงสด
  • { r: 0, g: 0.3, b: 0, a: 1 } เป็นสีเขียวเข้ม
  • { r: 0.5, g: 0.5, b: 0.5, a: 1 } เป็นสีเทาปานกลาง
  • { r: 0, g: 0, b: 0, a: 0 } คือสีดำโปร่งใสเริ่มต้น

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

  1. เมื่อเลือกสีแล้ว ให้โหลดหน้าเว็บซ้ำ คุณควรเห็นสีที่เลือกในผืนผ้าใบ

ภาพพิมพ์แคนวาสที่ล้างเป็นสีน้ำเงินเข้มเพื่อสาธิตวิธีเปลี่ยนสีล้างเริ่มต้น

4 วาดเรขาคณิต

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

ทำความเข้าใจวิธีที่ GPU วาดภาพ

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

ซึ่งแตกต่างจาก API อย่าง Canvas 2D ที่มีรูปร่างและตัวเลือกมากมายพร้อมให้คุณใช้งาน แต่ GPU จะจัดการกับรูปร่าง (หรือรูปเรขาคณิตพื้นฐานตามที่ WebGPU เรียก) เพียงไม่กี่ประเภทเท่านั้น ได้แก่ จุด เส้น และสามเหลี่ยม คุณจะใช้รูปสามเหลี่ยมเท่านั้นในโค้ดแล็บนี้

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

จุดเหล่านี้หรือจุดยอดจะระบุเป็นค่า X, Y และ (สำหรับเนื้อหา 3 มิติ) Z ซึ่งกำหนดจุดในระบบพิกัดคาร์ทีเซียนที่ WebGPU หรือ API ที่เกี่ยวข้องกำหนด โครงสร้างของระบบพิกัดเป็นวิธีที่ง่ายที่สุดในการคิดเกี่ยวกับความสัมพันธ์กับผืนผ้าใบในหน้าเว็บ ไม่ว่าผืนผ้าใบจะกว้างหรือสูงเพียงใด ขอบด้านซ้ายจะอยู่ที่ -1 บนแกน X เสมอ และขอบด้านขวาจะอยู่ที่ +1 บนแกน X เสมอ ในทํานองเดียวกัน ขอบด้านล่างจะเป็น -1 บนแกน Y เสมอ และขอบด้านบนจะเป็น +1 บนแกน Y ซึ่งหมายความว่า (0, 0) จะเป็นจุดศูนย์กลางของผืนผ้าใบเสมอ (-1, -1) จะเป็นมุมล่างซ้ายเสมอ และ (1, 1) จะเป็นมุมขวาบนเสมอ ซึ่งเรียกว่าพื้นที่คลิป

กราฟง่ายๆ ที่แสดงภาพพื้นที่พิกัดอุปกรณ์ที่แปลงเป็นรูปแบบมาตรฐาน

โดยทั่วไปแล้ว จะไม่มีการกำหนดจุดยอดในระบบพิกัดนี้ตั้งแต่แรก ดังนั้น GPU จึงใช้โปรแกรมขนาดเล็กที่เรียกว่า Vertex Shader เพื่อดำเนินการทางคณิตศาสตร์ที่จำเป็นในการเปลี่ยนจุดยอดให้เป็นพื้นที่คลิป รวมถึงการคำนวณอื่นๆ ที่จำเป็นในการวาดจุดยอด เช่น Shader อาจใช้ภาพเคลื่อนไหวบางอย่างหรือคำนวณทิศทางจากจุดยอดไปยังแหล่งกำเนิดแสง โปรแกรมเปลี่ยนสีเหล่านี้เขียนโดยคุณซึ่งเป็นนักพัฒนา WebGPU และช่วยให้คุณควบคุมวิธีการทำงานของ GPU ได้อย่างน่าทึ่ง

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

จากนั้นระบบจะรวบรวมผลลัพธ์ของสีพิกเซลเหล่านั้นให้เป็นพื้นผิว ซึ่งจะแสดงบนหน้าจอได้

กำหนดจุดยอด

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

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

กราฟพิกัดอุปกรณ์ที่แปลงเป็นรูปแบบมาตรฐานซึ่งแสดงพิกัดของมุมสี่เหลี่ยมจัตุรัส

หากต้องการส่งพิกัดเหล่านั้นไปยัง GPU คุณต้องใส่ค่าไว้ใน TypedArray หากคุณยังไม่คุ้นเคย TypedArrays คือกลุ่มออบเจ็กต์ JavaScript ที่ช่วยให้คุณจัดสรรบล็อกหน่วยความจำที่อยู่ติดกันและตีความแต่ละองค์ประกอบในชุดเป็นประเภทข้อมูลหนึ่งๆ ได้ เช่น ใน Uint8Array องค์ประกอบแต่ละรายการในอาร์เรย์จะเป็นไบต์แบบไม่ลงนามรายการเดียว TypedArrays เหมาะอย่างยิ่งสำหรับการส่งข้อมูลไปมากับ API ที่ไวต่อเลย์เอาต์หน่วยความจำ เช่น WebAssembly, WebAudio และ (แน่นอน) WebGPU

สำหรับตัวอย่างสี่เหลี่ยมจัตุรัส เนื่องจากค่าเป็นเศษส่วน Float32Array จึงเหมาะกว่า

  1. สร้างอาร์เรย์ที่มีตำแหน่งจุดยอดทั้งหมดในแผนภาพโดยวางการประกาศอาร์เรย์ต่อไปนี้ในโค้ด ตำแหน่งที่ดีในการวางคือบริเวณด้านบนใต้การเรียกใช้ context.configure()

index.html

const vertices = new Float32Array([
//   X,    Y,
  -0.8, -0.8,
   0.8, -0.8,
   0.8,  0.8,
  -0.8,  0.8,
]);

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

แต่มีปัญหาเกิดขึ้น คุณยังจำได้ไหมว่า GPU ทำงานด้วยรูปสามเหลี่ยม ซึ่งหมายความว่าคุณต้องระบุจุดยอดเป็นกลุ่มๆ ละ 3 จุด คุณมี 1 กลุ่ม 4 คน วิธีแก้ปัญหาคือใช้จุดยอด 2 จุดซ้ำเพื่อสร้างสามเหลี่ยม 2 รูปที่ใช้เส้นขอบร่วมกันผ่านตรงกลางของสี่เหลี่ยมจัตุรัส

แผนภาพแสดงวิธีใช้จุดยอดทั้ง 4 ของสี่เหลี่ยมจัตุรัสเพื่อสร้างสามเหลี่ยม 2 รูป

หากต้องการสร้างสี่เหลี่ยมจัตุรัสจากแผนภาพ คุณต้องระบุจุดยอด (-0.8, -0.8) และ (0.8, 0.8) 2 ครั้ง โดย 1 ครั้งสำหรับสามเหลี่ยมสีน้ำเงินและอีก 1 ครั้งสำหรับสามเหลี่ยมสีแดง (คุณเลือกแบ่งสี่เหลี่ยมจัตุรัสด้วยมุมอีก 2 มุมแทนก็ได้ ผลลัพธ์จะเหมือนกัน)

  1. อัปเดตอาร์เรย์ vertices ก่อนหน้าให้มีลักษณะดังนี้

index.html

const vertices = new Float32Array([
//   X,    Y,
  -0.8, -0.8, // Triangle 1 (Blue)
   0.8, -0.8,
   0.8,  0.8,

  -0.8, -0.8, // Triangle 2 (Red)
   0.8,  0.8,
  -0.8,  0.8,
]);

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

สร้างบัฟเฟอร์เวิร์กเทกซ์

GPU วาดจุดยอดด้วยข้อมูลจากอาร์เรย์ JavaScript ไม่ได้ GPU มักจะมีหน่วยความจำของตัวเองที่ได้รับการเพิ่มประสิทธิภาพอย่างมากสำหรับการเรนเดอร์ ดังนั้นข้อมูลที่คุณต้องการให้ GPU ใช้ขณะวาดภาพจะต้องอยู่ในหน่วยความจำดังกล่าว

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

  1. หากต้องการสร้างบัฟเฟอร์เพื่อเก็บเวิร์กเทอร์ ให้เพิ่มการเรียกใช้ต่อไปนี้ไปยัง device.createBuffer() หลังการกําหนดอาร์เรย์ vertices

index.html

const vertexBuffer = device.createBuffer({
  label: "Cell vertices",
  size: vertices.byteLength,
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});

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

ถัดไป ให้ระบุขนาดของบัฟเฟอร์เป็นไบต์ คุณต้องมีบัฟเฟอร์ขนาด 48 ไบต์ ซึ่งกำหนดโดยการคูณขนาดของตัวเลขทศนิยม 32 บิต ( 4 ไบต์) ด้วยจํานวนตัวเลขทศนิยมในอาร์เรย์ vertices (12) แต่ TypedArrays จะคำนวณ byteLength ให้คุณอยู่แล้ว คุณจึงใช้ค่าดังกล่าวเมื่อสร้างบัฟเฟอร์ได้

สุดท้าย คุณต้องระบุการใช้งานของบัฟเฟอร์ นี่เป็น Flag GPUBufferUsage อย่างน้อย 1 รายการ โดยรวม Flag หลายรายการเข้าด้วยกันด้วยโอเปอเรเตอร์ | ( bitwise OR) ในกรณีนี้ คุณต้องระบุว่าต้องการให้ใช้บัฟเฟอร์สำหรับข้อมูลเวิร์กเท็กซ์ (GPUBufferUsage.VERTEX) และต้องการให้คัดลอกข้อมูลลงในบัฟเฟอร์ได้ด้วย (GPUBufferUsage.COPY_DST)

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

เมื่อสร้างบัฟเฟอร์เป็นครั้งแรก ระบบจะเริ่มต้นหน่วยความจำที่มีให้เป็น 0 การเปลี่ยนเนื้อหาทำได้หลายวิธี แต่วิธีที่ง่ายที่สุดคือการเรียกใช้ device.queue.writeBuffer() ด้วย TypedArray ที่ต้องการคัดลอก

  1. หากต้องการคัดลอกข้อมูลเวิร์กเท็กซ์ลงในหน่วยความจำของบัฟเฟอร์ ให้เพิ่มโค้ดต่อไปนี้

index.html

device.queue.writeBuffer(vertexBuffer, /*bufferOffset=*/0, vertices);

กำหนดเลย์เอาต์เวิร์กเซสชัน

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

  • กำหนดโครงสร้างข้อมูลเวิร์กเซ็ตด้วยพจนานุกรม GPUVertexBufferLayout

index.html

const vertexBufferLayout = {
  arrayStride: 8,
  attributes: [{
    format: "float32x2",
    offset: 0,
    shaderLocation: 0, // Position, see vertex shader
  }],
};

ข้อมูลนี้อาจดูสับสนเล็กน้อยในตอนแรก แต่อธิบายได้ค่อนข้างง่าย

สิ่งแรกที่ให้คือ arrayStride นี่คือจำนวนไบต์ที่ GPU ต้องการข้ามไปข้างหน้าในบัฟเฟอร์เมื่อกำลังมองหาเวิร์กเท็กซ์ถัดไป แต่ละจุดยอดของสี่เหลี่ยมจัตุรัสประกอบด้วยตัวเลขทศนิยม 32 บิต 2 รายการ ดังที่กล่าวไว้ก่อนหน้านี้ จํานวนเต็ม 32 บิตมีขนาด 4 ไบต์ จํานวนเต็ม 2 รายการจึงมีขนาด 8 ไบต์

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

ในแอตทริบิวต์เดียว คุณต้องกำหนด format ของข้อมูลก่อน ซึ่งมาจากรายการประเภท GPUVertexFormat ที่อธิบายข้อมูลเวิร์กเท็กซ์แต่ละประเภทที่ GPU เข้าใจ จุดยอดแต่ละจุดมีเลขทศนิยม 32 บิต 2 รายการ คุณจึงใช้รูปแบบ float32x2 หากข้อมูลเวิร์กเท็กซ์ประกอบด้วยจำนวนเต็มแบบไม่ลงนาม 16 บิต 4 รายการแทน คุณจะใช้ uint16x4 แทน คุณเห็นรูปแบบไหม

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

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

โปรดทราบว่าแม้ว่าคุณจะกําหนดค่าเหล่านี้ในตอนนี้ แต่คุณยังไม่ได้ส่งค่าเหล่านั้นไปยัง WebGPU API เราจะพูดถึงเรื่องนี้ในภายหลัง แต่วิธีที่ง่ายที่สุดในการคิดถึงค่าเหล่านี้คือเมื่อคุณกําหนดจุดยอด คุณจึงกําลังตั้งค่าไว้เพื่อใช้ในภายหลัง

เริ่มต้นด้วยชิดเดอร์

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

Shader คือโปรแกรมขนาดเล็กที่คุณเขียนและเรียกใช้ใน GPU แต่ละชิดเดอร์จะทำงานในระยะของข้อมูลที่แตกต่างกัน ได้แก่ ระยะการประมวลผลเวิร์กเทอร์ซ ระยะการประมวลผลเศษ หรือระยะการประมวลผลทั่วไป เนื่องจากอยู่ใน GPU จึงมีโครงสร้างที่เข้มงวดกว่า JavaScript ทั่วไป แต่โครงสร้างดังกล่าวช่วยให้ระบบทำงานได้อย่างรวดเร็วและที่สำคัญคือทํางานแบบขนานกันได้

Shader ใน WebGPU เขียนด้วยภาษาการจัดแสงที่เรียกว่า WGSL (WebGPU Shading Language) WGSL มีไวยากรณ์คล้ายกับ Rust เล็กน้อย โดยมีฟีเจอร์ที่มุ่งเน้นที่การทํางานของ GPU ประเภทต่างๆ (เช่น เวกเตอร์และคณิตศาสตร์เมทริกซ์) ให้ง่ายและรวดเร็วยิ่งขึ้น การสอนภาษาแรเงาทั้งหมดอยู่นอกเหนือขอบเขตของ Codelab นี้ แต่หวังว่าคุณจะได้เรียนรู้พื้นฐานบางอย่างไปพร้อมกับดูตัวอย่างง่ายๆ

ระบบจะส่งผ่านเฉดสีเป็นสตริงไปยัง WebGPU

  • สร้างที่สำหรับป้อนโค้ด Shader โดยคัดลอกข้อมูลต่อไปนี้ลงในโค้ดใต้ vertexBufferLayout

index.html

const cellShaderModule = device.createShaderModule({
  label: "Cell shader",
  code: `
    // Your shader code will go here
  `
});

หากต้องการสร้างชิเดอร์ที่คุณเรียกว่า device.createShaderModule() ให้ระบุ label และ WGSL code เป็นสตริง (ไม่บังคับ) (โปรดทราบว่าคุณใช้เครื่องหมายแบ็กทิกที่นี่เพื่ออนุญาตให้ใช้สตริงหลายบรรทัดได้) เมื่อคุณเพิ่มโค้ด WGSL ที่ถูกต้อง ฟังก์ชันจะแสดงผลออบเจ็กต์ GPUShaderModule พร้อมผลลัพธ์ที่คอมไพล์

กำหนด Vertex Shader

เริ่มต้นด้วย Vertex Shader เนื่องจาก GPU จะเริ่มที่จุดนี้ด้วย

เวิร์กเชดเวอร์เทกซ์จะกำหนดเป็นฟังก์ชัน และ GPU จะเรียกใช้ฟังก์ชันนั้น 1 ครั้งสำหรับทุกๆ เวิร์กเทกซ์ใน vertexBuffer เนื่องจาก vertexBuffer มีตำแหน่ง (จุดยอด) 6 ตำแหน่ง ระบบจึงเรียกใช้ฟังก์ชันที่คุณกำหนด 6 ครั้ง ทุกครั้งที่มีการเรียกใช้ จะมีการส่งตำแหน่งอื่นจาก vertexBuffer ไปยังฟังก์ชันเป็นอาร์กิวเมนต์ และหน้าที่ของฟังก์ชันเวิร์กเชดร์คือแสดงผลตำแหน่งที่สอดคล้องกันในคลิป

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

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

  1. สร้างฟังก์ชัน @vertex ว่าง ดังนี้

index.html (โค้ด createShaderModule)

@vertex
fn vertexMain() {

}

แต่การดำเนินการดังกล่าวไม่ถูกต้อง เนื่องจาก Vertex Shader ต้องแสดงผลตำแหน่งสุดท้ายของ Vertex ที่ประมวลผลในพื้นที่คลิปเป็นอย่างน้อย ซึ่งจะแสดงเป็นเวกเตอร์ 4 มิติเสมอ เวกเตอร์เป็นองค์ประกอบที่ใช้กันทั่วไปในโปรแกรมเปลี่ยนสี จึงทำให้ระบบถือว่าเวกเตอร์เป็นองค์ประกอบพื้นฐานระดับพรีเมียมในภาษา โดยมีประเภทเป็นของตัวเอง เช่น vec4f สำหรับเวกเตอร์ 4 มิติ นอกจากนี้ยังมีเวกเตอร์ 2 มิติ (vec2f) และเวกเตอร์ 3 มิติ (vec3f) ประเภทคล้ายกันด้วย

  1. หากต้องการระบุว่าค่าที่แสดงผลเป็นตําแหน่งที่ต้องระบุ ให้ทําเครื่องหมายด้วยแอตทริบิวต์ @builtin(position) ใช้สัญลักษณ์ -> เพื่อระบุว่าฟังก์ชันแสดงผลค่านี้

index.html (โค้ด createShaderModule)

@vertex
fn vertexMain() -> @builtin(position) vec4f {

}

แน่นอนว่าหากฟังก์ชันมีประเภทผลลัพธ์ คุณต้องแสดงผลค่าในส่วนเนื้อหาของฟังก์ชัน คุณสามารถสร้าง vec4f ใหม่เพื่อแสดงผลได้โดยใช้ไวยากรณ์ vec4f(x, y, z, w) ค่า x, y และ z ทั้งหมดเป็นตัวเลขทศนิยมซึ่งระบุตำแหน่งของจุดยอดในพื้นที่คลิปในค่าที่แสดง

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

index.html (โค้ด createShaderModule)

@vertex
fn vertexMain() -> @builtin(position) vec4f {
  return vec4f(0, 0, 0, 1); // (X, Y, Z, W)
}

สิ่งที่คุณต้องการแทนคือการใช้ข้อมูลจากบัฟเฟอร์ที่คุณสร้างขึ้น ซึ่งทำได้โดยการประกาศอาร์กิวเมนต์สำหรับฟังก์ชันด้วยแอตทริบิวต์และประเภท @location() ที่ตรงกับที่คุณอธิบายไว้ใน vertexBufferLayout คุณได้ระบุ shaderLocation ของ 0 ดังนั้นในโค้ด WGSL ให้ทําเครื่องหมายอาร์กิวเมนต์ด้วย @location(0) นอกจากนี้ คุณยังกําหนดรูปแบบเป็น float32x2 ซึ่งเป็นเวกเตอร์ 2 มิติ ดังนั้นใน WGSL อาร์กิวเมนต์ของคุณจะเป็น vec2f คุณตั้งชื่ออะไรก็ได้ แต่เนื่องจากค่าเหล่านี้แสดงตำแหน่งเวิร์กเท็กซ์ ชื่ออย่าง pos จึงดูเหมาะสม

  1. เปลี่ยนฟังก์ชัน Shader เป็นโค้ดต่อไปนี้

index.html (โค้ด createShaderModule)

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {
  return vec4f(0, 0, 0, 1);
}

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

  1. แสดงตำแหน่งที่ถูกต้องโดยระบุคอมโพเนนต์ตำแหน่งที่จะใช้อย่างชัดเจน ดังนี้

index.html (โค้ด createShaderModule)

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {
  return vec4f(pos.x, pos.y, 0, 1);
}

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

  1. เขียนคำสั่ง return ใหม่โดยใช้โค้ดต่อไปนี้

index.html (โค้ด createShaderModule)

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {
  return vec4f(pos, 0, 1);
}

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

กำหนด Shader ระดับเศษ

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

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

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

ฟังก์ชัน Shader ระดับเศษส่วนของ WGSL จะแสดงด้วยแอตทริบิวต์ @fragment และจะแสดงผล vec4f ด้วย ในกรณีนี้ เวกเตอร์แสดงถึงสี ไม่ใช่ตำแหน่ง ค่าที่แสดงผลต้องมีแอตทริบิวต์ @location เพื่อระบุ colorAttachment จาก beginRenderPass ที่ระบบจะเขียนสีที่แสดงผล เนื่องจากคุณมีไฟล์แนบเพียงรายการเดียว ตำแหน่งจึงเป็น 0

  1. สร้างฟังก์ชัน @fragment ว่าง ดังนี้

index.html (โค้ด createShaderModule)

@fragment
fn fragmentMain() -> @location(0) vec4f {

}

องค์ประกอบ 4 รายการของเวกเตอร์ที่แสดงผลคือค่าสีของสีแดง เขียว น้ำเงิน และอัลฟ่า ซึ่งจะตีความในลักษณะเดียวกับ clearValue ที่คุณตั้งค่าไว้ใน beginRenderPass ก่อนหน้านี้ ดังนั้น vec4f(1, 0, 0, 1) คือสีแดงสด ซึ่งดูเหมือนจะเป็นสีที่เหมาะสมกับสี่เหลี่ยมของคุณ แต่คุณตั้งค่าเป็นสีใดก็ได้

  1. ตั้งค่าเวกเตอร์สีที่แสดงผล เช่น

index.html (โค้ด createShaderModule)

@fragment
fn fragmentMain() -> @location(0) vec4f {
  return vec4f(1, 0, 0, 1); // (Red, Green, Blue, Alpha)
}

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

สรุปคือหลังจากเพิ่มโค้ด Shader ที่อธิบายไว้ข้างต้น ตอนนี้การเรียก createShaderModule จะมีลักษณะดังนี้

index.html

const cellShaderModule = device.createShaderModule({
  label: 'Cell shader',
  code: `
    @vertex
    fn vertexMain(@location(0) pos: vec2f) ->
      @builtin(position) vec4f {
      return vec4f(pos, 0, 1);
    }

    @fragment
    fn fragmentMain() -> @location(0) vec4f {
      return vec4f(1, 0, 0, 1);
    }
  `
});

สร้างไปป์ไลน์การแสดงผล

โมดูล Shader ใช้สำหรับการแสดงผลเพียงอย่างเดียวไม่ได้ แต่ต้องใช้เป็นส่วนหนึ่งของ GPURenderPipeline ซึ่งสร้างขึ้นโดยการเรียกใช้ device.createRenderPipeline() ไปป์ไลน์การเรนเดอร์จะควบคุมวิธีวาดเรขาคณิต ซึ่งรวมถึงสิ่งต่างๆ เช่น การใช้เชดเดอร์ วิธีตีความข้อมูลในบัฟเฟอร์เวิร์กเท็กซ์ เรขาคณิตประเภทใดที่ควรแสดงผล (เส้น จุด สามเหลี่ยม ฯลฯ) และอื่นๆ

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

  • สร้างไปป์ไลน์การแสดงผล ดังนี้

index.html

const cellPipeline = device.createRenderPipeline({
  label: "Cell pipeline",
  layout: "auto",
  vertex: {
    module: cellShaderModule,
    entryPoint: "vertexMain",
    buffers: [vertexBufferLayout]
  },
  fragment: {
    module: cellShaderModule,
    entryPoint: "fragmentMain",
    targets: [{
      format: canvasFormat
    }]
  }
});

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

ถัดไป คุณต้องระบุรายละเอียดเกี่ยวกับระยะ vertex module คือ GPUShaderModule ที่มี Vertex Shader และ entryPoint จะระบุชื่อฟังก์ชันในโค้ด Shader ที่เรียกใช้สำหรับการเรียกใช้ Vertex ทุกครั้ง (คุณมีฟังก์ชัน @vertex และ @fragment หลายรายการในโมดูลเชดเดอร์เดียวได้) บัฟเฟอร์คืออาร์เรย์ของออบเจ็กต์ GPUVertexBufferLayout ที่อธิบายวิธีแพ็กข้อมูลในบัฟเฟอร์เวิร์กเท็กซ์ที่คุณใช้กับไปป์ไลน์นี้ แต่โชคดีที่คุณได้กําหนดค่านี้ไว้ก่อนหน้านี้ใน vertexBufferLayout โปรดส่งข้อมูลมาที่นี่

สุดท้าย คุณจะเห็นรายละเอียดเกี่ยวกับระยะ fragment ซึ่งรวมถึงโมดูลและ entryPoint ของ Shader เช่น เวิร์กสเตจ ขั้นตอนสุดท้ายคือการกําหนด targets ที่จะใช้กับไปป์ไลน์นี้ นี่เป็นอาร์เรย์ของพจนานุกรมที่ให้รายละเอียด เช่น พื้นผิว format ของไฟล์แนบสีที่ไปป์ไลน์แสดงผล รายละเอียดเหล่านี้ต้องตรงกับพื้นผิวที่ระบุไว้ใน colorAttachments ของพาสการเรนเดอร์ที่ใช้ไปป์ไลน์นี้ พาสการแสดงผลใช้พื้นผิวจากบริบทของ Canvas และใช้ค่าที่คุณบันทึกไว้ใน canvasFormat สำหรับรูปแบบ ดังนั้นคุณจึงต้องส่งรูปแบบเดียวกันที่นี่

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

วาดสี่เหลี่ยมจัตุรัส

ตอนนี้คุณก็มีทุกอย่างที่จำเป็นในการวาดสี่เหลี่ยมจัตุรัสแล้ว

  1. หากต้องการวาดสี่เหลี่ยม ให้กลับไปที่คู่การเรียกใช้ encoder.beginRenderPass() และ pass.end() แล้วเพิ่มคำสั่งใหม่ต่อไปนี้ระหว่างคู่การเรียกใช้

index.html

// After encoder.beginRenderPass()

pass.setPipeline(cellPipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.draw(vertices.length / 2); // 6 vertices

// before pass.end()

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

ถัดไป ให้เรียกใช้ setVertexBuffer() พร้อมบัฟเฟอร์ที่มีจุดยอดของสี่เหลี่ยมจัตุรัส คุณเรียกใช้ด้วย 0 เนื่องจากบัฟเฟอร์นี้สอดคล้องกับองค์ประกอบที่ 0 ในคําจํากัดความ vertex.buffers ของไปป์ไลน์ปัจจุบัน

และสุดท้าย คุณเรียกใช้ draw() ซึ่งดูเรียบง่ายอย่างน่าประหลาดใจหลังจากการตั้งค่าทั้งหมดก่อนหน้านี้ สิ่งที่คุณต้องส่งผ่านมีเพียงจํานวนเวิร์กเทอร์มินัลที่ควรแสดงผล ซึ่งจะดึงมาจากบัฟเฟอร์เวิร์กเทอร์มินัลที่ตั้งค่าไว้ในปัจจุบันและตีความด้วยไปป์ไลน์ที่ตั้งค่าไว้ในปัจจุบัน คุณอาจกำหนดค่าเป็น 6 โดยตรง แต่การคำนวณจากอาร์เรย์เวิร์กเท็กซ์ (12 ตัวเลขทศนิยม / 2 พิกัดต่อเวิร์กเท็กซ์ == 6 เวิร์กเท็กซ์) หมายความว่าหากตัดสินใจที่จะแทนที่สี่เหลี่ยมจัตุรัสด้วยรูปทรงอื่น เช่น วงกลม คุณจะต้องอัปเดตด้วยตนเองน้อยลง

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

สี่เหลี่ยมจัตุรัสสีแดง 1 รูปที่แสดงผลด้วย WebGPU

5 วาดตารางกริด

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

ในส่วนนี้ คุณจะได้เรียนรู้สิ่งต่อไปนี้

  • วิธีส่งตัวแปร (เรียกว่า "Uniform") ไปยังโปรแกรมเปลี่ยนรูปแบบจาก JavaScript
  • วิธีใช้ยูนิฟอร์มเพื่อเปลี่ยนลักษณะการแสดงผล
  • วิธีใช้การสร้างอินสแตนซ์เพื่อวาดเรขาคณิตเดียวกันในหลายรูปแบบ

กําหนดตารางกริด

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

  • กําหนดขนาดตารางกริดโดยการเพิ่มค่าคงที่ไว้ที่ด้านบนของโค้ด JavaScript

index.html

const GRID_SIZE = 4;

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

วิธีหนึ่งที่อาจทำได้คือทำให้บัฟเฟอร์เวิร์กเท็กซ์ใหญ่ขึ้นอย่างมาก และกำหนดสี่เหลี่ยมจัตุรัสขนาด GRID_SIZE คูณ GRID_SIZE ไว้ในบัฟเฟอร์ดังกล่าวในขนาดและตำแหน่งที่เหมาะสม โค้ดสำหรับการดำเนินการดังกล่าวนั้นไม่ยากเกินไป เพียงใช้ลูป for 2-3 รายการและคณิตศาสตร์เล็กน้อย แต่วิธีนี้ก็ไม่ได้ใช้ GPU ให้เกิดประโยชน์สูงสุดและยังใช้หน่วยความจำมากกว่าที่จำเป็นเพื่อให้ได้ผลลัพธ์ที่ต้องการ ส่วนนี้จะกล่าวถึงแนวทางที่เหมาะกับ GPU มากกว่า

สร้างบัฟเฟอร์แบบสอดคล้อง

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

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

  • สร้างบัฟเฟอร์แบบสอดคล้องกันโดยเพิ่มโค้ดต่อไปนี้

index.html

// Create a uniform buffer that describes the grid.
const uniformArray = new Float32Array([GRID_SIZE, GRID_SIZE]);
const uniformBuffer = device.createBuffer({
  label: "Grid Uniforms",
  size: uniformArray.byteLength,
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(uniformBuffer, 0, uniformArray);

โค้ดนี้ควรดูคุ้นเคยมาก เนื่องจากเกือบจะเหมือนกับโค้ดที่คุณใช้สร้างบัฟเฟอร์เวิร์กเท็กซ์ก่อนหน้านี้ นั่นเป็นเพราะระบบจะสื่อสารกับ WebGPU API ผ่านออบเจ็กต์ GPUBuffer เดียวกันกับที่สื่อสารกับเวิร์กเทอร์เทกซ์ โดยความแตกต่างหลักๆ คือ usage ครั้งนี้จะมี GPUBufferUsage.UNIFORM แทน GPUBufferUsage.VERTEX

เข้าถึงยูนิฟอร์มในโปรแกรมเปลี่ยนสี

  • กำหนดชุดเครื่องแบบโดยเพิ่มโค้ดต่อไปนี้

index.html (การเรียก createShaderModule)

// At the top of the `code` string in the createShaderModule() call
@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {
  return vec4f(pos / grid, 0, 1);
}

// ...fragmentMain is unchanged

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

จากนั้น คุณจะใช้เวกเตอร์ตารางกริดได้ตามต้องการในส่วนอื่นๆ ของโค้ด Shader ในโค้ดนี้ คุณจะต้องหารตำแหน่งจุดยอดด้วยเวกเตอร์ตารางกริด เนื่องจาก pos เป็นเวกเตอร์ 2 มิติและ grid เป็นเวกเตอร์ 2 มิติ WGSL จึงทำการหารตามองค์ประกอบ กล่าวคือ ผลลัพธ์จะเหมือนกับการพูดว่า vec2f(pos.x / grid.x, pos.y / grid.y)

การดำเนินการกับเวกเตอร์ประเภทเหล่านี้พบได้ทั่วไปในเชดเดอร์ GPU เนื่องจากเทคนิคการแสดงผลและการประมวลผลจำนวนมากใช้การดำเนินการเหล่านี้

ในกรณีนี้ หมายความว่า (หากคุณใช้ขนาดตารางกริด 4) สี่เหลี่ยมจัตุรัสที่คุณแสดงผลจะมีขนาด 1 ใน 4 ของขนาดเดิม ซึ่งเหมาะอย่างยิ่งหากคุณต้องการใส่ 4 รายการในแถวหรือคอลัมน์

สร้างกลุ่มการเชื่อมโยง

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

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

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

index.html

const bindGroup = device.createBindGroup({
  label: "Cell renderer bind group",
  layout: cellPipeline.getBindGroupLayout(0),
  entries: [{
    binding: 0,
    resource: { buffer: uniformBuffer }
  }],
});

นอกจาก label ที่เป็นมาตรฐานแล้ว คุณต้องมี layout ที่อธิบายประเภททรัพยากรที่กลุ่มการเชื่อมโยงนี้มีด้วย คุณจะเจาะลึกเรื่องนี้ได้ในขั้นตอนถัดไป แต่ตอนนี้คุณสามารถขอเลย์เอาต์กลุ่มการเชื่อมโยงจากไปป์ไลน์ได้ เนื่องจากคุณสร้างไปป์ไลน์ด้วย layout: "auto" ซึ่งจะทำให้ไปป์ไลน์สร้างเลย์เอาต์กลุ่มการเชื่อมโยงโดยอัตโนมัติจากการเชื่อมโยงที่คุณประกาศไว้ในโค้ด Shader เอง ในกรณีนี้ คุณจะขอให้ getBindGroupLayout(0) โดยที่ 0 สอดคล้องกับ @group(0) ที่คุณพิมพ์ไว้ใน Shader

หลังจากระบุเลย์เอาต์แล้ว ให้ระบุอาร์เรย์ entries แต่ละรายการเป็นพจนานุกรมที่มีค่าต่อไปนี้เป็นอย่างน้อย

  • binding ซึ่งสอดคล้องกับค่า @binding() ที่คุณป้อนในโปรแกรมเปลี่ยนสี ในกรณีนี้คือ 0
  • resource ซึ่งเป็นทรัพยากรจริงที่คุณต้องการแสดงต่อตัวแปรที่ดัชนีการเชื่อมโยงที่ระบุ ในกรณีนี้คือบัฟเฟอร์แบบคงที่

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

เชื่อมโยงกลุ่มการเชื่อมโยง

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

  1. กลับไปที่พาสการแสดงผลและเพิ่มบรรทัดใหม่นี้ก่อนเมธอด draw()

index.html

pass.setPipeline(cellPipeline);
pass.setVertexBuffer(0, vertexBuffer);

pass.setBindGroup(0, bindGroup); // New line!

pass.draw(vertices.length / 2);

0 ที่ส่งเป็นอาร์กิวเมนต์แรกจะสอดคล้องกับ @group(0) ในโค้ด Shader คุณกําลังบอกว่า @binding แต่ละรายการที่เป็นส่วนหนึ่งของ @group(0) ใช้ทรัพยากรในกลุ่มการเชื่อมโยงนี้

ตอนนี้บัฟเฟอร์แบบคงที่จะแสดงในโปรแกรมเปลี่ยนรูปแบบแล้ว

  1. รีเฟรชหน้าเว็บ แล้วคุณควรเห็นข้อมูลประมาณนี้

สี่เหลี่ยมจัตุรัสสีแดงเล็กๆ ตรงกลางพื้นหลังสีน้ำเงินเข้ม

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

จัดการเรขาคณิตในโปรแกรมเปลี่ยนสี

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

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

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

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

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

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

  1. แก้ไขโมดูล Vertex Shader ด้วยโค้ดต่อไปนี้

index.html (การเรียก createShaderModule)

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {

  // Add 1 to the position before dividing by the grid size.
  let gridPos = (pos + 1) / grid;

  return vec4f(gridPos, 0, 1);
}

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

ภาพการแสดงผลของผืนผ้าใบที่แบ่งออกเป็นตารางกริด 4x4 โดยมีสี่เหลี่ยมจัตุรัสสีแดงในเซลล์ (2, 2)

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

  1. แปลตำแหน่งของเรขาคณิต เช่น

index.html (การเรียก createShaderModule)

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {

  // Subtract 1 after dividing by the grid size.
  let gridPos = (pos + 1) / grid - 1;

  return vec4f(gridPos, 0, 1);
}

ตอนนี้สี่เหลี่ยมจัตุรัสของคุณอยู่ในเซลล์ (0, 0) อย่างสวยงามแล้ว

ภาพแสดงผืนผ้าใบที่แบ่งออกเป็นตารางกริด 4x4 โดยมีสี่เหลี่ยมจัตุรัสสีแดงในเซลล์ (0, 0)

จะเกิดอะไรขึ้นหากคุณต้องการวางในเซลล์อื่น หาคำตอบได้โดยประกาศเวกเตอร์ cell ใน Shader และป้อนข้อมูลด้วยค่าคงที่ เช่น let cell = vec2f(1, 1)

หากคุณเพิ่มลงใน gridPos ระบบจะยกเลิก - 1 ในอัลกอริทึม ซึ่งไม่ใช่สิ่งที่คุณต้องการ แต่คุณต้องการย้ายสี่เหลี่ยมจัตุรัสเพียง 1 หน่วยตารางกริด (1 ใน 4 ของผืนผ้าใบ) สำหรับแต่ละเซลล์ ดูเหมือนว่าคุณต้องหารด้วย grid อีกรอบ

  1. เปลี่ยนตำแหน่งตารางกริด ดังนี้

index.html (การเรียก createShaderModule)

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {

  let cell = vec2f(1, 1); // Cell(1,1) in the image above
  let cellOffset = cell / grid; // Compute the offset to cell
  let gridPos = (pos + 1) / grid - 1 + cellOffset; // Add it here!

  return vec4f(gridPos, 0, 1);
}

หากรีเฟรชตอนนี้ คุณจะเห็นข้อมูลต่อไปนี้

ภาพการแสดงผลของผืนผ้าใบที่แบ่งออกเป็นตารางกริด 4x4 โดยมีสี่เหลี่ยมจัตุรัสสีแดงอยู่ตรงกลางระหว่างเซลล์ (0, 0), เซลล์ (0, 1), เซลล์ (1, 0) และเซลล์ (1, 1)

อืม ยังไม่ตรงกับที่คุณต้องการ

สาเหตุคือ เนื่องจากพิกัดของผืนผ้าใบมีตั้งแต่ -1 ถึง +1 จึงกว้าง 2 หน่วย ซึ่งหมายความว่าหากต้องการย้ายจุดยอดไป 1 ใน 4 ของผืนผ้าใบ คุณต้องย้ายจุดยอด 0.5 หน่วย ข้อผิดพลาดนี้เกิดขึ้นได้ง่ายเมื่อใช้พิกัด GPU แต่โชคดีที่การแก้ไขก็ง่ายดายเช่นกัน

  1. คูณออฟเซตด้วย 2 ดังนี้

index.html (การเรียก createShaderModule)

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {

  let cell = vec2f(1, 1);
  let cellOffset = cell / grid * 2; // Updated
  let gridPos = (pos + 1) / grid - 1 + cellOffset;

  return vec4f(gridPos, 0, 1);
}

ซึ่งจะช่วยให้คุณได้รับสิ่งที่ต้องการ

ภาพแสดงผืนผ้าใบที่แบ่งออกเป็นตารางกริด 4x4 โดยมีสี่เหลี่ยมจัตุรัสสีแดงในเซลล์ (1, 1)

ภาพหน้าจอจะมีลักษณะดังนี้

ภาพหน้าจอสี่เหลี่ยมจัตุรัสสีแดงบนพื้นหลังสีน้ำเงินเข้ม สี่เหลี่ยมจัตุรัสสีแดงวาดในตำแหน่งเดียวกับที่อธิบายไว้ในแผนภาพก่อนหน้า แต่ไม่มีตารางกริดวางซ้อน

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

วาดอินสแตนซ์

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

วิธีหนึ่งในการแก้ปัญหานี้คือเขียนพิกัดเซลล์ลงในบัฟเฟอร์แบบรวม จากนั้นเรียกใช้ draw 1 ครั้งสำหรับแต่ละสี่เหลี่ยมจัตุรัสในตารางกริด โดยอัปเดตแบบรวมทุกครั้ง แต่วิธีนี้จะช้ามากเนื่องจาก GPU ต้องรอให้ JavaScript เขียนพิกัดใหม่ทุกครั้ง หนึ่งในกุญแจสำคัญในการทำให้ GPU มีประสิทธิภาพดีคือลดเวลาที่ GPU ต้องรอส่วนอื่นๆ ของระบบ

แต่ให้ใช้เทคนิคที่เรียกว่าการสร้างอินสแตนซ์แทน การสร้างอินสแตนซ์เป็นวิธีบอกให้ GPU วาดเรขาคณิตเดียวกันหลายชุดด้วยการเรียก draw เพียงครั้งเดียว ซึ่งเร็วกว่าการเรียก draw 1 ครั้งสำหรับสำเนาทุกรายการ สำเนาของเรขาคณิตแต่ละรายการเรียกว่าอินสแตนซ์

  1. หากต้องการบอก GPU ว่าคุณต้องการอินสแตนซ์สี่เหลี่ยมจัตุรัสเพียงพอที่จะเติมตารางกริด ให้เพิ่มอาร์กิวเมนต์ 1 รายการในการเรียกใช้การวาดที่มีอยู่

index.html

pass.draw(vertices.length / 2, GRID_SIZE * GRID_SIZE);

ซึ่งจะบอกให้ระบบทราบว่าคุณต้องการให้วาดจุดยอด 6 (vertices.length / 2) จุดของสี่เหลี่ยมจัตุรัส 16 (GRID_SIZE * GRID_SIZE) ครั้ง แต่หากรีเฟรชหน้าเว็บ คุณยังคงเห็นข้อมูลต่อไปนี้

รูปภาพที่เหมือนกับแผนภาพก่อนหน้าเพื่อระบุว่าไม่มีการเปลี่ยนแปลง

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

ในโปรแกรมเปลี่ยนรูปแบบ นอกจากแอตทริบิวต์เวิร์กเท็กซ์ เช่น pos ที่มาจากบัฟเฟอร์เวิร์กเท็กซ์แล้ว คุณยังเข้าถึงสิ่งที่เรียกว่าค่าในตัวของ WGSL ได้ด้วย ค่าเหล่านี้คำนวณโดย WebGPU และค่าหนึ่งคือ instance_index instance_index คือตัวเลข 32 บิตแบบไม่ลงนามตั้งแต่ 0 ถึง number of instances - 1 ที่คุณสามารถใช้เป็นส่วนหนึ่งของตรรกะโปรแกรมเปลี่ยนสี ค่าของอินสแตนซ์จะเหมือนกันสำหรับทุกเวิร์กเซตที่ประมวลผลซึ่งเป็นส่วนหนึ่งของอินสแตนซ์เดียวกัน ซึ่งหมายความว่าจะมีการเรียกใช้เวิร์กเชดเวอร์เทกซ์ 6 ครั้งโดยมี instance_index เป็น 0 1 ครั้งสำหรับแต่ละตำแหน่งในบัฟเฟอร์เวิร์กเทกซ์ จากนั้นทำซ้ำอีก 6 ครั้งโดยใช้ instance_index ของ 1 แล้วทำซ้ำอีก 6 ครั้งโดยใช้ instance_index ของ 2 และต่อไปเรื่อยๆ

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

  1. ใช้ instance แทนพิกัดของเซลล์

index.html

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f,
              @builtin(instance_index) instance: u32) ->
  @builtin(position) vec4f {
 
  let i = f32(instance); // Save the instance_index as a float
  let cell = vec2f(i, i); // Updated
  let cellOffset = cell / grid * 2;
  let gridPos = (pos + 1) / grid - 1 + cellOffset;

  return vec4f(gridPos, 0, 1);
}

หากรีเฟรชตอนนี้ คุณจะเห็นว่าคุณมีสี่เหลี่ยมจัตุรัสมากกว่า 1 ช่อง แต่คุณจะเห็นเพียง 16 รายการเท่านั้น

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

นั่นเป็นเพราะพิกัดเซลล์ที่คุณสร้างคือ (0, 0), (1, 1), (2, 2)... ไปจนถึง (15, 15) แต่มีเพียง 4 รายการแรกเท่านั้นที่พอดีกับผืนผ้าใบ หากต้องการสร้างตารางกริดที่ต้องการ คุณต้องเปลี่ยนรูปแบบ instance_index เพื่อให้แต่ละดัชนีแมปกับเซลล์ที่ไม่ซ้ำกันภายในตารางกริด ดังนี้

ภาพการแสดงผลของผืนผ้าใบที่แบ่งออกเป็นตารางกริด 4x4 ช่อง โดยแต่ละเซลล์จะสอดคล้องกับดัชนีอินสแตนซ์เชิงเส้นด้วย

การคำนวณนั้นค่อนข้างตรงไปตรงมา สำหรับค่า X ของเซลล์แต่ละเซลล์ คุณต้องการผลหารที่เหลือของ instance_index และขนาดกริด ซึ่งคุณดำเนินการได้ใน WGSL ด้วยโอเปอเรเตอร์ % และสำหรับค่า Y ของแต่ละเซลล์ คุณต้องการให้ instance_index หารด้วยความกว้างของตารางกริด โดยปัดเศษส่วนที่เหลือออก ซึ่งทำได้โดยใช้ฟังก์ชัน floor() ของ WGSL

  1. เปลี่ยนการคำนวณ เช่น

index.html

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f,
              @builtin(instance_index) instance: u32) ->
  @builtin(position) vec4f {

  let i = f32(instance);
  // Compute the cell coordinate from the instance_index
  let cell = vec2f(i % grid.x, floor(i / grid.x));

  let cellOffset = cell / grid * 2;
  let gridPos = (pos + 1) / grid - 1 + cellOffset;

  return vec4f(gridPos, 0, 1);
}

หลังจากอัปเดตโค้ดแล้ว คุณจะได้ตารางสี่เหลี่ยมจัตุรัสที่รอคอยมาอย่างยาวนาน

สี่เหลี่ยมจัตุรัสสีแดง 4 คอลัมน์ 4 แถวบนพื้นหลังสีน้ำเงินเข้ม

  1. เมื่อใช้งานได้แล้ว ให้กลับไปเพิ่มขนาดตารางกริด

index.html

const GRID_SIZE = 32;

สี่เหลี่ยมจัตุรัสสีแดง 32 แถว 32 คอลัมน์บนพื้นหลังสีน้ำเงินเข้ม

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

6 คะแนนพิเศษ: ทำให้ภาพมีสีสันมากขึ้น

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

ใช้โครงสร้างในเชดเดอร์

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

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

index.html (การเรียก createShaderModule)

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(@location(0) pos: vec2f,
              @builtin(instance_index) instance: u32) ->
  @builtin(position) vec4f {

  let i = f32(instance);
  let cell = vec2f(i % grid.x, floor(i / grid.x));
  let cellOffset = cell / grid * 2;
  let gridPos = (pos + 1) / grid - 1 + cellOffset;
 
  return  vec4f(gridPos, 0, 1);
}
  • แสดงข้อมูลเดียวกันโดยใช้โครงสร้างสำหรับอินพุตและเอาต์พุตของฟังก์ชัน

index.html (การเรียก createShaderModule)

struct VertexInput {
  @location(0) pos: vec2f,
  @builtin(instance_index) instance: u32,
};

struct VertexOutput {
  @builtin(position) pos: vec4f,
};

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(input: VertexInput) -> VertexOutput  {
  let i = f32(input.instance);
  let cell = vec2f(i % grid.x, floor(i / grid.x));
  let cellOffset = cell / grid * 2;
  let gridPos = (input.pos + 1) / grid - 1 + cellOffset;
 
  var output: VertexOutput;
  output.pos = vec4f(gridPos, 0, 1);
  return output;
}

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

ส่งข้อมูลระหว่างฟังก์ชันเวิร์กเท็กซ์และฟังก์ชันเศษส่วน

โปรดทราบว่าฟังก์ชัน @fragment ของคุณนั้นใช้งานได้ง่ายที่สุด

index.html (การเรียก createShaderModule)

@fragment
fn fragmentMain() -> @location(0) vec4f {
  return vec4f(1, 0, 0, 1);
}

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

หากต้องการส่งข้อมูลระหว่างระยะเวิร์กเทกซ์และระยะแฟกเทอร์ คุณจะต้องรวมข้อมูลนั้นไว้ในโครงสร้างเอาต์พุตที่มี @location ที่เราเลือก เนื่องจากคุณต้องการส่งค่าพิกัดเซลล์ ให้เพิ่มค่านั้นลงในโครงสร้าง VertexOutput จากก่อนหน้านี้ แล้วตั้งค่าในฟังก์ชัน @vertex ก่อนส่งคืน

  1. เปลี่ยนค่าผลลัพธ์ของเวิร์กเทกซ์ Shader ดังนี้

index.html (การเรียก createShaderModule)

struct VertexInput {
  @location(0) pos: vec2f,
  @builtin(instance_index) instance: u32,
};

struct VertexOutput {
  @builtin(position) pos: vec4f,
  @location(0) cell: vec2f, // New line!
};

@group(0) @binding(0) var<uniform> grid: vec2f;

@vertex
fn vertexMain(input: VertexInput) -> VertexOutput  {
  let i = f32(input.instance);
  let cell = vec2f(i % grid.x, floor(i / grid.x));
  let cellOffset = cell / grid * 2;
  let gridPos = (input.pos + 1) / grid - 1 + cellOffset;
 
  var output: VertexOutput;
  output.pos = vec4f(gridPos, 0, 1);
  output.cell = cell; // New line!
  return output;
}
  1. ในฟังก์ชัน @fragment ให้รับค่าโดยการเพิ่มอาร์กิวเมนต์ที่มี @location เดียวกัน (ชื่อไม่จำเป็นต้องตรงกัน แต่การติดตามสิ่งต่างๆ จะง่ายขึ้นหากชื่อตรงกัน)

index.html (การเรียก createShaderModule)

@fragment
fn fragmentMain(@location(0) cell: vec2f) -> @location(0) vec4f {
  // Remember, fragment return values are (Red, Green, Blue, Alpha)
  // and since cell is a 2D vector, this is equivalent to:
  // (Red = cell.x, Green = cell.y, Blue = 0, Alpha = 1)
  return vec4f(cell, 0, 1);
}
  1. หรือจะใช้ Struct แทนก็ได้

index.html (การเรียก createShaderModule)

struct FragInput {
  @location(0) cell: vec2f,
};

@fragment
fn fragmentMain(input: FragInput) -> @location(0) vec4f {
  return vec4f(input.cell, 0, 1);
}
  1. อีกทางเลือกหนึ่งคือการใช้โครงสร้างเอาต์พุตของระยะ @vertex ซ้ำ เนื่องจากในโค้ดของคุณ ฟังก์ชันทั้ง 2 อย่างนี้ได้รับการกำหนดไว้ในโมดูล Shader เดียวกัน ซึ่งช่วยให้การผ่านค่าเป็นเรื่องง่ายเนื่องจากชื่อและตําแหน่งมีความสอดคล้องกันโดยธรรมชาติ

index.html (การเรียก createShaderModule)

@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
  return vec4f(input.cell, 0, 1);
}

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

ตารางสี่เหลี่ยมจัตุรัสที่คอลัมน์ด้านซ้ายสุดเป็นสีเขียว แถวล่างสุดเป็นสีแดง และสี่เหลี่ยมจัตุรัสอื่นๆ ทั้งหมดเป็นสีเหลือง

ตอนนี้มีสีให้เลือกมากขึ้น แต่ก็ไม่ได้ดูดีนัก คุณอาจสงสัยว่าทําไมมีเพียงแถวซ้ายและแถวล่างเท่านั้นที่ต่างกัน นั่นเป็นเพราะค่าสีที่คุณแสดงผลจากฟังก์ชัน @fragment ต้องการให้แต่ละช่องอยู่ในช่วง 0 ถึง 1 และค่าที่อยู่นอกช่วงดังกล่าวจะถูกจำกัดให้อยู่ในช่วงดังกล่าว ส่วนค่าของเซลล์จะอยู่ระหว่าง 0 ถึง 32 ตามแต่ละแกน สิ่งที่คุณเห็นคือแถวและคอลัมน์แรกมีค่า 1 เต็มทันทีในช่องสีแดงหรือสีเขียว และทุกเซลล์หลังจากนั้นจะมีค่าเดียวกัน

หากต้องการให้สีเปลี่ยนอย่างราบรื่น คุณต้องแสดงผลค่าเศษทศนิยมสำหรับแต่ละช่องสี โดยควรเริ่มจาก 0 และสิ้นสุดที่ 1 ตามแต่ละแกน ซึ่งหมายความว่าต้องหารด้วย grid อีก

  1. เปลี่ยน Shader ระดับเศษข้อมูล ดังนี้

index.html (การเรียก createShaderModule)

@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
  return vec4f(input.cell/grid, 0, 1);
}

รีเฟรชหน้าเว็บแล้วคุณจะเห็นว่ารหัสใหม่มีการไล่สีที่ดีขึ้นมากในตารางกริดทั้งตาราง

ตารางสี่เหลี่ยมจัตุรัสที่เปลี่ยนจากสีดําเป็นสีแดงเป็นสีเขียวเป็นสีเหลืองในมุมต่างๆ

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

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

  1. เพิ่มสีที่สว่างลงในโปรแกรมเปลี่ยนรูปแบบเศษส่วนของภาพ ดังนี้

การเรียกใช้ createShaderModule

@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
  let c = input.cell / grid;
  return vec4f(c, 1-c.x, 1);
}

ผลลัพธ์ดูดีมาก

ตารางสี่เหลี่ยมจัตุรัสที่เปลี่ยนจากสีแดงเป็นสีเขียวเป็นน้ำเงินเป็นสีเหลืองในมุมต่างๆ

ขั้นตอนนี้ไม่สำคัญ แต่เนื่องจากดูดีกว่า จึงรวมไว้ในไฟล์ต้นทางของจุดตรวจสอบที่เกี่ยวข้อง และภาพหน้าจอที่เหลือใน Codelab นี้แสดงตารางกริดที่มีสีสันมากขึ้น

7 จัดการสถานะเซลล์

ถัดไป คุณต้องควบคุมเซลล์ในตารางกริดที่จะแสดงผล โดยอิงตามสถานะบางอย่างที่เก็บไว้ใน GPU ขั้นตอนนี้สำคัญต่อการจําลองขั้นสุดท้าย

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

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

สร้างบัฟเฟอร์พื้นที่เก็บข้อมูล

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

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

index.html

// Create an array representing the active state of each cell.
const cellStateArray = new Uint32Array(GRID_SIZE * GRID_SIZE);

// Create a storage buffer to hold the cell state.
const cellStateStorage = device.createBuffer({
  label: "Cell State",
  size: cellStateArray.byteLength,
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});

เช่นเดียวกับบัฟเฟอร์เวิร์กเท็กซ์และบัฟเฟอร์แบบคงที่ ให้เรียกใช้ device.createBuffer() ด้วยขนาดที่เหมาะสม แล้วอย่าลืมระบุการใช้งาน GPUBufferUsage.STORAGE ในครั้งนี้

คุณสามารถป้อนข้อมูลลงในบัฟเฟอร์ได้เช่นเดียวกับก่อนหน้านี้โดยป้อนค่าลงใน TypedArray ขนาดเดียวกัน แล้วเรียกใช้ device.queue.writeBuffer() เนื่องจากคุณต้องการดูผลของบัฟเฟอร์ในตารางกริด ให้เริ่มต้นด้วยการกรอกข้อมูลบางอย่างที่คาดการณ์ได้

  1. เปิดใช้งานทุกเซลล์ที่ 3 ด้วยรหัสต่อไปนี้

index.html

// Mark every third cell of the grid as active.
for (let i = 0; i < cellStateArray.length; i += 3) {
  cellStateArray[i] = 1;
}
device.queue.writeBuffer(cellStateStorage, 0, cellStateArray);

อ่านบัฟเฟอร์พื้นที่เก็บข้อมูลในโปรแกรมเปลี่ยนสี

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

  1. อัปเดต Shader ด้วยโค้ดต่อไปนี้

index.html

@group(0) @binding(0) var<uniform> grid: vec2f;
@group(0) @binding(1) var<storage> cellState: array<u32>; // New!

ก่อนอื่น ให้เพิ่มจุดยึดซึ่งอยู่ใต้ยูนิฟอร์มตารางกริด คุณต้องการคง @group ไว้เหมือนเดิมเพื่อให้สอดคล้องกับ grid แต่ต้องเปลี่ยนหมายเลข @binding ประเภท var คือ storage เพื่อแสดงบัฟเฟอร์ประเภทต่างๆ และประเภทที่คุณระบุสำหรับ cellState คืออาร์เรย์ของค่า u32 ไม่ใช่เวกเตอร์เดียว เพื่อจับคู่กับ Uint32Array ใน JavaScript

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

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

  1. อัปเดตโค้ด Shader เพื่อปรับขนาดตำแหน่งตามสถานะ "ทำงานอยู่" ของเซลล์ ค่าสถานะต้องแคสต์เป็น f32 เพื่อให้เป็นไปตามข้อกำหนดด้านความปลอดภัยของประเภทของ WGSL

index.html

@vertex
fn vertexMain(@location(0) pos: vec2f,
              @builtin(instance_index) instance: u32) -> VertexOutput {
  let i = f32(instance);
  let cell = vec2f(i % grid.x, floor(i / grid.x));
  let state = f32(cellState[instance]); // New line!

  let cellOffset = cell / grid * 2;
  // New: Scale the position by the cell's active state.
  let gridPos = (pos*state+1) / grid - 1 + cellOffset;

  var output: VertexOutput;
  output.pos = vec4f(gridPos, 0, 1);
  output.cell = cell;
  return output;
}

เพิ่มบัฟเฟอร์พื้นที่เก็บข้อมูลลงในกลุ่มการเชื่อมโยง

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

  • เพิ่มบัฟเฟอร์พื้นที่เก็บข้อมูล ดังนี้

index.html

const bindGroup = device.createBindGroup({
  label: "Cell renderer bind group",
  layout: cellPipeline.getBindGroupLayout(0),
  entries: [{
    binding: 0,
    resource: { buffer: uniformBuffer }
  },
  // New entry!
  {
    binding: 1,
    resource: { buffer: cellStateStorage }
  }],
});

ตรวจสอบว่า binding ของรายการใหม่ตรงกับ @binding() ของค่าที่สอดคล้องกันในชิดเดอร์

เมื่อดำเนินการเสร็จแล้ว คุณควรรีเฟรชและเห็นรูปแบบปรากฏในตารางกริด

แถบแนวทแยงรูปสี่เหลี่ยมหลากสีจากซ้ายล่างไปขวาบนบนพื้นหลังสีน้ำเงินเข้ม

ใช้รูปแบบบัฟเฟอร์แบบปิงปอง

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

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

// Example simulation. Don't copy into the project!
const state = [1, 0, 0, 0, 0, 0, 0, 0];

function simulate() {
  for (let i = 0; i < state.length-1; ++i) {
    if (state[i] == 1) {
      state[i] = 0;
      state[i+1] = 1;
    }
  }
}

simulate(); // Run the simulation for one step.

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

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

// Example simulation. Don't copy into the project!
const stateA = [1, 0, 0, 0, 0, 0, 0, 0];
const stateB = [0, 0, 0, 0, 0, 0, 0, 0];

function simulate(inArray, outArray) {
  outArray[0] = 0;
  for (let i = 1; i < inArray.length; ++i) {
     outArray[i] = inArray[i-1];
  }
}

// Run the simulation for two step.
simulate(stateA, stateB);
simulate(stateB, stateA);
  1. ใช้รูปแบบนี้ในโค้ดของคุณเองโดยอัปเดตการจัดสรรบัฟเฟอร์พื้นที่เก็บข้อมูลเพื่อสร้างบัฟเฟอร์ที่เหมือนกัน 2 รายการ

index.html

// Create two storage buffers to hold the cell state.
const cellStateStorage = [
  device.createBuffer({
    label: "Cell State A",
    size: cellStateArray.byteLength,
    usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
  }),
  device.createBuffer({
    label: "Cell State B",
     size: cellStateArray.byteLength,
     usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
  })
];
  1. เพื่อช่วยให้เห็นภาพความแตกต่างระหว่างบัฟเฟอร์ 2 รายการ ให้ป้อนข้อมูลที่แตกต่างกันในบัฟเฟอร์

index.html

// Mark every third cell of the first grid as active.
for (let i = 0; i < cellStateArray.length; i+=3) {
  cellStateArray[i] = 1;
}
device.queue.writeBuffer(cellStateStorage[0], 0, cellStateArray);

// Mark every other cell of the second grid as active.
for (let i = 0; i < cellStateArray.length; i++) {
  cellStateArray[i] = i % 2;
}
device.queue.writeBuffer(cellStateStorage[1], 0, cellStateArray);
  1. หากต้องการแสดงบัฟเฟอร์พื้นที่เก็บข้อมูลที่แตกต่างกันในการเรนเดอร์ ให้อัปเดตกลุ่มการเชื่อมโยงให้มีตัวแปร 2 รายการที่แตกต่างกันด้วย ดังนี้

index.html

const bindGroups = [
  device.createBindGroup({
    label: "Cell renderer bind group A",
    layout: cellPipeline.getBindGroupLayout(0),
    entries: [{
      binding: 0,
      resource: { buffer: uniformBuffer }
    }, {
      binding: 1,
      resource: { buffer: cellStateStorage[0] }
    }],
  }),
   device.createBindGroup({
    label: "Cell renderer bind group B",
    layout: cellPipeline.getBindGroupLayout(0),
    entries: [{
      binding: 0,
      resource: { buffer: uniformBuffer }
    }, {
      binding: 1,
      resource: { buffer: cellStateStorage[1] }
    }],
  })
];

ตั้งค่าลูปการแสดงผล

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

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

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

  1. ก่อนอื่น ให้เลือกอัตราในการอัปเดตการจําลอง (200 มิลลิวินาทีถือว่าดี แต่คุณจะช้าหรือเร็วกว่านั้นก็ได้) จากนั้นติดตามจํานวนขั้นตอนของการจําลองที่เสร็จสมบูรณ์

index.html

const UPDATE_INTERVAL = 200; // Update every 200ms (5 times/sec)
let step = 0; // Track how many simulation steps have been run
  1. จากนั้นย้ายโค้ดทั้งหมดที่คุณใช้แสดงผลอยู่ในตอนนี้ไปไว้ในฟังก์ชันใหม่ กําหนดเวลาให้ฟังก์ชันนั้นทําซ้ำตามช่วงเวลาที่ต้องการด้วย setInterval() ตรวจสอบว่าฟังก์ชันอัปเดตจํานวนขั้นตอนด้วย และใช้ข้อมูลดังกล่าวเพื่อเลือกกลุ่มการเชื่อมโยง 2 กลุ่มที่จะเชื่อมโยง

index.html

// Move all of our rendering code into a function
function updateGrid() {
  step++; // Increment the step count
 
  // Start a render pass
  const encoder = device.createCommandEncoder();
  const pass = encoder.beginRenderPass({
    colorAttachments: [{
      view: context.getCurrentTexture().createView(),
      loadOp: "clear",
      clearValue: { r: 0, g: 0, b: 0.4, a: 1.0 },
      storeOp: "store",
    }]
  });

  // Draw the grid.
  pass.setPipeline(cellPipeline);
  pass.setBindGroup(0, bindGroups[step % 2]); // Updated!
  pass.setVertexBuffer(0, vertexBuffer);
  pass.draw(vertices.length / 2, GRID_SIZE * GRID_SIZE);

  // End the render pass and submit the command buffer
  pass.end();
  device.queue.submit([encoder.finish()]);
}

// Schedule updateGrid() to run repeatedly
setInterval(updateGrid, UPDATE_INTERVAL);

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

แถบแนวทแยงรูปสี่เหลี่ยมหลากสีจากซ้ายล่างไปขวาบนบนพื้นหลังสีน้ำเงินเข้ม แถบแนวตั้งสี่เหลี่ยมจัตุรัสหลากสีบนพื้นหลังสีน้ำเงินเข้ม

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

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

8 เรียกใช้การจําลอง

ทีนี้มาต่อกันที่ส่วนสำคัญสุดท้ายของปริศนานี้ นั่นคือการจำลองเกมชีวิตในคอมพิวตเชเดอร์

ใช้คอมพิวตเชดเดอร์ได้ในที่สุด

คุณได้เรียนรู้เกี่ยวกับเชดเดอร์การประมวลผลแบบนามธรรมตลอดทั้งโค้ดแล็บนี้ แต่เชดเดอร์การประมวลผลคืออะไรกันแน่

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

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

  1. สร้าง Shader แบบประมวลผลด้วยโค้ดต่อไปนี้

index.html

// Create the compute shader that will process the simulation.
const simulationShaderModule = device.createShaderModule({
  label: "Game of Life simulation shader",
  code: `
    @compute
    fn computeMain() {

    }`
});

เนื่องจาก GPU มักใช้กับกราฟิก 3 มิติ โครงสร้างของเชดเดอร์การประมวลผลจึงให้คุณขอให้เรียกใช้เชดเดอร์ตามจำนวนครั้งที่กำหนดตามแกน X, Y และ Z ได้ ซึ่งจะช่วยให้คุณส่งงานตามตารางกริด 2 มิติหรือ 3 มิติได้อย่างง่ายดาย ซึ่งเหมาะอย่างยิ่งสำหรับกรณีการใช้งานของคุณ คุณต้องการเรียกใช้โปรแกรมเปลี่ยนสีนี้ GRID_SIZE ครั้ง GRID_SIZE ครั้ง โดยเรียก 1 ครั้งต่อเซลล์ของการจําลองแต่ละเซลล์

ตารางกริดนี้แบ่งออกเป็นกลุ่มงานเนื่องจากลักษณะของสถาปัตยกรรมฮาร์ดแวร์ GPU กลุ่มงานมีขนาด X, Y และ Z และแม้ว่าขนาดแต่ละขนาดจะเป็น 1 ก็ได้ แต่การสร้างกลุ่มงานให้ใหญ่ขึ้นอีกเล็กน้อยมักจะให้ประโยชน์ด้านประสิทธิภาพ สำหรับโปรแกรมเปลี่ยนสี ให้เลือกขนาดกลุ่มงานแบบกำหนดเอง 8x8 ซึ่งมีประโยชน์ในการติดตามในโค้ด JavaScript

  1. กำหนดค่าคงที่สำหรับขนาดของกลุ่มงาน ดังนี้

index.html

const WORKGROUP_SIZE = 8;

นอกจากนี้ คุณยังต้องเพิ่มขนาดกลุ่มงานลงในฟังก์ชัน Shader เอง ซึ่งทำได้โดยใช้ Template Literal ของ JavaScript เพื่อให้ใช้ค่าคงที่ที่เพิ่งกำหนดไว้ได้อย่างง่ายดาย

  1. เพิ่มขนาดกลุ่มงานลงในฟังก์ชัน Shader ดังนี้

index.html (การเรียกใช้ createShaderModule ของ Compute)

@compute
@workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE}) // New line
fn
computeMain() {

}

ซึ่งบอกให้โปรแกรมเปลี่ยนสีทราบว่าการทํางานด้วยฟังก์ชันนี้จะทําเป็นกลุ่ม (8 x 8 x 1) (ค่าแกนที่คุณไม่ได้ระบุจะเป็นค่าเริ่มต้น 1 แต่คุณต้องระบุแกน X เป็นอย่างน้อย)

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

  1. เพิ่มค่า @builtin ดังนี้

index.html (การเรียกใช้ createShaderModule ของ Compute)

@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn
computeMain(@builtin(global_invocation_id) cell: vec3u) {

}

คุณจะส่ง global_invocation_id ในตัว ซึ่งก็คือเวกเตอร์ 3 มิติของจำนวนเต็มแบบไม่ลงนามซึ่งบอกตำแหน่งของคุณในตารางกริดของการเรียกใช้ Shader คุณเรียกใช้โปรแกรมเปลี่ยนสีนี้ 1 ครั้งสำหรับแต่ละเซลล์ในตารางกริด คุณจะได้รับตัวเลข เช่น (0, 0, 0), (1, 0, 0), (1, 1, 0)... ไปจนถึง (31, 31, 0) ซึ่งหมายความว่าคุณสามารถใช้ตัวเลขดังกล่าวเป็นดัชนีเซลล์ที่จะดำเนินการได้

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

  1. ใช้ค่าคงที่กับ Compute Shader เพื่อบอกขนาดตารางกริด ดังนี้

index.html (การเรียกใช้ createShaderModule ของ Compute)

@group(0) @binding(0) var<uniform> grid: vec2f; // New line

@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {

}

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

  1. แสดงสถานะอินพุตและเอาต์พุตของเซลล์เป็นบัฟเฟอร์พื้นที่เก็บข้อมูล ดังนี้

index.html (การเรียกใช้ createShaderModule ของ Compute)

@group(0) @binding(0) var<uniform> grid: vec2f;
   
// New lines
@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;

@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {

}

โปรดทราบว่าบัฟเฟอร์พื้นที่เก็บข้อมูลแรกได้รับการประกาศด้วย var<storage> ซึ่งทำให้เป็นแบบอ่านอย่างเดียว แต่บัฟเฟอร์พื้นที่เก็บข้อมูลที่สองได้รับการประกาศด้วย var<storage, read_write> ซึ่งจะช่วยให้คุณทั้งอ่านและเขียนลงในบัฟเฟอร์ได้โดยใช้บัฟเฟอร์นั้นเป็นตัวเอาต์พุตสำหรับคอมพิวตเชดเดอร์ (WebGPU ไม่มีโหมดพื้นที่เก็บข้อมูลแบบเขียนอย่างเดียว)

ถัดไป คุณต้องมีวิธีจับคู่ดัชนีเซลล์กับอาร์เรย์พื้นที่เก็บข้อมูลเชิงเส้น ซึ่งโดยพื้นฐานแล้วการดำเนินการนี้ตรงข้ามกับสิ่งที่คุณทําในเวิร์กเทกซ์ Shader ที่คุณนํา instance_index เชิงเส้นไปแมปกับเซลล์ตารางกริด 2 มิติ (โปรดทราบว่าอัลกอริทึมของคุณสำหรับวิดีโอดังกล่าวคือ vec2f(i % grid.x, floor(i / grid.x)))

  1. เขียนฟังก์ชันเพื่อไปยังอีกทิศทางหนึ่ง โดยระบบจะนําค่า Y ของเซลล์ไปคูณกับความกว้างของตารางกริด แล้วเพิ่มค่า X ของเซลล์

index.html (การเรียกใช้ createShaderModule ของ Compute)

@group(0) @binding(0) var<uniform> grid: vec2f;

@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;

// New function  
fn cellIndex(cell: vec2u) -> u32 {
  return cell.y * u32(grid.x) + cell.x;
}

@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
 
}

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

  1. เพิ่มอัลกอริทึมง่ายๆ เช่น

index.html (การเรียกใช้ createShaderModule ของ Compute)

@group(0) @binding(0) var<uniform> grid: vec2f;

@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;
   
fn cellIndex(cell: vec2u) -> u32 {
  return cell.y * u32(grid.x) + cell.x;
}

@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
  // New lines. Flip the cell state every step.
  if (cellStateIn[cellIndex(cell.xy)] == 1) {
    cellStateOut[cellIndex(cell.xy)] = 0;
  } else {
    cellStateOut[cellIndex(cell.xy)] = 1;
  }
}

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

ใช้เลย์เอาต์กลุ่มการเชื่อมโยงและไปป์ไลน์

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

ทุกครั้งที่สร้างกลุ่มการเชื่อมโยง คุณต้องระบุ GPUBindGroupLayout ก่อนหน้านี้ คุณจะได้รับเลย์เอาต์ดังกล่าวโดยการเรียกใช้ getBindGroupLayout() ในไปป์ไลน์การแสดงผล ซึ่งจะสร้างเลย์เอาต์โดยอัตโนมัติเนื่องจากคุณระบุ layout: "auto" เมื่อสร้าง แนวทางนี้ได้ผลดีเมื่อคุณใช้ไปป์ไลน์เดียวเท่านั้น แต่หากมีพายป์ไลน์หลายรายการที่ต้องการแชร์ทรัพยากร คุณจะต้องสร้างเลย์เอาต์อย่างชัดเจน แล้วระบุเลย์เอาต์นั้นให้กับทั้งกลุ่มการเชื่อมโยงและไปป์ไลน์

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

  1. หากต้องการสร้างเลย์เอาต์ดังกล่าว ให้เรียกใช้ device.createBindGroupLayout() ดังนี้

index.html

// Create the bind group layout and pipeline layout.
const bindGroupLayout = device.createBindGroupLayout({
  label: "Cell Bind Group Layout",
  entries: [{
    binding: 0,
    // Add GPUShaderStage.FRAGMENT here if you are using the `grid` uniform in the fragment shader.
    visibility: GPUShaderStage.VERTEX | GPUShaderStage.COMPUTE,
    buffer: {} // Grid uniform buffer
  }, {
    binding: 1,
    visibility: GPUShaderStage.VERTEX | GPUShaderStage.COMPUTE,
    buffer: { type: "read-only-storage"} // Cell state input buffer
  }, {
    binding: 2,
    visibility: GPUShaderStage.COMPUTE,
    buffer: { type: "storage"} // Cell state output buffer
  }]
});

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

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

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

ในพจนานุกรมบัฟเฟอร์ คุณจะตั้งค่าตัวเลือกต่างๆ เช่น type ของบัฟเฟอร์ที่ใช้ ค่าเริ่มต้นคือ "uniform" คุณจึงเว้นพจนานุกรมว่างไว้เพื่อเชื่อมโยง 0 ได้ (แต่คุณต้องตั้งค่า buffer: {} เป็นอย่างน้อยเพื่อให้ระบบระบุรายการดังกล่าวว่าเป็นบัฟเฟอร์) การเชื่อมโยง 1 มีประเภทเป็น "read-only-storage" เนื่องจากคุณไม่ได้ใช้การเชื่อมโยงนั้นกับการเข้าถึง read_write ในโปรแกรมเปลี่ยนสี และ การเชื่อมโยง 2 มีประเภทเป็น "storage" เนื่องจากคุณใช้การเชื่อมโยงนั้นกับการเข้าถึง read_write

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

  1. อัปเดตการสร้างกลุ่มการเชื่อมโยง ดังนี้

index.html

// Create a bind group to pass the grid uniforms into the pipeline
const bindGroups = [
  device.createBindGroup({
    label: "Cell renderer bind group A",
    layout: bindGroupLayout, // Updated Line
    entries: [{
      binding: 0,
      resource: { buffer: uniformBuffer }
    }, {
      binding: 1,
      resource: { buffer: cellStateStorage[0] }
    }, {
      binding: 2, // New Entry
      resource: { buffer: cellStateStorage[1] }
    }],
  }),
  device.createBindGroup({
    label: "Cell renderer bind group B",
    layout: bindGroupLayout, // Updated Line

    entries: [{
      binding: 0,
      resource: { buffer: uniformBuffer }
    }, {
      binding: 1,
      resource: { buffer: cellStateStorage[1] }
    }, {
      binding: 2, // New Entry
      resource: { buffer: cellStateStorage[0] }
    }],
  }),
];

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

  1. สร้าง GPUPipelineLayout

index.html

const pipelineLayout = device.createPipelineLayout({
  label: "Cell Pipeline Layout",
  bindGroupLayouts: [ bindGroupLayout ],
});

เลย์เอาต์ไปป์ไลน์คือรายการเลย์เอาต์กลุ่มการเชื่อมโยง (ในกรณีนี้ คุณมี 1 รายการ) ที่ใช้กับไปป์ไลน์อย่างน้อย 1 รายการ ลําดับของเลย์เอาต์กลุ่มการเชื่อมโยงในอาร์เรย์ต้องสอดคล้องกับแอตทริบิวต์ @group ในโปรแกรมเปลี่ยนสี (ซึ่งหมายความว่า bindGroupLayout เชื่อมโยงกับ @group(0))

  1. เมื่อคุณมีเลย์เอาต์ไปป์ไลน์แล้ว ให้อัปเดตไปป์ไลน์การเรนเดอร์เพื่อใช้เลย์เอาต์แทน "auto"

index.html

const cellPipeline = device.createRenderPipeline({
  label: "Cell pipeline",
  layout: pipelineLayout, // Updated!
  vertex: {
    module: cellShaderModule,
    entryPoint: "vertexMain",
    buffers: [vertexBufferLayout]
  },
  fragment: {
    module: cellShaderModule,
    entryPoint: "fragmentMain",
    targets: [{
      format: canvasFormat
    }]
  }
});

สร้างไปป์ไลน์การประมวลผล

คุณต้องใช้ไปป์ไลน์การประมวลผลเพื่อใช้เชดเดอร์การประมวลผล เช่นเดียวกับที่ต้องใช้ไปป์ไลน์การเรนเดอร์เพื่อใช้เชดเดอร์ยอดและเศษ แต่โชคดีที่ไปป์ไลน์การประมวลผลมีความซับซ้อนน้อยกว่าไปป์ไลน์การแสดงผลมาก เนื่องจากไม่มีสถานะใดๆ ที่ต้องตั้งค่า มีเพียงโปรแกรมเปลี่ยนสีและเลย์เอาต์เท่านั้น

  • สร้างไปป์ไลน์การประมวลผลด้วยโค้ดต่อไปนี้

index.html

// Create a compute pipeline that updates the game state.
const simulationPipeline = device.createComputePipeline({
  label: "Simulation pipeline",
  layout: pipelineLayout,
  compute: {
    module: simulationShaderModule,
    entryPoint: "computeMain",
  }
});

โปรดทราบว่าคุณส่ง pipelineLayout ใหม่แทน "auto" เช่นเดียวกับในไปป์ไลน์การเรนเดอร์ที่อัปเดต ซึ่งช่วยให้ทั้งไปป์ไลน์การเรนเดอร์และไปป์ไลน์การประมวลผลใช้กลุ่มการเชื่อมโยงเดียวกันได้

บัตรสำหรับ Compute

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

  1. ย้ายการสร้างโปรแกรมเปลี่ยนไฟล์ไปไว้ที่ด้านบนของฟังก์ชัน แล้วเริ่มการประมวลผลด้วยโปรแกรมเปลี่ยนไฟล์ (ก่อน step++)

index.html

// In updateGrid()
// Move the encoder creation to the top of the function.
const encoder = device.createCommandEncoder();

const computePass = encoder.beginComputePass();

// Compute work will go here...

computePass.end();

// Existing lines
step++; // Increment the step count
 
// Start a render pass...

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

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

  1. ถัดไป ให้ตั้งค่าไปป์ไลน์และกลุ่มการเชื่อมโยงภายในพาสการประมวลผล โดยใช้รูปแบบเดียวกันในการสลับระหว่างกลุ่มการเชื่อมโยงกับพาสการแสดงผล

index.html

const computePass = encoder.beginComputePass();

// New lines
computePass.setPipeline(simulationPipeline);
computePass.setBindGroup(0, bindGroups[step % 2]);

computePass.end();
  1. สุดท้าย คุณส่งงานไปยังคอมพิวตเชดเดอร์โดยบอกจำนวนเวิร์กกรุ๊ปที่ต้องการเรียกใช้ในแต่ละแกนแทนที่จะวาดภาพเหมือนในพาสเรนเดอร์

index.html

const computePass = encoder.beginComputePass();

computePass.setPipeline(simulationPipeline);
computePass.setBindGroup(0, bindGroups[step % 2]);

// New lines
const workgroupCount = Math.ceil(GRID_SIZE / WORKGROUP_SIZE);
computePass.dispatchWorkgroups(workgroupCount, workgroupCount);

computePass.end();

สิ่งที่สำคัญมากที่ควรทราบคือตัวเลขที่คุณส่งไปยัง dispatchWorkgroups() ไม่ใช่จํานวนการเรียกใช้ แต่จะเป็นจํานวนเวิร์กกรุ๊ปที่จะเรียกใช้ตามที่ @workgroup_size ใน Shader กำหนด

หากต้องการให้โปรแกรมเปลี่ยนสีทำงาน 32x32 ครั้งเพื่อให้ครอบคลุมตารางกริดทั้งหมด และขนาดกลุ่มงานคือ 8x8 คุณจะต้องส่งกลุ่มงาน 4x4 (4 * 8 = 32) คุณจึงต้องหารขนาดตารางกริดด้วยขนาดของกลุ่มงาน แล้วส่งค่านั้นไปยัง dispatchWorkgroups()

ตอนนี้คุณรีเฟรชหน้าเว็บอีกครั้งได้ และคุณควรเห็นว่าตารางกริดจะกลับหัวทุกครั้งที่มีการอัปเดต

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

ใช้อัลกอริทึมสำหรับเกมชีวิต

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

  1. หากต้องการเริ่มต้นแต่ละเซลล์ในสถานะแบบสุ่ม ให้อัปเดตการเริ่มต้น cellStateArray เป็นโค้ดต่อไปนี้

index.html

// Set each cell to a random state, then copy the JavaScript array 
// into the storage buffer.
for (let i = 0; i < cellStateArray.length; ++i) {
  cellStateArray[i] = Math.random() > 0.6 ? 1 : 0;
}
device.queue.writeBuffer(cellStateStorage[0], 0, cellStateArray);

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

ก่อนอื่น คุณต้องทราบว่าเซลล์หนึ่งๆ มีเซลล์เพื่อนบ้านที่ใช้งานอยู่กี่เซลล์ คุณไม่สนใจว่ารายการใดที่ใช้งานอยู่ แต่สนใจเฉพาะจํานวน

  1. หากต้องการรับข้อมูลเซลล์ใกล้เคียงได้ง่ายขึ้น ให้เพิ่มฟังก์ชัน cellActive ที่แสดงผลค่า cellStateIn ของพิกัดที่ระบุ

index.html (การเรียกใช้ createShaderModule ของ Compute)

fn cellActive(x: u32, y: u32) -> u32 {
  return cellStateIn[cellIndex(vec2(x, y))];
}

ฟังก์ชัน cellActive จะแสดงผล 1 หากเซลล์ทำงานอยู่ ดังนั้นการเพิ่มผลลัพธ์ของการเรียกใช้ cellActive สำหรับเซลล์รอบข้างทั้ง 8 เซลล์จะให้จำนวนเซลล์ใกล้เคียงที่ทำงานอยู่

  1. ค้นหาจํานวนเพื่อนบ้านที่ใช้งานอยู่ ดังนี้

index.html (การเรียกใช้ createShaderModule ของ Compute)

fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
  // New lines:
  // Determine how many active neighbors this cell has.
  let activeNeighbors = cellActive(cell.x+1, cell.y+1) +
                        cellActive(cell.x+1, cell.y) +
                        cellActive(cell.x+1, cell.y-1) +
                        cellActive(cell.x, cell.y-1) +
                        cellActive(cell.x-1, cell.y-1) +
                        cellActive(cell.x-1, cell.y) +
                        cellActive(cell.x-1, cell.y+1) +
                        cellActive(cell.x, cell.y+1);

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

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

  1. รองรับการวนแถวตารางกริดโดยทำการเปลี่ยนแปลงเล็กน้อยในฟังก์ชัน cellIndex()

index.html (การเรียกใช้ createShaderModule ของ Compute)

fn cellIndex(cell: vec2u) -> u32 {
  return (cell.y % u32(grid.y)) * u32(grid.x) +
         (cell.x % u32(grid.x));
}

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

จากนั้นใช้กฎข้อใดข้อหนึ่งต่อไปนี้

  • เซลล์ที่มีเพื่อนบ้านน้อยกว่า 2 เซลล์จะใช้งานไม่ได้
  • เซลล์ที่ใช้งานอยู่ซึ่งมีเพื่อนบ้าน 2 หรือ 3 เซลล์จะยังคงทำงานต่อไป
  • เซลล์ที่ไม่ได้ใช้งานซึ่งมีเซลล์เพื่อนบ้าน 3 เซลล์จะกลายเป็นเซลล์ที่ใช้งานอยู่
  • เซลล์ที่มีเพื่อนบ้านมากกว่า 3 เซลล์จะใช้งานไม่ได้

คุณทําเช่นนี้ได้โดยใช้ชุดคำสั่ง if แต่ WGSL ยังรองรับคำสั่ง Switch ซึ่งเหมาะกับตรรกะนี้

  1. ใช้ตรรกะเกมชีวิต ดังนี้

index.html (การเรียกใช้ createShaderModule ของ Compute)

let i = cellIndex(cell.xy);

// Conway's game of life rules:
switch activeNeighbors {
  case 2: { // Active cells with 2 neighbors stay active.
    cellStateOut[i] = cellStateIn[i];
  }
  case 3: { // Cells with 3 neighbors become or stay active.
    cellStateOut[i] = 1;
  }
  default: { // Cells with < 2 or > 3 neighbors become inactive.
    cellStateOut[i] = 0;
  }
}

สำหรับการอ้างอิง การเรียกใช้โมดูล Shader การประมวลผลขั้นสุดท้ายตอนนี้มีลักษณะดังนี้

index.html

const simulationShaderModule = device.createShaderModule({
  label: "Life simulation shader",
  code: `
    @group(0) @binding(0) var<uniform> grid: vec2f;

    @group(0) @binding(1) var<storage> cellStateIn: array<u32>;
    @group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;

    fn cellIndex(cell: vec2u) -> u32 {
      return (cell.y % u32(grid.y)) * u32(grid.x) +
              (cell.x % u32(grid.x));
    }

    fn cellActive(x: u32, y: u32) -> u32 {
      return cellStateIn[cellIndex(vec2(x, y))];
    }

    @compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
    fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
      // Determine how many active neighbors this cell has.
      let activeNeighbors = cellActive(cell.x+1, cell.y+1) +
                            cellActive(cell.x+1, cell.y) +
                            cellActive(cell.x+1, cell.y-1) +
                            cellActive(cell.x, cell.y-1) +
                            cellActive(cell.x-1, cell.y-1) +
                            cellActive(cell.x-1, cell.y) +
                            cellActive(cell.x-1, cell.y+1) +
                            cellActive(cell.x, cell.y+1);

      let i = cellIndex(cell.xy);

      // Conway's game of life rules:
      switch activeNeighbors {
        case 2: {
          cellStateOut[i] = cellStateIn[i];
        }
        case 3: {
          cellStateOut[i] = 1;
        }
        default: {
          cellStateOut[i] = 0;
        }
      }
    }
  `
});

เท่านี้ก็เรียบร้อย เท่านี้ก็เรียบร้อย รีเฟรชหน้าเว็บแล้วดูว่าออโตมาตาแบบเซลล์ที่สร้างขึ้นใหม่เติบโตขึ้นอย่างไร

ภาพหน้าจอของสถานะตัวอย่างจากการจําลองเกมชีวิตที่มีการแสดงผลเซลล์สีสันสดใสบนพื้นหลังสีน้ำเงินเข้ม

9 ยินดีด้วย

คุณสร้างการจําลองเกมชีวิตคลาสสิกของ Conway เวอร์ชันที่ทํางานบน GPU ทั้งหมดโดยใช้ WebGPU API

สิ่งต่อไปที่ควรทำ

อ่านเพิ่มเติม

เอกสารอ้างอิง