Bollywood HungamaRamayana producer Namit Malhotra calls Los Angeles premiere his “Oscar moment” ahead of November 5 releaseEl ComercioLa hoja de ruta de Estados Unidos para Venezuela: reformas, vacancia de Delcy Rodríguez y eleccionesPunchBREAKING: NELFUND introduces token system to stop double payment of school feesThe Jerusalem PostQatari mediators present peace deal to Iran in mediation effort, official saysInquirer EntertainmentDonny Pangilinan felt ‘awkward’ over Showtime Baby Doll Juby’s dream about himDaily MaverickWildlife is not livestock — Aucamp’s dangerous plan to turn conservation into agricultureInquirerMimaropa drug bust in Palawan yields ₱51,884 worth of ‘shabu’ZDF heuteEntdecken Sie das ZDF-NachrichtenstudioSky TG24Meteo, freddo e maltempo in Italia nel weekend di Halloween e Ognissanti. Le previsioniVariety‘One Cut of the Dead’ Director Ueda Shinichiro on Turning a Microdrama Into Tokyo Feature ‘Earthlings in Love’NumeramaÀ mi chemin entre le VTC et le longtail, le vélo électrique Nakamura Crosscity+ perd 500 €DeadlineRussell Brand Forcefully Denies Charges As He Gives Evidence During Rape Trial
The Daily Newsstand · Free, Always
Wednesday, October 28, 2026

Спрайтшит на 0 байт: процедурный пиксель-арт

Translate

Этот проект начинался как песочница для социальных экспериментов: персонажи взаимодействуют и принимают решения, играя в дилемму заключённого, имеют память, наследуемые черты характера, ограниченные ресурсы для выживания и прочие проблемы, которые мешают счастливо существовать. Графика должна была успевать за всем этим разнообразием, а художника в проекте нет. Тут я решил поделиться как решал эту проблему: все объекты и анимация сделаны кодом прямо в браузере, на обычном 2D canvas. Заходите под кат - расскажу, как это устроено, и покажу код.

Живая сцена: спрайты, палитры и эффекты из игры. Поведение упрощено, растения проживают жизнь за полминуты.

Живая сцена: спрайты, палитры и эффекты из игры.

Спрайт — это строки текста

Вот панцирь черепашки, в коде он записан ровно так:

const SHELL = [
  '..hhhhh..',
  '.SShSSSS.',
  'SSSSSSSSS',
  'SSSSSSSSS',
  'SSSSSSSSS',
  'sssssssss',
  '.BBBBBBBB',
];

Каждый символ — один пиксель. Главное решение здесь в том, что буква обозначает не цвет, а роль: S — панцирь, s — его тёмная кромка, h — блик, B — живот. Цвет каждой роли задаёт отдельная палитра, поэтому один трафарет подходит всем черепашкам сразу. Рисуется это десятью строками:

function drawSprite(ctx, rows, palette, px, x0 = 0, y0 = 0) {
  rows.forEach((row, y) => {
    [...row].forEach((ch, x) => {
      const color = palette[ch];
      if (!color) return; // точка и буквы без цвета остаются прозрачными
      ctx.fillStyle = color;
      ctx.fillRect(x0 + x * px, y0 + y * px, px, px);
    });
  });
}

Черепашка целиком помещается в сетку 12 на 10. Собирается она из трёх деталей: панциря, головы и пары строк ног, каждая кладётся на сетку в своё место. Узор на панцире тоже не рисунок, а правило вида (x + y) % 3 === 0: оно решает, какие пиксели панциря перекрасить.

У текстовых спрайтов есть ещё один плюс: они лежат в git, у них читаемый дифф, и на них можно написать обычные юнит-тесты.

Стоящая черепашка: слева так, как она записана, справа те же буквы поверх пикселей.

Стоящая черепашка: слева так, как она записана, справа те же буквы поверх пикселей.

Цвет из генов

У черепашки есть ген hue, число от 0 до 1. Из него получается оттенок, а остальная палитра строится в HSL с постоянными насыщенностью и яркостью:

function palette(hueGene, elder) {
  const h = (Math.round(hueGene * 36) % 36) * 10; // 36 оттенков с шагом 10°
  const sat = elder ? 28 : 50;                     // к старости панцирь выцветает
  return {
    S: `hsl(${h} ${sat}% 46%)`,                    // панцирь
    s: `hsl(${h} ${sat}% 30%)`,                    // кромка темнее
    h: `hsl(${h} ${sat + 5}% 60%)`,                // блик светлее
    p: `hsl(${(h + 20) % 360} ${sat + 5}% 31%)`,   // узор: соседний оттенок
    B: 'hsl(72 42% 64%)',                          // кожа
    e: '#1b1b1f',                                  // глаз
  };
}

Состояние меняет палитру, трафарет остаётся прежним. У больной черепашки зеленеет лицо, у костра всё теплеет, обгоревшая на секунду темнеет и мигает.

Почему оттенков ровно 36? Готовый спрайт рисуется один раз в невидимый холст и кладётся в кэш под ключом из всего, от чего он зависит: оттенок, узор, поза, кадр, направление, состояние, возраст. Если брать произвольное значение гена, то у каждой черепашки был бы свой набор картинок и кэш рос без конца. Шаг в 10° на глаз почти не заметен, а число картинок становится конечным. 36 оттенков хватит всем. Во что всё это обходится, посчитаю в конце.

Дети получают ген так: берётся случайная точка между генами родителей и к ней добавляется немного шума. Поэтому семьи на лугу видны по цвету. Есть одна тонкость. Ген лежит на отрезке, а оттенок — на круге. Гены 0,02 и 0,98 дают двух почти одинаково красных родителей, а точка между ними может оказаться и в 0,5. Так у красной пары рождается бирюзовый ребёнок. Отцовство, если что, подтверждено кодом.

Палитра из генов: трафарет один, меняется только табличка цветов. Внизу все 36 оттенков.

Палитра из генов: трафарет один, меняется только табличка цветов. Внизу все 36 оттенков.

Анимация — это сдвиги

Поза — это несколько чисел: на сколько строк сдвинуть голову и панцирь в каждом кадре и какие строки взять для ног.

// на сколько строк сдвигается голова в каждом кадре позы
const HEAD_DY = { walk: [0, 0, 0, 0], hop: [1, -1], stomp: [-1, 1] };

// ходьба: четыре варианта ног по кругу
const LEGS = [
  ['.BB....BB...', '.bb....bb...'],
  ['BB......BB..', 'bb......bb..'],
  ['..BB..BB....', '..bb..bb....'],
  ['BB......BB..', '.b.......b..'],
];

Черепашка идёт — четыре варианта ног по кругу, голова на месте. Радуется — сначала приседает (панцирь на строку ниже, лапы в стороны), потом подпрыгивает, и голова взлетает вверх. Тушит огонь — задирает переднюю лапу и бьёт ею по земле, а голова качается в такт. Всего поз 17, и все вместе занимают несколько десятков строк данных. Перекрашивать их не нужно: цвет по-прежнему приходит из палитры.

Три позы из семнадцати: идёт, радуется и тушит огонь.

Три позы из семнадцати: идёт, радуется и тушит огонь.

Огонь — формула вместо рисунка

Для пламени трафарета нет вообще: функция строит его строка за строкой, по формуле. Вот что из неё получается при разной силе огня:

Пламя разной силы и один его кадр буквами.

Пламя разной силы и один его кадр буквами.

Для каждой строки функция считает ширину «капли» и её центр, который покачивается по синусу, причём кончик качается сильнее основания. Чем ближе к центру и к низу, тем светлее: белое ядро, жёлтый, оранжевый, красный край.

function flameRows(h, frame) {
  const rows = [];
  for (let y = 0; y < h; y++) {
    const k = (h - 1 - y) / (h - 1);                                // 0 у основания, 1 на кончике
    const half = 3.7 * (1 - k ** 1.4) + 0.45;                       // полуширина капли
    const center = 4 + Math.sin(k * 3.4 + frame * 2.1) * k * 1.3;   // кончик качается
    let row = '';
    for (let x = 0; x < 8; x++) {
      const d = Math.abs(x + 0.5 - center) / half;
      const n = Math.sin(x * 12.9898 + y * 78.233 + frame * 37.719) * 43758.5453;
      const lick = n - Math.floor(n) < 0.35 ? 0.12 : 0;             // язычки по краю
      row += d > 1 + lick ? '.' : d < 0.38 && k < 0.55 ? 'w' : d < 0.62 && k < 0.8 ? 'y' : d < 0.88 ? 'o' : 'r';
    }
    rows.push(row);
  }
  return rows;
}

Строчка с 43758.5453 знакома всем, кто писал шейдеры: это классический хеш из GLSL. Откуда взялась эта константа, помнят немногие, а копируют её все, и я не исключение. Здесь она решает, появится ли в этом кадре пиксель на краю пламени, и из-за неё огонь «дышит». Высота зависит от силы огня, от 3 до 12 строк. Кадров три, по 110 мс, и у каждой горящей клетки своё смещение по времени, чтобы соседние языки не дёргались в такт.

Молния

Туча — маска 40 на 15 пикселей, сложенная из семи кругов-«клубов». Они растут от лёгкой дымки до полной грозовой тучи. Оттенок пикселя зависит от соседей: если сверху пусто, это освещённый край, если снизу — самая тёмная кромка. На земле под тучей сгущается тень и расходится кольцо, которое пульсирует всё быстрее. Потом вспышка на 150 мс, молния на 400 мс и подпалина на шесть секунд:

Гроза на повторе: туча собирается, бьёт молния, дерево загорается.

Гроза на повторе: туча собирается, бьёт молния, дерево загорается. Мне кажется, что получилось вполне красиво.

Сама молния — ломаная от тучи до цели. Вершины смещаются поперёк линии, и у земли смещения затухают, чтобы удар пришёлся точно в клетку:

// hash(a, b, c): любой целочисленный хеш, возвращает число от 0 до 1
function boltPath(seed, x0, y0, x1, y1, segments = 12) {
  const len = Math.hypot(x1 - x0, y1 - y0);
  const nx = -(y1 - y0) / len, ny = (x1 - x0) / len; // перпендикуляр к линии удара
  const amp = Math.max(10, len * 0.07);
  const pts = [[x0, y0]];
  for (let i = 1; i < segments; i++) {
    const t = i / segments;
    const off = (hash(seed, i, 31) - 0.5) * 2 * amp * (1 - t * 0.7); // у земли зигзаг тише
    pts.push([x0 + (x1 - x0) * t + nx * off, y0 + (y1 - y0) * t + ny * off]);
  }
  pts.push([x1, y1]);
  return pts;
}

Из середины выходит вторая, короткая ветка. Обе рисуются тремя проходами: широкий бледно-голубой ореол, линия потоньше и белая сердцевина. Черепашки под тучей видят её заранее: над ними появляется знак тревоги, и осторожные уходят в сторону. Остальные, видимо, верят в теорию вероятностей.

Сколько стоит кадр

Кажется, что рисовать каждый пиксель через fillRect — верный способ посадить батарейку. Чтож, проверим. При 60 fps на кадр есть 16,7 мс. Беру живую сцену из начала статьи в том размере, в каком её видит читатель: 960×352, во всю ширину колонки, 30 на 11 клеток по 32 пикселя. На ней полтора десятка черепашек и три десятка растений. Мерил в браузере без видеокарты, чтобы всю работу честно делал процессор, а потом одним переключателем выключил кэш спрайтов, чтобы посмотреть, что он даёт.

Итог: 1 мс на кадр с кэшем и 6 мс без него.

С кэшем кадр — это полсотни копирований готовых картинок через drawImage, а поведение черепашек считается за сотые доли миллисекунды. Без кэша каждый спрайт в каждом кадре заново раскрашивается квадратик за квадратиком, и кадр дорожает вшестеро: из бюджета в 16,7 мс уходит уже треть, а на слабом устройстве и весь.

Единственное, что я не кэширую, — грозовую тучу. Она рисуется попиксельно в каждом кадре, и в грозу кадр дорожает на треть, а то и на две трети. Так что самое дорогое тут — погода.

Взамен кэш почти ничего не просит: за 10 минут в нём набирается около 1300 картинок, порядка 10 МБ, а кадр при этом не дорожает.

Что получилось

Подход прижился: в игре нет ни одного файла с графикой, а новые позы, предметы и эффекты добавляются как данные. Все хранится как текст в репозитории. Минусы тоже есть. Большие трафареты неудобно править руками, так что пришлось написать для них простой редактор (ну это было не сложно).

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.