From cc9ae899b0a32182d34c0e5abb5d5b1a87853d48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Lescaudey=20de=20Maneville?= Date: Thu, 16 Nov 2023 13:51:39 +0100 Subject: [PATCH 01/10] Sprite slicing and tiling --- Cargo.toml | 20 ++ assets/textures/slice_square.png | Bin 0 -> 13541 bytes assets/textures/slice_square_2.png | Bin 0 -> 12698 bytes crates/bevy_sprite/src/bundle.rs | 6 +- crates/bevy_sprite/src/lib.rs | 17 +- crates/bevy_sprite/src/render/mod.rs | 46 +-- crates/bevy_sprite/src/sprite.rs | 22 ++ .../src/texture_slice/border_rect.rs | 59 ++++ .../src/texture_slice/computed_slices.rs | 148 ++++++++++ crates/bevy_sprite/src/texture_slice/mod.rs | 85 ++++++ .../bevy_sprite/src/texture_slice/slicer.rs | 263 ++++++++++++++++++ examples/2d/sprite_slice.rs | 171 ++++++++++++ examples/2d/sprite_tile.rs | 48 ++++ examples/README.md | 2 + 14 files changed, 866 insertions(+), 21 deletions(-) create mode 100644 assets/textures/slice_square.png create mode 100644 assets/textures/slice_square_2.png create mode 100644 crates/bevy_sprite/src/texture_slice/border_rect.rs create mode 100644 crates/bevy_sprite/src/texture_slice/computed_slices.rs create mode 100644 crates/bevy_sprite/src/texture_slice/mod.rs create mode 100644 crates/bevy_sprite/src/texture_slice/slicer.rs create mode 100644 examples/2d/sprite_slice.rs create mode 100644 examples/2d/sprite_tile.rs diff --git a/Cargo.toml b/Cargo.toml index 5fe0449660fe0..fcb81a1eee19b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -440,6 +440,26 @@ description = "Renders an animated sprite" category = "2D Rendering" wasm = true +[[example]] +name = "sprite_tile" +path = "examples/2d/sprite_tile.rs" + +[package.metadata.example.sprite_tile] +name = "Sprite Tile" +description = "Renders a sprite tiled in a grid" +category = "2D Rendering" +wasm = true + +[[example]] +name = "sprite_slice" +path = "examples/2d/sprite_slice.rs" + +[package.metadata.example.sprite_slice] +name = "Sprite Slice" +description = "Showcases slicing sprites into sections that can be scaled independently via the 9-patch technique" +category = "2D Rendering" +wasm = true + [[example]] name = "text2d" path = "examples/2d/text2d.rs" diff --git a/assets/textures/slice_square.png b/assets/textures/slice_square.png new file mode 100644 index 0000000000000000000000000000000000000000..bee873c46b5a4e97719fe25149e4fe719af95050 GIT binary patch literal 13541 zcmeIZ`9IX%8$bSbbv>`=bzSFN=jx`Fh3Q7Y zt%48)Z9I1L@JR?lg8xNA=yl+ah48Lr2*N_g4j()f?mRsheaB-gT52|UX6nmS&ds#* ziCV`rOM86M+D|GGvU1u_Zspta9DP;1zU_r7-j#Np(w{>GKjMrZw-e)4nSVWISqsxq~qyEXYsb&-~fndy%T zdI&bX2_hUH=a{Mk(evjoFZY&COa-^uHgx6|D6lVbB1XTk$YovyBPP8UM%qRPs#?gt zx&v$<6T&@?Pd&HHi9I$?F-^&q+aajcVxL~kIm;we7ROstBx5o+5(qmMvK37Ts+;0X zhoZ*Y%S75XN55$1(DasjZ8kEyURKmie<^apo$gs|8S$ANQh%3}w{AX*CG_5(v+~#W znqlPALNi7}eaa%%82{~ev)lZW_>2u|Cvo~OCfkH3mn)WDzBEky`hILXWDzsWS4=y7 zwp!2p*V_}F1JYZ$`Zh?7$_Sg$tEl6I)gLJ)OKWoj`_B6Zr+Oi-M9lQI(3H8({)wj~?G9Tr2N#s)T%YopwWdl1}L}&E!@H?F%vf;|6dXInjhHA*BpB#*G zM`;=*BmEc*^1GGx=xOAL^VRknbO&e0+9j2xp_PHt2{%KmynqNKK3ZtG59eIvyWjNq zMvDGd_Qu2q#8L7|?Jpk22su|{_4pO1O8$p9$YAS+3K2_g(en074c;qnW`Mcqnior0N zz2~-xRZJzMuTvEH zxrlJGKsGw0q=@^cZiUXRIvpTzSO99Ww|9{;YU5r&Os;=`)JzRU@h0`>C0b{r(Fp6! z8$~ULaq%^O2<+=CbVAHGIWJ+zrFyzt=dtr?W`_Dd^v9Pqwv$Qt`WBi8cf()H92;?@ zhKi$Qu1pa>yV&sdZJfsB(z-9=P`xrY{rU+;`p5&W{*{S~XOdxBi>R$!;FSCJBaQL> z^^N4i*r>h33Ca8S$ISX|GF<4|l~D78`!zRuc&~W|jZ8V-1Gu|$1UiLb#2$HT@{1)Bv;|F1rV$`IT;$q4d zSi@qrW)WRR}B7ract-}}j1Ry29XGpjyMd3xuDqtN z>P`Z#70KBWu+D~N0bEq)fv9(yX`8AlXI7v7c4y1^6`g z2Ax$iMP?06j4Z?vw@Yf%Knyae3&fkHG+LyPOIY}1zZeW^U)U04@5sq~lI zEnU##K|z|1-?Mx;qZ{LeR~s#CX!{`cCla4>ZhQlGTg;YsI&`fRZe|s5x(LJxLMGCf zYOt8GvWFz~s4ZiqEFgOJomfv|-#*BoiogwsA0=oGWeRz74An}0UzWq7LY_=X|3t^8 zBMFl^hvGTkyAZ?UoK@V`y5IEVCcRX54as)4rfOzht^yYI%08*?s<)Z}GB&_q_1`RB_ zP-AWlvInPKs5zzYgZN{KLC>XoUQx@*g1s6*#_HuF^(Srd+{%IZ_Zx6(h!%qLe5=EK zBF4(9RLsej8I8zIo?OnLRhNg(Pwe*J0@X)y$!Pj2LA_%!#jaztiyQS+z5PL4} zSzj*Su0`2A!_WL9!$rVKf+D%zAV#X8tO)3^RZO##5BSP5S+b-i5x*`uIhPJfW4TNj{%4qM*Z zF7cxo9{sVv*)V-t+a6K00V*iSUP90O>3gc87|IB%pco!;^#wxt9@zBQX}EPZ5@{3^ zlv2P)rN?t;4a>+4=uhai0AzDk?2|oMt0FYDjU6lp)sCe>fm7ccmb7a^qxTIcmHh1C z^AfgdUC`!U^7VV(CxAxuCGF)3#0RW9yIGjV`nz71{lj}-a$0mHi@x&-i?2p`?9`9q9&N8-)$~(jSL*k?0_o(Hl8uW3t1eJ;xR|^NT3M16Ean8zn?K)jj30G5 z-=|XT0)h=?B6Z4FHEMUeCxoW3$4)bwH-57C_P$6BB!-n2WEoRee_)J(akB$+Vk3go zn#t^nf%)&gZJA?cXCsQgZ3R0{(F&%|?TZjZBO<=36r3q8rW6U&xe0aR!%MHyDnDgP zwX>&D$hQ(0Wx+5A#kb@xX59&$c*z1M^cDt5Fs9H1H%KSY~ zWE}zG9vLvcE=<|N>>3xOHjg+!$wFs>oty6rvZFs)Ixp%tAbUVwy_j$Ym6m_NuzEd~ z{fGPuCF;*nE80}hbdQ|3U9Z+zM(@o((is9GN=JZqJpVdfXajjST`sqF*CDvfYdRPj zq~Ysx*i}FR{effb;JN>nMSwDIRTTR4ZGG5<6@Dm??ejLaM>%%?2>!yk-0y<6I zI>wJ89{ce9fKezTA-vCp5l}GRD31YrzcJaj)PW+3?YzS4eGRL6ar(1uZ2hWHad(Dwg=`_=AC1 z6fq)c7sw5U+z3CxvHxsR0XB&^r3JQUTH{&>+qc#03A|RsqF#HYuY5$}dn5o(JBOy} zuLwQq?^x)Nogk9^eB6HqMO0OmtYMi({VryORf#;ljb8%#FOBArt)cOJ<1~W(*VaQs z>VBj#@nujI5o-Ssa|6bN{?xbiU{*5tR;~F!_fr%xuU8@5YHF1Jii=_j%i~>@5pvYJ z%>ro1Z-%dha>B`746A;Ph*9Trw%oo4dTslW$HnqJ9*)QlfX^Qu#RQKa@!J-A*!@=8 z3J9bMpoB`6r^Yh~Q)87zxM=-T1l#@!P);N8X@d~7Pbiz!)TJefK+1sGGH!~O_8_o! za$4Ne4;tn0t`@+P9uJR&oJC;QMFFX@WA^2{Z9t-OfV1Y{n-}De)WW#iaKOf0IAV|q zk+y)4y#=8eQ3ey5ue|F`qF9VIu;Go=aVsSCbzBEKH;JK(L}>t`Y3%NL*eMR> zkUH79){M*4~HXB2P84b1MBGHAxP@3cHTiwu_J6R z0vQaZjNYJq&EqEqodXmsW;Z2;Nh6Rqf$lfOcYM`C5reSW+@OcjhlqFtz?yJtFEn+^ ztc9Q`$IXC9^?(yG;*LfU{kNK`ps@BE-NlB)gBiKNL)yh^XW+E=mcfsZ^$I)?sy=AXT z26tutlJ@4+sUfH7?==-?&z{M9G&oJBmoSt4-dYbwH1v_*1E2zF$j{)$=gT0^oI_f3QBSBV}@z>Hs zqOR-)hk!HRULoOmYZtcKc}s?mn;i=MWe*nhWzS~r+;0?-cea|-DHjpI90eDdqXh~c zC}M95?#Cb}jd0cyT_%Umasuw{L_20eCqV6N#lB7V9$K`9cLZ9@;1&WWv+z?kyv1y@$w*!59D#0)9)!18nf93AS{+2*RU)F z;Njk&&5m8CNQRck31B`tgFtO5up+_xA#9~)3P;{JQ;0(Uf=@v%xtui#l?g4BmAlh- zz&HpBAr56RD5!9_=j6O={>tNDLsBZ$-Agt7~Fi~)#xV9_!u>>vmptjOQE z75{xmu$8DN2I;A6k_I#)zs%o$0(@u;81_df!x?OoTbyGm3dF>N^Gx;ATnR{v`zL?g z8tkm(2tbbFdnN{kJ{63KfFW!k04?RB<}(qPuwg5f2@UpTzXOJIECbTIj?*QyG z0h5zHOeg@OZ81Dzo&;vD0(W!H^IL&UOV|AL8R&EW2@w-m=RLg+-otcNBPu^t14#2# z3|yA~HEw6*x>OM`rXi_hhCPtra$8@PzF_mQIbb}k%Y7emfOHbnDHlg z0Xf%Y0LxNfvx6x30_`Y19*Is-g?53vZMe^A!5&&Ez$tadM4Fn*su6qN) z3V2&$moO!l0Vn(W_n9cb92!FZl13J7T=TNXvJRD4p5_uCwNZ{+faa0u@ZZtqd+)oW z!{x#9Vp0Qqz^uR*kB|n8sVpEw|2wcY(Abmo*cczZKqZZSy`_dCT~U9LFN9mHH`ZRx z1BD`0f6j(Sk6O9W5mj67#Dvm|LZ=5ER9+UIY93;cBlxxu;f>}cKFXtNMIkpW0&O;# zeos3Xmivan{X=M7fR7ePW!Zkxd_1djka_XM<#Cy?i*@|!ogUyP1})DHD1|_F@ShY$ zqyrWvI0y3qq60-wkWL-KM^6pE^PJemshzKQ3u8p2-b3!bPF(I?kE`4Q4NOzqSEYFi zpmHRSs-U`>W@^h)D`8JxTsMn{MNzWHKF3(ea@*tg^AX!v$u)8>dH7e09hFCQDb7?E zbg*lbYc*jBA(A-2<41$6e^djxbC-4^|V!p^hQP9xOozw3gf%F-pN0uk!Hj5;sY%~R$YeP{FSj~t8{d46t@dGo(H z@aY{N47@$o>H)rAE-U8G>`0>r_d)r?H!$>gRiqz)ctEGFBm6hH*-qUO{#NNk)=P|) z&IoyP%Gx=)W=gi<>t=Wr{O~2xeq;PYm{~7)16fX_UOCrMX!trMRLDPsw^yJw))Sqw z8w&p&V6$dne}1<(Vjkc-&F|fs1Eb&9imKZJy89XP=BT@n&J?4}r9rr5Ne=Y!7=XW{ zj1c38bl80%yuGk0B%fjwC}wDJUkm#0=Z(2OQbxG*L&>Xt8oYJxms6kFBSSYp&a7Og zz_Oh@%5*vkRQkhv?OFRKnObR&EZ+z+pEEzWM8FHFe@Y^%009HU*62~oA6hLMcmhmf zs8;rYUN3pno?(5k(99TX^f%f_2%F5D#s6K{O<9H<$*?m=zAzWvW0t zWsi$vpyP$r+Ax~3}MUfBBq?o~f3z)J$Wp*7g>e~O^R|7Vf^ zQR{y`^1rC{{|dkO=$>54cWJ#h)&?kEK-0LO(SjMaQ-NhmMm9vpUK5ZX>O+YZJOoWa3a03rWyXe%$6pCUD< zb0;Z2fkx0B^;qJ`irCA%SWe?F^5ok?h@P+v~ZMtG_6Ym2-bB!K*>#g*X$ zrSp2bQWIFp@!QnO7D6GUeqfw;>ko2~>`LC~CIj!>jw2mQrqb z-gFTL5NkZ>b=(vZGp$K?vB!G z*_Ui7A~NY#~TI3+@HTlaeY>IYf*^R?`rzykqwVfsjFVx~5nDa$Z7o zAt*fA_FsqWk+=G9Nb-&^g92Z&Spn;aiD9(25&U@ccu{ZbVOVsO36kN+uNw@eM}Gn6ANc8coTmMeyL&EXDdTZxP>PNFk0-a{ePc#IYevC@d)QFb7Dz!5VI7VxA^=PLK$7wFfc1`;)+;A4rVx$IHi-%&*QEE z>0vsdJN6r83$(w65DWhnuHST&H}fTaqJ^8J3`yN|j|D)!8Z1%Zohr+OezmjP))TwC z%>odIYeYejtM1wzURv!F`1O_DM&VZ%gp%L#V^QZTXyz%f)S{S=c`V8${^9%z;G(F| z2fKOO??+Z?b1(1_l@Wax_ltpY7evxRUAo9|SWnz^S_HWx=D_g(RROFXlxQ`9x$;(W+<`$h zfIo8-7+*|m`~f>h4PG{#8v;c67_F9$gYAE3cYDt$E*d&&fC|@l{gN@wleyO}tF47l z4m>{mJ5PU0{^cRI7p#_LMV$w@Fl0HBMAHb5p>`AW>>uJKa)cd#oTMlC@GJW#Kk@w` z>c55k8^h9M8w;>EOOEN5J%~`cF@*|nI|Qg-(jh} z92^wH@G5y7!l5@ece^f>MTWh(Kp*n;@pyjrZzz6JJ{Sq`TCm15D1|a%q7Z=fC*xxp z3~Lf`l;BZSU4;ysdt8J%zj%J%8Tiv9;M4L4gX;h%m**W{34^ioJF{2;PWdxyFeLM{ zZn*Am^MMHkF8^uVzlRwDkTLaM`Zom3Z1r@oa5RO*zechze=p8?!wVnAGiX)!85<$L zSP;yo950&!BV{##1;RgM5J0ePEO?mBY$7Shsr4MS|Ug7M+Ai>b^wLiL4S&@?Ed04 zvf$ZH9)|%QhsE33JKdc3pNawMczY>shO2)9f}ngWA>a1zr;#;ZK7wUaj6qct>322$ z7t9uc&r?=l{xA6~R)tG(El9WR9O0mKU?^m77AuS|6UMT-Z+9zPZ52joafR=zJ$@<+ zyRv_it{71AtY|yi;p(7gFDOBSK#+hAt=R^y`y@BOwiz=TPtY68KSZgX_-^;6w*PcU zjn{pLa{&njK4XbLC-5*jPK^r9EfdAIM*^e8$j^9z%||P;c~ww%ppConn~>84KKu_D z5S3}~2Jq$)2cYL_Eb0SmFlgsI;kZ-)Fgox==I6GO=Ao+d;p6VQZd$IyK>z%#&I5~H`F^%xO< z6`23(kt*Ng9&qHuzqedpDSDqH_Hly%rqT}2Xmhs*k7;lepYL7u1mA}M(GU1s;1h(T zd?uGAehCqZ*I&!8p0!cidn%jkyD;|S$7q|Y&mT4|Il187xYz%$ptKkDy4+`@IT4=* z_Ds9n=WmCk{?;(YJJ0#?I&2K7<2Ry$T$IS&Z-i4g0ql`BmU>rD~$lZdfjm_lXG?4 z-2@&1Z4fOKmpBPXA5BSrCq&_Ptg$<+llK4}A=Rfq#wb`8l zP$#}g7^Goy8~z0FQo{$9;rpeRVGN~6I-^tUT7Lq=gYePz%*w{X21*h2XPh&p?!B_z zj0oO@L9y+_O)YJNtKG+5IjSL|%A+?*R40fqLTJ zsYZvP8@82TLK5+G(mr7Xa*eEuztj;}Pq{c^N?PL%eAXc#$)Tyk7V^-0)mRM1p(`AP zehs{=V#DL{S>WYyB5=PN?@iR3v-T$1ZucaXSbqL4kZl*Bf#~_120#u;)lfL)>@V=R z2HwbDg^-p;E|r&{#T3n#%tfXA8K zvkxJ4iGJ}-w`7pDGU`V}3}W$pK=r;w%9TYVK6&LJdi-R66&h1%4*D)+z>6~-6#Rh8 zF^XO=#Ev=x+SDBmPR&w=7_JF2$Q^Yuvp<$gu1qmgC;n-e?Ec4Q8>2nQ0zCNuTZ!z8 z+z)muzS<~^5Gh6hC;cwoYKAQW-qlkUkSzc)3cc&)hS#3s90gLw{s#IZ%h2AOvhk$3 z(+Jn0%NDw*N>O!s%Lp5lBL<#tf|aMOk$6D~Ddfo)SM@OCAOO&AYlOZ+BeKLs_gU;f z-68z~XR{(jP?3{OM5CcZ?>Esf2%QK&`!7t?0}z!HcOTg=CIX#Qwyl?=-X-PvNiyUD zfnF72IU*P+$NIv`zS2YZ_X%i0gr5|RiBbR?D>5!EC?k>h{2Dm`e<(gbX}=oM1~ent zbVj&hUXUG~qN|GXJ+=x6?vjR&+kupnlhy!L>)-e4dUNtEdMsyXTyv)$nrL(gU!%wk z7<){vs)?LBoTBbA4fQT}YUP^PL-*p8A)STuCsrM=W7^xQ&q|PXgMhZX-Qc`3Nzr5p zVxMEG(SIaXg6I}bGEcy-fK65!KRkZoV}uigD6*1l!w-88jKv8e^z7RP1itLbVzs>= zd)Ffv5G*3plK`$G`Jk1&vtz-~=2x$J7HD(Mpl-k(|w+{{TitWO;d5M3aiJCw31==5bEF^ifiDyzn$m7Wzx)b_DJmR`V)Hb-q0dI^Pu0=as>8jYGAxBBw5T ziN>7-_c7+g#N>6`#ox0%2k$BG8*4_n#e>5|uQTc_Xt;4u483|AIg$)*h;Qh5`}5s; zmw^pXx;?_qUUm~9O6U1@@JeT>^i=-gjx1L4$v@}6u?Y1AT%1l?i!y>Lg_O6+P!bb? z`$1#z=>YTe>c*jy*eHY*=-k<9k0OeJzCgGOQ8q3Tp&EybN(C>QsZ#9=Dv;X=9+<6Wac+keSEkTn6%Ub-046gZC!0?(2m-TCEFa@ zVBNtzr*B>9V^zRD)rT4GW~7{mh@~Gt`e~QwZ`=H~Kqg#`C`GEGF~r2yZx?(I2i=9h z#M37ofPN0I8fDC~d}8(uuyv|G6aJ7d_%rpd3{%q!aw0UOOT7vY$~;_aSNKvc1-@7N zoWj`gtwCrd{V(V=4ju#w?x8*joSoJy{x(ny+E{wGqYeNs)XxrmI*lq1U&I{+*R!U_xtsH}U4OLVmK$uKAE^e5xxI9o5HozRdX6f>>WZH|t5+~FdcWexSI#}zx_T-bCncprSZ8K7#;vP$ z+-QQU3A3;HJM;39on5{MxZ{^0fe^^snd1){DmXEJ;jYKei#ZkU5c8{F#oN_|`ued3 zZ1IVZj2sCwI2}D`+--$tsFjT0RMpesN+qwqMqmE+=1rm|Ke6Ll0-eU)*-*{dbhD`Y zwHs)x*r$Eh7Mx{Wjy};m<7p_(xw}m^d>A*aH7HXIIZ9J?P&0@7vbo!VS2j|&`w5#> zodq#dQB{)7(pYdpSI+;=tt;rn0=3M)#{z=?oJ2JUo%o1FkO&y z*-7DkAUaTliJxcN}}3BM4j!y|gmzpo657yvh zjHxYU=8f)}B1b`9-KU;t_^?9@bUNR%Z9GK?W(`y=#f@^m7B=4%g-jB?hq#=$?11nF z(}^6G>4+GzqcP($X*=x!&mz;{s{TApO1E&-#kIr6=~8%~Y)lOeCq!m&o1VP8PHE*> zE|^5aZRaoYCJ9m+)C->EOpyowM(U6%v-HQKE@wZ8IR|t`z41+xr7cnU$T#(x$ALMj z5)mS3c{GBsZ}j}#q%1s0)k#Ful9__dSGMNvQ;5zD#VcIXa0-_mXU0)#dV zN?4EZw%tveq?vi@EEhC4IAH4LHZ{4m(~E#aMQxrp=XbY3hXg@>p^dyTnJs(k@+a=Z z<#I_#HFG>qf2KFmQM0^DK8mDA`~PTank^{=j&>T>1(g8__$#V92Ez|)DP`5Snx%(&@#Op zu(dA?w_NGX9$DmgO}-nhfFpnBUp>VwxGE@c@fW#|!ab3@{#wAM`*cB<*x8~MT*1VN zkumKNda*FgfVp)^)coZ9y@b0maDk_ixN49wN^7eQtWXCp!)1Mi0ejPKQ{CWlnMVdM*yB4SZAMgTQ~sj#wNnGIqWCe*n^1I2r%| literal 0 HcmV?d00001 diff --git a/assets/textures/slice_square_2.png b/assets/textures/slice_square_2.png new file mode 100644 index 0000000000000000000000000000000000000000..b38d6ee6664beadffaf2d57384a4c28693a8f587 GIT binary patch literal 12698 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 TQtTEAfIo{Ddb*r;X2<;(0Wsr= literal 0 HcmV?d00001 diff --git a/crates/bevy_sprite/src/bundle.rs b/crates/bevy_sprite/src/bundle.rs index 7013993482347..bbf45a9761d4f 100644 --- a/crates/bevy_sprite/src/bundle.rs +++ b/crates/bevy_sprite/src/bundle.rs @@ -1,6 +1,6 @@ use crate::{ texture_atlas::{TextureAtlas, TextureAtlasSprite}, - Sprite, + Sprite, SpriteScaleMode, }; use bevy_asset::Handle; use bevy_ecs::bundle::Bundle; @@ -13,6 +13,8 @@ use bevy_transform::components::{GlobalTransform, Transform}; #[derive(Bundle, Clone, Default)] pub struct SpriteBundle { pub sprite: Sprite, + /// Defines if how the sprite behaves along its size + pub scale_mode: SpriteScaleMode, pub transform: Transform, pub global_transform: GlobalTransform, pub texture: Handle, @@ -30,6 +32,8 @@ pub struct SpriteBundle { pub struct SpriteSheetBundle { /// The specific sprite from the texture atlas to be drawn, defaulting to the sprite at index 0. pub sprite: TextureAtlasSprite, + /// Defines if how the sprite behaves along its size + pub scale_mode: SpriteScaleMode, /// A handle to the texture atlas that holds the sprite images pub texture_atlas: Handle, /// Data pertaining to how the sprite is drawn on the screen diff --git a/crates/bevy_sprite/src/lib.rs b/crates/bevy_sprite/src/lib.rs index 9f68a9a3b981e..31f4202de85b7 100644 --- a/crates/bevy_sprite/src/lib.rs +++ b/crates/bevy_sprite/src/lib.rs @@ -7,6 +7,7 @@ mod render; mod sprite; mod texture_atlas; mod texture_atlas_builder; +mod texture_slice; pub mod collide_aabb; @@ -14,8 +15,9 @@ pub mod prelude { #[doc(hidden)] pub use crate::{ bundle::{SpriteBundle, SpriteSheetBundle}, - sprite::Sprite, + sprite::{Sprite, SpriteScaleMode}, texture_atlas::{TextureAtlas, TextureAtlasSprite}, + texture_slice::{BorderRect, SliceScaleMode, TextureSlicer}, ColorMaterial, ColorMesh2dBundle, TextureAtlasBuilder, }; } @@ -27,6 +29,7 @@ pub use render::*; pub use sprite::*; pub use texture_atlas::*; pub use texture_atlas_builder::*; +pub use texture_slice::*; use bevy_app::prelude::*; use bevy_asset::{load_internal_asset, AssetApp, Assets, Handle}; @@ -50,6 +53,7 @@ pub const SPRITE_SHADER_HANDLE: Handle = Handle::weak_from_u128(27633439 #[derive(Debug, Hash, PartialEq, Eq, Clone, SystemSet)] pub enum SpriteSystem { ExtractSprites, + ComputeSlices, } impl Plugin for SpritePlugin { @@ -63,13 +67,22 @@ impl Plugin for SpritePlugin { app.init_asset::() .register_asset_reflect::() .register_type::() + .register_type::() + .register_type::() .register_type::() .register_type::() .register_type::() .add_plugins((Mesh2dRenderPlugin, ColorMaterialPlugin)) .add_systems( PostUpdate, - calculate_bounds_2d.in_set(VisibilitySystems::CalculateBounds), + ( + calculate_bounds_2d.in_set(VisibilitySystems::CalculateBounds), + ( + compute_slices_on_asset_event, + compute_slices_on_sprite_change, + ) + .in_set(SpriteSystem::ComputeSlices), + ), ); if let Ok(render_app) = app.get_sub_app_mut(RenderApp) { diff --git a/crates/bevy_sprite/src/render/mod.rs b/crates/bevy_sprite/src/render/mod.rs index 54048bfd448be..d845ef17ada9c 100644 --- a/crates/bevy_sprite/src/render/mod.rs +++ b/crates/bevy_sprite/src/render/mod.rs @@ -2,7 +2,7 @@ use std::ops::Range; use crate::{ texture_atlas::{TextureAtlas, TextureAtlasSprite}, - Sprite, SPRITE_SHADER_HANDLE, + ComputedTextureSlices, Sprite, SPRITE_SHADER_HANDLE, }; use bevy_asset::{AssetEvent, AssetId, Assets, Handle}; use bevy_core_pipeline::{ @@ -352,6 +352,7 @@ pub fn extract_sprite_events( } pub fn extract_sprites( + mut commands: Commands, mut extracted_sprites: ResMut, texture_atlases: Extract>>, sprite_query: Extract< @@ -361,6 +362,7 @@ pub fn extract_sprites( &Sprite, &GlobalTransform, &Handle, + Option<&ComputedTextureSlices>, )>, >, atlas_query: Extract< @@ -375,26 +377,34 @@ pub fn extract_sprites( ) { extracted_sprites.sprites.clear(); - for (entity, view_visibility, sprite, transform, handle) in sprite_query.iter() { + for (entity, view_visibility, sprite, transform, handle, slices) in sprite_query.iter() { if !view_visibility.get() { continue; } - // PERF: we don't check in this function that the `Image` asset is ready, since it should be in most cases and hashing the handle is expensive - extracted_sprites.sprites.insert( - entity, - ExtractedSprite { - color: sprite.color, - transform: *transform, - rect: sprite.rect, - // Pass the custom size - custom_size: sprite.custom_size, - flip_x: sprite.flip_x, - flip_y: sprite.flip_y, - image_handle_id: handle.id(), - anchor: sprite.anchor.as_vec(), - original_entity: None, - }, - ); + if let Some(slices) = slices { + extracted_sprites.sprites.extend( + slices + .extract_sprites(transform, entity, sprite, handle) + .map(|e| (commands.spawn_empty().id(), e)), + ); + } else { + // PERF: we don't check in this function that the `Image` asset is ready, since it should be in most cases and hashing the handle is expensive + extracted_sprites.sprites.insert( + entity, + ExtractedSprite { + color: sprite.color, + transform: *transform, + rect: sprite.rect, + // Pass the custom size + custom_size: sprite.custom_size, + flip_x: sprite.flip_x, + flip_y: sprite.flip_y, + image_handle_id: handle.id(), + anchor: sprite.anchor.as_vec(), + original_entity: None, + }, + ); + } } for (entity, view_visibility, atlas_sprite, transform, texture_atlas_handle) in atlas_query.iter() diff --git a/crates/bevy_sprite/src/sprite.rs b/crates/bevy_sprite/src/sprite.rs index b87f1859d7307..b8893d466b68d 100644 --- a/crates/bevy_sprite/src/sprite.rs +++ b/crates/bevy_sprite/src/sprite.rs @@ -3,6 +3,8 @@ use bevy_math::{Rect, Vec2}; use bevy_reflect::{std_traits::ReflectDefault, Reflect}; use bevy_render::color::Color; +use crate::TextureSlicer; + #[derive(Component, Debug, Default, Clone, Reflect)] #[reflect(Component, Default)] #[repr(C)] @@ -23,6 +25,26 @@ pub struct Sprite { pub anchor: Anchor, } +#[derive(Component, Debug, Default, Clone, Reflect)] +#[reflect(Component, Default)] +pub enum SpriteScaleMode { + /// The entire texture stretches when its dimensions change. This is the default option. + #[default] + Stretched, + /// The texture will be cut in 9 slices, keeping the texture in proportions on resize + Sliced(TextureSlicer), + /// The texture will be repeated if stretched beyond `stretched_value` + Tiled { + /// Should the image repeat horizontally + tile_x: bool, + /// Should the image repeat vertically + tile_y: bool, + /// The texture will repeat when the ratio between the *drawing dimensions* of texture and the + /// *original texture size* are above this value. + stretch_value: f32, + }, +} + /// How a sprite is positioned relative to its [`Transform`](bevy_transform::components::Transform). /// It defaults to `Anchor::Center`. #[derive(Component, Debug, Clone, Copy, PartialEq, Default, Reflect)] diff --git a/crates/bevy_sprite/src/texture_slice/border_rect.rs b/crates/bevy_sprite/src/texture_slice/border_rect.rs new file mode 100644 index 0000000000000..e32f2891c1579 --- /dev/null +++ b/crates/bevy_sprite/src/texture_slice/border_rect.rs @@ -0,0 +1,59 @@ +use bevy_reflect::Reflect; + +/// Struct defining a [`Sprite`](crate::Sprite) border with padding values +#[derive(Default, Copy, Clone, PartialEq, Debug, Reflect)] +pub struct BorderRect { + /// Pixel padding to the left + pub left: f32, + /// Pixel padding to the right + pub right: f32, + /// Pixel padding to the top + pub top: f32, + /// Pixel padding to the bottom + pub bottom: f32, +} + +impl BorderRect { + /// Creates a new border as a square, with identical pixel padding values on every direction + #[must_use] + #[inline] + pub const fn square(value: f32) -> Self { + Self { + left: value, + right: value, + top: value, + bottom: value, + } + } + + /// Creates a new border as a rectangle, with: + /// - `horizontal` for left and right pixel padding + /// - `vertical` for top and bottom pixel padding + #[must_use] + #[inline] + pub const fn rectangle(horizontal: f32, vertical: f32) -> Self { + Self { + left: horizontal, + right: horizontal, + top: vertical, + bottom: vertical, + } + } +} + +impl From for BorderRect { + fn from(v: f32) -> Self { + Self::square(v) + } +} + +impl From<[f32; 4]> for BorderRect { + fn from([left, right, top, bottom]: [f32; 4]) -> Self { + Self { + left, + right, + top, + bottom, + } + } +} diff --git a/crates/bevy_sprite/src/texture_slice/computed_slices.rs b/crates/bevy_sprite/src/texture_slice/computed_slices.rs new file mode 100644 index 0000000000000..120168cd2f2b8 --- /dev/null +++ b/crates/bevy_sprite/src/texture_slice/computed_slices.rs @@ -0,0 +1,148 @@ +use crate::{ExtractedSprite, Sprite, SpriteScaleMode}; + +use super::TextureSlice; +use bevy_asset::{AssetEvent, Assets, Handle}; +use bevy_ecs::prelude::*; +use bevy_math::{Rect, Vec2}; +use bevy_render::texture::Image; +use bevy_transform::prelude::*; +use bevy_utils::HashSet; + +/// Component storing texture slices for sprite entities with a tiled or sliced [`SpriteDrawMode`] +/// +/// This component is automatically inserted and updated +#[derive(Debug, Clone, Component)] +pub struct ComputedTextureSlices(Vec); + +impl ComputedTextureSlices { + /// Computes [`ExtractedSprite`] iterator from the sprite slices + /// + /// # Arguments + /// + /// * `transform` - the sprite entity global transform + /// * `original_entity` - the sprite entity + /// * `sprite` - The sprite component + /// * `handle` - The sprite texture handle + pub(crate) fn extract_sprites<'a>( + &'a self, + transform: &'a GlobalTransform, + original_entity: Entity, + sprite: &'a Sprite, + handle: &'a Handle, + ) -> impl ExactSizeIterator + 'a { + self.0.iter().map(move |slice| { + let transform = + transform.mul_transform(Transform::from_translation(slice.offset.extend(0.0))); + ExtractedSprite { + original_entity: Some(original_entity), + color: sprite.color, + transform, + rect: Some(slice.texture_rect), + custom_size: Some(slice.draw_size), + flip_x: sprite.flip_x, + flip_y: sprite.flip_y, + image_handle_id: handle.id(), + anchor: sprite.anchor.as_vec(), + } + }) + } +} + +/// Generates sprite slices for a `sprite` given a `scale_mode`. The slices +/// will be computed according to the `image_handle` dimensions or the sprite rect. +/// +/// Returns `None` if either: +/// - The scale mode is [`SpriteScaleMode::Stretched`] +/// - The image asset is not loaded +fn compute_sprite_slices( + sprite: &Sprite, + scale_mode: &SpriteScaleMode, + image_handle: &Handle, + images: &Assets, +) -> Option { + if let SpriteScaleMode::Stretched = scale_mode { + return None; + } + let image_size = images.get(image_handle).map(|i| { + Vec2::new( + i.texture_descriptor.size.width as f32, + i.texture_descriptor.size.height as f32, + ) + })?; + let slices = match scale_mode { + SpriteScaleMode::Stretched => unreachable!(), + SpriteScaleMode::Sliced(slicer) => slicer.compute_slices( + sprite.rect.unwrap_or(Rect { + min: Vec2::ZERO, + max: image_size, + }), + sprite.custom_size, + ), + SpriteScaleMode::Tiled { + tile_x, + tile_y, + stretch_value, + } => { + let slice = TextureSlice { + texture_rect: sprite.rect.unwrap_or(Rect { + min: Vec2::ZERO, + max: image_size, + }), + draw_size: sprite.custom_size.unwrap_or(image_size), + offset: Vec2::ZERO, + }; + slice.tiled(*stretch_value, (*tile_x, *tile_y)) + } + }; + Some(ComputedTextureSlices(slices)) +} + +/// System reacting to added or modified [`Image`] handles, and recompute sprite slices +/// on matching sprite entities +pub(crate) fn compute_slices_on_asset_event( + mut commands: Commands, + mut events: EventReader>, + images: Res>, + sprites: Query<(Entity, &SpriteScaleMode, &Sprite, &Handle)>, +) { + // We store the asset ids of added/modified image assets + let added_handles: HashSet<_> = events + .read() + .filter_map(|e| match e { + AssetEvent::Added { id } | AssetEvent::Modified { id } => Some(*id), + _ => None, + }) + .collect(); + if added_handles.is_empty() { + return; + } + // We recompute the sprite slices for sprite entities with a matching asset handle id + for (entity, scale_mode, sprite, image_handle) in &sprites { + if !added_handles.contains(&image_handle.id()) { + continue; + } + if let Some(slices) = compute_sprite_slices(sprite, scale_mode, image_handle, &images) { + commands.entity(entity).insert(slices); + } + } +} + +/// System reacting to changes on relevant sprite bundle components to compute the sprite slices +pub(crate) fn compute_slices_on_sprite_change( + mut commands: Commands, + images: Res>, + changed_sprites: Query< + (Entity, &SpriteScaleMode, &Sprite, &Handle), + Or<( + Changed, + Changed>, + Changed, + )>, + >, +) { + for (entity, scale_mode, sprite, image_handle) in &changed_sprites { + if let Some(slices) = compute_sprite_slices(sprite, scale_mode, image_handle, &images) { + commands.entity(entity).insert(slices); + } + } +} diff --git a/crates/bevy_sprite/src/texture_slice/mod.rs b/crates/bevy_sprite/src/texture_slice/mod.rs new file mode 100644 index 0000000000000..44adf74185ab6 --- /dev/null +++ b/crates/bevy_sprite/src/texture_slice/mod.rs @@ -0,0 +1,85 @@ +mod border_rect; +mod computed_slices; +mod slicer; + +use bevy_math::{Rect, Vec2}; +pub use border_rect::BorderRect; +pub use slicer::{SliceScaleMode, TextureSlicer}; + +pub(crate) use computed_slices::{ + compute_slices_on_asset_event, compute_slices_on_sprite_change, ComputedTextureSlices, +}; + +#[derive(Debug, Clone)] +pub(crate) struct TextureSlice { + /// texture area to draw + pub texture_rect: Rect, + /// slice draw size + pub draw_size: Vec2, + /// offset of the slice + pub offset: Vec2, +} + +impl TextureSlice { + /// Transforms the given slice in an collection of tiled subdivisions. + /// + /// # Arguments + /// + /// * `stretch_value` - The slice will repeat when the ratio between the *drawing dimensions* of texture and the + /// *original texture size* (rect) are above `stretch_value`. + /// - `tile_x` - should the slice be tiled horizontally + /// - `tile_y` - should the slice be tiled vertically + pub fn tiled(self, stretch_value: f32, (tile_x, tile_y): (bool, bool)) -> Vec { + if !tile_x && !tile_y { + return vec![self]; + } + let stretch_value = stretch_value.max(0.001); + let rect_size = self.texture_rect.size(); + // Each tile expected size + let expected_size = Vec2::new( + if tile_x { + rect_size.x * stretch_value + } else { + self.draw_size.x + }, + if tile_y { + rect_size.y * stretch_value + } else { + self.draw_size.y + }, + ); + let mut slices = Vec::new(); + let base_offset = Vec2::new( + -self.draw_size.x / 2.0, + self.draw_size.y / 2.0, // Start from top + ); + let mut offset = base_offset; + + let mut remaining_columns = self.draw_size.y; + while remaining_columns > 0.0 { + let size_y = expected_size.y.min(remaining_columns); + offset.x = base_offset.x; + offset.y -= size_y / 2.0; + let mut remaining_rows = self.draw_size.x; + while remaining_rows > 0.0 { + let size_x = expected_size.x.min(remaining_rows); + offset.x += size_x / 2.0; + let draw_size = Vec2::new(size_x, size_y); + let delta = draw_size / expected_size; + slices.push(Self { + texture_rect: Rect { + min: self.texture_rect.min, + max: self.texture_rect.min + self.texture_rect.size() * delta, + }, + draw_size, + offset: self.offset + offset, + }); + offset.x += size_x / 2.0; + remaining_rows -= size_x; + } + offset.y -= size_y / 2.0; + remaining_columns -= size_y; + } + slices + } +} diff --git a/crates/bevy_sprite/src/texture_slice/slicer.rs b/crates/bevy_sprite/src/texture_slice/slicer.rs new file mode 100644 index 0000000000000..3ad150a6936b8 --- /dev/null +++ b/crates/bevy_sprite/src/texture_slice/slicer.rs @@ -0,0 +1,263 @@ +use super::{BorderRect, TextureSlice}; +use bevy_math::{vec2, Rect, Vec2}; +use bevy_reflect::Reflect; + +/// Slices a texture using the **9-slicing** technique. This allows to reuse an image at various sizes +/// without needing to prepare multiple assets. The associated texture will be split into nine portions, +/// so that on resize the different portions scale or tile in different ways to keep the texture in proportion. +/// +/// For example, when resizing a 9-sliced texture the corners will remain unscaled while the other +/// sections will be scaled or tiled. +/// +/// See [9-sliced](https://en.wikipedia.org/wiki/9-slice_scaling) textures. +#[derive(Debug, Clone, Reflect)] +pub struct TextureSlicer { + /// The sprite borders, defining the 9 sections of the image + pub border: BorderRect, + /// Defines how the center part of the 9 slices will scale + pub center_scale_mode: SliceScaleMode, + /// Defines how the 4 side parts of the 9 slices will scale + pub sides_scale_mode: SliceScaleMode, + /// Defines the maximum scale of the 4 corner slices (default to `1.0`) + pub max_corner_scale: f32, +} + +/// Defines how a texture slice scales when resized +#[derive(Debug, Copy, Clone, Default, Reflect)] +pub enum SliceScaleMode { + /// The slice will be stretched to fit the area + #[default] + Stretch, + /// The slice will be tiled to fit the area + Tile { + /// The slice will repeat when the ratio between the *drawing dimensions* of texture and the + /// *original texture size* are above `stretch_value`. + /// + /// Example: `1.0` means that a 10 pixel wide image would repeat after 10 screen pixels. + /// `2.0` means it would repeat after 20 screen pixels. + /// + /// Note: The value should be inferior or equal to `1.0` to avoid quality loss. + /// + /// Note: the value will be clamped to `0.001` if lower + stretch_value: f32, + }, +} + +impl TextureSlicer { + /// Computes the 4 corner slices + fn corner_slices(&self, base_rect: Rect, render_size: Vec2) -> [TextureSlice; 4] { + let coef = render_size / base_rect.size(); + let BorderRect { + left, + right, + top, + bottom, + } = self.border; + let min_coef = coef.x.min(coef.y).min(self.max_corner_scale); + [ + // Top Left Corner + TextureSlice { + texture_rect: Rect { + min: base_rect.min, + max: base_rect.min + vec2(left, top), + }, + draw_size: vec2(left, top) * min_coef, + offset: vec2( + -render_size.x + left * min_coef, + render_size.y - top * min_coef, + ) / 2.0, + }, + // Top Right Corner + TextureSlice { + texture_rect: Rect { + min: vec2(base_rect.max.x - right, base_rect.min.y), + max: vec2(base_rect.max.x, top), + }, + draw_size: vec2(right, top) * min_coef, + offset: vec2( + render_size.x - right * min_coef, + render_size.y - top * min_coef, + ) / 2.0, + }, + // Bottom Left + TextureSlice { + texture_rect: Rect { + min: vec2(base_rect.min.x, base_rect.max.y - bottom), + max: vec2(base_rect.min.x + left, base_rect.max.y), + }, + draw_size: vec2(left, bottom) * min_coef, + offset: vec2( + -render_size.x + left * min_coef, + -render_size.y + bottom * min_coef, + ) / 2.0, + }, + // Bottom Right Corner + TextureSlice { + texture_rect: Rect { + min: vec2(base_rect.max.x - right, base_rect.max.y - bottom), + max: base_rect.max, + }, + draw_size: vec2(right, bottom) * min_coef, + offset: vec2( + render_size.x - right * min_coef, + -render_size.y + bottom * min_coef, + ) / 2.0, + }, + ] + } + + /// Computes the 2 horizontal side slices (left and right borders) + fn horizontal_side_slices( + &self, + [tl_corner, tr_corner, bl_corner, br_corner]: &[TextureSlice; 4], + base_rect: Rect, + render_size: Vec2, + ) -> [TextureSlice; 2] { + [ + // left + TextureSlice { + texture_rect: Rect { + min: base_rect.min + vec2(0.0, self.border.top), + max: vec2( + base_rect.min.x + self.border.left, + base_rect.max.y - self.border.bottom, + ), + }, + draw_size: vec2( + bl_corner.draw_size.x, + render_size.y - bl_corner.draw_size.y - tl_corner.draw_size.y, + ), + offset: vec2(-render_size.x + bl_corner.draw_size.x, 0.0) / 2.0, + }, + // right + TextureSlice { + texture_rect: Rect { + min: vec2( + base_rect.max.x - self.border.right, + base_rect.min.y + self.border.bottom, + ), + max: vec2(base_rect.max.x, base_rect.max.y - self.border.top), + }, + draw_size: vec2( + br_corner.draw_size.x, + render_size.y - (br_corner.draw_size.y + tr_corner.draw_size.y), + ), + offset: vec2(render_size.x - br_corner.draw_size.x, 0.0) / 2.0, + }, + ] + } + + /// Computes the 2 vertical side slices (bottom and top borders) + fn vertical_side_slices( + &self, + [tl_corner, tr_corner, bl_corner, br_corner]: &[TextureSlice; 4], + base_rect: Rect, + render_size: Vec2, + ) -> [TextureSlice; 2] { + [ + // Bottom + TextureSlice { + texture_rect: Rect { + min: vec2( + base_rect.min.x + self.border.left, + base_rect.max.y - self.border.bottom, + ), + max: vec2(base_rect.max.x - self.border.right, base_rect.max.y), + }, + draw_size: vec2( + render_size.x - (bl_corner.draw_size.x + br_corner.draw_size.x), + bl_corner.draw_size.y, + ), + offset: vec2(0.0, bl_corner.offset.y), + }, + // Top + TextureSlice { + texture_rect: Rect { + min: base_rect.min + vec2(self.border.left, 0.0), + max: vec2( + base_rect.max.x - self.border.right, + base_rect.min.y + self.border.top, + ), + }, + draw_size: vec2( + render_size.x - (tl_corner.draw_size.x + tr_corner.draw_size.x), + tl_corner.draw_size.y, + ), + offset: vec2(0.0, tl_corner.offset.y), + }, + ] + } + + /// Slices the given `rect` into at least 9 sections. If the center and/or side parts are set to tile, + /// a bigger number of sections will be computed. + /// + /// # Arguments + /// + /// * `rect` - The section of the texture to slice in 9 parts + /// * `render_size` - The optional draw size of the texture. If not set the `rect` size will be used. + pub(crate) fn compute_slices( + &self, + rect: Rect, + render_size: Option, + ) -> Vec { + let render_size = render_size.unwrap_or_else(|| rect.size()); + let mut slices = Vec::with_capacity(9); + // Corners + let corners = self.corner_slices(rect, render_size); + // Sides + let vertical_sides = self.vertical_side_slices(&corners, rect, render_size); + let horizontal_sides = self.horizontal_side_slices(&corners, rect, render_size); + // Center + let center = TextureSlice { + texture_rect: Rect { + min: rect.min + vec2(self.border.left, self.border.bottom), + max: vec2(rect.max.x - self.border.right, rect.max.y - self.border.top), + }, + draw_size: vec2( + render_size.x - (corners[2].draw_size.x + corners[3].draw_size.x), + render_size.y - (corners[2].draw_size.y + corners[0].draw_size.y), + ), + offset: Vec2::ZERO, + }; + + slices.extend(corners); + match self.center_scale_mode { + SliceScaleMode::Stretch => { + slices.push(center); + } + SliceScaleMode::Tile { stretch_value } => { + slices.extend(center.tiled(stretch_value, (true, true))); + } + } + match self.sides_scale_mode { + SliceScaleMode::Stretch => { + slices.extend(horizontal_sides); + slices.extend(vertical_sides); + } + SliceScaleMode::Tile { stretch_value } => { + slices.extend( + horizontal_sides + .into_iter() + .flat_map(|s| s.tiled(stretch_value, (false, true))), + ); + slices.extend( + vertical_sides + .into_iter() + .flat_map(|s| s.tiled(stretch_value, (true, false))), + ); + } + } + slices + } +} + +impl Default for TextureSlicer { + fn default() -> Self { + Self { + border: Default::default(), + center_scale_mode: Default::default(), + sides_scale_mode: Default::default(), + max_corner_scale: 1.0, + } + } +} diff --git a/examples/2d/sprite_slice.rs b/examples/2d/sprite_slice.rs new file mode 100644 index 0000000000000..24bacbaf70666 --- /dev/null +++ b/examples/2d/sprite_slice.rs @@ -0,0 +1,171 @@ +//! Showcases sprite 9 slice scaling +use bevy::prelude::*; + +fn main() { + App::new() + .add_plugins(DefaultPlugins.set(WindowPlugin { + primary_window: Some(Window { + resolution: (1350.0, 700.0).into(), + ..default() + }), + ..default() + })) + .add_systems(Startup, setup) + .run(); +} + +fn spawn_sprites( + commands: &mut Commands, + texture_handle: Handle, + base_pos: Vec3, + slice_border: f32, +) { + // Reference sprite + commands.spawn(SpriteBundle { + transform: Transform::from_translation(base_pos), + texture: texture_handle.clone(), + sprite: Sprite { + custom_size: Some(Vec2::splat(100.0)), + ..default() + }, + ..default() + }); + + // Scaled regular sprite + commands.spawn(SpriteBundle { + transform: Transform::from_translation(base_pos + Vec3::X * 150.0), + texture: texture_handle.clone(), + sprite: Sprite { + custom_size: Some(Vec2::new(100.0, 200.0)), + ..default() + }, + ..default() + }); + + // Stretched Scaled sliced sprite + commands.spawn(SpriteBundle { + transform: Transform::from_translation(base_pos + Vec3::X * 300.0), + texture: texture_handle.clone(), + sprite: Sprite { + custom_size: Some(Vec2::new(100.0, 200.0)), + ..default() + }, + scale_mode: SpriteScaleMode::Sliced(TextureSlicer { + border: BorderRect::square(slice_border), + center_scale_mode: SliceScaleMode::Stretch, + ..default() + }), + ..default() + }); + + // Scaled sliced sprite + commands.spawn(SpriteBundle { + transform: Transform::from_translation(base_pos + Vec3::X * 450.0), + texture: texture_handle.clone(), + sprite: Sprite { + custom_size: Some(Vec2::new(100.0, 200.0)), + ..default() + }, + scale_mode: SpriteScaleMode::Sliced(TextureSlicer { + border: BorderRect::square(slice_border), + center_scale_mode: SliceScaleMode::Tile { stretch_value: 0.5 }, + sides_scale_mode: SliceScaleMode::Tile { stretch_value: 0.2 }, + ..default() + }), + ..default() + }); + + // Scaled sliced sprite horizontally + commands.spawn(SpriteBundle { + transform: Transform::from_translation(base_pos + Vec3::X * 700.0), + texture: texture_handle.clone(), + sprite: Sprite { + custom_size: Some(Vec2::new(300.0, 200.0)), + ..default() + }, + scale_mode: SpriteScaleMode::Sliced(TextureSlicer { + border: BorderRect::square(slice_border), + center_scale_mode: SliceScaleMode::Tile { stretch_value: 0.2 }, + sides_scale_mode: SliceScaleMode::Tile { stretch_value: 0.3 }, + ..default() + }), + ..default() + }); + + // Scaled sliced sprite horizontally with max scale + commands.spawn(SpriteBundle { + transform: Transform::from_translation(base_pos + Vec3::X * 1050.0), + texture: texture_handle, + sprite: Sprite { + custom_size: Some(Vec2::new(300.0, 200.0)), + ..default() + }, + scale_mode: SpriteScaleMode::Sliced(TextureSlicer { + border: BorderRect::square(slice_border), + center_scale_mode: SliceScaleMode::Tile { stretch_value: 0.1 }, + sides_scale_mode: SliceScaleMode::Tile { stretch_value: 0.2 }, + max_corner_scale: 0.2, + }), + ..default() + }); +} + +fn setup(mut commands: Commands, asset_server: Res) { + commands.spawn(Camera2dBundle::default()); + // Load textures + let handle_1 = asset_server.load("textures/slice_square.png"); + let handle_2 = asset_server.load("textures/slice_square_2.png"); + + spawn_sprites( + &mut commands, + handle_1, + Vec3::new(-600.0, 200.0, 0.0), + 200.0, + ); + spawn_sprites( + &mut commands, + handle_2, + Vec3::new(-600.0, -200.0, 0.0), + 80.0, + ); + + let font = asset_server.load("fonts/FiraSans-Bold.ttf"); + let style = TextStyle { + font: font.clone(), + font_size: 30.0, + color: Color::WHITE, + }; + let alignment = TextAlignment::Center; + // Spawn text + commands.spawn(Text2dBundle { + text: Text::from_section("Original texture", style.clone()).with_alignment(alignment), + transform: Transform::from_xyz(-550.0, 0.0, 0.0), + ..default() + }); + commands.spawn(Text2dBundle { + text: Text::from_section("Stretched texture", style.clone()).with_alignment(alignment), + transform: Transform::from_xyz(-400.0, 0.0, 0.0), + ..default() + }); + commands.spawn(Text2dBundle { + text: Text::from_section("Stretched and sliced", style.clone()).with_alignment(alignment), + transform: Transform::from_xyz(-250.0, 0.0, 0.0), + ..default() + }); + commands.spawn(Text2dBundle { + text: Text::from_section("Sliced and Tiled", style.clone()).with_alignment(alignment), + transform: Transform::from_xyz(-100.0, 0.0, 0.0), + ..default() + }); + commands.spawn(Text2dBundle { + text: Text::from_section("Sliced and Tiled", style.clone()).with_alignment(alignment), + transform: Transform::from_xyz(150.0, 0.0, 0.0), + ..default() + }); + commands.spawn(Text2dBundle { + text: Text::from_section("Sliced and Tiled with corner constraint", style.clone()) + .with_alignment(alignment), + transform: Transform::from_xyz(550.0, 0.0, 0.0), + ..default() + }); +} diff --git a/examples/2d/sprite_tile.rs b/examples/2d/sprite_tile.rs new file mode 100644 index 0000000000000..bbaa7bb06480e --- /dev/null +++ b/examples/2d/sprite_tile.rs @@ -0,0 +1,48 @@ +//! Displays a single [`Sprite`] tiled in a grid, with a scaling animation + +use bevy::prelude::*; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_systems(Startup, setup) + .add_systems(Update, animate) + .run(); +} + +#[derive(Resource)] +struct AnimationState { + min: f32, + max: f32, + current: f32, + speed: f32, +} + +fn setup(mut commands: Commands, asset_server: Res) { + commands.spawn(Camera2dBundle::default()); + commands.insert_resource(AnimationState { + min: 128.0, + max: 512.0, + current: 128.0, + speed: 50.0, + }); + commands.spawn(SpriteBundle { + texture: asset_server.load("branding/icon.png"), + scale_mode: SpriteScaleMode::Tiled { + tile_x: true, + tile_y: true, + stretch_value: 0.5, // The image will tile every 128px + }, + ..default() + }); +} + +fn animate(mut sprites: Query<&mut Sprite>, mut state: ResMut, time: Res