История одной оптимизации во время мастер-класса Clojure Minecraft

В июне компания JUXT приняла участие в ClojureD, ежегодной замечательной конференции по Clojure в Берлине. В течение дня проводился ряд семинаров, на которых рассказывалось о конкретной идее или инструменте и предлагалось поработать с ними. Наша группа решила посетить семинар "Изменим мир (Minecraft) с помощью кода", который проводили Арне Брассер (Arne Brasseur), Ариэль Алекси (Ariel Alexi) и Фелипе Баррос (Felipe Barros). В этом посте рассказывается о том, как мы использовали полученные на семинаре знания для создания изображений в игре и оптимизировали код с помощью Tufte.

Семинар "Измените мир (Minecraft) с помощью кода" на ClojureD 2022 (Галерея 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".

Комментарии (0)