วาดตัวหนังสือบน SDL2 ด้วย FreeType (ตอนที่ 2)

วันนี้จะมาลองเขียนโค๊ดกันจริง ๆ จัง ๆ แล้ว

โค๊ดที่ผมจะเขียน ก็ตามหัวข้อเลย คือใช้ SDL2 เป็นตัววาดหลัก ถ้าเกิดว่าคุณ ๆ ทั้งหลายถนัดใช้ API ตัวอื่นก็แล้วแต่ตามสะดวกครับ คอนเซพท์นั้นคล้าย ๆ กัน ต่างกันตรงที่ว่าเราจะใช้อะไรวาดก็เท่านั้นเอง

สำหรับ SDL2 นั้นจะมีส่วนที่เป็น Renderer เพิ่มขึ้นมา จากเดิมนั้น SDL จะใช้การ Blit Surface ซึ่ง Renderer นั้นจริง ๆ ก็เป็นการใช้งาน HW Acceleration นั่นเอง ก็คือถ้าคอมไพล์มาให้รองรับ OpenGL ก็ใช้ OpenGL เป็นตัววาดครับ (ไม่ยืนยันนะ ไม่มั่นใจเหมือนกัน) สำหรับท่านที่อยากลองใช้ SDL2 ก็ต้องไปดึงโค๊ดจาก Repository ของ SDL มาคอมไพล์เอง เพราะตอนนี้ยังอยู่ระหว่างการพัฒนา และอาจจะไม่เถียรพอที่จะเอาไปใช้งานจริงจังได้ แต่สำหรับงานนี้เรียกได้ว่านิ่งเกินพอครับ

Code ตัวอย่าง สามารถดึงลงไปได้จาก git://github.com/wutipong/drawtext-sdl2-freetype2-harfbuzz.git นะครับ จะได้ไม่ต้องพิมพ์เอง (จริง ๆ มันมีแค่ไฟล์เดียวแหละ 555) ของวันนี้จะชื่อ sdl-ft-1 อ้อไฟล์ฟอนท์ก็หาเอาเองนะครับ

โครงโปรแกรม (เฉพาะส่วน SDL)

เริ่มต้นกันเลย ผมเริ่มจากโครงของโปรแกรมก่อนนะครับ โครงสร้างก็มีดังนี้

  1. Init SDL ขึ้นมา ใส่ค่าอะไรก็ได้ขอให้มี SDL_INIT_VIDEO อยู่ด้วยเป็นอันใช้ได้ หรือจะใช้ SDL_INIT_EVERYTHING แบบนี้ก็ได้
  2. สร้าง object SDL_Window ขึ้นมา เอาไว้อ้างอิงถึงหน้าต่างที่จะเปิดขึ้นมา
  3. สร้าง SDL_Renderer ขึ้นมาสำหรับ SDL_Window ที่สร้างขึ้นมาตอนแรก 
  4. ใน Main Loop เราก็จะทำการดึง event ขึ้นมา ถ้ามีการสั่งปิดก็ให้ออกจาก loop ซะ จะได้ทำการปิดโปรแกรม
  5. ใน loop เช่นก้น ก็ใส่โค๊ดที่วาดภาพบนหน้าจอลงไป แล้วปิดด้วย SDL_Delay เพื่อคืน CPU ให้ process อื่น สำคัญมาก ไม่เช่นนั้น Task Scheduler จะคิดว่าโปรแกรมเราค้าง แล้วจะปิดโปรแกรมเราไปดื้อ ๆ ครับ
  6. ก่อนออกจากโปรแกรมก็ clean up resource ต่าง ๆ ให้เรียบร้อย
int main(int argc, char **argv) {
SDL_Init(SDL_INIT_EVERYTHING);
SDL_Window* window = SDL_CreateWindow("Test Window",
SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED, 800, 600, 0);

SDL_Renderer* renderer = SDL_CreateRenderer(window, -1, 0);

//Initialize FreeType library object
//Initialize FreeType font object

while (true) {
SDL_Event event;
if (SDL_PollEvent(&event)) {
if (event.type == SDL_QUIT)
break;
}

SDL_SetRenderDrawColor(renderer, 0x50, 0x82, 0xaa, 0xff);
SDL_RenderClear(renderer);

//Draw Text into the renderer

SDL_RenderPresent(renderer);
SDL_Delay(10);
}

//Clean up FreeType font object
//Clean up FreeType library object

SDL_DestroyWindow(window);
SDL_Quit();
return 0;
}

จะเห็นว่ายังไม่มีโค๊ดส่วนของ FreeType เลย แต่น่าจะเห็นในคอมเม้นแล้วว่าผมมีทำอะไรไว้ เดี๋ยวจะค่อย ๆ อธิบายละกัน

ใส่ FreeType ลงไป

ตัว FreeType นั้นเราจะมี object หลักหนึ่งตัว คือ FT_:Library ซึ่งเราจะใช้แค่ตัวเดียว สร้างตอนเปิดโปรแกรม ลบทิ้งตอนปิดโปรแกรม เราใช้คำสั่ง FT_Init_FreeType และ FT_Done_FreeType เป็นตัวสร้างและทำลาย object นี้ครับ

FT_Library library;
FT_Init_FreeType(&library);
....
FT_Done_FreeType(library);

ง่าย ๆ ใช่ไหมครับ ?

ต่อไปคือ เราจะสร้าง object FT_Face ขึ้นมา เป็นการโหลดไฟล์ฟอนท์ขึ้นมานั่นเอง FT_Face นั้นจะเก็บข้อมูลทั้งส่วนที่เกี่ยวข้องกับฟอนท์โดยรวม และข้อมูลของ glyph ปัจจุบัน ก็เรียกใช้ฟังก์ชั่น FT_New_Face และ FT_Done_Face สำหรับสร้างและทำลาย

FT_Face face;
FT_New_Face(library, fontpath, 0, &face);
...
FT_Done_Face(face);

หลังจากที่เราโหลดไฟล์ฟอนท์ขึ้นมาแล้ว สิ่งต่อไปที่ต้องทำคือ กำหนดขนาดของฟอนท์ เรียกฟังก์ชั่น FT_Set_Pixel_Sizes() เพื่อกำหนดขนาดครับ สิ่งหนึ่งที่ต้องระวังก็คือ ฟอนท์ที่แตกต่างกันในขนาดเท่ากัน จะมีขนาดไม่เท่ากันครับ ขึ้นอยู่กับผู้ออกแบบ

FT_Set_Pixel_Sizes(face, 0, 64); //64px height.

ถึงตรงนี้เรายังไม่ได้วาดตัวหนังสือลงไป แต่ได้โปรแกรมตัวโครงที่สมบูรณ์แล้ว ก็ออกมาเป็นแบบนี้ครับ

int main(int argc, char **argv) {
SDL_Init(SDL_INIT_EVERYTHING);
SDL_Window* window = SDL_CreateWindow("Test Window",
SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED, 800, 600, 0);

SDL_Renderer* renderer = SDL_CreateRenderer(window, -1, 0);

FT_Library library;
FT_Init_FreeType(&library);

FT_Face face;
FT_New_Face(library, "./font/LayijiMahaniyom-Bao-1.2.ttf", 0, &face);
FT_Set_Pixel_Sizes(face, 0, 64);

while (true) {
SDL_Event event;
if (SDL_PollEvent(&event)) {
if (event.type == SDL_QUIT)
break;
}

SDL_SetRenderDrawColor(renderer, 0x50, 0x82, 0xaa, 0xff);
SDL_RenderClear(renderer);

//Draw Text into the renderer

SDL_RenderPresent(renderer);
SDL_Delay(10);
}

FT_Done_Face(face);
FT_Done_FreeType(library);

SDL_DestroyWindow(window);
SDL_Quit();
return 0;
}

เริ่มวาดตัวอักษร

มาถึงจุดนี้แล้วเราจะเริ่มวาดกันละ เราจะใช้ FreeType ในการดึงเอาข้อมูล Glyph Metric และเป็นตัวเรนเดอร์ Glyph ให้เป็นภาพบิทแมพ 
ขั้นแรกเราก็จะเริ่มจากโหลด Glyph ของตัวอักษรที่ต้องการเข้าไปก่อน ก็เรียกใช้ฟังก์ชั่น FT_Load_Char() ครับ โดยผมจะระบุ flag ตัวหนึ่งคือ FT_LOAD_RENDER ลงไปเพื่อให้ FreeType นั้นทำการเรนเดอร์ Glyph ขึ้นมาเลย (ถ้าไม่ระบุตอนนี้ก็สั่งให้เรนเดอร์ทีหลังได้เช่นกัน)
FT_Load_Char(face, charcode, FT_LOAD_RENDER);
ถึงจุดนี้ ตัว object face จะมีข้อมูล glyph metric สำหรับ charcode ที่เราใส่ลงไป และมีภาพ glyph เรียบร้อย โดยภาพ glyph จะอยู่ใน face->glyph->bitmap ครับ ส่วน glyph metric นั้นจะอยู่ที่ face->glyph->metrics
ภาพ glyph ที่อยู่ใน glyph->bitmap เนี่ยจะมี type เป็น FT_Bitmap โดยตัวมันเองจะเก็บข้อมูลแต่ละ pixel เป็นค่าความทึบของแต่ละจุด (opacity) ซึ่งเราจะมองเป็นเป็น alpha channel ก็ได้

*note มีบางกรณีที่ FT_Bitmap เก็บค่าเป็น RGB แต่ผมไม่พูดถึงนะครับ

ปัญหาคือ ภาพ glyph ที่อยู่ใน face นั้นจะไม่สามารถนำมาใช้ได้โดยตรง เราต้องทำการสร้าง SDL_Surface ใหม่ขึ้นมาแล้ววาดภาพ glyph ลงไป ผมสร้างฟังก์ชั่นใหม่ขึ้นมาเพื่อการนี้โดยเฉพาะ เพราะว่ามันซับซ้อนประมาณนึง

void CreateSurfaceFromFT_Bitmap(const FT_Bitmap& bitmap,
const unsigned int& color, SDL_Surface*& output) {
output = SDL_CreateRGBSurface(0, bitmap.width, bitmap.rows, 32, 0x000000ff,
0x0000ff00, 0x00ff0000, 0xff000000);

SDL_FillRect(output, NULL, color);

SDL_LockSurface(output);

unsigned char *src_pixels = bitmap.buffer;
unsigned int *target_pixels =
reinterpret_cast(output->pixels);

for (int i = 0; i < bitmap.rows; i++) {
for (int j = 0; j < bitmap.width; j++) {
unsigned int pixel = target_pixels[i * output->w + j];
unsigned int alpha = src_pixels[i * bitmap.pitch + j];

pixel &= (alpha << 24) | 0x00ffffff;

target_pixels[i * output->w + j] = pixel;
}
}
SDL_UnlockSurface(output);
}

โดยหลัก ๆ ก็คือ สร้าง surface ใหม่ขึ้นมาให้มีขนาดเท่ากับ FT_Bitmap ต้นฉบับ ถมสีลงไป แล้วไปปรับแก้ alpha ของแต่ละจุดให้ตรงกับค่าสีใน FT_Bitmap ต้นฉบับนั่นเอง

เมื่อถึงจุดนี้เราจะได้ภาพตัวอักษรกันแล้ว ต่อไปก็ไปดูกันที่ glyph metric ซึ่งไม่มีอะไรพิสดารเลย เป็นแค่ตัวแปรที่อยู่ภายใน glyph->metric อีกที โดยเราใช้ตัวแปรสามตัวนี้ครับ

  1. horiBearingX
  2. horiBearingY
  3. horiAdvance
ใน structure ที่เก็บค่า glyph metric นั้นจะมีตัวแปรอีกสามตัว เอาไว้ใช้ในตอนวาดในแนวตั้ง ซึ่งผมไม่พูดถึงนะครับ
ปัญหาต่อไปที่จะเจอคือ ค่าตัวแปรสามตัวนี้เก็บค่าเป็น Fixed Point decimal ครับ ก็คือ 26 บิทแรกจะเป็นจำนวนเต็ม และ อีก 6 บิทที่เหลือเป็นจุดทศนิยม เพื่อความสะดวก ผมจะใช้วิธีการ shift ไปทางขวาไป 6 บิทเพื่อล้างเอาส่วนทศนิยมออกไปให้หมดเลย (ไหน ๆ ก็ไม่ได้ใช้อยู่แล้ว) 
เวลาวาดลงไปก็จะมีขั้นตอนประมาณนี้ครับ
  1. กำหนดเส้นบรรทัด (baseline) และจุดเริ่มต้น (origin)
  2. โหลด glyph ขึ้นมาด้วยคำสั่ง FT_Load_Char()
  3. วาดภาพ glyph ลงไปใน SDL_Surface
  4. กำหนดจุดเริ่มต้นของภาพ glyph โดย 
    1. เลื่อนภาพไปจากจุด origin เป็นระยะ bearing x ไปทางขวาเมื่อค่าเป็น + และทางซ้ายเมื่อเป็นลบ
    2. เลื่อนขึ้นข้างบนจากจุด origin เป็นระยะ bearing y เมื่อค่าเป็นบวก และเลื่อนลงเมื่อเป็นลบ
  5. วาด glyph ลงไป
  6. กำหนดจุด origin ใหม่ไปทางด้านขวาของจุดเดิม โดยเพิ่มเป็นระยะ advance 
  7. วนกลับไปที่ 2.
อ่านแล้วน่าจะงง ดูโค๊ดกันเลย
void DrawText(const std::wstring& text, const unsigned int& color,
const int& baseline, const int& x_start, const FT_Face& face,
SDL_Renderer*& renderer) {

SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);

int x = x_start;

for (unsigned int i = 0; i < text.length(); i++) {
FT_Load_Char(face, text[i], FT_LOAD_RENDER);

SDL_Surface* surface = NULL;
CreateSurfaceFromFT_Bitmap(face->glyph->bitmap, color, surface);
SDL_Texture* glyph_texture = SDL_CreateTextureFromSurface(renderer,
surface);

SDL_Rect dest;
dest.x = x + (face->glyph->metrics.horiBearingX >> 6);

dest.y = baseline - (face->glyph->metrics.horiBearingY >> 6);
dest.w = surface->w;
dest.h = surface->h;

SDL_RenderCopy(renderer, glyph_texture, NULL, &dest);

x += (face->glyph->metrics.horiAdvance >> 6);

SDL_DestroyTexture(glyph_texture);
SDL_FreeSurface(surface);
}
}

ก็จบแล้ว สุดท้ายก็แค่เรียกฟังก์ชั่นนี้ใน main loop เท่านั้นเอง โค๊ดสุดท้ายก็จะมีหน้าตาแบบนี้

 int main(int argc, char **argv) {
SDL_Init(SDL_INIT_EVERYTHING);
SDL_Window* window = SDL_CreateWindow("Test Window",
SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED, 800, 600, 0);

SDL_Renderer* renderer = SDL_CreateRenderer(window, -1, 0);

FT_Library library;
FT_Init_FreeType(&library);

FT_Face face;
FT_New_Face(library, "./font/LayijiMahaniyom-Bao-1.2.ttf", 0, &face);
FT_Set_Pixel_Sizes(face, 0, 64);

while (true) {
SDL_Event event;
if (SDL_PollEvent(&event)) {
if (event.type == SDL_QUIT)
break;
}

SDL_SetRenderDrawColor(renderer, 0x50, 0x82, 0xaa, 0xff);
SDL_RenderClear(renderer);

DrawText(text, 0xffffffff, 300, 30, face, renderer);

SDL_RenderPresent(renderer);
SDL_Delay(10);
}

FT_Done_Face(face);
FT_Done_FreeType(library);

SDL_DestroyWindow(window);
SDL_Quit();
return 0;
}

อันนี้คือภาพสกรีนชอตทดลองนะครับ

ฟอนท์ เลย์อิจิ มหานิยม เบา 1.2
ก็ออกมาสวยสมใจอยู่นะครับ 🙂 

ทิ้งท้าย

ปัญหาของวิธีนี้มีอยู่บ้างก็คือว่า มันกินแรงซีพียูมหาศาลครับ เพราะว่ามันวาดใหม่ตลอดเวลา เครื่องผม (Phenom X4) นั้นมีเฟรมเรทแค่ราว ๆ 20-30 fps เท่านั้นเอง คราวหน้าจะมาแนะนำเทคนิคที่จะมาช่วยในจุดนี้ครับ 
ปัญหาที่สองก็คือ วรรณยุกต์ลอย/จม ซึ่งจะเป็นทุกฟอนท์ แค่ว่าจะลอย หรือจะจม เท่านั้นเอง เหตุผลก็คือการวาดตัวหนังสือในลักษณะนี้จะไม่มีการเอาตัวอักษรที่อยู่ข้างหน้า และข้างหลังวรรณยุกต์ มาคำนวนหาระยะที่ถูกต้อง มันจะใช้ระยะที่กำหนดตายตัวมาสำหรับ glyph นี้มาใช้ ดังนั้นผู้ออกแบบจึงมีทางเลือกแค่กำหนดให้มันลอยให้พ้นสระระดับบนเท่านั้นเอง ผมจะพูดถึงเรื่องการใช้งาน library อื่น ๆ เพื่อเอาฟีเจอร์ที่ฝังอยู่ในฟอนท์มาช่วยแก้ปัญหานี้ในโอกาสต่อไปครับ
อันนี้เป็นตัวอย่างของกรณีที่วรรณยุกต์จมนะครับ
ฟอนท์ ไทยแซนส์ นอย
จะเห็นว่า ตรงคำว่า “ไม่รู้” กับ “ซะแล้ว” เนี่ย วรรณยุกต์สวยเลยละ แต่คำว่า “ค่ำ” กับ “กี่โมง” เนี่ย ไม้เอกหายไปเลย เพราะว่ามันจมครับ

Credit 

ใส่ความเห็น

อีเมลของคุณจะไม่แสดงให้คนอื่นเห็น ช่องข้อมูลจำเป็นถูกทำเครื่องหมาย *

This site uses Akismet to reduce spam. Learn how your comment data is processed.