spatilabs.co · OsciDraw · 8 сентября 2026
OsciDraw — аудит архитектуры
Снимок на v310. Собрано из кода и двух чатов: основание (июль 2026, v1–v102) и продолжение (август–сентябрь 2026, v102–v310). Ничего в репозитории не менялось — это только записка, почему приложение устроено так, как есть.
Замысел, который до сих пор держит форму
Первый чат начался с формулировки: мультиплатформенный GPU-пейнтер, огромные кисти на огромном холсте должны быть быстрыми. Холст — UV-развёртка на одном полноэкранном треугольнике (не кваде), чтобы потом тот же ключ тайла открыть в 3D texture paint. Три стартовых инструмента: кисть, move (2D sculpt / liquify) и ластик. Кисть ведёт себя как осциллятор в синтезаторе: LFO, MIDI и OSC крутят размер, цвет и прочие параметры на лету. Штрих — не цепочка кружков по времени, а непрерывный поток покрытия, «как чистая синусоида», но круглый штамп. Эффекты (частицы, искры) живут во время штриха и запекаются в текстуру при отпускании.
С этого момента почти каждое жёсткое решение в коде — защита этой скорости и этого ощущения пера. Если что-то конфликтует с «перо пишет сразу и на правильной глубине», побеждает перо.
Как сейчас разложен код
Workspace из пяти крейтов. oscidraw-core — чистые типы: холст,
вьюпорт, кисть, LFO, матрица модуляции, инструменты, слой-стек.
oscidraw-gpu — wgpu: устройство, tiled canvas, аккумулятор
покрытия, композитор слоёв, liquify, лупа, blur окна. oscidraw-input
— WinTab, Precision Touchpad, MIDI, OSC. oscidraw-effects — задел
под Niagara-подобные эмиттеры. oscidraw-app — winit-цикл, egui,
история, сессия, шорткаты, панели.
Кадр устроен так: опрос планшета и мыши → непрерывный ресэмпл штриха
с экранного курсора (чтобы MIDI-зум при неподвижном пере рисовал
дугу, а не мосты) → egui UI → sync_live_hsl → RenderState::draw.
В draw: flush штрихов, recompose только грязных тайлов, основной
проход canvas triangle, лупа, egui, опционально resize/HSL blur,
submit, present. UI-поток и GPU-поток не разделены: тяжёлая работа
на compose блокирует окно. Это сознательно — меньше латентности
пера, дороже любые «пересобери весь стек» вызовы.
Слои живут в двух местах. LayerStack в core — дерево как в CSP/SAI:
папки, clip, alpha lock, маска, blend, тег, tint, lock. LayerStore
на GPU — тайлы и маски каждого растра. Композит пишется в
canvas.tiles, а на экран идёт уже этот композит плюс UDIM-тинт в
display-шейдере. История хранит снимки слоёв патчами, не целиком
64 МБ на каждый штрих. Сессия: settings.json, session_canvas.psd,
session_layers.bin в конфиг-директории, плюс автосейв. Версия на
экране — не semver, а crates/oscidraw-app/.build_number, который
build.rs поднимает на каждый release build.
Пайплайн краски — почему именно так
Покрытие, а не штампы
Это не первая модель. Сначала были дискретные штампы — альфа
копилась на перекрытиях, швы темнели. Потом капсулы SDF с той же
болезнью. Потом ribbon mesh («ёжик» из miter). Остался путь как в
Procreate: капсула в coverage R16Float размером 6144 (тайл 4096
плюс поля по 1024), бленд MAX — перекрытия не темнеют, считается
только максимум покрытия. Display = композит + живой coverage. На
lift покрытие × цвет × opacity печётся в тайл слоя, буфер чистится.
Пока перо стоит, dab всё равно ставится на каждый display tick —
иначе airbrush замирает.
Live stroke сидит в композиторе, не в fs_quad
Раньше превью штриха легко оказывалось поверх всего холста. После
слоёв это сломано по смыслу: кисть должна жить на активном растре,
с его opacity, blend, clip, маской и alpha lock. Поэтому live
coverage мешается в shaders/layer_blend.wgsl под флагом FLAG_LIVE,
а canvas_quad.wgsl только показывает уже собранный композит и
рисует серый UDIM под прозрачными кирпичами. Display-шейдер больше
не имеет права знать про краску.
Ластик — умножение, не dest-out
v295. Классический dest-out в sRGB оставлял белые следы: «вычитание»
из уже гамма-закодированного композита выглядело как бумага, а не
дырка. Сейчас ластик делает src * (1 - coverage) в режиме REPLACE
на самом слое. Alpha lock при ластике — no-op: нельзя стереть то, что
lock обещает сохранить. Краска при lock идёт только там, где альфа
слоя уже ненулевая, и эту альфу не меняет.
Один тайл — один submit
Комментарий в LayerStore::recompose прямо говорит: один 4k-стек в
одном command buffer ловит Windows TDR и убивает устройство. Поэтому
каждый ключ тайла — отдельный encoder/submit. Медленнее на бумаге,
живее драйвер. То же семейство решений: не Wait на UI-потоке при
readback, стартовать штрих с CPU-кэша, а не с 64 МБ GPU read, на
lift копировать только патч, сессию декодировать в другом потоке
(v266, v283, v286, v296). На restore сессии полный recompose всех
4k тайлов запрещён — это уже убивало GPU через TDR (v293). Диалоги
файлов тоже не на UI-потоке: на Windows вложенный modal зависает.
UDIM — серый под краской, не в краске
Home (0,0) всегда белая бумага (лупа и пипетка читают её как
холст). Соседние кирпичи прозрачные. Серый workspace — live tint в
display-шейдере, слайдер внизу Layers. Когда серый однажды запекли
в пиксели слоя, ластик жевал «фальшивую бумагу». v298 вычистил это
из пикселей. v300 оставил тинт только на соседях, home белый.
Тайл-ключ уже Mari-style (1001 = home), в комментарии к
TileCoord прямо написано, что будущая покраска меша ляжет на те
же координаты.
Перо, окно, курсор — принятые развилки
WinTab — главный источник XY и давления Wacom. Очередь пакетов
расширили: дефолт Wacom на 8 пакетов переполнялся и давал stall
(v210). Кнопки мыши для pan/zoom читаются через GetAsyncKeyState,
потому что синтезированные WM_*BUTTONDOWN от планшета приходят с
задержкой (v191–v193). Перовые кнопки нельзя пускать в mouse-nav:
холст улетал за экран (v194).
Относительный mouse-synth для пера пробовали как лекарство от лага WinTab XY (v204, v207) и откатили: канвас летел, зум умирал (v205), затем DeviceEvent relative окончательно выключили и вернули абсолютный WinTab (v211). Текущий компромисс: абсолют WinTab для позиции, latch кнопок без ожидания отложенного WM, LMB как fallback если tip-down потерялся (v253), leftover LMB от Wacom игнорируется во время ресайза окна, чтобы не срывать захват и не плевать краску (v251–v252). Tip-down владеет WinTab XY: нельзя подмешивать trackball и выдумывать mouse pressure 1.0 во время штриха. PTP (ASUS) идёт своим Raw HID путём и не должен зумить через egui wheel. WM_POINTER исследовали и не взяли как основной канал — Windows Ink у пользователя выключен, как в Clip Studio. Рано в истории (около v93) уже откатывали слой Wacom Feel MT / BPT3 / GetCursorPos-хаков: слишком хрупко. На frameless окне digitizer context сначала съедал кнопки пера у egui — маски кнопок на быстром контексте обнулили, координаты оставили WinTab.
Над холстом системный курсор скрыт — рисуется превью кисти (кольцо
цвета, давление внутренним кольцом, инверсная звезда в центре). Над
панелями снова системный курсор: кастомная стрелка зависала из-за
throttled redraw (v107, запрос «курсор зависает над интерфейсами»).
Перо над UI не должно начинать штрих — pointer_hits_ui_chrome и
point_over_canvas вычитают плавающие окна, в том числе HSL popup.
Окно без рамки, ресайз пером за видимый кадр, swapchain равен client rect — иначе live resize дрался с DXGI/устаревшей поверхностью (v236–v249). Скругление рамки — v304. Space-rotate — радиальный компас у курсора, Shift = 15°, север помечен «гиперборейским» солнцем, юг луной: это не декоративная прихоть, а якорь ориентации холста, когда F флипает плоскость вьюпорта в точке курсора, а не пиксели холста (v141).
Цвет и приборы
Источник истины кисти — HSVA, не RGB. Иначе MIDI saturation/value при уходе в чёрный обнуляли друг друга через RGB round-trip (v104). Колесо: жёлтый сверху, синий снизу; квадрат S/V сжат в диск, чтобы маркер не вылезал за кольцо. 3D-орбита под кривыми: цилиндр HSV и CIE Lab gamut, форма зависит от активного пространства (sRGB/P3). Точки с холста — реальные цвета канваса. Сфера выбора — PBR. Симуляция орбиты по умолчанию выключена и на паузе замораживается в скриншот, иначе виджет форсил репейнт во время рисования (v156–v159). Закрытые приборы спят и не считают (v254).
Слои
Панель появилась слева в v289 и переехала вправо в v290, чтобы не
спорить с цветом и кривыми. Сверху тулбар слоя, снизу UDIM tint,
ещё ниже — док Hue/Saturation. Модель как в CSP: папки, clip to
below, alpha lock, маска, blend, цветовой тег, tint. Drag в списке
меняет глубину стека, в том числе в папку (v305). Paint / fill /
liquify всегда идут в stack.active. Live stroke мешается на этой
же глубине, иначе превью врало бы про lock и blend.
HSL и ресайз — свежие решения
U открывает Hue/Saturation. Слайдеры крутят HSV в sRGB;
«Luminosity (V)» — это Value, как в CSP, не Lab L. Preview живой,
не блокирует краску: Apply печёт выбранный растр через ping-pong
fs_hsl, Cancel откатывает слайдеры. Сначала слой лочился в момент
открытия. Подпись «к какому слою» при этом врала при переключении в
панели — пользователь это заметил. v309 начал ретаргетить preview
на текущий растр каждый кадр.
Это сразу повесило UI. Preview по умолчанию включён, док HSL всегда
виден. Смена слоя при нулевых слайдерах всё равно помечала LiveHsl
как «другой слой» и пересобирала 4096-тайлы старого и нового.
Пустой новый слой шёл в recompose_all() всего документа. Windows
в 5:07 записал Application Hang на oscidraw.exe. v310: identity /
Preview off / папка = никакой GPU-работы; пустые ключи больше не
зовут recompose_all. Подпись по-прежнему следует за выбранным
растром. Preview переезжает только когда слайдеры реально ненулевые.
Ресайз окна (v307–v308) сделан «как на iPad»: снимок последнего хорошего кадра, dual-Kawase Gaussian, пока тянешь — холд и деформация снимка, потом fade в новую вёрстку. Во время Hold сцена не пересобирается. Док HSL блюрится на всю ширину панели слоёв, без щели справа. Перо должно попадать в кнопки, крестик и title drag попапа — отдельные hit-fix в v307.
Процесс версий — тоже архитектура
Пользователь хочет знать, какой именно бинарник запущен. Поэтому
OsciDraw vN растёт на каждую release-сборку, а не от semver.
Два cargo build --release подряд пропускают номер — поэтому
правило: одна сборка на шип, сразу копировать в
release/oscidraw.exe. После шипа — локальный коммит
OsciDraw vN: зачем и annotated tag vN. Remote не трогать.
В git не класть release/*.exe, release/*.dll,
tmp_wacom_probe/. Логи nav_*.log и paint_diag.log тоже не
версионируются. Так репозиторий стал лентой решений, а не кучей
WIP.
Находки — не трогать пока
Ниже только наблюдения. Фиксов в этом проходе нет.
Производительность
screen_blur.capture копирует весь swapchain каждый кадр в
WindowFx::None, даже когда окно не ресайзится. Если открыт HSL
popup, capture сбрасывает blurred_ready, и полный Kawase (четыре
прохода) гоняется каждый кадр. Bind group на каждый слой каждого
тайла создаётся заново при compose — аллокации на горячем пути.
Многие действия панели слоёв зовут recompose_all() (видимость,
opacity, blend, clip, маска, tint, duplicate, delete, relocate):
при нескольких UDIM-кирпичах это снова UI-thread × 4096. Даже после
v310 ненулевой HSL при смене слоя пересобирает тайлы старого и
нового растра на UI-потоке — на жирном документе клик снова может
подвиснуть, просто реже. Coverage-буфер 6144² R16Float — около
75 МБ на штрих. Орбита гамута может просмотреть до 80 тысяч
пикселей CPU-кэша. snapshot_document() по-прежнему умеет
синхронно прочитать весь тайл с GPU — это сейв и часть undo, не
hover. Diagnostics в main всегда пишут paint_diag.log.
Баги и дыры
Утром 8 сентября, ещё до v309, Windows дважды записал Application
Error 0xc0000409 (STATUS_STACK_BUFFER_OVERRUN / abort) на
release/oscidraw.exe около 1:52–1:56 — окно ресайза и blur тогда
только появились. Корневую причину не разобрали: это может быть
wgpu validation на copy_texture_to_texture при несовпадении
размера, panic=abort или настоящий overrun. Select слоя
применяется после draw, поэтому подпись HSL и preview отстают на
кадр. write_psd / save_psd помечены unused — рабочий сейв идёт
через layer pack + session psd, старый PSD-путь, похоже, мёртвый
или недоделанный. drop(render) в handle_layer_action дропает
ссылку и ничего не делает. Эмиттер искр создаётся и тикается,
BakePipeline конструируется, шейдеры particle_*.wgsl лежат в
shaders/, но в кадр частицы не рисуются и не пекутся — исходный
замысел «sparkles during stroke» не доведён.
Ненужный или спящий код
Компилятор уже орёт пачкой: from_home / home_arc /
from_composite в history; варианты SetUdimTint и Rename в
LayerAction; UiAction::ToggleHsl и ToolPreviewKind::Eyedropper
никто не конструирует; start_learn у MIDI; gpu_flip_horizontal,
recompose_dirty, read_canvas_region, restore_canvas_rgba;
поля pipette_loupe_anim и fill_fade; методы орбиты
refresh_canvas_samples, sim_playing, orbit_idle_frozen;
LayerCompositor.tile_size; glam::Vec4 в emitter;
StrokeAccum.bake_erase_pipeline помечен dead_code — ластик идёт
через основной bake. Smear живёт как Shift-жест, не как отдельный
ActiveTool. В корне куча нетреканных nav_*.log и
paint_diag.log с прошлых охот за лагом пера. CUDA / Metal / iOS /
WebGL из самого первого запроса так и не появились: реально
Windows + wgpu (Vulkan/DX12) + GNU windres для иконки. Тег и
.build_number однажды разъехались (v103 против сборки 139) —
поэтому шип всегда в один cargo build.
Источники: репозиторий OsciDraw на v310; чат основания (июль 2026); чат UI / слои / HSL (август–сентябрь 2026); Windows Application Hang 08.09.2026 5:07; Application Error 0xc0000409 в 1:52 и 1:56 того же дня.
← spatilabs.co · made with love by laditeo.art