วิธีแก้ปัญหาสระลอยแบบระยะยาว

วันนี้จะคุยกันเล็กน้อยเกี่ยวกับเรื่องการวาดตัวหนังสือครับ เรื่องนี้เป็นเรื่องที่ถ้าเป็นสักสิบห้าปี หรือยี่สิบปีก่อนเนี่ย เป็นเรื่องที่คุยกันเยอะมาก เพราะแอพส่วนใหญ่ในขณะนั้นวาดตัวหนังสือภาษาไทยแล้วมักจะมีปัญหาครับ ที่เห็นบ่อย ๆ คือปัญหาที่เรียกว่า “ปัญหาสระลอย”

ปัญหาสระลอย

ปัญหาสระลอย คือการที่ สระระดับบน (หรือที่เรียกว่าสระลอย) มันไปบังบรรณยุกต์ ทำให้ไม่สามารถแยกแยะบางคำได้ (เช่น ที กับ ที่) ทางแก้ในขณะนั้นก็คือการยกวรรณยุกต์ให้สูงขึ้นไปอีก ซึ่งจะทำให้สระไม่บังวรรณยุกต์ แต่ในกรณีที่ไม่มีสระระดับบน วรรณยุกต์จะลอยเท้งเต้งอยู่บนฟ้าแทน

(ปัญหานี้ผมชอบเรียกว่า ปัญหาวรรณยุกต์จม มากกว่า แต่คำว่าสระลอยเป็นที่รู้จักกันมากกว่า)

เนื่องจากพัฒนาการของระบบการวาดตัวหนังสือ ทำให้เราแทบจะไม่ค่อยเจอปัญหานี้แล้วในปัจจุบัน ระบบปฎิบัติการณ์และ web browser ที่ระบบรองรับการวาดตัวหนังสือที่ซับซ้อน (โดยการใช้ฟอนต์แบบ OpenType) ทำให้การวาดตัวหนังสือนั้นทำได้ถูกต้อง

แต่จากการที่ผมสำรวจ Game Engine ในตลาดส่วนใหญ่นั้นยังคงมีปัญหานี้อยู่ (รวมทั้งตัวที่นิยมกันในตลาดอย่าง Unity3d และ Unreal Engine)

ที่มาของปัญหานี้คือ ในระบบ TrueType ที่นิยมกันในอดีตนั้น Glyph (ภาพตัวอักษร) แต่ละตัวจะมีตำแหน่งตายตัว โดยอ้างอิงจากจุดเริ่มต้นและเส้นบรรทัด (baseline) การวางตำแหน่งจะไม่มีความเกี่ยวโยงกับตัวอักษรอื่น ๆ ที่อยู่รอบ ๆ เช่น ตำแหน่งของวรรณยุกต์จะไม่สัมพันธ์กับสระหรือพยัญชนะ

(ภาพจาก Freetype.org – ลองเข้าไปอ่านสำหรับข้อมูลเพิ่มเติมนะครับ )

และ Game Engine ทุกตัวที่ผมค้นคว้ามานั้น ก็ใช้วิธีการวางตำแหน่งแบบเดียวกับ TrueType ก็เลยวางตำแหน่งผิดเหมือนกัน

วิธีการแก้ไขปัญหาสระลอยแบบทั่วๆ ไป

เท่าที่ผมสังเกต การแก้ปัญหาสระลอยที่เป็นที่นิยมในหมู่นักพัฒนาเกมไทย มักจะใช้วิธีที่เรียกว่า Glyph Substitution (GSUB) หรือการแทนที่ภาพตัวอักษร วิธีที่จะใช้วิธีการตรวจสอบว่า ตัวหนังสือที่อยู่ก่อนหน้าวรรณยุกต์นั้นเป็นตัวไหน แล้วทำการเลือกว่าจะใช้วรรณยุกต์ตัวไหน

  • ถ้าข้างหน้าวรรณยุกต์เป็นสระระดับบน ใช้วรรณยุกต์ที่อยู่สูงเพื่อหลบสระ
  • ถ้าข้างหน้าวรรณยุกต์เป็นพยัญชนะ ใช้วรรณยุกต์ที่อยู่ระดับต่ำ

วิธีนี้เป็นวิธีที่ใช้ในอดีตเหมือนกัน (ก่อนที่ OpenType จะแพร่หลาย) แต่ก็มีข้อเสียตรงที่การวางตำแหน่งจะยังตายตัวอยู่ อย่างการเขียนคำว่า ปี่ นั้นอาจจะได้ผลที่ไม่ถูกต้อง เพราะว่าต่อให้ปรับระดับความสูงต่ำตามกฎข้างบน หางของตัว ป ก็ยังคงไปปิดสระหรือพยัญชนะนั้นอยู่ดี

การที่จะวางวรรณยุกต์ (และสระระดับบน) ให้ถูกต้องด้วยกฎตายตัวนั้นทำให้สวยได้ยาก เพราะมีกรณีจำนวนมากที่จะต้องจัดการในโค๊ด รวมทั้งจำนวน Glyph และ Glyph Metrics ที่จะต้องโหลดเข้าไปอีก

วิธีการแก้ไขปัญหาสระลอยแบบ OpenType

OpenType เป็น super set ของ TrueType โดยมีการเพิ่มฟีเจอร์ต่าง ๆ เข้าไปในมาตรฐานเดิม สำหรับภาษาไทยก็มีฟีเจอร์ที่สามารถนำมาใช้เพื่อทำให้สามารถวาดตัวหนังสือได้ถูกต้อง อย่างการแทนภาพตัวอักษร หรือการวางตำแหน่งภาพตัวอักษรใหม่ (Glyph Positioning — GPOS) Microsoft ได้แนะนำว่า สำหรับฟอนต์ภาษาไทยควรมีฟีเจอร์ดังต่อไปนี้

ภาพจาก Microsoft — สำหรับรายละเอียดของฟีเจอร์แต่ละตัวนั้นสามารถดูได้จากเว็บไซท์ของ Microsoft นะครับ

ทีนี้ถ้าเราดูในตารางจะเห็นว่า มีแค่ฟีเจอร์สองตัวสุดท้ายเท่านั้นที่ถูกระบุว่า “Required” เท่านั้น ฟีเจอร์ด้านบนไม่ได้สำคัญมาก กล่าวคือ มันเป็นฟีเจอร์ที่ทำให้เรนเดอร์ประโยคออกมาได้สวยงาม แต่ไม่ได้จำเป็นสักเท่าไหร่ อย่าง ccmp เนี่ย ที่เห็นชัด ๆ คือ คำว่า “กตัญญู” ตัว ญ + ู นั้นตามหลักแล้วเราไม่จำเป็นจะต้องเขียนเชิงของตัว ญ ซึ่ง ccmp ของภาษาไทยมันจะมีกฎข้อนี้ระบุอยู่ แต่ถ้าเขียนไว้มันก็ไม่ได้เสียหายครับ ตราบใดที่ไม่ได้เขียนทับกัน

หรือ kerning ภาษาไทยเนี่ยแทบไม่ได้จะจำเป็นเลย

ดังนั้น ถ้าเราจะนำวิธีของ OpenType มาใช้เนี่ย เราทำแค่ mark-to-base (mark) กับ mark-to-mark (mkmk) ก็พอแล้วครับ

Mark กับ Base

การวางตำแหน่งแบบ mark-to-mark กับ mark-to-base นั้นจริง ๆ คล้ายกันมาก ๆ คือ มันจะเป็นการจับคู่กันระหว่างตัวอักษรสองตัว ตัวอักษรแต่ละตัวจะมีตำแหน่งตะขอ (anchor) อยู่ เราก็แค่เอาตะขอของตัวอักษรตัวหลังมาเกี่ยวกับตัวหน้าซะ ก็คือ เราจะเลื่อนตำแหน่งของภาพตัวอักษรที่อยู่ข้างหลังมาจนกว่าตำแหน่งตะขอของตัวหลังนั้นไปทับกับตัวหน้าครับ

ยกตัวอย่าง เช่น คำว่า ป่า เนี่ย เราก็จะวางแบบนี้

ทีนี้ mark กับ base แตกต่างกันยังไง ? โดยทั่วไป mark จะเป็นเครื่องหมายที่ลอยอยู่ด้านบน หรือลอยอยู่ด้านใต้ของ base ครับ ส่วน base ก็มักจะเป็นพวกตัวอักษรที่อยู่บนเส้นบรรทัด (baseline) อีกอย่างหนึ่งก็คือ ตัวอักษรที่เป็น mark จะอยู่ข้างหลัง base เสมอครับ โดยตัวอักษร base ที่ mark จะไปเกาะอยู่นั้นไม่จำเป็นต้องอยู่ติดกันก็ได้ แค่เป็นตัวอักษรตัวสุดท้ายที่อยู่หน้า mark ก็ถือว่าเป็นคู่ที่เกาะกันครับ เช่นคำว่า “ปู่” เป็นต้น คำนี้ประกอบด้วยตัวอักษรสามตัว คือ ป ู และ ่ ซึ่งทั้ง ูและ ่ นั้นก็เกาะอยู่กับ ป ทั้งนั้น แค่อยู่กันคนละจุด

จากตัวอย่างข้างบนก็น่าจะเห็นคุณสมบัติอีกอย่างหนึ่งของ base ก็คือ มันมีตะขอได้หลายจุดนั้นเอง และที่ mark เองก็ต้องระบุได้ว่าจะเกาะกับตะขอไหนของ base ด้วย

Mark กับ Mark

mkmk จะเป็นการจับคู่ตัวอักษรที่เป็น mark ด้วยกัน อย่างเช่นคำว่า ปี่ เป็นต้น คำนี้จะใช้ mark-to-base ตรงคู่ของ ป กับ ี แล้วคู่หลังจะเป็น mark to mark ครับ

ความแตกต่างระหว่าง mark-to-mark กับ mark-to-mark นั้นก็คือ mkmk จะเป็นจับคู่ระหว่าง mark ที่อยู่ติดกันเท่านั้น (mark-to-base จะจับระหว่าง mark กับ base ที่ใกล้ที่สุดแต่ไม่จำเป็นต้องติดกัน) และ mark-to-mark นั้น ตัวอักษรตัวหน้าจะมี anchor แค่จุดเดียวเท่านั้น

การ implement

ข่าวไม่ดีอันแรกคือ ผมยังไม่เคยเห็นใครสร้าง font สำหรับเกมที่มีฟีเจอร์การปรับตำแหน่งด้วยตะขอแบบนี้เล่าให้ฟังมาข้างบนเลยครับ (อาจจะมีเป็น plugin ของ Unity3d แต่ผมไม่เคยลองนะ) ดังนั้น เราอาจจะต้องสร้างเอง หรือ … ไปบีบให้ผู้สร้าง Engine เขาทำมาใส่ Engine ให้หน่อย

สำหรับคนที่เลือกที่จะทำเอง วิธีที่ง่ายที่สุดก็คือการสร้างฟอนต์จากฟอนต์แบบ OpenType ที่มีอยู่แล้ว แต่การอ่านไฟล์ OTF/TTF นั้นออกจะเป็นเรื่องที่โหดร้ายไปหน่อยนึง (ตัวไฟล์มีลักษณะเป็น BigEndian Binary ประกอบด้วยตารางย่อย ๆ มากมาย และอ่านลำบากพอสมควร) ผมพบว่ามี Utility บางตัวที่สามารถแตกข้อมูลไบนารีออกมาให้อยู่ในรูปที่อ่านได้ง่ายขึ้น เช่น OTFCC ที่สามารถดัมป์ไฟล์ ttf/otf ให้เป็น json ได้

จากไฟล์ json ผมเขียน tool สำหรับ สร้าง glyph texture และ font file ที่เป็นฟอร์แมทส่วนตัว ข้างในมี กฎการทำ glyph positioning และอื่น ๆ เอาไว้ แล้วก็สร้างตัวอ่านไฟล์ + ตัววาดตัวอักษรในเอนจินของตัวเอง

ข้างล่างนี้เป็นสกรีนชอตจากโปรเจคตัวอย่างที่ผมทำอยู่ครับ (ฟอนต์ที่ใช้เป็น Noto Sans UI Thai ตัวอัลฟ่าครับ) ตัวสีขาวเป็นตัวที่มีการทำการวาง layout ใหม่ ส่วนตัวสีเทาคือตัวที่ใช้วิธีแบบ TrueType เหมือนเอนจินทั่ว ๆ ไป

เรื่อง performance impact ก็มีบ้างพอสมควรครับ เพราะต้องทำลูปหลายรอบอยู่กว่าจะคำนวนหมด ทริคคือพยายามคำนวน layout แค่ครั้งเดียวแล้วใช้ไปเรื่อย ๆ อย่าคำนวนใหม่บ่อย ๆ