Lulucat

Tebeşir Aracının Performansı: Tam Ekran Pass'lerinden Scissor Dikdörtgenlerine

Gaoge ZhangGaoge Zhang

Lulucat Notes'un tebeşir aracı yoğun el yazısı bölgelerinde yavaşladı. Darboğaz 3,571 giriş örneği değil, kare başına 70 tam ekran scratch pass'iydi. Reddedilen düşük çözünürlüklü bir cache ve stroke başına scissor rectangle, hikâyenin geri kalanını anlatıyor.

iPad'de 255% yakınlaştırılmış Lulucat Notes'un kırpılmış görünümü; kırmızı ve mavi tebeşirle klasik bir Çince pasaj yazılmış, uygulama araç çubuğunun bir bölümü de görünüyor.

Son cihaz test build’inde kırmızı ve mavi tebeşir, 255% yakınlaştırmada 155 stroke.

Lulucat Notes’taki tebeşir aracının belirli bir performans sorunu vardı: boş bir alanda yazmak akıcı hissettiriyor, ancak tebeşir stroke’larıyla dolu bir alana geçildiğinde kalem ucu geride kalıyordu. Aynı alanda yazmaya devam etmek canvas pan işlemini de giderek yavaşlatıyordu.

Sıradan el yazısıyla doldurulmuş tek bir sayfa sorunu tetiklemeye yetiyordu: 300% yakınlaştırma, yerel alanda 70 görünür tebeşir stroke’u ve toplam 3,571 giriş örnek noktası. Boş alanlar akıcı kalıyordu; yalnızca stroke’ların yoğunlaştığı alan yavaşlıyordu.

Düzeltmeden sonra aynı sayfa 255% yakınlaştırmada yeni yazıları almaya devam edebiliyor ve mevcut stroke’lar hem kalem basılıyken hem de pan sırasında tam netliğini koruyor.

iPad'de 255% yakınlaştırılmış Lulucat Notes, kırmızı ve mavi tebeşir el yazısını gösteriyor. Metinde “天行健,君子以自强不息;地势坤,君子以厚德载物” yazıyor — klasik bir Çince pasaj. Sağ üstte mavi bir Lulucat maskotu var. Alt araç çubuğunda stroke sayısı 155, Save, Clear ve 255% yakınlaştırma kaydırıcısı görünüyor.

Son cihaz ekran görüntüsü, toplam 155 stroke. Bu yakınlaştırma düzeyinde kalem basılıyken veya pan yapılırken mevcut stroke’ların netliği geçici olarak değişmiyor.

Tebeşir neden scratch texture’a ihtiyaç duyar?

Sıradan bir kalem, her dairesel stamp’i source-over blending ile doğrudan mürekkep texture’ı üzerine composite edebilir. Tebeşir ise bir grain-gating katmanı ekler: renderer önce bütün bir stroke için body coverage’ını ve depth’i biriktirir, sonra hangi konumların tebeşir tozu alacağını belirlemek için sabit bir grain texture kullanır ve son olarak sonucu mevcut mürekkebin üzerine composite eder.

Bu scratch texture tek bir tebeşir stroke’unu izole eder. İzolasyon önemlidir, çünkü aynı stroke içindeki stamp’ler yoğun biçimde üst üste biner; her stamp ayrı ayrı grain-gate edilseydi stroke’un merkez çizgisi tekrarlanan rengi biriktirir, tebeşir gözenekleri de örnekleme yoğunluğuna göre kayardı.

Yüksek yakınlaştırma düzeylerinde Lulucat Notes mevcut viewport’ta görünür olan vector stroke’ları yeniden çizer. Eski implementation, görünür her tebeşir stroke’u için şu adımları uyguluyordu:

  1. Ana render encoder’ı sonlandırmak;
  2. Scratch texture’ı temizlemek;
  3. Bu tebeşir stroke’unu scratch’e çizmek;
  4. Ana render encoder’ı yeniden açmak;
  5. Scratch’i bir full-screen triangle ile drawable’a geri composite etmek.

Tek bir stroke’un rendering semantics’i doğruydu, ancak işin kapsamı çok daha büyüktü. iPad’in drawable’ı 2732×2048 — yaklaşık 5.6 million pixels. Her tebeşir stroke’u bir scratch pass ve bir full-screen composite tetikliyordu. 70 tebeşir stroke’u yaklaşık 141 render encoder ve 70 full-screen composite anlamına geliyordu.

Görünür tebeşir stroke’larının sayısı , drawable piksel sayısı olsun. Yalnızca piksel kapsamıyla ölçeklenen işi dikkate aldığımızda eski implementation yaklaşık olarak

şeklindeydi.

Her tebeşir stroke’u ayrıca sabit bir render-pass maliyeti taşıyordu; dolayısıyla bu maliyet de ile doğrusal olarak ölçekleniyordu. 3,571 giriş noktası yalnızca ikincil bir maliyet getiriyordu. Yerel stroke sayısıyla büyüyen şey, her stroke’un tetiklediği full-screen iş kapsamıydı.

Ölçümler iPadOS 18.6.2 çalıştıran 12.9-inch iPad Pro (5th generation, M1) üzerinde alındı. Değişiklikten önce ve sonra aynı viewport’tan alınan GPU timestamp’lerini karşılaştırdık; bu belirli iPad’deki aynı Debug device build içinde command-buffer timestamp’leri kullandık — aşağıda ona LucasPad diyeceğiz. Aşağıdaki aralıklar çok kareli log’lardaki tipik dalgalanmalardır, shipping frame-rate taahhütleri değildir. 70 görünür tebeşir stroke’unda tek bir kare genellikle 52–60 ms GPU zamanı gerektiriyordu; yaklaşık 120 stroke bulunan bir alanda GPU zamanı 77–80 ms’ye çıkıyordu.

Scratch ve composite pass’lerinin full-screen rectangle alanına göre tahmin edildiğinde, kare başına teorik iş kapsamı yaklaşık 783 million pixels’tan 1.34 billion pixels’a büyüyordu. Bu sayı rectangle alanlarının toplamıdır; fragment invocation sayısına, video-memory read/write byte’larına veya GPU hardware counter’larına eşdeğer değildir. Metal’in fast clear, attachment load/store ve pass switching işlemleri GPU ve driver’ın kontrolünde kalır.

Bu, boş alanların neden akıcı kaldığını da açıklar. Visibility culling viewport dışındaki stroke’ları atlar; boş bir alandaki sıfıra yakındır, yoğun bir alandaki ise artmaya devam eder.

0.85 ms’de yanlış cevap

Uygulamada zaten point başına 2 pixel ile bake edilmiş tam sayfa bir ink texture vardı. Yazma, pan ve zoom sırasında bu texture’ı doğrudan göstermeyi denedik; yalnızca mevcut Apple Pencil stroke’unu canlı vector olarak tuttuk. Etkileşim sona erdikten sonra, yüksek çözünürlüklü vector sonucunu yeniden render etmek için bir ek kare kullanılıyordu.

Bu yaklaşım çok iyi performans verdi. 300% yakınlaştırmadaki aynı yoğun alanda GPU zamanı 0.84–0.85 ms’ye düştü ve mevcut tebeşir stroke’larının sayısıyla artık artmıyordu.

Gerçek cihazdaki sorun da aynı derecede açıktı. 300% yakınlaştırmada point başına yaklaşık 6 ekran pikseli gerekiyordu, ancak cache yalnızca 2 sağlıyordu. Apple Pencil ekrana değer değmez mevcut stroke’ların tamamı yumuşak, düşük çözünürlüklü bir görüntüye dönüşüyordu; Pencil kaldırıldığında tam netliklerine geri dönüyorlardı.

Testçi şöyle dedi: “Yazarken tüm tuval bulanıklaşıyor. Elimi çekince düzeliyor.”

Bu optimizasyonu kaldırdık. 0.85 ms ölçülen en düşük sonuçtu, ancak kabul edilebilir bir tebeşir aracı değildi. Mevcut stroke’lar yazma geri bildiriminin parçasıdır; kalem basıldığında netlikleri değişemez.

Her tebeşir stroke’unu kendi rectangle’ıyla sınırlamak

Son düzeltme stroke başına scratch ve stroke başına compositing’i korudu, yalnızca piksel iş kapsamlarını azalttı. Her stroke’un zaten tüm stamp yarıçaplarının birleşiminden türetilmiş bir canvas bounding box’ı vardı. Renderer bu bounding box’ı mevcut viewport’un drawable koordinatlarına dönüştürüyor ve antialiasing payı için 2 pixel ekliyordu:

Aynı scissor rectangle daha sonra üç iş için kullanılır: scratch’i temizlemek, stroke’u çizmek ve sonucu ana surface’e geri composite etmek.

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

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

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

Aynı mantık 4096² ink texture üzerinde baking ve partial replay için de kullanılır; böylece yüksek yakınlaştırmadaki görüntü ile yerleşmiş ink layer iki farklı tebeşir davranışı üretmez.

Burada gözden kaçırılması kolay iki ayrıntı var.

İlk olarak, bir render pass’in loadAction = .clear işlemi attachment load aşamasında gerçekleşir ve rasterization scissor ile sınırlanmaz. Bunu kullanmaya devam etmek scratch texture’ın tamamını yine temizlerdi. Düzeltilen pass .dontCare kullanır, ardından scissor içinde bir clear_fragment çizer. Bu rectangle daha sonra bütünüyle yazılır ve composite yalnızca aynı rectangle’ı okur; bu nedenle eski attachment içeriğinin yüklenmesi gerekmez.

İkinci olarak, her tebeşir stroke’unun composite işlemi bittikten sonra outer scissor geri yüklenmelidir. Bu state restoration satırı atlanırsa sonraki kalemler, görüntüler veya seçimler önceki tebeşir stroke’unun bounds’ı tarafından clip edilmeye devam eder ve eksik stroke’lar ya da eksik görüntüler ortaya çıkar.

Tebeşir grain’i rectangle içindeki yerel UV’lerden değil, mutlak canvas koordinatlarından örneklenmeye devam eder. Scissor’ı taşımak yalnızca GPU’nun hangi pikselleri işlediğini değiştirir; her pikselin hangi grain texture konumunu okuduğunu değiştirmez. Bu nedenle bitişik rectangle’lar texture seam oluşturmaz ve canvas’ı sürüklemek grain’in kaymasına neden olmaz.

Yalnızca piksel iş yükünü düşündüğümüzde yeni iş kapsamı yaklaşık olarak

şeklindedir; burada , mevcut ekranda ‘inci tebeşir stroke’unun eksen hizalı bounding-box alanıdır. Render encoder sayısı azalmamıştır, ancak her clear ve composite artık stroke’un ekran bounding box’ı ile sınırlıdır.

Aynı renkli tebeşir stroke’ları neden batch edilmedi?

Sayfadaki tebeşir stroke’larının çoğu aynı renk ve yoğunluğu paylaşır; onlarca stroke’u scratch’e bir kerede çizip yalnızca bir kez composite etmek cazip gelir. Bu, render pass sayısını daha da azaltırdı, ancak üst üste binen bölgelerdeki renk ve grain semantiğini değiştirirdi.

Kasten basitleştirilmiş bir durumu düşünelim: iki stroke belirli bir pikselde aynı grain-gate değerini paylaşsın; gövde coverage değerleri sırasıyla ve olsun. Gerçek shader’da gate, her stroke’un pressure depth’ine de bağlıdır; bu basit durum, batch işleminin genel olarak eşdeğer olmadığını göstermeye yeter. Mevcut stroke başına compositing

üretirken gövdeleri önce birleştirip tek bir gate uygulamak

üretir.

Aradaki fark ‘dir. İki stroke üst üste bindiğinde ve grain gate ne tam sıfır ne de tam bir olduğunda sonuçlar farklıdır. Doğrudan batch etmek, tebeşir tozunun kesişimlerdeki dağılımını değiştirirdi.

Tam batch işlemi, stroke piksellerinin birbirinden tamamen ayrık olduğunu kanıtlamayı veya her stroke için bağımsız bir atlas bölgesi ayırıp orijinal sırayla composite etmeyi gerektirir. Son cihaz testinde scissor yaklaşımını koruduk; bu nedenle bu turda bir atlas ya da onun yönetim karmaşıklığı eklenmedi.

Bir milyar pikselden birkaç milyona

Son cihaz testinin ölçümleri:

SenaryoDüzeltmeden önceHassas scissor
70 görünür tebeşir stroke’u, 300% yakınlaştırma, yazmaGPU 52–60 ms≈ 9–10 ms
≈ 121 görünür tebeşir stroke’u, 300% yakınlaştırmaGPU 77–80 ms13.7–15.6 ms
Kare başına teorik rectangle kapsamı (scratch + composite)783 M–1.34 B pixels≈ 1.7 M–3 M pixels

Ayrıca bir cihaz dokümanındaki 3,452 stroke ve 202,710 örnek noktasıyla clipping bounds’u doğruladık. 0.5×, 1×, 2×, 3×, 5× ve 8× yakınlaştırmada 186,408 viewport vakası üretildi; sıfır olmayan coverage üretebilecek her point sprite hesaplanan scissor’ın içinde kaldı. Bu kontrol canvas kenarlarını, viewport kenarlarını ve çeşitli offset birleşimlerini kapsadı.

Test edilen kod, etkileşim durumuna göre düşük çözünürlüklü bir LOD’a geçmez. Düşük zoom düzeyleri hâlâ tam sayfa ink texture’ı gösterir; yüksek zoom düzeyleri hâlâ görünür stroke’ları vector olarak yeniden çizer. Eşiğin aynı tarafında, kalem basılması ve pan mevcut stroke’ları farklı bir netlik düzeyiyle değiştirmez. Yüksek zoom’lu vector redraw sırasında her tebeşir stroke’unun scratch clear ve composite işlemi yalnızca kendi ekran bounding box’ını kapsar.

GPU zamanı netliği yakalamadı

GPU performans sorunları veri yapısındaki en görünür miktarla mutlaka ölçeklenmez. Bu durumda 3,571 giriş noktası kolay bir şüpheliydi; frame time’ı belirleyen, 70 tebeşir stroke’unun her birinin tetiklediği full-screen iş ve bunlara eşlik eden render-pass switching idi.

Görsel anlam da kullanılabilir optimizasyonları sınırladı. Stroke başına scratch, özgün compositing sırası ve mutlak canvas grain koordinatları istendiği gibi kaldırılamazdı. Aynı renk ve aynı yoğunluk yalnızca parametrelerin eşleştiği anlamına gelir — üst üste binen sonuçların birleştirilebileceğini kanıtlamaz.

Gerçek cihaz geri bildirimi GPU zamanı en düşük olan sürümü reddetti. “Tüm tuval bulanıklaşıyor” cümlesi, tek başına ölçümün ifade edemediği ürün kısıtını ortaya koydu: Apple Pencil ekrana değdiğinde kullanıcılar mevcut stroke’ları da izliyor.

Test sürümü yeni bir etkileşim cache layer eklemez ve netliği azaltmaz. Yüksek zoom’lu vector redraw yalnızca her tebeşir stroke’unun işini kendi ekran bounding box’ıyla sınırlar. Test build’i LucasPad’e yeniden yükledikten sonra geri bildirim şöyle oldu: “Harika görünüyor.”