1. ভূমিকা

WebGPU কী?
ওয়েব অ্যাপে আপনার GPU-এর ক্ষমতা অ্যাক্সেস করার জন্য WebGPU হল একটি নতুন, আধুনিক API।
আধুনিক API
WebGPU-এর আগে, WebGL ছিল, যা WebGPU-এর বৈশিষ্ট্যগুলির একটি উপসেট অফার করেছিল। এটি সমৃদ্ধ ওয়েব কন্টেন্টের একটি নতুন শ্রেণী সক্ষম করেছে এবং ডেভেলপাররা এটি দিয়ে আশ্চর্যজনক জিনিস তৈরি করেছে। তবে, এটি 2007 সালে প্রকাশিত OpenGL ES 2.0 API-এর উপর ভিত্তি করে তৈরি হয়েছিল, যা আরও পুরানো OpenGL API-এর উপর ভিত্তি করে তৈরি হয়েছিল। সেই সময়ে GPU গুলি উল্লেখযোগ্যভাবে বিকশিত হয়েছে, এবং তাদের সাথে ইন্টারফেস করার জন্য ব্যবহৃত নেটিভ API গুলি Direct3D 12 , Metal এবং Vulkan-এর সাথেও বিকশিত হয়েছে।
WebGPU এই আধুনিক API গুলির অগ্রগতি ওয়েব প্ল্যাটফর্মে নিয়ে আসে। এটি ক্রস-প্ল্যাটফর্ম পদ্ধতিতে GPU বৈশিষ্ট্যগুলি সক্ষম করার উপর দৃষ্টি নিবদ্ধ করে, একই সাথে এমন একটি API উপস্থাপন করে যা ওয়েবে স্বাভাবিক মনে হয় এবং এটি তৈরি করা কিছু নেটিভ API গুলির তুলনায় কম শব্দসমষ্টিপূর্ণ।
রেন্ডারিং
জিপিইউগুলি প্রায়শই দ্রুত, বিস্তারিত গ্রাফিক্স রেন্ডারিংয়ের সাথে যুক্ত থাকে এবং ওয়েবজিপিইউও এর ব্যতিক্রম নয়। ডেস্কটপ এবং মোবাইল জিপিইউ উভয় ক্ষেত্রেই আজকের সবচেয়ে জনপ্রিয় রেন্ডারিং কৌশলগুলিকে সমর্থন করার জন্য প্রয়োজনীয় বৈশিষ্ট্যগুলি এতে রয়েছে এবং হার্ডওয়্যার ক্ষমতাগুলি বিকশিত হওয়ার সাথে সাথে ভবিষ্যতে নতুন বৈশিষ্ট্যগুলি যুক্ত করার পথ প্রদান করে।
গণনা করুন
রেন্ডারিং ছাড়াও, WebGPU আপনার GPU-এর সাধারণ উদ্দেশ্যে, অত্যন্ত সমান্তরাল ওয়ার্কলোড সম্পাদনের সম্ভাবনাকে উন্মুক্ত করে। এই কম্পিউট শেডারগুলি কোনও রেন্ডারিং উপাদান ছাড়াই, অথবা আপনার রেন্ডারিং পাইপলাইনের একটি শক্তভাবে সমন্বিত অংশ হিসাবে ব্যবহার করা যেতে পারে।
আজকের কোডল্যাবে আপনি শিখবেন কিভাবে WebGPU এর রেন্ডারিং এবং কম্পিউটিং ক্ষমতা উভয়ের সুবিধা গ্রহণ করে একটি সহজ পরিচায়ক প্রকল্প তৈরি করবেন!
তুমি কী তৈরি করবে
এই কোডল্যাবে, আপনি WebGPU ব্যবহার করে Conway's Game of Life তৈরি করবেন। আপনার অ্যাপটি করবে:
- সহজ 2D গ্রাফিক্স আঁকতে WebGPU এর রেন্ডারিং ক্ষমতা ব্যবহার করুন।
- সিমুলেশনটি সম্পাদন করতে WebGPU এর কম্পিউট ক্ষমতা ব্যবহার করুন।

গেম অফ লাইফ হল একটি সেলুলার অটোমেটন যা কিছু নিয়মের উপর ভিত্তি করে সময়ের সাথে সাথে কোষের একটি গ্রিডের অবস্থা পরিবর্তন করে। গেম অফ লাইফ-এ কোষগুলি তাদের প্রতিবেশী কোষগুলির কতগুলি সক্রিয় তার উপর নির্ভর করে সক্রিয় বা নিষ্ক্রিয় হয়ে যায়, যা আকর্ষণীয় প্যাটার্নের দিকে পরিচালিত করে যা আপনি দেখার সাথে সাথে ওঠানামা করে।
তুমি কি শিখবে
- কিভাবে WebGPU সেট আপ করবেন এবং ক্যানভাস কনফিগার করবেন।
- কিভাবে সহজ 2D জ্যামিতি আঁকবেন।
- আঁকা ছবি পরিবর্তন করার জন্য ভার্টেক্স এবং ফ্র্যাগমেন্ট শেডার কীভাবে ব্যবহার করবেন।
- একটি সহজ সিমুলেশন করার জন্য কম্পিউট শেডার কীভাবে ব্যবহার করবেন।
এই কোডল্যাবটি WebGPU-এর পিছনের মৌলিক ধারণাগুলি উপস্থাপনের উপর দৃষ্টি নিবদ্ধ করে। এটি API-এর একটি বিস্তৃত পর্যালোচনা করার উদ্দেশ্যে নয়, এবং এটি 3D ম্যাট্রিক্স গণিতের মতো প্রায়শই সম্পর্কিত বিষয়গুলিও অন্তর্ভুক্ত করে (অথবা প্রয়োজন হয়)।
তোমার যা লাগবে
- ChromeOS, macOS, অথবা Windows-এ Chrome-এর সাম্প্রতিক সংস্করণ (১১৩ বা তার পরবর্তী)। WebGPU হল একটি ক্রস-ব্রাউজার, ক্রস-প্ল্যাটফর্ম API কিন্তু এখনও সর্বত্র পাঠানো হয়নি।
- HTML, JavaScript এবং Chrome DevTools সম্পর্কে জ্ঞান।
অন্যান্য গ্রাফিক্স এপিআই, যেমন WebGL, Metal, Vulkan, অথবা Direct3D, এর সাথে পরিচিত হওয়ার প্রয়োজন নেই , তবে যদি আপনার এগুলির সাথে কোনও অভিজ্ঞতা থাকে তবে আপনি সম্ভবত WebGPU এর সাথে অনেক মিল লক্ষ্য করবেন যা আপনার শেখার শুরুতে সাহায্য করতে পারে!
2. সেট আপ করুন
কোডটি পান
এই কোডল্যাবের কোনও নির্ভরতা নেই এবং এটি আপনাকে WebGPU অ্যাপ তৈরির জন্য প্রয়োজনীয় প্রতিটি ধাপে নিয়ে যাবে, তাই শুরু করার জন্য আপনার কোনও কোডের প্রয়োজন হবে না। তবে, চেকপয়েন্ট হিসেবে কাজ করতে পারে এমন কিছু কার্যকরী উদাহরণ https://github.com/GoogleChromeLabs/your-first-webgpu-app-codelab এ পাওয়া যাবে। আপনি যদি আটকে যান তবে সেগুলি পরীক্ষা করে দেখতে পারেন এবং যাওয়ার সময় সেগুলি উল্লেখ করতে পারেন।
ডেভেলপার কনসোল ব্যবহার করুন!
WebGPU একটি জটিল API যার অনেক নিয়ম রয়েছে যা সঠিক ব্যবহারকে জোরদার করে। আরও খারাপ বিষয় হল, API কীভাবে কাজ করে তার কারণে, এটি অনেক ত্রুটির জন্য সাধারণ জাভাস্ক্রিপ্ট ব্যতিক্রমগুলি উত্থাপন করতে পারে না, যার ফলে সমস্যাটি কোথা থেকে আসছে তা সঠিকভাবে নির্ধারণ করা কঠিন হয়ে পড়ে।
WebGPU ডেভেলপ করার সময় আপনার সমস্যা হবে , বিশেষ করে একজন নতুন ব্যবহারকারী হিসেবে, এবং এতে কোন সন্দেহ নেই! API এর পেছনের ডেভেলপাররা GPU ডেভেলপমেন্টের সাথে কাজ করার চ্যালেঞ্জগুলি সম্পর্কে সচেতন, এবং তারা কঠোর পরিশ্রম করেছেন যাতে আপনার WebGPU কোডে যখনই কোনও ত্রুটি দেখা দেয় তখন আপনি ডেভেলপার কনসোলে খুব বিস্তারিত এবং সহায়ক বার্তা পাবেন যা আপনাকে সমস্যাটি সনাক্ত করতে এবং সমাধান করতে সহায়তা করবে।
যেকোনো ওয়েব অ্যাপ্লিকেশনে কাজ করার সময় কনসোল খোলা রাখা সবসময়ই কার্যকর, তবে এটি এখানে বিশেষভাবে প্রযোজ্য!
৩. WebGPU আরম্ভ করুন
<canvas> দিয়ে শুরু করুন
যদি আপনি কেবল গণনা করার জন্য WebGPU ব্যবহার করতে চান, তাহলে স্ক্রিনে কিছু না দেখিয়েই WebGPU ব্যবহার করা যেতে পারে। কিন্তু যদি আপনি কিছু রেন্ডার করতে চান, যেমন আমরা কোডল্যাবে করতে যাচ্ছি, তাহলে আপনার একটি ক্যানভাস প্রয়োজন। তাহলে শুরু করার জন্য এটি একটি ভালো জায়গা!
একটি <canvas> উপাদান সহ একটি নতুন HTML ডকুমেন্ট তৈরি করুন, এবং একটি <script> ট্যাগ যেখানে আমরা ক্যানভাস উপাদানটি জিজ্ঞাসা করি। (অথবা 00-starter-page.html ব্যবহার করুন।)
- নিম্নলিখিত কোডটি ব্যবহার করে একটি
index.htmlফাইল তৈরি করুন:
সূচক.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 বিটগুলিতে প্রবেশ করতে পারেন! প্রথমে, আপনার বিবেচনা করা উচিত যে WebGPU-এর মতো API গুলি পুরো ওয়েব ইকোসিস্টেম জুড়ে প্রচারিত হতে কিছুটা সময় নিতে পারে। ফলস্বরূপ, একটি ভাল প্রথম সতর্কতামূলক পদক্ষেপ হল ব্যবহারকারীর ব্রাউজার WebGPU ব্যবহার করতে পারে কিনা তা পরীক্ষা করা।
- WebGPU-এর এন্ট্রি পয়েন্ট হিসেবে কাজ করে এমন
navigator.gpuঅবজেক্টটি বিদ্যমান কিনা তা পরীক্ষা করতে, নিম্নলিখিত কোডটি যোগ করুন:
সূচক.html
if (!navigator.gpu) {
throw new Error("WebGPU not supported on this browser.");
}
আদর্শভাবে, আপনি যদি WebGPU অনুপলব্ধ থাকে তবে ব্যবহারকারীকে জানাতে চান যে পৃষ্ঠাটি এমন একটি মোডে ফিরে যেতে হবে যা WebGPU ব্যবহার করে না। (হয়তো এটি পরিবর্তে WebGL ব্যবহার করতে পারে?) তবে এই কোডল্যাবের উদ্দেশ্যে, আপনি কোডটি আরও কার্যকর করা বন্ধ করার জন্য কেবল একটি ত্রুটি নিক্ষেপ করবেন।
একবার আপনি যখন জানবেন যে WebGPU ব্রাউজার দ্বারা সমর্থিত, তখন আপনার অ্যাপের জন্য WebGPU চালু করার প্রথম ধাপ হল একটি GPUAdapter অনুরোধ করা। আপনি একটি অ্যাডাপ্টারকে WebGPU-এর আপনার ডিভাইসের একটি নির্দিষ্ট GPU হার্ডওয়্যারের প্রতিনিধিত্ব হিসাবে ভাবতে পারেন।
- একটি অ্যাডাপ্টার পেতে,
navigator.gpu.requestAdapter()পদ্ধতিটি ব্যবহার করুন। এটি একটি প্রতিশ্রুতি প্রদান করে, তাই এটিকেawaitদিয়ে কল করা সবচেয়ে সুবিধাজনক।
সূচক.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 এর সাথে বেশিরভাগ মিথস্ক্রিয়া ঘটে।
-
adapter.requestDevice()কল করে ডিভাইসটি পান, যা একটি প্রতিশ্রুতিও প্রদান করে।
সূচক.html
const device = await adapter.requestDevice();
requestAdapter() এর মতো, এখানে আরও উন্নত ব্যবহারের জন্য কিছু বিকল্প রয়েছে যেমন নির্দিষ্ট হার্ডওয়্যার বৈশিষ্ট্য সক্ষম করা বা উচ্চতর সীমা অনুরোধ করা, তবে আপনার উদ্দেশ্যে ডিফল্টগুলি ঠিকঠাক কাজ করে।
ক্যানভাস কনফিগার করুন
এখন যেহেতু আপনার একটি ডিভাইস আছে, পৃষ্ঠায় কিছু দেখানোর জন্য এটি ব্যবহার করতে চাইলে আরও একটি কাজ করতে হবে: আপনার তৈরি করা ডিভাইসের সাথে ব্যবহারের জন্য ক্যানভাসটি কনফিগার করুন।
- এটি করার জন্য, প্রথমে ক্যানভাস থেকে
GPUCanvasContextঅনুরোধ করুনcanvas.getContext("webgpu")কল করে। (এটি একই কল যা আপনি যথাক্রমে2dএবংwebglকনটেক্সট টাইপ ব্যবহার করে ক্যানভাস 2D বা WebGL কনটেক্সট শুরু করতে ব্যবহার করবেন।) এটি যেcontextপ্রদান করে তা অবশ্যইconfigure()পদ্ধতি ব্যবহার করে ডিভাইসের সাথে যুক্ত করতে হবে, যেমন:
সূচক.html
const context = canvas.getContext("webgpu");
const canvasFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({
device: device,
format: canvasFormat,
});
এখানে কয়েকটি বিকল্প দেওয়া যেতে পারে, তবে সবচেয়ে গুরুত্বপূর্ণ হল আপনি যে device দিয়ে কনটেক্সট ব্যবহার করতে যাচ্ছেন এবং format , যা টেক্সচার ফর্ম্যাট যা কনটেক্সট ব্যবহার করা উচিত।
WebGPU ইমেজ ডেটা সংরক্ষণের জন্য টেক্সচার হল সেইসব বস্তু যা ব্যবহার করে, এবং প্রতিটি টেক্সচারের একটি ফর্ম্যাট থাকে যা GPU কে জানতে দেয় যে মেমোরিতে ডেটা কীভাবে রাখা হয়। টেক্সচার মেমোরি কীভাবে কাজ করে তার বিশদ বিবরণ এই কোডল্যাবের আওতার বাইরে। গুরুত্বপূর্ণ বিষয় হল ক্যানভাসের প্রেক্ষাপট আপনার কোড আঁকার জন্য টেক্সচার সরবরাহ করে এবং আপনি যে ফর্ম্যাটটি ব্যবহার করেন তা ক্যানভাস কতটা দক্ষতার সাথে সেই ছবিগুলি দেখায় তার উপর প্রভাব ফেলতে পারে। বিভিন্ন ধরণের ডিভাইস বিভিন্ন টেক্সচার ফর্ম্যাট ব্যবহার করার সময় সবচেয়ে ভালো কাজ করে এবং আপনি যদি ডিভাইসের পছন্দের ফর্ম্যাট ব্যবহার না করেন তবে ছবিটি পৃষ্ঠার অংশ হিসাবে প্রদর্শিত হওয়ার আগে পর্দার আড়ালে অতিরিক্ত মেমোরি কপি তৈরি হতে পারে।
সৌভাগ্যবশত, আপনাকে এ ব্যাপারে খুব বেশি চিন্তা করতে হবে না কারণ WebGPU আপনাকে বলে দেয় যে আপনার ক্যানভাসের জন্য কোন ফর্ম্যাট ব্যবহার করতে হবে! প্রায় সব ক্ষেত্রেই, আপনি navigator.gpu.getPreferredCanvasFormat() কল করে ফেরত মানটি পাস করতে চান, যেমনটি উপরে দেখানো হয়েছে।
ক্যানভাস পরিষ্কার করুন
এখন যেহেতু আপনার একটি ডিভাইস আছে এবং ক্যানভাসটি এটি দিয়ে কনফিগার করা হয়েছে, আপনি ক্যানভাসের বিষয়বস্তু পরিবর্তন করতে ডিভাইসটি ব্যবহার শুরু করতে পারেন। শুরু করতে, একটি কঠিন রঙ দিয়ে এটি পরিষ্কার করুন।
এটি করার জন্য—অথবা WebGPU-তে প্রায় অন্য কিছু করার জন্য—আপনাকে GPU-কে কিছু কমান্ড প্রদান করতে হবে যা তাকে কী করতে হবে তা নির্দেশ করবে।
- এটি করার জন্য, ডিভাইসটিকে একটি
GPUCommandEncoderতৈরি করতে বলুন, যা GPU কমান্ড রেকর্ড করার জন্য একটি ইন্টারফেস প্রদান করে।
সূচক.html
const encoder = device.createCommandEncoder();
আপনি GPU-তে যে কমান্ডগুলি পাঠাতে চান তা রেন্ডারিংয়ের সাথে সম্পর্কিত (এই ক্ষেত্রে, ক্যানভাস পরিষ্কার করা), তাই পরবর্তী ধাপ হল রেন্ডার পাস শুরু করার জন্য encoder ব্যবহার করা।
WebGPU-তে সমস্ত অঙ্কন অপারেশন সম্পন্ন হলে রেন্ডার পাস ব্যবহার করা হয়। প্রতিটি পাস একটি beginRenderPass() কল দিয়ে শুরু হয়, যা সম্পাদিত যেকোনো অঙ্কন কমান্ডের আউটপুট গ্রহণকারী টেক্সচারগুলিকে সংজ্ঞায়িত করে। আরও উন্নত ব্যবহারগুলি বিভিন্ন উদ্দেশ্যে যেমন রেন্ডার করা জ্যামিতির গভীরতা সংরক্ষণ করা বা অ্যান্টিএলিয়াসিং প্রদানের জন্য বিভিন্ন টেক্সচার, যাকে অ্যাটাচমেন্ট বলা হয়, প্রদান করতে পারে। তবে, এই অ্যাপের জন্য আপনার কেবল একটির প্রয়োজন।
- আপনার পূর্বে তৈরি করা ক্যানভাস কনটেক্সট থেকে টেক্সচারটি
context.getCurrentTexture()কল করে পান, যা ক্যানভাসেরwidthandheightবৈশিষ্ট্য এবংcontext.configure()কল করার সময় নির্দিষ্টformatসাথে মিলে যাওয়া পিক্সেল width and height সহ একটি টেক্সচার প্রদান করে।
সূচক.html
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
loadOp: "clear",
storeOp: "store",
}]
});
টেক্সচারটি colorAttachment এর view প্রপার্টি হিসেবে দেওয়া হয় । রেন্ডার পাসের জন্য আপনাকে GPUTexture এর পরিবর্তে GPUTextureView প্রদান করতে হবে, যা টেক্সচারের কোন অংশে রেন্ডার করতে হবে তা বলে। এটি শুধুমাত্র আরও উন্নত ব্যবহারের ক্ষেত্রেই গুরুত্বপূর্ণ, তাই এখানে আপনি টেক্সচারের উপর কোনও আর্গুমেন্ট ছাড়াই createView() কল করবেন, যা নির্দেশ করে যে আপনি চান রেন্ডার পাসটি সম্পূর্ণ টেক্সচার ব্যবহার করুক।
রেন্ডার পাস টেক্সচারের সাথে কী করতে চাও, তাও আপনাকে নির্দিষ্ট করতে হবে যখন এটি শুরু হবে এবং কখন শেষ হবে:
-
"clear"এরloadOpমান নির্দেশ করে যে রেন্ডার পাস শুরু হওয়ার সময় আপনি টেক্সচারটি পরিষ্কার করতে চান। -
"store"এরstoreOpমান নির্দেশ করে যে রেন্ডার পাস শেষ হয়ে গেলে আপনি রেন্ডার পাসের সময় করা যেকোনো অঙ্কনের ফলাফল টেক্সচারে সংরক্ষণ করতে চান।
রেন্ডার পাস শুরু হয়ে গেলে... কিছুই করার নেই! অন্তত আপাতত। loadOp: "clear" দিয়ে রেন্ডার পাস শুরু করার কাজটি টেক্সচার ভিউ এবং ক্যানভাস পরিষ্কার করার জন্য যথেষ্ট।
-
beginRenderPass()পরপরই নিম্নলিখিত কলটি যোগ করে রেন্ডার পাসটি শেষ করুন:
সূচক.html
pass.end();
এটা জানা গুরুত্বপূর্ণ যে কেবল এই কলগুলি করলেই GPU আসলে কিছু করতে বাধ্য হয় না। এগুলি কেবল GPU-এর পরবর্তী করণীয় কমান্ড রেকর্ড করছে।
-
GPUCommandBufferতৈরি করতে, কমান্ড এনকোডারেfinish()কল করুন। কমান্ড বাফার হল রেকর্ড করা কমান্ডের জন্য একটি অস্বচ্ছ হ্যান্ডেল।
সূচক.html
const commandBuffer = encoder.finish();
-
GPUDeviceএরqueueব্যবহার করে GPU তে কমান্ড বাফার জমা দিন। কিউ সমস্ত GPU কমান্ড সম্পাদন করে, নিশ্চিত করে যে তাদের কার্যকরকরণ সুশৃঙ্খল এবং সঠিকভাবে সিঙ্ক্রোনাইজ করা হয়েছে। কিউ এরsubmit()পদ্ধতি কমান্ড বাফারের একটি অ্যারে গ্রহণ করে, যদিও এই ক্ষেত্রে আপনার কাছে কেবল একটি থাকে।
সূচক.html
device.queue.submit([commandBuffer]);
একবার কমান্ড বাফার জমা দিলে, এটি আর ব্যবহার করা যাবে না, তাই এটি ধরে রাখার দরকার নেই। আপনি যদি আরও কমান্ড জমা দিতে চান, তাহলে আপনাকে আরেকটি কমান্ড বাফার তৈরি করতে হবে। এই কারণেই এই দুটি ধাপ একসাথে ভেঙে একটিতে পরিণত হওয়া মোটামুটি সাধারণ, যেমনটি এই কোডল্যাবের নমুনা পৃষ্ঠাগুলিতে করা হয়েছে:
সূচক.html
// Finish the command buffer and immediately submit it.
device.queue.submit([encoder.finish()]);
GPU-তে কমান্ড জমা দেওয়ার পর, জাভাস্ক্রিপ্টকে ব্রাউজারে নিয়ন্ত্রণ ফিরিয়ে দিতে দিন। সেই সময়ে, ব্রাউজার দেখতে পাবে যে আপনি কনটেক্সটের বর্তমান টেক্সচার পরিবর্তন করেছেন এবং ক্যানভাস আপডেট করে সেই টেক্সচারটিকে একটি চিত্র হিসাবে প্রদর্শন করেছেন। এর পরে যদি আপনি আবার ক্যানভাসের বিষয়বস্তু আপডেট করতে চান, তাহলে আপনাকে একটি নতুন কমান্ড বাফার রেকর্ড করতে হবে এবং জমা দিতে হবে, একটি রেন্ডার পাসের জন্য একটি নতুন টেক্সচার পেতে আবার context.getCurrentTexture() কল করতে হবে।
- পৃষ্ঠাটি পুনরায় লোড করুন। লক্ষ্য করুন যে ক্যানভাসটি কালো রঙে পূর্ণ। অভিনন্দন! এর অর্থ হল আপনি সফলভাবে আপনার প্রথম WebGPU অ্যাপ তৈরি করেছেন।

একটি রঙ বেছে নাও!
সত্যি কথা বলতে, কালো স্কোয়ারগুলি বেশ বিরক্তিকর। তাই পরবর্তী বিভাগে যাওয়ার আগে একটু সময় নিন যাতে এটি কিছুটা ব্যক্তিগতকৃত হয়।
-
encoder.beginRenderPass()কলে,colorAttachmentএclearValueসহ একটি নতুন লাইন যোগ করুন, যেমন:
সূচক.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 অপারেশন করার সময় কোন রঙ ব্যবহার করা উচিত। এতে পাস করা অভিধানে চারটি মান রয়েছে: লালের জন্য 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 }হল ডিফল্ট, স্বচ্ছ কালো।
এই কোডল্যাবের উদাহরণ কোড এবং স্ক্রিনশটগুলি গাঢ় নীল ব্যবহার করেছে, তবে আপনার পছন্দের যেকোনো রঙ বেছে নিতে দ্বিধা করবেন না!
- একবার আপনার রঙ বেছে নিলে, পৃষ্ঠাটি পুনরায় লোড করুন। আপনি ক্যানভাসে আপনার পছন্দের রঙটি দেখতে পাবেন।

৪. জ্যামিতি আঁকুন
এই বিভাগের শেষে, আপনার অ্যাপটি ক্যানভাসে কিছু সহজ জ্যামিতি আঁকবে: একটি রঙিন বর্গক্ষেত্র। এখনই সতর্ক থাকুন যে এত সহজ আউটপুটের জন্য এটি অনেক কাজ বলে মনে হবে, কিন্তু এর কারণ হল WebGPU অনেক জ্যামিতিকে খুব দক্ষতার সাথে রেন্ডার করার জন্য ডিজাইন করা হয়েছে। এই দক্ষতার একটি পার্শ্ব প্রতিক্রিয়া হল যে তুলনামূলকভাবে সহজ কাজগুলি করা অস্বাভাবিকভাবে কঠিন মনে হতে পারে, তবে আপনি যদি WebGPU-এর মতো একটি API-এর দিকে ঝুঁকছেন তবে এটিই প্রত্যাশা - আপনি আরও জটিল কিছু করতে চান।
জিপিইউ কীভাবে অঙ্কন করে তা বুঝুন
আর কোনও কোড পরিবর্তনের আগে, GPU গুলি কীভাবে স্ক্রিনে দেখা আকারগুলি তৈরি করে তার একটি খুব দ্রুত, সরলীকৃত, উচ্চ-স্তরের ওভারভিউ করা মূল্যবান। (GPU রেন্ডারিং কীভাবে কাজ করে তার মূল বিষয়গুলির সাথে যদি আপনি ইতিমধ্যেই পরিচিত হন তবে Defining Vertices বিভাগে যেতে দ্বিধা করবেন না।)
ক্যানভাস 2D এর মতো API-তে ব্যবহারের জন্য প্রচুর আকার এবং বিকল্প রয়েছে, তার বিপরীতে, আপনার GPU আসলে কেবল কয়েকটি ভিন্ন ধরণের আকার (অথবা WebGPU দ্বারা প্রিমিটিভ হিসাবে উল্লেখ করা হয়): বিন্দু, রেখা এবং ত্রিভুজ নিয়ে কাজ করে। এই কোডল্যাবের উদ্দেশ্যে আপনি কেবল ত্রিভুজ ব্যবহার করবেন।
জিপিইউগুলি প্রায় একচেটিয়াভাবে ত্রিভুজগুলির সাথে কাজ করে কারণ ত্রিভুজগুলির অনেক সুন্দর গাণিতিক বৈশিষ্ট্য রয়েছে যা এগুলিকে অনুমানযোগ্য এবং দক্ষ উপায়ে প্রক্রিয়া করা সহজ করে তোলে। জিপিইউ দিয়ে আপনি যা কিছু আঁকেন তার প্রায় সবকিছুই জিপিইউ আঁকতে পারার আগে ত্রিভুজে বিভক্ত করতে হবে এবং সেই ত্রিভুজগুলিকে তাদের কোণ বিন্দু দ্বারা সংজ্ঞায়িত করতে হবে।
এই বিন্দুগুলি, অথবা শীর্ষবিন্দুগুলি , X, Y, এবং (3D কন্টেন্টের জন্য) Z মানের পরিপ্রেক্ষিতে দেওয়া হয়েছে যা WebGPU বা অনুরূপ API দ্বারা সংজ্ঞায়িত কার্টেসিয়ান স্থানাঙ্ক সিস্টেমে একটি বিন্দুকে সংজ্ঞায়িত করে। স্থানাঙ্ক সিস্টেমের গঠনটি আপনার পৃষ্ঠার ক্যানভাসের সাথে কীভাবে সম্পর্কিত তার পরিপ্রেক্ষিতে চিন্তা করা সবচেয়ে সহজ। আপনার ক্যানভাস যতই প্রশস্ত বা লম্বা হোক না কেন, বাম প্রান্তটি সর্বদা X অক্ষে -1 এ থাকে এবং ডান প্রান্তটি সর্বদা X অক্ষে +1 এ থাকে। একইভাবে, নীচের প্রান্তটি সর্বদা Y অক্ষে -1 থাকে এবং উপরের প্রান্তটি Y অক্ষে +1 থাকে। এর অর্থ হল (0, 0) সর্বদা ক্যানভাসের কেন্দ্র, (-1, -1) সর্বদা নীচের-বাম কোণ এবং (1, 1) সর্বদা উপরের-ডান কোণ। এটি ক্লিপ স্পেস নামে পরিচিত।

এই স্থানাঙ্ক ব্যবস্থায় প্রাথমিকভাবে শীর্ষবিন্দুগুলি খুব কমই সংজ্ঞায়িত করা হয়, তাই GPU গুলি শীর্ষবিন্দুগুলিকে ক্লিপ স্পেসে রূপান্তর করার জন্য প্রয়োজনীয় গণিত সম্পাদনের জন্য ভার্টেক্স শেডার নামক ছোট প্রোগ্রামের উপর নির্ভর করে, সেইসাথে শীর্ষবিন্দুগুলি আঁকতে প্রয়োজনীয় অন্যান্য গণনাও করে। উদাহরণস্বরূপ, শেডার কিছু অ্যানিমেশন প্রয়োগ করতে পারে বা শীর্ষবিন্দু থেকে আলোর উৎসের দিকের দিক গণনা করতে পারে। এই শেডারগুলি আপনি, WebGPU ডেভেলপার দ্বারা লেখা এবং এগুলি GPU কীভাবে কাজ করে তার উপর আশ্চর্যজনক পরিমাণে নিয়ন্ত্রণ প্রদান করে।
সেখান থেকে, GPU এই রূপান্তরিত শীর্ষবিন্দু দ্বারা গঠিত সমস্ত ত্রিভুজ গ্রহণ করে এবং নির্ধারণ করে যে স্ক্রিনে কোন পিক্সেলগুলি আঁকতে হবে। তারপর এটি আপনার লেখা আরেকটি ছোট প্রোগ্রাম চালায় যাকে ফ্র্যাগমেন্ট শেডার বলা হয় যা প্রতিটি পিক্সেলের রঙ গণনা করে। এই গণনাটি সবুজ রঙের রিটার্ন করার মতো সহজ বা কাছাকাছি অন্যান্য পৃষ্ঠ থেকে সূর্যের আলোর লাফিয়ে পড়ার সাপেক্ষে পৃষ্ঠের কোণ গণনা করার মতো জটিল হতে পারে, কুয়াশার মধ্য দিয়ে ফিল্টার করা এবং পৃষ্ঠটি কতটা ধাতব তা দ্বারা পরিবর্তিত। এটি সম্পূর্ণরূপে আপনার নিয়ন্ত্রণে, যা ক্ষমতায়নকারী এবং অপ্রতিরোধ্য উভয়ই হতে পারে।
সেই পিক্সেল রঙের ফলাফলগুলি তখন একটি টেক্সচারে জমা হয়, যা পরে স্ক্রিনে দেখানো যায়।
শীর্ষবিন্দু সংজ্ঞায়িত করুন
আগেই উল্লেখ করা হয়েছে, The Game of Life সিমুলেশনটি কোষের একটি গ্রিড হিসেবে দেখানো হয়েছে। আপনার অ্যাপটির গ্রিডটি কল্পনা করার জন্য একটি উপায় প্রয়োজন, যা সক্রিয় কোষগুলিকে নিষ্ক্রিয় কোষ থেকে আলাদা করে। এই কোডল্যাব দ্বারা ব্যবহৃত পদ্ধতি হল সক্রিয় কোষগুলিতে রঙিন বর্গক্ষেত্র আঁকা এবং নিষ্ক্রিয় কোষগুলিকে খালি রাখা।
এর মানে হল যে আপনাকে GPU-তে চারটি ভিন্ন বিন্দু প্রদান করতে হবে, বর্গক্ষেত্রের চারটি কোণের জন্য একটি করে। উদাহরণস্বরূপ, ক্যানভাসের কেন্দ্রে আঁকা একটি বর্গক্ষেত্র, প্রান্ত থেকে একভাবে টানা, এর কোণ স্থানাঙ্কগুলি এইভাবে রয়েছে:

GPU-তে স্থানাঙ্কগুলি সরবরাহ করার জন্য, আপনাকে TypedArray- তে মানগুলি স্থাপন করতে হবে। যদি আপনি ইতিমধ্যে এটির সাথে পরিচিত না হন, তাহলে TypedArray হল জাভাস্ক্রিপ্ট অবজেক্টের একটি গ্রুপ যা আপনাকে মেমোরির সংলগ্ন ব্লক বরাদ্দ করতে এবং সিরিজের প্রতিটি উপাদানকে একটি নির্দিষ্ট ডেটা টাইপ হিসাবে ব্যাখ্যা করতে দেয়। উদাহরণস্বরূপ, Uint8Array তে, অ্যারের প্রতিটি উপাদান একটি একক, স্বাক্ষরবিহীন বাইট। TypedArray মেমোরি লেআউটের প্রতি সংবেদনশীল API, যেমন WebAssembly, WebAudio, এবং (অবশ্যই) WebGPU-এর মাধ্যমে ডেটা পিছনে পিছনে পাঠানোর জন্য দুর্দান্ত।
বর্গাকার উদাহরণের জন্য, যেহেতু মানগুলি ভগ্নাংশ, একটি Float32Array উপযুক্ত।
- আপনার কোডে নিম্নলিখিত অ্যারে ঘোষণাটি স্থাপন করে এমন একটি অ্যারে তৈরি করুন যা ডায়াগ্রামের সমস্ত শীর্ষবিন্দু অবস্থান ধরে রাখে। এটি স্থাপন করার জন্য একটি ভাল জায়গা হল উপরের দিকে,
context.configure()কলের ঠিক নীচে।
সূচক.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 গুলি ত্রিভুজের ক্ষেত্রে কাজ করে, মনে আছে? তাহলে এর মানে হল আপনাকে তিনজনের গ্রুপে শীর্ষবিন্দু প্রদান করতে হবে। আপনার চারটির একটি গ্রুপ আছে। সমাধান হল দুটি শীর্ষবিন্দু পুনরাবৃত্তি করে দুটি ত্রিভুজ তৈরি করা যা বর্গক্ষেত্রের মাঝখানে একটি প্রান্ত ভাগ করে।

চিত্র থেকে বর্গক্ষেত্র তৈরি করতে, আপনাকে (-0.8, -0.8) এবং (0.8, 0.8) শীর্ষবিন্দু দুটি দুবার তালিকাভুক্ত করতে হবে, একবার নীল ত্রিভুজের জন্য এবং একবার লাল ত্রিভুজের জন্য। (আপনি বর্গক্ষেত্রটিকে অন্য দুটি কোণ দিয়ে ভাগ করতেও পারেন; এতে কোনও পার্থক্য নেই।)
- আপনার পূর্ববর্তী
verticesঅ্যারেটি এইরকম দেখতে আপডেট করুন:
সূচক.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,
]);
যদিও চিত্রটি স্পষ্টতার জন্য দুটি ত্রিভুজের মধ্যে একটি বিচ্ছেদ দেখায়, শীর্ষবিন্দুগুলির অবস্থানগুলি হুবহু একই, এবং GPU এগুলিকে ফাঁক ছাড়াই রেন্ডার করে। এটি একটি একক, কঠিন বর্গক্ষেত্র হিসাবে রেন্ডার করবে।
একটি ভার্টেক্স বাফার তৈরি করুন
জাভাস্ক্রিপ্ট অ্যারের ডেটা দিয়ে GPU শীর্ষবিন্দু আঁকতে পারে না। GPU-এর প্রায়শই নিজস্ব মেমোরি থাকে যা রেন্ডারিংয়ের জন্য অত্যন্ত অপ্টিমাইজ করা হয়, এবং তাই GPU-কে আঁকার সময় যে কোনও ডেটা ব্যবহার করতে চান তা সেই মেমোরিতে রাখতে হবে।
ভার্টেক্স ডেটা সহ অনেক মানের জন্য, GPU-সাইড মেমরি GPUBuffer অবজেক্টের মাধ্যমে পরিচালিত হয়। বাফার হল মেমরির একটি ব্লক যা GPU-তে সহজেই অ্যাক্সেসযোগ্য এবং নির্দিষ্ট উদ্দেশ্যে চিহ্নিত করা হয়। আপনি এটিকে GPU-দৃশ্যমান TypedArray-এর মতো কিছুটা ভাবতে পারেন।
- আপনার শীর্ষবিন্দু ধরে রাখার জন্য একটি বাফার তৈরি করতে, আপনার
verticesঅ্যারের সংজ্ঞার পরেdevice.createBuffer()এ নিম্নলিখিত কলটি যুক্ত করুন।
সূচক.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 ইতিমধ্যেই আপনার জন্য তাদের বাইটদৈর্ঘ্য গণনা করে, এবং তাই আপনি বাফার তৈরি করার সময় এটি ব্যবহার করতে পারেন।
অবশেষে, আপনাকে বাফারের ব্যবহার নির্দিষ্ট করতে হবে। এটি GPUBufferUsage ফ্ল্যাগগুলির মধ্যে একটি বা একাধিক, যার একাধিক ফ্ল্যাগ | ( bitwise OR ) অপারেটরের সাথে একত্রিত করা হয়েছে। এই ক্ষেত্রে, আপনি নির্দিষ্ট করেন যে আপনি বাফারটি ভার্টেক্স ডেটা ( GPUBufferUsage.VERTEX ) এর জন্য ব্যবহার করতে চান এবং আপনি এতে ডেটা কপি করতেও সক্ষম হতে চান ( GPUBufferUsage.COPY_DST )।
আপনার কাছে যে বাফার অবজেক্টটি ফেরত পাওয়া যায় তা অস্বচ্ছ—আপনি (সহজেই) এতে থাকা ডেটা পরীক্ষা করতে পারবেন না। উপরন্তু, এর বেশিরভাগ বৈশিষ্ট্যই অপরিবর্তনীয়—আপনি GPUBuffer তৈরি করার পরে এর আকার পরিবর্তন করতে পারবেন না, এবং ব্যবহারের পতাকাও পরিবর্তন করতে পারবেন না। আপনি যা পরিবর্তন করতে পারেন তা হল এর মেমরির বিষয়বস্তু।
যখন বাফারটি প্রাথমিকভাবে তৈরি করা হবে, তখন এতে থাকা মেমোরিটি শূন্যে ইনিশিয়ালাইজ করা হবে। এর বিষয়বস্তু পরিবর্তন করার বিভিন্ন উপায় আছে, তবে সবচেয়ে সহজ হল device.queue.writeBuffer() একটি TypedArray দিয়ে কল করা যা আপনি কপি করতে চান।
- বাফারের মেমোরিতে ভার্টেক্স ডেটা কপি করতে, নিম্নলিখিত কোডটি যোগ করুন:
সূচক.html
device.queue.writeBuffer(vertexBuffer, /*bufferOffset=*/0, vertices);
শীর্ষবিন্দু বিন্যাস সংজ্ঞায়িত করুন
এখন আপনার কাছে ভার্টেক্স ডেটা সহ একটি বাফার আছে, কিন্তু জিপিইউর ক্ষেত্রে এটি কেবল বাইটের একটি ব্লব। যদি আপনি এটি দিয়ে কিছু আঁকতে চান তবে আপনাকে আরও কিছু তথ্য সরবরাহ করতে হবে। আপনাকে ভার্টেক্স ডেটার গঠন সম্পর্কে WebGPU কে আরও বলতে সক্ষম হতে হবে।
-
GPUVertexBufferLayoutঅভিধানের সাহায্যে ভার্টেক্স ডেটা স্ট্রাকচার সংজ্ঞায়িত করুন:
সূচক.html
const vertexBufferLayout = {
arrayStride: 8,
attributes: [{
format: "float32x2",
offset: 0,
shaderLocation: 0, // Position, see vertex shader
}],
};
প্রথম নজরে এটি কিছুটা বিভ্রান্তিকর মনে হতে পারে, তবে এটি ভেঙে ফেলা তুলনামূলকভাবে সহজ।
প্রথমেই আপনি arrayStride দেবেন। পরবর্তী শীর্ষবিন্দু খুঁজতে গেলে GPU-কে বাফারে এগিয়ে যেতে কত বাইট লাগবে তা এখানে উল্লেখ করা হয়েছে। আপনার বর্গক্ষেত্রের প্রতিটি শীর্ষবিন্দু দুটি 32-বিট ভাসমান বিন্দু সংখ্যা দিয়ে তৈরি। যেমনটি আগেই উল্লেখ করা হয়েছে, একটি 32-বিট ফ্লোট 4 বাইট, তাই দুটি ফ্লোট 8 বাইট।
এরপরে আছে attributes প্রপার্টি, যা একটি অ্যারে। attributes হল প্রতিটি শীর্ষবিন্দুতে এনকোড করা তথ্যের পৃথক অংশ। আপনার শীর্ষবিন্দুতে কেবল একটি বৈশিষ্ট্য (শীর্ষবিন্দু অবস্থান) থাকে, তবে আরও উন্নত ব্যবহারের ক্ষেত্রে প্রায়শই একাধিক বৈশিষ্ট্য সহ শীর্ষবিন্দু থাকে যেমন একটি শীর্ষবিন্দুর রঙ বা জ্যামিতি পৃষ্ঠটি যে দিকে নির্দেশ করছে। যদিও এই কোডল্যাবের জন্য এটি সুযোগের বাইরে।
আপনার একক বৈশিষ্ট্যে, আপনাকে প্রথমে ডেটার format নির্ধারণ করতে হবে। এটি GPUVertexFormat ধরণের তালিকা থেকে আসে যা GPU বুঝতে পারে এমন প্রতিটি ধরণের ভার্টেক্স ডেটা বর্ণনা করে। আপনার প্রতিটি ভার্টেক্সে দুটি 32-বিট ফ্লোট রয়েছে, তাই আপনি float32x2 ফর্ম্যাটটি ব্যবহার করেন। যদি আপনার ভার্টেক্স ডেটা চারটি 16-বিট স্বাক্ষরবিহীন পূর্ণসংখ্যা দিয়ে তৈরি হয়, উদাহরণস্বরূপ, আপনি পরিবর্তে uint16x4 ব্যবহার করবেন। প্যাটার্নটি দেখেছেন?
এরপর, offset বর্ণনা করে যে এই বিশেষ অ্যাট্রিবিউটটি কত বাইট দিয়ে শুরু হয়। আপনার বাফারে একাধিক অ্যাট্রিবিউট থাকলেই কেবল আপনাকে এই বিষয়ে চিন্তা করতে হবে, যা এই কোডল্যাবের সময় আসবে না।
অবশেষে, আপনার কাছে shaderLocation আছে। এটি 0 থেকে 15 এর মধ্যে একটি ইচ্ছাকৃত সংখ্যা এবং আপনার সংজ্ঞায়িত প্রতিটি বৈশিষ্ট্যের জন্য এটি অবশ্যই অনন্য হতে হবে। এটি এই বৈশিষ্ট্যটিকে ভার্টেক্স শেডারের একটি নির্দিষ্ট ইনপুটের সাথে সংযুক্ত করে, যা আপনি পরবর্তী বিভাগে শিখবেন।
লক্ষ্য করুন যে আপনি যদিও এই মানগুলি এখন সংজ্ঞায়িত করছেন, আপনি আসলে এগুলিকে WebGPU API-তে কোথাও পাঠাচ্ছেন না। এটি আসছে, তবে আপনার শীর্ষবিন্দুগুলি সংজ্ঞায়িত করার সময় এই মানগুলি সম্পর্কে চিন্তা করা সবচেয়ে সহজ, তাই আপনি পরে ব্যবহারের জন্য এখনই এগুলি সেট আপ করছেন।
শেডার দিয়ে শুরু করুন
এখন আপনার কাছে সেই ডেটা আছে যা আপনি রেন্ডার করতে চান, কিন্তু আপনাকে এখনও GPU-কে ঠিক কীভাবে এটি প্রক্রিয়া করতে হবে তা বলতে হবে। এর একটি বড় অংশ শেডারের ক্ষেত্রে ঘটে।
শেডার হলো ছোট প্রোগ্রাম যা আপনি লেখেন এবং আপনার GPU তে এক্সিকিউট করেন। প্রতিটি শেডার ডেটার একটি ভিন্ন পর্যায়ে কাজ করে: Vertex প্রসেসিং, Fragment প্রসেসিং, অথবা general Compute । যেহেতু এগুলি GPU তে থাকে, তাই এগুলি আপনার গড় জাভাস্ক্রিপ্টের তুলনায় আরও কঠোরভাবে গঠন করা হয়। কিন্তু এই কাঠামোটি তাদের খুব দ্রুত এবং, গুরুত্বপূর্ণভাবে, সমান্তরালভাবে এক্সিকিউট করতে দেয়!
WebGPU-তে শেডার্স WGSL (WebGPU শেডিং ল্যাঙ্গুয়েজ) নামক একটি শেডিং ভাষায় লেখা হয়। WGSL, সিনট্যাক্সিকভাবে, কিছুটা রাস্টের মতো, যার বৈশিষ্ট্যগুলি সাধারণ ধরণের GPU কাজ (যেমন ভেক্টর এবং ম্যাট্রিক্স গণিত) সহজ এবং দ্রুত করার লক্ষ্যে তৈরি। শেডিং ভাষার সম্পূর্ণতা শেখানো এই কোডল্যাবের আওতার বাইরে, তবে আশা করি আপনি কিছু সহজ উদাহরণের মধ্য দিয়ে যাওয়ার সময় কিছু মৌলিক বিষয় শিখতে পারবেন।
শেডারগুলি নিজেরাই স্ট্রিং হিসেবে WebGPU-তে প্রবেশ করে।
-
vertexBufferLayoutনিচে আপনার কোডে নিম্নলিখিতটি অনুলিপি করে আপনার শেডার কোড প্রবেশ করার জন্য একটি জায়গা তৈরি করুন:
সূচক.html
const cellShaderModule = device.createShaderModule({
label: "Cell shader",
code: `
// Your shader code will go here
`
});
শেডার তৈরি করতে আপনি device.createShaderModule() কল করেন, যেখানে আপনি একটি ঐচ্ছিক label এবং WGSL code স্ট্রিং হিসেবে প্রদান করেন। (মনে রাখবেন যে আপনি এখানে বহু-লাইন স্ট্রিং অনুমোদন করার জন্য ব্যাকটিক ব্যবহার করেন!) একবার আপনি কিছু বৈধ WGSL কোড যোগ করলে, ফাংশনটি সংকলিত ফলাফল সহ একটি GPUShaderModule অবজেক্ট ফেরত পাঠায়।
ভার্টেক্স শেডার সংজ্ঞায়িত করুন
ভার্টেক্স শেডার দিয়ে শুরু করুন কারণ জিপিইউও সেখান থেকেই শুরু হয়!
একটি ভার্টেক্স শেডারকে একটি ফাংশন হিসেবে সংজ্ঞায়িত করা হয়, এবং GPU আপনার vertexBuffer এর প্রতিটি ভার্টেক্সের জন্য একবার ফাংশনটিকে কল করে। যেহেতু আপনার vertexBuffer এর ছয়টি অবস্থান (ভার্টিস) আছে, তাই আপনি যে ফাংশনটি সংজ্ঞায়িত করেন তা ছয়বার কল করা হয়। প্রতিবার এটি কল করার সময়, vertexBuffer থেকে একটি ভিন্ন অবস্থান ফাংশনে একটি আর্গুমেন্ট হিসাবে পাস করা হয় এবং ভার্টেক্স শেডার ফাংশনের কাজ হল ক্লিপ স্পেসে একটি সংশ্লিষ্ট অবস্থান ফিরিয়ে দেওয়া।
এটা বোঝা গুরুত্বপূর্ণ যে, এগুলোকে ক্রমানুসারে ডাকা হবে না। পরিবর্তে, GPU গুলি এই ধরণের শেডারগুলিকে সমান্তরালভাবে চালানোর ক্ষেত্রে পারদর্শী, একই সাথে শত শত (অথবা এমনকি হাজার হাজার!) শীর্ষবিন্দু প্রক্রিয়াকরণ করতে পারে! GPU গুলির অবিশ্বাস্য গতির জন্য এটি একটি বিশাল অংশ, তবে এর সাথে সীমাবদ্ধতাও রয়েছে। চরম সমান্তরালকরণ নিশ্চিত করার জন্য, শীর্ষবিন্দু শেডারগুলি একে অপরের সাথে যোগাযোগ করতে পারে না। প্রতিটি শেডার ইনভোকেশন একবারে শুধুমাত্র একটি শীর্ষবিন্দুর জন্য ডেটা দেখতে পারে এবং শুধুমাত্র একটি শীর্ষবিন্দুর জন্য মান আউটপুট করতে সক্ষম।
WGSL-এ, একটি vertex shader ফাংশনের নাম আপনি যা খুশি রাখতে পারেন, তবে এটি কোন shader stage প্রতিনিধিত্ব করে তা নির্দেশ করার জন্য এর সামনে @vertex অ্যাট্রিবিউট থাকতে হবে। WGSL fn কীওয়ার্ড দিয়ে ফাংশন নির্দেশ করে, যেকোনো আর্গুমেন্ট ঘোষণা করতে বন্ধনী ব্যবহার করে এবং স্কোপ নির্ধারণ করতে কোঁকড়া বন্ধনী ব্যবহার করে।
- একটি খালি
@vertexফাংশন তৈরি করুন, যেমন:
index.html (createShaderModule কোড)
@vertex
fn vertexMain() {
}
যদিও এটি বৈধ নয়, কারণ একটি ভার্টেক্স শেডারকে ক্লিপ স্পেসে প্রক্রিয়াকৃত ভার্টেক্সের কমপক্ষে চূড়ান্ত অবস্থানে ফিরে যেতে হবে। এটি সর্বদা একটি 4-মাত্রিক ভেক্টর হিসাবে দেওয়া হয়। ভেক্টরগুলি শেডারগুলিতে ব্যবহার করা এত সাধারণ জিনিস যে তাদের ভাষায় প্রথম-শ্রেণীর আদিম হিসাবে বিবেচনা করা হয়, 4-মাত্রিক ভেক্টরের জন্য vec4f মতো নিজস্ব প্রকার রয়েছে। 2D ভেক্টর ( vec2f ) এবং 3D ভেক্টর ( vec3f ) এর জন্যও একই ধরণের প্রকার রয়েছে!
- যে মানটি ফেরত দেওয়া হচ্ছে তা প্রয়োজনীয় অবস্থান তা বোঝাতে,
@builtin(position)অ্যাট্রিবিউট দিয়ে চিহ্নিত করুন। ফাংশনটি যে এটিই ফেরত দেয় তা বোঝাতে একটি->চিহ্ন ব্যবহার করা হয়।
index.html (createShaderModule কোড)
@vertex
fn vertexMain() -> @builtin(position) vec4f {
}
অবশ্যই, যদি ফাংশনটির একটি রিটার্ন টাইপ থাকে, তাহলে আপনাকে ফাংশন বডিতে একটি মান ফেরত দিতে হবে। আপনি vec4f(x, y, z, w) সিনট্যাক্স ব্যবহার করে একটি নতুন vec4f রিটার্ন করতে পারেন। x , y , এবং z মানগুলি হল ফ্লোটিং পয়েন্ট সংখ্যা যা রিটার্ন মানে, ক্লিপ স্পেসে শীর্ষবিন্দু কোথায় অবস্থিত তা নির্দেশ করে।
- Return a static value of
(0, 0, 0, 1), and you technically have a valid vertex shader, although one that never displays anything since the GPU recognizes that the triangles it produces are just a single point and then discards it.
index.html (createShaderModule code)
@vertex
fn vertexMain() -> @builtin(position) vec4f {
return vec4f(0, 0, 0, 1); // (X, Y, Z, W)
}
What you want instead is to make use of the data from the buffer that you created, and you do that by declaring an argument for your function with a @location() attribute and type that match what you described in the vertexBufferLayout . You specified a shaderLocation of 0 , so in your WGSL code, mark the argument with @location(0) . You also defined the format as a float32x2 , which is a 2D vector, so in WGSL your argument is a vec2f . You can name it whatever you like, but since these represent your vertex positions, a name like pos seems natural.
- Change your shader function to the following code:
index.html (createShaderModule code)
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(0, 0, 0, 1);
}
And now you need to return that position. Since the position is a 2D vector and the return type is a 4D vector, you have to alter it a bit. What you want to do is take the two components from the position argument and place them in the first two components of the return vector, leaving the last two components as 0 and 1 , respectively.
- Return the correct position by explicitly stating which position components to use:
index.html (createShaderModule code)
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(pos.x, pos.y, 0, 1);
}
However , because these kinds of mappings are so common in shaders, you can also pass the position vector in as the first argument in a convenient shorthand and it means the same thing.
- Rewrite the
returnstatement with the following code:
index.html (createShaderModule code)
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(pos, 0, 1);
}
And that's your initial vertex shader! It's very simple, just passing out the position effectively unchanged, but it's good enough to get started.
Define the fragment shader
Next up is the fragment shader. Fragment shaders operate in a very similar way to vertex shaders, but rather than being invoked for every vertex, they're invoked for every pixel being drawn.
Fragment shaders are always called after vertex shaders. The GPU takes the output of the vertex shaders and triangulates it, creating triangles out of sets of three points. It then rasterizes each of those triangles by figuring out which pixels of the output color attachments are included in that triangle, and then calls the fragment shader once for each of those pixels. The fragment shader returns a color, typically calculated from values sent to it from the vertex shader and assets like textures, which the GPU writes to the color attachment.
Just like vertex shaders, fragment shaders are executed in a massively parallel fashion. They're a little more flexible than vertex shaders in terms of their inputs and outputs, but you can consider them to simply return one color for each pixel of each triangle.
A WGSL fragment shader function is denoted with the @fragment attribute and it also returns a vec4f . In this case, though, the vector represents a color, not a position. The return value needs to be given a @location attribute in order to indicate which colorAttachment from the beginRenderPass call the returned color is written to. Since you only had one attachment, the location is 0.
- Create an empty
@fragmentfunction, like this:
index.html (createShaderModule code)
@fragment
fn fragmentMain() -> @location(0) vec4f {
}
The four components of the returned vector are the red, green, blue, and alpha color values, which are interpreted in exactly the same way as the clearValue you set in beginRenderPass earlier. So vec4f(1, 0, 0, 1) is bright red, which seems like a decent color for your square. You're free to set it to whatever color you want, though!
- Set the returned color vector, like this:
index.html (createShaderModule code)
@fragment
fn fragmentMain() -> @location(0) vec4f {
return vec4f(1, 0, 0, 1); // (Red, Green, Blue, Alpha)
}
And that's a complete fragment shader! It's not a terribly interesting one; it just sets every pixel of every triangle to red, but that's sufficient for now.
Just to recap, after adding the shader code detailed above, your createShaderModule call now looks like this:
সূচক.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);
}
`
});
Create a render pipeline
A shader module can't be used for rendering on its own. Instead, you have to use it as part of a GPURenderPipeline , created by calling device.createRenderPipeline() . The render pipeline controls how geometry is drawn, including things like which shaders are used, how to interpret data in vertex buffers, which kind of geometry should be rendered (lines, points, triangles...), and more!
The render pipeline is the most complex object in the entire API, but don't worry! Most of the values you can pass to it are optional, and you only need to provide a few to start.
- Create a render pipeline, like this:
সূচক.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
}]
}
});
Every pipeline needs a layout that describes what types of inputs (other than vertex buffers) the pipeline needs, but you don't really have any. Fortunately, you can pass "auto" for now, and the pipeline builds its own layout from the shaders.
Next, you have to provide details about the vertex stage. The module is the GPUShaderModule that contains your vertex shader, and the entryPoint gives the name of the function in the shader code that is called for every vertex invocation. (You can have multiple @vertex and @fragment functions in a single shader module!) The buffers is an array of GPUVertexBufferLayout objects that describe how your data is packed in the vertex buffers that you use this pipeline with. Luckily, you already defined this earlier in your vertexBufferLayout ! Here's where you pass it in.
Lastly, you have details about the fragment stage. This also includes a shader module and entryPoint , like the vertex stage. The last bit is to define the targets that this pipeline is used with. This is an array of dictionaries giving details—such as the texture format —of the color attachments that the pipeline outputs to. These details need to match the textures given in the colorAttachments of any render passes that this pipeline is used with. Your render pass uses textures from the canvas context, and uses the value you saved in canvasFormat for its format, so you pass the same format here.
That's not even close to all of the options that you can specify when creating a render pipeline, but it's enough for the needs of this codelab!
Draw the square
And with that, you now have everything that you need in order to draw your square!
- To draw the square, jump back down to the
encoder.beginRenderPass()andpass.end()pair of calls, and then add these new commands between them:
সূচক.html
// After encoder.beginRenderPass()
pass.setPipeline(cellPipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.draw(vertices.length / 2); // 6 vertices
// before pass.end()
This supplies WebGPU with all the information necessary to draw your square. First, you use setPipeline() to indicate which pipeline should be used to draw with. This includes the shaders that are used, the layout of the vertex data, and other relevant state data.
Next, you call setVertexBuffer() with the buffer containing the vertices for your square. You call it with 0 because this buffer corresponds to the 0th element in the current pipeline's vertex.buffers definition.
And last, you make the draw() call, which seems strangely simple after all the setup that's come before. The only thing you need to pass in is the number of vertices that it should render, which it pulls from the currently set vertex buffers and interprets with the currently set pipeline. You could just hard-code it to 6 , but calculating it from the vertices array (12 floats / 2 coordinates per vertex == 6 vertices) means that if you ever decided to replace the square with, for example, a circle, there's less to update by hand.
- Refresh your screen and (finally) see the results of all your hard work: one big colored square.

5. Draw a grid
First, take a moment to congratulate yourself! Getting the first bits of geometry on screen is often one of the hardest steps with most GPU APIs. Everything you do from here can be done in smaller steps, making it easier to verify your progress as you go.
In this section, you learn:
- How to pass variables (called uniforms) to the shader from JavaScript.
- How to use uniforms to change the rendering behavior.
- How to use instancing to draw many different variants of the same geometry.
Define the grid
In order to render a grid, you need to know a very fundamental piece of information about it. How many cells does it contain, both in width and height? This is up to you as the developer, but to keep things a bit easier, treat the grid as a square (same width and height) and use a size that's a power of two. (That makes some of the math easier later.) You want to make it bigger eventually, but for the rest of this section, set your grid size to 4x4 because it makes it easier to demonstrate some of the math used in this section. Scale it up after!
- Define the grid size by adding a constant to the top of your JavaScript code.
সূচক.html
const GRID_SIZE = 4;
Next, you need to update how you render your square so that you can fit GRID_SIZE times GRID_SIZE of them on the canvas. That means the square needs to be a lot smaller, and there needs to be a lot of them.
Now, one way you could approach this is by making your vertex buffer significantly bigger and defining GRID_SIZE times GRID_SIZE worth of squares inside it at the right size and position. The code for that wouldn't be too bad, in fact! Just a couple of for loops and a bit of math. But that's also not making the best use of the GPU and using more memory than necessary to achieve the effect. This section looks at a more GPU-friendly approach.
Create a uniform buffer
First, you need to communicate the grid size you've chosen to the shader, since it uses that to change how things display. You could just hard-code the size into the shader, but then that means that any time you want to change the grid size you have to re-create the shader and render pipeline, which is expensive. A better way is to provide the grid size to the shader as uniforms .
You learned earlier that a different value from the vertex buffer is passed to every invocation of a vertex shader. A uniform is a value from a buffer that is the same for every invocation. They're useful for communicating values that are common for a piece of geometry (like its position), a full frame of animation (like the current time), or even the entire lifespan of the app (like a user preference).
- Create a uniform buffer by adding the following code:
সূচক.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);
This should look very familiar, because it's almost exactly the same code that you used to create the vertex buffer earlier! That's because uniforms are communicated to the WebGPU API through the same GPUBuffer objects that vertices are, with the main difference being that the usage this time includes GPUBufferUsage.UNIFORM instead of GPUBufferUsage.VERTEX .
Access uniforms in a shader
- Define a uniform by adding the following code:
index.html (createShaderModule call)
// 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
This defines a uniform in your shader called grid , which is a 2D float vector that matches the array that you just copied into the uniform buffer. It also specifies that the uniform is bound at @group(0) and @binding(0) . You'll learn what those values mean in a moment.
Then, elsewhere in the shader code, you can use the grid vector however you need. In this code you divide the vertex position by the grid vector. Since pos is a 2D vector and grid is a 2D vector, WGSL performs a component-wise division. In other words, the result is the same as saying vec2f(pos.x / grid.x, pos.y / grid.y) .
These types of vector operations are very common in GPU shaders since many rendering and compute techniques rely on them!
What this means in your case is that (if you used a grid size of 4) the square that you render would be one-fourth of its original size. That's perfect if you want to fit four of them to a row or column!
Create a Bind Group
Declaring the uniform in the shader doesn't connect it with the buffer that you created, though. In order to do that, you need to create and set a bind group .
A bind group is a collection of resources that you want to make accessible to your shader at the same time. It can include several types of buffers, like your uniform buffer, and other resources like textures and samplers that are not covered here but are common parts of WebGPU rendering techniques.
- Create a bind group with your uniform buffer by adding the following code after the creation of the uniform buffer and render pipeline:
সূচক.html
const bindGroup = device.createBindGroup({
label: "Cell renderer bind group",
layout: cellPipeline.getBindGroupLayout(0),
entries: [{
binding: 0,
resource: { buffer: uniformBuffer }
}],
});
In addition to your now-standard label , you also need a layout that describes which types of resources this bind group contains. This is something that you dig into further in a future step, but for the moment you can happily ask your pipeline for the bind group layout because you created the pipeline with layout: "auto" . That causes the pipeline to create bind group layouts automatically from the bindings that you declared in the shader code itself. In this case, you ask it to getBindGroupLayout(0) , where the 0 corresponds to the @group(0) that you typed in the shader.
After specifying the layout, you provide an array of entries . Each entry is a dictionary with at least the following values:
-
binding, which corresponds with the@binding()value you entered in the shader. In this case,0. -
resource, which is the actual resource that you want to expose to the variable at the specified binding index. In this case, your uniform buffer.
The function returns a GPUBindGroup , which is an opaque, immutable handle. You can't change the resources that a bind group points to after it's been created, though you can change the contents of those resources. For example, if you change the uniform buffer to contain a new grid size, that is reflected by future draw calls using this bind group.
Bind the bind group
Now that the bind group is created, you still need to tell WebGPU to use it when drawing. Fortunately this is pretty simple.
- Hop back down to the render pass and add this new line before the
draw()method:
সূচক.html
pass.setPipeline(cellPipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.setBindGroup(0, bindGroup); // New line!
pass.draw(vertices.length / 2);
The 0 passed as the first argument corresponds to the @group(0) in the shader code. You're saying that each @binding that's part of @group(0) uses the resources in this bind group.
And now the uniform buffer is exposed to your shader!
- Refresh your page, and then you should see something like this:

Hooray! Your square is now one-fourth the size it was before! That's not much, but it shows that your uniform is actually applied and that the shader can now access the size of your grid.
Manipulate geometry in the shader
So now that you can reference the grid size in the shader, you can start doing some work to manipulate the geometry you're rendering to fit your desired grid pattern. To do that, consider exactly what you want to achieve.
You need to conceptually divide up your canvas into individual cells. In order to keep the convention that the X axis increases as you move right and the Y axis increases as you move up, say that the first cell is in the bottom left corner of the canvas. That gives you a layout that looks like this, with your current square geometry in the middle:

Your challenge is to find a method in the shader that lets you position the square geometry in any of those cells given the cell coordinates.
First, you can see that your square isn't nicely aligned with any of the cells because it was defined to surround the center of the canvas. You'd want to have the square shifted by half a cell so that it would line up nicely inside them.
One way you could fix this is to update the square's vertex buffer. By shifting the vertices so that the bottom-left corner is at, for example, (0.1, 0.1) instead of (-0.8, -0.8), you'd move this square to line up with the cell boundaries more nicely. But, since you have full control over how the vertices are processed in your shader, it's just as easy to simply nudge them into place using the shader code!
- Alter the vertex shader module with the following code:
index.html (createShaderModule call)
@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);
}
This moves every vertex up and to the right by one (which, remember, is half of the clip space) before dividing it by the grid size. The result is a nicely grid-aligned square just off of the origin.

Next, because your canvas' coordinate system places (0, 0) in the center and (-1, -1) in the lower left, and you want (0, 0) to be in the lower left, you need to translate your geometry's position by (-1, -1) after dividing by the grid size in order to move it into that corner.
- Translate your geometry's position, like this:
index.html (createShaderModule call)
@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);
}
And now your square is nicely positioned in cell (0, 0)!

What if you want to place it in a different cell? Figure that out by declaring a cell vector in your shader and populating it with a static value like let cell = vec2f(1, 1) .
If you add that to the gridPos , it undoes the - 1 in the algorithm, so that's not what you want. Instead, you want to move the square only by one grid unit (one-fourth of the canvas) for each cell. Sounds like you need to do another divide by grid !
- Change your grid positioning, like this:
index.html (createShaderModule call)
@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);
}
If you refresh now, you see the following:

Hm. Not quite what you wanted.
The reason for this is that since the canvas coordinates go from -1 to +1, it's actually 2 units across . That means if you want to move a vertex one-fourth of the canvas over, you have to move it 0.5 units. This is an easy mistake to make when reasoning with GPU coordinates! Fortunately, the fix is just as easy.
- Multiply your offset by 2, like this:
index.html (createShaderModule call)
@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);
}
And this gives you exactly what you want.

The screenshot looks like this:

Furthermore, you can now set cell to any value within the grid bounds, and then refresh to see the square render in the desired location.
Draw instances
Now that you can place the square where you want it with a bit of math, the next step is to render one square in each cell of the grid.
One way you could approach it is to write cell coordinates to a uniform buffer, then call draw once for each square in the grid, updating the uniform every time. That would be very slow, however, since the GPU has to wait for the new coordinate to be written by JavaScript every time. One of the keys to getting good performance out of the GPU is to minimize the time it spends waiting on other parts of the system!
Instead, you can use a technique called instancing. Instancing is a way to tell the GPU to draw multiple copies of the same geometry with a single call to draw , which is much faster than calling draw once for every copy. Each copy of the geometry is referred to as an instance .
- To tell the GPU that you want enough instances of your square to fill the grid, add one argument to your existing draw call:
সূচক.html
pass.draw(vertices.length / 2, GRID_SIZE * GRID_SIZE);
This tells the system that you want it to draw the six ( vertices.length / 2 ) vertices of your square 16 ( GRID_SIZE * GRID_SIZE ) times. But if you refresh the page, you still see the following:

Why? Well, it's because you draw all 16 of those squares in the same spot. Your need to have some additional logic in the shader that repositions the geometry on a per-instance basis.
In the shader, in addition to the vertex attributes like pos that come from your vertex buffer, you can also access what are known as WGSL's built-in values . These are values that are calculated by WebGPU, and one such value is the instance_index . The instance_index is an unsigned 32-bit number from 0 to number of instances - 1 that you can use as part of your shader logic. Its value is the same for every vertex processed that's part of the same instance. That means your vertex shader gets called six times with an instance_index of 0 , once for each position in your vertex buffer. Then six more times with an instance_index of 1 , then six more with instance_index of 2 , and so on.
To see this in action, you have to add the instance_index built-in to your shader inputs. Do this in the same way as the position, but instead of tagging it with a @location attribute, use @builtin(instance_index) , and then name the argument whatever you want. (You can call it instance to match the example code.) Then use it as part of the shader logic!
- Use
instancein place of the cell coordinates:
সূচক.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);
}
If you refresh now you see that you do indeed have more than one square! But you can't see all 16 of them.

That's because the cell coordinates you generate are (0, 0), (1, 1), (2, 2)... all the way to (15, 15), but only the first four of those fit on the canvas. To make the grid that you want, you need to transform the instance_index such that each index maps to a unique cell within your grid, like this:

The math for that is reasonably straightforward. For each cell's X value, you want the modulo of the instance_index and the grid width, which you can perform in WGSL with the % operator. And for each cell's Y value you want the instance_index divided by the grid width, discarding any fractional remainder. You can do that with WGSL's floor() function.
- Change the calculations, like this:
সূচক.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);
}
After making that update to the code you have the long-awaited grid of squares at last!

- And now that it's working, go back and crank up the grid size!
সূচক.html
const GRID_SIZE = 32;

Tada! You can actually make this grid really, really big now and your average GPU handles it just fine. You'll stop seeing the individual squares long before you run into any GPU performance bottlenecks.
6. Extra credit: make it more colorful!
At this point, you can easily skip to the next section since you've laid the groundwork for the rest of the codelab. But while the grid of squares all sharing the same color is serviceable, it's not exactly exciting, is it? Fortunately you can make things a bit brighter with a little more math and shader code!
Use structs in shaders
Until now, you've passed one piece of data out of the vertex shader: the transformed position. But you can actually return a lot more data from the vertex shader and then use it in the fragment shader!
The only way to pass data out of the vertex shader is by returning it. A vertex shader is always required to return a position, so if you want to return any other data along with it, you need to place it in a struct. Structs in WGSL are named object types that contain one or more named properties. The properties can be marked up with attributes like @builtin and @location too. You declare them outside of any functions, and then you can pass instances of them in and out of functions, as needed. For example, consider your current vertex shader:
index.html (createShaderModule call)
@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);
}
- Express the same thing using structs for the function input and output:
index.html (createShaderModule call)
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;
}
Notice that this requires you to refer to the input position and instance index with input , and the struct that you return first needs to be declared as a variable and have its individual properties set. In this case, it doesn't make too much difference, and in fact makes the shader function a bit longer, but as your shaders grow more complex, using structs can be a great way to help organize your data.
Pass data between the vertex and fragment functions
As a reminder, your @fragment function is as simple as possible:
index.html (createShaderModule call)
@fragment
fn fragmentMain() -> @location(0) vec4f {
return vec4f(1, 0, 0, 1);
}
You are not taking any inputs, and you are passing out a solid color (red) as your output. If the shader knew more about the geometry that it's coloring, though, you could use that extra data to make things a bit more interesting. For instance, what if you want to change the color of each square based on its cell coordinate? The @vertex stage knows which cell is being rendered; you just need to pass it along to the @fragment stage.
To pass any data between the vertex and fragment stages, you need to include it in an output struct with a @location of our choice. Since you want to pass the cell coordinate, add it to the VertexOutput struct from earlier, and then set it in the @vertex function before you return.
- Change the return value of your vertex shader, like this:
index.html (createShaderModule call)
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;
}
- In the
@fragmentfunction, receive the value by adding an argument with the same@location. (The names don't have to match, but it's easier to keep track of things if they do!)
index.html (createShaderModule call)
@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);
}
- Alternatively, you could use a struct instead:
index.html (createShaderModule call)
struct FragInput {
@location(0) cell: vec2f,
};
@fragment
fn fragmentMain(input: FragInput) -> @location(0) vec4f {
return vec4f(input.cell, 0, 1);
}
- Another alternative, since in your code both of these functions are defined in the same shader module, is to reuse the
@vertexstage's output struct! This makes passing values easy because the names and locations are naturally consistent.
index.html (createShaderModule call)
@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
return vec4f(input.cell, 0, 1);
}
No matter which pattern you chose, the result is that you have access to the cell number in the @fragment function and are able to use it in order to influence the color. With any of the above code, the output looks like this:

There are definitely more colors now, but it's not exactly nice looking. You might wonder why only the left and bottom rows are different. That's because the color values that you return from the @fragment function expect each channel to be in the range of 0 to 1, and any values outside of that range are clamped to it. Your cell values, on the other hand, range from 0 to 32 along each axis. So what you see here is that the first row and column immediately hit that full 1 value on either the red or green color channel, and every cell after that clamps to the same value.
If you want a smoother transition between colors, you need to return a fractional value for each color channel, ideally starting at zero and ending at one along each axis, which means yet another divide by grid !
- Change the fragment shader, like this:
index.html (createShaderModule call)
@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
return vec4f(input.cell/grid, 0, 1);
}
Refresh the page, and you can see that the new code does give you a much nicer gradient of colors across the entire grid.

While that's certainly an improvement, there's now an unfortunate dark corner in the lower left, where the grid becomes black. When you start doing the Game of Life simulation, a hard-to-see section of the grid will obscure what's going on. It would be nice to brighten that up.
Fortunately, you have a whole unused color channel—blue—that you can use. The effect that you ideally want is to have the blue be brightest where the other colors are darkest, and then fade out as the other colors grow in intensity. The easiest way to do that is to have the channel start at 1 and subtract one of the cell values. It can be either cx or cy . Try both, and then pick the one you prefer!
- Add brighter colors to the fragment shader, like this:
createShaderModule call
@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
let c = input.cell / grid;
return vec4f(c, 1-c.x, 1);
}
The result looks quite nice!

This isn't a critical step! But because it looks better, it's included it in the corresponding checkpoint source file, and the rest of the screenshots in this codelab reflect this more colorful grid.
7. Manage cell state
Next, you need to control which cells on the grid render, based on some state that's stored on the GPU. This is important for the final simulation!
All you need is an on-off signal for each cell, so any options that allow you to store a large array of nearly any value type works. You might think that this is another use case for uniform buffers! While you could make that work, it's more difficult because uniform buffers are limited in size, can't support dynamically sized arrays (you have to specify the array size in the shader), and can't be written to by compute shaders. That last item is the most problematic, since you want to do the Game of Life simulation on the GPU in a compute shader.
Fortunately, there's another buffer option that avoids all of those limitations.
Create a storage buffer
Storage buffers are general-use buffers that can be read and written to in compute shaders, and read in vertex shaders. They can be very large, and they don't need a specific declared size in a shader, which makes them much more like general memory. That's what you use to store the cell state.
- To create a storage buffer for your cell state, use what—by now—is probably starting to be a familiar-looking snippet of buffer creation code:
সূচক.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,
});
Just like with your vertex and uniform buffers, call device.createBuffer() with the appropriate size, and then make sure to specify a usage of GPUBufferUsage.STORAGE this time.
You can populate the buffer the same way as before by filling the TypedArray of the same size with values and then calling device.queue.writeBuffer() . Because you want to see the effect of your buffer on the grid, start by filling it with something predictable.
- Activate every third cell with the following code:
সূচক.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);
Read the storage buffer in the shader
Next, update your shader to look at the contents of the storage buffer before you render the grid. This looks very similar to how uniforms were added previously.
- Update your shader with the following code:
সূচক.html
@group(0) @binding(0) var<uniform> grid: vec2f;
@group(0) @binding(1) var<storage> cellState: array<u32>; // New!
First, you add the binding point, which tucks right underneath the grid uniform. You want to keep the same @group as the grid uniform, but the @binding number needs to be different. The var type is storage , in order to reflect the different type of buffer, and rather than a single vector, the type that you give for the cellState is an array of u32 values, in order to match the Uint32Array in JavaScript.
Next, in the body of your @vertex function, query the cell's state. Because the state is stored in a flat array in the storage buffer, you can use the instance_index in order to look up the value for the current cell!
How do you turn off a cell if the state says that it's inactive? Well, since the active and inactive states that you get from the array are 1 or 0, you can scale the geometry by the active state! Scaling it by 1 leaves the geometry alone, and scaling it by 0 makes the geometry collapse into a single point, which the GPU then discards.
- Update your shader code to scale the position by the cell's active state. The state value must be cast to a
f32in order to satisfy WGSL's type safety requirements:
সূচক.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;
}
Add the storage buffer to the bind group
Before you can see the cell state take effect, add the storage buffer to a bind group. Because it's part of the same @group as the uniform buffer, add it to the same bind group in the JavaScript code, as well.
- Add the storage buffer, like this:
সূচক.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 }
}],
});
Make sure that the binding of the new entry matches the @binding() of the corresponding value in the shader!
With that in place, you should be able to refresh and see the pattern appear in the grid.

Use the ping-pong buffer pattern
Most simulations like the one you're building typically use at least two copies of their state. On each step of the simulation, they read from one copy of the state and write to the other. Then, on the next step, flip it and read from the state they wrote to previously. This is commonly referred to as a ping pong pattern because the most up-to-date version of the state bounces back and forth between state copies each step.
Why is that necessary? Look at a simplified example: imagine that you're writing a very simple simulation in which you move any active blocks right by one cell each step. To keep things easy to understand, you define your data and simulation in 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.
But if you run that code, the active cell moves all the way to the end of the array in one step! Why? Because you keep updating the state in-place, so you move the active cell right, and then you look at the next cell and... hey! It's active! Better move it to the right again. The fact that you change the data at the same time that you observe it corrupts the results.
By using the ping pong pattern, you ensure that you always perform the next step of the simulation using only the results of the last 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);
- Use this pattern in your own code by updating your storage buffer allocation in order to create two identical buffers:
সূচক.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,
})
];
- To help visualize the difference between the two buffers, fill them with different data:
সূচক.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);
- To show the different storage buffers in your rendering, update your bind groups to have two different variants, as well:
সূচক.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] }
}],
})
];
Set up a render loop
So far, you've only done one draw per page refresh, but now you want to show data updating over time. To do that you need a simple render loop.
A render loop is an endlessly repeating loop that draws your content to the canvas at a certain interval. Many games and other content that want to animate smoothly use the requestAnimationFrame() function to schedule callbacks at the same rate that the screen refreshes (60 times every second).
This app can use that, as well, but in this case, you probably want updates to happen in longer steps so that you can more easily follow what the simulation is doing. Manage the loop yourself instead so that you can control the rate at which your simulation updates.
- First, pick a rate for our simulation to update at (200ms is good, but you can go slower or faster if you like), and then keep track of how many steps of simulation have been completed.
সূচক.html
const UPDATE_INTERVAL = 200; // Update every 200ms (5 times/sec)
let step = 0; // Track how many simulation steps have been run
- Then move all of the code you currently use for rendering into a new function. Schedule that function to repeat at your desired interval with
setInterval(). Make sure that the function also updates the step count, and use that to pick which of the two bind groups to bind.
সূচক.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);
And now when you run the app you see that the canvas flips back and forth between showing the two state buffers you created.


With that, you're pretty much done with the rendering side of things! You're all set to display the output of the Game of Life simulation you build in the next step, where you finally start using compute shaders.
Obviously there is so much more to WebGPU's rendering capabilities than the tiny slice that you explored here, but the rest is beyond the scope of this codelab. Hopefully, it gives you enough of a taste of how WebGPU's rendering works, though, that it helps make exploring more advanced techniques like 3D rendering easier to grasp.
8. Run the simulation
Now, for the last major piece of the puzzle: performing the Game of Life simulation in a compute shader!
Use compute shaders, at last!
You've learned abstractly about compute shaders throughout this codelab, but what exactly are they?
A compute shader is similar to vertex and fragment shaders in that they are designed to run with extreme parallelism on the GPU, but unlike the other two shader stages, they don't have a specific set of inputs and outputs. You are reading and writing data exclusively from sources you choose, like storage buffers. This means that instead of executing once for each vertex, instance, or pixel, you have to tell it how many invocations of the shader function you want. Then, when you run the shader, you are told which invocation is being processed, and you can decide what data you are going to access and which operations you are going to perform from there.
Compute shaders must be created in a shader module, just like vertex and fragment shaders, so add that to your code to get started. As you might guess, given the structure of the other shaders that you've implemented, the main function for your compute shader needs to be marked with the @compute attribute.
- Create a compute shader with the following code:
সূচক.html
// Create the compute shader that will process the simulation.
const simulationShaderModule = device.createShaderModule({
label: "Game of Life simulation shader",
code: `
@compute
fn computeMain() {
}`
});
Because GPUs are used frequently for 3D graphics, compute shaders are structured such that you can request that the shader be invoked a specific number of times along an X, Y, and Z axis. This lets you very easily dispatch work that conforms to a 2D or 3D grid, which is great for your use case! You want to call this shader GRID_SIZE times GRID_SIZE times, once for each cell of your simulation.
Due to the nature of GPU hardware architecture, this grid is divided into workgroups . A workgroup has an X, Y, and Z size, and although the sizes can be 1 each, there are often performance benefits to making your workgroups a bit bigger. For your shader, choose a somewhat arbitrary workgroup size of 8 times 8. This is useful to keep track of in your JavaScript code.
- Define a constant for your workgroup size, like this:
সূচক.html
const WORKGROUP_SIZE = 8;
You also need to add the workgroup size to the shader function itself, which you do using JavaScript's template literals so that you can easily use the constant you just defined.
- Add the workgroup size to the shader function, like this:
index.html (Compute createShaderModule call)
@compute
@workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE}) // New line
fn computeMain() {
}
This tells the shader that work done with this function is done in (8 x 8 x 1) groups. (Any axis you leave off defaults to 1, although you have to at least specify the X axis.)
As with the other shader stages, there's a variety of @builtin values that you can accept as input into your compute shader function in order to tell you which invocation you're on and decide what work you need to do.
- Add a
@builtinvalue, like this:
index.html (Compute createShaderModule call)
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
}
You pass in the global_invocation_id builtin, which is a three-dimensional vector of unsigned integers that tells you where in the grid of shader invocations you are. You run this shader once for each cell in your grid. You get numbers like (0, 0, 0) , (1, 0, 0) , (1, 1, 0) ... all the way to (31, 31, 0) , which means that you can treat it as the cell index you're going to operate on!
Compute shaders can also use uniforms, which you use just like in the vertex and fragment shaders.
- Use a uniform with your compute shader to tell you the grid size, like this:
index.html (Compute createShaderModule call)
@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) {
}
Just like in the vertex shader, you also expose the cell state as a storage buffer. But in this case, you need two of them! Because compute shaders don't have a required output, like a vertex position or fragment color, writing values to a storage buffer or texture is the only way to get results out of a compute shader. Use the ping-pong method that you learned earlier; you have one storage buffer that feeds in the current state of the grid and one that you write out the new state of the grid to.
- Expose the cell input and output state as storage buffers, like this:
index.html (Compute createShaderModule call)
@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) {
}
Note that the first storage buffer is declared with var<storage> , which makes it read-only, but the second storage buffer is declared with var<storage, read_write> . This allows you to both read and write to the buffer, using that buffer as the output for your compute shader. (There is no write-only storage mode in WebGPU).
Next, you need to have a way to map your cell index into the linear storage array. This is basically the opposite of what you did in the vertex shader, where you took the linear instance_index and mapped it to a 2D grid cell. (As a reminder, your algorithm for that was vec2f(i % grid.x, floor(i / grid.x)) .)
- Write a function to go in the other direction. It takes the cell's Y value, multiplies it by the grid width, and then adds the cell's X value.
index.html (Compute createShaderModule call)
@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) {
}
And, finally, to see that it's working, implement a really simple algorithm: if a cell is currently on, it turns off, and vice versa. It's not the Game of Life yet, but it's enough to show that the compute shader is working.
- Add the simple algorithm, like this:
index.html (Compute createShaderModule call)
@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;
}
}
And that's it for your compute shader—for now! But before you can see the results, there are a few more changes that you need to make.
Use Bind Group and Pipeline Layouts
One thing that you might notice from the above shader is that it largely uses the same inputs (uniforms and storage buffers) as your render pipeline. So you might think that you can simply use the same bind groups and be done with it, right? The good news is that you can! It just takes a bit more manual setup to be able to do that.
Any time that you create a bind group, you need to provide a GPUBindGroupLayout . Previously, you got that layout by calling getBindGroupLayout() on the render pipeline, which in turn created it automatically because you supplied layout: "auto" when you created it. That approach works well when you only use a single pipeline, but if you have multiple pipelines that want to share resources, you need to create the layout explicitly, and then provide it to both the bind group and pipelines.
To help understand why, consider this: in your render pipelines you use a single uniform buffer and a single storage buffer, but in the compute shader you just wrote, you need a second storage buffer. Because the two shaders use the same @binding values for the uniform and first storage buffer, you can share those between pipelines, and the render pipeline ignores the second storage buffer, which it doesn't use. You want to create a layout that describes all of the resources that are present in the bind group, not just the ones used by a specific pipeline.
- To create that layout, call
device.createBindGroupLayout():
সূচক.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
}]
});
This is similar in structure to creating the bind group itself, in that you describe a list of entries . The difference is that you describe what type of resource the entry must be and how it's used rather than providing the resource itself.
In each entry, you give the binding number for the resource, which (as you learned when you created the bind group) matches the @binding value in the shaders. You also provide the visibility , which are GPUShaderStage flags that indicate which shader stages can use the resource. You want both the uniform and first storage buffer to be accessible in the vertex and compute shaders, but the second storage buffer only needs to be accessible in compute shaders.
Finally, you indicate what type of resource is being used. This is a different dictionary key, depending on what you need to expose. Here, all three resources are buffers, so you use the buffer key to define the options for each. Other options include things like texture or sampler , but you don't need those here.
In the buffer dictionary, you set options like what type of buffer is used. The default is "uniform" , so you can leave the dictionary empty for binding 0. (You do have to at least set buffer: {} , though, so that the entry is identified as a buffer.) Binding 1 is given a type of "read-only-storage" because you don't use it with read_write access in the shader, and binding 2 has a type of "storage" because you do use it with read_write access!
Once the bindGroupLayout is created, you can pass it in when creating your bind groups rather than querying the bind group from the pipeline. Doing so means that you need to add a new storage buffer entry to each bind group in order to match the layout you just defined.
- Update the bind group creation, like this:
সূচক.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] }
}],
}),
];
And now that the bind group has been updated to use this explicit bind group layout, you need to update the render pipeline to use the same thing.
- Create a
GPUPipelineLayout.
সূচক.html
const pipelineLayout = device.createPipelineLayout({
label: "Cell Pipeline Layout",
bindGroupLayouts: [ bindGroupLayout ],
});
A pipeline layout is a list of bind group layouts (in this case, you have one) that one or more pipelines use. The order of the bind group layouts in the array needs to correspond with the @group attributes in the shaders. (This means that bindGroupLayout is associated with @group(0) .)
- Once you have the pipeline layout, update the render pipeline to use it instead of
"auto".
সূচক.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
}]
}
});
Create the compute pipeline
Just like you need a render pipeline to use your vertex and fragment shaders, you need a compute pipeline to use your compute shader. Fortunately, compute pipelines are far less complicated than render pipelines, as they don't have any state to set, only the shader and layout.
- Create a compute pipeline with the following code:
সূচক.html
// Create a compute pipeline that updates the game state.
const simulationPipeline = device.createComputePipeline({
label: "Simulation pipeline",
layout: pipelineLayout,
compute: {
module: simulationShaderModule,
entryPoint: "computeMain",
}
});
Notice that you pass in the new pipelineLayout instead of "auto" , just like in the updated render pipeline, which ensures that both your render pipeline and your compute pipeline can use the same bind groups.
Compute passes
This brings you to the point of actually making use of the compute pipeline! Given that you do your rendering in a render pass, you can probably guess that you need to do compute work in a compute pass. Compute and render work can both happen in the same command encoder, so you want to shuffle your updateGrid function a bit.
- Move the encoder creation to the top of the function, and then begin a compute pass with it (before the
step++).
সূচক.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...
Just like compute pipelines, compute passes are much simpler to kick off than their rendering counterparts because you don't need to worry about any attachments.
You want to do the compute pass before the render pass because it allows the render pass to immediately use the latest results from the compute pass. That's also the reason that you increment the step count between the passes, so that the output buffer of the compute pipeline becomes the input buffer for the render pipeline.
- Next, set the pipeline and bind group inside the compute pass, using the same pattern for switching between bind groups as you do for the rendering pass.
সূচক.html
const computePass = encoder.beginComputePass();
// New lines
computePass.setPipeline(simulationPipeline);
computePass.setBindGroup(0, bindGroups[step % 2]);
computePass.end();
- Finally, instead of drawing like in a render pass, you dispatch the work to the compute shader, telling it how many workgroups you want to execute on each axis.
সূচক.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();
Something very important to note here is that the number you pass into dispatchWorkgroups() is not the number of invocations! Instead, it's the number of workgroups to execute, as defined by the @workgroup_size in your shader.
If you want the shader to execute 32x32 times in order to cover your entire grid, and your workgroup size is 8x8, you need to dispatch 4x4 workgroups (4 * 8 = 32). That's why you divide the grid size by the workgroup size and pass that value into dispatchWorkgroups() .
Now you can refresh the page again, and you should see that the grid inverts itself with each update.


Implement the algorithm for the Game of Life
Before you update the compute shader to implement the final algorithm, you want to go back to the code that's initializing the storage buffer content and update it to produce a random buffer on each page load. (Regular patterns don't make for very interesting Game of Life starting points.) You can randomize the values however you want, but there's an easy way to start that gives reasonable results.
- To start each cell in a random state, update the
cellStateArrayinitialization to the following code:
সূচক.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);
Now you can finally implement the logic for the Game of Life simulation. After everything it took to get here, the shader code may be disappointingly simple!
First, you need to know for any given cell how many of its neighbors are active. You don't care about which ones are active, only the count.
- To make getting neighboring cell data easier, add a
cellActivefunction that returns thecellStateInvalue of the given coordinate.
index.html (Compute createShaderModule call)
fn cellActive(x: u32, y: u32) -> u32 {
return cellStateIn[cellIndex(vec2(x, y))];
}
The cellActive function returns one if the cell is active, so adding the return value of calling cellActive for all eight surrounding cells gives you how many neighboring cells are active.
- Find the number of active neighbors, like this:
index.html (Compute createShaderModule call)
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);
This leads to a minor problem, though: what happens when the cell you're checking is off the edge of the board? According to your cellIndex() logic right now, it either overflows to the next or previous row, or runs off the edge of the buffer!
For the Game of Life, a common and easy way to resolve this is to have cells on the edge of the grid treat cells on the opposite edge of the grid as their neighbors, creating a kind of wrap-around effect.
- Support grid wrap-around with a minor change to the
cellIndex()function.
index.html (Compute createShaderModule call)
fn cellIndex(cell: vec2u) -> u32 {
return (cell.y % u32(grid.y)) * u32(grid.x) +
(cell.x % u32(grid.x));
}
By using the % operator to wrap the cell X and Y when it extends past the grid size, you ensure that you never access outside the storage buffer bounds. With that, you can rest assured that the activeNeighbors count is predictable.
Then you apply one of four rules:
- Any cell with fewer than two neighbors becomes inactive.
- Any active cell with two or three neighbors stays active.
- Any inactive cell with exactly three neighbors becomes active.
- Any cell with more than three neighbors becomes inactive.
You can do this with a series of if statements, but WGSL also supports switch statements, which are a good fit for this logic.
- Implement the Game of Life logic, like this:
index.html (Compute createShaderModule call)
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;
}
}
For reference, the final compute shader module call now looks like this:
সূচক.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;
}
}
}
`
});
And... that's it! You're done! Refresh your page and watch your newly built cellular automaton grow!

9. অভিনন্দন!
You created a version of the classic Conway's Game of Life simulation that runs entirely on your GPU using the WebGPU API!
এরপর কী?
- Review the WebGPU Samples