Lulucat

ประสิทธิภาพของเครื่องมือชอล์ก: จาก full-screen pass สู่ scissor rectangle

Gaoge ZhangGaoge Zhang

เครื่องมือชอล์กใน Lulucat Notes ช้าลงในบริเวณที่มีลายมือหนาแน่น คอขวดไม่ใช่จุดตัวอย่างอินพุต 3,571 จุด แต่คือการทำ full-screen scratch pass 70 ครั้งต่อ frame แคชความละเอียดต่ำที่ถูกปฏิเสธและ scissor rectangle ต่อ stroke บอกเล่าเรื่องราวที่เหลือ

ภาพครอบจาก Lulucat Notes บน iPad ที่ซูม 255% แสดงลายมือด้วยชอล์กสีแดงและน้ำเงินของข้อความจีนคลาสสิก พร้อมส่วนหนึ่งของแถบเครื่องมือแอป

ชอล์กสีแดงและน้ำเงินใน device build สุดท้าย, 155 strokes ที่ซูม 255%

เครื่องมือชอล์กใน Lulucat Notes มีปัญหาด้านประสิทธิภาพที่เฉพาะเจาะจง: การเขียนบนพื้นที่ว่างยังลื่นไหล แต่เมื่อเลื่อนไปยังบริเวณที่มี stroke ชอล์กอยู่เต็ม ปลายปากกาจะเริ่มตามไม่ทัน การเขียนต่อในบริเวณเดิมยังทำให้การแพน canvas ช้าลงเรื่อย ๆ ด้วย

หน้าเดียวที่เขียนด้วยลายมือทั่วไปก็เพียงพอที่จะทำให้ปัญหาเกิดขึ้น: ซูม 300% มี stroke ชอล์กที่มองเห็นได้ 70 เส้นในบริเวณนั้น รวมจุดตัวอย่างอินพุต 3,571 จุด พื้นที่ว่างยังคงลื่นไหล มีเพียงบริเวณที่ stroke เหล่านี้กระจุกตัวอยู่เท่านั้นที่ช้าลง

หลังแก้ไข หน้ากระดาษเดิมยังรับการเขียนใหม่ต่อได้ที่ซูม 255% และ stroke เดิมยังคงความคมชัดเต็มที่ทั้งตอนแตะปากกาและตอนแพน

Lulucat Notes บน iPad ที่ซูม 255% แสดงลายมือชอล์กสีแดงและน้ำเงิน ข้อความอ่านว่า “天行健,君子以自强不息;地势坤,君子以厚德载物” — ข้อความจีนคลาสสิก มาสคอต Lulucat สีน้ำเงินอยู่มุมขวาบน แถบเครื่องมือด้านล่างแสดงจำนวน stroke 155, Save, Clear และแถบเลื่อนซูม 255%

ภาพหน้าจอจากการทดสอบบนอุปกรณ์, รวม 155 strokes ที่ระดับซูมนี้ ความคมชัดของ stroke เดิมจะไม่เปลี่ยนชั่วคราวไม่ว่าจะตอนแตะปากกาหรือตอนแพน

ทำไมชอล์กจึงต้องมี scratch texture

ปากกาทั่วไปสามารถ composite circular stamp แต่ละอันลงบน ink texture โดยตรงด้วย source-over blending ได้ ชอล์กเพิ่มชั้น grain-gating เข้ามา: renderer จะสะสม body coverage และ depth ของ stroke ทั้งเส้นก่อน จากนั้นใช้ grain texture คงที่เพื่อตัดสินว่าตำแหน่งใดจะได้รับฝุ่นชอล์ก แล้วจึง composite ผลลัพธ์ลงบนหมึกเดิม

scratch texture นี้แยก stroke ชอล์กแต่ละเส้นออกจากกัน การแยกมีความสำคัญเพราะ stamp ภายใน stroke เดียวกันทับซ้อนกันมาก หาก grain-gate stamp แต่ละอันแยกกัน เส้นกึ่งกลางของ stroke จะสะสมสีซ้ำ และรูพรุนของชอล์กจะเลื่อนไปตามความหนาแน่นของการ sampling

ที่ระดับซูมสูง Lulucat Notes จะวาด vector stroke ที่มองเห็นได้ใน viewport ปัจจุบันใหม่ implementation เดิมทำขั้นตอนต่อไปนี้กับ stroke ชอล์กที่มองเห็นได้แต่ละเส้น:

  1. จบ main render encoder;
  2. ล้าง scratch texture;
  3. วาด stroke ชอล์กเส้นนี้ลงใน scratch;
  4. เปิด main render encoder อีกครั้ง;
  5. composite scratch กลับไปยัง drawable ด้วย full-screen triangle

ความหมายของ stroke เดียวถูกต้อง แต่ขอบเขตงานใหญ่เกินไปมาก drawable ของ iPad มีขนาด 2732×2048 — ประมาณ 5.6 million pixels ทุก stroke ชอล์กจะกระตุ้นหนึ่ง scratch pass และหนึ่ง full-screen composite ชอล์ก 70 strokes หมายถึงประมาณ 141 render encoders และ 70 full-screen composites

ให้จำนวน stroke ชอล์กที่มองเห็นได้เป็น และจำนวนพิกเซลของ drawable เป็น หากพิจารณาเฉพาะงานที่เพิ่มตามพื้นที่พิกเซล implementation เดิมใกล้เคียงกับ

stroke ชอล์กแต่ละเส้นยังมีค่าใช้จ่ายคงที่ของ render pass ด้วย ดังนั้นต้นทุนส่วนนี้จึงเพิ่มเป็นเส้นตรงตาม เช่นกัน จุดอินพุต 3,571 จุดมีต้นทุนเพียงส่วนรอง สิ่งที่เพิ่มตามจำนวน stroke ในพื้นที่คือขอบเขตงานแบบเต็มหน้าจอที่แต่ละ stroke กระตุ้นขึ้นมา

วัดผลบน 12.9-inch iPad Pro (5th generation, M1) ที่ใช้ iPadOS 18.6.2 เราเปรียบเทียบ GPU timestamp จาก viewport เดียวกันก่อนและหลังการเปลี่ยนแปลง โดยใช้ command-buffer timestamp ใน Debug device build เดียวกันบน iPad เครื่องนี้โดยเฉพาะ — ต่อไปจะเรียกว่า LucasPad ช่วงค่าด้านล่างเป็นความผันผวนทั่วไปจาก log หลาย frame ไม่ใช่คำมั่นเรื่อง frame rate ของเวอร์ชันที่จะจัดส่ง เมื่อมี stroke ชอล์กที่มองเห็นได้ 70 เส้น หนึ่ง frame มักใช้ GPU time 52–60 ms; ในบริเวณที่มีประมาณ 120 strokes GPU time เพิ่มเป็น 77–80 ms

เมื่อประมาณจากพื้นที่ full-screen rectangle ของ scratch และ composite pass ขอบเขตงานตามทฤษฎีต่อ frame เพิ่มจากประมาณ 783 million pixels เป็น 1.34 billion pixels ตัวเลขนี้เป็นผลรวมของพื้นที่สี่เหลี่ยม และไม่เท่ากับจำนวน fragment invocation, จำนวนไบต์ที่อ่าน/เขียนใน video memory หรือ GPU hardware counters การทำ fast clear, attachment load/store และ pass switching ของ Metal ยังคงอยู่ภายใต้การควบคุมของ GPU และไดรเวอร์

นี่อธิบายได้เช่นกันว่าทำไมพื้นที่ว่างยังลื่นไหล visibility culling จะข้าม stroke ที่อยู่นอก viewport; ในพื้นที่ว่างใกล้ศูนย์ ส่วน ในพื้นที่หนาแน่นยังเพิ่มขึ้นเรื่อย ๆ

คำตอบที่ผิดที่ 0.85 ms

แอปมี ink texture เต็มหน้าที่ bake ไว้แล้วที่ 2 พิกเซลต่อ point เราลองแสดง texture นี้โดยตรงระหว่างการเขียน การแพน และการซูม โดยเก็บเฉพาะ stroke ของ Apple Pencil ปัจจุบันไว้เป็น live vector; เมื่อ interaction จบลง จะใช้เพิ่มอีกหนึ่ง frame เพื่อ render ผลลัพธ์ vector ความละเอียดสูงใหม่

แนวทางนี้ทำงานได้ดีมาก ในบริเวณหนาแน่นเดียวกันที่ซูม 300% GPU time ลดลงเหลือ 0.84–0.85 ms และไม่เพิ่มขึ้นตามจำนวน stroke ชอล์กเดิมอีกต่อไป

แต่ปัญหาบนอุปกรณ์จริงก็ชัดเจนไม่แพ้กัน ที่ซูม 300% ต้องใช้ประมาณ 6 พิกเซลบนหน้าจอต่อ point แต่ cache ให้มาเพียง 2 ทันทีที่ Apple Pencil แตะลง stroke เดิมทั้งหมดจะกลายเป็นภาพความละเอียดต่ำที่นุ่มเบลอ และเมื่อยก Pencil ขึ้นจึงจะกลับมาคมชัดเต็มที่

ผู้ทดสอบพูดว่า: “ตอนเขียนอยู่ ทั้ง canvas จะเบลอไปหมด พอปล่อยก็กลับมาคมชัด”

เราถอด optimization นี้ออก 0.85 ms เป็นผลลัพธ์ที่วัดได้ต่ำที่สุด แต่ไม่ใช่เครื่องมือชอล์กที่ยอมรับได้ stroke เดิมเป็นส่วนหนึ่งของ feedback ขณะเขียน ความคมชัดของมันเปลี่ยนตอนแตะปากกาไม่ได้

จำกัด stroke ชอล์กแต่ละเส้นไว้ในสี่เหลี่ยมของตัวเอง

การแก้ไขสุดท้ายยังคง scratch ต่อ stroke และ compositing ต่อ stroke ไว้ เพียงลดขอบเขตงานพิกเซลของทั้งสองอย่าง stroke แต่ละเส้นมี canvas bounding box อยู่แล้ว ซึ่งได้มาจาก union ของรัศมี stamp ทั้งหมด renderer จะแปลง bounding box นี้เป็นพิกัด drawable ของ viewport ปัจจุบัน และเพิ่มขอบ 2 พิกเซลสำหรับ antialiasing:

จากนั้นใช้ scissor rectangle เดียวกันกับสามงาน: ล้าง scratch, วาด stroke และ composite ผลลัพธ์กลับไปยัง main surface

let rect = displayScissorRect(for: stroke.bounds, viewport: viewport)

scratchEncoder.setScissorRect(rect)
clearScratchExplicitly()
drawStrokeIntoScratch(stroke)

mainEncoder.setScissorRect(rect)
compositeChalkFromScratch(stroke)
mainEncoder.setScissorRect(fullDrawable)

ตรรกะเดียวกันนี้ยังใช้กับการ bake และ partial replay บน ink texture 4096² ด้วย ดังนั้นการแสดงผลที่ซูมสูงกับชั้นหมึกที่ลงตัวแล้วจึงไม่สร้างพฤติกรรมชอล์กสองแบบ

มีรายละเอียดสองอย่างที่พลาดได้ง่าย

อย่างแรก loadAction = .clear ของ render pass เกิดขึ้นใน attachment load stage และไม่ได้ถูกจำกัดด้วย rasterization scissor หากยังใช้ต่อไปก็จะล้าง scratch texture ทั้งหมดอยู่ดี pass ที่แก้แล้วใช้ .dontCare จากนั้นวาด clear_fragment ภายใน scissor สี่เหลี่ยมนี้จะถูกเขียนเต็มพื้นที่ในภายหลัง และ composite จะอ่านเฉพาะสี่เหลี่ยมเดียวกัน จึงไม่จำเป็นต้องโหลดค่า attachment เดิม

อย่างที่สอง หลัง composite ของ stroke ชอล์กแต่ละเส้นเสร็จ outer scissor ต้องถูกคืนค่า หากละเว้นบรรทัดคืนค่าสถานะนี้ pen, image หรือ selection ถัดไปจะยังถูก clip ด้วย bounds ของ stroke ชอล์กก่อนหน้า ทำให้ดูเหมือน stroke หรือ image หายไป

grain ของชอล์กยังคง sampling จากพิกัด canvas แบบสัมบูรณ์ แทนที่จะใช้ local UV ภายในสี่เหลี่ยม การย้าย scissor เปลี่ยนเพียงว่าพิกเซลใดที่ GPU ประมวลผล ไม่ได้เปลี่ยนว่าพิกเซลแต่ละจุดอ่าน grain texture ตำแหน่งใด ดังนั้นสี่เหลี่ยมที่อยู่ติดกันจึงไม่เกิด texture seam และการลาก canvas ก็ไม่ทำให้ grain drift

เมื่อพิจารณาเฉพาะภาระงานพิกเซล ขอบเขตงานใหม่ใกล้เคียงกับ

โดย คือพื้นที่ของ axis-aligned bounding-box ของ stroke ชอล์กเส้นที่ บนหน้าจอปัจจุบัน จำนวน render encoder ไม่ได้ลดลง แต่ตอนนี้การ clear และ composite แต่ละครั้งถูกจำกัดด้วย screen bounding box ของ stroke

ทำไมไม่ batch stroke ชอล์กสีเดียวกัน

stroke ชอล์กส่วนใหญ่บนหน้ากระดาษใช้สีและ density เดียวกัน จึงชวนให้ลองวาดหลายสิบ stroke ลงใน scratch พร้อมกันแล้ว composite เพียงครั้งเดียว วิธีนี้จะลดจำนวน render pass ลงได้อีก แต่จะเปลี่ยน semantics ของสีและ grain ในบริเวณที่ทับซ้อนกัน

พิจารณากรณีที่จงใจทำให้ง่าย: สอง stroke มีค่า grain-gate เดียวกันที่พิกเซลหนึ่ง โดยมี body coverage เป็น และ ตามลำดับ ใน shader จริง gate ยังขึ้นอยู่กับ pressure depth ของแต่ละ stroke ด้วย กรณีแบบง่ายนี้ก็เพียงพอจะแสดงว่าการ batch ไม่ได้ให้ผลเทียบเท่ากันโดยทั่วไป การ compositing ต่อ stroke แบบปัจจุบันให้ผลเป็น

ขณะที่การรวม body ก่อนแล้วใช้ gate เดียวให้ผลเป็น

ความแตกต่างคือ เมื่อสอง stroke ทับซ้อนกันและ grain gate ไม่ใช่ศูนย์บริสุทธิ์หรือหนึ่งบริสุทธิ์ ผลลัพธ์จะแตกต่างกัน การ batch โดยตรงจะเปลี่ยนวิธีที่ฝุ่นชอล์กตกลงตรงจุดตัด

การ batch อย่างถูกต้องต้องพิสูจน์ว่า pixel ของ stroke แยกจากกันโดยไม่ทับซ้อน หรือจัดสรร atlas region อิสระให้แต่ละ stroke แล้ว composite ตามลำดับเดิม การทดสอบบนอุปกรณ์ขั้นสุดท้ายยังคงใช้แนวทาง scissor ดังนั้นรอบนี้จึงไม่ได้เพิ่ม atlas หรือความซับซ้อนในการจัดการมัน

จากพันล้านพิกเซลกลับมาเหลือไม่กี่ล้าน

ผลวัดจากการทดสอบบนอุปกรณ์ขั้นสุดท้าย:

สถานการณ์ก่อนแก้ไขPrecise scissor
stroke ชอล์กที่มองเห็นได้ 70 เส้น, ซูม 300%, กำลังเขียนGPU 52–60 ms≈ 9–10 ms
stroke ชอล์กที่มองเห็นได้ ≈ 121 เส้น, ซูม 300%GPU 77–80 ms13.7–15.6 ms
ขอบเขต rectangle ตามทฤษฎีต่อ frame (scratch + composite)783 M–1.34 B pixels≈ 1.7 M–3 M pixels

เรายังตรวจสอบ clipping bounds ด้วย 3,452 strokes และจุดตัวอย่าง 202,710 จุดจากเอกสารบนอุปกรณ์ ที่ซูม 0.5×, 1×, 2×, 3×, 5× และ 8× เราสร้างกรณี viewport 186,408 กรณี ทุก point sprite ที่สามารถสร้าง coverage ที่ไม่เป็นศูนย์ล้วนอยู่ภายใน scissor ที่คำนวณได้ การตรวจสอบนี้ครอบคลุมขอบ canvas ขอบ viewport และชุด offset หลายรูปแบบ

โค้ดที่ทดสอบไม่ได้สลับไปใช้ LOD ความละเอียดต่ำตามสถานะ interaction ระดับซูมต่ำยังคงแสดง ink texture เต็มหน้า ส่วนระดับซูมสูงยังคงวาด stroke ที่มองเห็นได้ใหม่เป็น vector ในฝั่งเดียวกันของ threshold การแตะปากกาและการแพนจะไม่แทนที่ stroke เดิมด้วยความคมชัดอีกระดับ ระหว่าง high-zoom vector redraw การล้าง scratch และ composite ของ stroke ชอล์กแต่ละเส้นครอบคลุมเพียง screen bounding box ของตัวเอง

GPU time ไม่ได้จับความคมชัด

ปัญหาประสิทธิภาพ GPU ไม่จำเป็นต้องเพิ่มตามปริมาณที่เห็นได้ชัดที่สุดในโครงสร้างข้อมูล ในกรณีนี้ จุดอินพุต 3,571 จุดเป็นผู้ต้องสงสัยที่เดาได้ง่าย แต่สิ่งที่กำหนด frame time คือ full-screen work ที่ stroke ชอล์กทั้ง 70 เส้นกระตุ้นขึ้นมา รวมถึงการสลับ render pass ของมัน

ความหมายทางภาพยังจำกัด optimization ที่ใช้ได้ scratch ต่อ stroke, ลำดับการ compositing เดิม และพิกัด grain บน canvas แบบสัมบูรณ์ไม่อาจเอาออกตามใจชอบได้ สีและ density ที่เหมือนกันหมายความเพียงว่าพารามิเตอร์ตรงกัน ไม่ได้พิสูจน์ว่าผลลัพธ์ที่ทับซ้อนกันจะรวมได้

feedback จากอุปกรณ์จริงปฏิเสธเวอร์ชันที่มี GPU time ต่ำที่สุด ประโยค “ทั้ง canvas จะเบลอไปหมด” แสดงข้อจำกัดของผลิตภัณฑ์ที่การวัดเพียงอย่างเดียวไม่อาจบอกได้: เมื่อ Apple Pencil แตะลง ผู้ใช้กำลังสังเกต stroke เดิมไปพร้อมกัน

เวอร์ชันที่ผ่านการทดสอบไม่เพิ่ม interaction cache layer ใหม่และไม่ลดความคมชัด high-zoom vector redraw เพียงจำกัดงานของ stroke ชอล์กแต่ละเส้นไว้ใน screen bounding box ของตัวเอง หลังโหลด test build กลับขึ้น LucasPad อีกครั้ง feedback คือ: “ดูดีมาก”