چندضلعیها، واشر سهبعدی، حذف سطح پنهان و تعامل
فهرست کامل برنامهٔ واشر، تابع initShader و تابع خواندن کد مبدأ Shader، همراه با برنامههای نمونهٔ دیگری که در فصلهای بعد میسازیم، در ضمیمهٔ A ارائه شدهاند.
۲.۹ چندضلعیها و بازگشت
خروجی برنامهٔ واشر ما، که در شکل ۲.۲ دیده میشود، ساختار قابلتوجهی دارد. اگر برنامه را با تکرارهای بیشتری اجرا کنیم، مقدار زیادی از تصادفیبودن تصویر از بین میرود. با بررسی ساختار میبینیم فارغ از تعداد نقاط تولیدشده، در ناحیهٔ میانی هیچ نقطهای وجود ندارد. اگر پارهخطهایی میان نقاط میانی اضلاع مثلث اولیه رسم و آن را به چهار مثلث تقسیم کنیم، مثلث میانی فاقد نقطه است، شکل ۲.۳۸.
شکل ۲.۳۸ — نصفکردن اضلاع یک مثلث.
در سه مثلث دیگر نیز همین مشاهده برقرار است: هر یک را میتوان با اتصال نقاط میانی اضلاع به چهار مثلث تقسیم کرد و مثلث میانیِ هرکدام باز هم بدون نقطه خواهد بود.
این ساختار روش دومی برای تولید واشر سیرپینسکی پیشنهاد میکند؛ روشی که بهجای نقطه از چندضلعی استفاده میکند و به مولد اعداد تصادفی نیاز ندارد. مزیت چندضلعیها این است که میتوان نواحی توپر را روی نمایشگر پر کرد. راهبرد آن است که با یک مثلث آغاز کنیم، با نصفکردن اضلاع آن را به چهار مثلث کوچکتر تقسیم کنیم و مثلث میانی را از بررسیهای بعدی کنار بگذاریم. این فرایند روی مثلثهای باقیمانده تکرار میشود تا مثلثهایی که حذف میشوند آنقدر کوچک شوند ــ تقریباً هماندازهٔ یک پیکسل ــ که بتوان مثلثهای باقیمانده را رسم کرد.
این فرایند را میتوان با برنامهای بازگشتی پیادهسازی کرد. ابتدا تابع سادهای میسازیم که مکان سه رأس تعریفکنندهٔ یک مثلث را به آرایهٔ points اضافه کند:
#include "vec.h"
typedef vec2 point2;
void triangle(point2 a, point2 b, point2 c)
/* specify one triangle */
{
static int i = 0;
points[i] = a;
i++;
points[i] = b;
i++;
points[i] = c;
i++;
}
بنابراین هر بار triangle فراخوانی شود سه رأس دوبعدی به آرایهٔ داده افزوده میشود. فرض کنید رأسهای مثلث اولیه با آرایهٔ زیر داده شده باشند:
آنگاه نقاط میانی اضلاع در آرایهٔ mid[3] قرار میگیرند و چنین محاسبه میشوند:
point2 mid[3];
mid[0] = (v[0] + v[1])/2.0;
mid[1] = (v[0] + v[2])/2.0;
mid[2] = (v[1] + v[2])/2.0;
با این شش مکان میتوان با triangle دادهٔ سه مثلث ساختهشده از (v[0], mid[0], mid[1])، (v[2], mid[1], mid[2]) و (v[1], mid[2], mid[0]) را در points قرار داد. اما هدف فقط رسم این مثلثها نیست؛ میخواهیم آنها را نیز تقسیم کنیم، پس فرایند بازگشتی میشود. تابع بازگشتی زیر را تعریف میکنیم:
divide_triangle(point2 a, point2 b, point2 c, int k);
این تابع فقط هنگامی مثلثها را رسم میکند که k صفر باشد؛ در غیر این صورت مثلث مشخصشده با a، b و c را تقسیم و k را کم میکند:
void divide_triangle(point2 a, point2 b, point2 c, int k)
{
if(k > 0)
{
// compute midpoints of sides
point2 ab = (a + b)/2.0;
point2 ac = (a + c)/2.0;
point2 bc = (b + c)/2.0;
// subdivide all but inner triangle
divide_triangle(a, ab, ac, k-1);
divide_triangle(c, ac, bc, k-1);
divide_triangle(b, bc, ab, k-1);
}
else triangle(a,b,c); /* draw triangle at end of recursion */
}
شکل ۲.۳۹ — مثلثها پس از پنج مرحله تقسیم.
اکنون تابع نمایش تقریباً بدیهی است. این تابع از مقدار سراسری n که برنامهٔ اصلی تعیین کرده برای تعداد مراحل تقسیم استفاده میکند و divide_triangle را یک بار با فراخوانی زیر اجرا میکند:
divide_triangle(v[0], v[1], v[2], Ndivisions);
Ndivisions تعداد دفعات تقسیم مثلث اولیه است. اگر رأسهای مشترک میان مثلثها را بهصورت مشترک نگهداری نکنیم و هر مثلث را مستقل در نظر بگیریم، هر تقسیم تعداد رأسها را سه برابر میکند، بنابراین:
Nvertices = 3^(Ndivisions+1).
Buffer Object را دقیقاً مانند قبل تنظیم میکنیم و سپس همهٔ مثلثها را چنین رندر میکنیم:
void display( void )
{
glClear(GL_COLOR_BUFFER_BIT);
glDrawArrays(GL_TRIANGLES, 0, Nvertices);
glFlush();
}
بقیهٔ برنامه تقریباً با برنامهٔ پیشین واشر یکسان است. خروجی پنج مرحله تقسیم در شکل ۲.۳۹ نشان داده شده و برنامهٔ کامل در ضمیمهٔ A آمده است.
یادداشت: در Callbackهای GLUT اغلب راه مناسبی برای عبور دادن متغیرها نداریم و ناچاریم از پارامترهای سراسری استفاده کنیم. هرچند این روش معمولاً مطلوب نیست، بهعلت ثابتبودن قالب این توابع جایگزین مناسبی وجود ندارد.
۲.۱۰ واشر سهبعدی
گفتیم گرافیک دوبعدی حالت ویژهای از گرافیک سهبعدی است، اما هنوز برنامهٔ سهبعدی کاملی ندیدهایم. اکنون برنامهٔ واشر سیرپینسکی دوبعدی را به برنامهای تبدیل میکنیم که واشر سهبعدی، یعنی ساختاری محدودنشده به یک صفحه، تولید کند. میتوان هر یک از دو روش واشر دوبعدی را تعمیم داد. هر دو تعمیم به روشی مشابه آغاز میشوند: مثلث اولیه را با یک چهاروجهی جایگزین میکنیم، شکل ۲.۴۰.
شکل ۲.۴۰ — چهاروجهی.
۲.۱۰.۱ استفاده از نقاط سهبعدی
چون هر چهاروجهی محدب است، نقطهٔ میانی پارهخط میان یک رأس و هر نقطهٔ داخل چهاروجهی نیز داخل آن قرار میگیرد. بنابراین همان روش قبل را دنبال میکنیم، با این تفاوت که بهجای سه رأس مثلث به چهار رأس اولیه برای تعریف چهاروجهی نیاز داریم. تا زمانی که هیچ سه رأسی همخط نباشند، چهار رأس چهاروجهی را میتوان بهصورت تصادفی انتخاب کرد بیآنکه ماهیت نتیجه تغییر کند.
تغییرات لازم عمدتاً در تابع display است. آرایهٔ رأسها را چنین تعریف و مقداردهی اولیه میکنیم:
// vertices of an arbitrary tetrahedron
point3 vertices[4] = { point3(-1.0, -1.0, -1.0),
point3( 1.0, -1.0, -1.0),
point3( 0.0, 1.0, -1.0),
point3( 0.0, 0.0, 1.0) };
// arbitrary initial location inside tetrahedron
point3 p = point3(0.0, 0.0, 0.0);
دادهٔ رأسها را در آرایهٔ زیر ذخیره میکنیم:
point3 points[NumPoints];
محل جدید مانند قبل محاسبه میشود، اما محاسبهٔ نقطهٔ میانی برای مؤلفهٔ z نیز افزوده شده است:
// computes and plots a single new location
point3 p;
int rand();
int j = rand() % 4; // pick a vertex at random
// compute point halfway between a vertex and the old location
p = (p + vertices[j])/2.0;
Vertex-Array Object و Buffer Object را دقیقاً مانند نسخهٔ دوبعدی میسازیم و میتوانیم همان تابع نمایش را به کار ببریم.
واشر سهبعدی مشکلی دارد که در نسخهٔ دوبعدی وجود نداشت: نقاط به یک صفحه محدود نیستند؛ ازاینرو درک ساختار سهبعدی از تصویر دوبعدی نمایشدادهشده میتواند دشوار باشد، بهویژه اگر همهٔ نقاط با یک رنگ رندر شوند.
برای حل این مشکل میتوان فرایند تعیین رنگ پیچیدهتری به Shaderها افزود تا رنگ هر نقطه به مکان همان نقطه وابسته باشد. مکعب رنگ را میتوان روی حجم مشاهدهٔ پیشفرض نگاشت کرد؛ هر دو مکعباند، اما x، y و z در بازهٔ −1 تا 1 هستند، در حالی که هر مؤلفهٔ رنگ باید بین 0 و 1 باشد. با نگاشتهای
r=(1+x)/2، g=(1+y)/2 و b=(1+z)/2
هر نقطه در حجم مشاهده به رنگی متمایز نگاشت میشود. در Vertex Shader میتوان رنگ را از مؤلفههای vPosition تعیین کرد:
in vec4 vPosition;
out vec4 color;
void main()
{
color = vec4((1.0 + vPosition.xyz)/2.0, 1.0);
gl_Position = vPosition;
}
این رنگ برای Rasterizer خروجی میشود تا Fragment Shader آن را بهعنوان ورودی برای تعیین رنگ Fragment استفاده کند:
in vec4 color;
void main()
{
gl_FragColor = color;
}
شکل ۲.۴۱ نشان میدهد که با تولید تعداد کافی نقطه، شکل حاصل شبیه چهاروجهی اولیهای میشود که چهاروجهیهای هرچه کوچکتر از آن حذف شدهاند.
۲.۱۰.۲ استفاده از چندضلعیها در سه بعد
رویکرد جالبتر برای واشر سهبعدی سیرپینسکی، استفاده از چندضلعیها همراه با تقسیم یک چهاروجهی به چهاروجهیهای کوچکتر است. از یک چهاروجهی شروع میکنیم، نقاط میانی شش یال آن را پیدا میکنیم و این نقاط را مطابق شکل ۲.۴۲ به هم متصل میکنیم.
شکل ۲.۴۱ — واشر سیرپینسکی سهبعدی.
اکنون چهار چهاروجهی کوچکتر، یکی در مجاورت هر رأس اولیه، و ناحیهای دیگر در وسط داریم که آن را کنار میگذاریم.
در ادامهٔ روش دوم مربوط به یک مثلث، تقسیم بازگشتی را برای چهار چهاروجهیِ نگهداشتهشده به کار میبریم. چون سطوح یک چهاروجهی چهار مثلث تعیینشده توسط چهار رأس آن هستند، در پایان تقسیمها میتوان هر چهاروجهی نهایی را با رسم چهار مثلث رندر کرد.
شکل ۲.۴۲ — چهاروجهی تقسیمشده.
بیشتر کد تقریباً مانند حالت دوبعدی است. تابع triangle اکنون بهجای نقاط دوبعدی از نقاط سهبعدی استفاده میکند:
#include "vec.h"
typedef vec3 point3;
void triangle(point3 a, point3 b, point3 c)
/* specify one triangle */
{
static int i = 0;
points[i] = a;
i++;
points[i] = b;
i++;
points[i]= c;
i++;
}
هر چهاروجهی را با رنگآمیزی متفاوت چهار وجه و تابع زیر رسم میکنیم:
void tetra(point3 a, point3 b, point3 c, point3 d)
{
triangle(a, b, c);
triangle(a, c, d);
triangle(a, d, b);
triangle(b, d, c);
}
تقسیم چهاروجهی مشابه تقسیم مثلث است و divide_tetra همین کار را انجام میدهد:
void divide_tetra(point3 a, point3 b, point3 c, point3 d, int m)
{
if(m > 0)
{
point3 mid[6];
// compute six midpoints
mid[0] = (a + b)/2.0;
mid[1] = (a + c)/2.0;
mid[2] = (a + d)/2.0;
mid[3] = (b + c)/2.0;
mid[4] = (c + d)/2.0;
mid[5] = (b + d)/2.0;
// create 4 tetrahedrons by subdivision
divide_tetra(a, mid[0], mid[1], mid[2], m-1);
divide_tetra(mid[0], b, mid[3], mid[5], m-1);
divide_tetra(mid[1], mid[3], c, mid[4], m-1);
divide_tetra(mid[2], mid[5], mid[5], d, m-1);
}
else tetra(a,b,c,d); /* draw tetrahedron at end of recursion */
}
اکنون میتوان با چهار رأس آغاز و n مرحله تقسیم را چنین اجرا کرد:
divide_tetra(v[0], v[1], v[2], v[3], n);
پیش از آنکه برنامهٔ سهبعدی مفیدی داشته باشیم دو مسئلهٔ دیگر باقی میماند. نخست رنگ است. اگر مانند مثال اول فقط یک رنگ به کار ببریم، ساختار سهبعدی دیده نمیشود. از طرف دیگر میتوانیم مانند مثال قبلی رنگ هر Fragment را بر اساس موقعیت سهبعدی نقطه تعیین کنیم، اما ترجیح میدهیم تعداد کمی رنگ داشته باشیم و سطح هر مثلث را با یکی از آنها رنگ کنیم.
رنگهای پایهای را در برنامه انتخاب میکنیم:
typedef vec3 color3;
color3 base_colors[4] = {color3(1.0, 0.0, 0.0), color3(0.0, 1.0, 0.0),
color3(0.0, 0.0, 1.0), color3(0.0, 0.0, 0.0)};
سپس هنگام تولید نقاط برای هرکدام رنگی اختصاص میدهیم. هنگام تولید مثلثها Index رنگ را تنظیم میکنیم:
int colorindex;
void tetra(point3 a, point3 b, point3 c, point3 d)
{
colorindex = 0;
triangle(a,b,c);
colorindex = 1;
triangle(a,c,d);
colorindex = 2;
triangle(a,d,b);
colorindex = 3;
triangle(b,d,c);
}
و آرایهای از رنگ با یک رنگ برای هر نقطه میسازیم:
color3 colors[NumVertices];
int i = 0; // number of vertices
void triangle(point3 a, point3 b, point3 c)
/* specify one triangle */
{
colors[i] = base_colors[colorindex];
points[i] = a;
i++;
colors[i] = base_colors[colorindex];
points[i] = b;
i++;
colors[i] = base_colors[colorindex];
points[i] = c;
i++;
}
این رنگها همراه با رأسهای مربوطه در Buffer Object به GPU فرستاده میشوند. در حافظهٔ بافر ابتدا دادهٔ رأسها و سپس دادهٔ رنگها را قرار میدهیم.
برای این کار ابتدا بافری بهاندازهٔ همهٔ دادهها تخصیص میدهیم و سپس دادهها را در دو عملیات با glBufferSubData بارگذاری میکنیم. glBufferSubData امکان بهروزرسانی بخشی از Buffer Object موجود را با دادهٔ جدید فراهم میکند. پارامتر اول مشخص میکند کدام آرایهٔ Buffer بهروزرسانی شود؛ پارامتر دوم Offset بایتی آغاز نوشتن، پارامتر سوم تعداد بایتهای خواندهشده از اشارهگر حافظه و پارامتر چهارم خود اشارهگر حافظه است:
GLuint buffer;
glGenBuffers(1, &buffer);
glBindBuffer(GL_ARRAY_BUFFER, buffer);
// Allocate a buffer of uninitialized data of the correct size
glBufferData(GL_ARRAY_BUFFER, sizeof(points) + sizeof(colors),
NULL, GL_STATIC_DRAW);
// Load the separate arrays of data
glBufferSubData(GL_ARRAY_BUFFER, 0, sizeof(points), points );
glBufferSubData(GL_ARRAY_BUFFER, sizeof(points),
sizeof(colors), colors );
فراخوانی اول بایتهای بازهٔ [0, sizeof(points)-1] را بهروزرسانی میکند. چون دادهٔ رنگ باید بلافاصله پس از دادهٔ رأس نوشته شود، Offset آغاز فراخوانی دوم برابر طول بایتی آرایهٔ points، یعنی sizeof(points)، است.
اگر رنگ در Shader با نام vColor باشد، آرایهٔ رأس دوم در مقداردهی اولیهٔ Shader چنین تنظیم میشود:
loc2 = glGetAttribLocation(program, "vColor");
glEnableVertexAttribArray(loc2);
glVertexAttribPointer(loc2, 3, GL_FLOAT, GL_FALSE, 0,
BUFFER_OFFSET(sizeof(points)));
در Vertex Shader از vColor برای تنظیم رنگی استفاده میکنیم که به Fragment Shader فرستاده خواهد شد.
۲.۱۰.۳ حذف سطح پنهان
اگر کد بخش پیشین را اجرا کنید، ممکن است نتیجه گیجکننده باشد. برنامه مثلثها را به ترتیبی رسم میکند که در کد مشخص شدهاند؛ این ترتیب را بازگشت برنامه تعیین میکند، نه روابط هندسی مثلثها. هر مثلث بهصورت توپر رسم میشود و روی مثلثهایی قرار میگیرد که پیشتر رندر شدهاند.
این ترتیب با چیزی که در یک واشر سیرپینسکی سهبعدی ساختهشده از چهاروجهیهای جامد کوچک میبینیم متفاوت است. ناظر فقط آن دسته از وجههای چهاروجهی را میبیند که از دید او جلوی همهٔ وجههای دیگر باشند. شکل ۲.۴۳ نسخهای ساده از مسئلهٔ سطح پنهان را نشان میدهد: از موقعیت ناظر چهارضلعی A کاملاً دیده میشود، مثلث B پشت آن پنهان است و مثلث C فقط بخشی قابل مشاهده دارد.
شکل ۲.۴۳ — مسئلهٔ سطح پنهان.
بدون ورود به جزئیات الگوریتمی خاص میتوان دید که با دانستن موقعیت ناظر و مثلثها باید بتوان ترتیب رسم را طوری تعیین کرد که تصویر درست حاصل شود. الگوریتمهایی که اشیا را برای رسم درست مرتب میکنند، بسته به نگاه ما به مسئله، «الگوریتم سطح مرئی» (Visible-Surface) یا «الگوریتم حذف سطح پنهان» (Hidden-Surface Removal) نام دارند. این الگوریتمها در فصلهای ۳ و ۶ بهتفصیل بررسی میشوند.
فعلاً از الگوریتم حذف سطح پنهانی استفاده میکنیم که OpenGL مستقیماً پشتیبانی میکند: «الگوریتم Z-Buffer». این الگوریتم بهسادگی فعال و غیرفعال میشود. در برنامهٔ اصلی باید فضای ذخیرهسازی کمکی، یعنی Z Buffer یا Depth Buffer، را با تغییر حالت نمایش درخواست کنیم:
glutInitDisplayMode(GLUT_SINGLE | GLUT_RGB | GLUT_DEPTH);
Z-Buffer یکی از بافرهایی است که Framebuffer را تشکیل میدهند. الگوریتم را با فراخوانی زیر فعال میکنیم:
این فراخوانی میتواند در main یا تابع مقداردهی اولیهای مانند init قرار گیرد. چون الگوریتم اطلاعات را در Depth Buffer ذخیره میکند، هر بار که میخواهیم نمایش را دوباره رسم کنیم باید این بافر را پاک کنیم؛ بنابراین دستور پاکسازی تابع نمایش را چنین تغییر میدهیم:
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
Display Callback کامل چنین است:
void display()
{
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glDrawArrays(GL_TRIANGLES, 0, NumVertices);
glFlush();
}
نتیجه برای بازگشتی با چهار مرحله در شکل ۲.۴۴ آمده و برنامهٔ کامل در ضمیمهٔ A قرار دارد.
شکل ۲.۴۴ — واشر سهبعدی پس از چهار مرحله بازگشت.
۲.۱۱ افزودن تعامل
در این بخش ورودی مبتنی بر رویداد را با مجموعهای از مثالهای ساده و سازوکار Callback معرفیشده در بخش ۲.۷ توسعه میدهیم. رویدادهای مختلفی را که سامانهٔ پنجرهای تشخیص میدهد بررسی میکنیم و برای رویدادهای موردنیاز برنامه، Callbackهایی مینویسیم که نحوهٔ واکنش برنامه را تعیین کنند.
۲.۱۱.۱ استفاده از ابزار اشارهگر
از تغییر تابع main برنامهٔ واشر شروع میکنیم. در نسخهٔ اولیه با توابع GLUT پنجره را روی صفحه قرار دادیم و سپس با glutMainLoop وارد حلقهٔ رویداد شدیم. چون غیر از Display Callback، Callback دیگری وجود نداشت، پس از ورود به حلقه کار دیگری نمیتوانستیم انجام دهیم؛ حتی پایاندادن برنامه نیز فقط با سازوکاری خارجی و وابسته به سیستم مانند Control-C ممکن بود. مثال نخست این کمبود را با استفاده از ابزار اشارهگر برای پایاندادن برنامه برطرف میکند: با فشردن دکمهای مشخص از ماوس تابع استاندارد exit اجرا میشود.
فقط رویدادهایی را بررسی میکنیم که GLUT میشناسد. سامانههای پنجرهای استاندارد مانند X Window System یا Microsoft Windows رویدادهای بسیار بیشتری دارند و جزئیات آنها میان سیستمها فرق میکند. GLUT زیرمجموعهای کوچک و مشترک از رویدادها را میشناسد که برای ساخت برنامههای گرافیکی تعاملی پایه کافی است. چون GLUT برای سامانههای پنجرهای اصلی پیادهسازی شده، برنامههای سادهٔ ما با کامپایل مجدد روی چند سیستم اجرا میشوند.
دو نوع رویداد به ابزار اشارهگر مربوطاند؛ معمولاً ماوس فرض میشود، اما میتواند Trackpad یا Data Tablet نیز باشد. «رویداد حرکت» (Move Event) هنگامی تولید میشود که ماوس با یکی از دکمههای فشرده جابهجا شود. اگر ماوس بدون نگهداشتن دکمه حرکت کند، «رویداد حرکت غیرفعال» (Passive Move Event) ایجاد میشود. پس از رویداد حرکت، موقعیت ماوس در اختیار برنامه قرار میگیرد.
«رویداد ماوس» زمانی رخ میدهد که یکی از دکمهها فشرده یا رها شود. فشردن دکمه Mouse-Down و رهاکردن آن Mouse-Up ایجاد میکند. اطلاعات بازگشتی شامل دکمهٔ ایجادکنندهٔ رویداد، حالت دکمه پس از رویداد (up یا down) و موقعیت مکاننما در مختصات پنجره است؛ مبدأ این مختصات گوشهٔ بالا-چپ پنجره است.
Mouse Callback را، معمولاً در main، با تابع GLUT زیر ثبت میکنیم:
Callback باید قالب زیر را داشته باشد و برنامهنویس آن را فراهم کند:
void myMouse(int button, int state, int x, int y);
درون تابع، عملی را که برای هر رویداد مشخص میخواهیم تعریف میکنیم. یک Mouse Callback میتواند برای ترکیبهای مختلف دکمه و حالت چند عمل داشته باشد. در مثال ساده میخواهیم فشردن دکمهٔ چپ برنامه را خاتمه دهد:
void myMouse(int button, int state, int x, int y)
{
if(button == GLUT_LEFT_BUTTON && state == GLUT_DOWN)
exit(0);
}
اگر رویداد ماوس دیگری مانند فشردن دکمهای دیگر رخ دهد، چون عملی برای آن تعریف نشده هیچ واکنشی انجام نمیشود.
اکنون مثالی میسازیم که بسیاری از ویژگیهای برنامههای CAD را در خود دارد و تعامل بیشتری اضافه میکند. برنامهای ساده یک مثلث را نمایش میدهد که رأسهایش با ابزار اشارهگر بهصورت تعاملی وارد میشوند. همان Shaderهای قبلی را استفاده میکنیم، بنابراین بخش عمدهٔ کد مشابه مثالهای گذشته است. آرایهٔ سراسری زیر سه رأس دوبعدی را نگه میدارد:
با Mouse Callback هر بار که دکمهٔ چپ فشرده میشود داده را ثبت میکنیم:
int w, h;
int count = 0;
void mouse(int button, int state, int x, int y)
{
if(button == GLUT_RIGHT_BUTTON && state == GLUT_DOWN)
{
exit(0);
}
if(button == GLUT_LEFT_BUTTON && state == GLUT_DOWN)
{
points[count].x = (float) x / (w/2) - 1.0;
points[count].y = (float) (h-y) / (h/2) - 1.0;
count++;
}
if(count == 3)
{
glutPostRedisplay();
count = 0;
}
}
دکمهٔ راست برنامه را پایان میدهد و دکمهٔ چپ دادهٔ رأس مثلث را وارد میکند. متغیرهای سراسری h و w ارتفاع و عرض پنجرهٔ OpenGL را نگه میدارند؛ مثلاً در main:
w = 512;
h = 512;
glutInitWindowSize(w, h);
در هر فشردن دکمهٔ چپ، مکان مقیاسشدهٔ ماوس در points قرار میگیرد و سپس به رأس بعدی میرویم. مقیاسکردن لازم است چون Callback موقعیت ماوس را در مختصات صفحه با مبدأ گوشهٔ بالا-چپ بازمیگرداند. برای پنجرهٔ w × h گوشهٔ بالا-چپ (0,0) و گوشهٔ پایین-راست (w-1,h-1) است. افزایش y از بالا به پایین در سامانههای پنجرهای رایج است و ریشه در سامانههای تلویزیونی دارد که تصویر را از بالا به پایین نمایش میدادند.
در برنامهٔ ما مبدأ پنجره در مرکز است، گوشهٔ پایین-چپ (-1.0,-1.0) و گوشهٔ بالا-راست (1.0,1.0) است. چون بدویهای خارج از این ناحیه Clip میشوند، باید مقادیر بازگشتی ماوس را به همین ناحیه نگاشت و y را نیز معکوس کنیم تا مثلثها وارونه نشوند.
دو خط زیر این تبدیل مختصات را انجام میدهند:
points[count].x = (float) x / (w/2) - 1.0;
points[count].y = (float) (h-y) / (h/2) - 1.0;
پس از دریافت سه رأس میتوان مثلث را رسم کرد. رسم از طریق Display Callback انجام میشود، اما بهجای فراخوانی مستقیم display از تابع زیر استفاده میکنیم:
این تابع یک Flag داخلی را تنظیم میکند که نشان میدهد نمایش باید دوباره رسم شود. در هر دور حلقهٔ رویداد ممکن است چند رویداد رخ دهد که Callbackهای آنها به Refresh نیاز داشته باشند. اگر هر Callback مستقیماً Display Function را اجرا کند ممکن است نمایش در همان دور چند بار رسم شود. در عوض همه glutPostRedisplay را برای تنظیم Flag به کار میبرند. در پایان حلقه، اگر Flag تنظیم باشد Display Callback یک بار اجرا و Flag پاک میشود. در مثال ما هر سه بار فشردن متوالی دکمهٔ چپ مثلث جدیدی مشخص میکند که جایگزین مثلث قبلی میشود.
یادداشت: برخی برنامههای تعاملی ممکن است واقعاً نیاز داشته باشند Display Callback را مستقیماً اجرا کنند.
۲.۱۱.۲ رویدادهای پنجره
بیشتر سامانههای پنجرهای اجازه میدهند کاربر اندازهٔ پنجره را تعاملی تغییر دهد، معمولاً با کشیدن گوشهٔ پنجره با ماوس. این «رویداد تغییر شکل» (Reshape Event) نمونهای از Window Event است. کوچککردن پنجره به Icon و آشکارشدن پنجرهای که زیر پنجرهای دیگر بوده نیز از این رویدادها هستند و هرکدام میتوانند Callback مخصوص داشته باشند.
بر خلاف بیشتر Callbackها، برای Reshape یک Callback پیشفرض وجود دارد که فقط Viewport را به اندازهٔ جدید پنجره تغییر میدهد؛ این رفتار الزاماً مطلوب کاربر نیست. با تغییر اندازهٔ پنجره سه پرسش مطرح است:
- آیا همهٔ اشیای پنجرهٔ قبلی دوباره رسم شوند؟
- اگر نسبت ابعاد پنجرهٔ جدید با قبلی متفاوت است چه کنیم؟
- اگر اندازهٔ پنجره تغییر کرده، اندازه یا ویژگیهای بدویهای جدید نیز تغییر کند؟
برای این پرسشها پاسخ واحدی وجود ندارد. اگر تصویر یک صحنهٔ واقعی را نمایش میدهیم، احتمالاً تابع Reshape باید از اعوجاج شکل جلوگیری کند؛ اما ممکن است بخشی از پنجرهٔ جدید استفادهنشده بماند یا بخشی از صحنه در پنجره جا نشود. اگر بخواهیم اشیای پیش از تغییر اندازه را دوباره رسم کنیم به سازوکاری برای ذخیره و بازیابی آنها نیاز داریم. معمولاً همهٔ عملیات رسم را در تابعی واحد، مانند Display Callback مثالهای قبل، کپسوله میکنیم؛ هرچند در برنامههای تعاملی که تصمیم دربارهٔ رسم در زمان تعامل گرفته میشود این همیشه بهترین راه نیست.
Reshape Event در دادهٔ خود ارتفاع و عرض پنجرهٔ جدید را بازمیگرداند. از آنها میتوان برای مقیاس مجدد دادهٔ هندسه استفاده کرد:
GLint windowHeight, windowWidth;
void reshape(GLsizei w, GLsizei h)
{
windowWidth = w;
windowHeight = h;
glViewport(0, 0, windowWidth, windowHeight);
}
این تابع Viewport جدیدی میسازد که کل پنجرهٔ تغییراندازهیافته را پوشش میدهد و مقادیر عرض و ارتفاع جدید را در متغیرهای سراسری ذخیره میکند تا Mouse Callback بتواند از آنها استفاده کند. چون Reshape Callback خود یک Display Callback ایجاد میکند، نیازی به glutPostRedisplay نیست.
۲.۱۱.۳ رویدادهای صفحهکلید
صفحهکلید نیز میتواند ابزار ورودی باشد. هنگامی که تمرکز ماوس در پنجره است، با فشردن یا رهاکردن کلید رویداد صفحهکلید ایجاد میشود. glutKeyboardFunc Callback فشردن کلید و glutKeyboardUpFunc Callback رهاکردن کلید است.
هنگام وقوع رویداد، کد ASCII کلید و محل ماوس بازگردانده میشود. همهٔ Callbackهای فشردن کلید در یک تابع Callback ثبت میشوند، مثلاً:
اگر فقط بخواهیم از صفحهکلید برای خروج از برنامه استفاده کنیم، Callback میتواند چنین باشد:
void myKey(unsigned char key, int x, int y)
{
if(key==’q’ || key == ’Q’) exit(0);
}
GLUT تابع glutGetModifiers را نیز دارد که امکان تعریف عمل با کلیدهای Meta مانند Control و Alt را میدهد. این کلیدهای ویژه بهخصوص با ماوسهای یک یا دو دکمهای مفیدند، زیرا میتوانند عملکردی معادل دکمههای چپ، راست و میانی فراهم کنند. اطلاعات بیشتر در بخش منابع پیشنهادی پایان فصل آمده است.
۲.۱۱.۴ Idle Callback
Idle Callback زمانی فراخوانی میشود که رویداد دیگری وجود نداشته باشد؛ مقدار پیشفرض آن اشارهگر تابع NULL است. کاربرد معمول آن ادامهٔ تولید بدویهای گرافیکی از طریق تابع نمایش در زمان بیکاری یا ساخت نمایش متحرک است.
برنامهٔ مثلث را کمی گسترش میدهیم تا مثلث حول مرکز پنجره بچرخد. در چرخش دوبعدی شکل ۲.۴۵، نقطهٔ (x,y) پس از چرخش به اندازهٔ φ درجه حول مبدأ به (x′,y′) میرود. اگر نقطهٔ اولیه در مختصات قطبی
x = r cos(θ) و y = r sin(θ)
باشد، نقطهٔ چرخیده چنین است:
x′ = r cos(θ+φ) = r(cosθ cosφ − sinθ sinφ)
y′ = r sin(θ+φ) = r(cosθ sinφ + sinθ cosφ)
یا بهصورت دکارتی:
x′ = x cosφ − y sinφ
y′ = x sinφ + y cosφ.
شکل ۲.۴۵ — چرخش دوبعدی.
بهجای نمایش مستقیم مثلث با رأسهای واردشده، هر بار ابتدا رأسها را به اندازهٔ زاویهای کوچک میچرخانیم. Idle Callback فقط درخواست Redisplay میدهد. در main آن را چنین ثبت میکنیم:
Display Callback علاوه بر تغییر موقعیت رأسها باید دادهٔ جدید را به GPU نیز بفرستد؛ در کد زیر زاویه یکهزارم درجه است.
const float DegreesToRadians = M_PI / 180.0;
float angle = 0.001*DegreesToRadians; // small angle in radians
void display()
{
for( int i = 0; i < 3; i++)
{
float x = cos(angle)*points[i].x - sin(angle)*points[i].y;
float y = sin(angle)*points[i].x + cos(angle)*points[i].y;
points[i].x = x;
points[i].y = y;
}
glBufferData(GL_ARRAY_BUFFER, sizeof(points), points,
GL_STATIC_DRAW);
glClear(GL_COLOR_BUFFER_BIT); // clear the window
glDrawArrays(GL_TRIANGLES, 0, 3);
glFlush();
}
تابع Idle فقط چنین است:
void idle()
{
glutPostRedisplay();
}
راه دیگر این بود که زاویه را در Idle Callback اندکی افزایش دهیم و در Display Callback همیشه چرخش را روی نقاط اولیه اعمال کنیم.
بیشتر Callbackها را در حین اجرای برنامه میتوان با تعیین تابع Callback جدید تغییر داد. همچنین با قرار دادن تابع روی NULL میتوان Callback را غیرفعال کرد. در مثال ما میخواهیم هنگام جمعآوری دادهٔ رأسها چرخش متوقف و پس از تکمیل مثلث جدید دوباره آغاز شود. Mouse Callback را چنین تغییر میدهیم:
void mouse(int button, int state, int x, int y)
{
if(button == GLUT_RIGHT_BUTTON && state == GLUT_DOWN)
{
exit(0);
}
if(button == GLUT_LEFT_BUTTON && state == GLUT_DOWN)
{
glutIdleFunc(NULL);
points[count].x = (float) x / (w/2) - 1.0;
points[count].y = (float) (h-y) / (h/2) - 1.0;
count++;
}
if(count == 3)
{
glutIdleFunc(idle);
glutPostRedisplay();
count = 0;
}
}
۲.۱۱.۵ بافر دوگانه
با وجود کاملبودن برنامه، بسته به سرعت رایانه و اندازهٔ افزایش زاویه در Idle Callback ممکن است بهجای مثلثی که نرم میچرخد تصویری شکسته و تکهتکه ببینید. در صحنههایی با اشیای متحرک زیاد این مشکل شدیدتر است.
علت، مستقلبودن زمان نمایش خودکار محتوای Framebuffer از کد برنامهای است که مقادیر آن را تغییر میدهد. Framebuffer معمولاً با «نرخ نوسازی» (Refresh Rate) ثابتی در حدود ۶۰ تا ۱۰۰ هرتز یا Frame در ثانیه دوباره نمایش داده میشود، در حالی که برنامهٔ کاربردی ناهمگام کار میکند و هر لحظه میتواند Framebuffer را تغییر دهد. بنابراین ممکن است Refresh زمانی رخ دهد که برنامه هنوز در حال تغییر محتواست و بیننده تصویری نیمهرسمشده ببیند. بعضی سیستمعاملها پارامتری برای همگامسازی رسم برنامه با نمایش Framebuffer دارند.
راهحل رایجتر «بافر دوگانه» (Double Buffering) است. بهجای یک Framebuffer، سختافزار دو Framebuffer دارد. «Front Buffer» بافری است که نمایش داده میشود و «Back Buffer» برای ساخت Frame بعدی در اختیار برنامه است. پس از تکمیل رسم، Front و Back با هم جابهجا میشوند. Back Buffer جدید پاک میشود و رسم Frame بعدی در آن آغاز میشود. بنابراین بهجای glFlush در انتهای Display Callback مینویسیم:
همچنین در مقداردهی اولیه باید Double Buffer درخواست کنیم:
glutInitDisplayMode(GLUT_RGBA | GLUT_DOUBLE | GLUT_DEPTH);
پیشفرض GLUT معادل GLUT_SINGLE است، اما سختافزارهای گرافیکی نوین حافظهٔ کافی دارند که عملاً همیشه بتوان از Double Buffering استفاده کرد. بیشتر کارتهای گرافیک امکان همگامسازی Refresh نمایش با برنامه را نیز دارند.
۲.۱۱.۶ مدیریت پنجره
GLUT هم چند پنجره و هم Subwindowهای یک پنجره را پشتیبانی میکند. پنجرهٔ Top-Level دوم با برچسب «second window» چنین باز میشود:
uint id = glutCreateWindow("second window");
شناسهٔ صحیح بازگشتی اجازه میدهد این پنجره را برای رندر اشیا بهعنوان پنجرهٔ جاری انتخاب کنیم:
میتوان با اجرای glutInitDisplayMode پیش از glutCreateWindow ویژگیهای این پنجره را با پنجرههای دیگر متفاوت کرد. هر پنجره همچنین میتواند مجموعهٔ Callback مخصوص خود را داشته باشد، زیرا تعیین Callback به پنجرهٔ جاری مربوط است.
۲.۱۲ منوها
میتوانستیم با بدویهای گرافیکی و Mouse Callbackها ابزارهای ورودی گرافیکی مختلف بسازیم؛ مثلاً Slidebar شکل ۲.۴۶ را با مستطیلهای توپر، متن برچسبها و ماوس برای تعیین موقعیت پیاده کنیم. اما توسعهٔ این کد، بهویژه برای ساخت Widgetهای زیبا و کارآمد، خستهکننده است. بیشتر سامانههای پنجرهای Toolkit حاوی Widgetهای آماده دارند، ولی چون هدف این کتاب وابستهنشدن به سامانهٔ پنجرهای خاص است، وارد جزئیات آنها نمیشویم. خوشبختانه GLUT قابلیت دیگری به نام Pop-Up Menu دارد که با ماوس امکان ساخت برنامههای تعاملی پیچیدهتر را میدهد.
برای استفاده از منو چند مرحله ساده لازم است: عمل متناظر با هر ورودی منو را تعریف کنیم، منو را به دکمهای از ماوس وصل کنیم و Callback منو را ثبت کنیم. مثال ساده منویی سهگزینهای است: گزینهٔ نخست خروج، گزینهٔ دوم آغاز چرخش و گزینهٔ سوم توقف چرخش. کد ساخت منو و اتصال آن به دکمهٔ راست در main چنین است:
glutCreateMenu(demo_menu);
glutAddMenuEntry("quit", 1);
glutAddMenuEntry("start rotation", 2);
glutAddMenuEntry("stop rotation", 3);
glutAttachMenu(GLUT_RIGHT_BUTTON);
glutCreateMenu تابع Callback یعنی demo_menu را ثبت میکند. آرگومان دوم هر ورودی شناسهای است که هنگام انتخاب به Callback فرستاده میشود؛ بنابراین Callback چنین است:
شکل ۲.۴۷ — ساختار منوهای سلسلهمراتبی.
void demo_menu(int id)
{
switch(id)
{
case 1:
exit(0);
break;
case 2:
glutIdleFunc(idle);
break;
case 3:
glutIdleFunc(NULL);
break;
}
glutPostRedisplay();
}
glutPostRedisplay از طریق Callback ثبتشده با glutDisplayFunc درخواست رسم مجدد میدهد تا صفحه بدون منو دوباره نمایش داده شود.
GLUT منوهای سلسلهمراتبی را نیز پشتیبانی میکند، شکل ۲.۴۷. فرض کنید منوی اصلی فقط دو گزینه داشته باشد: نخست برنامه را خاتمه دهد و دوم Submenuای باز کند که دو گزینهٔ روشن و خاموشکردن چرخش را دارد. کد منو در main چنین است:
sub_menu = glutCreateMenu(rotation_menu);
glutAddMenuEntry("start rotation", 2);
glutAddMenuEntry("stop rotation", 3);
glutCreateMenu(top_menu);
glutAddMenuEntry("Quit", 1);
glutAddSubMenu("start/stop rotation", sub_menu);
glutAttachMenu(GLUT_RIGHT_BUTTON);
نوشتن Callbackهای rotation_menu و top_menu تمرینی مستقیم است.