From f05194c80c4d09d024be486882e5defbb10dd506 Mon Sep 17 00:00:00 2001 From: Pasha Stetsenko Date: Thu, 21 Jul 2022 10:23:49 -0700 Subject: [PATCH] feat: Added Rotate3DDecorator (#1805) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR creates a new Decorator for rotating a component in 3D. --- .../examples/lib/decorator_rotate3d.dart | 41 +++++++ doc/flame/examples/lib/flower.dart | 10 +- doc/flame/examples/lib/main.dart | 6 +- doc/flame/rendering/decorators.md | 32 ++++++ packages/flame/lib/rendering.dart | 1 + .../flame/lib/src/rendering/decorator.dart | 2 + .../lib/src/rendering/rotate3d_decorator.dart | 69 ++++++++++++ .../test/_goldens/rotate3d_decorator_1.png | Bin 0 -> 1515 bytes .../test/_goldens/rotate3d_decorator_2.png | Bin 0 -> 1404 bytes .../test/_goldens/rotate3d_decorator_3.png | Bin 0 -> 1499 bytes .../rendering/rotate3d_decorator_test.dart | 104 ++++++++++++++++++ 11 files changed, 262 insertions(+), 3 deletions(-) create mode 100644 doc/flame/examples/lib/decorator_rotate3d.dart create mode 100644 packages/flame/lib/src/rendering/rotate3d_decorator.dart create mode 100644 packages/flame/test/_goldens/rotate3d_decorator_1.png create mode 100644 packages/flame/test/_goldens/rotate3d_decorator_2.png create mode 100644 packages/flame/test/_goldens/rotate3d_decorator_3.png create mode 100644 packages/flame/test/rendering/rotate3d_decorator_test.dart diff --git a/doc/flame/examples/lib/decorator_rotate3d.dart b/doc/flame/examples/lib/decorator_rotate3d.dart new file mode 100644 index 00000000000..e380dd11ce8 --- /dev/null +++ b/doc/flame/examples/lib/decorator_rotate3d.dart @@ -0,0 +1,41 @@ +import 'package:doc_flame_examples/flower.dart'; +import 'package:flame/experimental.dart'; +import 'package:flame/game.dart'; +import 'package:flame/rendering.dart'; + +class DecoratorRotate3DGame extends FlameGame with HasTappableComponents { + @override + Future onLoad() async { + var step = 0; + add( + Flower( + size: 100, + position: canvasSize / 2, + decorator: Rotate3DDecorator() + ..center = canvasSize / 2 + ..perspective = 0.01, + onTap: (flower) { + step++; + final decorator = flower.decorator! as Rotate3DDecorator; + if (step == 1) { + decorator.angleY = -0.8; + } else if (step == 2) { + decorator.angleX = 1.0; + } else if (step == 3) { + decorator.angleZ = 0.2; + } else if (step == 4) { + decorator.angleX = 10; + } else if (step == 5) { + decorator.angleY = 2; + } else { + decorator + ..angleX = 0 + ..angleY = 0 + ..angleZ = 0; + step = 0; + } + }, + )..onTapUp(), + ); + } +} diff --git a/doc/flame/examples/lib/flower.dart b/doc/flame/examples/lib/flower.dart index 1e76a72b57a..7d6570b0db3 100644 --- a/doc/flame/examples/lib/flower.dart +++ b/doc/flame/examples/lib/flower.dart @@ -3,13 +3,19 @@ import 'dart:ui'; import 'package:flame/components.dart'; import 'package:flame/experimental.dart'; +import 'package:flame/rendering.dart'; const tau = 2 * pi; class Flower extends PositionComponent with TapCallbacks, HasDecorator { - Flower({required double size, void Function(Flower)? onTap, super.position}) - : _onTap = onTap, + Flower({ + required double size, + void Function(Flower)? onTap, + Decorator? decorator, + super.position, + }) : _onTap = onTap, super(size: Vector2.all(size), anchor: Anchor.center) { + this.decorator = decorator; final radius = size * 0.38; _paths.add(_makePath(radius * 1.4, 6, -0.05, 0.8)); _paths.add(_makePath(radius, 6, 0.25, 1.5)); diff --git a/doc/flame/examples/lib/main.dart b/doc/flame/examples/lib/main.dart index 10d6199835e..8f31b07859d 100644 --- a/doc/flame/examples/lib/main.dart +++ b/doc/flame/examples/lib/main.dart @@ -2,6 +2,7 @@ import 'dart:html'; // ignore: avoid_web_libraries_in_flutter import 'package:doc_flame_examples/decorator_blur.dart'; import 'package:doc_flame_examples/decorator_grayscale.dart'; +import 'package:doc_flame_examples/decorator_rotate3d.dart'; import 'package:doc_flame_examples/decorator_tint.dart'; import 'package:doc_flame_examples/drag_events.dart'; import 'package:doc_flame_examples/tap_events.dart'; @@ -27,7 +28,10 @@ void main() { case 'decorator_grayscale': game = DecoratorGrayscaleGame(); break; - case 'decorator_tinted': + case 'decorator_rotate3d': + game = DecoratorRotate3DGame(); + break; + case 'decorator_tint': game = DecoratorTintGame(); break; } diff --git a/doc/flame/rendering/decorators.md b/doc/flame/rendering/decorators.md index 65f8ee0d919..64619a2692a 100644 --- a/doc/flame/rendering/decorators.md +++ b/doc/flame/rendering/decorators.md @@ -87,6 +87,38 @@ Possible uses: - tint the scene deep blue during the night time; +### Rotate3DDecorator + +```{flutter-app} +:sources: ../flame/examples +:page: decorator_rotate3d +:show: widget infobox +:width: 180 +:height: 160 +``` + +This decorator applies a 3D rotation to the underlying component. You can specify the angles of the +rotation, as well as the pivot point and the amount of perspective distortion to apply. + +The decorator also supplies the `isFlipped` property, which allows you to determine whether the +component is currently being viewed from the front side or from the back. This is useful if you want +to draw a component whose appearance is different in the front and in the back. + +```dart +final decorator = Rotate3DDecorator( + center: component.center, + angleX: rotationAngle, + perspective: 0.002, +); +``` + +Possible uses: +- a card that can be flipped over; +- pages in a book; +- transitions between app routes; +- 3d falling particles such as snowflakes or leaves. + + ## Using decorators ### HasDecorator mixin diff --git a/packages/flame/lib/rendering.dart b/packages/flame/lib/rendering.dart index 057934c9391..95bafa01afd 100644 --- a/packages/flame/lib/rendering.dart +++ b/packages/flame/lib/rendering.dart @@ -1,2 +1,3 @@ export 'src/rendering/decorator.dart' show Decorator; export 'src/rendering/paint_decorator.dart' show PaintDecorator; +export 'src/rendering/rotate3d_decorator.dart' show Rotate3DDecorator; diff --git a/packages/flame/lib/src/rendering/decorator.dart b/packages/flame/lib/src/rendering/decorator.dart index 52e8a266c74..b90f9c77d40 100644 --- a/packages/flame/lib/src/rendering/decorator.dart +++ b/packages/flame/lib/src/rendering/decorator.dart @@ -1,6 +1,7 @@ import 'dart:ui'; import 'package:flame/src/rendering/paint_decorator.dart'; +import 'package:flame/src/rendering/rotate3d_decorator.dart'; /// [Decorator] is an abstract class that encapsulates a particular visual /// effect that should apply to drawing commands wrapped by this class. @@ -16,6 +17,7 @@ import 'package:flame/src/rendering/paint_decorator.dart'; /// /// The following implementations are available: /// - [PaintDecorator] +/// - [Rotate3DDecorator] abstract class Decorator { /// Applies visual effect while [draw]ing on the [canvas]. /// diff --git a/packages/flame/lib/src/rendering/rotate3d_decorator.dart b/packages/flame/lib/src/rendering/rotate3d_decorator.dart new file mode 100644 index 00000000000..bba660fa9cb --- /dev/null +++ b/packages/flame/lib/src/rendering/rotate3d_decorator.dart @@ -0,0 +1,69 @@ +import 'dart:math'; +import 'dart:ui'; + +import 'package:flame/src/rendering/decorator.dart'; +import 'package:vector_math/vector_math_64.dart'; + +/// [Rotate3DDecorator] treats the underlying component as if it was a flat +/// sheet of paper, and applies a 3D rotation to it. +/// +/// The angles of rotation can be changed dynamically, allowing you to rotate +/// the content continuously at the desired angular speeds. +class Rotate3DDecorator extends Decorator { + Rotate3DDecorator({ + Vector2? center, + this.angleX = 0.0, + this.angleY = 0.0, + this.angleZ = 0.0, + this.perspective = 0.001, + }) : center = center ?? Vector2.zero(); + + /// The center of rotation, in the **parent** coordinate space. + Vector2 center; + + /// Angle of rotation around the X axis. This rotation is usually described as + /// "vertical". + double angleX; + + /// Angle of rotation around the Y axis. This rotation is typically described + /// as "horizontal". + double angleY; + + /// Angle of rotation around the Z axis. This is a regular "2D" rotation + /// because it occurs entirely inside the plane in which the component is + /// normally drawn. + double angleZ; + + /// The strength of the perspective effect. In other words, how much the + /// elements that are "behind" the canvas are shrunk, and those in front of + /// it are expanded. + double perspective; + + /// Returns `true` if the component is currently being rendered from its + /// back side, and `false` if it shows the front side. + /// + /// The "front" side is the one displayed at `angleX = angleY = 0`, and the + /// "back" side is shows if the component is rotated 180º degree around either + /// the X or Y axis. + bool get isFlipped { + const tau = 2 * pi; + final phaseX = (angleX / tau - 0.25) % 1.0; + final phaseY = (angleY / tau - 0.25) % 1.0; + return (phaseX > 0.5) ^ (phaseY > 0.5); + } + + @override + void apply(void Function(Canvas) draw, Canvas canvas) { + canvas.save(); + canvas.translate(center.x, center.y); + final matrix = Matrix4.identity() + ..setEntry(3, 2, perspective) + ..rotateX(angleX) + ..rotateY(angleY) + ..rotateZ(angleZ) + ..translate(-center.x, -center.y); + canvas.transform(matrix.storage); + draw(canvas); + canvas.restore(); + } +} diff --git a/packages/flame/test/_goldens/rotate3d_decorator_1.png b/packages/flame/test/_goldens/rotate3d_decorator_1.png new file mode 100644 index 0000000000000000000000000000000000000000..97b9998411dade4255f50d85f26d6885b19b9074 GIT binary patch literal 1515 zcmeAS@N?(olHy`uVBq!ia0vp^DL}k{gAGXjntk#ckYX$ja(7}_cTVOdki$~!8`!3c|q9tuJnlARtVBAOYO0s?gWlAD&#Z&ts1 z?jys%Xn6SA>V1rxcv>Zzoq8M)&&><9+U9~p6y8q7Et7iq5Rj`*cGVGpdX}81cA;-~EcX(1Ry{cN< zTJ!MUvE( zuzc%;%cAi`jhV0Y{dz+!Ul_g0(rr0>=j%-mZplSQ1X5q?pXyy#`N~8k%glA!n%R~w ztUTYSEO{HC=f3ONip2uol>fV5mnqeY{5tbx>jwXo&leoX*{sU_!0tr7;H25-oFca6 zsjHaJZJm>}^YW~enqNyS4`n@D#+#+Pyr83USMQZTy3_d$W>W9Yx#Y3uJ$9py82_2zdAk5$#0Zg)x#efK4aklpVY^G zPtTmod=Q||x}|7=(h>fzacMWa{wxW|<^uBc&EwNFj`(wJ-KIPxio<(a7~hq1wR{FA zx20sBvA3ymN|}>;^3ocCET^J-mw0bZ$lCPnYFdW z@88gV{*LpMysug1ja1p|P1*l( z?VojKmtMte%6q(gu73f0^vUn4#%HE?+0FX5pepw3^Yh1o81wR~*rT`ioc!g@n$RW5 z624Juv0h3O*Y6u`3>t=qMT!mmes`PoJcw#hKjtZ2s(AM1F7e4qhGA+~ylWdX#n_uz z4p{njyrdggnsk-1R8f>AmCbc^%Y&s~7PR^-Ikl*D%8S60 zObtd4>r~Qm0=>ixeoHS_*rAI4uj`+WxH(>!iOtVwI7NJ8E?~Y(&Avr&@oxk z*spu-vq4~s&Z)2_+1>NM9IHywd8Jj^m8xClGtukXpJl;sZq)j0Dm(r5TguH*Jy_T0S+RCL)ECZG%i#R=h&dEP>;|92d>4?~LskSr+ z&JEwB@-Q&$2uxN@<>~cf--?oEe9lgGPB>}g7NWRtz0X#UH>SH*NQdk{C2`yK{){Z4 z-6HOylZuYZaeu>EW(CwQmf>vHrMM`=C=WmCPZLylDR^SPJg~rK@O1TaS?83{1OWR0 BttbEh literal 0 HcmV?d00001 diff --git a/packages/flame/test/_goldens/rotate3d_decorator_2.png b/packages/flame/test/_goldens/rotate3d_decorator_2.png new file mode 100644 index 0000000000000000000000000000000000000000..88a1406804d80aaee7b72d89c4809dc0313e2eb6 GIT binary patch literal 1404 zcmeAS@N?(olHy`uVBq!ia0vp^DL}k{gAGXjntk#ckYX$ja(7}_cTVOdki$~!n7|{T;bAqO-a%0+e@aLQ|8YO*GcZ(&)YO}Bmd_gbBsGV6cUX# zRq`J=S0rG_r*zEmh((74yWnGmBpH{U=8i&qf>ZQ7+F$KyFVJ7gy-AAu`>uVC{~VNe z&OQ}$|8L{kfaf=5(>$8zyie}@n(^7}>kW_H{RX>kYlcq9Oh2BbbD>xDTBd*9w`upB z`LnfDxi3A5wc7J-4qxc5i)psECjItZ*|g^3BiZTfm6E#G7N3<`yE1OG-Q{^`?>g!U|wd^nxb9xC5wH{Z>8sDOy;U|c%3pcD^O8AV_x;DRjwOq4{!QD!%O%{ z`-=~yTYebL`r;|VVp^1yBI5tz<-dc?f9CA1c`<#C#Qi%Tg*JP%x)x1axl!Qw{4dHv z?%yN4XKALZN}12QzQ{?x??>U6Z|B~ZO74`rq@u2#arEN_zv4^iZmYohRE)tAfs>*E&PWK62|nA)Red3DEMU%kt37&hKt?&TxOtl(wlyrgMH z^9j7MM|?aFR+Y-4DGo^!E`?lIS!Nv{n|hD=$9_iTe~B*hnye(Zo;cBwy>XHJ{B zu<=aAzb1>03m%THo7JwuC-F&<_lLRcMgPUw=XEE~JTu|@m&*CSCclpkIl(z=VzBb! zgg;*&Jzspj_V1FPmOe>45&L)Ew-rKv zrj`9X(C{^5vsZD^9?w>rCb7p$KcvVD&yHNmx#=k5`UYkktptr@cO zrz|*rw05<^Vlhir$@*n-^`B?5{hYaAbI>e>*(;C#tgY>5Tz{Dlh_Wv(lFq%B5xZmC z`HW8)f8Bqz_im|v_5+wh6_SBDP2)wtQv+W4r{6Vv1sZ&-!sWef&4T;+La%}HP|1pW zuS$mdKI;Vst08|TitN;K2 literal 0 HcmV?d00001 diff --git a/packages/flame/test/_goldens/rotate3d_decorator_3.png b/packages/flame/test/_goldens/rotate3d_decorator_3.png new file mode 100644 index 0000000000000000000000000000000000000000..96490279518d5c371412160153ca5df37e48e08c GIT binary patch literal 1499 zcmbVMX;4#F6i(P0mPjOl$Rgo|H7Eo!DkD0ek01|`9c7>hE>T-bwG?WDpisz*1g7Od zB_dR;F_<7|qM!r;v4Rb1-7o<`F^GU93?(G2iqPjw`@?^2@65gDobTSV&fJd|6%m3r zvophBF!)f28G}j|x_`x)py#`)d$Xv(X2gVmm@AJQ)Tm*U0fxrm(3OK@6=E>Pa42(Y z9Q&7vD&I(lkX76iRVrQXnB+ZLmzE;meD=}*4?Y;N`KTz0wt8Rf%ge(QehIlBZuASB`uSRN!_$-lBT+r zO!@Jd$lacBj=n2aIjB9O!AfGF+~wEUS2RX_`^)XJN~xGwHaU$+2(9>!iJwHxzjGPViWZp-44Jvc0wQ zhF`R^3tHTVLC2NZE%uj8TJ3-);OS@Ko7n3?{_4?i)&&Q9db_2pk}W3aRLEEXe_jki zvC#C^j?2Z#T|vOplYh!0b8kZQ;j74);d?c;4UvNa%RUd$PbCVFZ^{dpp7Xn~I6#Op z=Vd+bw4*e`Ms}m&(rWK+vvfQ!z~>(&v&f?0ob2%KQ(#Vz!160PH*Tslrpps8<{~zd z<}&JYgsM_M?F{h;05tyHrP!i*$lWCl%B+cHt#Sw;H_x_x2hj(;0o2<(Gxh2g58o0PjEbItK-P#-NKc3ddh_nQFNs*NPyg@r440m8)-QW@a}I@0^`_DmRpnUt8eekV_+r^`D;qcrgay3tvXrlNP? zf}m+&c17~vN*$gGR-hI8Mv{X$QkWUd&z(1z=f+Pq}J0|*Jh+@+%DdIGB#y{C(yp&!BB`K?qc^0URVe)tQI`~ zU8@^(L@G13V?XXpTgC>t0=$rO} z*n3G-JOuX7(-KCiN`}C^*@tBjrCCR8XdK1qjJQKJ(~SIR$CN#@^77vH8iGON{*-It z)TAYyiL!P%*V_)58F6@~a_@eiJs7R5XgyZE(9*X(_vAJlW-(ire1Ki=u6JT!7=T~z z`iqi4h~B|lSg<%9*ltQSbRrt$n3g04xa#$`0jOuy_&4J?ZktC1wzO`}ZF$e(f%J5oR(dE2ts3#bx%YbR}@Od zX_pi3oC1@+gpro4YA;x(GrA2PTM2Y++j#R$z>MR5+eBxfSK|<0>GpqE>c4T?r>}EN YUUewKRPH|*pahKx4US-50r%wp1rdgsHvj+t literal 0 HcmV?d00001 diff --git a/packages/flame/test/rendering/rotate3d_decorator_test.dart b/packages/flame/test/rendering/rotate3d_decorator_test.dart new file mode 100644 index 00000000000..4716c15fe7b --- /dev/null +++ b/packages/flame/test/rendering/rotate3d_decorator_test.dart @@ -0,0 +1,104 @@ +import 'dart:ui'; + +import 'package:flame/components.dart'; +import 'package:flame/rendering.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Rotate3DDecorator', () { + testGolden( + 'Rotation around X axis', + (game) async { + for (var angle = 0.0; angle <= 1.5; angle += 0.5) { + game.add( + DecoratedRectangle( + position: Vector2(20, 30), + size: Vector2(60, 100), + paint: Paint()..color = const Color(0x9dde0445), + decorator: Rotate3DDecorator( + center: Vector2(50, 80), + angleX: angle, + perspective: 0.005, + ), + ), + ); + } + }, + size: Vector2(100, 160), + goldenFile: '../_goldens/rotate3d_decorator_1.png', + ); + + testGolden( + 'Rotation around Y axis', + (game) async { + for (var angle = 0.0; angle <= 1.5; angle += 0.5) { + game.add( + DecoratedRectangle( + position: Vector2(20, 30), + size: Vector2(60, 100), + paint: Paint()..color = const Color(0x9dde0445), + decorator: Rotate3DDecorator( + center: Vector2(50, 80), + angleY: angle, + perspective: 0.005, + ), + ), + ); + } + }, + size: Vector2(100, 160), + goldenFile: '../_goldens/rotate3d_decorator_2.png', + ); + + testGolden( + 'Rotation around all axes', + (game) async { + game.add( + DecoratedRectangle( + position: Vector2(20, 30), + size: Vector2(60, 100), + paint: Paint()..color = const Color(0xff199f2b), + decorator: Rotate3DDecorator( + center: Vector2(50, 80), + angleX: 0.7, + angleY: 1.0, + angleZ: 0.5, + perspective: 0.005, + ), + ), + ); + }, + size: Vector2(100, 160), + goldenFile: '../_goldens/rotate3d_decorator_3.png', + ); + + test('isFlipped', () { + final decorator = Rotate3DDecorator(); + expect(decorator.isFlipped, false); + decorator.angleZ = 2.0; + expect(decorator.isFlipped, false); + decorator.angleX = 2.0; + expect(decorator.isFlipped, true); + decorator.angleY = 2.0; + expect(decorator.isFlipped, false); + decorator.angleY = -0.5; + expect(decorator.isFlipped, true); + decorator.angleY = -1.5; + expect(decorator.isFlipped, true); + decorator.angleY = -1.6; + expect(decorator.isFlipped, false); + }); + }); +} + +class DecoratedRectangle extends RectangleComponent with HasDecorator { + DecoratedRectangle({ + super.position, + super.size, + super.paint, + Decorator? decorator, + }) { + this.decorator = decorator; + } +}