Lulucat

Performa Alat Kapur: Dari Pass Layar Penuh ke Persegi Panjang Scissor

Gaoge ZhangGaoge Zhang

Alat kapur di Lulucat Notes melambat di area tulisan tangan yang padat. Hambatan utamanya bukan 3,571 sampel input, melainkan 70 scratch pass layar penuh per frame. Cache beresolusi rendah yang ditolak dan persegi panjang scissor per goresan melengkapi ceritanya.

Tampilan potongan Lulucat Notes di iPad pada zoom 255%, menampilkan tulisan tangan kapur merah dan biru dari sebuah bagian klasik Tionghoa, dengan sebagian toolbar aplikasi terlihat.

Kapur merah dan biru pada build perangkat yang digunakan untuk hasil akhir, 155 goresan pada zoom 255%.

Alat kapur di Lulucat Notes mengalami masalah performa yang spesifik: menulis di area kosong terasa mulus, tetapi ketika masuk ke area yang sudah dipenuhi goresan kapur, ujung pena mulai tertinggal. Terus menulis di area yang sama juga secara bertahap memperlambat panning kanvas.

Satu halaman tulisan tangan biasa sudah cukup untuk memicunya: zoom 300%, 70 goresan kapur yang terlihat di area lokal, dengan total 3,571 titik sampel input. Area kosong tetap lancar; hanya area tempat goresan terkonsentrasi yang menjadi lambat.

Setelah perbaikan, halaman yang sama dapat terus menerima tulisan baru pada zoom 255%, dan goresan yang sudah ada tetap sepenuhnya jelas selama pen-down maupun panning.

Layar Lulucat Notes di iPad pada zoom 255%, menampilkan tulisan tangan kapur merah dan biru. Teksnya berbunyi "天行健,君子以自强不息;地势坤,君子以厚德载物" — bagian klasik Tionghoa. Maskot Lulucat biru berada di kanan atas. Toolbar bawah menampilkan jumlah goresan 155, Save, Clear, dan slider zoom 255%.

Tangkapan layar perangkat yang digunakan untuk hasil akhir, total 155 goresan. Pada tingkat zoom ini, baik pen-down maupun panning tidak mengubah kejernihan goresan yang sudah ada untuk sementara.

Mengapa kapur membutuhkan tekstur scratch

Pena biasa dapat mengompositkan setiap cap lingkaran langsung ke tekstur tinta dengan blending source-over. Kapur menambahkan lapisan grain-gating: renderer terlebih dahulu mengakumulasikan body coverage dan depth untuk seluruh goresan, lalu menggunakan tekstur grain tetap untuk menentukan posisi yang menerima debu kapur, dan akhirnya mengompositkan hasilnya ke tinta yang sudah ada.

Tekstur scratch ini mengisolasi satu goresan kapur. Isolasi penting karena cap dalam satu goresan saling tumpang tindih dengan kuat; jika setiap cap diberi grain gate secara terpisah, garis tengah goresan akan mengakumulasi warna berulang, dan pori-pori kapur akan bergeser mengikuti kepadatan sampling.

Pada tingkat zoom tinggi, Lulucat Notes menggambar ulang goresan vektor yang terlihat di viewport saat ini. Implementasi lama melakukan langkah-langkah berikut untuk setiap goresan kapur yang terlihat:

  1. Mengakhiri main render encoder;
  2. Mengosongkan tekstur scratch;
  3. Menggambar satu goresan kapur ini ke dalam scratch;
  4. Membuka kembali main render encoder;
  5. Mengompositkan scratch kembali ke drawable dengan segitiga layar penuh.

Semantik satu goresan sudah benar, tetapi cakupan pekerjaannya jauh lebih besar. Drawable iPad berukuran 2732×2048 — kira-kira 5.6 juta piksel. Setiap goresan kapur memicu satu scratch pass dan satu komposit layar penuh. Tujuh puluh goresan kapur berarti sekitar 141 render encoder dan 70 komposit layar penuh.

Misalkan jumlah goresan kapur yang terlihat adalah dan jumlah piksel drawable adalah . Jika hanya mempertimbangkan pekerjaan yang skalanya mengikuti cakupan piksel, implementasi lama mendekati

Setiap goresan kapur juga membawa overhead render-pass tetap, sehingga biaya itu pun meningkat linear terhadap . 3,571 titik input hanya menyumbang biaya sekunder. Yang skalanya mengikuti jumlah goresan lokal adalah cakupan kerja layar penuh yang dipicu oleh setiap goresan.

Pengukuran dilakukan pada iPad Pro 12.9 inci (generasi ke-5, M1) yang menjalankan iPadOS 18.6.2. Kami membandingkan timestamp GPU dari viewport yang sama sebelum dan sesudah perubahan, menggunakan timestamp command-buffer dalam build perangkat Debug yang sama pada iPad ini — yang selanjutnya disebut LucasPad. Rentang di bawah adalah fluktuasi umum dari log multi-frame, bukan komitmen frame rate untuk versi yang akan didistribusikan. Pada 70 goresan kapur yang terlihat, satu frame biasanya memerlukan 52–60 ms waktu GPU; di area dengan kira-kira 120 goresan, waktu GPU naik menjadi 77–80 ms.

Dengan memperkirakan berdasarkan luas persegi panjang penuh dari pass scratch dan komposit, cakupan kerja teoretis per frame tumbuh dari sekitar 783 juta piksel menjadi 1.34 miliar piksel. Angka ini adalah jumlah luas persegi panjang dan tidak setara dengan jumlah pemanggilan fragment, byte baca/tulis memori video, atau counter perangkat keras GPU. Fast clear Metal, attachment load/store, dan pergantian pass tetap berada di bawah kendali GPU dan driver.

Ini juga menjelaskan mengapa area kosong tetap lancar. Visibility culling melewati goresan di luar viewport; di area kosong mendekati nol, sedangkan di area padat terus meningkat.

Jawaban yang salah pada 0.85 ms

Aplikasi ini sudah memiliki tekstur tinta seluruh halaman yang dipra-render pada dua piksel per titik. Kami mencoba menampilkan tekstur ini secara langsung selama menulis, panning, dan zoom, dengan hanya goresan Apple Pencil saat ini yang tetap berupa vektor live; setelah interaksi berakhir, satu frame tambahan akan merender ulang hasil vektor beresolusi tinggi.

Pendekatan ini bekerja sangat baik. Di area padat yang sama pada zoom 300%, waktu GPU turun menjadi 0.84–0.85 ms dan tidak lagi bertambah mengikuti jumlah goresan kapur yang sudah ada.

Masalah pada perangkat sebenarnya sama jelasnya. Pada zoom 300%, diperlukan kira-kira enam piksel layar per titik, sedangkan cache hanya menyediakan dua. Begitu Apple Pencil menyentuh layar, semua goresan yang sudah ada berubah menjadi gambar lembut beresolusi rendah; ketika Pencil diangkat, goresan itu kembali tajam sepenuhnya.

Tester mengatakan satu hal: “Saat saya menulis, seluruh kanvas menjadi buram. Setelah saya mengangkatnya, semuanya kembali jelas.”

Optimisasi itu dihapus. 0.85 ms adalah hasil terendah yang diukur, tetapi bukan alat kapur yang dapat diterima. Goresan yang sudah ada merupakan bagian dari umpan balik menulis; kejernihannya tidak boleh berubah saat pen-down.

Membatasi setiap goresan kapur pada perseginya sendiri

Perbaikan akhir mempertahankan scratch per goresan dan komposit per goresan, lalu hanya mengurangi cakupan kerja pikselnya. Setiap goresan sudah memiliki bounding box canvas yang diturunkan dari gabungan semua radius cap-nya. Renderer mengubah bounding box ini ke koordinat drawable viewport saat ini dan menambahkan padding dua piksel untuk margin antialiasing:

Persegi panjang scissor yang sama kemudian digunakan untuk tiga hal: mengosongkan scratch, menggambar goresan, dan mengompositkan hasilnya kembali ke permukaan utama.

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

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

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

Logika yang sama juga digunakan untuk baking dan partial replay pada tekstur tinta 4096², sehingga tampilan zoom tinggi dan lapisan tinta yang sudah selesai tidak menghasilkan perilaku kapur yang berbeda.

Dua detail mudah terlewat di sini.

Pertama, loadAction = .clear pada render pass terjadi selama tahap attachment load dan tidak dibatasi oleh rasterization scissor. Jika terus menggunakannya, seluruh tekstur scratch tetap akan di-clear. Pass yang diperbaiki menggunakan .dontCare, lalu menggambar clear_fragment di dalam scissor. Persegi panjang ini selanjutnya ditulis penuh, dan komposit hanya membaca persegi panjang yang sama, sehingga isi attachment lama tidak perlu dimuat.

Kedua, setelah komposit setiap goresan kapur selesai, scissor luar harus dipulihkan. Jika baris pemulihan state ini dihilangkan, pena, gambar, atau seleksi berikutnya akan terus terpotong oleh batas goresan kapur sebelumnya, sehingga tampak seperti goresan atau gambar yang hilang.

Grain kapur tetap mengambil sampel dari koordinat canvas absolut, bukan UV lokal di dalam persegi panjang. Memindahkan scissor hanya mengubah piksel mana yang diproses GPU; itu tidak mengubah lokasi tekstur grain yang dibaca setiap piksel. Karena itu persegi panjang yang bersebelahan tidak menghasilkan sambungan tekstur, dan menyeret canvas tidak membuat grain bergeser.

Jika hanya mempertimbangkan beban kerja piksel, cakupan kerja baru mendekati

di mana adalah luas axis-aligned bounding box goresan kapur ke- pada layar saat ini. Jumlah render encoder tidak berkurang, tetapi setiap clear dan komposit kini dibatasi oleh bounding box layar goresan.

Mengapa goresan kapur dengan warna sama tidak diproses dalam batch

Sebagian besar goresan kapur di halaman menggunakan warna dan kepadatan yang sama, dan menggambar puluhan goresan sekaligus ke scratch lalu hanya mengomposit sekali memang terlihat menggoda. Ini akan lebih mengurangi render pass, tetapi mengubah semantik warna dan grain pada area yang tumpang tindih.

Pertimbangkan kasus yang sengaja disederhanakan: dua goresan berbagi nilai grain-gate yang sama pada satu piksel, dengan body coverage dan . Pada shader sebenarnya, gate juga bergantung pada kedalaman tekanan setiap goresan; kasus yang lebih sederhana ini sudah cukup untuk menunjukkan bahwa batching secara umum tidak ekuivalen. Komposit per goresan saat ini menghasilkan

sedangkan menggabungkan body terlebih dahulu lalu menerapkan satu gate menghasilkan

Perbedaannya adalah . Setiap kali dua goresan bertumpang tindih dan grain gate bukan nol murni atau satu murni, hasilnya berbeda. Pemrosesan batch secara langsung akan mengubah cara debu kapur jatuh di persilangan.

Batching yang tepat memerlukan pembuktian bahwa piksel goresan saling tidak beririsan, atau mengalokasikan region atlas independen untuk setiap goresan dan mengompositkannya dalam urutan asli. Penerimaan final di perangkat mempertahankan pendekatan scissor, sehingga putaran ini tidak memperkenalkan atlas atau kompleksitas pengelolaannya.

Dari satu miliar piksel kembali menjadi beberapa juta

Pengukuran perangkat final:

SkenarioSebelum perbaikanScissor presisi
70 goresan kapur terlihat, zoom 300%, menulisGPU 52–60 ms≈ 9–10 ms
≈ 121 goresan kapur terlihat, zoom 300%GPU 77–80 ms13.7–15.6 ms
Cakupan persegi panjang teoretis per frame (scratch + komposit)783 M–1.34 B piksel≈ 1.7 M–3 M piksel

Kami juga memverifikasi batas clipping menggunakan 3,452 goresan dan 202,710 titik sampel dari sebuah dokumen perangkat. Pada zoom 0.5×, 1×, 2×, 3×, 5×, dan 8×, dihasilkan 186,408 kasus viewport; setiap point sprite yang dapat menghasilkan coverage non-zero berada di dalam scissor yang dihitung. Pemeriksaan ini mencakup tepi canvas, tepi viewport, dan berbagai kombinasi offset.

Kode final tidak beralih ke LOD beresolusi rendah berdasarkan status interaksi. Tingkat zoom rendah tetap menampilkan tekstur tinta seluruh halaman; tingkat zoom tinggi tetap menggambar ulang goresan yang terlihat sebagai vektor. Pada sisi threshold yang sama, pen-down dan panning tidak mengganti goresan yang sudah ada dengan tingkat kejernihan yang berbeda. Selama penggambaran ulang vektor pada zoom tinggi, clear scratch dan komposit setiap goresan kapur hanya mencakup bounding box layarnya sendiri.

Waktu GPU tidak menangkap kejernihan

Masalah performa GPU tidak selalu berskala dengan jumlah yang paling terlihat dalam struktur data. Dalam kasus ini, 3,571 titik input adalah tersangka yang mudah; yang menentukan waktu frame adalah pekerjaan layar penuh yang dipicu oleh setiap 70 goresan kapur, bersama dengan pergantian render-pass-nya.

Semantik visual juga membatasi optimisasi yang tersedia. Scratch per goresan, urutan komposit asli, dan koordinat grain canvas absolut tidak dapat dihilangkan begitu saja. Warna dan kepadatan yang sama hanya berarti parameternya cocok — itu tidak membuktikan bahwa hasil yang tumpang tindih dapat digabungkan.

Feedback perangkat nyata menolak versi dengan waktu GPU terendah. Pernyataan “seluruh kanvas menjadi buram” memberikan batasan produk yang tidak diungkapkan oleh pengukuran saja: ketika Apple Pencil menyentuh layar, pengguna juga sedang mengamati goresan yang sudah ada.

Versi final tidak memperkenalkan lapisan cache interaksi baru dan tidak mengurangi kejernihan. Penggambaran ulang vektor pada zoom tinggi hanya membatasi pekerjaan setiap goresan kapur pada bounding box layarnya sendiri. Setelah dimuat kembali ke LucasPad, feedback-nya menjadi: “Terlihat bagus.”