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

1. บทนำ

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

WebGPU คืออะไร

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

Modern API

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

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

การแสดงผล

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

Compute

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

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

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

ใน Codelab นี้ คุณจะได้สร้างเกมชีวิตของ Conway โดยใช้ WebGPU แอปของคุณจะทำสิ่งต่อไปนี้

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

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

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

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

  • วิธีตั้งค่า WebGPU และกำหนดค่า Canvas
  • วิธีวาดเรขาคณิต 2 มิติอย่างง่าย
  • วิธีใช้ Vertex และ Fragment 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 นี้ไม่มีทรัพยากร Dependency และจะแนะนำทุกขั้นตอนที่จำเป็นในการสร้างแอป WebGPU ทำให้คุณไม่ต้องเขียนโค้ดใดๆ เพื่อเริ่มต้นใช้งาน อย่างไรก็ตาม ตัวอย่างที่ใช้งานได้ซึ่งใช้เป็นจุดตรวจสอบได้มีอยู่ที่นี่ https://glitch.com/edit/#!/your-first-webgpu-app คุณสามารถดูตัวอย่างและอ้างอิงได้ขณะดำเนินการหากพบปัญหา

ใช้ Developer Console!

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

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

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

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 จะบอกคุณว่าควรใช้รูปแบบใดสำหรับภาพพิมพ์แคนวาส ในเกือบทุกกรณี คุณจะต้องส่งค่าที่แสดงผลโดยการเรียกใช้ navigator.gpu.getPreferredCanvasFormat() ดังที่แสดงด้านบน

ล้าง Canvas

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

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

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

index.html

const encoder = device.createCommandEncoder();

คำสั่งที่คุณต้องการส่งไปยัง GPU จะเกี่ยวข้องกับการแสดงผล (ในกรณีนี้คือ การล้าง Canvas) ดังนั้นในขั้นตอนต่อไป ให้ใช้ 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) จะเป็นมุมบนขวาเสมอ ฟีเจอร์นี้เรียกว่า Clip Space

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

โดยทั่วไปแล้ว ระบบจะไม่กำหนดจุดยอดในระบบพิกัดนี้ตั้งแต่แรก ดังนั้น 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 ใช้ขณะวาดภาพจะต้องอยู่ในหน่วยความจำดังกล่าว

สำหรับค่าจำนวนมาก ซึ่งรวมถึงข้อมูล Vertex หน่วยความจำฝั่ง 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 รายการ ซึ่งจะไม่ปรากฏขึ้นระหว่าง Codelab นี้

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

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

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

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

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

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

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

  • สร้างที่สำหรับป้อนรหัสตัวปรับแสงเงาโดยคัดลอกโค้ดต่อไปนี้ลงในโค้ดของคุณด้านล่าง 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 Shadr

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

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

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

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

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

index.html (โค้ด createShaderModule)

@vertex
fn vertexMain() {

}

แต่นั่นจะไม่ถูกต้อง เนื่องจากเครื่องมือปรับแสงเงา 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) และในทางเทคนิคแล้วคุณมี Vertex Shader ที่ถูกต้อง แม้ว่าจะไม่แสดงอะไรเลยเนื่องจาก 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. เปลี่ยนฟังก์ชันตัวปรับแสงเงาเป็นโค้ดต่อไปนี้

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 ระดับเศษ

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

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

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

ฟังก์ชัน Fragment ของ 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 ของคุณ และ entryPoint จะตั้งชื่อฟังก์ชันในโค้ดตัวปรับแสงเงาที่เรียกเมื่อมีการเรียกใช้ 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 ชิ้น

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

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

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

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

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

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

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

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

index.html

const GRID_SIZE = 4;

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

วิธีหนึ่งที่อาจทำได้คือทำให้บัฟเฟอร์เวิร์กเท็กซ์มีขนาดใหญ่ขึ้นอย่างมาก และกำหนดสี่เหลี่ยมจัตุรัสในขนาดและตำแหน่งที่เหมาะสมเป็นจำนวน GRID_SIZE คูณ GRID_SIZE รหัสสำหรับการดำเนินการดังกล่าวนั้นไม่ยากเกินไป สำหรับการวนซ้ำและการคำนวณเล็กๆ น้อยๆ แต่วิธีนี้ก็ไม่ได้ใช้ 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. รีเฟรชหน้าเว็บ แล้วคุณควรเห็นข้อมูลประมาณนี้

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

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

จัดการเรขาคณิตในเครื่องมือเฉดสี

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

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

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

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

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

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

  1. เปลี่ยนโมดูล Vertex Shadr ด้วยโค้ดต่อไปนี้

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);
}

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

การแสดงภาพแคนวาสที่แบ่งเป็นตารางกริดขนาด 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 ในโปรแกรมเปลี่ยนสีและป้อนข้อมูลด้วยค่าคงที่ เช่น 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 วาดเรขาคณิตเดียวกันหลายชุดด้วยการเรียกใช้ 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 ในตัวลงในอินพุต Shader ทําในลักษณะเดียวกับตําแหน่ง แต่ใช้ @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 ของแต่ละเซลล์ คุณต้องการmoduloของ 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. คะแนนพิเศษ: ทำให้ภาพมีสีสันมากขึ้น

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

ใช้ Struct ในตัวสร้างเงา

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

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

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

หากต้องการส่งข้อมูลระหว่างขั้นตอน 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);
}

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

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

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

โชคดีที่คุณมีช่องสีที่ไม่ได้ใช้งานทั้งหมด ซึ่งอาจเป็นสีน้ำเงิน ซึ่งคุณสามารถนำมาใช้ได้ ผลลัพธ์ที่คุณต้องการคือให้สีน้ำเงินสว่างที่สุดเมื่อสีอื่นๆ มืดที่สุด จากนั้นค่อยๆ จางลงเมื่อสีอื่นๆ เข้มขึ้น วิธีที่ง่ายที่สุดคือให้ช่องstartที่ 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);
}

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

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

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

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

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

สิ่งที่จําเป็นคือสัญญาณเปิด/ปิดสําหรับแต่ละเซลล์ ดังนั้นตัวเลือกใดก็ตามที่ให้คุณจัดเก็บอาร์เรย์ขนาดใหญ่ของค่าเกือบทุกประเภทจะใช้งานได้ คุณอาจคิดว่านี่เป็น Use Case อีกรูปแบบหนึ่งของบัฟเฟอร์แบบสอดคล้อง แม้ว่าคุณจะสามารถทำให้ได้ผล แต่ก็อาจทำได้ยากกว่า เนื่องจากบัฟเฟอร์ที่สม่ำเสมอมีขนาดจำกัด ไม่สามารถรองรับอาร์เรย์ขนาดแบบไดนามิก (คุณต้องระบุขนาดอาร์เรย์ในตัวปรับแสงเงา) และไม่สามารถเขียนโดยใช้เฉดสีประมวลผลได้ รายการสุดท้ายนี้เป็นปัญหามากที่สุด เนื่องจากคุณจะทำการจำลอง Game of Life ใน 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. เปิดใช้งานทุกเซลล์ที่สามด้วยโค้ดต่อไปนี้

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() เพื่อกำหนดเวลา Callback ในอัตราเดียวกับที่หน้าจอรีเฟรช (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 รายการที่คุณสร้างขึ้น

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

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

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

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

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

ใช้ตัวปรับแสงเงาการประมวลผลในที่สุด!

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

เชดเดอร์การประมวลผลคล้ายกับเชดเดอร์เวิร์กเทอร์และเชดเดอร์เศษ ซึ่งออกแบบมาให้ทำงานแบบขนานกันอย่างมากใน GPU แต่ต่างจากเชดเดอร์ระยะที่ 2 อื่นๆ ตรงที่ไม่มีชุดอินพุตและเอาต์พุตที่เฉพาะเจาะจง คุณอ่านและเขียนข้อมูลจากแหล่งที่มาที่คุณเลือกเท่านั้น เช่น บัฟเฟอร์พื้นที่เก็บข้อมูล ซึ่งหมายความว่า แทนที่จะดำเนินการกับจุดยอดแต่ละจุด อินสแตนซ์ หรือพิกเซลแต่ละรายการ คุณต้องบอกกับจำนวนการเรียกใช้ฟังก์ชันตัวปรับเฉดสีที่คุณต้องการ จากนั้นเมื่อเรียกใช้ 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 ก็ได้ แต่การสร้างกลุ่มงานให้ใหญ่ขึ้นอีกเล็กน้อยมักจะให้ประโยชน์ด้านประสิทธิภาพ สำหรับเครื่องมือให้เฉดสี ให้เลือกขนาดกลุ่มงานที่กำหนดเองตั้งแต่ 8 คูณ 8 ซึ่งมีประโยชน์ในการติดตามในโค้ด JavaScript

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

index.html

const WORKGROUP_SIZE = 8;

คุณต้องเพิ่มขนาดกลุ่มงานลงในฟังก์ชันตัวปรับเฉดสีเอง ซึ่งทำโดยใช้ลิเทอรัลเทมเพลตของ 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. ใช้ค่าคงที่กับคอมพิวตเชดเดอร์เพื่อบอกขนาดตารางกริด ดังนี้

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) {

}

คุณยังเปิดเผยสถานะเซลล์เป็นบัฟเฟอร์พื้นที่เก็บข้อมูล เช่นเดียวกับในตัวปรับแสงเงา Vertex แต่ในกรณีนี้ คุณต้องใช้ 2 รายการ เนื่องจากตัวปรับเฉดสีการประมวลผลไม่มีเอาต์พุตที่จำเป็น เช่น ตำแหน่งจุดยอดมุมหรือสีของส่วนย่อย การเขียนค่าไปยังบัฟเฟอร์การเก็บข้อมูลหรือพื้นผิวจึงเป็นวิธีเดียวที่จะทำให้ได้ผลลัพธ์จากตัวปรับแสงเงาประมวลผล ใช้วิธีการปิงปองที่คุณได้เรียนรู้ก่อนหน้านี้ คุณมีบัฟเฟอร์การเก็บข้อมูล 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 ไม่มีโหมดพื้นที่เก็บข้อมูลแบบเขียนอย่างเดียว)

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

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

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

@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) {
  
}

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

  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...

เช่นเดียวกับไปป์ไลน์ Compute

คุณควรทำผ่านการคำนวณก่อนผ่านการแสดงผล เนื่องจากจะช่วยให้ผ่านการแสดงผลใช้ผลลัพธ์ล่าสุดจากผ่านการคำนวณได้ทันที นอกจากนั้นยังเป็นเหตุผลที่คุณเพิ่มจำนวน 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 ในตัวปรับแสงเงา

หากต้องการให้โปรแกรมเปลี่ยนสีทำงาน 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 (การเรียก Compute createShaderModule)

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;
  }
}

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

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

ขั้นตอนถัดไป

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

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