Первый «настоящий» метал-api-ческий фильтр

Этот пост не для опытных «металлистов», было бы дерзко сразу вот так вот начать публиковать готовый код фильтров с минимумом комментариев. Поэтому будет много текста и вилами по воде, но зато подробно и поможет (надеюсь) осознать красоту и простоту идеи. Ко всему прочему, мне и самому интересно на сколько я могу низко пасть объяснить простым языком неофиту всю глубину падения прекрасного мира Metal. Во-вторых: все мне говорят я понимаю, что последовательное изложения материала упорядочивает информацию. И это важно.

Для первого раза выберем какой-то фильтр без объяснения что-это, зачем, какие фильтры бывают в природе и бывают ли только фильтры. Просто будем знать, что иногда с изображением можно выполнить какой-то трюк и его изменить, испортить, улучшить, или вообще синтезировать другое. Главная тема показать как с помощью iOS Metal API можно этого добиться. Никакой ловкости, только рутинное вбивание кода на клавиатуре. Начнем с классики жанра: фильтра управления насыщенностью изображения.

Эта штука, конечно, самый важный на свете фильтр из области Image Processing. В сети можно найти массу примеров его реализации на различных платформах, языках программирования и способов применения. Оно и понятно: просто, наглядно, часто-надо. И как только не склоняют вот эту тему. Например,  так:

+ (CGFloat) clamp:(CGFloat)pixel
{
      if(pixel > 255) return 255;
      else if(pixel < 0) return 0;
      return pixel;
}

- (UIImage*) saturation:(CGFloat)s
{
      CGImageRef inImage = self.CGImage;
      CFDataRef ref = CGDataProviderCopyData(CGImageGetDataProvider(inImage)); 
      UInt8 * buf = (UInt8 *) CFDataGetBytePtr(ref); 
      int length = CFDataGetLength(ref);

      for(int i=0; i<length; i+=4)
      {
            int r = buf[i];
            int g = buf[i+1];
            int b = buf[i+2];

            CGFloat avg = (r + g + b) / 3.0;
            buf[i] = [UIImage clamp:(r - avg) * s + avg];
            buf[i+1] = [UIImage clamp:(g - avg) * s + avg];
            buf[i+2] = [UIImage clamp:(b - avg) * s + avg];
      }

      CGContextRef ctx = CGBitmapContextCreate(buf,
                              CGImageGetWidth(inImage), 
                              CGImageGetHeight(inImage), 
                              CGImageGetBitsPerComponent(inImage),
                              CGImageGetBytesPerRow(inImage), 
                              CGImageGetColorSpace(inImage),
                              CGImageGetAlphaInfo(inImage));

      CGImageRef img = CGBitmapContextCreateImage(ctx);
      CFRelease(ref);
      CGContextRelease(ctx);
      return [UIImage imageWithCGImage:img];
}

Скопировано отсюда: Stackoverflow. Особенно доставляют строчки с 15 по 25.

В принципе все правильно, можно и так. Но лучше не так.  И причин тому много: скорость исполнения такого кода, однопоточная реализация и, главное для телефонов, некислая нагрузка на батарейку если, внезапно, нам нужно, не просто обработать картинку, а обработать полноформатную картинку полученную из фото-камеры устройства или принять и нарисовать видео поток. Мы так делать не будем, а сразу начнем делать «правильно». Как правильно сделать существует вариантов больше чем один — это и Core Image Framework, и Core Graphics Framework и OpenGL ES, все это доступно на iOS и OSX достаточно давно и хорошо изучено. Примеров в сети много, а может вы и сами давно пользуетесь. Со временем, найду в себе силы и приведу пару примеров отдельно, но только для того, что-бы показать, насколько это некрасиво по сравнению с металическим API, а главное, насколько это не быстрее в разработке и в выполнении кода.


И так, функция управления насыщенностью изображения.

Запишем расчет функции фильтра в немного вольном виде:
f(n) = M({X_{rgb}\times\ W_{rgb}},\ X_{rgb},\ S_{v}),\ {S_{v}\in[0,1]},\ {X_{rgb}=\left(\begin{array}{c}r\ g\ b\end{array}\right),\ W_{rgb}=\left(\begin{array}{c}w_{r}\\w_{g}\\w_{b}\end{array}\right)} , где M - функция смешивания, X_{rgb} - вектор rgb каналов,W_{rgb} - матрица весов каналов rgb, S_{v} -значение насыщенности

Лирическое отступление для полноты просветления: берем исходный пиксел в пространстве RGB, вычисляем вклад каждого канала в яркость картинки в соответствие с эмпирически полученными коэффициентами которые давно уже все известны, перемножаем два вектора для получения значения яркости пиксела и смешиваем исходный пиксел с полученным в соответсвии со значением насыщенности, которое мы выбрали.

 

Напишем же уже что-нибудь!

Я не буду углубляться в детали создания проекта под XCode, очевидно, с этим у вас не возникнет проблем. Тут стоит только отметить несколько деталей: Metal не работает на симуляторе, только на реальном устройстве. На момент написания статьи XCode 7.1 не умеет отлаживать код — вываливается по ассерту: тут дискуссия. Но сама программа работает если ее просто запустить на телефоне.
Если проект создавать лень, можно сразу скачать его, выбрать папку ImageMetalling-00 и почитать комментарии внутри.

Если есть силы и вот эта вся вода еще не надоела, начнем разбор кода. Этот пример будет целиком на Swift версии 2. По одной простой причине: Swift — провоцирует компактный кодинг и поэтому можно слегка обострить ощущение простоты программирования под Metal и показать как мало нужно написать букв для реализации алгоритма фильтрации с помощью Metal Framework и Metal Shading Language (MSL). Причем если сам алгоритм фильтрации может быть достаточно сложен, то сумма затрат на написание обвязки связанной с реализацией идеи в виде программы, может стремится к нулю в сравнении с затратами на разработку самого алгоритма и реализации его в MSL.

Есть один нюанс, который несколько усложняет совместное использование Swift и MSL, вместо Objective-C: MSL — это диалект C++11. А теперь представим себе, что наш проект состоит из огромного количества фильтров и требует большого описательного блока в виде типизации данных параметризующих функции фильтров. Поскольку MSL, как мы знаем, диалект C++, то можно написать общие заголовки с определениями этих структур и импортировать их в Objective-C реализации. Такой трюк не прокатывает в лоб со Swift.

В любом случае начнем со Swift — это проще, веселее и компактнее. Создадим 3 файла в проекте:

  1. IMPSaturationViewController.swift — контроллер интерфейса, как водится
  2. IMPSaturationView.swift — реализация отображения работы фильтра
  3. IMPSaturationFilter.metal — код фильтра на MSL

Начнем с конца, а именно с реализации фильтра. Собственно это же наша главная идея — делать всякие трюки с изображениями. Вот здесь этот трюк и прошьем. Но сначала об общих принципах работы с Metal.

Принципы

Так случилось, что давным давно на планете одержали победу два программных стандарта работы с видео-картами OpenGL и DirectX. Изначально они были заточены исключительно под нужды игровой индустрии. И мы как могли пытались использовать эти прослойки между софтом и GPU для своих вычислительных задач, и не только для игр, но и выработки нежного кроличьего мяса и распараллеливания вычислений над всякими большими данными.  Долго мучались, надо сказать. А потом пришла Nvidia и научила GPU удобно работать не только с натягиванием текстур на вершины, но и давать быстро посчитать что-то другое. Назвали они эту удобную фигню CUDA. Если вы посмотрите на программный слой, который Nvidia раздает программистам (CUDA SDK), то приятно удивитесь и возможно обрадуетесь: настолько все становится просто и прозрачно для работы с параллелизмом. Но я вас уверяю — рано радуетесь. Apple, не была бы Apple, если бы не украла переработала творчески идеи Nvidia и не родила бы еще более удобный и еще более быстрый вариант работы с CUDA. Ну и назвала удачно, как мне кажется. Так вот основная идея Metal — это на самом деле в железе вот это все,  а ключевая вычислительная парадигма SIMD, данная нам в ощущения посредством Metal Framework как средства доступа к памяти GPU и записи туда данных и инструкций из основного кода приложения и Metal Shading Language (MSL) как языка программирования вычислений на GPU. А еще Apple, как всегда кривляется и очень много  amazing, красиво обернула и представила идею в собственном ключе.

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

Если вы знакомы с C++, то наверняка сразу же начнете фигачить фильтры как горячие пончики. Единственное, наверное, что придется подучить — это стандартную библиотеку входящую в состав MSL. Если же вы изучали, до этого, Open GL и GLSL, то считайте вы почти знаете все, что вам нужно. На то у Apple, полагаю и был расчет. И надо сказать, он себя оправдывает. C++ (хоть и с некоторыми ограничениями) в качестве языка шейдеров — это чорт-побери, реально прорыв:) Более того, в отличие от реализации OpenGL ES версии в iOS, где шейдеры компилируются ядром во время загрузки, MSL код компилируется во время сборки проекта, что упрощает отладку на порядок.

И так, мы поняли, что мы можем написать сам фильтр на MSL и сделать это довольно просто. Единственное, что потребуется понять как нам связывать данные, которые мы храним в памяти приложения с параметрами функций MSL. Рассмотрим код нашего фильтра управления насыщенностью.

Код фильтра

kernel void kernel_adjustSaturation(
    //
    // текстура с исходной картинкой
    //
    texture2d<float, access::sample> inTexture [[texture(0)]],
    //
    // текстура в которую можно что-то записать
    //
    texture2d<float, access::write> outTexture [[texture(1)]],
    //
    // Какой-то параметр функции фильтра, который мы передали в качестве аргумента
    // через универсальный буфер обмена
    //
    constant float &saturation [[buffer(0)]],
    //
    // Идентификатор потока в сетке вычислений.
    // По сути - это двумерная координата пиксела, в уме держим, что пикселы
    // обрабатываются одновременно.
    //
    uint2 gid [[thread_position_in_grid]]
                                    )
{
    //
    // Текстра, в терминах MSL - объект.
    // У объектов этого класса есть метод read(позиция) -
    // прочитать пиксел тектсуры в формате RGBA с точностью плавающей точки.
    //
    float4 inColor = inTexture.read(gid);
    
    //
    // наша главная функция Image Processing-а
    //
    
    //  вычисляем светлоту пиксела
    float  value = dot(inColor.rgb, float3(0.299, 0.587, 0.114));
    // накладываем светлоту на исходный цвет
    // - степень смешения задаем через значение насыщенности
    float4 outColor(mix(float4(float3(value), 1.0), inColor, saturation));
    
    //
    // записываем все в результирующую текстуру.
    //
    outTexture.write(outColor, gid);
}

В строке 1 кода придумаем название функции, это название затем будем использовать как строковый параметр для конструирования объекта ссылающегося на скомпилированный код функции для запуска вычислений. Т.е. вот прям так и напишем «kernel_adjustSaturation». Модификатор kernel говорит компилятору MSL, что функция относится к классу вычислительных и создает специальные области памяти для хранения объектов специального типа для передачи в качестве входных параметров. Это сделано для того, что бы различать работу со стандартными для GPU вершинными и фрагментными функциями шейдеров, чаще использующихся в 3D-графике и игрушках. Хотя, очевидно, для задач Image Processing их можно использовать точно также как было принято в OpenGL.

В строке 5 объявляем параметр (C++ стайл!), который получит функция из основой памяти программы (на самом деле из совместной CPU/GPU). texture2d<float, access::sample> — означает, что мы передаем объект текстуры каждый пиксел которой представлен вектором из чисел с точностью плавающей точки. При этом тип вектора определяется при создании объекта в API Metal Framework, т.е. в теле программы, чаще всего это будет RGBA — т.е. значение каналов r,g,b и альфа канала.  access::sample — тип класса пространственного доступа к данным текстуры. Тут как не трудно догадаться тип задан sample — и значит, мы можем получить «не существующий» семпл пиксела — т.е. приблизительное его значение, если попытаемся работать в нормализованных координатах текстуры: (значения позиции от 0 до 1). Пока не будем пользоваться это возможностью. После имени аргумента можно увидеть такую запись: [[texture(0)]] — это так называемый attribute qualifiers позиции параметра в стеке аргументов функции. В коде приложения мы потом используем эти знания для указания индекса аргумента для связывания с данными в памяти программы. См. код отображалщика.

В строке 9 объявляем результирующую структуру. Тип доступа объявляем для записи. Ну тут вроде все очевидно.

В строке 14 обявляем параметр который является собственно параметром нашей фильтрующей функции. Точно также указываем номер индекса, но уже для типа данных buffer. buffer — может быть произвольной структурой в стиле языка Си/Си++ или вообще просто массив данных. В общем по сути все, что угодно, если мы знаем как это угодно обработать.

В строке 20 MSL автоматически подставит в конец стека аргументов идентификатор треда в котором ведется вычисление. Для этого мы всегда должны написать что-то вроде [[thread_position_in_grid]] после имени аргумента, хотя и тут есть всякие гибкие варианты, на которых пока не будем останавливаться (помним в уме: C++ и его мощная шаблонизация творят чудеса!). По сути такое объявление идентификатора является пространственной координатой пиксела в текстуре и пока знания этого достаточно.

Дальше все более или менее понятно из кода фильтра. По ходу еще можно заметить объявления переменных встроенными классами типа float4 и тп. Как и полагается, объекты имеют разнообразные конструкторы описание которых можно посмотреть в документации. По сути работа с ними похожа на работу с векторами из GLSL.

Вот и все. Фильтр готов. Теперь надо его заставить работать с реальными данными, т.е. с картинкой.

Контроллер

На реализации контроллера детально останавливаться не будем: тут все как обычно. В нашем конкретном случае в storyboard-е контроллера добавим вьюху IMPSaturationView для отображения картинки и прикладывания в ней фильтра, и слайдер для управления параметрами фильтра. Свяжем объекты с кодом:

   /**
     * Контрол степени десатурации
     */
    @IBOutlet weak var saturationSlider: UISlider!
    
    /**
     * И представление и фильтр в одном флаконе.
     */
    @IBOutlet weak var renderingView: IMPSaturationView!
    
    /**
     * Ловим события от слайдера
     */
    @IBAction func valueChanged(sender: UISlider) {
        self.renderingView.saturation = sender.value;
    }

Инициализируем вьюху-фильтр при старте:

   override func viewDidAppear(animated: Bool) {
        super.viewDidAppear(animated)
        
        //
        // картинуку берем прям из ресурсов проекта
        //
        renderingView.loadImage("IMG_6295_1.JPG")

        //
        // инициализируем слайдер
        //
        self.saturationSlider.value = 1
        
        //
        // выставляем занчение нассыщенности
        //
        self.renderingView.saturation = self.saturationSlider.value;
    }

На этом все.

Код реализации отображалки

Весь код можно посмотреть в исходниках. Основное место, как и водится, заняли всякие подготовительные процедуры: объявление переменных, инициализация объекта, предварительное создание объектов из Metal Framework для размещения кода фильтра в памяти GPU, подготовки данных для передачи в фильтр и т.п.

Поскольку пост вводный, остановлюсь подробно на некоторых обязательных гигиенических процедурах подготовки фреймворка Metal к работе в нашем приложении. В дальнейшем, этих знаний будет достаточно для решения 90% задач связанных с несложной обработкой изображений.

Подготовка

Чтобы мы могли загрузить наш фильтр в GPU и «передать в него» (на самом деле память специальным образом подготовить, конечно) данные (картинку) и параметры (степень насыщенности), нужно создать несколько объектов.

    private let device:MTLDevice! = MTLCreateSystemDefaultDevice()

Так мы создаем контекст объекта GPU с которым в дальнейшем планируем взаимодействовать. В телефоне GPU один. В OSX их может быть много. В нашем случае используем телефон, т.е. создаем один.

    private var commandQueue:MTLCommandQueue!=nil
    //...
    commandQueue = device.newCommandQueue()
    //...

Для программирования GPU будет использоваться очередь команд. Это еще одно фундаментальное отличие от некоторых привычных парадигм работы с GPU (например в OpenGL). Очередь команд Metal позволяет работать с устройствами не беспокоясь о многопоточности основного приложения и текущем статусе выполнения команд в самом GPU: пока мы не заполнили очередь и не сказали «погнали!», графический контроллер спокойно живет сам по себе.

Фреймворк Metal представляет два вида работы с различными своими классами объектов. Первый вид: переиспользуемые объекты, т.е. объекты, которые можно один раз создать и использовать бесконечно много раз в жизненном цикле приложения. Второй: классы одноразовых объектов, т.е. созданный объект таких классов может использоваться только однажды и для выполнения повторного действия требуется пересоздание объекта. Очередь команд относится к виду переиспользуемых классов объектов. С очередью команд можно работать синхронно и асинхронно. Если требуется многопоточность для работы с очередями, Apple рекомендует создавать экземпляр для каждого потока.

     let library:MTLLibrary!  = self.device.newDefaultLibrary()

Отдельно загружаем библиотеку шейдеров — это все файлы с расширением .metal, которые мы добавили в проект. В нашем случае файл один, вот тот самый, который мы уже подробно рассмотрели. Библиотека является переиспользуемым объектом.

     let function:MTLFunction! = library.newFunctionWithName("kernel_adjustSaturation")

Пдыщь! Создаем ссылку на конкретную функцию из библиотеки. Функция — переиспользуемый объект.

    private var pipeline:MTLComputePipelineState!=nil
    // ...
    pipeline = try! self.device.newComputePipelineStateWithFunction(function)
    // ...

Загружаем функцию в «трубопровод» — этот объект содержит все, что нам потребуется для запуска исполнения кода функции. Этот объект переиспользуемый.

    private var imageTexture:MTLTexture!=nil

Почти самый главный объект программы: текстура. Обычно под текстурами понимаются всякие графические шняги, которые натягиваются в шейдерах на вершины и потом отрисовываются в виде Нюка Дюкема в игрушках. Для нас текстура будет служить контейнером изображения, с которым мы проводим манипуляцию: прикладываем фильтр.

   func loadImage(file: String){
        autoreleasepool {
            //
            // Создаем лоадер текстуры из MetalKit
            //
            let textureLoader = MTKTextureLoader(device: self.device!)
            
            //
            // Подцепим картинку прямо из проекта.
            //
            if let image = UIImage(named: file){
                //
                // Положим картинку в текстуру. Теперь мы можем применять различные преобразования к нашей картинке
                // используя всю мощь GPU.
                //
                imageTexture = try! textureLoader.newTextureWithCGImage(image.CGImage!, options: nil)
                
                //
                // Количество групп параллельных потоков зависит от размера картинки.
                // По сути мы должны сказать сколько раз мы должны запустить вычисления разных кусков картинки.
                //
                threadGroups = MTLSizeMake(
                    (imageTexture.width+threadGroupCount.width)/threadGroupCount.width,
                    (imageTexture.height+threadGroupCount.height)/threadGroupCount.height, 1)
            }
        }        

До версии iOS 9.0 Apple не давала ни каких инструментов загрузки картинки в текстуру. Приходилось самостоятельно читать сырое изображения и записывать в память текстуры сырые данные. В iOS9 появился MetalKit SDK облегчающий нам жизнь. В этом коде приведен пример загрузки картинки из файла. Форматы файла могут быть почти любые (png,jpg,tiff,…). Нужно только помнить одну штуку с этим загрузчиком: картинка прочитается в соответствие с координатной системой металлического движка, а именно от upper-left угла, т.е запишется сверху вниз. Поэтому, для нормального отображение нужно будет её перевернуть, или перевернуть исходное изображение до обработки. Мы перевернем view, в котором будем картинку отрисовывать.

Подготовка графического слоя представления

Снова воспользуемся MetlaKit: добавим view, который умеет работать с текстурами Metal, и быстро отображать их на экранчике телефона. Используем MTLView. Если по каким-то причинам, важно собираться и работать под iOS8, можно с тем же успехом использовать слой CAMetalLayer. Сценарий работы очень похож на сценарий работы с GLKView и CAEAGLLayer. Смысл рисования в MTLView состоит в том, что MTLView имеет свойство currentDrawable, которое мы можем связать с командным буфером очереди команд и связать текстуру этого слоя с результатом рисования GPU, т.е. объявить как целевую текстуру, тогда результат жизнедеятельности расчетов будет напрямую связан с куском памяти, в которую GPU будет поливать обработанные пикселы. И это будет очень быстро и эффективно.

  required init?(coder aDecoder: NSCoder) {
        
        super.init(coder: aDecoder)
        
        // 
        // Сначала подготовим анимационный слой в который будем рисовать результаты работы 
        // нашего фильтра.
        //
        metalView = MTKView(frame: self.bounds, device: self.device)
        metalView.autoResizeDrawable = true
        metalView.autoresizingMask = [.FlexibleHeight, .FlexibleWidth]
        
        //
        // Координатная система Metal: ...The origin of the window coordinates is in the upper-left corner...
        // https://developer.apple.com/library/ios/documentation/Miscellaneous/Conceptual/MetalProgrammingGuide/Render-Ctx/Render-Ctx.html
        //
        // Поэтому, что бы отобразить загруженную текстуру и не возится с вращением картинки пока сделаем так:
        // просто приведем координаты к bottom-left варианту, т.е. попросту зеркально отобразим
        //
        metalView.layer.transform = CATransform3DMakeRotation(CGFloat(M_PI),1.0,0.0,0.0)
        self.addSubview(metalView)
        
        //
        // Нам нужно сказать рисовалке о реальной размерности экрана в котором будет картинка рисоваться.
        //
        let scaleFactor:CGFloat! = metalView.contentScaleFactor
        metalView.drawableSize = CGSizeMake(self.bounds.width*scaleFactor, self.bounds.height*scaleFactor)
        //...
}

Очевидно, что результиующей текстурой может быть произвольная переменная этого класса. Тогда мы, сможем, к примеру, получить данные и сконвертнуть их в jpeg и записать в Camera Roll, или положить в UIImage и нарисовать на экране с помощью UIImageView, но в данном примере, мы просто захотели увидеть обработанную картинку сразу после применения фильтра.

Выполнение фильтра и отображения результата

Вот, теперь все. Мы все подготовили, осталось выполнить фильтр:

    func refresh(){
        
        if let actualImageTexture = imageTexture{
            
            //
            // Вытягиваем из очереди одноразовый буфер команд.
            //
            let commandBuffer = commandQueue.commandBuffer()
            
            //
            // Подготавливаем кодер для интерпретации команд очереди.
            //
            let encoder = commandBuffer.computeCommandEncoder()
            
            //
            // Устанавливаем ссылку на код фильтра
            //
            encoder.setComputePipelineState(pipeline)
            
            //
            // Устанавливаем ссылку на память с данными текстуры (картинки)
            //
            encoder.setTexture(actualImageTexture, atIndex: 0)
            
            //
            // Устанавливаем ссылку на память результрующей текстуры - т.е. куда рисуем 
            // В нашем случае это слой Core Animation подготовленный для рисования текстур из Metal
            // (ну или MTKview, что тоже самое)
            //
            encoder.setTexture(metalView.currentDrawable!.texture, atIndex: 1)
            
            //
            // Передаем ссылку на буфер с данными для параметризации функции фильтра.
            //
            encoder.setBuffer(self.saturationUniform, offset: 0, atIndex: 0)
            
            //
            // Говорим металическому диспетчеру как переллелить вычисления.
            //
            encoder.dispatchThreadgroups(threadGroups!, threadsPerThreadgroup: threadGroupCount)
            
            //
            // Упаковываем команды в код.
            //
            encoder.endEncoding()
            
            //
            // Говорим куда рисовать данные.
            //
            commandBuffer.presentDrawable(metalView.currentDrawable!)
            
            //
            // Говорим металическому слой запускать исполняемый код. Все.
            //
            commandBuffer.commit()
        }

Тут стоит обратить внимание на переменную commandBuffer класса MTLCommandBuffer, являющегося одноразовым объектом. Команд буфер помещается в очередь команд, а в сам буфер мы эти команды напихиваем с помощью кодера, который как раз связываем с кодом нашего фильтра, а также говорим какие объекты «должны быть переданы в качестве аргументов» в саму функцию. Кодер также является одноразовым объектом. И буфер команд и кодер пересоздаются на каждом этапе запуска кода фильтра. Эти объекты легковесные и как утверждает Apple, можно не беспокоится об их пересоздании.

Надеюсь тут ничего не ясного нет, все в меру очевидно, и видно как связываем данные подготовленные в программе с параметрами функции из шейдера MSL: для этого кодируем в командный буфер ссылки на объекты исходной текстуры, текстуры окна отображения и буфера с параметром фильтра.

Все. Слов больше чем кода. И исключительно с целью прояснения где в этой кухне расположены приборы и как ими пользоваться. Надеюсь теперь все стало ясно. Лишние слова можно выкинуть и приступить к программежу.


Если заметили ашипку, если написал глупость, если что-то не понятно: комментируйте или пишите на: imagemetalling [*] gmail.com .

Реклама

Добавить комментарий

Заполните поля или щелкните по значку, чтобы оставить свой комментарий:

Логотип WordPress.com

Для комментария используется ваша учётная запись WordPress.com. Выход /  Изменить )

Google+ photo

Для комментария используется ваша учётная запись Google+. Выход /  Изменить )

Фотография Twitter

Для комментария используется ваша учётная запись Twitter. Выход /  Изменить )

Фотография Facebook

Для комментария используется ваша учётная запись Facebook. Выход /  Изменить )

w

Connecting to %s