div.main {margin-left: 20pt; margin-right: 20pt}Программирование графики на
Java.
Эту статью меня побудили написать частые вопросы в различных
java-форумах о работе с графикой и замене инструменту java.awt.Graphics2D для
Java1.x.
На своём опыте я неоднократно убеждался, что чтобы хорошо понять
программные инструменты, лучше всего написать программу с их использованием. Для
этой статьи я решил написать некий пакет графических утилит, который можно было
бы использовать в качестве замены или дополнения к Graphics2D и написать его
так, чтобы он работал на джава-машинах любой версии.
Прежде всего, нужно понять, что такой пакет графических утилит
должен уметь делать и что хотелось бы видеть в результате работы с ним:
Более всего в java мне не хватает реализации blending-прозрачности. Во
всех JVM версии до 1.4 прозрачность реализована diffuse mosaic методом, то
есть при наложении верхней картинки на нижнюю некоторые пиксели верхней
картинки заменяются пикселями нижней картинки. Нормальная
blending-прозрачность реализована только в JVM версии 1.4.
Ещё в Graphics недоступен анти-альясинг, причём отсутствует он именно в
Graphics, хотя и реализован фильтр для картинок
java.awt.image.AreaAveragingScaleFilter, который вообще-то предназначен для
"гладкого" растягивания/сжимания картинок, но при этом умеет отлично
сглаживать картинки, если в данных новая ширина / высота оставить прежние
значения.
Многим для графики не хватает заливки фигур, получающихся при рисовании.
Иногда гораздо легче залить фигуру, образованую только что нарисоваными
прямыми, чем проявлять изобретательность и создавать полигон для последующей
заливки.
Раз уж заговорили про заливку, то также очень хочется уметь заливать
фигуру различными текстурами, а для этого нужно уметь "вырезать" из картинки
различные области.
Очень удобно было бы работать с пакетом, работающим со слоями - мы как бы
рисуем на разных листах бумаги, вырезаем из них разные фигуры, заменяем часть
прозрачной плёнкой, а потом их соединяем в определённой последовательности и
смотрим, что получилось. Люди, работающие с векторными и полу растровыми
графическими редакторами, могут вспомнить те же Macromedia Flashware, Corel
Draw, Adobe Illustrator или тот же Adobe Photoshop.
Раз уж нужна работа с layer-структурой, то нужно позаботится о механизме
наложения слоёв. Очень хочется их логически разделить, прогнать через какие-то
фильтры (например, который бы поворачивал слой вокруг произвольно выбранной
точки или как-то ещё трансформировал) и вновь соединить.
Осталось вспомнить о примитивах - графический пакет будет сложен в
использовании, если он не умеет их чертить. Я наметил несколько базовых
графических операций:
умение ставить определённого цвета точку
умение чертить отрезок по 2м точкам
умение собрать из отрезков многоугольник, параметры которого заданы
умение вставлять растровые изображения в произвольную точку слоя
Для базовых примитивов этого, пожалуй, хватит. При создании большего
количества инструментов рисования пакет получится "раздутым" и его будет
сложно использовать в апплетах, где важен размер классов. Если нужно сделать
что-то другое, например нарисовать примитив из Graphics, то можно нарисовать
что-то в картинку Image, которую уже пакет нарисует в слой. Вот такие
задачи я поставил пакету графических утилит.
Итак, начнём с главного. Нужно связать проектируемый инструмент с
Graphics таким образом, чтобы всё нарисованое пакетом легко через Graphics
выводилось. Сразу же приходят на ум два пути решения этой задачи - сделать
наследник Graphics или работать прямо с битовым представлением изображения. Оба
эти решения имеют свои преимущества и свои недостатки. Преимущество создания
наследника Graphics состоит в том, что его можно сразу же без оглядки применять,
скажем, для рисования в Canvas. С другой стороны среди абстрактных методов
Graphics непредусмотрены функции, определяющие цвет точки на холсте рисования,
из-за этого затея со слоями получится нереализуемой, да и красивая реализация
alpha blending тоже вряд ли возможно. Для Graphics2D, как для наследника
Graphics, программиста пришлось создавать, например, класса BufferedImage, куда
Graphics2D рисует, как в холст и из которого можно получить цвет точки с
задаными координатами. Потом BufferedImage рисуется, как простая картинка через
Graphics, туда, куда нужно. Однако такой подход привёл к созданию пакета
размером в, как минимум, 120кб и это если ещё исключить классы-спутники типа
AffineTransform. Класс BufferedImage наводит на мысль о втором методе реализации
- создание класса на основе Image. Преймущества создания такого класса состоит в
том, что легко можно получить доступ к любой точке холста, да и интегрировать
такой инструмент с Graphics тоже просто - рисуй картинку стандартными методами,
да и всё. Однако и тут свои проблемы и самая главная из них - это отсутствие
возможностей прямо рисовать текст теми шрифтами, что доступны из java.awt.Font.
Несомненно, эту проблему можно обойти, но путь этого "обхода" будет достаточно
сложным - через Graphics нужно рисовать в картинку из производителя
MemoryImageSource, а потом эту картинку рисовать в слой. Однако только второй
метод позволяет достаточно выполнить поставленную выше задачу, и выбор сделан в
пользу наследников Image.
Одна из самых сложных задач - правильное проектирование пакета
инструментов. Рассмотрим основные части, исходя из постановки задачи и путей её
разрешения:
Пакет должен базироваться на наследниках Image.
Пакет базируется на представлении холста, как стопки слоёв, наложенных
один на другой.
Процесс рисования логически разделяется на 2 части: процесс рисования в
отдельно взятом слое и соединение всех слоёв в картинку. Класс этой картинки,
должн быть наследником Image и выводиться посредством Graphics.
Каждый слой потенциально может быть бесконечных размеров (ну, по крайней
мере, пока int не кончится :). Из-за этого хранить информацию только в
растровом виде будет сложно - ограничения памяти компьютеров, нужно вводить
векторные или полу-растровые слои.
Должна быть поддержка фильтров.
Так как в постановке задачи есть задача о выделении областей, то нужно
предусмотреть работу с такими облостями.
Кроме того, всё это должно легко конвертироваться в растровый вид и не
слишком тормозить при такой конвертации. Понятно, что для быстрой анимации
задачу нужно было бы ставить иначе, но и пакет не предназначен для создания
графического редактора, а поэтому скорость немаловажна.
Самое главное пакет не должен занимать много места - предполагается, ведь,
что с этим пакетом будут работать апплеты.
Подумав над тем, что здесь выписано и, посидев несколько часов над
ArgoUML, я пришёл к следующей структуре пакета:
Главный стержень всего пакета - это слой, а чтобы было с ним удобно
работать, то нужно либо создать интерфейс, либо написать базовый для всех
слоёв класс (как, например Component - базовый для пакета java.awt). Ещё один
выход, особенно удобный, - это написать одновременно и интерфейс и класс, этот
интерфейс реализующий, что я и решил сделать.
В задачу пакета также входят области, которые нужно выделять, поэтому
нужно создать некий класс или интерфейс для них. В данном случае я решил
сделать интерфейс, а учитывая то, что сами области к слоям имеют отношение
достаточно дальнее, а видов областей предвидится несколько, то я вынес все
области, кроме интерфейса в отдельный пакет.
Также логически разделим все слои на два типа - слои-фильтры, которые сами
ничего не рисуют, а только преобразуют вышележащие слои и собственно слои,
содержащие активную информацию. Для слоёв и фильтров я создал ещё 2 пакета.
Теперь нужно подумать над логической структурой слоёв. Что от них
требуется? Требуется чтобы они составляли стек - один лежит на другом, чтобы из
каждого слоя можно было получить графическую информацию и чтобы при получении
этой самой информации учитывались размеры всех листов "стопки". Причём
составлять стэк и получать размеры "стопки листов" должен уметь делать каждый
слой. Сделал я это так: написал интерфейс Layer. В
интерфейсе определил 3 метода для работы со стеком - getCoverLayer,
attachCoverLayer и detachCoverLayer - то бишь получить лист, накрывающий текущий
(или null, если такой отсутствует), накрыть текущий слой листом и вынуть
накрывающий лист, не снимая остальную часть стопки. Для работы же графикой я
определил тоже 3 метода - функции получения ширины/высоты текущего объекта
(getLayerWidth и getLayerHeight) и функцию getPixel - получение цвета точки,
заданной координатами (x,y) в текущем слое.
К формату цвета точки я отнёсся по-простому: как и в
DirectColorModel каждая точка представлена у меня 32 битным int числом, из
которого по маскам 0xff000000, 0xff0000, 0xff00 и 0xff можно получить
соответственно Alpha, Red, Green и Blue компоненты, по 8 бит на каждую. Для того
чтобы больше не думать на тему добывания компонент из int представления цвета, я
написал класс ColorUtil, который тоже включил в пакет. За одним, кроме
разбития числа на компоненты и собирания его обратно разными способами, я
включил в утилиту функции получения среднего цвета из нескольких цветов и
получения цвета, при перекрытии двух точек - когда полупрозрачная точка рисуется
поверх другой полупрозрачной точки. Последняя функция нужна для реализации
Blending прозрачности. Все методы в ColorUtil объявлены статическими.
Настало время писать базовый класс для всех слоёв, который был
назван по профессии - AbstractLayer. Сразу же на лету реализовал стековые функции
интерфейса Layer, а вот получение размера слоя и точек принципиально
оставил абстрактными. Тут настал черёд призадуматься о том наборе слоёв, которые
я на базе абстрактного layer'а хотел создать. По идее, второй по важности класс
после базового для всех слоёв, должен быть класс, реализующий интефейс Layer и
ImageProducer - интерфейса из java.awt.image и наследующий Image. Но в java нет
множественного наследования и, если я буду делать класс наследником Image, то
сделать его наследником абстрактного слоя я не смогу. Это, конечно, не фатально
- для таких случаев я интерфейс Layer и
писал, но придётся снова определять стековые функции, что неблагополучно
скажется на размере всего пакета. Поэтому я вернулся к AbstractLayer и сделал его наследником Image. Делается это
достаточно просто - функции получения размера getHeight и getWidth возвращают
значения getLayerWidth и getLayerHeight из интерфейса Layer,
метод getSource возвращает ссылку на сам объект (return this), для этого и нужна
реализация интерфейса ImageProducer. Остальные функции либо ничего не делают,
либо выдают null. Теперь о реализации ImageProducer. Фактически в этой
реализации достаточно определить метод startProduction - метод, выдающий
графическую информацию. Так как есть возможность выдать всю картинку полностью,
а не кусочками, то во всех функциях регистрации потребителей (ImageConsumer)
ставим ссылку на startProduction, а функция isConsumer всегда возвращает true.
Функция startProduction пишется просто - вначале делается массив, в котором
будет храниться "фотография" слоя, массив этот имеет размеры
getLayerWidth()*getLayerHeight(). Потом, используя getPixel(int x, int y),
подготовленный массив заполняется а, потребитель уведомляется, что картинка ему
придёт одним кусочком, без ошибок и что картинка в массиве хранится
сверху-вних/слева-направо. После подготовки массив отправляется потребителю
(вызвав ImageConsumer.setPixels) и, после передачи, потребителя нужно уведомить
об окончании вывода данных. Вот и вся функция startProduction.
С интеграцией пакета со стандартными графическими устройствами я
разобрался ещё на уровне абстрактного слоя. Теперь нужно думать о других слоях.
Первый слой, который мене пришёл в голову - это слой компоновки. Такой класс
можно представить себе как конверт в стопке бумаги - его можно вынуть из стопки
или положить туда, так же, как и любой другой лист, но ещё есть возможность его
открыть и увидеть, что в нём самом тоже содержится стопка листов. Так как
написание такого класса просто, даю лишь ссылку на него - ComposeLayer.
Следующим мне в голову пришёл RasterLayer - класс, содержащий в себе картинку в растровом
виде, и который может управлять размером содержащийся в нём картинки.
Первоначально я хотел ещё сделать специальный фильтр, позволяющий управлять
размерами вышележащего слоя, но решился отказаться от этой идеи, поэтому все
функции по изменению размеров листа лежат на растровом слое и его потомках. Для
того, чтобы RasterLayer мог изменить размеры произвольного листа, я ввёл в
него фунции, позволяющие "сграбить" изображение с класса, реализующего интерфейс
Layer.
Также в класс введена функция setSize, позволяющая непропорционально
растянуть/сжать изображение в растровом слое, а пиксели, выдываемые getPixel
учитывают прозрачность, как растрового изображения, так и прозрачность
вышележащих слоёв. Тем, кто интересуется "хорошими" алгоритмами сжатия и
растягивания изображений рекомендую взглянуть на исходник RasterLayer. Реализация алгоритма не очень хорошо
комментирована, но понять идею можно.
Однако растровый слой не решает всех задач, поставленых мною для
работы с растровыми изображениями - растровое поле класса во-первых, ограничено
по размерам, во-вторых - верхний левый угол его всегда в точке (0,0), а
в-третьих, этот класс, хоть и может "сграбить" изображение с Layer
класса, но неспособен создаваться из картинки Image. Эти недостатки я решил
устранить классом ImageLayer - класса-расширения RasterLayer. Для того, чтобы можно было создавать класс из
изображения Image, класс реализует интерфейс ImageConsumer, а исходник этого
класса посвящается всем тем, кто не знает, как можно "сграбить" картинку, не
используя java.awt.PixelGrabber. А делается это следующим образом: все методы из
ImageConsumer, кроме imageComplete, setDimension и 2х функций setPixels пишутся
пустыми (только для того, чтобы реализовать интерфейс), а вот оставшиеся четыре
функции нужно аккуратно скопировать из исходника ImageLayer - это неоднократно мною проверенный код. Не надо
также забывать, что объявлены глобальные переменные int buf[] - массив данных
картинки, цвета пикселов функции setPixels записывают в формате DirectColorModel
(то есть используют битовые маски, совместимые с утилитами из ColorUtil), определены также int thisw, thish - ширина/высота
картинки и флаг окончания загрузки изображения boolean loaded. Помимо загрузки
изображений из Image, класс ещё умеет задавать координаты верхнего левого угла
растрового изображения и унаследовал все функции из RasterLayer, как родительского класса.
Для следующего класса пришлось определить интерфейс Area - это
интерфейс, задающий область и границы. Не мудрствуя лукаво, я определил в нём
всего два метода - boolean isInArea(int x, int y) - метод тестирующий точку на
принадлежность к некой области и boolean isOnBorder(int x, int y) - метод,
тестирующий, лежит ли точка на границе этой области.
Класс слоя, для которого нужет интерфейс Area
называется AreaLayer и выводит он границу определённой толщины и цвета с
областью, для которой цвет тоже можно задать. Ничего сложного в реализации этого
класса нет, смотрите исходник.
Теперь я, наконец, подхожу к фильтрам. Первый фильтр - это фильтр
анти-алясинга, сглаживающий, например, контур, получаемый в AreaLayer. Фильтр называется просто - AntiAliasingFilter, он создан тоже, как и все фильтры, на базе
абстрактного слоя. В нём переопределена функция getPixel, в которой, собственно,
и производится сглаживание простейшим методом нахождения среднего из цвета
заданой точки и цветов точек соседних с заданой.
Другие два фильтра используются для отсечения частей слоя, лежащих
или не лежащих в области, заданной Area.
Фильтр ClipFilter отсекает всё, что лежит не в области и не на её
границе, заливая это всё прозрачным цветом, а второй - InvertClipFilter отсекает область с границей, оставляя всё
остальное.
Плавно перехожу к областям. Тут я сделал базовый класс для
контуров, обозвав его Contour. Для создания класса нужно ему передать все точки
границы контура, а также точки, определяющие, какие области, отсечённые
контуром, считаются областью значений. Класс этот я сделал не чисто векторным, а
полу растровым - isOnBorder и isInArea берут данные из битовой маски, которая
создаётся при инициализации контура, при этом контур считается частью области и
если isOnBorder выдаёт для точки true, то isInArea для этой точки тоже выдаёт
true. Битовая маска создаётся следующим образом: вначале в неё чертится заданый
контур, потом, если заданы внутренние точки, то от них ведётся заливка области
до границ контура. Если внутренние точки определены неверно или контур
несплошной, то может быть залит прямоугольник, в который вписан заданый контур,
но остальная часть слоя залита не будет. Кто интересуется алгоритмом заливки, то
смотрите исходник класса Contour,
функцию fillArea.
На базе этого класса сделан ShapeContour - этот класс задаёт контур ломаной или
многоугольника (если ломаная замкнута). Ломаная задаётся последовательностью
вершин, многоугольник ещё требует внутренние точки. Этот класс вычисляет все
точки контура и передаёт эти данные родителю, то есть Contour.
Для того, чтобы выделять области, ограниченые ранее нарисованными
фигурами, я написал класс ColorArea. Этот класс выделяет область в заданном ему слое по
начальной точке и цветовому допуску, то есть все точки, лежащие рядом с исходной
точкой и отличающиеся от неё по цвету в задаваемых пределах попадают в область
(аналогия wizard из Photoshop). Реализован класс тоже полу растровым методом -
при инициализации задаётся максимальный прямоугольник выделения, потом создаётся
бит-маска, размерами в этот прямоугольник, по которой уже определяется, лежит ли
точка в области ли на границе её.
И, наконец, класс области - ComposeArea. По аналогии с ComposeLayer этот класс объединяет несколько областей в одну,
причём в зависимости от параметров он может провести как их логическое
складывание (если точка лежит хотя бы в одной области, значит она лежит в
области результата), так и логическое вычитание (точка может лежать в одной и
только в одной из областей).
Итак, с базовым
API я разобрался. Теперь как это всё применить.
Допустим, стоит задача нарисовать некий многоугольник и отсечь по
нему картинку. Для решения этой задачи создаём ComposeLayer. В его стек помещаем ClipFilter, в нём задаём ShapeContour по нужным вершинам, а сверху фильтра кладем ImageLayer, созданный из нужной картинки. Теперь просто рисуем
через Graphics объект ComposeLayer, как простую картинку. Вот и всё.
Class Hierarchy
class java.lang.Object
class layergraph.areas.ColorArea
(implements layergraph.Area)
class layergraph.ColorUtil
class layergraph.areas.ComposeArea
(implements layergraph.Area)
class layergraph.areas.Contour
(implements layergraph.Area)
class layergraph.areas.ShapeContour
class java.awt.Image
class layergraph.AbstractLayer
(implements java.awt.image.ImageProducer, layergraph.Layer)
class layergraph.filters.AntiAliasingFilter
class layergraph.layers.AreaLayer
class layergraph.filters.ClipFilter
class layergraph.layers.ComposeLayer
class layergraph.filters.InvertClipFilter
class layergraph.layers.RasterLayer
class layergraph.layers.ImageLayer
(implements java.awt.image.ImageConsumer)
Interface Hierarchy
interface layergraph.Area
interface layergraph.Layer
Для наглядности я написал апплет, использующий написаный пакет: // файл LayergraphApplet.java
import java.applet.*;
import java.awt.*;
import layergraph.areas.ShapeContour;
import layergraph.layers.ImageLayer;
import layergraph.filters.ClipFilter;
import layergraph.filters.AntiAliasingFilter;
// Апплет-иллюстрация использования пакета layergraph
public class LayergraphApplet extends Applet implements Runnable
{
Thread thread; // главный поток апплета
AntiAliasingFilter layer1; // нижний слой, с который и рисуется; все остальные слои лежат выше этого
ClipFilter layer2; // слой, отсекающий части картинки из layer3, лежащих вне области contour
ImageLayer layer3; // собственно слой с картинкой
ShapeContour contour; // многоугольник
int shape[][], innerPoint[], step, k, w, h; // вершины многоугольника
boolean loaded; // флаг, что картинка загружена
public void init() // инициализация апплета
{
// создаём слой и начинаем загрузку картинки
layer3 = new ImageLayer(getImage(getCodeBase(), "YmI.jpg"), 0, 0);
thread = new Thread(this); // инициализируем поток
loaded = false; // картинка ещё не загружена
// получаю размеры окна апплета
w = getSize().width;
h = getSize().height;
// определяю внутреннюю точка многогранника, как центральную точку окна апплета
innerPoint = new int[2];
innerPoint[0] = w/2;
innerPoint[1] = h/2;
k = 1; // служебная
step = 0; // служебная
// массив вершин прямоугольника
// контур замкнут (последняя точка совпадает с первой)
shape = new int[4]52];
shape[0][0] = 0; shape[0][1] = 0; //(0,0)
shape[1][0] = w; shape[1][1] = 0; //(w,0)
shape[2][0] = w; shape[2][1] = h; //(w,h)
shape[3][0] = 0; shape[3][1] = h; //(0,h)
shape[4][0] = 0; shape[4][1] = h; //(0,0)
contour = new ShapeContour(shape, innerPoint); // создаю контур
layer2 = new ClipFilter(contour); // слой отсечения всего, что лежит вне контура
// пристыковываю к слою отсечения картинку,
// теперь layer2 показывает отсечёную часть картинки
layer2.attachCoverLayer(layer3);
layer1 = new AntiAliasingFilter(); // фильтр сглаживания
layer2.attachCoverLayer(layer2); // сглаживаю отсечёную часть картинки
}
public void start(){thread.start();}
public void run()
{
try
{
thread.sleep(30); // анимация; задержка в 30ms
if(layer3.isLoaded() && !loaded) // если вднруг картинка загрузилась
{
layer3.setSize(w,h); // изменяю размер картинка до размеров окна апплета
loaded = true; // и ставлю флаг, что картинка загружена
}
// пока картинка не загружена, анимация не производится
if(!loaded) return;
// создаю новый прямоугольник
if((step == 0)||(step == 20)) k = -k;
step = step+k;
shape[0][0] = step*w/40; shape[0][1] = step*h/40;
shape[1][0] = w-step*w/40; shape[1][1] = step*h/40;
shape[2][0] = w-step*w/40; shape[2][1] = h-step*h/40;
shape[3][0] = step*w/40; shape[3][1] = h-step*h/40;
shape[4][0] = shape[0][0]; shape[4][1] = shape[0][1];
// изменяю контур, задаю новые вершины
// область отсечения автоматически изменяется
contour.setShape(shape, innerPoint);
repaint(); // перерисовываю окно
}
catch(Exception e){e.printStackTrace();}
}
public synchronized void update(Graphics g){paint(g);} // анимация
public synchronized void paint(Graphics g) // функция рисования
{
if(!loaded) // пока картинка не загружена - чёрный экран
{
g.setColor(new Color(0,0,0));
g.fillRect(0,0,w,h);
return;
}
// рисую сглаженый кусок картинки, отсечённый вторым слоем
g.drawImage(layer1, 0, 0, this);
}
}
Исходные коды для пакета и сам пакет.
|