اولین برنامه WebGPU شما

1. مقدمه

The WebGPU Logo consists of several blue triangles that form a stylized 'W'

WebGPU چیست؟

WebGPU یک API جدید و مدرن برای دسترسی به قابلیت های GPU شما در برنامه های وب است.

API مدرن

قبل از WebGPU، WebGL وجود داشت که زیرمجموعه ای از ویژگی های WebGPU را ارائه می کرد. کلاس جدیدی از محتوای غنی وب را فعال کرد و توسعه دهندگان چیزهای شگفت انگیزی با آن ساخته اند. با این حال، بر اساس OpenGL ES 2.0 API، منتشر شده در سال 2007، که بر اساس OpenGL API حتی قدیمی تر بود، ساخته شد. پردازنده‌های گرافیکی در آن زمان به طور قابل توجهی تکامل یافته‌اند، و API‌های بومی که برای ارتباط با آن‌ها استفاده می‌شوند نیز با Direct3D 12 ، Metal و Vulkan تکامل یافته‌اند.

WebGPU پیشرفت های این API های مدرن را به پلتفرم وب می آورد. این برنامه بر روی فعال کردن ویژگی‌های GPU به روشی بین پلتفرمی تمرکز می‌کند، در حالی که API را ارائه می‌کند که در وب احساس طبیعی می‌کند و نسبت به برخی از APIهای بومی که در بالای آنها ساخته شده است، پرمخاطب‌تر است.

رندرینگ

پردازنده‌های گرافیکی اغلب با ارائه گرافیک‌های سریع و دقیق همراه هستند و WebGPU نیز از این قاعده مستثنی نیست. ویژگی‌های مورد نیاز برای پشتیبانی از بسیاری از محبوب‌ترین تکنیک‌های رندر امروزی در پردازنده‌های گرافیکی دسکتاپ و موبایل را دارد و مسیری را برای افزودن ویژگی‌های جدید در آینده با ادامه تکامل قابلیت‌های سخت‌افزاری فراهم می‌کند.

محاسبه کنید

علاوه بر رندر، WebGPU پتانسیل GPU شما را برای انجام بارهای کاری بسیار موازی با هدف کلی باز می کند. این شیدرهای محاسباتی را می توان به صورت مستقل، بدون هیچ گونه مولفه رندر یا به عنوان بخشی کاملاً یکپارچه از خط لوله رندر استفاده کرد.

در نرم افزار Codelab امروز یاد خواهید گرفت که چگونه از قابلیت های رندر و محاسبه WebGPU برای ایجاد یک پروژه مقدماتی ساده استفاده کنید!

چیزی که خواهی ساخت

در این کد لبه، شما بازی زندگی کانوی را با استفاده از WebGPU می‌سازید. برنامه شما:

  • از قابلیت های رندر WebGPU برای ترسیم گرافیک های دو بعدی ساده استفاده کنید.
  • از قابلیت های محاسباتی WebGPU برای انجام شبیه سازی استفاده کنید.

A screenshot of the final product of this codelab

بازی زندگی چیزی است که به عنوان یک خودکار سلولی شناخته می شود، که در آن شبکه ای از سلول ها در طول زمان بر اساس مجموعه ای از قوانین تغییر حالت می دهند. در بازی زندگی سلول‌ها بسته به تعداد سلول‌های همسایه‌شان فعال یا غیرفعال می‌شوند، که منجر به الگوهای جالبی می‌شود که با تماشای شما نوسان می‌کنند.

چیزی که یاد خواهید گرفت

  • نحوه راه اندازی WebGPU و پیکربندی یک بوم.
  • نحوه ترسیم هندسه دو بعدی ساده
  • نحوه استفاده از سایه‌زن‌های راس و قطعه برای اصلاح آنچه ترسیم می‌شود.
  • نحوه استفاده از شیدرهای محاسباتی برای انجام یک شبیه سازی ساده

این آزمایشگاه کد بر معرفی مفاهیم اساسی پشت WebGPU تمرکز دارد. در نظر گرفته نشده است که یک بررسی جامع از API باشد، و همچنین موضوعات مرتبط اغلب مانند ریاضیات ماتریس سه بعدی را پوشش نمی دهد (یا به آن نیاز دارد).

آنچه شما نیاز دارید

  • نسخه اخیر Chrome (113 یا جدیدتر) در ChromeOS، macOS، یا Windows. WebGPU یک API متقابل مرورگر و چند پلتفرمی است اما هنوز به همه جا ارسال نشده است.
  • آشنایی با HTML، جاوا اسکریپت و ابزار توسعه کروم .

آشنایی با سایر APIهای گرافیکی مانند WebGL، Metal، Vulkan یا Direct3D الزامی نیست ، اما اگر تجربه ای با آنها دارید، احتمالاً متوجه شباهت های زیادی با WebGPU خواهید شد که ممکن است به شروع یادگیری شما کمک کند!

2. راه اندازی شوید

کد را دریافت کنید

این Codelab هیچ وابستگی ندارد و شما را در تمام مراحل مورد نیاز برای ایجاد برنامه WebGPU راهنمایی می کند، بنابراین برای شروع به هیچ کدی نیاز ندارید. با این حال، برخی از نمونه‌های کاری که می‌توانند به عنوان نقاط بازرسی عمل کنند در https://glitch.com/edit/#!/your-first-webgpu-app موجود هستند. در صورت گیر افتادن می توانید آنها را بررسی کنید و در حین رفتن به آنها ارجاع دهید.

از کنسول توسعه دهنده استفاده کنید!

WebGPU یک API نسبتاً پیچیده با قوانین زیادی است که استفاده مناسب را اعمال می کند. بدتر از آن، به دلیل نحوه عملکرد API، نمی تواند استثناهای معمولی جاوا اسکریپت را برای بسیاری از خطاها ایجاد کند، و تشخیص دقیق مشکل از کجاست.

هنگام توسعه با WebGPU، به خصوص به عنوان یک مبتدی، با مشکلاتی مواجه خواهید شد و این مشکلی ندارد! توسعه دهندگان پشت API از چالش های کار با توسعه GPU آگاه هستند و سخت تلاش کرده اند تا اطمینان حاصل کنند که هر زمان که کد WebGPU شما خطایی ایجاد کند، پیام های بسیار دقیق و مفیدی را در کنسول توسعه دهنده دریافت خواهید کرد که به شما در شناسایی و رفع آن کمک می کند. موضوع

باز نگه داشتن کنسول هنگام کار بر روی هر برنامه وب همیشه مفید است، اما به خصوص در اینجا کاربرد دارد!

3. WebGPU را راه اندازی کنید

با یک <canvas> شروع کنید

اگر تنها چیزی که می خواهید استفاده از آن برای انجام محاسبات است، می توان از WebGPU بدون نشان دادن چیزی روی صفحه استفاده کرد. اما اگر می‌خواهید هر چیزی را رندر کنید، مانند آنچه که می‌خواهیم در لبه کد انجام دهیم، به یک بوم نیاز دارید. بنابراین این نقطه خوبی برای شروع است!

یک سند HTML جدید با یک عنصر <canvas> در آن و همچنین یک تگ <script> ایجاد کنید که در آن عنصر canvas را پرس و جو می کنیم. (یا از 00-starter-page.html از glitch استفاده کنید.)

  • یک فایل index.html با کد زیر ایجاد کنید:

index.html

<!doctype html>

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

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

آداپتور و دستگاه را درخواست کنید

اکنون می توانید وارد بیت های WebGPU شوید! اول، باید در نظر داشته باشید که APIهایی مانند WebGPU ممکن است مدتی طول بکشد تا در کل اکوسیستم وب منتشر شوند. در نتیجه، اولین اقدام احتیاطی خوب این است که بررسی کنید آیا مرورگر کاربر می تواند از WebGPU استفاده کند یا خیر.

  1. برای بررسی اینکه آیا شی navigator.gpu ، که به عنوان نقطه ورودی برای WebGPU عمل می کند، وجود دارد، کد زیر را اضافه کنید:

index.html

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

در حالت ایده‌آل، می‌خواهید با بازگرداندن صفحه به حالتی که از WebGPU استفاده نمی‌کند، به کاربر اطلاع دهید که WebGPU در دسترس نیست. (شاید بجای آن بتواند از WebGL استفاده کند؟) برای اهداف این نرم افزار کد، شما فقط یک خطا را برای جلوگیری از اجرای بیشتر کد ارسال می کنید.

هنگامی که می دانید که WebGPU توسط مرورگر پشتیبانی می شود، اولین گام در راه اندازی WebGPU برای برنامه شما درخواست یک GPUAdapter است. شما می توانید یک آداپتور را به عنوان نمایش WebGPU از یک قطعه خاص از سخت افزار GPU در دستگاه خود در نظر بگیرید.

  1. برای دریافت آداپتور، از متد navigator.gpu.requestAdapter() استفاده کنید. این یک وعده را برمی‌گرداند، بنابراین راحت‌تر است که آن را با await تماس بگیرید.

index.html

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

اگر هیچ آداپتور مناسبی پیدا نشد، مقدار adapter برگشتی ممکن است null باشد، بنابراین می‌خواهید این امکان را مدیریت کنید. اگر مرورگر کاربر از WebGPU پشتیبانی کند اما سخت افزار GPU آنها تمام ویژگی های لازم برای استفاده از WebGPU را نداشته باشد، ممکن است این اتفاق بیفتد.

در بیشتر مواقع، مانند آنچه در اینجا انجام می‌دهید، به سادگی اجازه دهید مرورگر یک آداپتور پیش‌فرض را انتخاب کند، مشکلی ندارد، اما برای نیازهای پیشرفته‌تر ، آرگومان‌هایی وجود دارد که می‌توان به requestAdapter() ارسال کرد که مشخص می‌کند می‌خواهید از کم مصرف یا پر مصرف استفاده کنید. سخت افزار عملکرد در دستگاه هایی با چندین پردازنده گرافیکی (مانند برخی از لپ تاپ ها).

هنگامی که یک آداپتور دارید، آخرین مرحله قبل از شروع کار با GPU درخواست یک GPUDevice است. دستگاه رابط اصلی است که از طریق آن بیشترین تعامل با GPU اتفاق می افتد.

  1. دستگاه را با فراخوانی adapter.requestDevice() دریافت کنید که یک وعده را نیز برمی گرداند.

index.html

const device = await adapter.requestDevice();

همانند requestAdapter() ، گزینه‌هایی وجود دارد که می‌توان برای استفاده‌های پیشرفته‌تر مانند فعال کردن ویژگی‌های سخت‌افزاری خاص یا درخواست محدودیت‌های بالاتر، از اینجا استفاده کرد ، اما برای اهداف شما، پیش‌فرض‌ها به خوبی کار می‌کنند.

بوم را پیکربندی کنید

اکنون که دستگاهی دارید، اگر می‌خواهید از آن برای نمایش هر چیزی در صفحه استفاده کنید، یک کار دیگر وجود دارد: بوم را برای استفاده با دستگاهی که به تازگی ایجاد کرده‌اید، پیکربندی کنید.

  • برای انجام این کار، ابتدا با فراخوانی canvas.getContext("webgpu") یک GPUCanvasContext از بوم درخواست کنید. (این همان فراخوانی است که برای مقداردهی اولیه زمینه های Canvas 2D یا WebGL استفاده می کنید، به ترتیب با استفاده از انواع زمینه 2d و webgl .) context که برمی گرداند باید با استفاده از متد configure() با دستگاه مرتبط شود، مانند بنابراین:

index.html

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

چند گزینه وجود دارد که می توان در اینجا رد کرد، اما مهم ترین آنها device است که قرار است از متن با آن استفاده کنید و format ، که قالب بافتی است که متن باید از آن استفاده کند.

بافت ها اشیایی هستند که WebGPU از آنها برای ذخیره داده های تصویر استفاده می کند و هر بافت دارای قالبی است که به GPU اجازه می دهد بداند آن داده ها چگونه در حافظه چیده شده اند. جزئیات نحوه عملکرد حافظه بافت فراتر از محدوده این نرم افزار کد است. نکته مهمی که باید بدانید این است که بافت بوم بافت‌هایی را برای کد شما فراهم می‌کند تا به درون آن کشیده شود، و قالبی که استفاده می‌کنید می‌تواند بر میزان کارآمدی بوم آن تصاویر را نشان دهد. انواع مختلف دستگاه ها هنگام استفاده از فرمت های بافت مختلف بهترین عملکرد را دارند، و اگر از فرمت ترجیحی دستگاه استفاده نکنید، ممکن است قبل از نمایش تصویر به عنوان بخشی از صفحه، کپی های حافظه اضافی در پشت صحنه اتفاق بیفتد.

خوشبختانه، لازم نیست زیاد نگران هیچ یک از این موارد باشید، زیرا WebGPU به شما می گوید که از کدام فرمت برای بوم خود استفاده کنید! تقریباً در همه موارد، همانطور که در بالا نشان داده شده است، می خواهید مقدار بازگشتی را با فراخوانی navigator.gpu.getPreferredCanvasFormat() ارسال کنید.

بوم را پاک کنید

اکنون که دستگاهی دارید و بوم با آن پیکربندی شده است، می توانید از دستگاه برای تغییر محتوای بوم استفاده کنید. برای شروع، آن را با یک رنگ ثابت پاک کنید.

برای انجام این کار - یا تقریباً هر چیز دیگری در WebGPU - باید برخی از دستورات را به GPU ارائه دهید که به آن دستور دهید چه کاری انجام دهد.

  1. برای انجام این کار، از دستگاه بخواهید یک GPUCommandEncoder ایجاد کند که یک رابط برای ضبط دستورات GPU فراهم می کند.

index.html

const encoder = device.createCommandEncoder();

دستوراتی که می خواهید به GPU ارسال کنید مربوط به رندر (در این مورد، پاک کردن بوم) است، بنابراین گام بعدی استفاده از encoder برای شروع Render Pass است.

پاس های رندر زمانی هستند که تمام عملیات ترسیم در WebGPU اتفاق می افتد. هر کدام با یک فراخوان beginRenderPass() شروع می شود که بافت هایی را که خروجی هر دستور طراحی انجام شده را دریافت می کنند، تعریف می کند. استفاده‌های پیشرفته‌تر می‌توانند چندین بافت به نام پیوست‌ها را با اهداف مختلفی مانند ذخیره‌سازی عمق هندسه رندر شده یا ارائه آنتی‌الیاسینگ فراهم کنند. اما برای این برنامه فقط به یکی نیاز دارید.

  1. با فراخوانی context.getCurrentTexture() بافتی را از بافت بوم که قبلا ایجاد کردید، دریافت کنید، که بافتی را با عرض و ارتفاع پیکسلی مطابق با صفات width و height بوم و format مشخص شده هنگام فراخوانی context.configure() برمی گرداند.

index.html

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

بافت به عنوان ویژگی view یک colorAttachment داده می شود . پاس‌های رندر مستلزم این است که به جای GPUTexture یک GPUTextureView ارائه دهید، که به آن می‌گوید به کدام قسمت‌های بافت رندر شود. این فقط برای موارد استفاده پیشرفته‌تر اهمیت دارد، بنابراین در اینجا createView() بدون هیچ آرگومان روی بافت فراخوانی می‌کنید، که نشان می‌دهد می‌خواهید رندر پاس از کل بافت استفاده کند.

همچنین باید مشخص کنید که می خواهید پاس رندر با بافت چه زمانی شروع شود و چه زمانی تمام شود:

  • مقدار loadOp "clear" نشان می دهد که می خواهید بافت با شروع رندر پاک شود.
  • یک مقدار storeOp از "store" نشان می‌دهد که پس از اتمام رندر، می‌خواهید نتایج هر ترسیمی که در طول رندر پاس انجام می‌شود در بافت ذخیره شود.

وقتی رندر پاس شروع شد، کاری انجام نمی دهید! حداقل فعلا. عمل شروع رندر پاس با loadOp: "clear" برای پاک کردن نمای بافت و بوم کافی است.

  1. با افزودن فراخوانی زیر بلافاصله پس از beginRenderPass() رندر پاس را پایان دهید:

index.html

pass.end();

مهم است بدانید که صرفاً برقراری این تماس‌ها باعث نمی‌شود که GPU واقعاً کاری انجام دهد. آنها فقط دستوراتی را برای GPU ضبط می کنند تا بعداً انجام دهد.

  1. برای ایجاد یک GPUCommandBuffer ، finish() در انکودر فرمان فراخوانی کنید. بافر فرمان یک دسته غیر شفاف برای دستورات ضبط شده است.

index.html

const commandBuffer = encoder.finish();
  1. بافر فرمان را با استفاده از queue GPUDevice به GPU ارسال کنید. صف تمام دستورات GPU را انجام می دهد و اطمینان می دهد که اجرای آنها به خوبی مرتب شده و به درستی همگام شده است. متد submit() صف آرایه ای از بافرهای دستوری را می گیرد، اگرچه در این مورد شما فقط یکی دارید.

index.html

device.queue.submit([commandBuffer]);

هنگامی که یک بافر فرمان را ارسال می کنید، دیگر نمی توان از آن استفاده کرد، بنابراین نیازی به نگه داشتن آن نیست. اگر می خواهید دستورات بیشتری ارسال کنید، باید بافر دستور دیگری بسازید. به همین دلیل است که تقریباً معمول است که این دو مرحله را در یک قسمت جمع کنید، همانطور که در صفحات نمونه برای این لبه کد انجام می شود:

index.html

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

پس از ارسال دستورات به GPU، اجازه دهید جاوا اسکریپت کنترل را به مرورگر بازگرداند. در آن مرحله، مرورگر می بیند که شما بافت فعلی زمینه را تغییر داده اید و بوم را برای نمایش آن بافت به عنوان یک تصویر به روز می کند. اگر می‌خواهید پس از آن دوباره محتویات بوم را به‌روزرسانی کنید، باید یک بافر دستوری جدید ضبط و ارسال کنید و دوباره context.getCurrentTexture() فراخوانی کنید تا یک بافت جدید برای یک پاس رندر دریافت کنید.

  1. صفحه را دوباره بارگیری کنید. توجه داشته باشید که بوم با رنگ سیاه پر شده است. تبریک می گویم! این بدان معناست که شما اولین برنامه WebGPU خود را با موفقیت ایجاد کرده اید.

A black canvas that indicates that WebGPU has successfully been used to clear the canvas contents.

یک رنگ انتخاب کنید!

با این حال، صادقانه بگویم، مربع های سیاه بسیار خسته کننده هستند. بنابراین قبل از رفتن به بخش بعدی کمی وقت بگذارید تا فقط کمی آن را شخصی کنید.

  1. در فراخوانی encoder.beginRenderPass() ، یک خط جدید با یک clearValue به colorAttachment اضافه کنید، مانند این:

index.html

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

clearValue به پاس رندر دستور می دهد که هنگام اجرای عملیات clear در ابتدای پاس از چه رنگی استفاده کند. فرهنگ لغت ارسال شده به آن شامل چهار مقدار است: 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 } مشکی شفاف و پیش فرض است.

کد مثال و اسکرین شات ها در این کد لبه از رنگ آبی تیره استفاده می کنند، اما با خیال راحت هر رنگی را که می خواهید انتخاب کنید!

  1. هنگامی که رنگ خود را انتخاب کردید، صفحه را دوباره بارگیری کنید. شما باید رنگ انتخابی خود را در بوم ببینید.

A canvas cleared to a dark blue color to demonstrate how to change the default clear color.

4. هندسه را ترسیم کنید

در پایان این بخش، برنامه شما هندسه ساده ای را روی بوم ترسیم می کند: یک مربع رنگی. اکنون به شما هشدار داده می شود که برای چنین خروجی ساده ای کار زیادی به نظر می رسد، اما دلیل آن این است که WebGPU برای ارائه هندسه بسیار کارآمد طراحی شده است. یک عارضه جانبی این کارایی این است که انجام کارهای نسبتاً ساده ممکن است به طور غیرعادی دشوار به نظر برسد، اما اگر به API مانند WebGPU روی می آورید، این انتظار است - می خواهید کاری کمی پیچیده تر انجام دهید.

درک نحوه ترسیم پردازنده‌های گرافیکی

قبل از هر گونه تغییر کد دیگر، ارزش آن را دارد که یک مرور بسیار سریع، ساده و سطح بالا از نحوه ایجاد اشکالی که در صفحه نمایش مشاهده می کنید توسط GPUها انجام دهید. (اگر قبلاً با اصول نحوه عملکرد رندر GPU آشنا هستید، به بخش Defining Vertices بروید.)

برخلاف یک API مانند Canvas 2D که اشکال و گزینه‌های زیادی برای استفاده شما آماده است، GPU شما واقعاً فقط با چند نوع اشکال مختلف (یا همان‌طور که WebGPU به آن‌ها بدوی اشاره می‌کنند) سر و کار دارد: نقاط، خطوط و مثلث‌ها. . برای اهداف این نرم افزار کد شما فقط از مثلث ها استفاده می کنید.

پردازنده‌های گرافیکی تقریباً منحصراً با مثلث‌ها کار می‌کنند، زیرا مثلث‌ها ویژگی‌های ریاضی خوبی دارند که پردازش آنها را به روشی قابل پیش‌بینی و کارآمد آسان می‌کند. تقریباً هر چیزی که با GPU ترسیم می کنید باید قبل از اینکه GPU بتواند آن را ترسیم کند به مثلث تقسیم شود و آن مثلث ها باید با نقاط گوشه آنها تعریف شوند.

این نقاط، یا رئوس ، بر حسب مقادیر X، Y و (برای محتوای سه بعدی) Z داده می شوند که یک نقطه را در یک سیستم مختصات دکارتی تعریف شده توسط WebGPU یا APIهای مشابه تعریف می کنند. ساختار سیستم مختصات از نظر نحوه ارتباط آن با بوم صفحه شما راحت تر است. مهم نیست که بوم شما چقدر پهن یا بلند باشد، لبه سمت چپ همیشه در محور X در 1- است و لبه سمت راست همیشه روی 1+ در محور X است. به طور مشابه، لبه پایین همیشه -1 در محور Y است، و لبه بالا +1 در محور Y است. به این معنی که (0، 0) همیشه مرکز بوم، (-1، -1) همیشه گوشه پایین سمت چپ، و (1، 1) همیشه گوشه بالا سمت راست است. این به عنوان فضای کلیپ شناخته می شود.

A simple graph visualizing the Normalized Device Coordinate space.

رئوس در ابتدا به ندرت در این سیستم مختصات تعریف می شوند، بنابراین GPUها به برنامه های کوچکی به نام سایه زن رئوس برای انجام هر ریاضی لازم برای تبدیل رئوس به فضای کلیپ و همچنین هر محاسبات دیگری که برای ترسیم رئوس لازم است، متکی هستند. به عنوان مثال، سایه زن ممکن است کمی انیمیشن اعمال کند یا جهت راس به منبع نور را محاسبه کند. این سایه‌زن‌ها توسط شما، توسعه‌دهنده WebGPU نوشته شده‌اند، و کنترل شگفت‌انگیزی بر نحوه عملکرد GPU ارائه می‌دهند.

از آنجا، GPU تمام مثلث های ساخته شده توسط این رئوس تبدیل شده را می گیرد و تعیین می کند که کدام پیکسل های روی صفحه برای ترسیم آنها لازم است. سپس برنامه کوچک دیگری را اجرا می کند که شما می نویسید به نام shader fragment که محاسبه می کند هر پیکسل چه رنگی باید داشته باشد. این محاسبه می تواند به سادگی سبز برگشتی یا به پیچیدگی محاسبه زاویه سطح نسبت به نور خورشید که از سایر سطوح مجاور منعکس می شود، از طریق مه فیلتر می شود و با فلزی بودن سطح اصلاح می شود. کاملاً تحت کنترل شماست که می تواند هم قدرتمند و هم طاقت فرسا باشد.

سپس نتایج آن رنگ‌های پیکسلی در بافتی جمع می‌شود که می‌تواند روی صفحه نمایش داده شود.

رئوس را تعریف کنید

همانطور که قبلا ذکر شد، شبیه سازی بازی زندگی به صورت شبکه ای از سلول ها نشان داده شده است. برنامه شما به راهی برای تجسم شبکه نیاز دارد و سلول‌های فعال را از سلول‌های غیرفعال متمایز می‌کند. روش استفاده شده توسط این کد لبه ترسیم مربع های رنگی در سلول های فعال و خالی گذاشتن سلول های غیرفعال است.

این به این معنی است که شما باید چهار نقطه مختلف برای پردازنده گرافیکی ارائه دهید، یکی برای هر چهار گوشه مربع. به عنوان مثال، مربعی که در مرکز بوم کشیده شده و از لبه ها به سمت داخل کشیده شده است، مختصات گوشه ای مانند این دارد:

A Normalized Device Coordinate graph showing coordinates for the corners of a square

برای تغذیه آن مختصات به GPU، باید مقادیر را در یک TypedArray قرار دهید. اگر قبلاً با آن آشنا نیستید، TypedArrays گروهی از اشیاء جاوا اسکریپت هستند که به شما امکان می‌دهند بلوک‌های پیوسته حافظه را تخصیص دهید و هر عنصر در سری را به عنوان یک نوع داده خاص تفسیر کنید. به عنوان مثال، در یک Uint8Array ، هر عنصر در آرایه یک بایت منفرد و بدون علامت است. TypedArray ها برای ارسال داده ها به عقب و جلو با API هایی که به چیدمان حافظه حساس هستند، مانند WebAssembly، WebAudio و (البته) WebGPU عالی هستند.

برای مثال مربع، چون مقادیر کسری هستند، یک Float32Array مناسب است.

  1. با قرار دادن اعلان آرایه زیر در کد خود، آرایه ای ایجاد کنید که تمام موقعیت های رئوس در نمودار را نگه دارد. یک مکان خوب برای قرار دادن آن نزدیک به بالا، درست زیر فراخوانی context.configure() است.

index.html

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

توجه داشته باشید که فاصله و نظر هیچ تاثیری روی مقادیر ندارد. این فقط برای راحتی شما و خوانایی بیشتر است. این به شما کمک می کند ببینید که هر جفت مقدار مختصات X و Y را برای یک راس می سازد.

اما یک مشکل وجود دارد! پردازنده‌های گرافیکی بر حسب مثلث کار می‌کنند، یادتان هست؟ بنابراین این بدان معنی است که شما باید رئوس را در گروه های سه تایی ارائه دهید. شما یک گروه چهار نفره دارید. راه حل این است که دو تا از رئوس را تکرار کنید تا دو مثلث ایجاد کنید که یک لبه را از وسط مربع به اشتراک می گذارند.

A diagram showing how the four vertices of the square will be used to form two triangles.

برای تشکیل مربع از نمودار، باید رئوس (0.8-، 0.8-) و (0.8، 0.8) را دو بار لیست کنید، یک بار برای مثلث آبی و یک بار برای مثلث قرمز. (همچنین می توانید به جای آن مربع را با دو گوشه دیگر تقسیم کنید؛ فرقی نمی کند.)

  1. آرایه vertices قبلی خود را به روز کنید تا چیزی شبیه به این باشد:

index.html

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

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

اگرچه نمودار جدایی بین دو مثلث را برای وضوح نشان می دهد، اما موقعیت های رئوس دقیقاً یکسان هستند و GPU آنها را بدون شکاف ارائه می کند. به صورت یک مربع منفرد و جامد ارائه می شود.

یک بافر رأس ایجاد کنید

GPU نمی تواند با داده های آرایه جاوا اسکریپت رئوس بکشد. پردازنده‌های گرافیکی معمولاً حافظه مخصوص به خود را دارند که برای رندر کردن بسیار بهینه شده است، بنابراین هر داده‌ای که می‌خواهید GPU هنگام ترسیم از آن استفاده کند، باید در آن حافظه قرار داده شود.

برای بسیاری از مقادیر، از جمله داده های راس، حافظه سمت GPU از طریق اشیاء GPUBuffer مدیریت می شود. بافر بلوکی از حافظه است که به راحتی برای GPU قابل دسترسی است و برای اهداف خاصی علامت گذاری می شود. شما می توانید آن را کمی مانند یک TypedArray قابل مشاهده با GPU در نظر بگیرید.

  1. برای ایجاد یک بافر برای نگه داشتن رئوس خود، پس از تعریف آرایه vertices ، فراخوانی زیر را به device.createBuffer() اضافه کنید.

index.html

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

اولین چیزی که باید به آن توجه کنید این است که به بافر یک برچسب می دهید. به هر شی WebGPU که ایجاد می کنید می توان یک برچسب اختیاری داد و شما قطعاً می خواهید این کار را انجام دهید! برچسب هر رشته‌ای است که می‌خواهید، به شرطی که به شما کمک کند تا بفهمید شی چیست. اگر با هر مشکلی مواجه شدید، از آن برچسب‌ها در پیام‌های خطایی که WebGPU تولید می‌کند استفاده می‌شود تا به شما کمک کند بفهمید چه اشتباهی رخ داده است.

بعد، یک اندازه برای بافر در بایت بدهید. شما به یک بافر با 48 بایت نیاز دارید که با ضرب اندازه یک شناور 32 بیتی ( 4 بایت ) در تعداد شناورهای آرایه vertices خود (12) تعیین می کنید. خوشبختانه، TypedArrays قبلاً طول بایت خود را برای شما محاسبه می‌کند، و بنابراین می‌توانید هنگام ایجاد بافر از آن استفاده کنید.

در نهایت، باید میزان استفاده از بافر را مشخص کنید. این یک یا چند پرچم GPUBufferUsage است که چندین پرچم با | عملگر ( بیتی OR ). در این مورد، شما مشخص می‌کنید که می‌خواهید از بافر برای داده‌های راس استفاده شود ( GPUBufferUsage.VERTEX ) و همچنین می‌خواهید بتوانید داده‌ها را در آن کپی کنید ( GPUBufferUsage.COPY_DST ).

شی بافری که به شما بازگردانده می شود مات است—شما نمی توانید (به راحتی) داده هایی را که در آن نگهداری می کند بررسی کنید. علاوه بر این، بیشتر ویژگی‌های آن تغییرناپذیر هستند—شما نمی‌توانید اندازه یک GPUBuffer را پس از ایجاد آن تغییر دهید، و همچنین نمی‌توانید پرچم‌های استفاده را تغییر دهید. آنچه می توانید تغییر دهید محتویات حافظه آن است.

هنگامی که بافر در ابتدا ایجاد می شود، حافظه موجود در آن به صفر مقداردهی می شود. راه های مختلفی برای تغییر محتویات آن وجود دارد، اما ساده ترین آن فراخوانی device.queue.writeBuffer() با TypedArray است که می خواهید در آن کپی کنید.

  1. برای کپی کردن داده های راس در حافظه بافر، کد زیر را اضافه کنید:

index.html

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

طرح راس را تعریف کنید

اکنون شما یک بافر با داده‌های راس در آن دارید، اما تا آنجا که به GPU مربوط می‌شود، فقط یک لکه بایت است. اگر می خواهید چیزی با آن ترسیم کنید، باید کمی اطلاعات بیشتری ارائه دهید. شما باید بتوانید به WebGPU بیشتر در مورد ساختار داده های راس بگویید.

index.html

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

این ممکن است در نگاه اول کمی گیج کننده باشد، اما تجزیه آن نسبتا آسان است.

اولین چیزی که می دهید arrayStride است. این تعداد بایت هایی است که پردازنده گرافیکی باید در بافر به سمت جلو پرش کند، زمانی که به دنبال راس بعدی می گردد. هر رأس مربع شما از دو عدد ممیز شناور 32 بیتی تشکیل شده است. همانطور که قبلا ذکر شد، یک شناور 32 بیتی 4 بایت است، بنابراین دو شناور 8 بایت است.

بعد attributes است که یک آرایه است. ویژگی ها تک تک اطلاعاتی هستند که در هر رأس کدگذاری می شوند. رئوس شما فقط حاوی یک ویژگی (موقعیت راس) است، اما موارد استفاده پیشرفته‌تر اغلب دارای رئوس با چندین ویژگی مانند رنگ یک راس یا جهتی است که سطح هندسه به آن اشاره می‌کند. با این حال، این خارج از محدوده این کد لبه است.

در ویژگی واحد خود، ابتدا format داده ها را تعریف می کنید. این از لیستی از انواع GPUVertexFormat است که هر نوع داده راس را که GPU می تواند درک کند، توصیف می کند. رئوس شما هر کدام دو شناور 32 بیتی دارند، بنابراین از فرمت float32x2 استفاده می کنید. برای مثال، اگر داده‌های راس شما از چهار عدد صحیح بدون علامت 16 بیتی تشکیل شده است، به جای آن از uint16x4 استفاده می‌کنید. الگو را ببینید؟

بعد، offset توضیح می دهد که این ویژگی خاص چند بایت در رأس شروع می شود. شما واقعاً فقط باید نگران این باشید که بافر شما بیش از یک ویژگی در خود داشته باشد، که در طول این کد لبه ظاهر نمی شود.

در نهایت، shaderLocation دارید. این یک عدد دلخواه بین 0 تا 15 است و باید برای هر ویژگی که شما تعریف می کنید منحصر به فرد باشد. این ویژگی را به یک ورودی خاص در سایه زن راس پیوند می دهد که در بخش بعدی با آن آشنا خواهید شد.

توجه داشته باشید که اگرچه اکنون این مقادیر را تعریف می کنید، اما در واقع آنها را در هیچ کجا به WebGPU API منتقل نمی کنید. این در حال انجام است، اما فکر کردن به این مقادیر در نقطه ای که رئوس خود را مشخص می کنید، راحت تر است، بنابراین اکنون آنها را برای استفاده بعداً تنظیم می کنید.

با شیدرها شروع کنید

اکنون داده‌هایی را دارید که می‌خواهید رندر کنید، اما هنوز باید به GPU بگویید دقیقاً چگونه آن‌ها را پردازش کند. بخش بزرگی از آن با شیدرها اتفاق می افتد.

Shader ها برنامه های کوچکی هستند که می نویسید و روی GPU شما اجرا می کنند. هر سایه زن در مرحله متفاوتی از داده ها عمل می کند: پردازش راس ، پردازش قطعه یا محاسبه عمومی. از آنجایی که آنها بر روی GPU هستند، ساختار آنها از جاوا اسکریپت متوسط ​​شما سخت تر است. اما این ساختار به آنها اجازه می دهد تا خیلی سریع و مهمتر از همه، به صورت موازی اجرا کنند!

سایه بان ها در WebGPU به زبان سایه زنی به نام WGSL (WebGPU Shading Language) نوشته می شوند. WGSL، از لحاظ نحوی، کمی شبیه Rust است، با ویژگی‌هایی با هدف آسان‌تر و سریع‌تر کردن انواع رایج GPU (مانند ریاضیات بردار و ماتریسی). آموزش کامل زبان سایه‌زنی فراتر از محدوده این کد لبه است، اما امیدواریم که با قدم زدن در چند مثال ساده، برخی از اصول اولیه را به دست آورید.

خود شیدرها به عنوان رشته به WebGPU منتقل می شوند.

  • با کپی کردن موارد زیر در کد زیر vertexBufferLayout ، مکانی را برای وارد کردن کد سایه زن ایجاد کنید:

index.html

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

برای ایجاد سایه بان code label device.createShaderModule() را فراخوانی می کنید. (توجه داشته باشید که در اینجا از بکتیک استفاده می کنید تا رشته های چند خطی را مجاز کنید!) هنگامی که کدهای معتبر WGSL را اضافه می کنید، تابع یک شی GPUShaderModule را با نتایج کامپایل شده برمی گرداند.

سایه زن راس را تعریف کنید

با سایه زن راس شروع کنید زیرا GPU نیز از آنجا شروع می شود!

سایه زن راس به عنوان یک تابع تعریف می شود و GPU آن تابع را یک بار برای هر رأس در vertexBuffer شما فراخوانی می کند. از آنجایی که vertexBuffer شما دارای شش موقعیت (راس) در آن است، تابعی که تعریف می کنید شش بار فراخوانی می شود. هر بار که فراخوانی می شود، یک موقعیت متفاوت از vertexBuffer به عنوان آرگومان به تابع ارسال می شود، و وظیفه تابع سایه زن راس است که موقعیت مربوطه را در فضای کلیپ برگرداند.

درک این نکته مهم است که آنها لزوماً به ترتیب متوالی نیز فراخوانی نمی شوند. در عوض، پردازنده‌های گرافیکی در اجرای موازی سایه‌بان‌هایی مانند این برتری دارند و به طور بالقوه صدها (یا حتی هزاران راس!) را به طور همزمان پردازش می‌کنند! این بخش بزرگی از چیزی است که مسئول سرعت باورنکردنی پردازنده‌های گرافیکی است، اما با محدودیت‌هایی همراه است. برای اطمینان از موازی سازی شدید، سایه زن های راس نمی توانند با یکدیگر ارتباط برقرار کنند. هر فراخوانی سایه بان فقط می تواند داده های یک راس را در یک زمان ببیند و فقط می تواند مقادیر را برای یک راس منفرد تولید کند.

در WGSL، یک تابع سایه زن راس را می‌توان هر چه می‌خواهید نامید، اما باید صفت @vertex را در جلوی خود داشته باشد تا نشان دهد کدام مرحله سایه‌زن را نشان می‌دهد. WGSL توابع را با کلمه کلیدی fn نشان می دهد، از پرانتز برای اعلام هر آرگومان استفاده می کند، و از پرانتزهای فرفری برای تعریف محدوده استفاده می کند.

  1. یک تابع @vertex خالی مانند زیر ایجاد کنید:

index.html (ایجاد کد ShaderModule)

@vertex
fn vertexMain() {

}

با این حال، این معتبر نیست، زیرا یک سایه زن راس باید حداقل موقعیت نهایی راس در حال پردازش در فضای کلیپ را برگرداند. این همیشه به عنوان یک بردار 4 بعدی داده می شود. وکتورها برای استفاده در سایه بان ها آنقدر رایج هستند که در زبان به عنوان اولیه های درجه یک در نظر گرفته می شوند، با انواع خاص خود مانند vec4f برای یک بردار 4 بعدی. انواع مشابه برای بردارهای دو بعدی ( vec2f ) و بردارهای سه بعدی ( vec3f ) نیز وجود دارد!

  1. برای نشان دادن اینکه مقدار برگردانده شده موقعیت مورد نیاز است، آن را با ویژگی @builtin(position) علامت گذاری کنید. نماد -> برای نشان دادن اینکه این همان چیزی است که تابع برمی گرداند استفاده می شود.

index.html (ایجاد کد ShaderModule)

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

}

البته، اگر تابع دارای نوع بازگشتی باشد، باید در واقع مقداری را در بدنه تابع برگردانید. می توانید با استفاده از نحو vec4f(x, y, z, w) یک vec4f جدید برای بازگشت بسازید. مقادیر x ، y و z همگی اعداد ممیز شناور هستند که در مقدار بازگشتی، محل قرارگیری راس در فضای کلیپ را نشان می‌دهند.

  1. یک مقدار استاتیک (0, 0, 0, 1) را برگردانید ، و شما از نظر فنی یک سایه بان معتبر راس دارید ، اگرچه کسی که هرگز چیزی را نشان نمی دهد زیرا GPU تشخیص می دهد که مثلث هایی که تولید می کند فقط یک نقطه واحد است و سپس آن را کنار می گذارد.

index.html (کد createshadermodule)

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

آنچه شما می خواهید در عوض استفاده از داده های بافر که ایجاد کرده اید استفاده کنید و این کار را با اعلام آرگومان برای عملکرد خود با یک ویژگی @location() انجام می دهید و آن را با آنچه در vertexBufferLayout توضیح داده اید مطابقت دهید. شما یک shaderLocation 0 را مشخص کرده اید ، بنابراین در کد WGSL خود ، آرگومان را با @location(0) علامت گذاری کنید. شما همچنین فرمت را به عنوان یک float32x2 تعریف کردید ، که یک بردار 2D است ، بنابراین در WGSL استدلال شما یک vec2f است. شما می توانید آن را هر آنچه را که دوست دارید نامگذاری کنید ، اما از آنجا که این موقعیت های راس شما را نشان می دهد ، نامی مانند POS طبیعی به نظر می رسد.

  1. عملکرد سایه بان خود را به کد زیر تغییر دهید:

index.html (کد createshadermodule)

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

و اکنون باید آن موقعیت را برگردانید. از آنجا که موقعیت یک بردار 2D است و نوع بازگشت یک بردار 4D است ، شما باید کمی آن را تغییر دهید. کاری که شما می خواهید انجام دهید این است که دو مؤلفه را از استدلال موقعیت بگیرید و آنها را در دو مؤلفه اول بردار برگشتی قرار دهید و دو مؤلفه آخر را به ترتیب 0 و 1 قرار دهید.

  1. موقعیت صحیح را با صریح بیان کنید که از کدام مؤلفه های موقعیت استفاده می شود:

index.html (کد createshadermodule)

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

با این حال ، از آنجا که این نوع نقشه ها در سایه بان ها بسیار متداول است ، می توانید بردار موقعیت را نیز به عنوان اولین استدلال در یک قطعه مناسب منتقل کنید و این به معنای همان چیز است.

  1. بیانیه return را با کد زیر بازنویسی کنید:

index.html (کد createshadermodule)

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

و این سایه بان اولیه شما است! این بسیار ساده است ، فقط از موقعیت به طور مؤثر بدون تغییر خارج می شوید ، اما برای شروع کار به اندازه کافی خوب است.

سایه بان قطعه را تعریف کنید

در مرحله بعد سایه بان قطعه است. سایه بان های قطعه ای به روشی بسیار مشابه با سایه بان های راس کار می کنند ، اما به جای اینکه برای هر راس فراخوانی شوند ، برای هر پیکسل ترسیم شده مورد استفاده قرار می گیرند.

سایه بان های قطعه همیشه پس از سایه بان های راس خوانده می شوند. GPU خروجی سایه بان های راس را می گیرد و آن را مثلث می کند و مثلث هایی را از مجموعه های سه امتیاز ایجاد می کند. سپس هر یک از این مثلث ها را با فهمیدن اینکه کدام پیکسل از پیوست های رنگی خروجی در آن مثلث گنجانده شده است ، تغییر می دهد و سپس یک بار برای هر یک از آن پیکسل ها یک بار سایه بان را فراخوانی می کند. سایه بان قطعه ای یک رنگ را برمی گرداند ، که به طور معمول از مقادیر ارسال شده به آن از سایه بان ورتکس و دارایی هایی مانند بافت محاسبه می شود ، که GPU به ضمیمه رنگ می نویسد.

درست مانند سایه بان های ورتکس ، سایه بان های قطعه به صورت موازی به صورت موازی اجرا می شوند. آنها از نظر ورودی و خروجی آنها کمی انعطاف پذیر تر از سایه بان های Vertex هستند ، اما می توانید آنها را در نظر بگیرید که به سادگی یک رنگ را برای هر پیکسل از هر مثلث برگردانند.

یک عملکرد سایه بان قطعه WGSL با ویژگی @fragment مشخص شده است و همچنین یک vec4f را برمی گرداند. در این حالت ، هرچند ، بردار نشان دهنده یک رنگ است ، نه یک موقعیت. به مقدار بازده باید یک ویژگی @location داده شود تا نشان دهد کدام یک از colorAttachment از طریق تماس beginRenderPass به رنگ برگشتی نوشته شده است. از آنجا که شما فقط یک پیوست داشتید ، مکان 0 است.

  1. مانند این یک تابع خالی @fragment ایجاد کنید:

index.html (کد createshadermodule)

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

}

چهار مؤلفه وکتور برگشتی مقادیر رنگ قرمز ، سبز ، آبی و آلفا است که دقیقاً به همان روشی که clearValue که قبلاً در beginRenderPass تنظیم کرده اید ، تفسیر می شوند. بنابراین vec4f(1, 0, 0, 1) قرمز روشن است که به نظر می رسد یک رنگ مناسب برای مربع شما است. شما آزاد هستید که آن را به هر رنگی که می خواهید تنظیم کنید ، هر چند!

  1. بردار رنگ برگشتی را مانند این تنظیم کنید:

index.html (کد createshadermodule)

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

و این یک سایه بان کامل است! این بسیار جالب نیست ؛ این فقط هر پیکسل از هر مثلث را به رنگ قرمز قرار می دهد ، اما در حال حاضر این کافی است.

فقط برای یادآوری ، پس از افزودن کد سایه بان که در بالا به شرح زیر است ، تماس createShaderModule شما اکنون به این شکل است:

index.html

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

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

خط لوله رندر ایجاد کنید

یک ماژول سایه بان برای ارائه به تنهایی قابل استفاده نیست. درعوض ، شما باید از آن به عنوان بخشی از GPURenderPipeline ، که با تماس با دستگاه. createrenderpipeline () ایجاد شده است ، استفاده کنید. خط لوله رندر چگونه هندسه را ترسیم می کند ، از جمله مواردی مانند سایه بان ها ، نحوه تفسیر داده ها در بافرهای راس ، که باید نوع هندسه (خطوط ، نقاط ، مثلث ...) و موارد دیگر ارائه شود!

خط لوله رندر پیچیده ترین شیء در کل API است ، اما نگران نباشید! بیشتر مقادیری که می توانید به آن منتقل کنید اختیاری است و برای شروع فقط باید چند مورد را تهیه کنید.

  • مانند این یک خط لوله رندر ایجاد کنید:

index.html

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

هر خط لوله به layout نیاز دارد که توصیف کند که چه نوع ورودی (غیر از بافر راس) خط لوله به آن نیاز دارد ، اما شما واقعاً هیچ چیزی ندارید. خوشبختانه ، شما می توانید "auto" را فعلاً عبور دهید ، و خط لوله طرح خود را از سایه بان ها می سازد.

در مرحله بعد ، شما باید جزئیات مربوط به مرحله vertex را ارائه دهید. module gpushadermodule است که حاوی سایه بان راس شما است و entryPoint نام عملکرد را در کد سایه بان می دهد که برای هر دعوت ورتکس فراخوانی می شود. (شما می توانید چندین توابع @vertex و @fragment در یک ماژول سایه دار واحد داشته باشید!) بافر مجموعه ای از اشیاء GPUVertexBufferLayout است که توصیف می کند که چگونه داده های شما در بافرهای Vertex بسته بندی شده است که از این خط لوله استفاده می کنید. خوشبختانه ، شما قبلاً این مورد را در vertexBufferLayout خود تعریف کرده اید! اینجا جایی است که شما آن را منتقل می کنید.

در آخر ، شما جزئیات مربوط به مرحله fragment را دارید. این همچنین شامل یک ماژول سایه دار و ورودی ، مانند مرحله vertex است. آخرین بیت تعریف targets است که از این خط لوله استفاده می شود. این مجموعه ای از فرهنگ لغت ها است که جزئیات آن را ارائه می دهند - از جمله format بافت - پیوست های رنگی که خط لوله به آن می رسد. این جزئیات باید با بافت های ارائه شده در colorAttachments موجود در هر نوع گذرگاه که از این خط لوله استفاده می شود مطابقت داشته باشد. Pass Render شما از بافت بوم استفاده می کند و از مقداری که در canvasFormat ذخیره کرده اید برای قالب آن استفاده می کند ، بنابراین شما همان قالب را در اینجا عبور می دهید.

این حتی به همه گزینه هایی که می توانید هنگام ایجاد خط لوله رندر مشخص کنید ، نزدیک نیست ، اما برای نیازهای این CodeLab کافی است!

مربع را بکشید

و با این کار ، شما اکنون برای ترسیم مربع خود هر آنچه را که لازم دارید دارید!

  1. برای ترسیم مربع ، به پایین به سمت encoder.beginRenderPass() و pass.end() تماس بگیرید و سپس این دستورات جدید را بین آنها اضافه کنید:

index.html

// After encoder.beginRenderPass()

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

// before pass.end()

این WebGPU را با تمام اطلاعات لازم برای ترسیم مربع خود تأمین می کند. ابتدا از setPipeline() استفاده می کنید تا مشخص کنید که از خط لوله برای ترسیم استفاده می شود. این شامل سایه بان هایی است که مورد استفاده قرار می گیرند ، طرح داده های راس و سایر داده های مربوط به حالت مرتبط است.

در مرحله بعد ، شما با بافر حاوی رئوس برای مربع خود با setVertexBuffer() تماس می گیرید. شما آن را با 0 صدا می کنید زیرا این بافر با عنصر 0 در تعریف vertex.buffers خط لوله فعلی مطابقت دارد.

و آخر ، شما draw() CALL را انجام می دهید ، که به نظر می رسد پس از تمام تنظیماتی که قبلاً آمده است ، به طرز عجیبی ساده به نظر می رسد. تنها چیزی که شما باید به آن منتقل کنید ، تعداد راس هایی است که باید ارائه دهد ، که آن را از بافرهای Vertex در حال حاضر تنظیم می کند و با خط لوله تنظیم شده در حال حاضر تفسیر می شود. شما فقط می توانید آن را به 6 مورد سخت کنید ، اما محاسبه آن از آرایه راس (12 مختصات شناور در 2 در هر راس == 6 راس) به این معنی است که اگر تا به حال تصمیم به جایگزینی مربع با ، به عنوان مثال ، یک دایره ، کمتر وجود دارد. برای به روزرسانی با دست

  1. صفحه نمایش خود را تازه کنید و (در آخر) نتایج تمام کار سخت خود را ببینید: یک مربع بزرگ رنگی.

A single red square rendered with WebGPU

5. یک شبکه بکشید

اول ، لحظه ای وقت بگذارید تا به خود تبریک بگویید! گرفتن اولین بیت هندسه روی صفحه نمایش اغلب یکی از سخت ترین مراحل با اکثر API های GPU است. هر کاری که از اینجا انجام می دهید می تواند در مراحل کوچکتر انجام شود و باعث می شود پیشرفت خود را هنگام انجام آسانتر کنید.

در این بخش ، شما یاد می گیرید:

  • نحوه عبور متغیرها (به نام لباس) به سایه بان از JavaScript.
  • نحوه استفاده از لباس برای تغییر رفتار رندر.
  • نحوه استفاده از Instancing برای ترسیم انواع مختلف هندسه یکسان.

شبکه را تعریف کنید

برای ارائه یک شبکه ، باید اطلاعات بسیار اساسی در مورد آن را بدانید. چه تعداد سلول حاوی عرض و قد است؟ این به عنوان توسعه دهنده به عهده شماست ، اما برای ساده تر نگه داشتن کارها ، شبکه را به عنوان یک مربع (همان عرض و ارتفاع) رفتار کنید و از اندازه ای استفاده کنید که قدرت دو باشد. . آن را بعد از آن مقیاس کنید!

  • اندازه شبکه را با اضافه کردن ثابت به بالای کد JavaScript خود تعریف کنید.

index.html

const GRID_SIZE = 4;

در مرحله بعد ، شما باید نحوه ارائه مربع خود را به روز کنید تا بتوانید GRID_SIZE Times GRID_SIZE آنها را روی بوم قرار دهید. این بدان معناست که مربع باید بسیار کوچکتر باشد و باید تعداد زیادی از آنها وجود داشته باشد.

اکنون ، یکی از راه هایی که می توانید به این امر نزدیک شوید ، با ایجاد بافر راس شما به طور قابل توجهی بزرگتر و تعریف GRID_SIZE Times GRID_SIZE مربع های موجود در آن در اندازه و موقعیت مناسب است. در واقع کد برای آن خیلی بد نخواهد بود! فقط یک زن و شوهر برای حلقه ها و کمی ریاضی. اما این همچنین بهترین استفاده از GPU و استفاده از حافظه بیشتر از حد لازم برای دستیابی به اثر را ندارد. در این بخش به یک رویکرد دوستانه GPU می پردازد.

یک بافر یکنواخت ایجاد کنید

ابتدا باید اندازه شبکه ای را که انتخاب کرده اید به سایه بان ارتباط برقرار کنید ، زیرا از آن برای تغییر نحوه نمایش چیزها استفاده می کند. شما فقط می توانید اندازه را در سایه بان سخت کنید ، اما این بدان معنی است که هر زمان که بخواهید اندازه شبکه را تغییر دهید ، باید دوباره سایه بان را ایجاد کنید و خط لوله را ارائه دهید ، که گران است. یک روش بهتر این است که اندازه شبکه را به عنوان لباس به سایه بان ارائه دهید.

شما قبلاً آموخته اید که مقدار متفاوتی از بافر راس به هر دعوت از یک سایه بان راس منتقل می شود. یکنواخت یک مقدار از بافر است که برای هر دعوت نامه یکسان است. آنها برای برقراری مقادیر مشترک برای یک قطعه هندسه (مانند موقعیت آن) ، یک قاب کامل انیمیشن (مانند زمان فعلی) یا حتی کل طول عمر برنامه (مانند ترجیح کاربر) مفید هستند.

  • با اضافه کردن کد زیر یک بافر یکنواخت ایجاد کنید:

index.html

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

این باید بسیار آشنا به نظر برسد ، زیرا تقریباً دقیقاً همان کدی است که قبلاً برای ایجاد بافر Vertex استفاده کرده اید! دلیل این امر این است که لباس از طریق همان اشیاء GPubuffer که راس ها هستند به API WebGPU ابلاغ می شوند ، با تفاوت اصلی این که usage این بار شامل GPUBufferUsage.UNIFORM به جای GPUBufferUsage.VERTEX است.

دسترسی به لباس در یک سایه بان

  • با افزودن کد زیر یک لباس را تعریف کنید:

index.html (تماس Createshadermodule)

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

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

// ...fragmentMain is unchanged 

این یک لباس در سایه بان شما به نام grid را تعریف می کند ، که یک بردار شناور 2D است که با آرایه ای که شما فقط در بافر یکنواخت کپی کرده اید مطابقت دارد. همچنین مشخص می کند که لباس در @group(0) و @binding(0) محدود شده است. شما می آموزید که این ارزش ها در یک لحظه به چه معناست.

سپس ، در جای دیگر در کد سایه بان ، می توانید از بردار شبکه استفاده کنید ، هرچند که نیاز دارید. در این کد موقعیت راس را با بردار شبکه تقسیم می کنید. از آنجا که pos یک بردار 2D است و grid یک بردار 2D است ، WGSL یک بخش مؤلفه را انجام می دهد. به عبارت دیگر ، نتیجه همان گفته vec2f(pos.x / grid.x, pos.y / grid.y) است.

این نوع عملیات بردار در سایه های GPU بسیار متداول است زیرا بسیاری از تکنیک های ارائه و محاسبه به آنها متکی هستند!

این بدان معنی است که در مورد شما این است که (اگر از اندازه شبکه 4 استفاده کرده اید) مربعی که ارائه می دهید یک چهارم از اندازه اصلی آن خواهد بود. این عالی است اگر می خواهید چهار مورد از آنها را در یک ردیف یا ستون قرار دهید!

یک گروه اتصال ایجاد کنید

اعلام لباس در سایه بان ، آن را با بافر که ایجاد کرده اید متصل نمی کند. برای انجام این کار ، شما باید یک گروه Bind را ایجاد و تنظیم کنید.

Bind Group مجموعه ای از منابعی است که می خواهید همزمان در دسترس سایه بان خود قرار دهید. این می تواند شامل انواع مختلفی از بافرها ، مانند بافر یکنواخت شما و سایر منابع مانند بافت و نمونه هایی باشد که در اینجا پوشانده نشده اند اما بخش های متداول تکنیک های ارائه دهنده WebGPU هستند.

  • با اضافه کردن کد زیر پس از ایجاد بافر یکنواخت و خط لوله رندر ، یک گروه اتصال را با بافر یکنواخت خود ایجاد کنید:

index.html

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

علاوه بر label استاندارد خود ، شما همچنین به یک layout نیاز دارید که توصیف می کند کدام نوع از منابع این گروه Bind را شامل می شود. این چیزی است که شما در یک مرحله آینده به آن حفر می کنید ، اما برای لحظه ای می توانید با خوشحالی از خط لوله خود برای طرح گروه Bind بخواهید زیرا خط لوله را با layout: "auto" . این امر باعث می شود خط لوله به طور خودکار طرح بندی گروه Bind را از اتصالی که در خود کد سایه بان اعلام کرده اید ، ایجاد کند. در این حالت ، شما از آن می خواهید که getBindGroupLayout(0) را بدست آورید ، جایی که 0 مربوط به @group(0) که در سایه بان تایپ کرده اید.

پس از مشخص کردن طرح ، مجموعه ای از entries را ارائه می دهید. هر ورودی یک فرهنگ لغت با حداقل مقادیر زیر است:

  • binding ، که با مقدار @binding() که در سایه بان وارد کرده اید مطابقت دارد. در این حالت ، 0 .
  • resource ، که منبع واقعی است که می خواهید در شاخص اتصال مشخص شده در معرض متغیر قرار دهید. در این حالت ، بافر یکنواخت شما.

این عملکرد یک GPUBindGroup را برمی گرداند ، که یک دسته مات و تغییر ناپذیر است. شما نمی توانید منابعی را که یک گروه Bind پس از ایجاد آن به آن اشاره می کند تغییر دهید ، اگرچه می توانید محتوای آن منابع را تغییر دهید. به عنوان مثال ، اگر بافر یکنواخت را برای حاوی اندازه شبکه جدید تغییر دهید ، که با استفاده از این گروه Bind با تماس های قرعه کشی آینده منعکس می شود.

گروه اتصال را به هم متصل کنید

اکنون که گروه Bind ایجاد شده است ، شما هنوز هم باید به WebGPU بگویید که هنگام ترسیم از آن استفاده کند. خوشبختانه این بسیار ساده است.

  1. به پاس رندر بروید و این خط جدید را قبل از draw() اضافه کنید:

index.html

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

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

pass.draw(vertices.length / 2);

0 به عنوان اولین آرگومان با @group(0) در کد Shader مطابقت دارد. شما @binding گویید که هر یک که بخشی از @group(0) است از منابع موجود در این گروه Bind استفاده می کند.

و اکنون بافر یکنواخت در معرض سایه بان شما قرار دارد!

  1. صفحه خود را تازه کنید ، و سپس باید چیزی شبیه به این را ببینید:

A small red square in the center of a dark blue background.

هورا! مربع شما اکنون یک چهارم اندازه ای است که قبلاً بود! این زیاد نیست ، اما نشان می دهد که لباس شما در واقع اعمال می شود و سایه بان اکنون می تواند به اندازه شبکه شما دسترسی پیدا کند.

هندسه را در سایه بان دستکاری کنید

بنابراین اکنون که می توانید اندازه شبکه را در سایه بان ارجاع دهید ، می توانید برای دستکاری در هندسه ای که ارائه می دهید ، کار خود را انجام دهید تا متناسب با الگوی شبکه مورد نظر خود باشد. برای انجام این کار ، دقیقاً آنچه را که می خواهید به دست آورید در نظر بگیرید.

شما باید از نظر مفهومی بوم خود را به سلولهای فردی تقسیم کنید. به منظور حفظ این کنوانسیون که محور X با حرکت به سمت راست افزایش می یابد و محور Y با بالا رفتن افزایش می یابد ، بگویید که سلول اول در گوشه سمت چپ پایین بوم قرار دارد. این یک طرح به شما می دهد که به نظر می رسد ، با هندسه مربع فعلی شما در وسط:

An illustration of the conceptual grid the Normalized Device Coordinate space will be divided when visualizing each cell with the currently rendered square geometry at it's center.

چالش شما پیدا کردن روشی در سایه بان است که به شما امکان می دهد هندسه مربع را در هر یک از این سلول ها با توجه به مختصات سلول قرار دهید.

اول ، می بینید که مربع شما به خوبی با هیچ یک از سلول ها مطابقت ندارد زیرا برای احاطه مرکز بوم تعریف شده است. شما می خواهید مربع را با نیم سلول تغییر دهید تا به خوبی در درون آنها قرار بگیرد.

یکی از راه هایی که می توانید این کار را برطرف کنید ، به روزرسانی بافر Vertex Square است. با جابجایی رئوس ها به گونه ای که گوشه پایین سمت راست ، به عنوان مثال ، (0.1 ، 0.1) به جای (-0.8 ، -0.8) باشد ، شما این مربع را حرکت می دهید تا با مرزهای سلولی به زیبایی بپردازید. اما ، از آنجا که شما کنترل کاملی بر نحوه پردازش رئوس ها در سایه بان شما دارید ، به راحتی می توانید با استفاده از کد سایه بان ، آنها را به راحتی در جای خود قرار دهید!

  1. ماژول Shader Vertex را با کد زیر تغییر دهید:

index.html (تماس Createshadermodule)

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

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

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

  return vec4f(gridPos, 0, 1);
}

این قبل از تقسیم آن بر روی اندازه شبکه ، هر راس و به سمت راست را به سمت راست حرکت می دهد (که به یاد داشته باشید ، نیمی از فضای کلیپ است). نتیجه یک مربع با شبکه زیبا و دقیقاً از مبدا است.

A visualization of the canvas conceptually divided into a 4x4 grid with a red square in cell (2, 2)

بعد ، زیرا سیستم مختصات بوم شما (0 ، 0) را در مرکز قرار می دهد و (-1 ، -1) در سمت چپ پایین ، و می خواهید (0 ، 0) در سمت چپ پایین باشد ، باید هندسه خود را ترجمه کنید موقعیت توسط (-1 ، -1) پس از تقسیم بر اندازه شبکه به منظور جابجایی آن به آن گوشه.

  1. موقعیت هندسه خود را مانند این ترجمه کنید:

index.html (تماس Createshadermodule)

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

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

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

  return vec4f(gridPos, 0, 1); 
}

و اکنون مربع شما به خوبی در سلول (0 ، 0) قرار گرفته است!

A visualization of the canvas conceptually divided into a 4x4 grid with a red square in cell (0, 0)

اگر می خواهید آن را در سلول دیگری قرار دهید ، چه می کنید؟ با اعلام یک بردار cell در سایه بان خود و جمع آوری آن با یک مقدار استاتیک مانند let cell = vec2f(1, 1) شکل دهید.

اگر آن را به gridPos اضافه کنید ، در الگوریتم - 1 را خنثی می کند ، بنابراین این چیزی نیست که شما می خواهید. در عوض ، شما می خواهید مربع را فقط با یک واحد شبکه (یک چهارم بوم) برای هر سلول حرکت دهید. به نظر می رسد که شما باید تقسیم دیگری را با grid انجام دهید!

  1. موقعیت یابی شبکه خود را تغییر دهید ، مانند این:

index.html (تماس Createshadermodule)

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

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

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

  return vec4f(gridPos, 0, 1);
}

اگر اکنون تازه کنید ، موارد زیر را مشاهده می کنید:

A visualization of the canvas conceptually divided into a 4x4 grid with a red square centered between cell (0, 0), cell (0, 1), cell (1, 0), and cell (1, 1)

هوم نه آنچه شما می خواستید.

دلیل این امر این است که از آنجا که مختصات بوم از -1 به 1+ می رود ، در واقع 2 واحد در سراسر است. این بدان معناست که اگر می خواهید یک چهارم رام بوم را حرکت دهید ، باید آن را 0.5 واحد جابجا کنید. این یک اشتباه آسان است که هنگام استدلال با مختصات GPU انجام دهید! خوشبختانه ، رفع آن به همان راحتی است.

  1. جبران خود را با 2 ضرب کنید ، مانند این:

index.html (تماس Createshadermodule)

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

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

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

  return vec4f(gridPos, 0, 1);
}

و این دقیقاً همان چیزی را که می خواهید به شما می دهد.

A visualization of the canvas conceptually divided into a 4x4 grid with a red square in cell (1, 1)

تصویر به این شکل است:

Screenshot of a red square on a dark blue background. The red square in drawn in the same position as described in the previous diagram, but without the grid overlay.

علاوه بر این ، اکنون می توانید cell در هر مقدار در محدوده شبکه تنظیم کنید ، و سپس برای دیدن رندر مربع در محل مورد نظر ، تازه کنید.

نمونه ها

اکنون که می توانید مربع را در جایی که می خواهید با کمی ریاضی قرار دهید ، مرحله بعدی این است که یک مربع را در هر سلول شبکه ارائه دهید.

یکی از راه هایی که می توانید به آن نزدیک شوید ، نوشتن مختصات سلول به یک بافر یکنواخت است ، سپس یک بار برای هر مربع در شبکه یک بار تماس بگیرید و هر بار لباس را به روز کنید. با این حال ، این بسیار کند خواهد بود ، زیرا GPU باید منتظر باشد تا مختصات جدید توسط JavaScript نوشته شود. یکی از کلیدهای دریافت عملکرد خوب از GPU ، به حداقل رساندن زمان صرف انتظار برای سایر قسمت های سیستم است!

در عوض ، می توانید از تکنیکی به نام Instancing استفاده کنید. Instancing راهی برای گفتن GPU برای ترسیم چندین نسخه از همان هندسه با یک تماس واحد برای draw است که بسیار سریعتر از draw یک بار برای هر نسخه است. به هر نسخه از هندسه به عنوان نمونه گفته می شود.

  1. برای گفتن به GPU که می خواهید نمونه های کافی از مربع خود را برای پر کردن شبکه ، یک استدلال به تماس قرعه کشی موجود خود اضافه کنید:

index.html

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

این به این سیستم می گوید که شما می خواهید آن را به عنوان شش بار ( vertices.length / 2 ) بارها و بارها مربع خود ( GRID_SIZE * GRID_SIZE ) بسازد. اما اگر صفحه را تازه کنید ، هنوز موارد زیر را مشاهده می کنید:

An identical image to the previous diagram, to indicate that nothing changed.

چرا؟ خوب ، به این دلیل است که شما همه 16 مورد از آن مربع ها را در همان نقطه ترسیم می کنید. نیاز شما به برخی از منطق اضافی در سایه بان است که هندسه را بر اساس هر حالت تغییر می دهد.

در سایه بان ، علاوه بر ویژگی های راس مانند pos که از بافر راس شما ناشی می شود ، می توانید به آنچه به مقادیر داخلی WGSL معروف است ، دسترسی پیدا کنید. اینها مقادیری هستند که توسط WebGPU محاسبه می شوند ، و یکی از این مقادیر instance_index است. instance_index یک عدد 32 بیتی امضا نشده از 0 تا number of instances - 1 که می توانید به عنوان بخشی از منطق سایه بان خود استفاده کنید. مقدار آن برای هر راس پردازش شده که بخشی از همان نمونه است ، یکسان است. این بدان معناست که سایه بان راس شما شش بار با یک instance_index از 0 ، یک بار برای هر موقعیتی در بافر Vertex شما تماس می گیرد. سپس شش بار دیگر با یک instance_index از 1 ، سپس شش مورد دیگر با instance_index از 2 و غیره.

برای دیدن این کار در عمل ، باید instance_index به ورودی های سایه بان خود اضافه کنید. این کار را به همان روش موقعیت انجام دهید ، اما به جای برچسب زدن آن با یک ویژگی @location ، از @builtin(instance_index) استفاده کنید ، و سپس استدلال را هرچه می خواهید نامگذاری کنید. (می توانید آن را instance بنامید تا با کد مثال مطابقت داشته باشید.) سپس از آن به عنوان بخشی از منطق Shader استفاده کنید!

  1. به جای مختصات سلول instance استفاده کنید:

index.html

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

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

  return vec4f(gridPos, 0, 1);
}

اگر اکنون تازه می کنید می بینید که واقعاً بیش از یک مربع دارید! اما شما نمی توانید همه 16 نفر را ببینید.

Four red squares in a diagonal line from the lower left corner to the top right corner against a dark blue background.

دلیل این امر این است که سلولی که شما تولید می کنید (0 ، 0) ، (1 ، 1) ، (2 ، 2) ... تمام راه (15 ، 15) است ، اما فقط چهار مورد اول این افراد در بوم قرار دارند. برای ساختن شبکه ای که می خواهید ، باید instance_index را به گونه ای تغییر دهید که هر فهرست مانند این نقشه ها را به یک سلول منحصر به فرد در شبکه خود تبدیل کند:

A visualization of the canvas conceptually divided into a 4x4 grid with each cell also corresponding to a linear instance index.

ریاضی برای آن به طور منطقی ساده است. برای مقدار X هر سلول ، شما می خواهید modulo از instance_index و عرض شبکه ، که می توانید در WGSL با اپراتور % انجام دهید. و برای مقدار y هر سلول می خواهید instance_index بر اساس عرض شبکه تقسیم شود و هر باقیمانده کسری را دور بیندازید. شما می توانید این کار را با عملکرد floor() انجام دهید.

  1. محاسبات را مانند این تغییر دهید:

index.html

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

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

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

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

  return vec4f(gridPos, 0, 1);
}

بعد از تهیه آن به روزرسانی در کد ، شبکه طولانی مدت مربع را انتظار دارید!

Four rows of four columns of red squares on a dark blue background.

  1. و حالا که کار می کند ، به عقب برگردید و اندازه شبکه را بالا ببرید!

index.html

const GRID_SIZE = 32;

32 rows of 32 columns of red squares on a dark blue background.

تادا! شما در واقع می توانید این شبکه را واقعاً بزرگ کنید ، اکنون واقعاً بزرگ است و میانگین GPU شما آن را بسیار خوب می کند. مدتها قبل از اینکه به هرگونه تنگنا عملکرد GPU بپردازید ، از دیدن مربع های جداگانه خودداری خواهید کرد.

6. اعتبار اضافی: آن را رنگارنگ تر کنید!

در این مرحله ، می توانید به راحتی به بخش بعدی بپردازید زیرا زمینه را برای بقیه CodeLab قرار داده اید. اما در حالی که شبکه مربع ها که همه رنگ یکسان هستند ، قابل استفاده است ، دقیقاً هیجان انگیز نیست ، اینطور است؟ خوشبختانه می توانید با کمی کد ریاضی و سایه بان کمی روشن تر شوید!

از ساختارها در سایه بان ها استفاده کنید

تاکنون ، شما یک قطعه از داده ها را از Shader Vertex عبور داده اید: موقعیت تبدیل شده. اما شما در واقع می توانید داده های بیشتری را از سایه بان Vertex برگردانید و سپس از آن در Shader Fragment استفاده کنید!

تنها راه انتقال داده ها از سایه بان راس ، بازگشت آن است. یک سایه بان راس همیشه برای بازگشت یک موقعیت مورد نیاز است ، بنابراین اگر می خواهید داده های دیگری را به همراه آن برگردانید ، باید آن را در یک ساختار قرار دهید. ساختار در WGSL انواع شیء نامگذاری شده است که حاوی یک یا چند ویژگی نامگذاری شده است. خواص را می توان با ویژگی هایی مانند @builtin و @location نیز مشخص کرد. شما آنها را خارج از هر کارکرد اعلام می کنید ، و سپس می توانید در صورت لزوم نمونه هایی از آنها را در داخل و خارج از توابع منتقل کنید. به عنوان مثال ، سایه بان فعلی خود را در نظر بگیرید:

index.html (تماس Createshadermodule)

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

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

  let i = f32(instance);
  let cell = vec2f(i % grid.x, floor(i / grid.x));
  let cellOffset = cell / grid * 2;
  let gridPos = (pos + 1) / grid - 1 + cellOffset;
  
  return  vec4f(gridPos, 0, 1);
}
  • همان چیزی را با استفاده از ساختارها برای ورودی و خروجی عملکرد بیان کنید:

index.html (تماس Createshadermodule)

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

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

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

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

توجه کنید که این امر شما را ملزم به مراجعه به موقعیت ورودی و شاخص نمونه با input می کند ، و ساختاری که ابتدا برمی گردید باید به عنوان یک متغیر اعلام شود و خصوصیات فردی آن را تنظیم کنید. در این حالت ، تفاوت زیادی ایجاد نمی کند ، و در واقع باعث می شود عملکرد سایه بان کمی طولانی تر شود ، اما هرچه سایه بان های شما پیچیده تر می شوند ، استفاده از ساختارها می تواند راهی عالی برای کمک به سازماندهی داده های شما باشد.

داده ها را بین راس و توابع قطعه عبور دهید

به عنوان یک یادآوری ، عملکرد @fragment شما به همان اندازه ممکن است:

index.html (تماس Createshadermodule)

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

شما هیچ ورودی را نمی گیرید و به عنوان خروجی خود از یک رنگ جامد (قرمز) عبور می کنید. اگر سایه بان در مورد هندسه ای که رنگ آمیزی آن است بیشتر می دانست ، می توانید از آن داده های اضافی استفاده کنید تا چیزها کمی جالب تر شود. به عنوان مثال ، اگر می خواهید رنگ هر مربع را بر اساس مختصات سلول آن تغییر دهید ، چه می کنید؟ مرحله @vertex می داند کدام سلول در حال ارائه است. شما فقط باید آن را به مرحله @fragment منتقل کنید.

برای تصویب هرگونه داده بین مراحل راس و قطعه ، باید آن را در یک ساختار خروجی با @location انتخابی ما قرار دهید. از آنجا که می خواهید مختصات سلول را منتقل کنید ، آن را از قبل به ساختار VertexOutput اضافه کنید ، و سپس قبل از بازگشت آن را در عملکرد @vertex تنظیم کنید.

  1. مقدار بازگشت سایه بان راس خود را تغییر دهید ، مانند این:

index.html (تماس Createshadermodule)

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

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

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

@vertex
fn vertexMain(input: VertexInput) -> VertexOutput  {
  let i = f32(input.instance);
  let cell = vec2f(i % grid.x, floor(i / grid.x));
  let cellOffset = cell / grid * 2;
  let gridPos = (input.pos + 1) / grid - 1 + cellOffset;
  
  var output: VertexOutput;
  output.pos = vec4f(gridPos, 0, 1);
  output.cell = cell; // New line!
  return output;
}
  1. در عملکرد @fragment ، با اضافه کردن یک آرگومان با همان @location مقدار را دریافت کنید. (نام ها لازم نیست که مطابقت داشته باشند ، اما پیگیری کارها در صورت انجام آسانتر است!)

index.html (تماس Createshadermodule)

@fragment
fn fragmentMain(@location(0) cell: vec2f) -> @location(0) vec4f {
  // Remember, fragment return values are (Red, Green, Blue, Alpha)
  // and since cell is a 2D vector, this is equivalent to:
  // (Red = cell.x, Green = cell.y, Blue = 0, Alpha = 1)
  return vec4f(cell, 0, 1);
}
  1. از طرف دیگر ، می توانید به جای آن از یک ساختار استفاده کنید:

index.html (تماس Createshadermodule)

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

@fragment
fn fragmentMain(input: FragInput) -> @location(0) vec4f {
  return vec4f(input.cell, 0, 1);
}
  1. جایگزین دیگر ، از آنجا که در کد شما هر دو کارکرد در همان ماژول سایه بان تعریف شده اند ، استفاده مجدد از ساختار خروجی مرحله @vertex است! این امر مقادیر عبور را آسان می کند زیرا نام و مکان ها به طور طبیعی سازگار هستند.

index.html (تماس Createshadermodule)

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

مهم نیست که کدام الگویی را انتخاب کرده اید ، نتیجه این است که شما در عملکرد @fragment به شماره سلول دسترسی دارید و قادر به استفاده از آن برای تأثیرگذاری بر رنگ هستید. با هر یک از کد فوق ، خروجی به این شکل است:

A grid of squares where the leftmost column is green, the bottom row is red, and all other squares are yellow.

قطعاً رنگهای بیشتری در حال حاضر وجود دارد ، اما دقیقاً زیبا به نظر نمی رسد. شاید تعجب کنید که چرا فقط ردیف های چپ و پایین متفاوت هستند. دلیل این امر این است که مقادیر رنگی که از تابع @fragment باز می گردید ، انتظار دارند که هر کانال در محدوده 0 تا 1 باشد و هر مقداری خارج از آن محدوده به آن بسته می شود. از طرف دیگر مقادیر سلول شما از 0 تا 32 در طول هر محور متغیر است. بنابراین آنچه در اینجا می بینید این است که ردیف اول و ستون بلافاصله آن مقدار کامل 1 را در کانال رنگ قرمز یا سبز و هر سلول بعد از آن بسته به همان مقدار قرار می دهد.

اگر می خواهید یک انتقال نرم و صاف بین رنگ ها ، برای هر کانال رنگی یک مقدار کسری را برگردانید ، در حالت ایده آل از صفر شروع می شود و در هر محور به یک پایان می رسد ، این بدان معنی است که یک تقسیم دیگر توسط grid !

  1. سایه بان قطعه را تغییر دهید ، مانند این:

index.html (تماس Createshadermodule)

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

صفحه را تازه کنید و می بینید که کد جدید شیب بسیار زیبایی از رنگ ها را در کل شبکه به شما می دهد.

A grid of squares that transition from black, to red, to green, to yellow in different corners.

در حالی که مطمئناً این یک پیشرفت است ، اکنون یک گوشه تاریک و ناگوار در سمت چپ پایین وجود دارد ، جایی که شبکه سیاه می شود. هنگامی که شما شروع به انجام شبیه سازی Game of Life می کنید ، یک بخش سخت مشاهده شده از شبکه ، آنچه را که اتفاق می افتد را مبهم می کند. خوب است که آن را روشن کنیم.

خوشبختانه ، شما یک کانال رنگی کاملاً بلااستفاده دارید که می توانید از آن استفاده کنید. تأثیراتی که شما در حالت ایده آل می خواهید این است که آبی روشن ترین باشد که رنگ های دیگر تاریک ترین هستند و با رشد سایر رنگ ها در شدت محو می شوند. ساده ترین راه برای انجام این کار این است که کانال از 1 شروع شود و یکی از مقادیر سلولی را کم کند. این می تواند یا cx یا cy باشد. هر دو را امتحان کنید ، و سپس یکی را که ترجیح می دهید انتخاب کنید!

  1. مانند این رنگ های روشن تر را به سایه قطعه قطعه اضافه کنید:

تماس Createshadermodule

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

نتیجه بسیار خوب به نظر می رسد!

A grid of squares that transition from red, to green, to blue to yellow in different corners.

این یک گام مهم نیست! اما از آنجا که به نظر می رسد بهتر است ، آن را در پرونده منبع بازرسی مربوطه گنجانده شده است ، و بقیه تصاویر موجود در این CodeLab این شبکه رنگارنگ تر را منعکس می کند.

7. وضعیت سلول را مدیریت کنید

در مرحله بعد ، شما باید بر اساس برخی وضعیتی که در GPU ذخیره شده است ، کنترل کنید که سلول های موجود در شبکه ارائه می شود. این برای شبیه سازی نهایی مهم است!

تمام آنچه شما نیاز دارید یک سیگنال خاموش برای هر سلول است ، بنابراین هر گزینه ای که به شما امکان می دهد مجموعه بزرگی از تقریباً هر نوع ارزش را ذخیره کنید. ممکن است فکر کنید که این یک مورد دیگر برای بافرهای یکنواخت است! 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.

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

index.html

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

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

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.

  1. Activate every third cell with the following code:

index.html

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

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.

  1. Update your shader with the following code:

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

  1. Update your shader code to scale the position by the cell's active state. The state value must be cast to a f32 in order to satisfy WGSL's type safety requirements:

index.html

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

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

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

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:

index.html

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

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.

Diagonal stripes of colorful squares going from bottom left to top right against a dark blue background.

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! چرا؟ 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); 
  1. Use this pattern in your own code by updating your storage buffer allocation in order to create two identical buffers:

index.html

// Create two storage buffers to hold the cell state.
const cellStateStorage = [
  device.createBuffer({
    label: "Cell State A",
    size: cellStateArray.byteLength,
    usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
  }),
  device.createBuffer({
    label: "Cell State B",
     size: cellStateArray.byteLength,
     usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
  })
];
  1. To help visualize the difference between the two buffers, fill them with different data:

index.html

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

// Mark every other cell of the second grid as active.
for (let i = 0; i < cellStateArray.length; i++) {
  cellStateArray[i] = i % 2;
}
device.queue.writeBuffer(cellStateStorage[1], 0, cellStateArray);
  1. To show the different storage buffers in your rendering, update your bind groups to have two different variants, as well:

index.html

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

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.

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

index.html

const UPDATE_INTERVAL = 200; // Update every 200ms (5 times/sec)
let step = 0; // Track how many simulation steps have been run
  1. 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.

index.html

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

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

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

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

And now when you run the app you see that the canvas flips back and forth between showing the two state buffers you created.

Diagonal stripes of colorful squares going from bottom left to top right against a dark blue background. Vertical stripes of colorful squares against a dark blue background.

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.

  1. Create a compute shader with the following code:

index.html

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

    }`
});

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.

  1. Define a constant for your workgroup size, like this:

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

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

  1. Add a @builtin value, 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.

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

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

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

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

  1. To create that layout, call device.createBindGroupLayout() :

index.html

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

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.

  1. Update the bind group creation, like this:

index.html

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

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

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.

  1. Create a GPUPipelineLayout .

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

  1. Once you have the pipeline layout, update the render pipeline to use it instead of "auto" .

index.html

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

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:

index.html

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

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.

  1. Move the encoder creation to the top of the function, and then begin a compute pass with it (before the step++ ).

index.html

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

const computePass = encoder.beginComputePass();

// Compute work will go here...

computePass.end();

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

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.

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

index.html

const computePass = encoder.beginComputePass();

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

computePass.end();
  1. 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.

index.html

const computePass = encoder.beginComputePass();

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

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

computePass.end();

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.

Diagonal stripes of colorful squares going from bottom left to top right against a dark blue background. Diagonal stripes of colorful squares two squares wide going from bottom left to top right against a dark blue background. The inversion of the previous image.

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.

  1. To start each cell in a random state, update the cellStateArray initialization to the following code:

index.html

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

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.

  1. To make getting neighboring cell data easier, add a cellActive function that returns the cellStateIn value 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.

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

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

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

index.html

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

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

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

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

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

      let i = cellIndex(cell.xy);

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

And... that's it! شما تمام شده اید! Refresh your page and watch your newly built cellular automaton grow!

Screenshot of an example state from the Game of Life simulation, with colorful cells rendered against a dark blue background.

9. تبریک می گویم!

You created a version of the classic Conway's Game of Life simulation that runs entirely on your GPU using the WebGPU API!

بعدش چی؟

در ادامه مطلب

اسناد مرجع