Сегодня мы создаем приложение камеры, которое использует WebGL для предварительного просмотра фильтров насыщенности и яркости. Это как Instagram, но нет.

Сегодня мы создаем приложение для камеры с фильтрами насыщенности и яркости при предварительном просмотре в реальном времени. Это как Instagram, но нет. Нет кнопки «Сделать фото». Вы должны сделать снимок экрана. Но фильтры!

Вы можете превратить его в Instagram с помощью этих полезных шейдеров WebGL, которые создает @stoffern. О да, мы используем WebGL для управления выводом камеры

В этом уроке вы узнаете:

  • как использовать камеру с react-native-camera
  • как делать WebGL с gl-react
  • обнаружение элементарных жестов с помощью PanResponder

Мы создаем приложение за пять шагов. Вы можете увидеть его готовый исходник на Github.

Хотите создавать новое приложение каждые две недели? Подписка по электронной почте.

Шаг 0: подготовка к работе

После запуска $ react-native init LiveInstagram нам нужно немного подготовиться. Я думаю, что это будет стандартный подход в будущем ... может быть, мне стоит сделать шаблон

Но шаблоны - это ловушка. Для сопровождающего.

Вот что вы делаете:

1. Установите набор инструментов Shoutem UI с $ react-native install @shoutem/ui. Мы с легкостью воспользуемся этим в этом уроке, но я привык, что он доступен 🙂

2. Очистите index.ios.js. Когда вы закончите, это должно выглядеть так:

// index.ios.js
import React, { Component } from 'react';
import {
  AppRegistry,
} from 'react-native';

import App from './src/App';

AppRegistry.registerComponent('LiveInstagram', () => App);

Вы можете сделать то же самое с index.android.js, если предпочитаете работать на Android. Я сосредотачиваюсь на iOS, потому что у меня нет устройства Android.

3. Создайте каталог src/ для всего нашего кода.

4. Создайте App.js файл с нашим базовым компонентом. А пока что-то вроде этого:

// src/App.js

import React, { Component } from 'react';
import { Screen } from '@shoutem/ui';

export default class App extends Component {
    render() {
        <Screen />
    }
}

Преимущество переноса всего нашего кода в src/App и распотрошивания index.*.js состоит в том, что легче обмениваться кодом между обеими платформами. Наше LiveInstagram приложение не соответствует соглашениям о пользовательском интерфейсе ни одной из платформ, поэтому мы можем использовать один и тот же код для обеих платформ.

Шаг 1. Приложение "Камера"

Начнем с простого приложения камеры. Запрашивает разрешения камеры, показывает предварительный просмотр вашей камеры в реальном времени.

Благодаря отличной работе @lwansbrough над react-native-camera, эта часть проста. После фиаско Firebase на прошлой неделе я вошел в этот проект, ожидая худшего.

С камерой было проще всего.

1. Установить с $ react-native install react-native-camera

2. Настройте запрос разрешений внутри Info.plist.

./ios/LiveInstagram/Info.plist

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
  ...
    <key>NSCameraUsageDescription</key>
    <string>We need your beautiful camera</string>
    <key>NSPhotoLibraryUsageDescription</key>
    <string>We're only saving to temp, promise</string>
  </dict>
</plist>

Нам нужно разрешение на использование камеры и разрешение на сохранение фотографий в библиотеке. Мы не сохраняем фотографии, поэтому запрашивать разрешения для библиотеки - отстой, но они нужны нам для доступа к temp.

react-native-camera раньше позволяла делать снимки прямо в памяти, но это устарело. Не знаю почему.

3. Визуализируйте вид камеры в App

// src/App.js
import Camera from 'react-native-camera';

export default class App extends Component {
    render() {
        return (
            <Screen>
                <Camera style={{flex: 1}}
                        ref={cam => this.camera=cam}
                        aspect={Camera.constants.Aspect.fill}>
                    
                </Camera>
            </Screen>
        );
    }
}

Мы добавляем <Camera> к нашему основному методу рендеринга. flex: 1 заставляет его заполнять экран, fill соотношение сторон обеспечивает красивый внешний вид. Позже мы будем использовать this.camera, чтобы получить доступ к экземпляру камеры и сделать фотографии.

Запустите приложение на телефоне с XCode, и вы увидите то, что видит ваш телефон. Мне нравится, как это было легко. Спасибо @lwansbrough.

Шаг 2. Визуализируйте тестовый компонент WebGL

Теперь, когда у нас есть камера, нам нужно настроить компонент WebGL. Изображение с камеры будет наложено на изображение, обработанное графическим процессором, с измененной яркостью и насыщенностью.

Мы используем gl-react v3 alpha от @gre, и я понятия не имею, как это работает. Я знаю, что это WebGL API, но он работает на собственном коде, поэтому на самом деле это не может быть WebGL.

Я счастлив, что абстракция настолько хороша, что мне не нужно знать, как что-то работает под ней ☺️

1. Нам нужно установить gl-react и gl-react-native. Убедитесь, что у вас установлена ​​next версия, потому что старая версия 2.x не работает. v3 - это полная переработка.

$ react-native install gl-react@next
$ react-native install gl-react-native@next

2. Нашему WebGL Surface необходимо знать, насколько он велик, поэтому мы должны сами отслеживать width и height. Мы можем сделать это с помощью обратного вызова onLayout для нашего Screen компонента и некоторого локального состояния.

// src/App.js

export default class App extends Component {
    state = {
        width: null,
        height: null
    }

    onLayout = (event) => {
        const { width, height } = event.nativeEvent.layout;

        this.setState({
            width,
            height
        });
    }
    
    render() {
        const { width, height } = this.state;

        if (width && height) {
            return (
                <Screen onLayout={this.onLayout}>
                        // ...
                </Screen>
            );
       }else{
            return (
                <Screen onLayout={this.onLayout} />
            );
        }
    }
}

onLayout вызывается каждый раз при изменении макета нашего приложения. Мы используем обратный вызов, чтобы прочитать новые width и height и сохранить их в this.state.

3. Мы отображаем WebGL Surface внутри Camera компонента. Это позволяет нам накладывать изображение с камеры, и это здорово. Мы рендерим непрозрачные объекты, поэтому камера в любом случае остается невидимой, а это расточительно.

// src/App.js

import { Surface } from "gl-react-native";

import Saturate from './Saturate';

export default class App extends Component {
    // ...
    
    render() {
        // ...
        
        const filter = {
        contrast: 1,
        saturation: 1,
        brightness: 1
        }
        
        <Camera style={{flex: 1}}
            ref={cam => this.camera=cam}
            aspect={Camera.constants.Aspect.fill}>

        <Surface style={{ width, height }}>
            <Saturate {...filter}>
                {{ uri: "https://i.imgur.com/uTP9Xfr.jpg" }}
            </Saturate>
        </Surface>

    </Camera>
    
    // ...
    }
}

Вы можете думать о Surface как о компоненте холста. Это область, в которой вы визуализируете узлы WebGL. Каждой поверхности требуется как минимум один дочерний узел Node.

Saturate - это Node компонент. Он отображает изображение и настраивает его contrast, saturation и brightness.

4. Код для Saturate взят из примера gl-react, потому что я недостаточно понимаю код шейдера GL, чтобы писать свой собственный.

// src/Saturation.js
//@flow
import React, { Component } from "react";
import { Shaders, Node, GLSL } from "gl-react";

const shaders = Shaders.create({
  Saturate: {
    frag: GLSL`
precision highp float;
varying vec2 uv;
uniform sampler2D t;
uniform float contrast, saturation, brightness;
const vec3 L = vec3(0.2125, 0.7154, 0.0721);
void main() {
  vec4 c = texture2D(t, uv);
    vec3 brt = c.rgb * brightness;
    gl_FragColor = vec4(mix(
    vec3(0.5),
    mix(vec3(dot(brt, L)), brt, saturation),
    contrast), c.a);
}
`
  }
});

const Saturate = ({ contrast, saturation, brightness, children }) => (
    <Node shader={shaders.Saturate}
          uniforms={{ contrast, saturation, brightness, t: children }} />
);

export default Saturate;

Вот что я понимаю: для gl-react мы определяем шейдеры статически. Каждый из них представляет собой языковой BLOB-объект GLSL и входит в большой Shaders словарь.

Я еще не понимаю язык GLSL. Я знаю, что main() вызывается для каждого пикселя, я знаю, что этот код работает на графическом процессоре, и я знаю, что он очень похож на код C. Также похоже, что он основан на векторной математике и матричной композиции.

Думаю, мы устанавливаем наше базовое изображение как текстуру. texture2D безусловно, подразумевает это.

Сам компонент Saturate отображает Node, который принимает shader и нечто, называемое униформой. Я не уверен, что это такое, но похоже, что они могут быть аргументами в пользу кода GLSL.

Шаг 3. Загрузите данные камеры в представление WebGL.

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

Мы делаем снимок каждые 5 миллисекунд, сохраняем его во временном месте и обновляем представление WebGL.

Экспериментально придумал 5 мс. Он выглядит гладко и, с некоторыми настройками качества изображения, предотвращает сбой приложения почти на целую минуту. Да, приложение вылетает. Да, это плохо. Нет, я [пока] не знаю, как это исправить. Может кто подскажет в комментариях

1. Мы запускаем таймер в onLayout, потому что хотим убедиться, что this.camera существует. Помните, мы рендерим Camera только после того, как у нас будет width/height.

// src/App.js
class App {
    // ...
    
    onLayout = (event) => {
        // ..
        this.start();
    }

    refreshPic = () => {
        // pic taking
    }

    start() {
        this.timer = setInterval(() => this.refreshPic(),
                                 5);
    }

    onComponentWillUnmount() {
        clearInterval(this.timer);
    }

onLayout вызывает start, который устанавливает интервал, выполняемый каждые 5 миллисекунд. Обязательно остановим интервал в onComponentWillUnmount.

2. Благодаря @lwansbrough сделать снимок так же просто, как сделать предварительный просмотр камеры.

// src/App.js

class App {
    // ...
    refreshPic = () => {
        this.camera
            .capture({
                target: Camera.constants.CaptureTarget.temp,
                jpegQuality: 70
            })
            .then(data => this.setState({
                path: data.path
            }))
            .catch(err => console.error(err));
    }

Мы вызываем .capture на this.camera, говорим ему использовать качество jpeg 70% и сохранять изображения во временном месте. Сохранение необходимо, потому что в последней версии react-native-camera не использовалась прямая запись в память. Это та часть, которая требует прав доступа к библиотеке на iOS.

Когда обещание разрешается, мы получаем путь к локальному образу и сохраняем его в локальном состоянии. Если есть ошибка, мы плачем.

3. Предварительный просмотр нашей камеры невидим. Отключение этого параметра позволит сэкономить ресурсы и даже может убедить наше приложение не аварийно завершить работу. Но я не мог понять, как это сделать. Следующее лучшее решение - снижение качества вывода.

Добавьте captureQuality в <Camera> при рендеринге. Нравится:

// src/App.js

class App {
    // ...
    <Camera style={{flex: 1}}
            ref={cam => this.camera=cam}
            captureQuality={Camera.constants.CaptureQuality["720p"]}
            aspect={Camera.constants.Aspect.fill}>

Вы можете поиграть со значениями константы filter, чтобы увидеть, как они влияют на изображение.

Шаг 4: базовое распознавание жестов

В React Native есть встроенный класс для определения жестов пользователя - PanResponder. Я не знаю, почему это так называется.

Мы можем использовать его, чтобы определить, в каком направлении пользователь перемещает курсор на экране. API, который он предлагает, довольно низкоуровневый, поэтому для выяснения намерений пользователя требуется некоторая ручная работа.

Основы такие:

1. Запустите экземпляр PanResponder в componentWillMount.

// src/App.js

class App {
    // ...
    
    componentWillMount() {
        this._panResponder = PanResponder.create({
            onMoveShouldSetResponderCapture: () => true,
            onMoveShouldSetPanResponderCapture: () => true,

            onPanResponderGrant: (e, {x0, y0}) => {
                // start gesture
            },

            onPanResponderMove: (e, {dx, dy}) => {
                // gesture progress
            },

            onPanResponderRelease: (ev, {vx, vy}) => {
                // gesture complete
            }
        });
    }

Мы создаем новый экземпляр PanResponder и назначаем его this._panResponder. Чтобы заставить его работать, мы определяем несколько обратных вызовов.

onPanResponderGrant вызывается, когда начинается жест, мы будем использовать его для инициализации нашего кода отслеживания. onPanResponderMove - это то место, где мы собираемся отслеживать движение и обновлять насыщенность / яркость, а onPanResponderRelease - это то место, где мы можем дать пользователю обратную связь «готово». Нам это не понадобится.

2. Мы прикрепляем наш ответчик к основному представлению приложения следующим образом:

// src/App.js

class App {
    // ...
    render() {
        const { width, height } = this.state;

        if (width && height) {
            return (
                <Screen onLayout={this.onLayout}
                        {...this._panResponder.panHandlers}>
                   // ..
        }else{
            return (
                <Screen onLayout={this.onLayout} />
            );
        }
    }
}

Это та {...this._panResponder.panHandlers} часть. Я ожидаю, что вы можете добавить подобное распознавание жестов к любому элементу React Native. Аккуратно, а?

Шаг 5. Управляйте насыщенностью / яркостью с помощью жестов

Пора все подключить. Мы собираемся переместить filter в локальное состояние и управлять им в нашем обработчике pan.

1. Мы перемещаем filter в местный штат вот так

// src/App.js

class App {
    state = {
        width: null,
        height: null,
        path: "https://i.imgur.com/uTP9Xfr.jpg",
        contrast: 1,
        brightness: 1,
        saturation: 1
    }

        // ...
        
        render() {
        const { width, height, brightness, contrast, saturation } = this.state;

        const filter = {
            brightness,
            contrast,
            saturation
        }
        
        // ..
    }
}

Эта часть была быстрой. Вместо использования магических значений в const filter =, мы извлекаем их из this.state.

2. Мы инициализируем наше начальное состояние, когда начинается жест. Мы будем использовать линейные шкалы D3, чтобы помочь нам с математикой «сколько перетащил пользователь и что это значит для яркости».

Запустите $ react-native install d3-scale. Не нужно использовать всю библиотеку D3, весы подойдут.

Вы можете думать о линейной шкале как о линейной функции из математики средней школы. Он отображает значения из домена в значения в диапазоне.

// src/App.js

import { scaleLinear } from 'd3-scale';

class App {
    // ...
    dragScaleX = scaleLinear()
    dragScaleY = scaleLinear()

    componentWillMount() {
        // ...
            onPanResponderGrant: (e, {x0, y0}) => {
                const { width, height } = this.state;

                this.dragScaleX
                    .domain([-x0, width-x0])
                    .range([-1, 1]);

                this.dragScaleY
                    .domain([-y0, height-y0])
                    .range([1, -1]);
            },
            
            // ...
        });
    }

Когда начинается жест, мы инициализируем наши dragScaleX и dragScaleY новым доменом и диапазоном. range меняется с -1 на 1, потому что мы добавляем его к значению по умолчанию 1. От 0 до 2 должны дать нам полный диапазон насыщенности и яркости.

domain сложнее. Мы хотим, чтобы на экране отображался полный диапазон. На всем верхнем уровне яркость на 2, на всем нижнем уровне - на 0, в середине - на 1.

Но мы получаем расстояние перетаскивания только внутри onPanResponderMove, поэтому мы должны установить наши домены на основе начальной позиции. Если мы начнем с x0, это означает, что мы дойдем до левого края экрана, когда перетащим в отрицательном направлении на целые x0. Это -x0. С другой стороны, у нас есть width-x0 пространства до того, как мы коснемся края.

Это делает наш домен [-x0, width-x0]. Аналогично для вертикальной оси: [-y0, height-y0].

3. Мы используем эти шкалы для изменения яркости и насыщенности в onPanResponderMove. Нравится:

// src/App.js

import { scaleLinear } from 'd3-scale';

class App {
    // ...

    componentWillMount() {
        // ...
            onPanResponderMove: (e, {dx, dy}) => {
                this.setState({
                    saturation: 1 + this.dragScaleX(dx),
                    brightness: 1 + this.dragScaleY(dy)
                });
            },
            
            // ...
        });
    }

Мы берем расстояние перетаскивания по обеим осям, переводим его в диапазон [-1, 1] с помощью масштабов и обновляем локальное состояние с помощью this.setState.

Это запускает повторную визуализацию, обновляет представление WebGL и показывает пользователю обновленный предварительный просмотр. Достаточно быстро, чтобы чувствовать себя отзывчивым.

Хотите создавать новое приложение каждые две недели? Подписка по электронной почте.

Первоначально опубликовано на сайте school.shoutem.com.