Lulucat

চক টুলের পারফরম্যান্স: ফুল-স্ক্রিন পাস থেকে scissor rectangle-এ

Gaoge ZhangGaoge Zhang

ঘন হাতের লেখার এলাকায় Lulucat Notes-এর চক টুল ধীর হয়ে গিয়েছিল। মূল বাধা ছিল 3,571টি ইনপুট নমুনা নয়; প্রতি ফ্রেমে 70টি full-screen scratch pass-ই আসল কারণ। বাতিল করা low-resolution cache এবং প্রতি stroke-এর scissor rectangle-ই বাকি গল্প বলে।

iPad-এ 255% জুমে Lulucat Notes-এর কাটা দৃশ্য; সেখানে একটি শাস্ত্রীয় চীনা অংশের লাল ও নীল চক-লেখা এবং অ্যাপের টুলবারের কিছু অংশ দেখা যাচ্ছে।

ডিভাইস-পরীক্ষার build-এ লাল ও নীল চক; 255% জুমে 155টি stroke।

Lulucat Notes-এর চক টুলে একটি নির্দিষ্ট ধরনের পারফরম্যান্স সমস্যা ছিল: ফাঁকা এলাকায় লেখা মসৃণ লাগত, কিন্তু আগে থেকেই চক-স্ট্রোকে ভরা এলাকায় গেলে কলমের ডগা পিছিয়ে পড়ত। সেই এলাকায় লেখা চালিয়ে গেলে ক্যানভাস সরানোও ধীরে ধীরে ধীর হয়ে যেত।

সাধারণ হাতের লেখার একটি পৃষ্ঠাই সমস্যা তৈরি করার জন্য যথেষ্ট ছিল: 300% জুম, স্থানীয় এলাকায় 70টি দৃশ্যমান chalk stroke, মোট 3,571টি ইনপুট sample point। ফাঁকা এলাকাগুলি মসৃণই থাকত; কেবল যেখানে stroke-গুলি জমা ছিল, সেই এলাকাটি ধীর হয়ে যেত।

সংশোধনের পরে একই পৃষ্ঠায় 255% জুমে নতুন লেখা চালিয়ে যাওয়া যায়, আর কলম নামানো ও ক্যানভাস সরানোর সময় বিদ্যমান stroke-গুলি সম্পূর্ণ পরিষ্কার থাকে।

iPad-এ 255% জুমে Lulucat Notes, লাল ও নীল চক-লেখা দেখাচ্ছে। লেখাটি হল "天行健,君子以自强不息;地势坤,君子以厚德载物" — একটি শাস্ত্রীয় চীনা অংশ। ওপরের ডান দিকে নীল Lulucat mascot আছে। নিচের টুলবারে 155টি stroke, Save, Clear এবং 255% জুম slider দেখা যাচ্ছে।

ডিভাইস-পরীক্ষার screenshot, মোট 155টি stroke। এই জুমে কলম নামানো বা ক্যানভাস সরানোর সময় বিদ্যমান stroke-গুলির স্বচ্ছতা সাময়িকভাবে বদলে যায় না।

চক-এর scratch texture কেন দরকার

একটি সাধারণ pen প্রতিটি গোলাকার stamp-কে source-over blending ব্যবহার করে সরাসরি ink texture-এর ওপর composite করতে পারে। চক-এর ক্ষেত্রে grain-gating-এর একটি স্তর যোগ হয়: renderer প্রথমে একটি সম্পূর্ণ stroke-এর body coverage ও depth জমা করে, তারপর fixed grain texture ব্যবহার করে কোন কোন অবস্থানে chalk dust পড়বে তা নির্ধারণ করে, এবং শেষে ফলটি বিদ্যমান ink-এর ওপর composite করে।

এই scratch texture একটি chalk stroke-কে আলাদা রাখে। এটি গুরুত্বপূর্ণ, কারণ একই stroke-এর stamp-গুলি অনেকখানি overlap করে; প্রতিটি stamp-এ আলাদা করে grain gate প্রয়োগ করলে stroke-এর centreline-এ বারবার colour জমত, আর sampling density বদলালে chalk pore-গুলিও সরে যেত।

উচ্চ জুমে Lulucat Notes বর্তমান viewport-এ দৃশ্যমান vector stroke-গুলি redraw করে। পুরনো বাস্তবায়নে প্রতিটি দৃশ্যমান chalk stroke-এর জন্য নিচের ধাপগুলি চলত:

  1. Main render encoder শেষ করা;
  2. Scratch texture clear করা;
  3. এই chalk stroke-টি scratch-এ draw করা;
  4. Main render encoder আবার খোলা;
  5. Full-screen triangle দিয়ে scratch-টি drawable-এ আবার composite করা।

একটি stroke-এর rendering semantics ঠিক ছিল, কিন্তু কাজের পরিধি ছিল অনেক বড়। iPad-এর drawable ছিল 2732×2048 — প্রায় 5.6 million pixel। প্রতিটি chalk stroke একটি scratch pass এবং একটি full-screen composite চালু করত। 70টি chalk stroke মানে প্রায় 141টি render encoder এবং 70টি full-screen composite।

দৃশ্যমান chalk stroke-এর সংখ্যা এবং drawable pixel count ধরা যাক। Pixel coverage-এর সঙ্গে যে কাজটি বাড়ে, শুধু সেটি ধরলে পুরনো implementation প্রায় এমন ছিল

প্রতিটি chalk stroke-এর জন্য একটি নির্দিষ্ট render-pass overhead-ও ছিল, তাই সেই খরচও -এর সঙ্গে সরলরৈখিকভাবে বাড়ত। 3,571টি input point কেবল গৌণ খরচ যোগ করেছিল। স্থানীয় stroke count-এর সঙ্গে বাড়ত প্রতিটি stroke-এর কারণে চালু হওয়া full-screen কাজের পরিসর।

মাপ নেওয়া হয়েছিল iPadOS 18.6.2 চালানো 12.9-inch iPad Pro (5th generation, M1)-এ। এই নির্দিষ্ট iPad-এর একই Debug device build-এ command-buffer timestamp ব্যবহার করে পরিবর্তনের আগে ও পরে একই viewport-এর GPU timestamp তুলনা করেছি — নিচে এই iPad-টিকে LucasPad বলা হবে। নিচের পরিসরগুলি বহু-ফ্রেমের log-এ দেখা স্বাভাবিক ওঠানামা; ব্যবহারকারীদের জন্য তৈরি build-এর frame rate-এর প্রতিশ্রুতি নয়। 70টি দৃশ্যমান chalk stroke থাকলে একটি frame-এ সাধারণত 52–60 ms GPU time লাগত; প্রায় 120টি stroke-যুক্ত এলাকায় GPU time বেড়ে 77–80 ms হত।

Scratch ও composite pass-এর full-screen rectangle area ধরে হিসাব করলে, প্রতি frame-এর theoretical work scope প্রায় 783 million pixel থেকে 1.34 billion pixel-এ বেড়েছিল। এই সংখ্যা rectangle area-গুলির যোগফল; এটি fragment invocation count, video-memory read/write byte বা GPU hardware counter-এর সমান নয়। Metal-এর fast clear, attachment load/store এবং pass switching এখনও GPU ও driver-এর নিয়ন্ত্রণে থাকে।

এতে ফাঁকা এলাকাগুলি কেন মসৃণ থাকত, সেটিও বোঝা যায়। Visibility culling viewport-এর বাইরের stroke বাদ দেয়; ফাঁকা এলাকায় প্রায় শূন্য, আর ঘন এলাকায় বাড়তেই থাকে।

0.85 ms-এ একটি ভুল উত্তর

অ্যাপটিতে আগে থেকেই প্রতি point-এ দুই pixel-এ তৈরি করা একটি পুরো-পৃষ্ঠার ink texture ছিল। লেখা, ক্যানভাস সরানো এবং জুম করার সময় এই texture-টি সরাসরি দেখানোর চেষ্টা করেছিলাম, কেবল বর্তমান Apple Pencil stroke-টিকে live vector হিসেবে রেখে; interaction শেষ হলে আরও একটি frame উচ্চ-রেজোলিউশনের vector result re-render করত।

এই পদ্ধতির পারফরম্যান্স অত্যন্ত ভালো ছিল। একই ঘন এলাকায় 300% zoom-এ GPU time কমে 0.84–0.85 ms হয়েছিল, এবং বিদ্যমান chalk stroke-এর সংখ্যা বাড়লেও তা আর বাড়েনি।

বাস্তব ডিভাইসে সমস্যাটিও ততটাই স্পষ্ট ছিল। 300% zoom-এ প্রতি point-এ প্রায় ছয়টি screen pixel দরকার ছিল, কিন্তু cache দিত মাত্র দুইটি। Apple Pencil screen-এ ছোঁয়ার সঙ্গে সঙ্গে সব বিদ্যমান stroke নরম, low-resolution image হয়ে যেত; Pencil তুললে সেগুলি আবার পূর্ণ clarity-তে ফিরে আসত।

Tester একটি কথাই বলেছিল: “আমি লেখার সময় পুরো canvas ঝাপসা হয়ে যায়। ছেড়ে দিলেই পরিষ্কার হয়।”

এই optimization-টি সরিয়ে দেওয়া হয়েছিল। 0.85 ms ছিল মাপা ফলগুলির মধ্যে সর্বনিম্ন, কিন্তু এটি গ্রহণযোগ্য chalk tool ছিল না। বিদ্যমান stroke-গুলি writing feedback-এর অংশ; pen-down-এর সময় তাদের clarity বদলানো যায় না।

প্রতিটি chalk stroke-কে তার নিজের rectangle-এ সীমাবদ্ধ করা

বাস্তবায়িত সংশোধনে প্রতিটি stroke-এর scratch ও compositing রাখা হয়েছিল; কেবল তাদের pixel work scope কমানো হয়। প্রতিটি stroke-এর stamp radius-গুলির union থেকে তৈরি canvas bounding box আগে থেকেই ছিল। Renderer সেই bounding box-কে বর্তমান viewport-এর drawable coordinates-এ transform করে এবং antialiasing margin-এর জন্য দুই pixel বাড়িয়ে দেয়:

একই scissor rectangle পরে তিনটি কাজে ব্যবহৃত হয়: scratch clear করা, stroke draw করা এবং ফলটি main surface-এ আবার composite করা।

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

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

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

4096² ink texture-এ baking এবং partial replay-এর ক্ষেত্রেও একই logic ব্যবহৃত হয়, তাই উচ্চ-জুমের display ও স্থির ink layer দুই ধরনের chalk behaviour তৈরি করে না।

এখানে দুটি detail সহজেই বাদ পড়ে।

প্রথমত, একটি render pass-এর loadAction = .clear attachment load stage-এ ঘটে এবং rasterisation scissor দ্বারা সীমাবদ্ধ নয়। এটি ব্যবহার করতে থাকলে পুরো scratch texture-ই clear হত। সংশোধিত pass .dontCare ব্যবহার করে, তারপর scissor-এর ভিতরে একটি clear_fragment draw করে। ওই rectangle পরে সম্পূর্ণ লেখা হয়, আর composite কেবল একই rectangle পড়ে; তাই পুরনো attachment content load করার দরকার হয় না।

দ্বিতীয়ত, প্রতিটি chalk stroke-এর composite শেষ হলে outer scissor restore করতে হয়। এই state-restoration line বাদ পড়লে পরের pen, image বা selection আগের chalk stroke-এর bounds-এর মধ্যে clipped হতে থাকবে, ফলে stroke বা image missing মনে হবে।

Chalk grain এখনও rectangle-এর local UV নয়, absolute canvas coordinates থেকে sample করে। Scissor সরালে GPU কেবল কোন pixel process করবে তা বদলায়; কোন pixel grain texture-এর কোন location পড়বে, তা বদলায় না। তাই পাশাপাশি rectangle-এ texture seam তৈরি হয় না, আর canvas drag করলেও grain drift করে না।

শুধু pixel workload ধরলে নতুন work scope প্রায় এমন

যেখানে হল বর্তমান screen-এ -তম chalk stroke-এর axis-aligned bounding-box area। Render encoder-এর সংখ্যা কমেনি, কিন্তু এখন প্রতিটি clear ও composite stroke-এর নিজের screen bounding box-এর মধ্যে সীমাবদ্ধ।

একই রঙের chalk stroke batch করা হয়নি কেন

পৃষ্ঠার বেশিরভাগ chalk stroke-এর রং ও density একই, তাই scratch-এ একসঙ্গে দশেরও বেশি stroke draw করে শুধু একবার composite করার কথা ভাবা স্বাভাবিক। এতে render pass আরও কমত, কিন্তু overlap হওয়া এলাকায় রং ও grain-এর semantics বদলে যেত।

একটি ইচ্ছাকৃতভাবে সরলীকৃত উদাহরণ ধরা যাক: একটি নির্দিষ্ট pixel-এ দুইটি stroke-এর grain-gate value একই , আর body coverage যথাক্রমে । আসল shader-এ gate প্রতিটি stroke-এর pressure depth-এর ওপরও নির্ভর করে; batching সাধারণভাবে সমতুল্য নয়—এটি দেখানোর জন্য এই সরল ক্ষেত্রটিই যথেষ্ট। বর্তমান per-stroke compositing দেয়

যেখানে body দুটিকে আগে merge করে পরে একটি gate প্রয়োগ করলে পাওয়া যায়

পার্থক্য হল । দুইটি stroke overlap করলে এবং grain gate শূন্য বা একেবারে এক না হলে ফল আলাদা হয়। সরাসরি batching করলে crossing-এ chalk dust কীভাবে বসে, তা বদলে যাবে।

Exact batching-এর জন্য প্রমাণ করতে হবে যে stroke pixel-গুলি পরস্পর disjoint, অথবা প্রতিটি stroke-এর জন্য আলাদা atlas region বরাদ্দ করে মূল ক্রমে composite করতে হবে। ডিভাইসের গ্রহণযোগ্যতা পরীক্ষায় scissor approach-ই রাখা হয়েছিল, তাই এই round-এ atlas বা তার management complexity যোগ করা হয়নি।

এক বিলিয়ন pixel থেকে আবার কয়েক মিলিয়নে

ডিভাইসের মাপজোক:

পরিস্থিতিসংশোধনের আগেনির্দিষ্ট scissor
70টি দৃশ্যমান chalk stroke, 300% জুমে লেখাGPU 52–60 ms≈ 9–10 ms
≈ 121টি দৃশ্যমান chalk stroke, 300% জুমGPU 77–80 ms13.7–15.6 ms
প্রতি ফ্রেমে rectangle-এর তাত্ত্বিক কাজের পরিসর (scratch + composite)783 M–1.34 B পিক্সেল≈ 1.7 M–3 M পিক্সেল

একটি ডিভাইস document-এর 3,452টি stroke এবং 202,710টি sample point ব্যবহার করে clipping bounds-ও যাচাই করেছি। 0.5×, 1×, 2×, 3×, 5× এবং 8× zoom-এ 186,408টি viewport case তৈরি করা হয়েছিল; non-zero coverage তৈরি করতে পারে এমন প্রতিটি point sprite computed scissor-এর ভিতরে পড়েছে। এই পরীক্ষায় canvas edge, viewport edge এবং বিভিন্ন offset combination অন্তর্ভুক্ত ছিল।

নির্বাচিত বাস্তবায়ন ইন্টারঅ্যাকশনের অবস্থার ভিত্তিতে low-resolution LOD-এ switch করে না। কম জুমে এখনও পুরো-পৃষ্ঠার ink texture দেখানো হয়; বেশি জুমে এখনও দৃশ্যমান stroke vector হিসেবে redraw করা হয়। Threshold-এর একই পাশে কলম নামানো বা ক্যানভাস সরানো বিদ্যমান stroke-কে অন্য স্বচ্ছতার স্তর দিয়ে replace করে না। High-zoom vector redraw-এর সময় প্রতিটি chalk stroke-এর scratch clear ও composite কেবল তার নিজের screen bounding box cover করে।

GPU time clarity ধরতে পারেনি

GPU performance problem data structure-এর সবচেয়ে চোখে পড়া পরিমাণের সঙ্গে সবসময় scale করে না। এখানে 3,571টি input point-কে সহজ suspect মনে হয়েছিল; কিন্তু frame time নির্ধারণ করেছিল 70টি chalk stroke-এর প্রতিটির কারণে চালু হওয়া full-screen work এবং render-pass switching।

Visual semantics-ও available optimization সীমিত করেছিল। Per-stroke scratch, original compositing order এবং absolute canvas grain coordinates ইচ্ছেমতো বাদ দেওয়া যায়নি। একই রং ও একই density মানে কেবল parameter একই; overlap করা result merge করা যাবে, তা প্রমাণ হয় না।

বাস্তব device-এর feedback সবচেয়ে কম GPU time-ওয়ালা version-টিকে বাতিল করেছিল। “পুরো canvas ঝাপসা হয়ে যায়”—এই মন্তব্য measurement একা যে product constraint প্রকাশ করতে পারেনি, তা স্পষ্ট করেছিল: Apple Pencil screen-এ ছোঁয়ার সময় ব্যবহারকারী বিদ্যমান stroke-গুলিও দেখছেন।

নির্বাচিত বাস্তবায়নে নতুন interaction cache layer যোগ করা হয়নি এবং স্বচ্ছতা কমানো হয়নি। High-zoom vector redraw কেবল প্রতিটি chalk stroke-এর কাজ তার নিজের screen bounding box-এ সীমাবদ্ধ করে। LucasPad-এ আবার load করার পর feedback ছিল: “দারুণ দেখাচ্ছে।”