Ночник-диммер для спальни я собрал за вечер и чуть не выбросил на следующий: ползунок ходит честно, а результат кривой. Первые две трети хода ручки свет почти не меняется — тускло и так; на последней десятой доле яркость взлетает так, что хочется выключить. Линейная шкала, которую рисует обычный map(), глазу не подходит: восприятие яркости и громкости у человека логарифмическое, и ползунок надо изгибать, а не глаз чинить.
База простая: map() из урока про целочисленное приведение типов и map — чистая пропорция на целых числах с обрезкой дробной части, и за границей входного диапазона он спокойно выдаёт значения «за забором». Дальше — продвинутое: вместо прямой линии пропорциональности подставляем кривую. Квадрат для яркости, логарифм для громкости, и все грабли, которые из этого растут, — начиная с тихого переполнения int при возведении в квадрат.
План такой: квадратная кривая v·v/255 и почему множитель обязан жить в long, логарифмический ползунок громкости без плавающей точки, обёртка safeMap с constrain внутри, инверсия шкалы одной строкой и таблица «линейная против квадратной» с числами — чтобы разницу было видно на тех же значениях, что крутит твой ползунок.
Квадратная кривая: v·v/255 и почему нужен long
Квадратная кривая — минимальное лечение нелинейного глаза. Идея: duty = v·v/255, где v — линейное значение с ползунка, приведённое к 0–255. Тёмный край растягивается по ощущениям: ползунок на четверти хода (v = 64) даёт duty 16, а не 64 — глаз получает тусклый уровень там, где линейная шкала уже «почти включила» свет. Края при этом не трогаются: v = 0 даёт 0, v = 255 даёт 255 — кривая сидит на тех же концах, что линейная, изгиб только между ними. Ступени не портятся: до середины хода соседние значения различаются минимум на единицу, выше — на два-три, глаз такой разницы не видит.
Переполнение — грабль номер один этой кривой. На Uno int — 16 бит, максимум 32 767, а 255 × 255 = 65 025: выражение (v * v) / 255 с int-переменной тихо переполняется ещё до деления — уже при v ≥ 181 — и вместо плавной кривой на верхах вылезают провалы и скачки. Лечение — один каст: (long)v * v / 255. В симуляторе числа широкие, грабль не воспроизводится, и искать его на настоящей плате мучительно: свет ведёт себя как попало только на верхней трети ручки. Приводи к long сразу, это та же арифметика, что в базовом уроке про map и приведение типов.
Для громкости кривую гнут в ту же сторону, но сильнее: слух различает децибелы, и между «шёпотом» и «громко» нужен не квадрат, а логарифм. Целочисленный логарифмический ползунок делается таблицей опорных точек: громкость 1, 2, 4, 8, 16, 32, 64, 128, 255 — девять значений растянуты на девять зон хода ручки, внутри зоны map() досыпает линейно. Две строки кода, ноль float: pow() и log() на AVR тянут за собой библиотеку плавающей точки — килобайт с лишним флеша и медленные вызовы там, где хватает таблицы.
// Квадратный диммер: safeMap с constrain, кривая v*v/255, инверсия.// analogWrite движок симулятора не исполняет - скетч для Arduino IDE.const int PIN_POT = A0;const int PIN_LED = 9; // ШИМ-пин с тильдойconst bool INVERS = false; // true: влево - ярко (режим ночника) int safeMap(long x, long inLo, long inHi, long outLo, long outHi) { if (x < inLo) x = inLo; // constrain внутри обёртки: голый map() if (x > inHi) x = inHi; // за границей диапазона даёт "за забор" return (x - inLo) * (outHi - outLo) / (inHi - inLo) + outLo;} void setup() { Serial.begin(9600); pinMode(PIN_LED, OUTPUT);} void loop() { int v = safeMap(analogRead(PIN_POT), 0, 1023, 0, 255); int kvad = (long)v * v / 255; // long обязателен: 255*255 = 65025 int yarkost = INVERS ? 255 - kvad : kvad; // инверсия шкалы одной строкой analogWrite(PIN_LED, yarkost); Serial.print("v="); Serial.print(v); Serial.print(" kvad="); Serial.println(kvad); delay(200);}Разбор по строкам. safeMap — это map() с constrain внутри: вход зажимается в границы до пропорции, поэтому за пределами диапазона обёртка не выдаёт значений «за забором», как это делает голый map() из базового урока. Каст (long)v * v — страховка от переполнения: квадрат считается в 32 битах, после деления на 255 результат честно возвращается в int. Инверсия — тернарник: 255 − kvad, одна строка, и ползунок ночника работает «влево — темно» без второй кривой. INVERS как const bool: компилятор выкидывает мёртвую ветку, на flash не тратится ни байта.
Куда это встраивать. В фейдере с плавным светом квадратную кривую ставят между ползунком и duty — и ступени на тёмном крае перестают цокать. В мосфетном диммере на ленту кривая лечит «всё или почти всё» на первых сантиметрах хода ручки: лента на 10% duty для глаза уже светится вполсилы. База по analogWrite и его таймерам — в уроке про ШИМ. И ещё один источник рябь: ручка на границе хода дрожит на последние единицы, яркость слегка переливается — лечится усреднением из урока про фильтр ADC.
| Ползунок v | Линейный duty | Квадратный duty | Что видит глаз при квадрате |
|---|---|---|---|
| 32 | 32 | 4 | тлеющий уголёк — управляемо |
| 64 | 64 | 16 | тусклый ровный уровень |
| 128 | 128 | 64 | ощутимая середина |
| 160 | 160 | 100 | чуть выше середины |
| 224 | 224 | 196 | почти полный, без слепящего скачка |
| 255 | 255 | 255 | полный накал — край общий с линейной |
- 1. Ползунок стоит на четверти хода (v = 64 из 255). Что дадут линейная и квадратная шкалы?
- 2. Что случится с (v * v) / 255 при v = 255, если v объявлено int на Uno?
- 3. Чем safeMap отличается от обычного map()?
- map() — прямая пропорция на целых числах; для глаза и уха её изгибают: квадрат для яркости, логарифм для громкости
- квадратная кривая: duty = (long)v * v / 255; без long на Uno int переполняется уже при v ≥ 181
- safeMap = map() с constrain внутри: за границей входного диапазона значений «за забором» нет
- инверсия шкалы — одна строка: 255 − duty; ночному ползунку не нужна вторая кривая
Частые вопросы по теме урока
- Ползунок стоит на четверти хода (v = 64 из 255). Что дадут линейная и квадратная шкалы?
- Квадратная кривая duty = v·v/255: 64·64/255 = 16. Линейная шкала на четверти хода даёт уже четверть полного горения, квадрат — шестнадцатую часть: глазу это тускло и управляемо. линейная 64, квадратная 16 — квадрат делает тёмный край управляемым
- Что случится с (v * v) / 255 при v = 255, если v объявлено int на Uno?
- int на Uno — 16 бит, максимум 32 767. Квадрат 255×255 = 65 025 переполняет его ещё до деления — уже при v ≥ 181. Лечение: (long)v * v / 255. В симуляторе грабль не воспроизводится — числа шире. переполнение 16 бит: 65 025 не влезает в int, кривую порвёт провалами
- Чем safeMap отличается от обычного map()?
- safeMap — map() с constrain внутри: вход зажимается до пропорции. Голый map() за границей входного диапазона продолжает пропорцию и выдаёт значения вне выходного диапазона. внутри вход зажат в границы: за пределами диапазона значений «за забором» нет