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

1. บทนำ

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

อัปเดตล่าสุด 28-08-2023

WebGPU คืออะไร

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

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

ประมวลผล

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

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

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

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

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

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

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

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

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

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

สิ่งที่คุณต้องมี

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

คุณไม่จำเป็นมีความคุ้นเคยกับ Graphics API อื่นๆ เช่น WebGL, Metal, Vulkan หรือ Direct3D แต่หากมีประสบการณ์การใช้งาน คุณจะสังเกตเห็นได้ว่า 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 โดยไม่ต้องแสดงข้อมูลใดๆ บนหน้าจอหากต้องการเพียงแค่ใช้ WebGPU ในการคำนวณ แต่หากคุณต้องการแสดงผลทุกอย่าง อย่างเช่นที่เรากำลังจะทำใน Codelab คุณจะต้องมีผืนผ้าใบ ซึ่งเป็นจุดเริ่มต้นที่ดี

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

  • สร้างไฟล์ 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 แทนก็ได้) อย่างไรก็ตาม เพื่อวัตถุประสงค์ของ Codelab นี้ คุณเพียงแค่แสดงข้อผิดพลาดเพื่อหยุดการทำงานของโค้ดต่อ

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

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

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

ล้าง Canvas

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

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

index.html

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

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

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

แคนวาสสีดำที่ระบุว่ามีการใช้ WebGPU เพื่อล้างเนื้อหา Canvas สำเร็จแล้ว

เลือกสีเลย

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

  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 อยู่แล้ว)

GPU ต่างจาก API อย่าง Canvas 2D ที่มีรูปร่างและตัวเลือกมากมายพร้อมให้คุณใช้งาน เพราะจริงๆ แล้ว GPU จะยอมรับรูปร่าง (หรือประเภทพื้นฐาน 2-3 ประเภท) เท่านั้น กล่าวคือ จุด เส้น และสามเหลี่ยม คุณจะใช้สามเหลี่ยมเท่านั้นตามวัตถุประสงค์ของ Codelab นี้

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

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

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

หาจุดยอด

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

ซึ่งหมายความว่าคุณจะต้องระบุจุดที่ต่างกัน 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 สำหรับจุดยอดมุมหนึ่งจุด

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

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

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

สร้างบัฟเฟอร์ Vertex

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 ให้คุณแล้ว คุณจึงใช้ข้อมูลดังกล่าวเมื่อสร้างบัฟเฟอร์ได้

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

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

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

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

index.html

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

กำหนดเลย์เอาต์ของ Vertex

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

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

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

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

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

เริ่มต้นด้วยตัวปรับแสงเงา

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

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

ตัวปรับแสงเงาใน WebGPU เขียนขึ้นในภาษาแรเงาที่เรียกว่า WGSL (ภาษาการแรเงา WebGPU) 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 เพราะเป็นที่ที่ GPU เริ่มต้นเช่นกัน!

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

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

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

กำหนดตัวปรับแสงเงา Fragment

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

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

เฟรมเฉดสี Fragment จะทำงานในลักษณะขนานกันอย่างมาก เช่นเดียวกับตัวปรับแสงเงา Vertex โมเดลดังกล่าวจะยืดหยุ่นกว่าตัวปรับแสงเงา 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)
}

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

โดยสรุปแล้ว หลังจากเพิ่มโค้ดตัวปรับเฉดสีตามที่ระบุไว้ด้านบนแล้ว การโทร 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);
    }
  `
});

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

index.html

const GRID_SIZE = 4;

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

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

สร้างบัฟเฟอร์แบบเดียวกัน

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

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

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

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

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

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

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

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

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

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

การเชื่อมโยง (Bin) คือคอลเล็กชันทรัพยากรที่คุณต้องการให้แรเงาเข้าถึงพร้อมกันได้ โดยอาจรวมบัฟเฟอร์หลายประเภท เช่น บัฟเฟอร์แบบเดียวกัน และทรัพยากรอื่นๆ อย่างพื้นผิวและเครื่องมือตัวอย่างที่ไม่ได้กล่าวถึงในที่นี้ แต่เป็นส่วนทั่วไปของเทคนิคการแสดงผล 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" ซึ่งทำให้ไปป์ไลน์สร้างเลย์เอาต์การเชื่อมโยงกลุ่มโดยอัตโนมัติจากการเชื่อมโยงที่คุณประกาศไว้ในโค้ดตัวปรับเฉดสี ในกรณีนี้ คุณจะขอให้ getBindGroupLayout(0) โดยที่ 0 ตรงกับ @group(0) ที่คุณพิมพ์ลงในตัวปรับแสงเงา

หลังจากระบุเลย์เอาต์แล้ว ให้ระบุอาร์เรย์เป็น 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) ในโค้ดตัวปรับแสงเงา คุณกำลังบอกว่า @binding แต่ละรายการที่เป็นส่วนหนึ่งของ @group(0) ใช้ทรัพยากรในกลุ่มการเชื่อมโยงนี้

และตอนนี้บัฟเฟอร์ที่เท่ากันก็เข้าสู่ตัวปรับเฉดสีแล้ว

  1. รีเฟรชหน้าเว็บของคุณ จากนั้นคุณควรเห็นข้อความต่อไปนี้

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

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

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

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

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

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

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

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

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

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

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

อืม ไม่ใช่สิ่งที่คุณต้องการ

เนื่องจากพิกัด Canvas เริ่มจาก -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 รูปในเซลล์ตารางกริดแต่ละเซลล์

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

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

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

index.html

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

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

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

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

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

คุณต้องเพิ่ม instance_index ในตัวลงในอินพุตของตัวปรับแสงเงาจึงจะเห็นการทำงานจริงของคุณ ทำในลักษณะเดียวกับตำแหน่ง แต่แทนที่จะติดแท็กด้วยแอตทริบิวต์ @location ให้ใช้ @builtin(instance_index) แล้วตั้งชื่ออาร์กิวเมนต์ตามที่ต้องการ (สามารถเรียกว่า 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);
  let cellOffset = cell / grid * 2; // Updated
  let gridPos = (pos + 1) / grid - 1 + cellOffset;

  return vec4f(gridPos, 0, 1);
}

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

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

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

การแสดงภาพ Canvas ตามแนวคิดที่แบ่งเป็นตารางกริดขนาด 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. เครดิตเพิ่มเติม: ทำให้มีสีสันมากขึ้น!

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

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

ก่อนหน้านี้ คุณได้ส่งข้อมูลหนึ่งออกจากตัวปรับแสงเงายอด (Verex Shadter) นั่นก็คือ ตำแหน่งที่เปลี่ยนรูปแบบ แต่คุณสามารถส่งคืนข้อมูลจำนวนมากจาก Vertex Shaดร่วง แล้วนำไปใช้ใน Fragment ได้รับการปรับแสง!

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

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

ส่งข้อมูลระหว่างฟังก์ชัน Vertex และฟังก์ชัน Fragment

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

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

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

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

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

  1. เปลี่ยนค่าที่ส่งกลับมาจากตัวปรับแสงเงา Vertex ดังนี้

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

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. เปลี่ยนตัวปรับแสงเงา Fragment ดังนี้

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

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

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

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

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

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

  1. เพิ่มสีที่สว่างขึ้นลงในตัวปรับแสงเงาแบบ Fragment ดังนี้

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

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

ผลการค้นหาดูดีมากเลย

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

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

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

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

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

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

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

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

  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. อัปเดตตัวปรับแสงเงาด้วยโค้ดต่อไปนี้

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. อัปเดตโค้ดตัวปรับแสงเงาเพื่อปรับขนาดตำแหน่งตามสถานะที่ใช้งานอยู่ของเซลล์ ต้องแคสต์ค่าสถานะเป็น 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(in, out) {
  out[0] = 0;
  for (let i = 1; i < in.length; ++i) {
     out[i] = in[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] }
    }],
  })
];

ตั้งค่าการแสดงภาพวนซ้ำ

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

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

มาถึงส่วนสำคัญสุดท้ายของปริศนา นั่นคือการเล่นจำลอง Game of Life ในโปรแกรมเฉดสีของการประมวลผล

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

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

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

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

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

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. เพิ่มขนาดกลุ่มงานลงในฟังก์ชันตัวปรับเฉดสี ดังนี้

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

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

}

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

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

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

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

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

}

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

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

  1. ใช้แบบเดียวกันกับตัวปรับแสงเงาประมวลผลเพื่อบอกขนาดตารางกริด ดังนี้

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

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

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

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

@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 (การเรียก 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>;
   
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;
  }
}

และนี่ก็คือตัวปรับเงาการประมวลผล แต่ก่อนที่คุณจะเห็นผลลัพธ์ คุณต้องทำการเปลี่ยนแปลงอีกเล็กน้อย

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

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

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

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

สุดท้าย ให้ระบุประเภททรัพยากรที่ใช้อยู่ นี่คือคีย์พจนานุกรมอื่น ขึ้นอยู่กับสิ่งที่คุณต้องการแสดง ในตัวอย่างนี้ ทรัพยากรทั้ง 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 รายการใช้อยู่ ลำดับของเลย์เอาต์การเชื่อมโยงกลุ่มในอาร์เรย์ต้องสอดคล้องกับแอตทริบิวต์ @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
    }]
  }
});

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

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

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

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" เช่นเดียวกับในไปป์ไลน์การแสดงผลที่อัปเดตแล้ว ซึ่งช่วยให้มั่นใจว่าทั้งไปป์ไลน์การแสดงผลและไปป์ไลน์การประมวลผลจะใช้กลุ่มการเชื่อมโยงเดียวกันได้

บัตรประมวลผล

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

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

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

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 รูปที่กว้างจากด้านล่างซ้ายไปขวาบนพื้นหลังสีน้ำเงินเข้ม การกลับรูปภาพก่อนหน้า

ใช้อัลกอริทึมสำหรับ Game of Life

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

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

ตอนนี้คุณสามารถใช้ตรรกะสำหรับการจำลอง Game of Life ได้แล้ว หลังจากทุกอย่างที่ต้องใช้มาถึงจุดนี้ โค้ด Shadr อาจกลายเป็นโค้ดที่ง่ายอย่างผิดหวัง!

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

  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 (การเรียก Compute createShaderModule)

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 (การเรียก Compute createShaderModule)

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

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

จากนั้นใช้กฎ 1 ใน 4 ข้อต่อไปนี้

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

คุณสามารถดำเนินการโดยใช้ชุดคำสั่ง if แต่ WGSL รองรับคำสั่งสวิตช์ด้วย ซึ่งเหมาะกับตรรกะนี้

  1. ใช้ตรรกะ Game of Life ดังนี้

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

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

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

ภาพหน้าจอของตัวอย่างสถานะจากการจำลอง Game of Life ซึ่งแสดงเซลล์หลากสีสันบนพื้นหลังสีน้ำเงินเข้ม

9. ยินดีด้วย

คุณได้สร้างการจำลอง Game of Life ของ Conway แบบคลาสสิกซึ่งทำงานบน GPU ทั้งหมดโดยใช้ WebGPU API

สิ่งที่ต้องทำต่อไป

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

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