Первый шарик на OLED я сделал за двадцать минут и ещё столько же любовался, как он мигает. Схема выглядела логично: clearDisplay — буфер чист — display() — показать чистый, потом рисуем круг и снова display(). В итоге каждое движение отправляло на экран два кадра: пустой и собранный. Стробоскоп вышел такой, что рябило в глазах, а я двое суток грешил на питание и провода. Всё по учебнику, но не то.
Диагноз помещается в одну фразу: экран обновляется только в display(), и каждый вызов — полноценный кадр, включая пустой. Сам clearDisplay ни при чём: он чистит буфер в ОЗУ и стекло не трогает — это разобрано в уроке про буфер и display(). Мерцание рождается из показа промежуточных кадров: пустых или полусобранных. Хорошая новость в том, что на монохромном SSD1306 с буфером в ОЗУ лечится всё одним движением — модель я уже разбирал, осталось применить её к движению.
Здесь соберём петлю анимации, которая не мигает: стирание, рисование, ровно один показ, интервалы по millis. Плюс бюджет шины — сколько кадров в секунду физически уезжает по I2C и что делать, когда хочется больше. Спойлер честности: чаще всего хочется не большего кадрового темпа, а более короткого пути рисования — и это разные вещи.
Петля кадра: стирание, рисование, один показ
Каркас неизменен для любой анимации. Раз в интервал — я беру 40 миллисекунд — делаем: clearDisplay, рисуем все элементы кадра, display() — и до следующего интервала loop занят своими делами. Кнопки опрашиваются, датчики читаются, Serial живёт: в моём погодном узле на той же шине висели часы, и дрожь цифр на экране совпала с опозданиями часов — полезный диагностический признак голодания шины. С тех пор я проверяю темп кадра именно так: часы на шине — бесплатный индикатор, опережающий любой осциллограф. Рисование идёт в буфер, стекло всё это время показывает прошлый кадр, а новый появляется целиком, когда собран.
Почему между clearDisplay и display() нельзя звать показ: каждый display() отправляет текущее содержимое буфера, каким бы оно ни было. Пустой буфер — пустой кадр на стекле, глаз видит вспышку. То же с показом после каждого print: полусобранные кадры мелькают, как плохой мультфильм. Правило одно: показ один, в конце, после сборки всего кадра. Симптом лишнего показа виден глазом: изображение как будто подрагивает по вертикали — пустой кадр гасит все пиксели на миллисекунды.
Теперь бюджет. Кадр 128×64 — 1024 байта, каждый байт по I2C едет девятью битами; на стандартных 100 кГц это около 92 мс передачи, мы считали в уроке про графику. Потолок — десять-одиннадцать кадров в секунду. Интервал 33 мс, то есть желанные 30 fps, физически не уезжает: кадры встают в очередь, шина голодает, соседи по I2C тормозят. Разгон Wire.setClock(400000) сразу после begin() сжимает кадр до 23 мс и делает 30 fps достижимыми — механика разгона разобрана в уроке про частоту шины, но помните про короткие провода. Промежуточный вариант — обновлять движение не каждый кадр: шарик перескакивает на клетку раз в два прохода, глаз этого не замечает, а шина дышит.
Позиция движущегося объекта — два подхода. Циклическое движение я считаю от времени: x = (millis() / 40) % 128 — координата всегда функция времени, дрейф не накапливается. Отскоки и смену направления от кнопки делаю шагом: x = x + dx, на границе dx переворачивается. Шаговой схеме нужен тайминг из урока про millis вместо delay — delay в петле анимации замораживает и кнопки, и Serial. И экономия без вреда: если за кадр ничего не изменилось, display() можно пропустить — буфер уже совпадает с экраном, и батарейный узел скажет спасибо.
| Интервал | Кадров в секунду | Шина 100 кГц | Когда годится |
|---|---|---|---|
| 1000 мс | 1 | занята на ~9% | цифры и статусы |
| 200 мс | 5 | ~46% | плавные счётчики, мой дефолт |
| 100 мс | 10 | ~92% | потолок Uno, шина почти занята |
| 40 мс | 25 | очередь и дрожь | целься ниже — 100 мс |
| 33 мс | 30 | не хватает, кадры в очередь | только с Wire.setClock(400000) |
#include <Wire.h>#include <Adafruit_GFX.h>#include <Adafruit_SSD1306.h> Adafruit_SSD1306 display(128, 64, &Wire, -1);int x = 10;int y = 32;int dx = 3;int dy = 2;long lastKadr = 0;bool bPrev = false; void setup() { display.begin(SSD1306_SWITCHCAPVCC, 0x3C); pinMode(2, INPUT_PULLUP); // кнопка на D2: удержание = пауза} void loop() { if (millis() - lastKadr >= 40) { // цель ~25 кадров/с lastKadr = millis(); bool b = (digitalRead(2) == LOW); if (b && !bPrev) { dx = -dx; } // фронт нажатия — разворот bPrev = b; if (!b) { x = x + dx; y = y + dy; if (x <= 3) { x = 3; dx = -dx; } // радиус 3: границы 3..124 if (x >= 124) { x = 124; dx = -dx; } if (y <= 3) { y = 3; dy = -dy; } if (y >= 60) { y = 60; dy = -dy; } } display.clearDisplay(); // буфер пуст, стекло ещё старый кадр display.fillCircle(x, y, 3, SSD1306_WHITE); display.display(); // ровно один показ на кадр }}Темп, пауза и счётчики в симуляторе
Разберём симуляторный скетч по строкам. Интервал кадра приходит из потенциометра на A0: map(analogRead(A0), 0, 1023, 60, 300) растягивает поворот ручки в диапазон 60–300 миллисекунд — от быстрого движения до ленивого, без единого delay. Ручка слева — 60 мс и резвый шарик, ручка справа — 300 мс и степенная походка; это те же 5–10 кадров в секунду, между которыми жил дашборд из урока про графику. Кадр собирается заново на каждом интервале: clearDisplay, шапка, дорожка из точек с шариком, подвал с координатами — те же три слоя, что у любого дашборда из урока про текст.
Кнопка на D2 работает паузой: пока контакт замкнут на землю, позиция не меняется, но кадр всё равно собирается и показывается — на стекле остаётся картинка, а не пустота. Это важная деталь паузы: я останавливаю только шаг, а сборку кадра оставляю, чтобы статус и счётчики продолжали жить; если заморозить и сборку, экран замрёт вместе с объектом — тоже вариант, но менее гибкий.
Serial печатает пары x= и dir= раз в кадр: в мониторе видно темп — при ручке на минимуме строки идут чаще, при максимуме реже. Такая печать — дешёвый осциллограф темпа: если строки идут вразнобой, значит, интервал плавает, и виноват обычно чужой delay, вкравшийся в соседний блок кода. На плате те же пары удобны перед Serial-плоттером из урока про график.
Стирать всё или только старое?
Локальная перерисовка экономит рисование: fillRect чёрным по старой позиции объекта, новый объект на новом месте, фон не трогаем. Полная перерисовка заново собирает весь кадр, включая шапку и рамки. На монохромном SSD1306 глаз разницы не увидит: и там, и там на экран уезжает один собранный кадр — промежуточных состояний стекло не показывает. Разница проявляется только в Serial: при полной перерисовке кадр собирается на пару десятков микросекунд дольше — на 40-миллисекундном интервале это в запасе.
Шину не экономит ни тот, ни другой: display() всегда передаёт все 1024 байта, сколько бы пикселей ни поменялось. Экономия от локальной перерисовки — только процессор, и заметна она на тяжёлых кадрах: график из сотни точек, таблица на восемь строк. Для шарика из одного fillCircle разница микросекундная, поэтому мой дефолт — полная перерисовка: она не забывает стереть хвосты и предсказуема при любом темпе ручки.
Шлейф — классическая грабля локального подхода: забыли погасить старое место, и объект тянется следом. Иногда шлейф и нужен — бегущая строка, график, где старое остаётся как история; тогда его не устраняют, а выбирают осознанно. Мой график температуры на 96 точек живёт именно так: новая точка прирастает справа, старые не стираются — история читается, а шина тратит те же 92 мс на кадр.
- Петля кадра: clearDisplay → рисование всех элементов → ровно один display() в конце
- clearDisplay чистит только буфер; мерцает показ пустого или полусобранного кадра — лишний display()
- Бюджет шины: 92 мс на кадр на 100 кГц ≈ 10 кадров/с; Wire.setClock(400000) поднимает потолок до ~40
- Циклическую позицию считай от millis(): x = (millis() / 40) % 128 не накапливает дрейф
- Локальное стирание экономит процессор, а не шину: display() всегда передаёт 1024 байта
- Между кадрами loop остаётся живым: кнопки и датчики опрашиваются без delay
- 1. Почему clearDisplay в каждом кадре может давать мерцание?
- 2. Сколько вызовов display() на один кадр анимации?
- 3. Каков потолок кадров в секунду для 128×64 по I2C на 100 кГц?
- 4. Чем хороша позиция, посчитанная от времени: x = (millis() / 40) % 128?
- 5. Что экономит стирание только старой зоны объекта вместо полной перерисовки?
Тот же каркас двигает что угодно: бегущую строку из текстового урока, стрелку компаса, график, который прирастает точкой раз в минуту. Спрайт собирайте из фигур урока про линии и фигуры, подписи и счётчики — из урока про текст и setCursor: анимация складывается из уже знакомых деталей, нового здесь только дисциплина одного показа. И правило, которое я вынес из своих стробоскопов: сначала интервал и бюджет шины, потом украшения — обратный порядок делает красивый кадр врагом кнопок и часов на той же шине. Если хочется плавнее — сначала смотрю, что ещё живёт на I2C, и только потом двигаю интервал вниз.
Домашнее задание: залейте скетч шарика на плату, поставьте интервал 33 мс и посмотрите на дрожь при 100 кГц — кадры не успевают. Добавьте Wire.setClock(400000) сразу после begin() и сравните: движение станет ровнее, а кнопка-пауза продолжит работать, потому что loop не занят передачей целиком. И запишите обе цифры — 92 и 23 миллисекунды: они пригодятся всякий раз, когда на A4 и A5 появится второй сосед.
Частые вопросы по теме урока
- Почему clearDisplay в каждом кадре может давать мерцание?
- clearDisplay трогает только ОЗУ. Мерцание появляется, когда на экран уходит пустой кадр — display() вызван до рисования. Сам clearDisplay безобиден: мерцает показ пустого буфера лишним display()
- Сколько вызовов display() на один кадр анимации?
- Каждый display() показывает текущий буфер целиком. Лишний вызов между этапами сборки отправляет на стекло полукадр. Один, после сборки всего кадра
- Каков потолок кадров в секунду для 128×64 по I2C на 100 кГц?
- Кадр 1024 байта × 9 бит ≈ 92 мс. Сто миллисекундного интервала почти целиком уходят на передачу — больше десяти кадров шина не уносит. Около 10
- Чем хороша позиция, посчитанная от времени: x = (millis() / 40) % 128?
- Шаговая схема копит погрешности тайминга. Координата от millis() каждый кадр вычисляется заново — расхождение не растёт. Не накапливает дрейф: координата всегда функция времени
- Что экономит стирание только старой зоны объекта вместо полной перерисовки?
- Передача всегда целая — 1024 байта. Выигрыш локальной перерисовки в микросекундах рисования, заметный на тяжёлых кадрах. Процессор, а шину нет: display() по-прежнему гонит 1024 байта