Сегодня мы создаем приложение камеры, которое использует 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.