Правила были просты. Каждый пользователь мог выбрать один из 16 цветов и закрасить им один пиксель в любом месте полотна. Можно было закрашивать сколько угодно пикселей и какими угодно цветами, но для того чтобы перекрасить следующий пиксель нужно было ждать 5 минут.
Правда, в правилах было сказано: «Координируя действия с другими, вы сможете создать гораздо больше, чем действуя в одиночку».
То, что произошло в течение следующих 72 часов, повергло организаторов в шок. На пустом полотне возникло это:
Каждый пиксель на полотне помещался вручную. Каждая иконка, каждый флаг, каждый мем кропотливо создавали сотни тысяч людей, которые не имели друг с другом ничего общего, кроме подключения к интернету. Так что, так или иначе, но происходившее на Reddit можно по праву считать рождением искусства.
Как все происходило
Несколькими словами это описать невозможно. На полотне происходили бесчисленные драмы — драки, сражения и войны, порой даже непонятно по какому поводу. Они велись на маленьких форумах, в частных чатах, их было так много и все они происходили сразу, так что уследить за всем не представлялось возможным. В целом на полотне прослеживалась вечная история о трех силах, необходимых человечеству для созидания.
Создатели
Первыми пришли создатели. Они были художниками, для которых чистое полотно обладает непреодолимой притягательной силой.
Создатели стали перекрашивать пиксели хаотично, просто чтобы посмотреть, что они могут сделать. Поэтому первые рисунки больше напоминали наскальную живопись – художники только начинали расправлять свои крылья.
Довольно быстро они поняли, что работая в одиночку и размещая только один пиксель каждые 5-10 минут, создать что-нибудь значительное невозможно. Кто-нибудь обязательно испортит их работу. Чтобы создать что-то большее, они должны работать вместе.
И тогда кто-то предложил рисовать на сетке, на которой будет ясно видно, где нужно закрасить следующий пиксель, чтобы получилось связное изображение. Так в левой нижней части полотна появился Dickbutt – известный интернет-мем, плод подросткового чувства юмора. Он стал первым совместным произведением.
Но создатели не остановились на достигнутом. Они стали добавлять к Dickbutt различные элементы, раскрашивать его в разные цвета и даже попытались трансформировать его в Dickbutterfly . За этой глупой затеей скрывался намек на надвигающееся творческое цунами.
Однако это произошло не сразу. Создателей опьянила их власть. Рядом с Dickbutt появился покемон Charmander, у которого вместо лапы начал расти член, а затем еще два.
Это уже не был дизайном. Некоторые создатели отчаянно пытались удалить провокационные дополнения, призывая к «чистому» искусству, но другие продолжали свое. Но не тут-то было.
Стало понятно, что слишком большая свобода ведет к хаосу. Творчество нуждается в ограничениях в той же мере, в которой оно нуждается в свободе. Когда кто-то может поставить любой пиксель в любом месте, как это может не привести к беспределу?
Хранители
Данную проблему очень быстро решил другой тип пользователей – хранители. Они пришли с одной целью – завоевать весь мир.
Сформировав фракции по цветам, они начали завоевывать пространство, закрашивая его в определенный цвет. Одной из первых и самой большой стала фракция Blue Corner (синий угол). Появившись в правом нижнем углу, она распространилась как чума. Ее последователи провозгласили, что таким образом они должны завевать все пространство полотна. Пиксель за пикселем, они стали воплощать свою идею в реальность, захватив вскоре огромные площади.
Blue Corner не был одинок в своих стремлениях. На другой стороне полотна появилась другая группа — Red Corner (красный угол). Ее участники заявили, что они – приверженцы левых политических взглядов. Еще одна группа – Green Lattice (зеленая решетка) – занялась повсеместным вкраплением зеленых и белых пикселей. Она продемонстрировала высокую эффективность, так как ей требовалось закрашивать вдвое меньше пикселей, чем другим фракциям.
Хранители пошли на создателей в лобовую атаку. Charmander стал первым местом сражения. Обнаружив, что Blue Corner начал забивать покемона синими пикселями, создатели осознали угрозу и прекратили междоусобные войны.
Они отбивались, заменяя каждый синий пиксель своим. Но силы были не равны. Благодаря своей целеустремленности, Blue Corner собрал гораздо большую армию, чем создатели. И единственное, что в такой ситуации оставалось сделать создателям, это умолять сохранить им жизнь.
И каким-то образом это переломило ситуацию. В «Синем углу» начались дебаты об их роли в творческом процессе. Один из участников задал вопрос: «Поскольку наша волна неизбежно полностью захватывает мир, должны ли мы проявлять милосердие к другим видам искусства, с которыми сталкиваемся?»
Это был вопрос, который рано или поздно вставал перед каждой фракцией. При всем своем экспансионистском рвении, что они должны были делать с искусством, стоявшем на их пути?
Это стало поворотным моментом. Бессмысленные фракции превратились в защитников.
Но это был еще не конец
В мире, заполненном хищным цветом, создатели смогли вернуться к своим творениям. Добавляя один элемент за другим, они начали делать их более сложными. Используя трехпиксельные шрифты, начали писать тексты. Одним из самых известных творений стал приквел «Звездных войн».
Создатели объединялись в группы, работающие над общим проектом. Они делились между собой стратегиями и шаблонами. Одной из наиболее успешных была группа, которая создала панель Windows 95 с кнопкой Start в углу.
Другие создали блок сердечек, как в старых видеоиграх, таких как Zelda. Начинали этот проект немногие, но к ним быстро присоединились другие и в итоге сердечки, раскрашенные в цвета различных флагов, растянулись на половину полотна.
Еще одна группа воссоздала картину Ван Гога «Звездная ночь».
Однако не все протекало гладко. Защитники, которые когда-то приветствовали создание произведений искусства, стали тиранами, диктующими моду. Они начали указывать, что можно создавать, а что – нет. Началось это незадолго до того, как создатели начали творить по своим правилам.
Фракции обратили взоры друг на друга, требуя от своих последователей принять свою сторону в эпических сражениях. У них не было времени, чтобы обращать внимание на жалкие мольбы создателей, которые хотели получить одобрение идей нового искусства.
Бои между защитниками разгорались нешуточные. Twitch live-streamer подговаривали своих последователей атаковать Blue Corner и Purple. Строились планы сражений. Взывали к эмоциям.
Проводились даже ложные атаки, когда приверженцы одного цвета размещали пиксели противников внутри своих собственных, чтобы можно было поплакаться о нарушении и атаковать в ответ.
Однако самой большой проблемой было жесткое правило – полотно не может увеличиваться. И воюющие между собой фракции, и создатели начали понимать, что для нового искусства у них просто не будет места.
С самого начала на полотне появлялись флаги различных стран. Они росли и натыкались друг на друга. Настоящая эпическая битва разразилась между флагами Германии и Франции. Стало ясно, что для освоения новых пространств необходим посредник.
Внезапно мир, спасшийся от примитивных набегов в начале, стал готов к полномасштабной войне. Отчаянные попытки решить проблему дипломатическим путем ни к чему не привели. Встречаясь в чатах, лидеры создателей и защитников лишь обвиняли друг друга.
Нужен был проныра, с которым каждый мог бы договориться.
Разрушители
На интернетовской площадке 4chan обратили внимание на то, что происходило на Reddit. И не смогли пройти мимо. Их пользователи выбрали самый близкий их сердцу цвет — черный. Они стали Пустотой.
Как слеза медленно растекается по поверхности, так и черные пиксели стали появляться в центре полотна, уничтожая все на своем пути.
Поначалу другие фракции пытались заключить с ними союз, наивно полагая, что дипломатия сработает. Но они потерпели неудачу, потому что Пустота была другой.
Пустота не было защитником. В отличие от остальных фракций, она не проявляла никакой лояльности к искусству. Последователи Пустоты исповедовали разрушительный эгалитаризм под лозунгом «Пустота поглотит все». Они не шли на контакт с другими. Они лишь хотели закрасить черным цветом весь мир.
И это было именно то, что требовалось. Оказавшись на грани исчезновения, все участники проекта объединились, чтобы бороться с Пустотой за сохранение своего искусство.
Но Пустоту было не так легко победить, потому что она была нужна. Необходимо было все уничтожить, чтобы из пепла возродилось новое искусство — лучшее. И без Пустоты это было невозможно.
Так Пустота стал катализатором создания крупнейшего произведения искусства.
За центральную часть полотна с самого начала шла упорная борьба. Создатели претендовали на эту территорию для своих произведений. Сначала они пытались это делать с помощью иконок. Затем скоординированной попыткой создать призму как на обложке альбома Pink Floyd «Обратная сторона Луны».
Но Пустота съела все. Одно за другим создаваемые произведения лишь разогревало ее хищный аппетит к хаосу.
И, тем не менее, это было именно то, что нужно. Уничтожив искусство, Пустота заставила пользователей придумать нечто лучшее. Они знали, что могут победить черного монстра. Им лишь нужна идея с хорошим потенциалом, которая привлекла бы достаточно последователей.
И этой идей стал американский флаг.
В последний день проекта все собрались вместе, чтобы прогнать пустоту раз и навсегда. Из людей, которые в иной ситуации разорвали бы друг друга на части, – из сторонников и противников Трампа, из демократов и республиканцев, из американцев и европейцев была создана коалиция.
Они объединились, чтобы создать что-то вместе, в этом маленьком уголке интернета, доказав, что в эпоху, когда такое сотрудничество кажется невозможным, они все еще могут это сделать.
Древние были правы
Вскоре после этого эксперимент на Reddit закончился. Сегодня его сопровождает множество историй, рассказываемых на десятках чатов. Каждое произведение искусства, созданное в проекте, покрыли сотни новых, из которых лишь немногие остались на окончательном полотне.
Но самое удивительное, пожалуй, то, что, несмотря на анонимность и отсутствие запретов, на заключительном полотне не было каких-либо расистских или человеконенавистнических символов. Это был красивый кругооборот искусства, жизни и смерти. И он был не первым в нашей истории.
Многие тысячелетия назад, когда человечество (реальное, а не только то, что на Reddit) еще находилось в зачаточном состоянии, индуистские философы предположили, что небеса состоят из трех конкурирующих, но необходимых, божеств: Брахмы-Создателя, Вишны-Хранителя и Шивы-Разрушителя.
Даже без одного из них, Вселенная не сможет функционировать. Для того чтобы был свет, необходима тьма. Чтобы существовала жизнь, нужна смерть. Для созидания и искусства должно быть разрушение.
Несколько дней проекта показали, что такой подход оказался пророческим. Самым невероятным образом Reddit доказал, что созидание требует наличия всех трех компонентов.
Финальное полотно
Для начала было крайне важно определить требования к первоапрельскому проекту, потому что запустить его нужно было без «разгона», чтобы все пользователи Reddit сразу получили к нему доступ. Если бы он с самого начала не работал идеально, то вряд ли привлёк бы внимание большого количества людей.
«Доска» должна быть размером 1000х1000 тайлов, чтобы выглядеть очень большой.
Все клиенты должны быть синхронизированы и отображать единое состояние доски. Ведь если у разных пользователей будут разные версии, им будет трудно взаимодействовать.
Нужно поддерживать как минимум 100 000 пользователей одновременно.
Пользователи могут размещать по одному тайлу в пять минут. Поэтому необходимо поддерживать среднюю частоту обновления 100 000 тайлов в пять минут (333 обновления в секунду).
Проект не должен негативно влиять на работу остальных частей и функций сайта (даже при условии высокого трафика на r/Place).
Главной трудностью при создании бэкенда было синхронизировать отображение состояния доски для всех клиентов. Было решено сделать так, чтобы клиенты в реальном времени прослушивали события размещения тайлов и немедленно запрашивали состояние всей доски. Иметь немного устаревшее полное состояние допустимо в случае подписки на обновления до того, как это полное состояние было сгенерировано. Когда клиент получает полное состояние, он отображает все тайлы, которые получил во время ожидания; все последующие тайлы должны отображаться на доске сразу же по мере получения.
Чтобы эта схема работала, запрос полного состояния доски должен выполняться как можно быстрее. Сначала мы хотели хранить всю доску в одной строке в Cassandra , и чтобы каждый запрос просто считывал эту строку. Формат каждой колонки в этой строке был таким:
Но поскольку доска содержит миллион тайлов, нам нужно было считывать миллион колонок. На нашем рабочем кластере это занимало до 30 секунд, что было неприемлемо и могло привести к чрезмерной нагрузке на Cassandra.
Тогда мы решили хранить всю доску в Redis. Взяли битовое поле на миллион четырёхбитовых чисел, каждое из которых могло кодировать четырёхбитный цвет, а координаты х и y определялись смещением (offset = x + 1000y) в битовом поле. Для получения полного состояния доски нужно было считать всё битовое поле.
Обновлять тайлы можно было посредством обновления значений с конкретными смещениями (не нужно блокировать или проводить целую процедуру чтения/ обновления/ записи). Но все подробности всё равно нужно хранить в Cassandra, чтобы пользователи могли узнать, кто и когда разместил каждый из тайлов. Также мы планировали использовать Cassandra для восстановления доски при сбое Redis. Считывание из него всей доски занимало меньше 100 мс, что было достаточно быстро.
Здесь показано, как мы хранили цвета в Redis на примере доски 2х2:
Мы переживали, что можем упереться в пропускную способность чтения в Redis. Если много клиентов одновременно подключались или обновлялись, то все они одновременно отправляли запросы на получение полного состояния доски. Поскольку доска представляла собой общее глобальное состояние, то очевидным решением было воспользоваться кешированием. Решили кешировать на уровне CDN (Fastly), потому что это было проще в реализации, да и кеш получался ближе всего к клиентам, что уменьшало время получения ответа.
Запросы полного состояния доски кешировались Fastly с тайм-аутом в секунду. Чтобы предотвратить большое количество запросов при истечении тайм-аута, мы воспользовались заголовком stale-while-revalidate . Fastly поддерживает около 33 POP, которые независимо друг от друга осуществляют кеширование, поэтому мы ожидали получать до 33 запросов полного состояния доски в секунду.
Для публикации обновлений для всех клиентов мы воспользовались своим вебсокет-сервисом . До этого мы успешно использовали его для обеспечения работы Reddit.Live с более чем 100 000 одновременных пользователей для уведомлений о личных сообщениях в Live и прочих фич. Сервис также был краеугольным камнем наших прошлых первоапрельских проектов - The Button и Robin. В случае с r/Place клиенты поддерживали вебсокет-подключения для получения обновлений о размещениях тайлов в реальном времени.
Сначала запросы попадали в Fastly. Если в нём была действующая копия доски, то он немедленно её возвращал без обращения к серверам приложений Reddit. Если же нет или копия была слишком старой, то приложение Reddit считывало полную доску из Redis и возвращало её в Fastly, чтобы тот закешировал и вернул клиенту.
Обратите внимание, что частота запросов никогда не достигала 33 в секунду, то есть кеширование с помощью Fastly было очень эффективным средством защиты приложения Reddit от большинства запросов.
А когда запросы всё же доходили до приложения, то Redis отвечал очень быстро.
Этапы отрисовки тайла:
Чтобы соблюсти строгую консистентность, все записи и чтение в Cassandra выполнялись с помощью QUORUM консистентного уровня .
На самом деле, здесь у нас возникла гонка, из-за чего пользователи могли размещать за раз несколько тайлов. На этапах 1–3 не было блокировки, поэтому одновременные попытки отрисовки тайлов могли пройти проверку на первом этапе и быть отрисованы – на втором. Похоже, некоторые пользователи обнаружили этот баг (либо они использовали ботов, которые пренебрегали ограничением на частоту отправки запросов) – и в результате с его помощью было размещено около 15 000 тайлов (~0,09% от общего количества).
Частота запросов и время ответов, измеренные приложением Reddit:
Пиковая частота размещения тайлов составила почти 200 в секунду. Это ниже нашего расчётного предела в 333 тайла/с (среднее значение при условии, что 100 000 пользователей размещают свои тайлы раз в пять минут).
При запросе конкретных тайлов данные считывались напрямую из Cassandra.
Частота запросов и время ответов, измеренные приложением Reddit:
Этот запрос оказался очень популярным. Вдобавок к регулярным клиентским запросам люди написали скрипты для извлечения всей доски по одному тайлу за раз. Поскольку этот запрос не кешировался в CDN, то все запросы обслуживались приложением Reddit.
Время ответа на эти запросы было довольно небольшим и держалось на одном уровне в течение всего существования проекта.
У нас нет отдельных метрик, показывающих, как r/Place повлиял на работу вебсокет-сервиса. Но мы можем прикинуть значения, сравнив данные до запуска проекта и после его завершения.
Общее количество подключений к вебсокет-сервису:
Базовая нагрузка до запуска r/Place была около 20 000 подключений, пик - 100 000 подключений. Так что на пике мы, вероятно, имели около 80 000 одновременно подключённых к r/Place пользователей.
Пропускная способность вебсокет-сервиса:
На пике нагрузки на r/Place вебсокет-сервис передавал более 4 Гбит/с (150 Мбит/с на каждый инстанс, всего 24 инстанса).
В процессе создания фронтенда для Place нам пришлось решать много сложных задач, связанных с кроссплатформенной разработкой. Мы хотели, чтобы проект работал одинаково на всех основных платформах, включая настольные ПК и мобильные устройства на iOS и Android.
Пользовательский интерфейс должен был выполнять три важные функции:
Главным объектом интерфейса был канвас, и для него идеально подошёл Canvas API . Мы использовали элемент
Канвас должен был отражать состояние доски в реальном времени. Нужно было нарисовать всю доску при загрузке страницы и дорисовывать обновления, приходящие через вебсокеты. Элемент canvas, использующий интерфейс CanvasRenderingContext2D , можно обновлять тремя способами:
Первый вариант нам не подошёл, потому что у нас не было доски в форме готового изображения. Оставались варианты 2 и 3. Проще всего было обновлять отдельные тайлы с помощью fillRect() : когда приходит обновление через вебсокет, просто рисуем прямоугольник размером 1х1 на позиции (x, y). В целом способ работал, но был не слишком удобен для отрисовки начального состояния доски. Метод putImageData() подходил гораздо лучше: мы могли определять цвет каждого пикселя в одном-единственном объекте ImageData и рисовать весь канвас за раз.
Использование putImageData() требует определения состояния доски в виде Uint8ClampedArray , где каждое значение - восьмибитное беззнаковое число в диапазоне от 0 до 255. Каждое значение представляет какой-то цветовой канал (красный, зелёный, синий, альфа), и для каждого пикселя нужно четыре элемента в массиве. Для канваса 2х2 необходим 16-байтный массив, в котором первые четыре байта представляют верхний левый пиксель канваса, а последние четыре - правый нижний.
Здесь показано, как пиксели канваса связаны со своими Uint8ClampedArray-представлениями:
Для канваса нашего проекта понадобился массив на четыре миллиона байтов - 4 Мб.
В бэкенде состояние доски хранится в виде четырёхбитного битового поля. Каждый цвет представлен числом от 0 до 15, что позволило нам упаковать два пикселя в каждый байт. Чтобы использовать это на клиентском устройстве, нужно сделать три вещи:
Для передачи бинарных данных мы использовали Fetch API в тех браузерах, которые его поддерживают. А в тех, которые не поддерживают, использовали XMLHttpRequest с responseType , имеющим значение “arraybuffer” .
Бинарные данные, полученные от API, в каждом байте содержат два пикселя. Самый маленький конструктор TypedArray , что у нас был, позволяет работать с бинарными данными в виде однобайтовых юнитов. Но они неудобны в использовании на клиентских устройствах, так что мы распаковывали данные, чтобы с ними было проще работать. Процесс простой: мы итерировали по упакованным данным, вытаскивали старшеразрядные и младшеразрядные биты, а затем копировали их в отдельные байты в другой массив.
Наконец, четырёхбитные цвета нужно было преобразовать в 32-битные.
Структура ImageData , которая нам понадобилась для использования putImageData() , требует, чтобы конечный результат был в виде Uint8ClampedArray с байтами, кодирующими цветовые каналы в очерёдности RGBA. Это означает, что нам нужно было осуществить ещё одну распаковку, разбивая каждый цвет на компонентные канальные байты и помещяя их в правильный индекс. Не слишком-то удобно выполнять четыре записи на каждый пиксель. Но к счастью, был ещё один вариант.
Объекты TypedArray по сути являются представлениями ArrayBuffer в виде массивов. Тут есть один нюанс: многочисленные инстансы TypedArray могут читать и писать в один и тот же инстанс ArrayBuffer . Вместо записи четырёх значений в восьмибитный массив мы можем записать одно значение в 32-битный! Используя Uint32Array для записи, мы смогли легко обновлять цвета тайлов, просто обновляя один индекс массива. Правда, пришлось сохранять нашу палитру цветов в обратном байтовом порядке (ABGR), чтобы байты автоматически попадали на правильные места при считывании с помощью Uint8ClampedArray .
Метод drawRect() хорошо подходил для отрисовки обновлений по отдельным пикселям по мере их получения, но было одно слабое место: большие порции обновлений, приходящие одновременно, могли привести к торможению в браузерах. А мы понимали, что обновления состояния доски могут приходить очень часто, так что проблему нужно было как-то решать.
Вместо того чтобы немедленно перерисовывать канвас при каждом получении обновления через вебсокет, мы решили сделать так, чтобы вебсокет-обновления, приходящие одновременно, можно было объединять в пакеты и сразу скопом отрисовывать. Для этого были внесены два изменения:
Благодаря переносу отрисовки в анимационный цикл мы смогли немедленно записывать вебсокет-обновления в ArrayBuffer , при этом откладывая фактическую отрисовку. Все вебсокет-обновления, приходящие между фреймами (около 16 мс), объединялись в пакеты и отрисовывались одновременно. Благодаря использованию requestAnimationFrame , если бы отрисовка заняла слишком много времени (дольше 16 мс), то это повлияло бы только на частоту обновления канваса (а не ухудшило бы производительность всего браузера).
Важно отметить, что канвас был нужен для того, чтобы пользователям было удобнее взаимодействовать с системой. Основной сценарий взаимодействия - размещение тайлов на канвасе.
Но делать точную отрисовку каждого пикселя в масштабе 1:1 было бы крайне сложно, и мы не избежали бы ошибок. Так что нам был необходим зум (большой!). Кроме того, пользователям нужна была возможность легко перемещаться по канвасу, ведь он был слишком велик для большинства экранов (особенно при использовании зума).
Поскольку пользователи могли размещать тайлы раз в пять минут, то ошибки при размещении были бы особенно неприятны для них. Нужно было реализовать зум такой кратности, чтобы тайл получался достаточно большим, и его можно было легко поместить в нужное место. Это было особенно важно на устройствах с сенсорными экранами.
Мы реализовали 40-кратный зум, то есть каждый тайл имел размер 40х40. Мы обернули элемент