История одной оптимизации во время мастер-класса Clojure Minecraft
В июне компания JUXT приняла участие в ClojureD, ежегодной замечательной конференции по Clojure в Берлине. В течение дня проводился ряд семинаров, на которых рассказывалось о конкретной идее или инструменте и предлагалось поработать с ними. Наша группа решила посетить семинар «Изменим мир (Minecraft) с помощью кода», который проводили Арне Брассер (Arne Brasseur), Ариэль Алекси (Ariel Alexi) и Фелипе Баррос (Felipe Barros). В этом посте рассказывается о том, как мы использовали полученные на семинаре знания для создания изображений в игре и оптимизировали код с помощью https://clojured.de/media/gallery/gallery-2022/nggallery/page/5)
Арне показал нам, как взаимодействовать с миром Minecraft используя Clojure REPL! Он начал с демонстрации того, как с помощью кода можно перемещать игрока по миру и добавлять предметы в его инвентарь. Используя выразительные возможности Clojure, он смог быстро начать генерацию структур в мире, применяя лишь небольшой набор грамотно составленных инструкций. В качестве последнего трюка он показал нам, как заставить цыплят взрываться!
Проект Witchcraft предоставляет удобный API для взаимодействия с серверами Minecraft на базе Bukkit. Именно он и использовался на семинаре.
Пробуем сами
Продемонстрировав свои навыки работы с Minecraft на основе REPL, Арне предоставил нам возможность самостоятельно попробовать и увидеть, что же у нас получится. После того как все было настроено и установлено, мы смогли запустить сервер, выполнить подключение к нему с помощью клиентского интерфейса и зайти в REPL с помощью CIDER. В репозитории воркшопа есть четыре пространства имен, которые демонстрируют различные способы использования Witchcraft при организации работы сервера и мира Minecraft.
Одной из наиболее интересных функций, предоставляемых Witchcraft, является nearest-material, которая находит материал Minecraft, в наибольшей степени соответствующий заданному цвету RGB. Witchcraft предоставляет файл соответствий между материалами (*основные строительные единицы в игре, представляющие собой разнообразные блоки, предметы, ресурсы и детали, которые составляют игровой мир Minecraft) и наиболее характерными цветами, присутствующими в текстуре этого материала.
Для определения наиболее подходящего материала (или блока) в Minecraft, который будет наиболее близко соответствовать заданному значению RGB цвета, функция nearest-material вычисляет расстояние между этим цветом и цветами, которые являются наиболее характерными для текстур блоков в игре.
Уилл Кейн (Will Caine) предложил блестящую идею считывать пиксели изображения и отображать их в мире Minecraft, используя данную функцию для определения наиболее подходящих материалов. Проведя небольшое исследование, нам удалось настроить библиотеку ImageIO на чтение нашего изображения, и вскоре у нас были значения RGB для каждого пикселя в файле!
Это выглядит как достаточно интересный проект, в рамках которого использовалась обработка изображений и их преобразование в игровой мир Minecraft. Значения RGB для каждого пикселя изображения могут быть использованы при определении наилучших материалов или блоков, которые должны быть размещены в Minecraft, чтобы создать визуальное представление этого изображения. Этот процесс может создавать уникальные и креативные искусственные структуры или объекты в игре на основе фактических изображений.
Генерация изображений в мире Minecraft
В игре Minecraft мир имеет ограничение по высоте — всего 319 блоков, от основания снизу до верхней грани. Это означает, что игровой мир ограничен по вертикали, и игроки могут строить или перемещаться только до вершины карты. Верхняя граница мира часто называется «sky limit» (пределом неба).
Если вы хотите внести изображение в игровой мир Minecraft, вам придется адаптировать его размеры под это ограничение. Поскольку высота мира определена 319 блоками, изображение должно быть уменьшено или масштабировано так, чтобы оно вписалось в заданную высоту. Это может потребовать изменения размеров и пропорций изображения, чтобы оно корректно отображалось в игровом мире Minecraft без нарушения вышеуказанных условий.
Наша первая реализация лениво отобразила каждый пиксель изображения в вектор RGB с помощью for. После этого мы задали масштаб этих векторов и выбрали значения RGB из большого массива значений.
(ns gen-image (:require [lambdaisland.witchcraft :as wc] [lambdaisland.witchcraft.palette :as palette]) (:import (javax.imageio ImageIO) (java.io File) (java.awt Color))) (defn img2world [filename coords mc-width] (let [buff (. ImageIO (read (File. filename))) img-width (.getWidth buff) img-height (.getHeight buff) rgbvec (for [x (range 0 img-width)] (for [y (range 0 img-height)] (let [rgbint (.getRGB buff x y) color (Color. rgbint true)] [(.getRed color) (.getGreen color) (.getBlue color)]))) scale-factor (/ img-width mc-width) mc-height (quot img-height scale-factor)] (for [x (range 0 mc-width) y (range 0 mc-height)] (wc/set-block (-> coords (update :x + x) (update :y + y) (assoc :material (palette/nearest-material (nth (nth rgbvec (* x scale-factor)) (* y scale-factor)))))))))
После совместной работы над решением нам наконец-то удалось сгенерировать изображение в мире Minecraft. Но мы столкнулись с проблемой: оно оказалось перевернутым вверх ногами!

--- (nth (nth rgbvec (* x scale-factor)) (* y scale-factor))) --- +++ (nth (nth rgbvec (* x scale-factor)) (* (- mc-height 1 y) scale-factor) +++
Обычно в компьютерной графике и изображениях координатная система имеет начало в верхнем левом углу изображения. Это означает, что координата (0, 0) находится в верхнем левом углу изображения, а увеличение координат происходит по направлению вниз (по оси Y) и вправо (по оси X).
При чтении изображения в буфер ImageIO мы ожидали, что начало координат будет в нижнем левом углу, и соответственно они будут увеличиваться по направлению вверх (по оси Y) и вправо (по оси X).
После инверсии координат Y мы смогли корректно отобразить изображение в Minecraft.

Оптимизация по скорости
Несмотря на то, что наше решение работало, генерация результирующего изображения происходила крайне медленно. Первой мыслью было то, что все эти повторные вызовы wc/set-block могут замедлять работу, поэтому мы рефакторизовали код, чтобы использовать wc/set-blocks для установки всех блоков сразу. Использование wc/set-blocks также имеет дополнительное преимущество — отменять сгенерированные изображения с помощью wc/undo! гораздо проще, поскольку при этом удаляется все изображение, а не только один сгенерированный блок за раз.
(defn img2world [filename coords mc-width] (let [buff (. ImageIO (read (File. filename))) img-width (.getWidth buff) img-height (.getHeight buff) scale-factor (/ img-width mc-width) mc-height (quot img-height scale-factor)] (wc/set-blocks (for [x (range 0 mc-width) y (range 0 mc-height)] (let [rgbint (.getRGB buff (* x scale-factor) (* y scale-factor)) color (Color. rgbint true) rgb [(.getRed color) (.getGreen color) (.getBlue color)]] [x (- mc-height 1 y) 0 (palette/nearest-material rgb)])) {:anchor coords})))
Но реализация решения по-прежнему оставалась медленной. Не наступил ли у нас некий жесткий предел? Для выяснения реальной ситуации и определения, что происходит в коде, мы решили провести его профилирование. Tufte — это простой профилировщик для Clojure и ClojureScript, поэтому он и был использован для анализа производительности кода. Мы добавили зависимость в deps.edn сервера и написали код профилирования. Для использования Tufte необходимо выделить (идентифицировать) участки кода, которые необходимо профилировать. Оборачиваем их в маркер (p). После того как участок кода был обрамлен маркером p и помещен внутрь профилировочной формы, вызываем функцию profile и передаем ей этот участок кода в качестве аргумента и наблюдаем за результатами.
(ns gen-image (:require ... [taoensso.tufte :as tufte :refer (defnp p profile)]) ...) (defn img2world [filename coords mc-width] (let [buff (p :new-buff (. ImageIO (read (File. filename)))) img-width (.getWidth buff) img-height (.getHeight buff) scale-factor (/ img-width mc-width) mc-height (quot img-height scale-factor)] (p :set-blocks (wc/set-blocks (for [x (range 0 mc-width) y (range 0 mc-height)] (let [rgbint (p :get-rgb (.getRGB buff (* x scale-factor) (* y scale-factor))) color (p :new-color (Color. rgbint true)) rgb (p :rgb-vec [(.getRed color) (.getGreen color) (.getBlue color)])] [x (- mc-height 1 y) 0 (p :near-mat (palette/nearest-material rgb))])) {:anchor coords})))) (tufte/add-basic-println-handler! {:format-pstats-opts {:columns [:n-calls :min :max :mean :clock :total]}}) (profile {} (p :img2world (img2world "juxt-logo.png" {:x 0 :y 150 :z 0} 200)))
pId nCalls Min Max Mean Clock Total :img2world 1 34.15s 34.15s 34.15s 34.15s 100% :set-blocks 1 34.14s 34.14s 34.14s 34.14s 100% :near-mat 15,400 1.47ms 28.95ms 2.18ms 33.62s 98% :get-rgb 15,400 963.00ns 6.83ms 7.39μs 113.86ms 0% :rgb-vec 15,400 124.00ns 42.28μs 1.14μs 17.49ms 0% :new-buff 1 7.26ms 7.26ms 7.26ms 7.26ms 0% :new-color 15,400 19.00ns 74.82μs 361.67ns 5.57ms 0% Accounted 1.70m 299% Clock 34.15s 100%
После выполнения профилирования кода с использованием Tufte, были получены результаты. Они показали, что 98% времени, затраченного на выполнение функции, приходится на nearest-material. nearest-material — это функция, которая, как описано выше, выполняет поиск ближайшего материала (в данном контексте, вероятно, блока или текстуры в игре Minecraft) на основе заданных входных данных (цвета в формате RGB). Она выполняет вычисления для определения ближайшего соответствия цвета материалу. Предполагается, что функция nearest-material часто вызывается с одними и теми же входными данными, так как входные и выходные значения этой функции ограничены небольшим диапазоном возможных значений. Поэтому, чтобы улучшить производительность функции nearest-material, предлагается использовать memoize .
Оптимизация с помощью memoize — это техника кеширования результатов функции на основе ее входных аргументов. После того как результаты были вычислены однажды, они кешируются, и при последующих вызовах функции с теми же аргументами результаты берутся из кеша, а не вычисляются заново. Такая оптимизация особенно эффективна в данном случае, поскольку изображение содержит лишь небольшой диапазон различных цветов.
--- [x (- mc-height 1 y) 0 (p (palette/nearest-material rgb))] --- +++ (def memo-nearest-material (memoize palette/nearest-material)) ... [x (- mc-height 1 y) 0 (p (memo-nearest-material rgb))] +++
pId nCalls Min Max Mean Clock Total :img2world 1 322.64ms 322.64ms 322.64ms 322.64ms 100% :set-blocks 1 317.40ms 317.40ms 317.40ms 317.40ms 98% :near-mat 15,400 395.00ns 2.55ms 10.79μs 166.12ms 51% :get-rgb 15,400 518.00ns 27.75μs 736.34ns 11.34ms 4% :new-buff 1 5.10ms 5.10ms 5.10ms 5.10ms 2% :rgb-vec 15,400 72.00ns 11.60μs 119.87ns 1.85ms 1% :new-color 15,400 20.00ns 17.50μs 43.02ns 662.46μs 0% Accounted 825.10ms 256% Clock 322.76ms 100%
Сейчас мы можем сгенерировать изображение менее чем за треть секунды, по сравнению с 34 секундами, что является 100-кратным улучшением. Разумеется, при последующих вызовах изображение генерируется еще быстрее, так как отображения цвета и материала уже кэшированы.
В целом, для функции nearest-material оптимизация была успешной, но после нее стало видно новое “бутылочное горлышко” производительности, связанное с wc/set-blocks. Мы решили остановиться на данном этапе и, возможно, вернуться к ней позднее, если это будет необходимо.
Попробуйте сами
Если вы хотите попробовать свои силы, то можете легко справиться с этой задачей самостоятельно, прочитав подробные инструкции к мастер-классу, доступные здесь. Здесь также есть несколько видеороликов на YouTube, которые помогут вам вдохновиться.
Спасибо Арне, Ариэлю и Фелипе за блестящий мастер-класс, организаторам ClojureD за отличную конференцию, а также компании JUXT за организацию нашего мероприятия.
Узнать больше про особенности Clojure — сферу разработки и основные фишки языка можно на открытом уроке курса «Clojure Developer».
ссылка на оригинал статьи https://habr.com/ru/articles/767248/
Добавить комментарий