مثلث ها در وب Ch1 چیزی را ترسیم می کنند

Summarize this content to 400 words in Persian Lang
این مجموعه WebGPU و گرافیک کامپیوتری را به طور کلی معرفی می کند.
ابتدا بیایید ببینیم قرار است چه چیزی بسازیم،
بازی زندگی
رندر سه بعدی
رندر سه بعدی اما با نورپردازی
رندر مدل سه بعدی
به جز دانش اولیه JS، هیچ دانش قبلی لازم نیست.
آموزش در حال حاضر در github من به پایان رسیده است، همراه با کد منبع.
WebGPU یک API نسبتاً جدید برای GPU است. اگرچه به عنوان WebGPU نامگذاری شده است، اما در واقع می توان آن را لایه ای در بالای Vulkan، DirectX 12، و Metal، OpenGL و WebGL در نظر گرفت. این یک API سطح پایین طراحی شده است و برای برنامه های کاربردی با عملکرد بالا مانند بازی ها و شبیه سازی ها استفاده می شود.
در این فصل چیزی را روی صفحه می کشیم. بخش اول به آموزش Google Codelabs اشاره خواهد کرد. ما یک بازی زندگی را روی صفحه ایجاد خواهیم کرد.
نقطه شروع
ما فقط یک پروژه وانیلی JS خالی در vite با تایپ اسکریپت فعال می سازیم. سپس تمام کدهای اضافی را پاک کنید و فقط آن را باقی بگذارید main.ts.
const main = async () => {
console.log(‘Hello, world!’)
}
main()
وارد حالت تمام صفحه شوید
از حالت تمام صفحه خارج شوید
قبل از کدنویسی واقعی، لطفاً بررسی کنید که آیا مرورگر شما WebGPU را فعال کرده است یا خیر. می توانید آن را در WebGPU Samples بررسی کنید.
اکنون کروم به صورت پیشفرض فعال است. در سافاری، باید به تنظیمات توسعه دهنده بروید، تنظیمات را پرچم گذاری کنید و WebGPU را فعال کنید.
ما همچنین باید انواع را برای WebGPU فعال کنیم، نصب کنید @webgpu/typesو در گزینه های کامپایلر tsc، اضافه کنید “types”: [“@webgpu/types”].
علاوه بر این، ما جایگزین with در index.html.
رسم مثلث
کدهای دیگ بخار زیادی برای WebGPU وجود دارد که در اینجا به نظر می رسد.
درخواست دستگاه
ابتدا باید به GPU دسترسی داشته باشیم. در WebGPU با مفهوم an انجام می شود adapterکه پل ارتباطی بین GPU و مرورگر است.
const adapter = await navigator.gpu.requestAdapter();
وارد حالت تمام صفحه شوید
از حالت تمام صفحه خارج شوید
سپس باید از آداپتور یک دستگاه درخواست کنیم.
const device = await adapter.requestDevice();
console.log(device);
وارد حالت تمام صفحه شوید
از حالت تمام صفحه خارج شوید
بوم را پیکربندی کنید
مثلث خود را روی بوم می کشیم. باید عنصر canvas را بگیریم و آن را پیکربندی کنیم.
const canvas = document.getElementById(‘app’) as HTMLCanvasElement;
const context = canvas.getContext(“webgpu”)!;
const canvasFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({
device: device,
format: canvasFormat,
});
وارد حالت تمام صفحه شوید
از حالت تمام صفحه خارج شوید
در اینجا، ما استفاده می کنیم getContext برای به دست آوردن اطلاعات نسبی در مورد بوم. با مشخص کردن webgpu، زمینه ای را دریافت خواهیم کرد که مسئول رندر با WebGPU است.
CanvasFormat در واقع حالت رنگ است، برای مثال، srgb. ما معمولا فقط از قالب مورد نظر استفاده می کنیم.
در نهایت، ما زمینه را با دستگاه و قالب پیکربندی می کنیم.
درک خط لوله رندر GPU
قبل از پرداختن به جزئیات مهندسی، ابتدا باید درک کنیم که GPU چگونه رندرینگ را مدیریت می کند.
خط لوله رندر GPU مجموعه ای از مراحل است که GPU برای ارائه یک تصویر طی می کند.
برنامه ای که بر روی پردازنده گرافیکی اجرا می شود سایه زن نامیده می شود. Shader برنامه ای است که روی GPU اجرا می شود. شیدر زبان برنامه نویسی خاصی دارد که در ادامه به آن خواهیم پرداخت.
خط لوله رندر مراحل زیر را دارد:
CPU داده ها را در GPU بارگذاری می کند. CPU ممکن است برخی از اشیاء نامرئی را برای ذخیره منابع GPU حذف کند.
CPU تمام رنگها، بافتها و سایر دادههایی را که GPU برای نمایش صحنه نیاز دارد، تنظیم میکند.
CPU یک تماس قرعه کشی به GPU را راه اندازی می کند.
GPU داده ها را از CPU دریافت می کند و شروع به رندر کردن صحنه می کند.
GPU وارد فرآیند هندسه می شود که رئوس صحنه را پردازش می کند.
در فرآیند هندسه، اولین مرحله سایه زن رأس است که رئوس صحنه را پردازش می کند. ممکن است رئوس را تغییر دهد، رنگ رئوس را تغییر دهد یا کارهای دیگری را روی رئوس انجام دهد.
مرحله بعدی سایه زن tessellation است که رئوس صحنه را پردازش می کند. تقسیم بندی رئوس را انجام می دهد که هدف آن افزایش جزئیات صحنه است. همچنین روش های زیادی دارد، اما توضیح آن در اینجا بسیار پیچیده است.
مرحله بعدی سایه زن هندسی است که رئوس صحنه را پردازش می کند. بر خلاف سایهزن راس، که در آن توسعهدهنده تنها میتواند نحوه تبدیل یک راس را تعریف کند، سایهزن هندسی میتواند نحوه تبدیل چند راس را تعریف کند. همچنین می تواند رئوس جدیدی ایجاد کند که می توان از آنها برای ایجاد هندسه جدید استفاده کرد.
آخرین مرحله فرآیند هندسه شامل بریدن، حذف قسمتهای غیرضروری که بیش از صفحه نمایش هستند، و حذف، حذف قسمتهای نامرئی که برای دوربین قابل مشاهده نیستند، میباشد.
مرحله بعدی فرآیند شطرنجی است که رئوس را به قطعات تبدیل می کند. قطعه، پیکسلی است که قرار است روی صفحه نمایش داده شود.
مرحله بعدی تکرار مثلث ها است که روی مثلث های صحنه تکرار می شود.
مرحله بعدی قطعه سایه زن است که قطعات صحنه را پردازش می کند. ممکن است رنگ قطعات را تغییر دهد، بافت قطعات را تغییر دهد یا کارهای دیگری را روی قطعات انجام دهد. در این قسمت تست عمق و استنسیل نیز انجام می شود. آزمایش عمق به معنای اعطای مقدار عمق به هر قطعه است و قطعه با کمترین مقدار عمق ارائه می شود. تست استنسیل به معنای اعطای مقدار شابلون به هر قطعه است و قطعه ای که تست شابلون را پشت سر گذاشته است ارائه می شود. مقدار استنسیل توسط توسعه دهنده تعیین می شود.
مرحله بعدی فرآیند ترکیب است که قطعات صحنه را با هم ترکیب می کند. به عنوان مثال، اگر دو قطعه با هم همپوشانی داشته باشند، فرآیند ترکیب دو قطعه را با هم ترکیب می کند.
آخرین مرحله فرآیند خروجی است که قطعات را به زنجیره مبادله خروجی می دهد. زنجیره swap زنجیره ای از تصاویر است که برای ارائه صحنه استفاده می شود. به بیان ساده تر، این یک بافر است که تصویری را که قرار است روی صفحه نمایش داده شود نگه می دارد.
بسته به موارد اولیه، کوچکترین واحدی که GPU می تواند ارائه دهد، خط لوله ممکن است مراحل مختلفی داشته باشد. به طور معمول، ما از مثلث ها استفاده می کنیم که به GPU سیگنال می دهند تا هر 3 گروه از رئوس را به عنوان یک مثلث در نظر بگیرد.
ایجاد Render Pass
Render Pass مرحله ای از رندر کامل GPU است. هنگامی که یک پاس رندر ایجاد می شود، GPU شروع به رندر صحنه می کند و برعکس پس از اتمام آن.
برای ایجاد یک پاس رندر، باید یک رمزگذار ایجاد کنیم که وظیفه کامپایل پاس رندر به کدهای GPU را بر عهده دارد.
const encoder = device.createCommandEncoder();
وارد حالت تمام صفحه شوید
از حالت تمام صفحه خارج شوید
سپس یک render pass ایجاد می کنیم.
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
loadOp: “clear”,
storeOp: “store”,
}]
});
وارد حالت تمام صفحه شوید
از حالت تمام صفحه خارج شوید
در اینجا، ما یک پاس رندر با پیوست رنگ ایجاد می کنیم. پیوست مفهومی در GPU است که تصویری را که قرار است رندر شود را نشان می دهد. یک تصویر ممکن است جنبه های زیادی داشته باشد که پردازنده گرافیکی باید آنها را پردازش کند و هر یک از آنها یک پیوست هستند.
در اینجا فقط یک پیوست داریم که پیوست رنگ است. نمای پانلی است که GPU روی آن رندر میشود، در اینجا آن را روی بافت بوم تنظیم میکنیم.
loadOp عملیاتی است که GPU قبل از رندر پاس انجام می دهد، clear یعنی GPU ابتدا تمام داده های قبلی را از آخرین فریم پاک می کند و storeOp عملیاتی است که GPU پس از رندر پاس انجام می دهد، store یعنی GPU داده ها را در بافت ذخیره می کند.
loadOp می تواند باشد load، که داده های آخرین فریم را حفظ می کند یا clear، که داده ها را از آخرین فریم پاک می کند. storeOp می تواند باشد store، که داده ها را در بافت ذخیره می کند یا discard، که داده ها را دور می اندازد.
حالا فقط زنگ بزن pass.end() برای پایان دادن به رندر پاس. اکنون دستور در بافر فرمان پردازنده گرافیکی ذخیره می شود.
برای دریافت دستور کامپایل شده از کد زیر استفاده کنید
const commandBuffer = encoder.finish();
وارد حالت تمام صفحه شوید
از حالت تمام صفحه خارج شوید
و در نهایت دستور را به صف رندر پردازنده گرافیکی ارسال کنید.
device.queue.submit([commandBuffer]);
وارد حالت تمام صفحه شوید
از حالت تمام صفحه خارج شوید
حالا باید یک بوم سیاه زشت را ببینید.
بر اساس مفاهیم کلیشه ای ما در مورد سه بعدی، انتظار داریم فضای خالی یک رنگ آبی باشد. ما می توانیم این کار را با تنظیم رنگ شفاف انجام دهیم.
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
loadOp: “clear”,
clearValue: { r: 0.1, g: 0.3, b: 0.8, a: 1.0 },
storeOp: “store”,
}]
});
وارد حالت تمام صفحه شوید
از حالت تمام صفحه خارج شوید
رسم مثلث با استفاده از Shader
حالا یک مثلث روی بوم می کشیم. برای این کار از شیدر استفاده خواهیم کرد. زبان سایه زن wgsl، WebGPU Shading Language خواهد بود.
حال فرض کنید می خواهیم مثلثی با مختصات زیر رسم کنیم.
(-0.5, -0.5), (0.5, -0.5), (0.0, 0.5)
وارد حالت تمام صفحه شوید
از حالت تمام صفحه خارج شوید
همانطور که قبلاً گفتیم، برای تکمیل یک خط لوله رندر، به یک سایه زن رأس و یک شیدر قطعه نیاز داریم.
سایه زن ورتکس
برای ایجاد ماژول های سایه زن از کد زیر استفاده کنید.
const cellShaderModule = device.createShaderModule({
label: “shader”,
code: `
// Shaders
`
});
وارد حالت تمام صفحه شوید
از حالت تمام صفحه خارج شوید
label در اینجا به سادگی یک نام است که برای اشکال زدایی در نظر گرفته شده است. code کد سایه زن واقعی است.
سایه زن راس تابعی است که هر پارامتری را می گیرد و موقعیت راس را برمی گرداند. با این حال، برخلاف آنچه ما انتظار داریم، سایه زن راس یک بردار چهار بعدی را برمی گرداند، نه یک بردار سه بعدی. بعد چهارم است w بعد، که برای تقسیم پرسپکتیو استفاده می شود. بعداً در مورد آن بحث خواهیم کرد.
اکنون می توانید به سادگی یک بردار چهار بعدی را در نظر بگیرید (x, y, z, w) به عنوان یک بردار سه بعدی (x / w, y / w, z / w).
با این حال، مشکل دیگری وجود دارد: نحوه انتقال داده ها به سایه زن و نحوه خارج کردن داده ها از سایه زن.
برای ارسال داده ها به سایه زن، از عبارت استفاده می کنیم vertexBuffer، بافری که حاوی داده های رئوس است. با کد زیر می توانیم بافر ایجاد کنیم
const vertexBuffer = device.createBuffer({
size: 24,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
mappedAtCreation: true,
});
وارد حالت تمام صفحه شوید
از حالت تمام صفحه خارج شوید
در اینجا یک بافر با اندازه 24 بایت، 6 شناور ایجاد می کنیم که به اندازه رئوس است.
usage استفاده از بافر است که است VERTEX برای داده های راس GPUBufferUsage.COPY_DST یعنی این بافر به عنوان مقصد کپی معتبر است. برای تمام بافرهایی که اطلاعات آنها توسط CPU نوشته شده است، باید این پرچم را تنظیم کنیم.
را map در اینجا به معنای نگاشت بافر به CPU است، به این معنی که CPU می تواند بافر را بخواند و بنویسد. را unmap به معنای برداشتن نقشه بافر است، به این معنی که CPU دیگر نمی تواند بافر را بخواند و بنویسد و بنابراین محتوا در اختیار GPU قرار می گیرد.
اکنون می توانیم داده ها را در بافر بنویسیم.
new Float32Array(vertexBuffer.getMappedRange()).set([
-0.5, -0.5,
0.5, -0.5,
0.0, 0.5,
]);
vertexBuffer.unmap();
وارد حالت تمام صفحه شوید
از حالت تمام صفحه خارج شوید
در اینجا، بافر را به CPU نگاشت می کنیم و داده ها را در بافر می نویسیم. سپس بافر را unmap می کنیم.
vertexBuffer.getMappedRange() محدوده بافری که به CPU نگاشت شده است را برمی گرداند. می توانیم از آن برای نوشتن داده ها در بافر استفاده کنیم.
با این حال، اینها فقط داده های خام هستند و GPU نمی داند چگونه آنها را تفسیر کند. ما باید چیدمان بافر را تعریف کنیم.
const vertexBufferLayout: GPUVertexBufferLayout = {
arrayStride: 8,
attributes: [{
format: “float32x2”,
offset: 0,
shaderLocation: 0,
}],
};
وارد حالت تمام صفحه شوید
از حالت تمام صفحه خارج شوید
در اینجا، arrayStride تعداد بایتهایی است که GPU باید هنگام جستجوی ورودی بعدی در بافر به جلو بپرد. به عنوان مثال، اگر arrayStride 8 باشد، GPU 8 بایت را رد می کند تا ورودی بعدی را دریافت کند.
از اینجا، ما استفاده می کنیم float32x2، گام 8 بایت، 4 بایت برای هر شناور و 2 شناور برای هر رأس است.
اکنون می توانیم سایه زن راس را بنویسیم.
const shaderModule = device.createShaderModule({
label: “shader”,
code: `
@vertex
fn vertexMain(@location(0) pos: vec2f) -> @builtin(position) vec4f {
return vec4f(pos, 0, 1);
}
`
});
وارد حالت تمام صفحه شوید
از حالت تمام صفحه خارج شوید
اینجا، @vertex به این معنی که این یک سایه زن رأس است. @location(0) به معنی محل صفت است که همانطور که قبلاً تعریف شد 0 است. لطفاً توجه داشته باشید که در زبان شیدر، شما با چیدمان بافر سروکار دارید، بنابراین هر زمان که مقداری را ارسال می کنید، باید ساختاری را ارسال کنید که فیلدهای آن تعریف شده است. @location، یا فقط یک مقدار با @location.
vec2f یک بردار شناور دو بعدی است و vec4f یک بردار شناور چهار بعدی است. از آنجایی که برای برگرداندن موقعیت vec4f به سایه زن راس نیاز است، باید آن را با حاشیه نویسی کنیم @builtin(position).
سایه بان قطعه
سایهزن قطعه، بهطور مشابه، چیزی است که خروجی راس درونیابی را میگیرد و پیوستها، رنگ را در این مورد، خروجی میدهد. درون یابی به این معنی است که اگرچه فقط پیکسل های خاصی در رئوس دارای مقدار تعیین شده هستند، برای هر پیکسل دیگر، مقادیر درون یابی می شوند، چه خطی، چه میانگین یا سایر میانگین ها. رنگ قطعه یک بردار چهار بعدی است که رنگ قطعه به ترتیب قرمز، سبز، آبی و آلفا است.
لطفاً توجه داشته باشید که رنگ در محدوده 0 تا 1 است، نه 0 تا 255. علاوه بر این، قطعه سایه زن رنگ هر رأس را تعیین می کند، نه رنگ مثلث. رنگ مثلث با رنگ رئوس، با درون یابی تعیین می شود.
از آنجایی که ما در حال حاضر زحمت کنترل رنگ قطعه را نداریم، می توانیم به سادگی یک رنگ ثابت را برگردانیم.
const shaderModule = device.createShaderModule({
label: “shader”,
code: `
@vertex
fn vertexMain(@location(0) pos: vec2f) -> @builtin(position) vec4f {
return vec4f(pos, 0, 1);
}
@fragment
fn fragmentMain() -> vec4 {
return vec4(1.0, 1.0, 0.0, 1.0);
}
`
});
وارد حالت تمام صفحه شوید
از حالت تمام صفحه خارج شوید
رندر خط لوله
سپس خط لوله رندر سفارشی شده را با جایگزینی راس و شیدر قطعه تعریف می کنیم.
const pipeline = device.createRenderPipeline({
label: “pipeline”,
layout: “auto”,
vertex: {
module: shaderModule,
entryPoint: “vertexMain”,
buffers: [vertexBufferLayout]
},
fragment: {
module: shaderModule,
entryPoint: “fragmentMain”,
targets: [{
format: canvasFormat
}]
}
});
وارد حالت تمام صفحه شوید
از حالت تمام صفحه خارج شوید
توجه داشته باشید که در قطعه سایه زن باید فرمت هدف را که فرمت بوم است مشخص کنیم.
قرعه کشی تماس
قبل از اینکه رندر پاس به پایان برسد، فراخوانی قرعه کشی را اضافه می کنیم.
pass.setPipeline(pipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.draw(3);
وارد حالت تمام صفحه شوید
از حالت تمام صفحه خارج شوید
اینجا، در setVertexBuffer، اولین پارامتر شاخص بافر در قسمت تعریف خط لوله است buffers، و پارامتر دوم خود بافر است.
هنگام تماس draw، پارامتر تعداد رئوس برای رسم است. از آنجایی که 3 راس داریم، 3 را رسم می کنیم.
حالا باید یک مثلث زرد روی بوم ببینید.
سلول های بازی زندگی را بکشید
حالا کدهایمان را کمی تغییر می دهیم- چون می خواهیم یک بازی زندگی بسازیم، بنابراین باید به جای مثلث، مربع بکشیم.
یک مربع در واقع دو مثلث است، بنابراین باید 6 راس رسم کنیم. تغییرات اینجا ساده است و نیازی به توضیح دقیق ندارید.
const main = async () => {
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
console.error(‘WebGPU not supported’);
return;
}
const device = await adapter.requestDevice();
console.log(device);
const canvas = document.getElementById(‘app’) as HTMLCanvasElement;
const context = canvas.getContext(“webgpu”)!;
const canvasFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({
device: device,
format: canvasFormat,
});
const vertices = [
0.0, 0.0,
0.0, 1.0,
1.0, 0.0,
1.0, 1.0,
0.0, 1.0,
1.0, 0.0,
]
const vertexBuffer = device.createBuffer({
size: vertices.length * 4,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
mappedAtCreation: true,
});
new Float32Array(vertexBuffer.getMappedRange()).set(vertices);
const vertexBufferLayout: GPUVertexBufferLayout = {
arrayStride: 8,
attributes: [{
format: “float32x2”,
offset: 0,
shaderLocation: 0,
}],
};
const shaderModule = device.createShaderModule({
label: “shader”,
code: `
@vertex
fn vertexMain(@location(0) pos: vec2f) -> @builtin(position) vec4f {
return vec4f(pos, 0, 1);
}
@fragment
fn fragmentMain() -> vec4 {
return vec4(1.0, 1.0, 0.0, 1.0);
}
`
});
vertexBuffer.unmap();
const pipeline = device.createRenderPipeline({
label: “Cell pipeline”,
layout: “auto”,
vertex: {
module: shaderModule,
entryPoint: “vertexMain”,
buffers: [vertexBufferLayout]
},
fragment: {
module: shaderModule,
entryPoint: “fragmentMain”,
targets: [{
format: canvasFormat
}]
}
});
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
loadOp: “clear”,
clearValue: { r: 0.1, g: 0.3, b: 0.8, a: 1.0 },
storeOp: “store”,
}]
});
pass.setPipeline(pipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.draw(vertices.length / 2);
pass.end();
const commandBuffer = encoder.finish();
device.queue.submit([commandBuffer]);
}
وارد حالت تمام صفحه شوید
از حالت تمام صفحه خارج شوید
حالا باید یک مربع زرد روی بوم ببینید.
سیستم مختصات
ما در مورد سیستم مختصات GPU بحث نکردیم. خوب، نسبتاً ساده است. سیستم مختصات واقعی GPU یک سیستم مختصات سمت راست است، به این معنی که محور x به سمت راست، محور y به سمت بالا و محور z به بیرون از صفحه نمایش میرود.
محدوده سیستم مختصات از -1 تا 1 است. مبدا در مرکز صفحه است. محور z از 0 تا 1 است، 0 صفحه نزدیک و 1 صفحه دور است. با این حال، محور z برای عمق است. هنگامی که رندر سه بعدی انجام می دهید، نمی توانید فقط از محور z برای تعیین موقعیت شی استفاده کنید، بلکه باید از تقسیم پرسپکتیو استفاده کنید. این NDC، مختصات نرمال شده دستگاه نامیده می شود.
به عنوان مثال، اگر می خواهید یک مربع در گوشه سمت چپ بالای صفحه بکشید، رئوس آن ها (-1، 1)، (-1، 0)، (0، 1)، (0، 0) هستند، هر چند شما برای ترسیم آن باید از دو مثلث استفاده کنید.
این مجموعه WebGPU و گرافیک کامپیوتری را به طور کلی معرفی می کند.
ابتدا بیایید ببینیم قرار است چه چیزی بسازیم،
بازی زندگی
رندر سه بعدی
رندر سه بعدی اما با نورپردازی
رندر مدل سه بعدی
به جز دانش اولیه JS، هیچ دانش قبلی لازم نیست.
آموزش در حال حاضر در github من به پایان رسیده است، همراه با کد منبع.
WebGPU یک API نسبتاً جدید برای GPU است. اگرچه به عنوان WebGPU نامگذاری شده است، اما در واقع می توان آن را لایه ای در بالای Vulkan، DirectX 12، و Metal، OpenGL و WebGL در نظر گرفت. این یک API سطح پایین طراحی شده است و برای برنامه های کاربردی با عملکرد بالا مانند بازی ها و شبیه سازی ها استفاده می شود.
در این فصل چیزی را روی صفحه می کشیم. بخش اول به آموزش Google Codelabs اشاره خواهد کرد. ما یک بازی زندگی را روی صفحه ایجاد خواهیم کرد.
نقطه شروع
ما فقط یک پروژه وانیلی JS خالی در vite با تایپ اسکریپت فعال می سازیم. سپس تمام کدهای اضافی را پاک کنید و فقط آن را باقی بگذارید main.ts
.
const main = async () => {
console.log('Hello, world!')
}
main()
قبل از کدنویسی واقعی، لطفاً بررسی کنید که آیا مرورگر شما WebGPU را فعال کرده است یا خیر. می توانید آن را در WebGPU Samples بررسی کنید.
اکنون کروم به صورت پیشفرض فعال است. در سافاری، باید به تنظیمات توسعه دهنده بروید، تنظیمات را پرچم گذاری کنید و WebGPU را فعال کنید.
ما همچنین باید انواع را برای WebGPU فعال کنیم، نصب کنید @webgpu/types
و در گزینه های کامپایلر tsc، اضافه کنید "types": ["@webgpu/types"]
.
علاوه بر این، ما جایگزین with
در
index.html
.
رسم مثلث
کدهای دیگ بخار زیادی برای WebGPU وجود دارد که در اینجا به نظر می رسد.
درخواست دستگاه
ابتدا باید به GPU دسترسی داشته باشیم. در WebGPU با مفهوم an انجام می شود adapter
که پل ارتباطی بین GPU و مرورگر است.
const adapter = await navigator.gpu.requestAdapter();
سپس باید از آداپتور یک دستگاه درخواست کنیم.
const device = await adapter.requestDevice();
console.log(device);
بوم را پیکربندی کنید
مثلث خود را روی بوم می کشیم. باید عنصر canvas را بگیریم و آن را پیکربندی کنیم.
const canvas = document.getElementById('app') as HTMLCanvasElement;
const context = canvas.getContext("webgpu")!;
const canvasFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({
device: device,
format: canvasFormat,
});
در اینجا، ما استفاده می کنیم getContext
برای به دست آوردن اطلاعات نسبی در مورد بوم. با مشخص کردن webgpu
، زمینه ای را دریافت خواهیم کرد که مسئول رندر با WebGPU است.
CanvasFormat
در واقع حالت رنگ است، برای مثال، srgb
. ما معمولا فقط از قالب مورد نظر استفاده می کنیم.
در نهایت، ما زمینه را با دستگاه و قالب پیکربندی می کنیم.
درک خط لوله رندر GPU
قبل از پرداختن به جزئیات مهندسی، ابتدا باید درک کنیم که GPU چگونه رندرینگ را مدیریت می کند.
خط لوله رندر GPU مجموعه ای از مراحل است که GPU برای ارائه یک تصویر طی می کند.
برنامه ای که بر روی پردازنده گرافیکی اجرا می شود سایه زن نامیده می شود. Shader برنامه ای است که روی GPU اجرا می شود. شیدر زبان برنامه نویسی خاصی دارد که در ادامه به آن خواهیم پرداخت.
خط لوله رندر مراحل زیر را دارد:
- CPU داده ها را در GPU بارگذاری می کند. CPU ممکن است برخی از اشیاء نامرئی را برای ذخیره منابع GPU حذف کند.
- CPU تمام رنگها، بافتها و سایر دادههایی را که GPU برای نمایش صحنه نیاز دارد، تنظیم میکند.
- CPU یک تماس قرعه کشی به GPU را راه اندازی می کند.
- GPU داده ها را از CPU دریافت می کند و شروع به رندر کردن صحنه می کند.
- GPU وارد فرآیند هندسه می شود که رئوس صحنه را پردازش می کند.
- در فرآیند هندسه، اولین مرحله سایه زن رأس است که رئوس صحنه را پردازش می کند. ممکن است رئوس را تغییر دهد، رنگ رئوس را تغییر دهد یا کارهای دیگری را روی رئوس انجام دهد.
- مرحله بعدی سایه زن tessellation است که رئوس صحنه را پردازش می کند. تقسیم بندی رئوس را انجام می دهد که هدف آن افزایش جزئیات صحنه است. همچنین روش های زیادی دارد، اما توضیح آن در اینجا بسیار پیچیده است.
- مرحله بعدی سایه زن هندسی است که رئوس صحنه را پردازش می کند. بر خلاف سایهزن راس، که در آن توسعهدهنده تنها میتواند نحوه تبدیل یک راس را تعریف کند، سایهزن هندسی میتواند نحوه تبدیل چند راس را تعریف کند. همچنین می تواند رئوس جدیدی ایجاد کند که می توان از آنها برای ایجاد هندسه جدید استفاده کرد.
- آخرین مرحله فرآیند هندسه شامل بریدن، حذف قسمتهای غیرضروری که بیش از صفحه نمایش هستند، و حذف، حذف قسمتهای نامرئی که برای دوربین قابل مشاهده نیستند، میباشد.
- مرحله بعدی فرآیند شطرنجی است که رئوس را به قطعات تبدیل می کند. قطعه، پیکسلی است که قرار است روی صفحه نمایش داده شود.
- مرحله بعدی تکرار مثلث ها است که روی مثلث های صحنه تکرار می شود.
- مرحله بعدی قطعه سایه زن است که قطعات صحنه را پردازش می کند. ممکن است رنگ قطعات را تغییر دهد، بافت قطعات را تغییر دهد یا کارهای دیگری را روی قطعات انجام دهد. در این قسمت تست عمق و استنسیل نیز انجام می شود. آزمایش عمق به معنای اعطای مقدار عمق به هر قطعه است و قطعه با کمترین مقدار عمق ارائه می شود. تست استنسیل به معنای اعطای مقدار شابلون به هر قطعه است و قطعه ای که تست شابلون را پشت سر گذاشته است ارائه می شود. مقدار استنسیل توسط توسعه دهنده تعیین می شود.
- مرحله بعدی فرآیند ترکیب است که قطعات صحنه را با هم ترکیب می کند. به عنوان مثال، اگر دو قطعه با هم همپوشانی داشته باشند، فرآیند ترکیب دو قطعه را با هم ترکیب می کند.
- آخرین مرحله فرآیند خروجی است که قطعات را به زنجیره مبادله خروجی می دهد. زنجیره swap زنجیره ای از تصاویر است که برای ارائه صحنه استفاده می شود. به بیان ساده تر، این یک بافر است که تصویری را که قرار است روی صفحه نمایش داده شود نگه می دارد.
بسته به موارد اولیه، کوچکترین واحدی که GPU می تواند ارائه دهد، خط لوله ممکن است مراحل مختلفی داشته باشد. به طور معمول، ما از مثلث ها استفاده می کنیم که به GPU سیگنال می دهند تا هر 3 گروه از رئوس را به عنوان یک مثلث در نظر بگیرد.
ایجاد Render Pass
Render Pass مرحله ای از رندر کامل GPU است. هنگامی که یک پاس رندر ایجاد می شود، GPU شروع به رندر صحنه می کند و برعکس پس از اتمام آن.
برای ایجاد یک پاس رندر، باید یک رمزگذار ایجاد کنیم که وظیفه کامپایل پاس رندر به کدهای GPU را بر عهده دارد.
const encoder = device.createCommandEncoder();
سپس یک render pass ایجاد می کنیم.
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
loadOp: "clear",
storeOp: "store",
}]
});
در اینجا، ما یک پاس رندر با پیوست رنگ ایجاد می کنیم. پیوست مفهومی در GPU است که تصویری را که قرار است رندر شود را نشان می دهد. یک تصویر ممکن است جنبه های زیادی داشته باشد که پردازنده گرافیکی باید آنها را پردازش کند و هر یک از آنها یک پیوست هستند.
در اینجا فقط یک پیوست داریم که پیوست رنگ است. نمای پانلی است که GPU روی آن رندر میشود، در اینجا آن را روی بافت بوم تنظیم میکنیم.
loadOp
عملیاتی است که GPU قبل از رندر پاس انجام می دهد، clear
یعنی GPU ابتدا تمام داده های قبلی را از آخرین فریم پاک می کند و storeOp
عملیاتی است که GPU پس از رندر پاس انجام می دهد، store
یعنی GPU داده ها را در بافت ذخیره می کند.
loadOp
می تواند باشد load
، که داده های آخرین فریم را حفظ می کند یا clear
، که داده ها را از آخرین فریم پاک می کند. storeOp
می تواند باشد store
، که داده ها را در بافت ذخیره می کند یا discard
، که داده ها را دور می اندازد.
حالا فقط زنگ بزن pass.end()
برای پایان دادن به رندر پاس. اکنون دستور در بافر فرمان پردازنده گرافیکی ذخیره می شود.
برای دریافت دستور کامپایل شده از کد زیر استفاده کنید
const commandBuffer = encoder.finish();
و در نهایت دستور را به صف رندر پردازنده گرافیکی ارسال کنید.
device.queue.submit([commandBuffer]);
حالا باید یک بوم سیاه زشت را ببینید.
بر اساس مفاهیم کلیشه ای ما در مورد سه بعدی، انتظار داریم فضای خالی یک رنگ آبی باشد. ما می توانیم این کار را با تنظیم رنگ شفاف انجام دهیم.
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
loadOp: "clear",
clearValue: { r: 0.1, g: 0.3, b: 0.8, a: 1.0 },
storeOp: "store",
}]
});
رسم مثلث با استفاده از Shader
حالا یک مثلث روی بوم می کشیم. برای این کار از شیدر استفاده خواهیم کرد. زبان سایه زن wgsl، WebGPU Shading Language خواهد بود.
حال فرض کنید می خواهیم مثلثی با مختصات زیر رسم کنیم.
(-0.5, -0.5), (0.5, -0.5), (0.0, 0.5)
همانطور که قبلاً گفتیم، برای تکمیل یک خط لوله رندر، به یک سایه زن رأس و یک شیدر قطعه نیاز داریم.
سایه زن ورتکس
برای ایجاد ماژول های سایه زن از کد زیر استفاده کنید.
const cellShaderModule = device.createShaderModule({
label: "shader",
code: `
// Shaders
`
});
label
در اینجا به سادگی یک نام است که برای اشکال زدایی در نظر گرفته شده است. code
کد سایه زن واقعی است.
سایه زن راس تابعی است که هر پارامتری را می گیرد و موقعیت راس را برمی گرداند. با این حال، برخلاف آنچه ما انتظار داریم، سایه زن راس یک بردار چهار بعدی را برمی گرداند، نه یک بردار سه بعدی. بعد چهارم است w
بعد، که برای تقسیم پرسپکتیو استفاده می شود. بعداً در مورد آن بحث خواهیم کرد.
اکنون می توانید به سادگی یک بردار چهار بعدی را در نظر بگیرید (x, y, z, w)
به عنوان یک بردار سه بعدی (x / w, y / w, z / w)
.
با این حال، مشکل دیگری وجود دارد: نحوه انتقال داده ها به سایه زن و نحوه خارج کردن داده ها از سایه زن.
برای ارسال داده ها به سایه زن، از عبارت استفاده می کنیم vertexBuffer
، بافری که حاوی داده های رئوس است. با کد زیر می توانیم بافر ایجاد کنیم
const vertexBuffer = device.createBuffer({
size: 24,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
mappedAtCreation: true,
});
در اینجا یک بافر با اندازه 24 بایت، 6 شناور ایجاد می کنیم که به اندازه رئوس است.
usage
استفاده از بافر است که است VERTEX
برای داده های راس GPUBufferUsage.COPY_DST
یعنی این بافر به عنوان مقصد کپی معتبر است. برای تمام بافرهایی که اطلاعات آنها توسط CPU نوشته شده است، باید این پرچم را تنظیم کنیم.
را map
در اینجا به معنای نگاشت بافر به CPU است، به این معنی که CPU می تواند بافر را بخواند و بنویسد. را unmap
به معنای برداشتن نقشه بافر است، به این معنی که CPU دیگر نمی تواند بافر را بخواند و بنویسد و بنابراین محتوا در اختیار GPU قرار می گیرد.
اکنون می توانیم داده ها را در بافر بنویسیم.
new Float32Array(vertexBuffer.getMappedRange()).set([
-0.5, -0.5,
0.5, -0.5,
0.0, 0.5,
]);
vertexBuffer.unmap();
در اینجا، بافر را به CPU نگاشت می کنیم و داده ها را در بافر می نویسیم. سپس بافر را unmap می کنیم.
vertexBuffer.getMappedRange()
محدوده بافری که به CPU نگاشت شده است را برمی گرداند. می توانیم از آن برای نوشتن داده ها در بافر استفاده کنیم.
با این حال، اینها فقط داده های خام هستند و GPU نمی داند چگونه آنها را تفسیر کند. ما باید چیدمان بافر را تعریف کنیم.
const vertexBufferLayout: GPUVertexBufferLayout = {
arrayStride: 8,
attributes: [{
format: "float32x2",
offset: 0,
shaderLocation: 0,
}],
};
در اینجا، arrayStride تعداد بایتهایی است که GPU باید هنگام جستجوی ورودی بعدی در بافر به جلو بپرد. به عنوان مثال، اگر arrayStride 8 باشد، GPU 8 بایت را رد می کند تا ورودی بعدی را دریافت کند.
از اینجا، ما استفاده می کنیم float32x2
، گام 8 بایت، 4 بایت برای هر شناور و 2 شناور برای هر رأس است.
اکنون می توانیم سایه زن راس را بنویسیم.
const shaderModule = device.createShaderModule({
label: "shader",
code: `
@vertex
fn vertexMain(@location(0) pos: vec2f) -> @builtin(position) vec4f {
return vec4f(pos, 0, 1);
}
`
});
اینجا، @vertex
به این معنی که این یک سایه زن رأس است. @location(0)
به معنی محل صفت است که همانطور که قبلاً تعریف شد 0 است. لطفاً توجه داشته باشید که در زبان شیدر، شما با چیدمان بافر سروکار دارید، بنابراین هر زمان که مقداری را ارسال می کنید، باید ساختاری را ارسال کنید که فیلدهای آن تعریف شده است. @location
، یا فقط یک مقدار با @location
.
vec2f
یک بردار شناور دو بعدی است و vec4f
یک بردار شناور چهار بعدی است. از آنجایی که برای برگرداندن موقعیت vec4f به سایه زن راس نیاز است، باید آن را با حاشیه نویسی کنیم @builtin(position)
.
سایه بان قطعه
سایهزن قطعه، بهطور مشابه، چیزی است که خروجی راس درونیابی را میگیرد و پیوستها، رنگ را در این مورد، خروجی میدهد. درون یابی به این معنی است که اگرچه فقط پیکسل های خاصی در رئوس دارای مقدار تعیین شده هستند، برای هر پیکسل دیگر، مقادیر درون یابی می شوند، چه خطی، چه میانگین یا سایر میانگین ها. رنگ قطعه یک بردار چهار بعدی است که رنگ قطعه به ترتیب قرمز، سبز، آبی و آلفا است.
لطفاً توجه داشته باشید که رنگ در محدوده 0 تا 1 است، نه 0 تا 255. علاوه بر این، قطعه سایه زن رنگ هر رأس را تعیین می کند، نه رنگ مثلث. رنگ مثلث با رنگ رئوس، با درون یابی تعیین می شود.
از آنجایی که ما در حال حاضر زحمت کنترل رنگ قطعه را نداریم، می توانیم به سادگی یک رنگ ثابت را برگردانیم.
const shaderModule = device.createShaderModule({
label: "shader",
code: `
@vertex
fn vertexMain(@location(0) pos: vec2f) -> @builtin(position) vec4f {
return vec4f(pos, 0, 1);
}
@fragment
fn fragmentMain() -> vec4 {
return vec4(1.0, 1.0, 0.0, 1.0);
}
`
});
رندر خط لوله
سپس خط لوله رندر سفارشی شده را با جایگزینی راس و شیدر قطعه تعریف می کنیم.
const pipeline = device.createRenderPipeline({
label: "pipeline",
layout: "auto",
vertex: {
module: shaderModule,
entryPoint: "vertexMain",
buffers: [vertexBufferLayout]
},
fragment: {
module: shaderModule,
entryPoint: "fragmentMain",
targets: [{
format: canvasFormat
}]
}
});
توجه داشته باشید که در قطعه سایه زن باید فرمت هدف را که فرمت بوم است مشخص کنیم.
قرعه کشی تماس
قبل از اینکه رندر پاس به پایان برسد، فراخوانی قرعه کشی را اضافه می کنیم.
pass.setPipeline(pipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.draw(3);
اینجا، در setVertexBuffer
، اولین پارامتر شاخص بافر در قسمت تعریف خط لوله است buffers
، و پارامتر دوم خود بافر است.
هنگام تماس draw
، پارامتر تعداد رئوس برای رسم است. از آنجایی که 3 راس داریم، 3 را رسم می کنیم.
حالا باید یک مثلث زرد روی بوم ببینید.
سلول های بازی زندگی را بکشید
حالا کدهایمان را کمی تغییر می دهیم- چون می خواهیم یک بازی زندگی بسازیم، بنابراین باید به جای مثلث، مربع بکشیم.
یک مربع در واقع دو مثلث است، بنابراین باید 6 راس رسم کنیم. تغییرات اینجا ساده است و نیازی به توضیح دقیق ندارید.
const main = async () => {
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
console.error('WebGPU not supported');
return;
}
const device = await adapter.requestDevice();
console.log(device);
const canvas = document.getElementById('app') as HTMLCanvasElement;
const context = canvas.getContext("webgpu")!;
const canvasFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({
device: device,
format: canvasFormat,
});
const vertices = [
0.0, 0.0,
0.0, 1.0,
1.0, 0.0,
1.0, 1.0,
0.0, 1.0,
1.0, 0.0,
]
const vertexBuffer = device.createBuffer({
size: vertices.length * 4,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
mappedAtCreation: true,
});
new Float32Array(vertexBuffer.getMappedRange()).set(vertices);
const vertexBufferLayout: GPUVertexBufferLayout = {
arrayStride: 8,
attributes: [{
format: "float32x2",
offset: 0,
shaderLocation: 0,
}],
};
const shaderModule = device.createShaderModule({
label: "shader",
code: `
@vertex
fn vertexMain(@location(0) pos: vec2f) -> @builtin(position) vec4f {
return vec4f(pos, 0, 1);
}
@fragment
fn fragmentMain() -> vec4 {
return vec4(1.0, 1.0, 0.0, 1.0);
}
`
});
vertexBuffer.unmap();
const pipeline = device.createRenderPipeline({
label: "Cell pipeline",
layout: "auto",
vertex: {
module: shaderModule,
entryPoint: "vertexMain",
buffers: [vertexBufferLayout]
},
fragment: {
module: shaderModule,
entryPoint: "fragmentMain",
targets: [{
format: canvasFormat
}]
}
});
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
loadOp: "clear",
clearValue: { r: 0.1, g: 0.3, b: 0.8, a: 1.0 },
storeOp: "store",
}]
});
pass.setPipeline(pipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.draw(vertices.length / 2);
pass.end();
const commandBuffer = encoder.finish();
device.queue.submit([commandBuffer]);
}
حالا باید یک مربع زرد روی بوم ببینید.
سیستم مختصات
ما در مورد سیستم مختصات GPU بحث نکردیم. خوب، نسبتاً ساده است. سیستم مختصات واقعی GPU یک سیستم مختصات سمت راست است، به این معنی که محور x به سمت راست، محور y به سمت بالا و محور z به بیرون از صفحه نمایش میرود.
محدوده سیستم مختصات از -1 تا 1 است. مبدا در مرکز صفحه است. محور z از 0 تا 1 است، 0 صفحه نزدیک و 1 صفحه دور است. با این حال، محور z برای عمق است. هنگامی که رندر سه بعدی انجام می دهید، نمی توانید فقط از محور z برای تعیین موقعیت شی استفاده کنید، بلکه باید از تقسیم پرسپکتیو استفاده کنید. این NDC، مختصات نرمال شده دستگاه نامیده می شود.
به عنوان مثال، اگر می خواهید یک مربع در گوشه سمت چپ بالای صفحه بکشید، رئوس آن ها (-1، 1)، (-1، 0)، (0، 1)، (0، 0) هستند، هر چند شما برای ترسیم آن باید از دو مثلث استفاده کنید.