Lulucat

Xây dựng lại pipeline kết xuất của Lulucat Notes cùng Kimi K3

Gaoge ZhangGaoge Zhang

Pipeline tile Core Graphics của chúng tôi đã trở thành pipeline point-sprite Metal trong sáu bước nhỏ, mỗi bước được kiểm chứng trên một iPad thật. Mã được lập trình theo cặp với Kimi K3 trên Fireworks.

Đã cập nhật

Tuần trước, kéo một vùng chọn lasso qua Lulucat Notes tạo ra một làn sóng thấy rõ: trong cùng một khung hình, một số tile màn hình hiển thị vùng chọn ở vị trí mới, còn những tile khác vẫn hiển thị vị trí cũ. Chúng tôi thay thế toàn bộ pipeline kết xuất — bitmap Core Graphics cộng với CATiledLayer — bằng Metal trong sáu bước nhỏ; mỗi bước được kiểm chứng trên một iPad thật trước khi bước tiếp theo bắt đầu.

Mã được lập trình theo cặp với Kimi K3, mô hình mở của Moonshot chạy trên Fireworks. Con người dẫn dắt, quyết định và kiểm thử; mô hình viết gần như từng dòng.

Pipeline

Pipeline cũ có hai kiểu vẽ dần lệch khỏi nhau: stroke được bake vào bitmap để hiển thị với chi phí thấp, và được vẽ lại dưới dạng vector mỗi khi tile cần nhiều chi tiết hơn. Pipeline mới chỉ có một ý tưởng: mọi thứ giống mực đều là một point sprite. Một pen stroke, một highlighter sweep và một eraser dab đều là cùng một vertex 32-byte — position, diameter, color — được vẽ bởi cùng một cặp shader thành các hình tròn được rasterize trên GPU, cách nhau một point dọc theo arc length của stroke.

Ba bảng của cùng một nét cong: các điểm chạm đầu vào, các con dấu tròn cách đều dọc theo đường cong theo chiều dài cung, và nét đặc được tổng hợp.

Ở khoảng cách một point, một chuỗi hình tròn lệch khỏi một hình viên nang hoàn hảo về mặt toán học khoảng 0.075 points — một phần năm pixel ở mật độ canvas của chúng tôi. Đổi lại, ba công cụ hợp nhất thành một code path duy nhất, và GPU làm đúng việc nó giỏi nhất.

Quanh ý tưởng đó, kiến trúc đơn giản. Mực đã commit nằm trong một texture 4096² duy nhất. UIScrollView vẫn còn, nhưng được giáng xuống thành một engine cử chỉ thuần túy: contentOffsetzoomScale của nó cấp dữ liệu cho một viewport uniform trong mỗi khung hình, nên pan và zoom không ghi gì cả. Mỗi khung hình là năm lần vẽ:

flowchart TB
    subgraph frame["Every frame: five draws"]
        direction TB
        paper["1 · paper blit"] --> ink["2 · committed ink"]
        ink --> live["3 · live stroke"]
        live --> sel["4 · selection"]
        sel --> dash["5 · lasso dashes"]
    end
    commit["stroke commit<br/>append stamps"] --> tex[("ink texture<br/>4096² render target")]
    replay["regional replay<br/>erase · delete · move · undo"] --> tex
    tex -. "sampled or re-drawn" .-> ink

Các thao tác chỉnh sửa ghi thẳng vào texture. Commit một stroke thì thêm các stamp của nó. Erase, delete, move và undo replay vùng bị ảnh hưởng phía sau một scissor rectangle: xóa vùng đó, vẽ lại các stroke giao với nó, xong. Partial erasure giữ nguyên ownership semantics từ bài viết trước — một erasure thuộc về stroke mà nó lấy mực đi — bằng cách vẽ từng stroke bị tẩy vào một scratch texture, trừ các erasure path của chính nó bằng destination-out blending (), rồi composite kết quả trở lại. Scratch isolation ngăn cục tẩy cắn xuyên qua giấy hoặc các stroke lân cận.

Vùng chọn đã khởi đầu tất cả nay cũng được vẽ dưới dạng point sprite. Kéo nó cập nhật một offset uniform duy nhất. Không ghi texture nào, không invalidate tile nào — làn sóng đã biến mất về mặt cấu trúc, chứ không phải được giảm nhẹ.

Người gác cổng: một pixel diff

Chúng tôi không xóa renderer Core Graphics. Chúng tôi giáng nó xuống thành một reference implementation offline, và mọi thay đổi Metal phải vượt qua một so sánh pixel với nó trên dữ liệu stroke thật được chụp trên thiết bị. Tiêu chí chấp nhận không phải là “pixel giống hệt” — hai rasterizer đúng có thể chính đáng bất đồng nhau vài gray levels dọc theo các cạnh anti-aliased. Cổng vào mang tính cấu trúc: không thiếu mực, không offset, không trôi màu, và không có khác biệt lớn ở bất kỳ đâu cách xa mực.

Ba phần cắt của cùng các ghi chú viết tay: được kết xuất bằng Core Graphics, được kết xuất bằng point sprite Metal, và hiệu pixel của chúng được phóng đại sáu lần, chỉ cho thấy các viền mờ dọc theo cạnh nét.

Bộ harness này bắt được bốn trong số năm bug chúng tôi gặp phải khi xây dựng renderer offline; tất cả đều được chẩn đoán bằng cách cắt output xuống mức pixel: struct stride của Swift/Metal không khớp (28 bytes so với 32, vì Metal căn float4 theo 16 — màn hình đầy các khối màu), [[point_size]] không đọc được dưới dạng varying trong fragment shader (mọi con dấu đều ra hình vuông), hai render encoder cùng tồn tại trên một command buffer (toàn bộ thành màu đen), và một start dab bị thiếu làm cho millimeter đầu tiên của các stroke nhanh trở nên vô hình.

Sáu bước, không phải một lần viết lại

Kế hoạch di chuyển gồm sáu bước có thể xuất độc lập: renderer offline vượt pixel diff; display shell với zero thay đổi thị giác; live stroke trên GPU; vùng chọn trên GPU; các mutation ghi trực tiếp vào texture; vẽ lại vector ở zoom cao (gộp vào bước hai, vì “zero thay đổi thị giác” đòi hỏi điều đó). Mỗi bước kết thúc bằng một con người viết, tẩy, zoom và kéo trên iPad Pro ở trên bàn — không phải simulator, không phải screenshot diff.

Thiết bị bắt được ba bug mà mọi kiểm tra tự động đều bỏ sót. Trên 100% zoom, các stroke được vẽ hai lần — texture mềm bên dưới, sprite sắc ở trên — tạo thành một vệt mờ mà con người nhận ra trong vài giây. Commit một stroke nhấp nháy trong một khung hình vì overlay cũ cross-fade không đồng bộ với lần cập nhật texture. Trên 170% zoom, mọi ghi chú đều biến mất: hình chữ nhật visibility-culling dùng contentOffset trong scaled coordinate space, nên nó trôi xa các stroke khi bạn zoom. Cả ba đều là các bản sửa từ một dòng đến một hàm, và không cái nào tồn tại trong bất kỳ test nào chúng tôi có thể viết trước đó, vì chúng tôi không biết phải tìm chúng. Đó là lý do con người vẫn phải ở trong vòng lặp với một app tiêu dùng ưu tiên UI.

Làm việc cùng Kimi K3 cảm thấy thế nào

Nhanh, trước hết. Vòng lặp “discuss, write, build, install, look” chạy trong vài phút, và một mô hình trả lời nhanh thay đổi số vòng lặp bạn có thể đủ sức thực hiện trong một ngày.

Thứ hai, nó không over-engineer. Codebase này chạy theo các house rules tường minh — không scaffolding tương thích ngược trước khi ra mắt, chỉ thêm complexity khi một thiết bị chứng minh điều đó là cần thiết — và K3 tuân theo chúng mà không cần nhắc nhở. Nó không thêm các spatial index “dành cho sau này”, không bọc mọi call trong các defensive check, không trừu tượng hóa một cách phỏng đoán. Prompt nó có cảm giác giống như làm việc với một đồng nghiệp có năng lực, người đã đọc các house rules và thực sự tin vào chúng.

Thứ ba, cho nó công cụ và nó sẽ sử dụng chúng một cách hăng hái. Chúng tôi đấu nối các image utility — xem, cắt đến một vùng pixel, đổi cỡ — và mô hình bắt đầu chủ động cắt output renderer của chính nó để chẩn đoán năm bug harness ở trên. Có công cụ nhắc nó phải nhìn.

Mặt kia: K3 đã viết hầu hết các bug trong câu chuyện này, kể cả bug không gian tọa độ làm các ghi chú biến mất. Giới hạn của nó là có thật. Điều khiến công việc an toàn không bao giờ là mô hình đúng; mà là harness bắt được độ trôi khi kết xuất và con người bắt được cảm giác. Thế nhưng, ngày qua ngày, tôi không thể phân biệt nó một cách đáng tin cậy so với các mô hình đóng ở tuyến đầu mà chúng tôi cũng dùng — các hệ thống cấp Opus. Trên một số trục nó rõ ràng tốt hơn: nhanh hơn, và ít có xu hướng đệm codebase bằng defensive design hơn nhiều.

Cách chúng tôi muốn xây dựng từ nay về sau

Chúng tôi đã chấm dứt với phát triển agentic theo spec lớn — kiểu mà bạn trao cho mô hình một đặc tả lớn và chấp nhận bất cứ thứ gì đáp xuống. Kiểu thất bại không phải là code tồi; mà là code chẳng ai hiểu.

Thứ đã hiệu quả ở đây, và thứ chúng tôi sẽ giữ lại: các bước nhỏ, mỗi bước được thảo luận trước khi bắt đầu, mỗi bước được con người hiểu trước khi được xây, mỗi bước được kiểm chứng trên thiết bị mà nó sẽ sống trên đó. Công việc của mô hình là nhanh, chính xác và trung thực về sự bất định. Công việc của con người là phán đoán, gu và kiểm chứng e2e — đặc biệt với phần mềm tiêu dùng ưu tiên UI, nơi đặc tả không thể mô tả cảm giác của điều “đúng”. Hóa ra Kimi K3 trên Fireworks có hình dáng phù hợp đúng với vòng lặp này: đủ nhanh để giữ vòng lặp chặt, đủ thông minh để giữ các bước nhỏ và sạch.

Làn sóng đã biến mất, pipeline là một ý tưởng thay vì hai, và quá trình đã đưa chúng tôi đến đó thì vẫn được giữ lại.