بافرها، تصاویر دیجیتال و نگاشت بافت در OpenGL
فصل ۷ — تکنیکهای گسسته
تا اینجا مستقیماً با اشیای هندسی مانند خطها، چندضلعیها و چندوجهیها کار کردهایم. میدانستیم این موجودیتها اگر قابل مشاهده باشند در نهایت به پیکسلهای بافر فریم رسترسازی میشوند، اما لازم نبود مستقیماً با خود پیکسلها سروکار داشته باشیم. طی چند دههٔ گذشته، پیشرفتهای سختافزاری و نرمافزاری امکان دسترسی مستقیم و غیرمستقیم برنامهٔ کاربردی به بافر فریم را فراهم کردهاند. بسیاری از جذابترین روشهای گرافیکی جدید به تعامل برنامه با بافرهای مختلف متکیاند. نگاشت بافت، Antialiasing، Compositing و Alpha Blending فقط چند نمونهاند که وقتی API اجازهٔ کار با بافرهای گسسته را میدهد ممکن میشوند. همزمان GPUها نیز مقدار زیادی حافظه برای پشتیبانی از این تکنیکهای گسسته در اختیار گرفتهاند.
این فصل این روشها را با تمرکز بر قابلیتهای OpenGL و APIهای مشابه معرفی میکند. ابتدا بافر فریم را دقیقتر بررسی میکنیم و مبانی کار با آرایههای پیکسلی را میبینیم. سپس به روشهای Mapping میپردازیم. این روشها در حین رندر به ما اجازه میدهند از سطحی هندسی ساده—حتی یک چندضلعی منفرد—توهم سطحی بسیار پیچیده ایجاد کنیم. همهٔ این تکنیکها از آرایههای پیکسلی برای تقویت فرایند سایهزنی فصل ۵ استفاده میکنند.
سپس برخی بافرهای دیگر پشتیبانیشده توسط OpenGL و کاربردهای جدید آنها را بررسی خواهیم کرد. بهویژه، روشهای ترکیب یا Compositing تصاویر را میبینیم. در اینجا از مؤلفهٔ چهارم حالت RGBA استفاده میکنیم و خواهیم دید این کانال چگونه برای Blend کردن تصاویر و ساخت اثرهایی مانند شفافیت به کار میرود. فصل با بحث دربارهٔ مسائل Aliasing پایان مییابد که هر بار با عناصر گسسته کار میکنیم پدید میآیند.
۷٫۱ بافرها
پیشتر از دو نوع بافر استاندارد استفاده کردهایم: بافر رنگی و بافر عمق. سختافزار و نرمافزار ممکن است بافرهای دیگری نیز برای هدفهای ویژه پشتیبانی کنند. ویژگی مشترک همهٔ بافرها ماهیت ذاتاً گسستهٔ آنهاست: هم وضوح مکانی و هم عمق عددی محدود دارند.
میتوان یک بافر دوبعدی را بلوکی از حافظه با (n\times m) عنصر k بیتی تعریف کرد (شکل ۷٫۱).
شکل ۷٫۱ — بافر به صورت آرایهٔ (n\times m) از عناصر k بیتی.
اصطلاح Frame Buffer را برای مجموعهٔ بافرهایی به کار بردهایم که سامانهٔ گرافیکی برای رندر استفاده میکند؛ شامل بافر رنگی جلو و عقب، بافر عمق و هر بافر دیگری که سختافزار فراهم کرده باشد. این بافرها معمولاً روی کارت گرافیک قرار دارند. بعداً مفهوم Frame Buffer را به بافرهای دیگری که برای رندر خارج از صفحه (Off-Screen) فراهم میشوند گسترش میدهیم. فعلاً فقط با Frame Buffer استاندارد کار میکنیم.
در یک مکان مکانی مشخص، k بیت میتواند شامل رنگ RGBA سیودو بیتی، عدد صحیح نمایندهٔ عمق یا بیتهای Mask باشد. گروههای بیت میتوانند نمایش یکبایتی مؤلفههای رنگ، اعداد صحیح عمق یا اعداد ممیز شناور برای رنگ و عمق را ذخیره کنند. شکل ۷٫۲ Frame Buffer در OpenGL و برخی اجزای آن را نشان میدهد.
اگر کل Frame Buffer را در نظر بگیریم، n و m با وضوح مکانی نمایشگر برابرند. عمق Frame Buffer یعنی k میتواند بیش از چندصد بیت باشد. حتی در حالتهای سادهٔ قبلی، ۶۴ بیت برای بافرهای رنگی جلو و عقب و ۳۲ بیت برای بافر عمق داریم. دقت عددی هر بافر توسط عمق آن تعیین میشود. مثلاً اگر بافرهای رنگی جلو و عقب هرکدام ۳۲ بیت باشند، هر مؤلفهٔ RGBA با دقت ۸ بیت ذخیره میشود.
هنگام کار با Frame Buffer معمولاً هر بار با یک بافر سازنده کار میکنیم. از اینجا به بعد اصطلاح Buffer را برای یک بافر مشخص درون Frame Buffer به کار میبریم. هر بافر ابعاد (n\times m) و عمق k دارد، اما k برای بافرهای مختلف میتواند متفاوت باشد. در بافر رنگی، k به تعداد رنگهای قابل نمایش وابسته است—معمولاً ۲۴ بیت برای RGB و ۳۲ بیت برای RGBA. در بافر عمق، k به دقت عمق قابل پشتیبانی وابسته است و اغلب ۳۲ بیت انتخاب میشود تا با اندازهٔ Float یا Integer متداول سازگار باشد.
اصطلاح Bitplane برای هر یک از k صفحهٔ (n\times m) در یک بافر و اصطلاح Pixel برای تمام k بیت یک مکان مکانی استفاده میشود. با این تعریف، بسته به بافر و روش ذخیرهسازی، یک Pixel میتواند یک Byte، Integer یا حتی Floating-Point Number باشد.
برنامهنویس کاربردی معمولاً نمیداند اطلاعات دقیقاً چگونه در Frame Buffer ذخیره میشود، زیرا این بخش داخل پیادهسازی و از دید او جعبهٔ سیاه است. برنامه با توابع OpenGL اطلاعات را در Frame Buffer مینویسد یا از آن میخواند. هنگام خواندن یا نوشتن پیکسلها، داده نهتنها میان حافظهٔ معمول CPU و حافظهٔ گرافیکی کارت منتقل میشود، بلکه غالباً باید برای سازگاری با قالب داخلی Frame Buffer بازآرایی شود.
در نتیجه، آنچه معمولاً «تصویر دیجیتال» با قالبهایی مانند JPEG، PNG یا TIFF مینامیم در سمت برنامه وجود دارد. برنامهنویس هم باید بداند تصویر خاص چگونه Decode شود تا از طریق OpenGL به Frame Buffer منتقل گردد و هم باید هزینهٔ زمانی جابهجایی حجم داده میان حافظهٔ پردازنده و Frame Buffer را در نظر بگیرد.
شکل ۷٫۲ — Frame Buffer در OpenGL شامل Front Buffer، Back Buffer، Depth Buffer و Stencil Buffer.
اگر برنامهنویس قالب داخلی ذخیرهسازی یک بافر را نیز بشناسد، اغلب میتواند برنامههایی بنویسد که کاراتر اجرا شوند.
۷٫۲ تصاویر دیجیتال
پیش از بررسی کار سامانهٔ گرافیکی با تصاویر دیجیتال از طریق عملیات Pixel و Bit، باید روشن کنیم منظور از تصویر دیجیتال چیست. در برنامهها معمولاً تصویر را به صورت آرایهای از پیکسلها نگه میداریم. اندازه و نوع دادهٔ آرایه به نوع تصویر وابسته است.
برای نمونه، در تصویر RGB معمولاً هر مؤلفهٔ رنگ را با یک Byte در بازهٔ ۰ تا ۲۵۵ نمایش میدهیم. یک تصویر (512\times512) میتواند چنین تعریف شود:
GLubyte myimage[512][512][3];
اگر از نمایش ممیز شناور استفاده کنیم:
typedef vec3 color3;
color3 myimage[512][512];
برای تصویر تکرنگ یا Luminance، هر پیکسل یک سطح خاکستری از سیاه (۰) تا سفید (۲۵۵) است:
GLubyte myimage[512][512];
یک راه ساخت تصویر دیجیتال، تولید آن مستقیماً با کد برنامه است. برای مثال، اگر بخواهیم تصویر (512\times512)ای شامل صفحهٔ شطرنجی (8\times8) از مربعهای قرمز و سیاه متناوب بسازیم، کد زیر نمونهای از این کار است:
color3 check[512][512];
color3 red = color3(1.0, 0.0, 0.0);
color3 black = color3(0.0, 0.0, 0.0);
for ( int i = 0; i < 512; i++)
for( int j = 0; j < 512; j++)
{
check[i][j] = ((8*i+j)/64) % 64 ? red : black;
}
نوشتن کد برای تولید تصویر معمولاً به الگوهای منظم محدود است. اغلب تصویر را مستقیماً از داده میسازیم. برای مثال اگر آرایهای از اعداد حقیقی حاصل آزمایش یا شبیهسازی داشته باشیم، میتوان آن را به بازهٔ ۰ تا ۲۵۵ Scale کرد و به تصویر Luminance از نوع Unsigned Byte تبدیل نمود؛ یا برای تصویر ممیز شناور به بازهٔ ۰٫۰ تا ۱٫۰ نگاشت کرد.
روش سوم که با گسترش اینترنت بسیار رایج شده، دریافت تصویر از منابع خارجی است. تصاویر ممکن است با Scan کردن تصویر پیوسته مانند عکس ایجاد شوند یا مستقیماً توسط دوربین دیجیتال تولید گردند. هر تصویر در یکی از قالبهای استاندارد متعدد ذخیره میشود؛ از جمله GIF، TIFF، PNG، PDF و JPEG. این قالبها میتوانند داده را مستقیم، بهصورت فشردهٔ Lossless یا فشردهٔ Lossy کد کنند و هر کدام از نیازهای تاریخی نوعی کاربرد شکل گرفتهاند.
برای مثال، تصویر PostScript (PS) توسط زبان PostScript مورد استفاده برای کنترل چاپگرها تعریف میشود. این قالب دادهٔ تصویر RGB یا Luminance را بهصورت دقیق در مجموعهنویسهٔ ASCII هفتبیتی کد میکند. در نتیجه توسط طیف وسیعی از چاپگرها و دستگاهها قابل فهم است اما فایل بسیار بزرگ میشود. Encapsulated PostScript (EPS) مشابه است ولی اطلاعات اضافی برای Preview تصویر دارد. تصویر GIF از نوع Color Index است و بنابراین جدول رنگ همراه آرایهای از اندیسهای تصویر ذخیره میکند.
TIFF میتواند دو شکل داشته باشد. در شکل نخست، تمام دادهٔ تصویر مستقیماً کد میشود و Header نحوهٔ آرایش دادهها را توضیح میدهد. در شکل دوم داده فشرده میشود. فشردهسازی ممکن است زیرا بیشتر تصاویر Redundancy زیادی دارند؛ برای مثال، نواحی وسیع بسیاری از تصاویر تغییر رنگ یا شدت اندکی دارند. الگوریتمهای فشردهسازی این افزونگی را حذف میکنند تا نسخهای کوچکتر تولید شود.
TIFF فشرده میتواند از الگوریتم Lempel–Ziv برای فشردهسازی Lossless استفاده کند؛ یعنی تصویر اصلی پس از Decode دقیقاً بازیابی میشود. JPEG از الگوریتمی Lossy استفاده میکند که خطاهای کوچکی را در فشردهسازی و بازسازی میپذیرد. در نتیجه JPEG نسبت فشردهسازی بسیار بالایی با اعوجاج بصری کم یا حتی نامحسوس به دست میآورد. نسبت فشردهسازی، نسبت تعداد بیتهای فایل اصلی به فایل فشرده است.
شکل ۷٫۳ — سه نسخه از یک تصویر Luminance: (الف) TIFF اصلی؛ (ب) JPEG با فشردهسازی حدود ۱۸ برابر؛ (ج) JPEG با فشردهسازی حدود ۳۷ برابر.
برای یک تصویر (1200\times1200)، اندازهٔ فایلهای نمونه بهترتیب 1,440,198، 80,109 و 38,962 بایت است. TIFF تقریباً برای هر پیکسل یک Byte بهعلاوه ۱۹۸ بایت Header و Trailer دارد. نسبت فشردهسازی دو JPEG تقریباً ۱۸ و ۳۷ است و حتی در نسبت بالاتر، اعوجاج قابل مشاهده اندک است.
اگر تصویر اصلی به PostScript ذخیره شود، فایل تقریباً دو برابر TIFF خواهد شد، زیرا هر Byte به دو نویسهٔ ASCII هفتبیتی تبدیل میشود و هر جفت دو Byte فضا میگیرد. در TIFF فشرده، ممکن است تنها حدود نیمی از فضای تصویر خام لازم باشد. استفاده از ZIP برای فایل مشابه نیز نتیجهای هممرتبه میدهد. مقدار واقعی فشردهسازی به محتوای تصویر وابسته است. با اینکه فشردهسازی Lossless تصویر را دقیقاً بازسازی میکند، نسبت فشردهسازی آن معمولاً بسیار کمتر از JPEG Lossy است.
این تفاوت توضیح میدهد چرا JPEG برای ارسال تصویر در اینترنت محبوب شده است: نسبت فشردهسازی Lossy بسیار بالاست در حالی که نسخهٔ بازسازیشده غالباً از نظر بصری تقریباً از اصل قابل تشخیص نیست. بیشتر دوربینهای دیجیتال تصویر را در قالب JPEG و RAW تولید میکنند. قالب RAW دادهٔ RGB پردازشنشده را همراه مقدار زیادی اطلاعات Header مانند تاریخ، وضوح و توزیع رنگها نگه میدارد.
تعداد زیاد قالبهای تصویر برای یک API گرافیکی مشکل ایجاد میکند. برخی قالبها ساده و برخی بسیار پیچیدهاند. OpenGL با پشتیبانی از بلوکهای پیکسلی به جای فایلهای تصویری قالببندیشده، این مشکل را دور میزند. بیشتر قالبهای پیکسلی OpenGL با قالبهای داخلی حافظه متناظرند و در تعداد بیت هر مؤلفهٔ رنگ و ترتیب مؤلفهها در حافظه تفاوت دارند. پشتیبانی محدودی نیز برای بافتهای فشرده وجود دارد، اما OpenGL مستقیماً قالبهای فایل استانداردی مانند JPEG را Decode نمیکند.
بنابراین OpenGL میتواند با تصویرهایی که در حافظه به صورت آرایهٔ انواع دادهٔ استاندارد موجودند کار کند، ولی خواندن فایل قالببندیشده به حافظهٔ CPU و نوشتن دوبارهٔ آن در فایل بر عهدهٔ برنامهٔ کاربردی است. جزئیات Decode و Encode قالبهای مختلف خارج از دامنهٔ این فصل است و منابع پیشنهادی اطلاعات لازم را ارائه میکنند.
همچنین میتوان تصویر دیجیتال را مستقیماً از خود سامانهٔ گرافیکی به دست آورد: صحنهٔ سهبعدی را با خط لولهٔ هندسی رندر میکنیم و سپس پیکسلهای حاصل را بازمیخوانیم. روش انجام این عملیات در ادامهٔ فصل بررسی میشود.
۷٫۳ نوشتن در بافرها
در سامانهٔ گرافیکی مدرن، برنامهٔ کاربر هم میتواند در بافرها بنویسد و هم از آنها بخواند. دو عامل این عملیات را از خواندن و نوشتن معمول حافظه متفاوت میکند. نخست، بهندرت فقط یک Pixel یا Bit منفرد را میخوانیم یا مینویسیم؛ معمولاً با بلوکهای مستطیلی پیکسل یا بیت، موسوم به Bit Block، کار میکنیم. برای مثال هنگام Fill چندضلعی یک Scanline کامل رسترسازی میشود، هنگام نمایش نویسهٔ رستری یک بلوک کوچک پیکسل نوشته میشود و در عملیات Clear تمام پیکسلهای یک بافر تغییر میکنند.
بنابراین مهم است سختافزار و نرمافزار عملیات مستطیلی Bit-Block Transfer یا BitBlt را تا حد ممکن کارآمد پشتیبانی کنند. این عملیات با نام Raster Operations یا Raster-Ops نیز شناخته میشوند.
فرض کنید بخواهیم بلوک (n\times m) پیکسل را از یک بافر مبدأ به همان بافر یا بافر مقصد دیگری کپی کنیم. شکل ۷٫۴ این انتقال را نشان میدهد. شکل مفهومی یک عملیات BitBlt چنین است:
write_block(source, n, m, x, y, destination, u, v);
که بلوک مبدأ با گوشهٔ پایین-چپ ((x,y)) را به مقصد از موقعیت ((u,v)) منتقل میکند.
شکل ۷٫۴ — نوشتن یک بلوک پیکسلی از مبدأ به مقصد.
جزئیات متعددی مانند عبور بخشی از بلوک از مرز مقصد باید مدیریت شود، اما اصل BitBlt این است که یک فراخوانی تابع میتواند کل بلوک مقصد را تغییر دهد. از دید سختافزار، نوع پردازش BitBlt تقریباً هیچ شباهتی به پردازش اشیای هندسی ندارد. در نتیجه سختافزار بهینهشده برای BitBlt معماری متفاوتی از خط لولهٔ هندسی دارد و معماری OpenGL معمولاً شامل دو مسیر جداگانهٔ Geometry Pipeline و Pixel Pipeline است.
۷٫۳٫۱ حالتهای نوشتن
تفاوت دوم نوشتن در بافر با نوشتن معمول حافظه، تنوع روشهای ترکیب دادهٔ ورودی با محتوای مقصد است. OpenGL شانزده Writing Mode منطقی را برای قراردادن دادهٔ پیکسلی در بافر پشتیبانی میکند.
در نوشتن معمول حافظه، مفهوم اصلی جایگزینی است. اجرای عبارت C زیر:
باعث میشود مقدار محل y با مقدار x جایگزین شود.
اما امکانهای دیگری نیز داریم. فرض کنید در بافر بتوانیم بیتبهبیت کار کنیم. در مدل شکل ۷٫۵، بیتی که میخواهیم—شاید پس از تغییر—در حافظه قرار دهیم Source Bit یا s و بیت موجود در محل مقصد Destination Bit یا d است. اگر مانند فصل ۳ پیش از نوشتن اجازهٔ خواندن مقصد داشته باشیم، نوشتن را میتوان با تابع جایگزینی f توصیف کرد:
d\leftarrow f(d,s).
برای مبدأ و مقصد یکبیتی دقیقاً ۱۶ تابع بولی ممکن بین دو بیت وجود دارد. شکل ۷٫۶ این شانزده حالت را نشان میدهد و هر ستون یک Writing Mode را تعریف میکند.
شکل ۷٫۵ — مدل نوشتن با خواندن بیت مقصد و ترکیب آن با بیت مبدأ.
شکل ۷٫۶ — شانزده Writing Mode منطقی دو بیت.
میتوان هر حالت را با عدد دودویی ستون یا با عملیات منطقی متناظر نامگذاری کرد. فرض کنید مقدار منطقی ۱ رنگ پسزمینه—مثلاً سفید—و ۰ رنگ پیشزمینه—مثلاً سیاه—باشد. Modeهای ۰ و ۱۵ عملیات Clear هستند که مقصد را بدون توجه به مقدار مبدأ و مقصد به یکی از دو رنگ ثابت تبدیل میکنند.
Modeهای ۳ و ۷ نمونههای رایجاند. Mode 3 تابع جایگزینی است:
d\leftarrow s.
و مقدار مقصد را مستقیماً با مبدأ جایگزین میکند. Mode 7 عمل OR منطقی است:
d\leftarrow s+d.
شکل ۷٫۷ نشان میدهد این دو حالت میتوانند روی Frame Buffer اثر متفاوتی داشته باشند. در مثال، خطچینی روی بافری نوشته میشود که مستطیلی سیاه از قبل در آن وجود دارد. هر دو حالت Foreground را روی Background مینویسند، اما هنگام نوشتن Background روی Foreground رفتار متفاوت دارند. انتخاب حالت به اثری بستگی دارد که برنامهنویس میخواهد ایجاد کند.
شکل ۷٫۷ — تفاوت نوشتن در Mode 3 و Mode 7.
۷٫۳٫۲ نوشتن با XOR
Mode 6 عملیات Exclusive OR یا XOR است که با (\oplus) نشان داده میشود و جالبترین Writing Mode به شمار میرود. برخلاف Modeهای ۳ و ۷، Mode 6 بدون خواندن بیت مقصد قابل پیادهسازی نیست. قدرت XOR از رابطهٔ زیر میآید:
d=(d\oplus s)\oplus s.
یعنی اگر XOR با یک مقدار ثابت دو بار روی یک بیت اعمال شود، بیت به حالت اولیه بازمیگردد.
کاربردهای مهم این ویژگی در تعامل کاربر دیده میشود. منوی برنامهای تعاملی را در نظر بگیرید که پس از کلیک ماوس روی بخشی از صفحه ظاهر میشود و پس از انتخاب دستور ناپدید میگردد، در حالی که ناحیهٔ زیر آن باید دقیقاً به حالت قبلی برگردد. این فرایند از حافظهٔ خارج از صفحه یا Backing Store استفاده میکند.
فرض کنید منو خارج از صفحه بهصورت آرایهٔ بیت M ذخیره شده و ناحیهٔ صفحهای که منو روی آن ظاهر میشود آرایهٔ S باشد. سه عملیات زیر را روی بیتهای متناظر اعمال میکنیم:
S\leftarrow S\oplus M,
M\leftarrow S\oplus M,
S\leftarrow S\oplus M.
با جایگذاری نتایج میبینیم پس از سه عملیات، منو روی صفحه قرار گرفته و محتوای اصلی ناحیهٔ صفحه در محل خارج از صفحهٔ منو ذخیره شده است؛ یعنی M و S بدون حافظهٔ موقت اضافی با سه BitBlt جابهجا شدهاند. این با Swap معمول مبتنی بر Replacement فرق دارد که به فضای موقت نیاز دارد.
گونههای متعددی از این تکنیک وجود دارد؛ از جمله حرکت Cursor روی صفحه بدون خرابکردن محتوای زیر آن و برخی روشهای Fill چندضلعی. چون بعضی APIها ریشه در دوران پیش از غالبشدن نمایشگرهای رستری دارند، XOR همیشه در همهٔ آنها موجود نبوده است.
در OpenGL حالت استاندارد نوشتن در Frame Buffer همان Mode 3 یا Copy است. میتوان عملیات منطقی رنگ را با کد زیر فعال کرد:
glLogicOp(mode);
glEnable(GL_COLOR_LOGIC_OP);
که mode میتواند یکی از شانزده حالت باشد. حالتهای رایج GL_COPY (پیشفرض) و GL_XOR هستند.
۷٫۴ روشهای نگاشت
یکی از قدرتمندترین کاربردهای دادهٔ گسسته، رندر سطح است. مدلکردن شیء فقط با مجموعهای از بدویهای هندسی و سپس رندر همان بدویها محدودیت دارد. مثلاً ساخت یک پرتقال مجازی را در نظر بگیرید. نخست ممکن است از یک کره شروع کنیم. طبق فصل ۵ میتوان کره را با مثلثها تقریب زد و این مثلثها را با ویژگیهای مادهای مشابه پوست پرتقال رندر کرد. نتیجه با این حال بیش از حد منظم است و شبیه پرتقال واقعی به نظر نمیرسد.
میتوان مانند فصل ۱۰ از سطح منحنی پیچیدهتری استفاده کرد و شکل کلی را دقیقتر کرد، اما باز هم جزئیات ریز پوست واقعی از دست میرود. اگر بخواهیم این جزئیات را با افزودن تعداد بسیار زیاد چندضلعیها مدل کنیم، حتی سختافزاری با توان رندر دهها میلیون چندضلعی در ثانیه نیز میتواند اشباع شود.
راه جایگزین این است که مدل هندسی را ساده نگه داریم و جزئیات را هنگام رندر اضافه کنیم. همانطور که در فصل ۶ دیدیم، رسترایزر از سطح—چه چندضلعی و چه سطح منحنی—قطعههایی تولید میکند که هرکدام به پیکسلی در Frame Buffer مربوطاند. Fragmentها رنگ، عمق و اطلاعات دیگری دارند و در رسترسازی باید Shade یا Color مناسب برای هر Fragment تعیین شود.
در فصل ۵ با مدل Modified Phong رنگ رأسها را محاسبه و روی سطح درونیابی کردیم. اما این رنگها میتوانند پس از رسترسازی و در Fragment Processing تغییر داده شوند. الگوریتمهای Mapping را میتوان یا روشی برای اصلاح الگوریتم سایهزنی با استفاده از آرایهای دوبعدی—Map—دانست، یا روشی که Map را برای تغییر پارامترهای سطح مانند Material Properties و Normalها استفاده میکند. سه تکنیک اصلی وجود دارد:
سه تکنیک اصلی عبارتاند از:
- Texture Mapping
- Bump Mapping
- Environment Mapping
Texture Mapping از یک تصویر یا Texture برای تأثیرگذاری بر رنگ Fragment استفاده میکند. بافت میتواند الگوی ثابت و منظم، بافتی تولیدشده با روش Procedural یا تصویر دیجیتالیشده باشد. در همهٔ موارد، نتیجه را میتوان نگاشت یک Texture روی سطح در جریان رندر همان سطح دانست، همانند شکل ۷٫۸.
شکل ۷٫۸ — نگاشت یک الگوی Texture روی سطح.
Texture Map جزئیات را با «نقاشیکردن» الگو روی سطح هندسی صاف ایجاد میکند. در مقابل، Bump Map بردارهای Normal را هنگام سایهزنی تغییر میدهد تا سطح دارای تغییرات کوچک هندسی مانند برجستگیهای پوست پرتقال به نظر برسد. Reflection Map یا Environment Map نیز ظاهر مواد بازتابنده را بدون نیاز به رهگیری پرتوهای بازتابی ایجاد میکند؛ تصویر محیط هنگام رندر روی سطح قرار میگیرد.
این سه روش اشتراکهای زیادی دارند: هر سه در Fragment Processing سایهزنی Fragment منفرد را تغییر میدهند؛ هر سه بر Map ذخیرهشده به صورت تصویر دیجیتال یک، دو یا سهبعدی تکیه دارند؛ و هر سه با حفظ پیچیدگی هندسی پایین، توهم هندسهٔ پیچیده را ایجاد میکنند. با این حال، هر سه در معرض خطاهای Aliasing نیز هستند.
نمونههای متنوعی از نگاشت دوبعدی در تصاویر رنگی کتاب وجود دارد. Environment Mapping میتواند تنها با یک Texture Map توهم سطح بسیار بازتابنده را ایجاد کند و از محاسبات سراسری بازتاب بپرهیزد. در واقعیت مجازی، شبیهسازی، مصورسازی و بازی تعاملی، کارایی بلادرنگ ضروری است و پشتیبانی سختافزاری Texture Mapping جزئیات بصری را با افت اندک سرعت رندر اضافه میکند.
با این حال در خط لولهٔ استاندارد، سه روش دقیقاً یکسان نیستند. Texture Mapping استاندارد توسط خط لولهٔ پایهٔ OpenGL پشتیبانی میشود و از هر دو Geometry Pipeline و Pixel Pipeline بهره میبرد. Environment Map حالت ویژهای از Texture Mapping است و با تغییر Fragment Processing میتوان آن را برای اثرهای دیگر گسترش داد.
Bump Mapping نیازمند پردازش مستقل هر Fragment است و بنابراین بهطور طبیعی با Fragment Shader پیادهسازی میشود.
۷٫۵ نگاشت بافت
Textureها الگو هستند؛ از الگوهای منظم ساده مانند نوار و شطرنج تا الگوهای پیچیدهای که مواد طبیعی را مشخص میکنند. در جهان واقعی، اشیایی با شکل و اندازهٔ مشابه را اغلب از روی Texture آنها از هم تشخیص میدهیم. بنابراین برای ساخت اشیای مجازی پرجزئیات میتوان بافت را روی هندسهٔ ساختهشده نگاشت کرد.
Texture میتواند یک، دو، سه یا چهار بعدی باشد. یک بافت یکبعدی ممکن است برای رنگآمیزی منحنی استفاده شود. بافت سهبعدی میتواند یک بلوک جامد ماده را توصیف کند که از درون آن شیء حجاری میشود. چون سطح در گرافیک اهمیت بنیادی دارد، نگاشت Texture دوبعدی روی سطح رایجترین کاربرد است و در این فصل تمرکز اصلی بر آن خواهد بود. با وجود این، منطق نگاشت در ابعاد مختلف تا حد زیادی مشابه است.
۷٫۵٫۱ نگاشت بافت دوبعدی
رویکردهای Texture Mapping متعددند، اما همگی به دنبالهای از نگاشتها میان سه یا چهار دستگاه مختصات نیاز دارند. در مراحل مختلف با این فضاها کار میکنیم:
- Screen Coordinates، جایی که تصویر نهایی ساخته میشود؛
- Object Coordinates، جایی که شیء هندسی تعریف میشود؛
- Texture Coordinates، برای مکانیابی داخل Texture؛
- Parametric Coordinates، برای تعریف سطوح پارامتری، بهویژه سطوح منحنی.
روش دقیق به نوع سطح و معماری رندر بستگی دارد. ابتدا بحثی عمومی دربارهٔ Mapها و نگاشتهای لازم داریم، سپس میبینیم معماری خط لولهای بلادرنگ مانند OpenGL چگونه Texture Mapping را انجام میدهد.
در بیشتر کاربردها Texture در آغاز یک تصویر دوبعدی از نوع بخش ۷٫۲ است. ممکن است توسط برنامه تولید یا از عکس Scan شده باشد، اما در نهایت به صورت آرایهای در حافظهٔ پردازنده قرار میگیرد. عناصر این آرایه را برای تأکید بر نقششان Texel یا Texture Element مینامیم، نه Pixel. از نظر مفهومی، در این مرحله بهتر است آرایه را الگوی پیوستهٔ مستطیلی (T(s,t)) در نظر بگیریم. متغیرهای مستقل s و t Texture Coordinates هستند. بدون از دست دادن کلیت، میتوان آنها را به بازهٔ ([0,1]) Scale کرد. در حالت چهاربعدی مختصات Texture در فضای ((s,t,r,q)) قرار میگیرد.
Texture Map برای هر نقطه روی یک شیء هندسی، Texel متناظری تعیین میکند و خود شیء برای نمایش به مختصات صفحه نگاشت میشود. اگر شیء در مختصات همگن ((x,y,z,w)) نمایش داده شود، از نظر مفهومی توابعی وجود دارند:
x=x(s,t),\quad y=y(s,t),\quad z=z(s,t),\quad w=w(s,t).
یکی از دشواریهای اصلی این است که گرچه چنین توابعی از نظر مفهومی وجود دارند، یافتن شکل صریح آنها همیشه ممکن نیست. افزون بر این، اغلب به مسئلهٔ معکوس نیاز داریم: اگر نقطهٔ ((x,y,z)) یا ((x,y,z,w)) روی شیء داده شده باشد، Texture Coordinates متناظر چگونه به دست میآیند؟ یعنی چگونه توابع معکوس
s=s(x,y,z,w),\qquad t=t(x,y,z,w)
را برای یافتن Texel (T(s,t)) به دست آوریم؟
اگر شیء هندسی با سطح پارامتری ((u,v)) تعریف شده باشد—مانند کرهٔ بخش ۵٫۶—نگاشت دیگری نیز داریم که مختصات شیء را بر حسب u و v میدهد. این نگاشت برای سطوح سادهای مانند کره و مثلث و سطوح فصل ۱۰ معلوم است، اما هنوز باید نگاشت میان مختصات پارامتری ((u,v)) و مختصات Texture ((s,t))، و گاهی نگاشت معکوس آنها، مشخص شود.
همچنین فرایند Projection از Object Coordinates به Screen Coordinates را داریم که در مسیر خود از Eye، Clip و Window Coordinates عبور میکند. میتوان کل این فرایند را در تابعی مجرد کرد که یک جفت ((s,t)) را به مکانی در Color Buffer میبرد؛ یعنی:
x_s=x_s(s,t),\qquad y_s=y_s(s,t).
بسته به الگوریتم و معماری رندر، ممکن است تابع معکوس نیز لازم باشد که برای یک Pixel در Color Buffer مشخص کند کدام Texel باید در رنگ آن سهم داشته باشد.
یک دیدگاه مفید این است که Texture Mapping را متشکل از دو نگاشت همزمان بدانیم: یکی از Texture Coordinates به Object Coordinates و دیگری از Parametric Coordinates به Object Coordinates؛ سپس نگاشت سوم Object Coordinates را به Screen Coordinates میبرد (شکل ۷٫۹).
شکل ۷٫۹ — نگاشتهای لازم برای Texture Mapping یک سطح پارامتری.
از نظر مفهومی، Texture Mapping ساده است. ناحیهای کوچک از الگوی Texture به ناحیهای از سطح هندسی نگاشت میشود که در تصویر نهایی با یک Pixel متناظر است. اگر T مقادیر RGB باشد، میتوان آن را برای اصلاح رنگ سطحی که مدل نورپردازی محاسبه کرده به کار برد، یا رنگ سطح را فقط از Texture تعیین کرد. این تخصیص رنگ در مرحلهٔ تعیین رنگ Fragment انجام میشود.
اما با نگاه دقیقتر چند دشواری پدید میآید. نخست، باید نگاشت Texture Coordinates به Object Coordinates مشخص شود. Texture دوبعدی معمولاً روی ناحیهای مستطیلی در Texture Space تعریف میشود، ولی نگاشت این مستطیل به ناحیهٔ دلخواه در فضای سهبعدی میتواند پیچیده یا دارای اعوجاج نامطلوب باشد. مثلاً مستطیل را نمیتوان بدون اعوجاج شکل و فاصله روی کره نگاشت کرد.
دوم، به دلیل ماهیت Pixel-by-Pixel رندر، در عمل بیشتر به نگاشت معکوس از Screen Coordinates به Texture Coordinates علاقه داریم. هنگام تعیین Shade یک Pixel است که باید معلوم کنیم کدام نقطهٔ تصویر Texture استفاده شود؛ بنابراین باید از صفحه به Texture برگردیم.
سوم، چون هر Pixel یک مستطیل کوچک روی نمایشگر است، مسئلهٔ واقعی نگاشت نقطهبهنقطه نیست، بلکه نگاشت ناحیهبهناحیه است. اینجا دوباره مسئلهٔ Aliasing مطرح میشود و اگر با دقت مدیریت نشود میتواند Artifactهایی مانند موجهای سینوسی کاذب یا الگوهای Moiré ایجاد کند.
شکل ۷٫۱۰ بخشی از این دشواری را نشان میدهد. فرض کنید میخواهیم رنگ Pixel مربعی با مرکز ((x_s,y_s)) را محاسبه کنیم. مرکز Pixel به نقطهای ((x,y,z)) در Object Space متناظر است، اما اگر شیء منحنی باشد، Back Projection گوشههای Pixel به Object Space ناحیهای منحنی میسازد.
شکل ۷٫۱۰ — Preimage یک Pixel در Object Space و Texture Space.
در Texture Image یعنی (T(s,t))، Back Projection پیکسل ناحیهای را میسازد که در حالت ایدهآل باید در Shade همان Pixel سهم داشته باشد.
فعلاً مسئلهٔ یافتن Inverse Map را کنار بگذاریم و فقط تعیین رنگ را بررسی کنیم. سادهترین روش این است که فقط Back Projection مرکز Pixel را بگیریم و از همان نقطه یک مقدار Texture نمونهبرداری کنیم. این روش ساده است اما میتواند Aliasing شدید ایجاد کند، بهویژه اگر Texture تناوبی باشد.
شکل ۷٫۱۱ این مشکل را نشان میدهد. Texture تکرارشونده روی سطحی تخت قرار دارد و Back Projection مرکز هر Pixel به طور اتفاقی میان خطوط تیره میافتد، بنابراین همیشه رنگ روشن نمونهبرداری میشود. بهطور کلی، نادیدهگرفتن اندازهٔ محدود Pixel میتواند الگوهای Moiré ایجاد کند.
شکل ۷٫۱۱ — Aliasing ناشی از نمونهبرداری نقطهای Texture.
راهبرد بهتر—ولی دشوارتر—این است که مقدار Texture را از میانگینگیری Map روی کل Preimage پیکسل به دست آوریم. این روش نیز کامل نیست؛ در مثال شکل ۷٫۱۱ ممکن است فقط سایهای میانگین ایجاد کند و باز هم نوارهای واقعی Texture را نشان ندهد. در نهایت، وضوح محدود Frame Buffer و Texture Map هر دو محدودیت بنیادی ایجاد میکنند و مشکل وقتی شدیدتر است که Texture مؤلفههای تناوبی با فرکانس بالا داشته باشد.
اکنون میتوانیم به مسئلهٔ Mapping بازگردیم. در گرافیک رایانهای، بیشتر سطوح منحنی به صورت پارامتری نمایش داده میشوند. نقطهٔ p روی سطح تابعی از دو پارامتر است و این نمایش پایهٔ ادامهٔ بحث خواهد بود.
برای هر جفت پارامتر u و v، نقطهٔ سطح پارامتری تولید میشود:
p(u,v)=\begin{bmatrix}x(u,v)\\y(u,v)\\z(u,v)\end{bmatrix}.
استخراج چنین سطوحی در فصل ۱۰ بررسی میشود. اگر سطح پارامتری در اختیار داشته باشیم، اغلب میتوان نقطهای از Texture Map یعنی (T(s,t)) را با نگاشت خطی به نقطهای روی سطح (p(u,v)) مرتبط کرد:
u=as+bt+c,
v=ds+et+f.
تا زمانی که (ae\ne bd)، این نگاشت معکوسپذیر است. نگاشت خطی، اعمال Texture روی مجموعهای از Patchهای سطح پارامتری را ساده میکند. در شکل ۷٫۱۲، Patch با گوشههای ((s_{min},t_{min})) و ((s_{max},t_{max})) به Patch سطحی با گوشههای ((u_{min},v_{min})) و ((u_{max},v_{max})) متناظر است و نگاشت میتواند به صورت زیر نوشته شود:
u=u_{min}+\frac{s-s_{min}}{s_{max}-s_{min}}(u_{max}-u_{min}),
v=v_{min}+\frac{t-t_{min}}{t_{max}-t_{min}}(v_{max}-v_{min}).
شکل ۷٫۱۲ — Texture Mapping خطی میان ناحیهٔ Texture و ناحیهٔ سطح پارامتری.
این نگاشت ساده است، اما انحنای سطح را در نظر نمیگیرد؛ Patchهای هماندازهٔ Texture ممکن است برای پوشاندن سطح به میزانهای متفاوت کشیده شوند.
رویکرد دیگر، نگاشت دومرحلهای است. مرحلهٔ نخست Texture را روی یک سطح میانی سهبعدی ساده مانند کره، استوانه یا مکعب قرار میدهد. در مرحلهٔ دوم، سطح میانیِ دارای Texture به سطح واقعی در حال رندر نگاشت میشود. این فرایند هم برای سطوح تعریفشده در مختصات هندسی و هم پارامتری قابل استفاده است.
فرض کنید Texture Coordinates روی مربع واحد باشند و سطح جانبی استوانهای با ارتفاع h و شعاع r را بهعنوان شیء میانی انتخاب کنیم.
شکل ۷٫۱۳ — Texture Mapping با استفاده از استوانهٔ میانی.
نقاط روی استوانه با معادلات پارامتری زیر داده میشوند:
x=r\cos(2\pi u),
y=r\sin(2\pi u),
z=v/h,
که u و v در ((0,1)) تغییر میکنند. بنابراین میتوان نگاشت سادهٔ
s=u,\qquad t=v
را استفاده کرد. چون فقط سطح جانبی استوانه را استفاده کردهایم، Texture بدون اعوجاج شکل دور آن پیچیده میشود. اما برای شیء بستهای مانند کره نمیتوان از اعوجاج اجتناب کرد. این مسئله شبیه ساخت نقشهٔ دوبعدی از سطح زمین است؛ همهٔ نقشهها به شکلی فاصله یا شکل را تحریف میکنند و انتخاب Projection تعیین میکند اعوجاج کجا متمرکز شود. برای نمونه، Mercator بیشترین اعوجاج را نزدیک قطبها دارد.
اگر کرهای با شعاع r سطح میانی باشد، یک نگاشت ممکن چنین است:
x=r\cos(2\pi u),
y=r\sin(2\pi u)\cos(2\pi v),
z=r\sin(2\pi u)\sin(2\pi v).
همچنین میتوان از جعبهٔ مستطیلی استفاده کرد، مانند شکل ۷٫۱۴. Texture روی جعبهای قرار میگیرد که مانند کارتن بستهبندی میتوان آن را باز و تخت کرد. این روش در Environment Mapping کاربرد زیادی دارد.
مرحلهٔ دوم، انتقال مقادیر Texture از شیء میانی به سطح مطلوب است. شکل ۷٫۱۵ سه راهبرد را نشان میدهد. در روش نخست، از نقطهای روی سطح میانی در جهت Normal حرکت میکنیم تا سطح شیء را قطع کنیم و مقدار Texture را در نقطهٔ تقاطع قرار دهیم. در روش دوم، مسیر را برعکس میکنیم: از نقطهای روی سطح شیء در جهت Normal همان نقطه حرکت میکنیم تا سطح میانی را قطع و Texture را نمونهبرداری کنیم. روش سوم، در صورت معلومبودن مرکز شیء، خطی از مرکز از میان نقطهٔ سطح رسم میکند و تقاطع این خط با سطح میانی را مییابد؛ Texture آن نقطه به سطح واقعی اختصاص مییابد.
شکل ۷٫۱۴ — Texture Mapping با جعبهٔ میانی و نمایش بازشدهٔ وجههای آن.
شکل ۷٫۱۵ — سه روش نگاشت دوم: (الف) استفاده از Normal سطح میانی؛ (ب) Normal سطح شیء؛ (ج) خط از مرکز شیء.
۷٫۶ Texture Mapping در OpenGL
OpenGL گزینههای متنوعی برای Texture Mapping دارد. نسخههای اولیه امکان نگاشت Textureهای یک و دوبعدی روی اشیای گرافیکی با ابعاد مختلف را فراهم میکردند و بعدها Texture سهبعدی نیز به بخش استاندارد و پشتیبانی سختافزاری معمول تبدیل شد. تمرکز ما نگاشت Texture دوبعدی روی سطح است.
Texture Mapping در OpenGL به معماری خط لولهای آن متکی است. دو خط لولهٔ موازی—Geometry Pipeline و Pixel Pipeline—در مرحلهٔ Fragment Processing پس از رسترسازی به هم میرسند، همانگونه که شکل ۷٫۱۶ نشان میدهد. این معماری مشخص میکند Texture Mapping چگونه پشتیبانی شود. در عمل، Texture Mapping بخشی از Fragment Processing است و هر Fragment پس از آن با z-Buffer برای دیدپذیری آزمون میشود.
میتوان Texture Mapping را بخشی از Shading دانست که Fragment-by-Fragment اجرا میشود. Texture Coordinates تقریباً مانند Normalها و Colorها مدیریت میشوند: به رأسها نسبت داده میشوند و سپس مقدار مورد نیاز Texture برای Fragmentها با درونیابی Texture Coordinates روی چندضلعی به دست میآید.
شکل ۷٫۱۶ — ادغام Pixel Pipeline و Geometry Pipeline در Fragment Processing.
۷٫۶٫۱ Texture Mapping دوبعدی
Texture Mapping نیازمند تعامل برنامهٔ کاربردی، Vertex Shader و Fragment Shader است. سه گام پایه دارد:
- ایجاد Texture Image و قراردادن آن در Texture Memory روی GPU؛
- اختصاص Texture Coordinates به هر Fragment؛
- اعمال Texture به هر Fragment.
هر گام را میتوان به روشهای متنوع انجام داد و پارامترهای بسیاری برای کنترل رفتار وجود دارند. با افزایش اهمیت Texture Mapping و تکامل GPUها، APIها نیز توابع بیشتری برای مدیریت Texture افزودهاند.
۷٫۶٫۲ Texture Objectها
در نسخههای قدیمی OpenGL فقط یک Texture فعال یا Current Texture وجود داشت. هر بار که Texture دیگری لازم بود—مثلاً برای سطوح مختلف یک صحنه—باید Texture Map جدیدی تنظیم و در Texture Memory بارگذاری میشد و Texelهای قبلی را جایگزین میکرد. این روش بسیار ناکارآمد بود.
همانطور که Program Objectهای متعدد داریم، Texture Object نیز به برنامه اجازه میدهد آرایهٔ Texture و پارامترهای کنترلکنندهٔ اعمال آن را در قالب یک شیء تعریف کند. تا زمانی که حافظه کافی باشد این اشیا در Texture Memory باقی میمانند.
برای ساخت Texture Object دوبعدی ابتدا شناسههای استفادهنشده را با glGenTextures میگیریم. برای یک Texture:
GLuint mytex[1];
glGenTextures(1, mytex);
سپس با glBindTexture ساخت یا فعالکردن Texture Object را آغاز میکنیم:
glBindTexture(GL_TEXTURE_2D, mytex[0]);
فراخوانیهای بعدی Texture Image و پارامترهای آن را تعیین میکنند و همه بخشی از همان Texture Object میشوند. فراخوانی glBindTexture با نام جدید، شیء جدیدی را آغاز میکند و با نام موجود، همان شیء را Current Texture Object میسازد. Texture Objectهای بدون استفاده را میتوان با glDeleteTextures حذف کرد.
۷٫۶٫۳ آرایهٔ Texture
Texture Mapping دوبعدی با آرایهای از Texelها، یعنی یک مستطیل پیکسلی دوبعدی، آغاز میشود. فرض کنید تصویر (512\times512)ای داریم:
GLubyte my_texels[512][512][3];
پس از glBindTexture آن را چنین بهعنوان Texture دوبعدی تعریف میکنیم:
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, 512, 512, 0,
GL_RGB, GL_UNSIGNED_BYTE, my_texels);
فرم عمومی تابع:
glTexImage2D(GLenum target, GLint level, GLint iformat,
GLsizei width, GLsizei height, GLint border, GLenum format,
GLenum type, GLvoid *tarray)
پارامتر target نوع Texture را مشخص میکند، از جمله تصویر منفرد یا Cube Map. پارامتر level برای Mipmapping استفاده میشود و مقدار ۰ بالاترین وضوح یا حالت بدون Mipmap است. iformat نحوهٔ ذخیرهٔ Texture در Texture Memory را تعیین میکند. width و height اندازهٔ تصویر حافظهاند. border دیگر استفاده نمیشود و باید صفر باشد. format و type توضیح میدهند پیکسلهای تصویر در حافظهٔ CPU چگونه ذخیره شدهاند تا OpenGL آنها را درست بخواند و در Texture Memory قرار دهد.
۷٫۶٫۴ Texture Coordinates و Samplerها
عنصر کلیدی در Fragment Shader، نگاشت میان مکان Fragment و مکان متناظر در Texture Image است که رنگ Texture از آن نمونهبرداری میشود. چون هر Fragment دارای Texture Coordinates درونیابیشده است، باید این مختصات به Sampler و Texture Object فعال متصل شوند.
شکل ۷٫۱۷ — نگاشت به مختصات بافت.
از آنجا که مکان Fragment در Frame Buffer یکی از ویژگیهای آن است، لازم نیست در Fragment Shader صریحاً به این مکان اشاره کنیم. دشواری بالقوه، تشخیص مکان مطلوب در Texture Image است. در بسیاری از کاربردها میتوان این مکان را از یک مدل ریاضیِ اشیا محاسبه کرد. در کاربردهای دیگر ممکن است نوعی تقریب به کار رود. OpenGL هیچ روش ترجیحیای تحمیل نمیکند و فقط میخواهد مکان مناسب را به Fragment Shader بدهیم یا آن را در خود Shader محاسبه کنیم.
بهجای استفاده از مکانهای صحیحِ Texel که به ابعاد Texture Image وابستهاند، دو مختصات اعشاری بافت، یعنی s و t، به کار میبریم که هر دو هنگام پیمایش تصویر بافت در بازهٔ (0.0, 1.0) تغییر میکنند. در مثال یک Texture Image دوبعدیِ (512\times512)، مقدار (0.0, 0.0) متناظر با Texel my_texels[0][0] و (1.0, 1.0) متناظر با my_texels[511][511] است؛ همانطور که در شکل ۷٫۱۷ دیده میشود. هر مقدار s و t در بازهٔ واحد به یک Texel یکتا مربوط میشود.
تعیین Texture Coordinate مناسب برای یک Fragment بر عهدهٔ برنامهٔ کاربردی و Shaderهاست. رایجترین روش این است که Texture Coordinate را یک Vertex Attribute در نظر بگیریم. بنابراین همانطور که در برنامه رنگ رأسها را میدهیم، میتوان Texture Coordinateها را نیز مشخص کرد؛ سپس آنها را به Vertex Shader فرستاد تا Rasterizer مختصات بافت رأسها را به مختصات بافت Fragmentها درونیابی کند.
برای نمونه، Texture شطرنجی را روی هر یک از وجههای مکعب مثال قبلی اعمال میکنیم. این نمونه بسیار ساده است، زیرا نگاشت میان هر وجه مکعب و Texture Coordinateهای رأسهای آن روشن است: به چهار گوشهٔ هر وجه بهترتیب مختصات (0.0, 0.0)، (0.0, 1.0)، (1.0, 1.0) و (1.0, 0.0) میدهیم.
به یاد دارید که برای شش وجه مکعب ۳۶ رأسِ مثلثی تولید میکردیم. آرایهای نیز برای نگهداری Texture Coordinateها اضافه میکنیم:
#define N 36
GLfloat tex_coord[N][2];
کد تابع quad چنین است:
typedef vec2 point2;
void quad(int a, int b, int c, int d)
{
static int i =0; /* vertex and color index */
quad_color[i] = colors[a];
points[i] = vertices[a];
tex_coord[i] = point2(0.0, 0.0);
i++;
quad_color[i] = colors[a];
points[i] = vertices[b];
tex_coord[i] = point2(0.0, 1.0);
i++;
quad_color[i] = colors[a];
points[i] = vertices[c];
tex_coord[i] = point2(1.0, 1.0);
i++;
quad_color[i] = colors[a];
points[i] = vertices[a];
tex_coord[i] = point2(0.0, 0.0);
i++;
quad_color[i] = colors[a];
points[i] = vertices[c];
tex_coord[i] = point2(1.0, 1.0);
i++;
quad_color[i] = colors[a];
points[i] = vertices[d];
tex_coord[i] = point2(1.0, 0.0);
i++;
}
همچنین باید مقداردهی اولیه را طوری انجام دهیم که Texture Coordinateها با شناسهٔ texcoord بهصورت Vertex Attribute به Vertex Shader ارسال شوند:
GLuint loc3;
loc3 = glGetAttribLocation(program, "texcoord");
glEnableVertexAttribArray(loc3);
glVertexAttribPointer(loc3, 2, GL_FLOAT, GL_FALSE, 0, tex_coord);
در مرحلهٔ بعد Buffer Object را برای ذخیرهٔ همهٔ دادهها مقداردهی میکنیم. چون سه آرایهٔ دادهٔ جداگانه داریم، باید آنها را طی سه عملیات در Bufferی با اندازهٔ مناسب قرار دهیم. اندازهٔ کل چنین محاسبه میشود:
GLintptr offset;
GLsizeiptr size =
sizeof(points) + sizeof(quad_color) + sizeof(tex_coord);
glGenBuffers(1, buffer);
glBindBuffer(GL_ARRAY_BUFFER, buffers[0]);
glBufferData(GL_ARRAY_BUFFER, size, NULL, GL_STATIC_DRAW);
offset = 0;
glBufferData(GL_ARRAY_BUFFER, offset, sizeof(points), points);
offset += sizeof(points);
glBufferData(GL_ARRAY_BUFFER, offset, sizeof(quad_color), quad_color);
offset += sizeof(quad_color);
glBufferData(GL_ARRAY_BUFFER, offset, sizeof(tex_coord), tex_coord);
اکنون در Vertex Shader، ویژگی Texture Coordinate و خروجی مربوط به آن را اضافه میکنیم. Vertex Shader مکعب چرخان با Texture Coordinateها به شکل زیر است:
in vec2 texcoord;
in vec4 vPosition;
in vec4 vColor;
out vec4 color;
out vec2 st;
uniform vec3 theta;
void main()
{
mat4 rx, ry, rz;
vec3 c = cos(theta);
vec3 s = sin(theta);
rz = mat4(c.z, -s.z, 0.0, 0.0,
s.z, c.z, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
0.0, 0.0, 0.0, 1.0);
ry = mat4(c.y, 0.0, s.y, 0.0,
0.0, 1.0, 0.0, 0.0,
-s.y, 0.0, c.y, 0.0,
0.0, 0.0, 0.0, 1.0);
rx = mat4(1.0, 0.0, 0.0, 0.0,
0.0, c.x, -s.x, 0.0,
0.0, s.x, c.x, 0.0,
0.0, 0.0, 0.0, 1.0);
gl_Position = rz*ry*rx*vPosition;
color = vColor;
st = texcoord;
}
Texture Coordinate خروجی، یعنی st، بهوسیلهٔ Rasterizer درونیابی میشود و میتواند ورودی Fragment Shader باشد.
توجه کنید که Vertex Shader فقط با Texture Coordinateها سروکار دارد و هیچ وابستگی مستقیمی به Texture Object ساختهشده در برنامه ندارد. این موضوع طبیعی است، زیرا خود Texture تا زمانی که بخواهیم به Fragment رنگ بدهیم—یعنی در Fragment Shader—مورد نیاز نیست. همچنین بسیاری از پیچیدگیهای اعمال Texture که هنوز همهٔ آنها را بررسی نکردهایم، در Texture Object نگهداری میشوند؛ در نتیجه Fragment Shader میتواند بسیار ساده باقی بماند.
عنصر کلیدی برای اتصال اجزای مختلف، نوع جدیدی از متغیر به نام Sampler است که اغلب فقط در Fragment Shader ظاهر میشود. متغیر Sampler دسترسی به Texture Object و همهٔ پارامترهای آن را فراهم میکند. برای انواع Texture پشتیبانیشده در OpenGL، Samplerهای متناظر وجود دارند؛ از جمله Texture یکبعدی (sampler1D)، دوبعدی (sampler2D)، سهبعدی (sampler3D) و انواع ویژهای مانند Cube Map (samplerCube).
Texture Object با نام mytex را که در برنامه ساختهایم، با استفاده از یک Uniform Variable به Shader متصل میکنیم:
GLuint tex_loc;
tex_loc = glGetUniformLocation(program, "texMap");
glUniform1i(tex_loc, 0);
در اینجا texMap نام Sampler در Fragment Shader است و پارامتر دوم glUniform1i به Texture Unit پیشفرض اشاره دارد. Texture Unitهای متعدد را در بخش ۷٫۶٫۷ بررسی خواهیم کرد.
Fragment Shader تقریباً بدیهی است. رنگهای درونیابیشدهٔ رأسها و Texture Coordinateها ورودی آن هستند. اگر بخواهیم مقادیر Texture در رنگها ضرب شوند—مثلاً Texture شطرنجی شیشهای را شبیهسازی کند که بهطور متناوب شفاف و مات است—میتوانیم رنگ برنامه را در مقدار Texture Image ضرب کنیم:
in vec2 st;
in vec4 color;
uniform sampler2D texMap;
void main()
{
gl_FragColor = color * texture2D(texMap, st);
}
در شکل ۷٫۱۸(a)، کل Texture روی یک مستطیل استفاده شده است. اگر تنها بخشی از بازهٔ s و t، مثلاً (0.0, 0.5)، استفاده شود، فقط بخشی از my_texels بهعنوان Texture Map به کار میرود و تصویری مانند شکل ۷٫۱۸(b) به دست میآید. OpenGL مقدارهای s و t را روی چهارضلعی درونیابی میکند و سپس آنها را به Texel مناسب در my_texels نگاشت میدهد. مثال چهارضلعی ساده است، چون نگاشت Texture Coordinateها به رأسها آشکار است؛ اما برای چندضلعیهای عمومی، برنامهنویس باید نحوهٔ اختصاص Texture Coordinateها را تعیین کند.
شکل ۷٫۱۸ — نگاشت Texture شطرنجی به چهارضلعی. (a) استفاده از کل آرایهٔ Texel. (b) استفاده از بخشی از آرایهٔ Texel.
شکل ۷٫۱۹ — نگاشت Texture به چندضلعیها. (a و b) دو نگاشت متفاوت Texture شطرنجی روی یک مثلث. (c) نگاشت Texture شطرنجی روی یک ذوزنقه.
شکل ۷٫۱۹ چند امکان را با یک Texture Map واحد نشان میدهد. شکلهای ۷٫۱۹(a) و ۷٫۱۹(b) همان مثلث را با Texture Coordinateهای متفاوت به کار میبرند. به آثار جانبی حاصل از درونیابی توجه کنید، و نیز به اینکه چهارضلعیها هنگام رندرشدن در شکل ۷٫۱۹(c) بهصورت دو مثلث رفتار میشوند.
اصول Texture Mapping در OpenGL ساده است: آرایهای از رنگها را بهعنوان مقادیر Texture مشخص میکنیم و سپس Texture Coordinateها را اختصاص میدهیم. بااینحال، برای استفادهٔ مؤثر از Texture چند نکتهٔ ظریف و مهم وجود دارد. حل مسائل حاصل مستلزم ایجاد موازنه میان کیفیت تصویر و کارایی است.
یکی از مسائل این است که مقدار s یا t خارج از بازهٔ (0.0, 1.0) چگونه تفسیر شود. معمولاً یا میخواهیم Texture برای مقادیر خارج از این بازه تکرار شود، یا مقدارها به ۰٫۰ و ۱٫۰ Clamp شوند؛ یعنی برای مقادیر پایینتر و بالاتر از بازه، بهترتیب مقدار مرزی ۰٫۰ و ۱٫۰ استفاده شود. برای Texture تکرارشونده این پارامتر را چنین تنظیم میکنیم:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
برای مؤلفهٔ t از GL_TEXTURE_WRAP_T استفاده میکنیم و برای Clampکردن، مقدار GL_CLAMP_TO_EDGE را به کار میبریم. اگر این توابع پس از glBindTexture اجرا شوند، پارامترهای حاصل بخشی از همان Texture Object خواهند شد.
۷٫۶٫۵ نمونهبرداری از Texture
Aliasing در Textureها یکی از مسائل مهم است. وقتی Texture Coordinateها را به آرایهٔ Texelها نگاشت میکنیم، بهندرت نقطهٔ حاصل دقیقاً روی مرکز یک Texel قرار میگیرد. یک راه این است که مقدار Texel نزدیکتر به Texture Coordinate خروجی Rasterizer را انتخاب کنیم. این روش Point Sampling نام دارد، اما بیش از همه مستعد خطاهای Aliasing قابل مشاهده است.
راهبرد بهتر—هرچند پرهزینهتر—استفاده از میانگین وزندار چند Texel در همسایگی Texel منتخب Point Sampling است. این روش Linear Filtering نام دارد. شکل ۷٫۲۰ موقعیتی را درون یک Texel نشان میدهد که از درونیابی دوخطی Texture Coordinateهای رأسها به دست آمده و نیز چهار Texel مجاور را که برای محاسبهٔ مقدار نرمتر استفاده میشوند. هنگام Linear Filtering در لبههای آرایهٔ Texel مسئلهای به وجود میآید، زیرا به مقادیر Texel خارج از آرایه نیاز داریم.
شکل ۷٫۲۰ — Texelهای استفادهشده در Linear Filtering.
پیچیدگی دیگری نیز در تعیین نحوهٔ استفاده از مقادیر Texel برای تولید مقدار Texture وجود دارد. اندازهٔ پیکسلی که روی صفحه رنگ میکنیم ممکن است از یک Texel کوچکتر یا بزرگتر باشد؛ شکل ۷٫۲۱ این دو حالت را نشان میدهد.
در حالت اول Texel از یک Pixel بزرگتر است (Magnification) و در حالت دوم کوچکتر است (Minification). سریعترین راهبرد در هر دو حالت استفاده از نزدیکترین مقدار Point Sampling است. میتوان این گزینه را برای بزرگنمایی و کوچکنمایی Texture چنین تنظیم کرد:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
در مقابل، اگر بهجای GL_NEAREST مقدار GL_LINEAR بدهیم، Filtering انجام میشود و تصویری نرمتر با Aliasing کمتر به دست میآید.
OpenGL روش دیگری نیز برای مسئلهٔ Minification دارد که Mipmapping نامیده میشود. برای اشیایی که تصویرشان روی Screen Space در مقایسه با اندازهٔ آرایهٔ Texel کوچک است، به وضوح کامل آرایهٔ اصلی نیازی نداریم.
شکل ۷٫۲۱ — نگاشت Texelها به Pixelها. (a) Minification. (b) Magnification.
OpenGL اجازه میدهد مجموعهای از آرایههای Texture با اندازههای کاهشیافته ساخته شود و سپس بهطور خودکار اندازهٔ مناسب را به کار گیرد. برای یک آرایهٔ اصلی (64\times64)، میتوان نسخههای (32\times32)، (16\times16)، (8\times8)، (4\times4)، (2\times2) و (1\times1) را برای Texture Object جاری با این فراخوانی ایجاد کرد:
glGenerateMipmap(GL_TEXTURE_2D);
همچنین میتوان Mapها را مستقیماً با پارامتر level در glTexImage2D تعریف کرد. این پارامتر سطح آرایهٔ Texture در سلسلهمراتب Mipmap را مشخص میکند. بنابراین سطح ۰ تصویر اصلی، سطح ۱ تصویر با نصف وضوح و سطوح بعدی نسخههای کوچکتر هستند. بااینحال، در فراخوانیهای مختلف glTexImage2D میتوان اشارهگر به تصاویر کاملاً متفاوتی داد؛ در نتیجه سطوح مختلف Mipmap الزاماً نباید صرفاً نسخهٔ کوچکشدهٔ یک تصویر باشند.
اگر پارامتر زیر تنظیم شود، این Mipmapها بهطور خودکار به کار میروند:
glTexParameteri(GL_TEXTURE_2D,
GL_TEXTURE_MIN_FILTER,GL_NEAREST_MIPMAP_NEAREST);
این گزینه از OpenGL میخواهد Point Sampling را روی مناسبترین Mipmap انجام دهد. گزینههای دیگر نیز وجود دارند: Filtering در مناسبترین Mipmap با GL_NEAREST_MIPMAP_LINEAR، Point Sampling همراه با Linear Filtering میان Mipmapها با GL_LINEAR_MIPMAP_NEAREST، یا هر دو نوع Filtering با GL_LINEAR_MIPMAP_LINEAR.
شکل ۷٫۲۲ تفاوت میان نگاشت Texture با نزدیکترین Texel، Linear Filtering و Mipmapping را نشان میدهد؛ Mipmapping نیز یک بار با نزدیکترین Texel و یک بار با Linear Filtering نمایش داده شده است. شیء یک چهارضلعی است که در تصویر پرسپکتیو تقریباً مثل مثلث دیده میشود. Texture مجموعهای از خطوط سیاه و سفید است که طوری اعمال شدهاند که خطوط در سمت دور چهارضلعی به هم نزدیک شوند.
به علت نظم زیاد این Texture Map، آثار Aliasing بسیار آشکارند. استفاده از نزدیکترین Texel باعث الگوهای Moiré و دندانهدارشدن خطوط میشود. Linear Filtering خطوط را نرمتر میکند، اما الگوهای Moiré هنوز مشخصاند. Texelهای میان نوارهای سیاه و سفید بهدلیل Filtering خاکستری میشوند. Mipmapping نیز بسیاری از مقادیر سیاه و سفید الگوی دورنگ را با خاکستریِ حاصل از میانگین دو رنگ جایگزین میکند. در بخشهایی از شیء که از بیننده دورترند، Texelها خاکستری شده و با پسزمینه ترکیب میشوند. Texture مبتنی بر Mipmap با نزدیکترین Texel هنوز دندانهدار است، اما ترکیب Mipmap و Linear Filtering این دندانهها را نرم میکند.
افزایش سرعت GPUها و وجود Texture Memory بزرگتر روی آنها باعث شده است در بسیاری از کاربردها Filtering و Mipmapping بدون جریمهٔ محسوس کارایی قابل استفاده باشند.
آخرین نکته دربارهٔ Texture در OpenGL، تعامل Texture و Shading است. برای رنگهای RGB چند گزینه وجود دارد. Texture میتواند با ضرب مؤلفههای رنگ Texture در مؤلفههای رنگ Shader، رنگی را که بدون Texture Mapping به دست میآمد تعدیل کند. یا میتوان رنگ Texture را کاملاً تعیینکنندهٔ رنگ Fragment قرار داد؛ روشی که Decaling نام دارد. این و حالتهای دیگر بهسادگی در Fragment Shader پیادهسازی میشوند.
شکل ۷٫۲۲ — نگاشت Texture روی یک چهارضلعی. (a) Point Sampling. (b) Linear Filtering. (c) Point Sampling با Mipmapping. (d) Linear Filtering با Mipmapping.
۷٫۶٫۶ کار با Texture Coordinateها
در مثالهای قبلی بهطور ضمنی فرض کردیم که میدانیم Texture Coordinateها چگونه باید تخصیص داده شوند. اگر با چندضلعیهای مستطیلی هماندازه کار کنیم، تعیین مختصات نسبتاً ساده است. همچنین Texture Coordinateها، مانند رأسها، میتوانند بهشکل آرایههای یکبعدی، دوبعدی، سهبعدی یا چهاربعدی ذخیره شوند. بنابراین میتوان آنها را با ماتریسها تبدیل کرد و همانند موقعیتهایی که با Model-View و Projection Matrix تبدیل میکردیم، دستکاری نمود.
با یک Texture Matrix میتوان Texture را Scale و جهتدهی کرد و افکتهایی ساخت که در آن Texture همراه شیء، دوربین یا منبعهای نور حرکت میکند.
اما اگر مجموعهٔ چندضلعیها تقریبی از یک جسم خمیده باشد، تخصیص Texture Coordinateها بسیار دشوارتر میشود. تقریب چندضلعی قوری یوتا در شکل ۷٫۲۳ نمونهای از این وضعیت است.
شکل ۷٫۲۳ — مدل چندضلعی قوری یوتا.
شکل ۷٫۲۴ — قوری یوتا با Texture Mapping.
با آنکه مدل قوری یوتا فقط از چهارضلعیها استفاده میکند، اندازهٔ آنها یکسان نیست: در نواحی با انحنای بیشتر، چهارضلعیها کوچکتر و در نواحی تختتر بزرگترند. شکل ۷٫۲۴ Texture شطرنجی را روی قوری نشان میدهد، بدون آنکه برای تفاوت اندازهٔ چندضلعیها اصلاحی انجام شده باشد.
وقتی یک مجموعه Texture Coordinate یکسان را به هر چندضلعی اختصاص میدهیم، فرایند Texture Mapping با Scaleکردن Texture Map، خود را با اندازهٔ هر چندضلعی سازگار میکند. بنابراین در بخشهایی مانند دستهٔ قوری که برای تقریب مناسب سطح خمیده به تعداد زیادی چندضلعی کوچک نیاز است، مربعهای سیاه و سفید در مقایسه با بدنهٔ قوری کوچکتر دیده میشوند.
در برخی کاربردها این الگو قابل قبول است. اما اگر همهٔ سطحهای قوری از یک مادهٔ واحد ساخته شده باشند، انتظار داریم الگوی یکسانی روی تمام قسمتها دیده شود. از نظر نظری میتوان Texture Matrix را برای Scaleکردن Texture Coordinateها به کار برد، ولی در عمل استخراج اطلاعات لازم از مدل برای تشکیل چنین ماتریسی تقریباً غیرممکن است.
یکی از راهحلها این است که Texture Coordinateهای هر رأس را بر حسب فاصله از یک صفحه، در Eye Coordinates یا Object Coordinates، تولید کنیم. از نظر ریاضی هر Texture Coordinate یک ترکیب خطی از مقادیر مختصات همگن است. برای s و t داریم:
s=a_sx+b_sy+c_sz+d_sw,
t=a_tx+b_ty+c_tz+d_tw.
شکل ۷٫۲۵ — قوری با تولید خودکار Texture Coordinate. (a) در Object Coordinates. (b) در Eye Coordinates.
شکل ۷٫۲۵(a) قوری را با تولید Texture Coordinate در Object Space نشان میدهد و شکل ۷٫۲۵(b) همان معادلات را با محاسبه در Eye Space به کار میگیرد. در Object Space، Texture به شیء متصل میماند و همراه آن میچرخد. در Eye Space، با اعمال Transformation به شیء، الگوی Texture تغییر میکند و این تصور را ایجاد میکند که شیء درون یک میدان Texture حرکت میکند.
یکی از کاربردهای مهم این روش تولید و نگاشت Terrain است؛ ویژگیهای سطح را میتوان مستقیماً بهعنوان Texture روی یک Mesh سهبعدی نگاشت کرد.
۷٫۶٫۷ Multitexturing
تا اینجا تنها اعمال یک Texture روی یک شیء را بررسی کردیم. بااینحال، بسیاری از افکتهای Surface Rendering هنگامی بهتر پیادهسازی میشوند که بیش از یک Texture روی سطح اعمال شود. برای مثال، فرض کنید بخواهیم روی شیئی سایه بیندازیم که رنگهای سطح آن خود توسط یک Texture Map تعیین شدهاند. میتوان برای سایه نیز Texture Map جداگانهای به کار برد، اما اگر فقط یک بار امکان اعمال Texture وجود داشته باشد، این روش عملی نخواهد بود.