
สวัสดีครับ ช่วงนี้มีเรื่องให้เขียนเยอะเลยทีเดียว ต้องขอบคุณโปรเจค Twitter ของผมจริงๆ ครับ ที่ทำให้ผมมีข้ออ้างกับตัวเองในการอู้งาน ได้มาเล่น Direct3D อย่างที่อยากเล่นมานานได้เสียที เหตุผลก็เพื่อจะลองวาด UI ของโปรแกรมผมด้วย Direct3D ดูนั่นเองครับ ตอนนี้ละที่ผมเริ่มมีกิเลสอยากได้ Acer F1 (Neo Touch) ขึ้นมาแล้ว SnapDragon กันให้รู้แล้วรู้รอดไปเลย เหอๆ จะได้เห็นกันเลยว่า GPU Acceleration มันจะเทพขนาดไหน
แต่ก่อนกิเลสผมมันจะเลยเถิดไปกว่านั้น ลองมาดูหลักการและเหตุผล รวมไปถึงขั้นตอนในการใช้งาน Direct3D วาดภาพ 2D กันก่อนครับ
Direct3D…แต่ใช้ทำ 2D???
เรื่องของเรื่องก็คือ อย่างที่เราทราบกันแล้วนั่นเองครับ ว่า System.Drawing.Graphics บน Windows Phone ไม่รองรับ Alpha Blending แถมยังเป็น Software ล้วนๆ ไม่มี Hardware Acceleration เลยแม้แต่นิดเดียว ส่วนการทำงาน ก็จะต้องใช้การ PInvoke ไปเรียกคำสั่งของ Windows ให้มันทำให้ แต่จากการทดสอบคร่าวๆ ของผม ก็ยังติดที่เรื่อง Performance อยู่พอสมควรเลยทีเดียวละครับ
อันทีจริง API ที่เป็นการวาดภาพ 2D จริงๆ ของ Microsoft คือ DirectDraw ครับ แต่เราก็ติดอีก ตรงที่ Windows Phone ไม่มี API ของ DirectDraw แบบ Managed ให้ นั่นก็หมายถึงการ PInvoke กระหน่ำอีกแล้ว ผมเลยกลับมามองที่ MDX หรือ Managed DirectX แทน เพราะว่า Microsoft มี Managed API ให้เรียกใช้ได้โดยง่ายจาก C# อยู่ใน Assembly ชื่อ Microsoft.WindowsMobile.DirectX ครับ (แต่มีข่าวแว่วๆ มาว่า มันอาจจะ Obsolete ใน Windows Mobile 7
)
แล้ว…จะทำได้ยังไง?
แน่นอนว่า พอเราพูดถึง 3D เราก็มักจะนึกถึงภาพโพลีกอนในเกม แต่ถ้าเรามองย้อนมาดูจริงๆ โพลีกอน ก็คือภาพ 2D ชิ้นเล็กๆ ที่เรียงต่อกันจนเป็นพื้นผิว 3D ดังนั้น ถ้า API ที่แสดงภาพ 3D ได้ แล้วทำไม จะทำภาพ 2D ไม่ได้ละ?
จริงๆ ผมอยากจะพูดไปถึงเรื่องเบื้องหลังด้วย แต่เดี๋ยวจะยาวเกินไป ผมเกริ่นให้แล้วกันว่า องค์ประกอบที่เล็กที่สุดของโพลีกอน หรือ Mesh คือ สามเหลี่ยม (Triangle) ซึ่งก็คือที่มาของตัวเลข Million Triangle Per Second ของการ์ดจอ ที่เป็นตัววัดความเร็วว่า ถ้าในจอมีโพลีกอนเท่ากัน ใครจะได้ FPS สูงกว่า แต่เดี๋ยวนี้เราลืมๆ มันไปแล้ว เพราะเราไปเน้นที่ Pixel/Vertex Shader มากกว่า
ดังนั้น ถ้าเราจะวาดภาพ 2D ซึ่งส่วนมาก มันก็คือสี่เหลี่ยม เราก็เลยใช้สามเหลี่ยมสองรูปมาต่อกัน เรียกว่า Quad ครับ แล้วก็ใช้การกำหนด Texture Coordinate (Tu, Tv) นิดหน่อย เราก็จะได้ภาพโพลีกอนสีเหลี่ยม ที่เอาภาพ Texture แปะลงไปได้ดังตัวอย่างด้านบนนั่นเอง
เอาละครับ พอจะเก็ทบ้างนิดๆ แล้วใช่หรือเปล่า มาเริ่มกันที่โค๊ดต่อเลยดีกว่า
เริ่มงาน!
แต่ก่อนที่เราจะไปถึงการวาดกันได้ เราต้องเริ่มใช้ Direct3D ให้เป็นก่อนครับ มาดูกันเลย (เนื่องจาก DirectX แต่ละรุ่น ต่างกัน ผมแนะนำให้ใช้ WM6.5 Emulator นะครับ เพราะผมทดสอบโค๊ดพวกนี้กับ 6.5 เท่านั้น)
- ขั้นแรก Add Reference Microsoft.WindowsMobile.DirectX มาก่อนครับ
- แล้วก็ทำการ Using มันด้วย
- using Microsoft.WindowsMobile.DirectX;
- using Microsoft.WindowsMobile.DirectX.Direct3D;
- จากนั้น เราจะต้องทำการ Initialize ระบบ DirectX ก่อน ให้สร้าง Function ชื่อ InitializeGraphics ขึ้นมา แบบนี้ (อย่าลืมประกาศตัวแปรชนิด Device ไว้ในคลาสด้วยละครับ)
- private Device device;
- public void InitializeGraphics()
- {
- PresentParameters pres = new PresentParameters();
- pres.Windowed = true;
- pres.SwapEffect = SwapEffect.Discard;
-
- device = new Device(0, DeviceType.Default,
- this, CreateFlags.None, pres);
- }
- จากนั้น ทำการ Override Method OnPaintBackground และ OnPaint เพื่อเปลี่ยนเป็นการวาดด้วย Direct3D แทน
- protected override void OnPaintBackground(PaintEventArgs e)
- {
- // Do nothing
- }
-
- protected override void OnPaint(PaintEventArgs e)
- {
- // Sets the surface to black everywhere
- device.Clear(ClearFlags.Target, Color.Black, 1.0F, 0);
- device.BeginScene();
-
- // Drawing Code Goes Here
-
- device.EndScene();
- device.Present();
- }
จะเห็นว่า การวาดนั้น เริ่มจากการ Clear พื้นด้วยสีดำก่อน (ไม่ต้องห่วงครับ ไม่มีการ Flicker เนื่องจากเป็น Double Buffering) แล้วจึงสั่ง Begin Scene เพื่อบอกว่า เราจะเริ่มวาด แล้วจบด้วยการ EndScene และ Present ซึ่งก็คือการ Flip Buffer เอาภาพจาก BackBuffer ที่เราวาดอยู่ ไปแสดงบน Front Buffer หรือหน้าจอนั่นเอง
- จากนั้น เราจะต้องไปแก้ไฟล์ Program.cs นิดหน่อย ดังนี้
- /// <summary>
- /// The main entry point for the application.
- /// </summary>
- [MTAThread]
- static void Main()
- {
- MdxForm mdx = new MdxForm();
- mdx.InitializeGraphics();
- mdx.Show();
- Application.Run(mdx);
- }
นั่นก็คือ เราจะสั่ง InitializeGraphics เพิ่มเข้าไปด้วย แล้วจึงค่อยปล่อยให้ Application.Run จัดการต่อ ทางที่ดีก็คือ ควรจะมีฟังก์ชั่น DisposeGraphics ด้วย เอาไว้คาย Resource คืนระบบ ก่อนออกจาก Main()
- จากนั้น ถ้าทดลองรันดู ก็จะได้หน้าจอสีดำๆ แบบนี้ ที่ Render ด้วย DirectX เรียบร้อย จะลองเปลี่ยนเป็นสีอื่นดูก็ได้นะครับ
โอว เยี่ยม แล้วถ้าจะวาดภาพล่ะ
หลังจากเราได้โครงมาแล้ว คราวนี้เราจะต้องสร้าง Quad และส่วนประกอบอีกมากมายจิปาถะขึ้นมาครับ ผมจะบอกเป็นข้อๆ นะครับ เริ่มจาก Quad ก่อนเลย
Quad (Polygon)
การจะวาดโพลีกอนบน Direct3D เราจะต้องมี Vertex Buffer ครับ สำหรับผู้ที่สงสัยว่าอะไรคือ Vertex มันคือ จุด ที่อยู่บนมุมของสามเหลี่ยมครับ จริงๆ พูดให้ถูกคือ มันคือผู้กำหนดว่า สามเหลี่ยมจะออกมาหน้าตาเป็นอย่างไรมากกว่า เพราะ GPU จะสร้างพื้นผิว โดยค่อยๆ ไล่ดูจากจุดทั้งสามนี้ครับ
Vertex Buffer ที่เราจะสร้าง เราจะสร้างทั้งหมด 4 จุดครับ (อ้าว) เมื่อกี้ผมเพิ่งบอกหยกๆ ว่าใช้สามเหลี่ยม 2 รูป ทำไมมีสี่จุดได้ นั่นก็เพราะเราจะใช้โพลีกอนแบบ Triangle Fan ครับ สำหรับโค๊ดสร้าง Triangle Fan ที่จะออกมาได้ Quad ของเรา มีดังนี้
- private VertexBuffer quad;
- private void CreateQuad()
- {
- this.quad = new VertexBuffer(
- typeof(CustomVertex.PositionTextured), // What type
- 4, // How many
- device, // The device
- 0, // Default usage
- CustomVertex.PositionTextured.Format, // Vertex format
- Pool.SystemMemory);
-
- CustomVertex.PositionTextured[] vertices =
- (CustomVertex.PositionTextured[])this.quad.Lock(0, 0);
-
- vertices[0].X = 0.0f;
- vertices[0].Y = 0.0f;
- vertices[0].Z = 1.0f;
- vertices[0].Tu = 0.0f;
- vertices[0].Tv = 0.0f;
-
- vertices[1].X = 1.0f;
- vertices[1].Y = 0.0f;
- vertices[1].Z = 1.0f;
- vertices[1].Tu = 1.0f;
- vertices[1].Tv = 0.0f;
-
- vertices[2].X = 1.0f;
- vertices[2].Y = -1.0f;
- vertices[2].Z = 1.0f;
- vertices[2].Tu = 1.0f;
- vertices[2].Tv = 1.0f;
-
- vertices[3].X = 0.0f;
- vertices[3].Y = -1.0f;
- vertices[3].Z = 1.0f;
- vertices[3].Tu = 0.0f;
- vertices[3].Tv = 1.0f;
-
- this.quad.Unlock();
- }
สังเกตว่า เรากำหนดให้ Z เป็น 1 ทั้งหมด และเราสร้างภาพสี่เหลี่ยมที่มีขนาด 1 Unit พอดี ส่วน Tu และ Tv นั้น คือ Coordinate ของ Texture ครับ โดย 0,0 หมายถึง มุมบนซ้ายของภาพ Texture และ 1,1 หมายถึง มุมล่างขวาของ Texture
อย่าลืมเพิ่ม CreateQuad() ในฟังก์ชั่น InitializeGraphics ละครับ
Projection, World, View
ทีนี้ ก่อนที่เราจะวาดภาพได้ เราก็จะต้องตั้งค่าการมองของ Direct3D เสียก่อน เนื่องจากว่า เราจะเอาแค่ระนาบ 2 มิติ จึงไม่ต้องมีการกำหนด View Matrix ซึ่งก็คือมุมกล้อง แต่ถ้าจะให้ภาพออกมาเป็นสองมิติแบบไม่เพี้ยนนั้น Projection จะต้องเป็น Orthographic projection ครับ (อ้างอิงจาก http://www.gamedev.net/reference/articles/article1972.asp) ส่วน World Matrix นั้น ตอนนี้ยังไม่ต้องทำอะไรกับมัน เนื่องจากเรายังไม่ได้วาดภาพออกมา เพิ่มโค๊ดพวกนี้ลงไปใน InitializeGraphics ได้เลยครับ
- device.Transform.Projection = Matrix.OrthoLH(this.Width, this.Height, 1f, 10f);
- device.Transform.World = Matrix.Identity;
- device.Transform.View = Matrix.Identity;
Rendering Stage
ในการทำงานของ GPU แบบที่อยู่ใน Windows Phone นั้น เราจะเป็นการสั่งให้มันทำงานเป็นชุดๆ ไป ไม่สามารถโปรแกรมมันด้วย Pixel/Vertex Shader ได้ เฃ่น ถ้าเราอยากจะทำ Alpha Blending เราก็จะต้องเลือก Stage ให้เป็น Alpha Blending แล้วจึงค่อยวาดภาพ (Render) ลงไป ในขณะที่ GPU ปัจจุบัน อยากจะให้แต่ละ Pixel ออกมาเป็นสีอะไร เราก็จะเขียนโปรแกรมคำนวณแบบ Per Pixel กันเลย
สำหรับงานที่เราต้องการ ก็คือการเปิด Alpha Blending เพื่อให้ Texture มันใสได้ครับ ก็ทำได้โดยการใช้โค๊ดดังนี้ใน InitializeGraphics
- device.RenderState.CullMode = Cull.None;
- device.RenderState.Lighting = false;
- device.RenderState.AlphaBlendEnable = true;
- device.RenderState.SourceBlend = Blend.SourceAlpha;
- device.RenderState.DestinationBlend = Blend.InvSourceAlpha;
-
- device.TextureState[0].AlphaOperation = TextureOperation.Modulate;
Texture และ StreamSource
ตอนนี้ เราก็มาถึงขั้นตอนสุดท้าย ก่อนจะเริ่มลงมือ Render กันแล้ว ก็คือการโหลด Texture ซึ่งผมเลือกที่จะกำหนดทุกอย่างเองหมด (จริงๆ จบแค่ชื่อไฟล์ก็ได้) แต่เหมือนว่า กำหนดแบบนี้ ชัวร์กว่าครับ และขั้นสุดท้าย ก็คือการตั้งค่าว่า เวลาที่ GPU Render จะให้เอา Triangle มาจากไหน แน่นอนว่า มันก็คือ VertexBuffer ที่ชื่อ quad ของเรานั่นเอง ใส่โค๊ดนี้เพิ่มใน InitializeGraphics ครับ
- tex = TextureLoader.FromFile(device, Program.Path + "\\beat_brick.png", 96, 96, 1, Usage.Texture, Format.A8R8G8B8, Pool.SystemMemory, Filter.Linear, Filter.None, 0);
- device.SetStreamSource(0, quad, 0);
เรนเดอร์!!!!
เอาละ คราวนี้ก็ถึงตอน Render แล้ว ก่อนเราจะ Render ได้ เราจะต้องคำนวณ Transformation Matrix เพื่อปรับขนาดของ Quad ของเราให้ตรงตามต้องการก่อน (มันถึงมีขนาด 1x1 ไงละ!!!) ซึ่งการคำนวณ Matrix นี้ DirectX เขาก็มีให้แล้ว เราก็แค่สร้าง Matrix ที่เราต้องการ แล้วเอามาคูณกันเท่านั้นเอง ซึ่งเราจะใช้ Matrix 3 ตัว ได้แก่
- Scale เอาไว้ปรับขนาด
- Translate เอาไว้ขยับ
- Rotate เอาไว้หมุน ซึ่งแกนที่เราหมุนคือ แกน Z หรือแกนที่ยิงจากด้านหลังจอมาใส่คนมอง เพราะ X, Y มันก็คือ Left/Top ของจอ
สำหรับ Matrix ที่คำนวณแล้ว ก็เอาไป Set เป็น World Transform เพื่อให้เกิดการ Transform เวลาที่วาด Quad ลงไปครับ สำหรับการวาด Quad นั้นก็ง่ายมาก เราเพียงแค่ทำการ SetTexture แล้วสั่ง DrawPrimitives เท่านั้นเอง และถ้าเราอยากวาดภาพอื่นๆ เราก็ไม่จำเป็นต้องใช้ Quad อันอื่น เราก็ใช้อันนี้ละครับ วาดได้เลย ผมเลยสร้างเป็น Function ขึ้นมาครับ (สำหรับ sz คือ ขนาดของหน้าจอ) อย่าลืมว่า เรากำลังเล่นแบบ Immediate Mode คือการวาดภาพ 3D ลง Buffer โดยตรง เราไม่ได้จำโพลีกอนเอาไว้ หรือจำเอาไว้ว่ามันคือภาพเหมือนเวลาเราทำงานกับ Win Form ครับ มันเหมือนเป็นแค่แปรงระบายสีเท่านั้นเอง
-
- public void Render(Device device, Texture texture, Size sz, RectangleF rDest, float? rotate )
- {
- float X;
- float Y;
- Matrix matTranslation;
- Matrix matScaling;
- Matrix matTransform;
-
- //Get coordinates
- X = rDest.Left - (float)(sz.Width) / 2;
- Y = -rDest.Top + (float)(sz.Height) / 2;
-
- //Setup translation and scaling matrices
- matScaling = Matrix.Scaling(rDest.Width, rDest.Height, 1.0f);
- matTranslation = Matrix.Translation(X, Y, 0.0f);
- matTransform = matScaling * matTranslation;
-
- //Check if quad is rotated
- if (rotate.HasValue)
- {
- //Create rotation matrix about the z-axis
- //Multiply matrices together
- matTransform *= Matrix.RotationZ( rotate.Value );
- }
-
- //Draw the quad
- device.Transform.World = matTransform;
- device.SetTexture(0, texture);
- device.DrawPrimitives(PrimitiveType.TriangleFan, 0, 2);
- }
องศาที่กำหนดด้วยตัวแปร rotation ต้องใช้เป็นเรเดียนนะครับ ถ้าจะแปลงจากองศาเป็นเรเดียน ใช้ 0.0174532925F ได้ครับ (คูณ เร็วกว่าหาร)
ถ้าลองสั่ง Render ใน OnPaint ก็จะได้ผลลัพทธ์ ตามภาพด้านล่างนี้ครับ
- protected override void OnPaint(PaintEventArgs e)
- {
- // Sets the surface to black everywhere
- device.Clear(ClearFlags.Target, Color.PowderBlue, 1.0F, 0);
- device.BeginScene();
-
- // Drawing Code Goes Here
- this.Render(device, tex, this.Size, new RectangleF(0, 0, 96, 96), 0.1f);
-
- device.EndScene();
- device.Present();
- }
ถ้าเกิดรันแล้วมีปัญหา ลองเช็คดูว่า โค๊ด InitializeGraphics นั้นถูกต้องหรือเปล่า ของผมเป็นแบบนี้
- private Device device;
- private Texture tex;
- public void InitializeGraphics()
- {
- PresentParameters pres = new PresentParameters();
- pres.Windowed = true;
- pres.SwapEffect = SwapEffect.Discard;
-
- device = new Device(0, DeviceType.Default,
- this, CreateFlags.None, pres);
-
- device.Transform.Projection = Matrix.OrthoLH(this.Width, this.Height, 1f, 10f);
- device.Transform.World = Matrix.Identity;
- device.Transform.View = Matrix.Identity;
-
- device.RenderState.CullMode = Cull.None;
- device.RenderState.Lighting = false;
- device.RenderState.AlphaBlendEnable = true;
- device.RenderState.SourceBlend = Blend.SourceAlpha;
- device.RenderState.DestinationBlend = Blend.InvSourceAlpha;
-
- device.TextureState[0].AlphaOperation = TextureOperation.Modulate;
-
- this.CreateQuad();
-
- tex = TextureLoader.FromFile(device, Program.Path + "\\beat_brick.png", 96, 96, 1, Usage.Texture, Format.A8R8G8B8, Pool.SystemMemory, Filter.Linear, Filter.None, 0);
- device.SetStreamSource(0, quad, 0);
- }
TIPS
- ถ้าอยากให้มันเรนเดอร์ไปเรื่อยๆ เหมือนเกม ให้เพิ่มว่า this.Invalidate() เป็นบรรทัดสุดท้าย และ Application.DoEvents() เป็นบรรทัดแรก ใน OnPaint จะทำให้จับ Event ได้ และภาพมันก็รันไปเรื่อยๆ ใช้ทำเกมได้ครับ
- อย่าใช้คลาส Sprite มันช้ากว่าราว 10 เท่าได้
- การคำนวณ FPS สามารถทำได้โดยการคำนวณผลต่างระหว่าง Environment.TicksCount สองครั้ง คือ ก่อน และหลัง Render จากนั้น เอาไปส่วน 1000 (1000d / (After – Before))
เอาละครับ วันนี้คงพอแค่นี้ก่อน ขอให้สนุกนะครับ! 