# اشتباهی که خیلیها ممکن است در ابتدا انجام دهند
فرض کنید تازه شروع به ساخت یک موتور بازی کردهاید. اولین راهحلی که احتمالاً به ذهنتان میرسد چیزی شبیه این است:
naive_render.cpp — C++
for (const Tree& tree : trees)
{
tree.Draw();
}
از نظر منطقی این کد کاملاً درست است. اما اگر فقط ۱۰ هزار درخت داشته باشید، این حلقه در هر فریم ۱۰ هزار بار اجرا میشود. حالا تصور کنید بازی با نرخ ۶۰ فریم بر ثانیه اجرا شود:
calc.cpp
// Draw calls per second:
10,000 × 60 = 600,000 Draw Call
و این تازه فقط برای درختهاست؛ ساختمانها، ماشینها، آدمها و سایر اشیا را حساب نکردهایم. به همین دلیل تقریباً هیچ موتور بازی مدرنی از این روش استفاده نمیکند.
# ترفند اول؛ فقط چیزی را بکش که دیده میشود
اگر پشت شخصیت بازی یک جنگل قرار داشته باشد، آیا بازیکن آن را میبیند؟ خیر. پس چرا باید کارت گرافیک زمانش را صرف رسم آن کند؟
تقریباً تمام موتورهای بازی قبل از رسم هر شیء چنین بررسیای انجام میدهند:
frustum_culling.cpp — C++
for (const Tree& tree : trees)
{
if (!camera.IsVisible(tree.GetBoundingBox()))
continue;
tree.Draw();
}
اگر درخت خارج از میدان دید دوربین باشد، حلقه حتی وارد تابع
Draw() هم نمیشود. به همین دلیل ممکن است از میان ۵۰ هزار درخت موجود در نقشه، فقط ۴۰۰ درخت واقعاً رندر شوند.
نکته فنی — Frustum Culling: این تکنیک به بررسی اینکه آیا یک شیء داخل میدان دید دوربین (Frustum) قرار دارد یا نه میپردازد. Frustum یک هرم بریدهشده است که نمایانگر بخشی از دنیای سهبعدی است که دوربین میبیند. هر شیء که خارج از این ناحیه باشد، رندر نمیشود.
# ترفند دوم؛ هرچه دورتر، سادهتر
حالا فرض کنید یک درخت دقیقاً یک کیلومتر از شما فاصله دارد. آیا واقعاً لازم است تمام جزئیات برگها و شاخههای آن پردازش شوند؟ قطعاً نه.
برای همین موتور بازی چند نسخه از یک مدل نگه میدارد:
lod.cpp — C++
float distance = glm::distance(camera.Position(), tree.Position());
if (distance < 30.0f)
{
renderer.Draw(tree.HighLOD());
}
else if (distance < 100.0f)
{
renderer.Draw(tree.MediumLOD());
}
else
{
renderer.Draw(tree.LowLOD());
}
وقتی به درخت نزدیک میشوید، نسخه باکیفیت نمایش داده میشود و هرچه فاصله بیشتر شود، مدل سادهتر جای آن را میگیرد. نکته جالب اینجاست که بیشتر بازیکنها حتی متوجه این تغییر نمیشوند.
نکته فنی — Level of Detail (LOD): این تکنیک به استفاده از نسخههای مختلف یک مدل با جزئیات متفاوت اشاره دارد. HighLOD معمولاً دهها هزار پلیگون دارد، در حالی که LowLOD ممکن است فقط چند صد پلیگون داشته باشد. این تکنیک بهطور چشمگیری بار پردازشی را کاهش میدهد.
# ترفند سوم؛ یک دستور به جای ده هزار دستور
یکی از گرانترین کارها در گرافیک، ارسال دستورهای متعدد از CPU به GPU است. اگر بخواهیم هر درخت را جداگانه رسم کنیم، چنین اتفاقی میافتد:
individual_draw.cpp — C++
for (const Tree& tree : trees)
{
renderer.Draw(tree.Mesh(), tree.Transform());
}
اما موتورهای مدرن از قابلیتی به نام GPU Instancing استفاده میکنند:
instancing.cpp — C++
renderer.DrawInstanced(
treeMesh,
treeTransforms.data(),
static_cast<uint32_t>(treeTransforms.size())
);
در این روش فقط یک بار مدل درخت به کارت گرافیک ارسال میشود و سپس لیستی از موقعیت همه درختها فرستاده میشود. حالا کارت گرافیک خودش هزاران نسخه از همان مدل را رسم میکند. در نتیجه هم CPU کمتر درگیر میشود و هم سرعت رندر به شکل محسوسی افزایش پیدا میکند.
نکته فنی — GPU Instancing: این تکنیک به رندر کردن چندین نمونه از یک مدل با یک Draw Call اشاره دارد. به جای ارسال دستورات جداگانه برای هر درخت، تمام موقعیتها در یک بافر ارسال میشوند و Shader روی GPU مسئول رسم همه آنهاست. این تکنیک میتواند کارایی را تا ۱۰۰ برابر افزایش دهد.
# و آن درختهای خیلی دور...
اگر به افق نگاه کنید، احتمالاً هزاران درخت میبینید. اما شاید باورتان نشود که بسیاری از آنها اصلاً مدل سهبعدی نیستند.
وقتی فاصله از یک حد بیشتر شود، موتور بازی تصمیم میگیرد به جای مدل واقعی، فقط یک تصویر دوبعدی نمایش دهد:
billboard.cpp — C++
if (distance > 300.0f)
{
renderer.DrawBillboard(
tree.BillboardTexture(),
tree.Position()
);
}
از آن فاصله، چشم انسان تقریباً هیچ تفاوتی میان یک تصویر دوبعدی و مدل واقعی احساس نمیکند، اما فشار روی کارت گرافیک بهشدت کاهش مییابد.
نکته فنی — Billboard: Billboard یک تصویر دوبعدی است که همیشه رو به دوربین قرار میگیرد. این تکنیک برای اشیاء دور مانند درختان، ابرها و ستارهها استفاده میشود. از آنجا که این اشیاء از فاصله دور تقریباً یکسان به نظر میرسند، استفاده از Billboard بار پردازشی را بهطور چشمگیری کاهش میدهد.
# موتور بازی در هر فریم چه تصمیمی میگیرد؟
اگر بخواهیم منطق موتور را بهصورت ساده نشان دهیم، چیزی شبیه این خواهد بود:
render_loop.cpp — C++
for (const Tree& tree : world.Trees())
{
// 1) Frustum Culling
if (!camera.IsVisible(tree.GetBoundingBox()))
continue;
// 2) Calculate distance
float distance = glm::distance(
camera.Position(),
tree.Position()
);
// 3) LOD Selection
if (distance < 30.0f)
{
renderer.Draw(tree.HighLOD());
}
else if (distance < 100.0f)
{
renderer.Draw(tree.MediumLOD());
}
else if (distance < 300.0f)
{
renderer.Draw(tree.LowLOD());
}
else
{
// 4) Billboard for very distant objects
renderer.DrawBillboard(
tree.BillboardTexture(),
tree.Position()
);
}
}
این نمونه ساده است، اما ایدهای که موتورهای بزرگی مانند Unreal Engine یا موتورهای اختصاصی بازیهای جهانباز استفاده میکنند نیز بر همین اساس شکل گرفته است؛ با این تفاوت که آنها از ساختارهای پیشرفتهتری مانند Octree، BVH، Occlusion Culling، GPU Culling و Streaming نیز استفاده میکنند.
# جمعبندی
هر بار که در یک بازی جهانباز به جنگلی نگاه میکنید، به یاد داشته باشید که آنچه میبینید نتیجه یک سری تصمیمات هوشمندانه است:
Frustum Culling: فقط اشیاء داخل میدان دید رندر میشوند.
LOD: هرچه دورتر، سادهتر.
GPU Instancing: هزاران نمونه با یک دستور.
Billboard: اشیاء خیلی دور فقط یک تصویر دوبعدی هستند.
LOD: هرچه دورتر، سادهتر.
GPU Instancing: هزاران نمونه با یک دستور.
Billboard: اشیاء خیلی دور فقط یک تصویر دوبعدی هستند.
takeaway.txt
موتورهای بازی همه چیز را رندر نمیکنند؛ آنها فقط آنچه لازم است را رندر میکنند.