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

1. บทนำ

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

WebGPU คืออะไร

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

API ที่ทันสมัย

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

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

การแสดงผล

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

Compute

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

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

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

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

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

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

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

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

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

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

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

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

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

2. ตั้งค่า

รับรหัส

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

ใช้คอนโซลนักพัฒนาแอป

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

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

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

3. เริ่มต้น WebGPU

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

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

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

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

  1. หากต้องการรับอะแดปเตอร์ ให้ใช้วิธี navigator.gpu.requestAdapter() ฟังก์ชันนี้จะแสดงผล Promise ดังนั้นจึงสะดวกที่สุดที่จะเรียกใช้ด้วย 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() ซึ่งจะแสดงผล Promise ด้วย

index.html

const device = await adapter.requestDevice();

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

กำหนดค่า Canvas

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

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

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

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

ล้าง Canvas

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

หากต้องการทำเช่นนั้น หรือทำสิ่งอื่นๆ ใน WebGPU คุณต้องระบุคำสั่งบางอย่างให้ GPU เพื่อสั่งให้ GPU ทำสิ่งต่างๆ

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

index.html

const encoder = device.createCommandEncoder();

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

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

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

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

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

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

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

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

Canvas สีดำซึ่งบ่งบอกว่า 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. เมื่อเลือกสีแล้ว ให้โหลดหน้าเว็บซ้ำ คุณควรเห็นสีที่เลือกใน Canvas

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

4. วาดรูปทรงเรขาคณิต

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

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

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

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

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

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

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

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

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

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

กำหนดจุดยอด

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

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

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

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

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

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

index.html

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

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

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

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

หากต้องการสร้างสี่เหลี่ยมจัตุรัสจากไดอะแกรม คุณต้องแสดงจุดยอด (-0.8, -0.8) และ (0.8, 0.8) สองครั้ง ครั้งหนึ่งสำหรับสามเหลี่ยมสีน้ำเงินและอีกครั้งสำหรับสามเหลี่ยมสีแดง (คุณอาจเลือกแบ่งสี่เหลี่ยมจัตุรัสด้วยอีก 2 มุมแทนก็ได้ ซึ่งไม่มีผลใดๆ)

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

index.html

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

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

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

สร้างบัฟเฟอร์จุดยอด

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

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

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

index.html

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

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

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

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

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

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

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

index.html

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

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

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

  • กำหนดโครงสร้างข้อมูลจุดยอดด้วยพจนานุกรม GPUVertexBufferLayout ดังนี้

index.html

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

ในตอนแรกอาจจะดูสับสนเล็กน้อย แต่ก็สามารถแบ่งย่อยได้ง่ายๆ

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

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

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

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

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

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

เริ่มต้นด้วย Shader

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

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

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

ระบบจะส่ง Shader ไปยัง WebGPU เป็นสตริง

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

index.html

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

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

กำหนด Vertex Shader

เริ่มจาก Vertex Shader เพราะเป็นจุดที่ GPU เริ่มทำงานด้วย

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

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

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

  1. สร้าง@vertexฟังก์ชันเปล่าๆ ดังนี้

index.html (โค้ด createShaderModule)

@vertex
fn vertexMain() {

}

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

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

index.html (โค้ด createShaderModule)

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

}

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

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

index.html (โค้ด createShaderModule)

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

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

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

index.html (โค้ด createShaderModule)

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

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

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

index.html (โค้ด createShaderModule)

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

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

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

index.html (โค้ด createShaderModule)

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

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

กำหนด Fragment Shader

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

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

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

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

  1. สร้าง@fragmentฟังก์ชันเปล่าๆ ดังนี้

index.html (โค้ด createShaderModule)

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

}

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

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

index.html (โค้ด createShaderModule)

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

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

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

index.html

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

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

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

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

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

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

index.html

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

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

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

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

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

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

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

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

index.html

// After encoder.beginRenderPass()

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

// before pass.end()

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

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

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

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

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

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

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

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

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

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

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

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

index.html

const GRID_SIZE = 4;

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

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

สร้างบัฟเฟอร์แบบสม่ำเสมอ

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

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

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

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

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

เข้าถึง Uniform ใน Shader

  • กำหนด Uniform โดยเพิ่มโค้ดต่อไปนี้

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 

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

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

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

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

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

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

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

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

index.html

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

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

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

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

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

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

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

  1. กลับไปที่ Render Pass แล้วเพิ่มบรรทัดใหม่นี้ก่อนเมธอด 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 ของขนาดเดิมแล้ว แม้จะไม่มากนัก แต่ก็แสดงให้เห็นว่าได้ใช้ยูนิฟอร์มแล้ว และตอนนี้ Shader สามารถเข้าถึงขนาดของกริดได้แล้ว

ปรับแต่งรูปทรงใน Shader

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

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

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

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

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

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

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

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

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

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

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

  return vec4f(gridPos, 0, 1);
}

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

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

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

  1. แปลตำแหน่งของรูปทรงเรขาคณิต ดังนี้

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

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

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

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

  return vec4f(gridPos, 0, 1); 
}

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

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

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

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

หากรีเฟรชตอนนี้ คุณจะเห็นสิ่งต่อไปนี้

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

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

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

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

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

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

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

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

  return vec4f(gridPos, 0, 1);
}

และจะช่วยให้คุณได้สิ่งที่ต้องการอย่างแน่นอน

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

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

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

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

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

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

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

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

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

index.html

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

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

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

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

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

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

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

index.html

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

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

  return vec4f(gridPos, 0, 1);
}

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

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

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

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

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

  1. เปลี่ยนการคำนวณดังนี้

index.html

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

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

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

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

  return vec4f(gridPos, 0, 1);
}

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

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

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

index.html

const GRID_SIZE = 32;

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

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

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

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

ใช้โครงสร้างใน Shader

จนถึงตอนนี้ คุณได้ส่งข้อมูล 1 รายการออกจาก Vertex Shader นั่นคือตำแหน่งที่แปลงแล้ว แต่จริงๆ แล้วคุณสามารถส่งคืนข้อมูลจาก Vertex Shader ได้มากกว่านี้มาก แล้วนำไปใช้ใน Fragment Shader

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  1. เปลี่ยนค่าที่ส่งคืนของ Vertex Shader ดังนี้

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

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

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

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

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

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

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

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 นี้ได้รับการกำหนดไว้ในโมดูล Shader เดียวกัน คุณจึงสามารถนำโครงสร้างเอาต์พุตของ@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 Shader ดังนี้

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

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

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

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

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

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

  1. เพิ่มสีที่สว่างขึ้นให้กับ Fragment Shader ดังนี้

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

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

ผลลัพธ์ดูดีทีเดียว

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

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

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

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

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

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

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

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

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

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

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

  1. เปิดใช้งานเซลล์ทุกๆ 3 เซลล์ด้วยโค้ดต่อไปนี้

index.html

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

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

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

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

index.html

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

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

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

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

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

index.html

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

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

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

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

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

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

index.html

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

index.html

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

index.html

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

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

index.html

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

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

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

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

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

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

index.html

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

index.html

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

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

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

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

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

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

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

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

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

มาถึงชิ้นส่วนสำคัญสุดท้ายของปริศนากันแล้ว นั่นคือการจำลองเกมแห่งชีวิตใน Compute Shader

ในที่สุดก็ใช้ Compute Shader ได้แล้ว

คุณได้เรียนรู้เกี่ยวกับ Compute Shader ในเชิงนามธรรมตลอด Codelab นี้ แต่จริงๆ แล้ว Compute Shader คืออะไร

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

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

  1. สร้าง Compute Shader ด้วยโค้ดต่อไปนี้

index.html

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

    }`
});

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

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

  1. กำหนดค่าคงที่สำหรับขนาดเวิร์กกรุ๊ป เช่น

index.html

const WORKGROUP_SIZE = 8;

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

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

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

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

}

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

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

  1. เพิ่ม@builtinค่า เช่น

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

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

}

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

Compute Shader ยังใช้ Uniform ได้ด้วย ซึ่งคุณจะใช้ได้เหมือนกับใน Vertex Shader และ Fragment Shader

  1. ใช้ Uniform กับ Compute Shader เพื่อบอกขนาดกริด เช่น

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

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

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

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

และตอนนี้ Compute Shader ของคุณก็มีเพียงเท่านี้ แต่ก่อนที่จะเห็นผลลัพธ์ คุณต้องทำการเปลี่ยนแปลงอีก 2-3 อย่าง

ใช้ Bind Group และ Pipeline Layout

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

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

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

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

index.html

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

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

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

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

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

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

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

index.html

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

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

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

  1. สร้าง GPUPipelineLayout

index.html

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

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

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

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

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

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

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

index.html

const computePass = encoder.beginComputePass();

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

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

index.html

const computePass = encoder.beginComputePass();

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

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

computePass.end();

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

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

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

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

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

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

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

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

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

  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 จะคาดการณ์ได้

จากนั้นคุณจะใช้กฎใดกฎหนึ่งใน 4 กฎต่อไปนี้

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

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

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

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

สำหรับการอ้างอิง ตอนนี้การเรียกโมดูล Compute Shader สุดท้ายจะมีลักษณะดังนี้

index.html

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

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

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

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

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

      let i = cellIndex(cell.xy);

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

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

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

9. ยินดีด้วย

คุณได้สร้างการจำลองเกม Conway's Game of Life แบบคลาสสิกเวอร์ชันที่ทำงานบน GPU ทั้งหมดโดยใช้ WebGPU API

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

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

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