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

ชอล์กสีแดงและน้ำเงินใน device build สุดท้าย, 155 strokes ที่ซูม 255%
เครื่องมือชอล์กใน Lulucat Notes มีปัญหาด้านประสิทธิภาพที่เฉพาะเจาะจง: การเขียนบนพื้นที่ว่างยังลื่นไหล แต่เมื่อเลื่อนไปยังบริเวณที่มี stroke ชอล์กอยู่เต็ม ปลายปากกาจะเริ่มตามไม่ทัน การเขียนต่อในบริเวณเดิมยังทำให้การแพน canvas ช้าลงเรื่อย ๆ ด้วย
หน้าเดียวที่เขียนด้วยลายมือทั่วไปก็เพียงพอที่จะทำให้ปัญหาเกิดขึ้น: ซูม 300% มี stroke ชอล์กที่มองเห็นได้ 70 เส้นในบริเวณนั้น รวมจุดตัวอย่างอินพุต 3,571 จุด พื้นที่ว่างยังคงลื่นไหล มีเพียงบริเวณที่ stroke เหล่านี้กระจุกตัวอยู่เท่านั้นที่ช้าลง
หลังแก้ไข หน้ากระดาษเดิมยังรับการเขียนใหม่ต่อได้ที่ซูม 255% และ stroke เดิมยังคงความคมชัดเต็มที่ทั้งตอนแตะปากกาและตอนแพน

ภาพหน้าจอจากการทดสอบบนอุปกรณ์, รวม 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 ชอล์กที่มองเห็นได้แต่ละเส้น:
- จบ main render encoder;
- ล้าง scratch texture;
- วาด stroke ชอล์กเส้นนี้ลงใน scratch;
- เปิด main render encoder อีกครั้ง;
- 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 ชอล์กที่มองเห็นได้เป็น
stroke ชอล์กแต่ละเส้นยังมีค่าใช้จ่ายคงที่ของ render pass ด้วย ดังนั้นต้นทุนส่วนนี้จึงเพิ่มเป็นเส้นตรงตาม
วัดผลบน 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
เมื่อพิจารณาเฉพาะภาระงานพิกเซล ขอบเขตงานใหม่ใกล้เคียงกับ
โดย
ทำไมไม่ batch stroke ชอล์กสีเดียวกัน
stroke ชอล์กส่วนใหญ่บนหน้ากระดาษใช้สีและ density เดียวกัน จึงชวนให้ลองวาดหลายสิบ stroke ลงใน scratch พร้อมกันแล้ว composite เพียงครั้งเดียว วิธีนี้จะลดจำนวน render pass ลงได้อีก แต่จะเปลี่ยน semantics ของสีและ grain ในบริเวณที่ทับซ้อนกัน
พิจารณากรณีที่จงใจทำให้ง่าย: สอง stroke มีค่า grain-gate
ขณะที่การรวม body ก่อนแล้วใช้ gate เดียวให้ผลเป็น
ความแตกต่างคือ
การ 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 ms | 13.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 คือ: “ดูดีมาก”