В конце прошлого года движок Flame для создания игровых приложений на Flutter опубликовал первую стабильную версию 1.0 и анонсировал большие изменения в системе миксинов, подходах к управлению компонентами и созданию сцены из иерархии объектов, каждый из которых может выступить в роли контейнера. Прежде всего Flame ориентирован на двумерную графику (спрайты, системы частиц, визуальные эффекты), но возможности Skia позволяют использовать трехмерные преобразования и обеспечивать работу с шейдерами, благодаря чему можно приблизиться к созданию трехмерных игр. В этой статье мы обсудим возможный путь создания трехмерных компонентов в Flame с произвольной визуализацией с использованием компилируемых шейдеров.

Прежде, чем мы перейдем к трехмерной графике, обсудим общие принципы создания дерева компонентов во Flame и присоединения обработчиков событий к объекту или к игре в целом. И прежде всего введем понятие компонента.

Компонент Flame является контейнером для хранения вложенных компонентов, поддерживает методы update (обновление состояние компонента на каждом кадре) и render (визуализация компонента на canvas). Компонент имеет свой жизненный цикл и позволяет выполнять действия по инициализации при подключению к родительскому компоненту (onMount), определять действия для загрузки связанных ресурсов (onLoad). Также компонент может быть отражен вертикально/горизонтально, смещен относительно родителя, повернут на произвольный угол или масштабирован (используется матричные трансформации Skia, применяемые при вызове render). Компонентом верхнего уровня является FlameGame, который передается как аргумент в виджет GameWidget и через него встраивается в Flutter-приложения.

Для управления доступными событиями используются миксины. На родительские компоненты (например, FlameGame) присоединяются миксины, дополняющие методы жизненного цикла для обработки соответствующих событий (например, HasTappables отслеживает нажатие по вложенным компонентам, HasHoverables и HasDraggables - наведение и перетаскивание, соответственно). Соответствующие компоненты помечаются миксинами Tappable, Hoverable и Draggable. Для реализации отслеживания событий клавиатуры и мыши используются миксины TapDetector (нажатие на компонент), DoubleTapDetector, ForcePressDetector, KeyboardEvents, PanDetector (перетаскивание), ScrollDetector, ScaleDetector, VerticalDragDetector, HorizontalDragDetector, MouseMovementDetector и другие.

Особого внимания заслуживает обнаружение столкновения объектов. Эти проверки происходят автоматически (если в родительском компоненте добавлен миксин HasCollisionDetection) по наложению определенных областей, которые добавляются в компоненты как вложенные компонента типа *Hitbox (например, RectangleHitbox или PolygonHitbox). Для обнаружения факта столкновения в компонент может быть добавлен миксин CollisionCallbacks, для которого можно переопределить метод onCollision для обнаружения столкновения с другим компонентом.

Компонентом может быть как визуализируемая фигура (общий родитель - PositionComponent, поддерживает координаты расположения position и размеры компонента size и его наследники - ShapeComponent, PolygonComponent и др.), так и иная сущность, например анимация движения (MoveToEffect), визуальные эффекты (ColorEffect), трансформации (ScaleEffect, RotateEffect), область пересечения (*Hitbox), система частиц (ParticleSystemComponent), текст (TextboxComponent), спрайт (SpriteComponent, SpriteAnimationComponent), изометрическая карта (IsometricTileMapComponent), изображение с эффектом параллакса (ParallaxComponent), элементы управления (HudButtonComponent, HudMarginComponent), а также служебные объекты FpsTextComponent, JoystickComponent и CameraComponent.

Flame может расширяться через дополнения (в pub.dev можно найти по префиксу flame_), а также созданий собственных реализаций Component, что мы и сделаем немного позднее при реализации 3D-объектов. Но сначала создадим простое приложение со счетчиком-нажатий на Flame. Для этого будем использовать детектор нажатий, кнопку для выполнения действия и текстовое поле со счетчиком. Дополнительно добавим индикатор кадров в секунду (FPS).

Установим необходимые зависимости в pubspec.yaml и установим через pub get:

dependencies:
  flame: ^1.2.1

Создадим класс-наследник от FlameGame (и сразу добавим HasTappables для передачи событий нажатия на экран в дерево компонентов):

class CounterGame extends FlameGame with HasTappables {
	String _getCounter() => "Counter value";

  @override
	Future<void>? onLoad() async {
		super.onLoad();
    add(FpsTextComponent()
        ..position = Vector2(16, 16)
        ..size = Vector2(128, 32));
    add(TextBoxComponent(text: _getCounter(), anchor: Anchor.center)
        ..position = Vector2(size.x / 2, size.y / 2 - 32)
        ..size = Vector2(size.x / 2, 32));
    add(TextBoxComponent("Press me")..position = size / 2..size=Vector2(128, 32));
	}
}

void main() {
	runApp(
		MaterialApp(home: GameWidget(game: CounterGame())),
	);
}

MaterialApp здесь нужен для корректной обработки событий касания экрана. Можно увидеть, что формирование сцены выполняется через добавление компонентов внутрь родительского компонента (в нашем случае CounterGame). Для каждого компонента (все наследуются от PositionComponent) необходимо определить положение и размер, иначе он не будет нарисован на экране. Также можно задать опорную точку (anchor), которая определяет какая часть компонента будет позиционироваться в точку, определенную в position. Размеры и положения задаются объектами класса Vector2 (из пакета vector_math_64). Теперь добавим кнопку, которая может обрабатывать события нажатия:

class Button extends TextBoxComponent with Tappable {
	Button(text) : super(text: text, size: Vector2(128, 32));

  @override
  bool onTapDown(TapDownInfo info) {
    return true;
	}
}

Здесь мы создали свою реализацию TextBoxComponent с предопределенным размером (128dp х 32dp), которая обрабатывает событие касания объекта. Теперь добавим объект для хранения состояния, подпишемся на него и будем изменять значение счетчика:

class CounterState with ChangeNotifier {
	int counter = 0;
	void increment() {
		counter++;
		notifyListeners();
	}
}

final state = CounterState();

class Button extends TextBoxComponent with Tappable {
	Button(text) : super(text: text, size: Vector2(128, 32));

  @override

  bool onTapDown(TapDownInfo info) {
    state.increment();
    return true;
  }
}

class CounterGame extends FlameGame with HasTappables {
  late TextBoxComponent textBox;

  String _getCounter() => "You have been pressed ${state.counter} times";

  @override
  Future<void>? onLoad() async {
    super.onLoad();
    state.addListener(() {
      textBox.text = _getCounter();
    });
    add(FpsTextComponent()
        ..position = Vector2(16, 16)
        ..size = Vector2(128, 32));
    add(textBox = TextBoxComponent(text: _getCounter(), anchor: Anchor.center)
        ..position = Vector2(size.x / 2, size.y / 2 - 32)
        ..size = Vector2(size.x / 2, 32));
    add(
      Button("Press me")..position = size / 2,
    );
  }
}

Как можно видеть, здесь нет необходимости сообщать об изменении компонента (как например в State нужно вызывать setState). Все компоненты отслеживают изменения своих атрибутов (например, при изменении текста могут измениться границы прямоугольника, если это разрешено при создании). Обновление происходит автоматически через вызов метода render.

Аналогично можно заполнить экран спрайтами (поддерживаются в том числе, SpriteSheet - изображения с сеткой спрайтов, из которых могут использоваться как отдельные спрайты, так и их последовательности, которые объединяются в анимацию), svg-изображениями (при установке flame_svg), анимациями rive (необходим flame_rive) и собственными построениями (можно использовать CustomPainterComponent, который принимает painter).

Теперь перейдем к нашей теме и поговорим о трехмерной графике. В действительности, трехмерное изображение всегда создается искусственно через проекцию точек и отрезков, составляющих фигуру, на плоскость экрана, при этом эффект глубины создается через использование перспективной проекции (в которой параллельные прямые, уходящие в глубину, пересекаются в одной точке на линии горизонта), эффект тумана и изменения яркости, эффект параллакса и другие. Мы сегодня поговорим про геометрические преобразования и создадим компонент для визуализации 3D-объектов во Flame.

Прежде всего нужно отметить, что все графические примитивы в Skia могут быть трансформированы (на стороне графического процессора) с использованием объекта класса Matrix4, что подразумевает, что у любого графического элемента точки определяются тремя координатами (но третья координата по умолчанию устанавливается в 0 и не используется при отображении на экране). В этом легко убедиться, если обернуть любой виджет в Transform и передать в параметр transform значение Matrix4.translationValues(0,0,100), это действие сдвинет виджет по оси Z на 100 единиц, но визуально это никак не отобразиться. Чтобы получить визуальный эффект перспективы, нужно внести значение в позицию, связывающую z и размер изображения (например, setEntry(3,2.0.005) для матрицы и тогда визуальное изменение размера частей контейнера будет проявляться, например, при повороте вокруг оси OX или OY).

Matrix4 является одним из классов библиотеки vector_math (при импорте нужно использовать package:vector_math/vector_math_64.dart), которая предоставляет определения векторов (от одномерного до четырехмерного), матриц (Matrix2, Matrix3, Matrix4), операции над векторами и матрицами (сложение, вычитание, скалярное произведение векторов, длина вектора, определитель матрицы, получение обратной матрицы, умножение матрицы на скаляр и на вектор, умножение матриц и др. Для нас наиболее важным является возможность создания матрицы для поворота вектора в 2D/3D, смещения точки в 2D/3D, масштабирования вектора, а также их комбинации. Попробуем построить треугольник в трехмерном пространстве и будем использовать для этого возможности Skia для матричных операций.

Поскольку Flutter предполагает построение только плоских изображений, перед тем, как создавать трансформацию для позиционирования треугольника, нам нужно определить положение равного выбранному треугольника на плоскости, а затем выполнить его перемещение и разворот в трехмерном пространстве. Для этого нам понадобиться немножко вспомнить математику и операции с векторами, а также общий вид матриц для аффинных преобразований. Аффинными называются преобразования, которые не изменяют фигуру и оставляет параллельные прямые параллельными, к таким преобразованиям относятся перенос (translation), масштабирование (scale), поворот (rotation).

Матрица масштабирования и поворота может быть записана как в виде 3х3, так и в расширенной матрице 4х4. Матрица сдвига может быть записана в виде вектора 1х3 (с которым выполняется сложение вектора координат), но это неудобно, поэтому обычно используют особым образом построенные матрицы 4х4, которые применяются к четырехмерному вектору координат, в котором первые три значения соответствуют координатам x, y, z, а четвертое обычное принимается за 1, что позволяет использовать четвертый столбец матрицы преобразования для выполнения сдвига при перемножении матрицы на вектор (или при создании составных преобразований через умножение матриц).

Матрица переноса в общем виде выглядит так:

M_t =\begin{pmatrix} 0 & 0 & 0 & t_x\\ 0 & 0 & 0 &t_y\\ 0 &0 &0 &t_z\\0 & 0 &0 &1 \end{pmatrix}

Для создания в Dart можно использовать factory-метод Matrix4.translationValues(tx, ty, tz).

Матрица масштабирования располагается на главной диагонали:

M_s = \begin{pmatrix}s_x & 0 & 0 & 0\\0 & s_y & 0 & 0 \\ 0 & 0 & s_z & 0 \\ 0 & 0 & 0 & 1\end{pmatrix}

В Dart можно создать такую матрицу через Matrix4.diagonal3values(sx, sy, sz).

Матрица поворота наиболее сложная, поскольку поворот вокруг любой оси одновременно затрагивает для точки положения по двум осям (например, поворот вокруг оси OZ изменяет одновременно x и y).

M_r=\begin{pmatrix}cos \alpha & -sin \alpha & 0 & 0\\sin\alpha & cos \alpha & 0  & 0\\0 & 0 & 1 & 0\\0 & 0 & 0 & 1\end{pmatrix}

Для создания соответствующей матрицы в Dart есть factory-методы Matrix4.rotationX (поворот вокруг оси OX), Matrix4.rotationY (вокруг OY), Matrix4.rotationZ (вокруг OZ).

Матрицы можно комбинировать через умножение (multiplied), а также применять к векторам координат (transform3 для преобразования Vector3 в новый Vector3, transform выполняет преобразования над Vector4).

Кроме того, можно создать матрицу для выполнения трех преобразований через вызов Matrix4.compose, который принимает три значения - Vector3 для переноса, кватернион для поворота и Vector3 для масштаба. Тут необходимо пояснить, что кватернион (Quaternion) является математической конструкцией для описания поворота, который исключает проблему Gimbal Lock, когда некоторые положения оказываются недоступны из-за конфликтующих матриц поворота. Кватернион может быть создан из матрицы поворота (Quaternion.fromRotation), определен как угол поворота между двумя векторами (Quaternion.fromTwoVector), а также через определение поворота вокруг заданной оси (Quaternion.axisAngle). Все эти знания пригодятся нам для решения задачи проекции трехмерной фигуры (мы начинаем с треугольника, поскольку это наименьшая фигура, которая целиком лежит только в одной плоскости) на canvas в Flutter.

Прежде всего нам необходимо выполнить поворот треугольника в плоскость OXY (плоскость экрана), для этого создадим кватернион между векторами нормалей. Одна из нормалей определяется к плоскости треугольника (нормаль для него определяется через векторное произведение векторов, составляющих его стороны, по свойству векторное произведение всегда перпендикулярно обоим векторам и, следовательно, перпендикулярно плоскости треугольника), вторая - к плоскости экрана, она всегда будет (0, 0, 1). Далее для корректного вычисления выполним параллельный перенос одной из вершин треугольника в точку (0, 0, 0) и далее осуществим поворот двух векторов, имеющих выбранную общую точку, с использованием кватерниона. Здесь в переменных point1, point2, point3 хранятся координаты точек исходного треугольника (Vector3):

final vector1 = point2 - point1;
final vector2 = point3 - point1;
final normal = vector1.cross(vector2);
final normalXY = Vector3(0, 0, 1);
final rot = Quaternion.fromTwoVectors(normal, normalXY);
final qp21 = rot1.asRotationMatrix().transform(vector1);
final qp31 = rot1.asRotationMatrix().transform(vector2);
final vertices = [Vector2.zero(), qp21.xy, qp31.xy];

Таким образом мы определили проекцию треугольника на плоскость экрана, но теперь нам нужна матрица для обратного преобразования треугольника в исходное положение и ориентацию, для этого используем обратное значение кватерниона и создадим матрицу для поворота и смещения треугольника в ожидаемое положение:

final matrix4 = (Matrix4.identity()..setEntry(3, 2, 0.005)) *
        Matrix4.compose(point1.xyz, rot.inverted(), Vector3.all(1));

Здесь мы также добавляем эффект перспективы и применяем его уже после размещения треугольника. Теперь мы можем использовать матрицу для построения треугольника:

canvas.save();
canvas.transform(matrix4.storage);
final path = Path();
path.moveTo(vertices[0].x, vertices[0].y);
path.lineTo(vertices[1].x, vertices[1].y);
path.lineTo(vertices[2].x, vertices[2].y);
path.lineTo(vertices[0].x, vertices[0].y);
canvas.drawPath(path, paint);
canvas.restore();

Мы можем объединить все наши наработки и создать компонент Flame для отображения граней трехмерных объектов, которые могут быть объединены в составные фигуры (например, можно загрузить obj-файл, который представляет из себя текст с указанием координат вершин и определением граней через набор треугольников, а также направлением нормалей к граням и отображением координат текстур).

Создадим несколько вспомогательных типов - точка в трехмерном пространстве и определение грани 3D-объекта через три точки:

typedef Point3D = Vector3;
class Edge3D {
	Point3D point1;
	Point3D point2;
	Point3D point3;
	Paint? paint;
	Edge3D(this.point1, this.point2, this.point3, {this.paint});
}

Дальше определим компонент для 3D-объекта, который, кроме перечисления граней, также будет принимать векторы и кватернион для позиционирования, масштабирования и ориентации объекта в трехмерном пространстве. Переданные значения используются для определения координат точек, которые в дальнейшем будут использоваться для создания матриц, но тут полезно выполнить оптимизацию, чтобы избежать избыточного вычисления матриц. Для этого мы создадим специальную реализацию Vector3 и Quaternion, которая будет использовать миксин ChangeNotifier и уведомлять подписчиков об изменениях (полный текст реализации классов представлен в github-репозитории):

class NotifyingVector3 extends Vector3 with ChangeNotifier {
  factory NotifyingVector3(double x, double y, double z) =>
      NotifyingVector3.zero()..setValues(x, y, z);

  NotifyingVector3.zero() : super.zero();

  factory NotifyingVector3.fromVector3(Vector3 vector) =>
      NotifyingVector3(vector.x, vector.y, vector.y);

  factory NotifyingVector3.all(double v) => NotifyingVector3.zero()..splat(v);

  factory NotifyingVector3.copy(Vector3 v) =>
      NotifyingVector3.zero()..setFrom(v);

  @override
  void applyQuaternion(Quaternion arg) {
    super.applyQuaternion(arg);
    notifyListeners();
  }
  //...
}

Теперь мы можем создать необходимый нам класс компонента:

class Component3D extends PositionComponent {
	List<Edge3D> edges;
	Paint paint;
	NotifyingVector3? translate;
	NotifyingQuaternion? rotation;
	NotifyingVector3? scaled;
  
	bool _invalidate = true;
	Component3D(this.edges,
              {Vector3? translation,
               Quaternion? rotation,
               Vector3? scaled,
               Paint? paint})
    : translation = translation != null
      ? NotifyingVector3.fromVector3(translation)
      : null,
  rotation = rotation != null
    ? NotifyingQuaternion.fromQuaternion(rotation)
    : null,
  scaled = scaled != null ? NotifyingVector3.fromVector3(scaled) : null,
  paint = paint ?? Paint()..color = Colors.white;
  
	this.translation?.addListener(_applyTransformation);
	this.rotation?.addListener(_applyTransformation);
	this.scaled?.addListener(_applyTransformation);
  
void _applyTransformation() {
	transformed = [];
	final transform = Matrix4.compose(translate ?? Vector3.zero(), rotation ?? Quaternion.identity(), scaled ?? Vector3.all(5),);
	transformed = edges.map((element) {
      return Edge3D(
      transform.transformed3(element.point1),
      transform.transformed3(element.point2),
      transform.transformed3(element.point3),
      paint: paint,
    );
    }).toList();
  _invalidate = true;

}

После изменения смещения, масштаба или поворота будет пересчитываться положение точек в списке transformed. Теперь мы можем добавить реализацию update и render, при этом мы будем кэшировать значение матрицы для передачи в Skia.

@override
void update(double dt) {
  if (!invalidate) return;
  invalidate = false;
  removeAll(children);
  transformed.forEach((edge) {
    add(Triangle(edge.point1, edge.point2. edge.point3, paint: edge.paint));
  });
}

Создадим класс Triangle для выполнения вычислений и преобразований:

class Triangle extends PolygonComponent {
	final Vector3 point1;
	final Vector3 point2;
	final Vector3 point3;
	final Paint paint;
	Matrix4? matrix;
  
  Triangle(Vector3 point1, Vector3 point2, Vector3 point3, {Paint? paint})
    : this.point1 = Vector3(point1.x, point1.y, point1.z),
  this.point2 = Vector3(point2.x, point2.y, point2.z),
  this.point3 = Vector3(point3.x, point3.y, point3.z),
  this.paint = paint ?? Paint()..color = Colors.white,
  super([Vector2.zero(), Vector2.zero(), Vector2.zero()]) {}
  
@override
void update(double dt) {
  if (matrix==null) {
    final vector1 = point2 - point1;
    final vector2 = point3 - point1;
    final normal = vector1.cross(vector2);
    final normalXY = Vector3(0, 0, 1);
    final rot = Quaternion.fromTwoVectors(normal, normalXY);
    final qp21 = rot.asRotationMatrix().transform(vector1);
    final qp31 = rot.asRotationMatrix().transform(vector2);
    refreshVertices(newVertices: [Vector2.zero(), qp21.xy, qp31.xy]);
    matrix = (Matrix4.identity()..setEntry(3, 2, 0.005)) *
      Matrix4.compose(point1.xyz, rot1.inverted(), Vector3.all(1));
  }
}
  
@override
void render(Canvas canvas) {
  canvas.save();
  canvas.transform(matrix.storage);
	super.render(canvas);
	canvas.restore();
}

Теперь мы можем определить трехмерную фигуру через набор треугольников и протестировать ее вращение:

class Game extends FlameGame {
  @override
  Future<void>? onLoad() async {
    super.onLoad();
    final point1 = Point3D(-5, 5, 5);
    final point2 = Point3D(5, -5, 5);
    final point3 = Point3D(5, 5, 5);
    final point4 = Point3D(-5, -5, 5);
    final point5 = Point3D(-5, 5, -5);
    final point6 = Point3D(-5, -5, -5);
    final point7 = Point3D(5, 5, -5);
    final point8 = Point3D(5, -5, -5);
    final edge1 = Edge3D(point1, point4, point2);
    final edge2 = Edge3D(point1, point2, point3);
    final edge3 = Edge3D(point1, point4, point6);
    final edge4 = Edge3D(point1, point6, point5);
    final edge5 = Edge3D(point5, point8, point7);
    final edge6 = Edge3D(point5, point6, point8);
    final edge7 = Edge3D(point3, point8, point7);
    final edge8 = Edge3D(point3, point2, point8);
    var angle = 0.0;
    final tg = Component3D(
      [
        edge1,
        edge2,
        edge3,
        edge4,
        edge5,
        edge6,
        edge7,
        edge8,
      ],
      paint: Paint()..color = Colors.yellow,
      rotation: Quaternion.fromRotation(
        Matrix3.rotationY(angle),
      ),
      scaled: Vector3.all(1),
    )
    ..position = size / 2
    ..size = size;
  
  	add(tg);
  	Timer.periodic(Duration(milliseconds: 16), (_) {
    	angle += 0.05;
    	tg.rotation!.setFromRotation(Matrix3.rotationY(angle));
		});
	}
}

В git-репозитории проекта (https://github.com/dzolotov/flutter-3d-sample) также представлен класс для загрузки obj-файлов и создания списка Edge3D для построения загружаемых 3d-моделей.

Но остается важным вопрос изменения окраски объекта и определения цвета точки с учетом фонового, точечного и направленного освещения. В простейшем случае можно использовать одинаковый оттенок для всей грани (задается через paint в определении Edge3D), это выглядит не очень реалистично, но может быть реализовано наиболее простым образом. Но что делать, если хочется использовать более сложные способы раскраски грани?

Skia для мобильных и десктопных платформ (в настоящее время в веб эта функциональность не поддерживается) позволяет использовать произвольные фрагментные шейдеры для определения Paint. Чтобы использовать эти возможности необходимо скомпилировать исходный код шейдера (на GLSL) в промежуточное представление (SPIR-V, Standard Portable Intermediate Representation) и затем загрузить его в приложение. Для удобства компиляции можно использовать пакет shader, который устанавливается как консольная утилита и позволяет выполнить компиляцию (с использованием внешнего инструмента) glsl-файла в SPIR-V. Создадим простой фрагментный шейдер, который будет создавать градиентное заполнение:

#version 320 es
layout(location = 0) out vec4 fragColor;
void main() {
	fragColor = vec4(gl_FragCoord.x/50.0, gl_FragCoord.y/50.0, 0.0, 0.5);
}

В шейдере можно получать текущие координаты (gl_FragCoord), координаты для текстуры (gl_TexCoord) и необходимо вернуть значение цвета точки (название переменной определяется в out vec4 определении). При вычислении можно использовать функцию texture для получения цвета в соответствующих координатах текстуры. Также доступны математические функции, локальные переменные, циклы и условные операторы, определение функций (хотя важно отметить, что некоторые конструкции, валидные в GLSL отобразят ошибку о неподдерживаемой операции в Skia).

Разместим код шейдера в assets и выполним компиляцию в SPIR-V:

pub global activate shader
shader -s --use-remote

Также можно использовать shader -d для создания исходного кода на Dart с загрузкой шейдера из массива (SPIR-V размещен внутри исходного кода). Добавим в main компиляцию:

import 'dart:ui' as ui;
ui.FragmentProgram? program;
void main() async {
	program = await ui.FragmentProgram.compile(
		spirv: (await Flame.bundle.load("stripe.sprv")).buffer);
}

И далее можно создать шейдер через вызов program.shader, в которую можно передать список значений (floatUniforms, тип значения Float32List) и сэмплеров (могут использовать для текстур, в sampleUniforms, тип значения ImageShader, в шейдере тип sampler2d). Полученный шейдер записывается в Paint()..shader = shader и используется для построения соответствующей грани.

Альтернативно можно использовать umbra, устанавливается также как утилита командной строки:

dart pub global activate umbra_cli
umbra install-deps
umbra create sample
umbra generate sample.glsl --output lib/shaders/

umbra также создает заготовку для шейдера, модифицируем его для получения градиентного заполнения с прозрачностью:

vec4 fragment(vec2 uv, vec2 fragCoord) {
    return vec4(fragCoord.x/50.0, fragCoord.y/50.0, 0.0, 0.5);
}

umbra также может генерировать исходный текст с загрузчиком (--target=dart-shader), spirv (--target=spirv) и даже виджет с примененным шейдером (--target=flutter-widget). Здесь координаты точки и координаты текстуры передаются через параметры функции fragment. Дальше использование шейдера ничем не отличается от рассмотренного выше сценария.

После компиляции шейдера и применения Component3D результатом будет вращающийся полупрозрачный куб без двух граней:

3d-изображение с примененным шейдером
3d-изображение с примененным шейдером

Более подробно об ограничениях и возможностях Skia Shading Language можно посмотреть здесь. Также можно использовать готовые шейдеры с сайта ShaderToy, но в некоторых случаях потребуется доработка исходного кода.

Во второй части статьи мы разберемся с математикой освещения (с учетом наших матриц для расположения треугольника в 3D-пространстве), обсудим использование canvas.drawVertices, а также применим текстуры для 3d-объектов и поговорим про сложности с удалением невидимых граней и возможные способы решения этой задачи.

Одной из сильных сторон Flutter, кроме декларативного описания интерфейса, которое де-факто стало стандартом во всех технологиях мобильной разработки, является возможность использования системных сервисов Android/iOS и доступа к оборудованию. Приглашаю вас на бесплатный урок в рамках которого мы поговорим о механизмах обмена данными между Flutter-приложением и нативным кодом, а также сделаем приложение будильника, которое будет отслеживать сон, включать сигнал в подходящее время и отправлять информацию о режиме сна в Google Fit.

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