From 01139b3472d3c52c25dbf35df6d6e9b1ff5f5787 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Lescaudey=20de=20Maneville?= Date: Mon, 15 Jan 2024 16:40:06 +0100 Subject: [PATCH] Sprite slicing and tiling (#10588) > Replaces #5213 # Objective Implement sprite tiling and [9 slice scaling](https://en.wikipedia.org/wiki/9-slice_scaling) for `bevy_sprite`. Allowing slice scaling and texture tiling. Basic scaling vs 9 slice scaling: ![Traditional_scaling_vs_9-slice_scaling](https://user-images.githubusercontent.com/26703856/177335801-27f6fa27-c569-4ce6-b0e6-4f54e8f4e80a.svg) Slicing example: Screenshot 2022-07-05 at 15 05 49 Tiling example: Screenshot 2023-11-16 at 13 53 32 # Solution - `SpriteBundlue` now has a `scale_mode` component storing a `SpriteScaleMode` enum with three variants: - `Stretched` (default) - `Tiled` to have sprites tile horizontally and/or vertically - `Sliced` allowing 9 slicing the texture and optionally tile some sections with a `Textureslicer`. - `bevy_sprite` has two extra systems to compute a `ComputedTextureSlices` if necessary,: - One system react to changes on `Sprite`, `Handle` or `SpriteScaleMode` - The other listens to `AssetEvent` to compute slices on sprites when the texture is ready or changed - I updated the `bevy_sprite` extraction stage to extract potentially multiple textures instead of one, depending on the presence of `ComputedTextureSlices` - I added two examples showcasing the slicing and tiling feature. The addition of `ComputedTextureSlices` as a cache is to avoid querying the image data, to retrieve its dimensions, every frame in a extract or prepare stage. Also it reacts to changes so we can have stuff like this (tiling example): https://github.com/bevyengine/bevy/assets/26703856/a349a9f3-33c3-471f-8ef4-a0e5dfce3b01 # Related - [ ] Once #5103 or #10099 is merged I can enable tiling and slicing for texture sheets as ui # To discuss There is an other option, to consider slice/tiling as part of the asset, using the new asset preprocessing but I have no clue on how to do it. Also, instead of retrieving the Image dimensions, we could use the same system as the sprite sheet and have the user give the image dimensions directly (grid). But I think it's less user friendly --------- Co-authored-by: Alice Cecile Co-authored-by: ickshonpe Co-authored-by: Alice Cecile --- 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 | 23 ++ .../src/texture_slice/border_rect.rs | 59 ++++ .../src/texture_slice/computed_slices.rs | 150 ++++++++++ crates/bevy_sprite/src/texture_slice/mod.rs | 86 ++++++ .../bevy_sprite/src/texture_slice/slicer.rs | 267 ++++++++++++++++++ examples/2d/sprite_slice.rs | 144 ++++++++++ examples/2d/sprite_tile.rs | 48 ++++ examples/README.md | 2 + 14 files changed, 847 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 d5b1fa1cb7..c10d0c7acf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -455,6 +455,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 b2f6394811..16aa7f2b58 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, + ImageScaleMode, Sprite, }; use bevy_asset::Handle; use bevy_ecs::bundle::Bundle; @@ -15,6 +15,8 @@ use bevy_transform::components::{GlobalTransform, Transform}; pub struct SpriteBundle { /// Specifies the rendering properties of the sprite, such as color tint and flip. pub sprite: Sprite, + /// Controls how the image is altered when scaled. + pub scale_mode: ImageScaleMode, /// The local transform of the sprite, relative to its parent. pub transform: Transform, /// The absolute transform of the sprite. This should generally not be written to directly. @@ -35,6 +37,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, + /// Controls how the image is altered when scaled. + pub scale_mode: ImageScaleMode, /// 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 e64fb9808d..babef34c3f 100644 --- a/crates/bevy_sprite/src/lib.rs +++ b/crates/bevy_sprite/src/lib.rs @@ -6,6 +6,7 @@ mod render; mod sprite; mod texture_atlas; mod texture_atlas_builder; +mod texture_slice; pub mod collide_aabb; @@ -13,8 +14,9 @@ pub mod prelude { #[doc(hidden)] pub use crate::{ bundle::{SpriteBundle, SpriteSheetBundle}, - sprite::Sprite, + sprite::{ImageScaleMode, Sprite}, texture_atlas::{TextureAtlas, TextureAtlasSprite}, + texture_slice::{BorderRect, SliceScaleMode, TextureSlicer}, ColorMaterial, ColorMesh2dBundle, TextureAtlasBuilder, }; } @@ -26,6 +28,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}; @@ -51,6 +54,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 { @@ -64,13 +68,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 113cef88e9..a5d89ce0c8 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::{ @@ -333,6 +333,7 @@ pub fn extract_sprite_events( } pub fn extract_sprites( + mut commands: Commands, mut extracted_sprites: ResMut, texture_atlases: Extract>>, sprite_query: Extract< @@ -342,6 +343,7 @@ pub fn extract_sprites( &Sprite, &GlobalTransform, &Handle, + Option<&ComputedTextureSlices>, )>, >, atlas_query: Extract< @@ -356,26 +358,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 039f5d722a..5c60d759a5 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; + /// Specifies the rendering properties of a sprite. /// /// This is commonly used as a component within [`SpriteBundle`](crate::bundle::SpriteBundle). @@ -26,6 +28,27 @@ pub struct Sprite { pub anchor: Anchor, } +/// Controls how the image is altered when scaled. +#[derive(Component, Debug, Default, Clone, Reflect)] +#[reflect(Component, Default)] +pub enum ImageScaleMode { + /// 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 0000000000..e32f2891c1 --- /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 0000000000..dd316f7d3c --- /dev/null +++ b/crates/bevy_sprite/src/texture_slice/computed_slices.rs @@ -0,0 +1,150 @@ +use crate::{ExtractedSprite, ImageScaleMode, Sprite}; + +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 [`ImageScaleMode`] +/// +/// 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 + #[must_use] + 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 [`ImageScaleMode::Stretched`] +/// - The image asset is not loaded +#[must_use] +fn compute_sprite_slices( + sprite: &Sprite, + scale_mode: &ImageScaleMode, + image_handle: &Handle, + images: &Assets, +) -> Option { + if let ImageScaleMode::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 { + ImageScaleMode::Stretched => unreachable!(), + ImageScaleMode::Sliced(slicer) => slicer.compute_slices( + sprite.rect.unwrap_or(Rect { + min: Vec2::ZERO, + max: image_size, + }), + sprite.custom_size, + ), + ImageScaleMode::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, &ImageScaleMode, &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, &ImageScaleMode, &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 0000000000..d16e6654ec --- /dev/null +++ b/crates/bevy_sprite/src/texture_slice/mod.rs @@ -0,0 +1,86 @@ +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 + #[must_use] + 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 0000000000..b302d2e356 --- /dev/null +++ b/crates/bevy_sprite/src/texture_slice/slicer.rs @@ -0,0 +1,267 @@ +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 + #[must_use] + 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) + #[must_use] + 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) + #[must_use] + 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. + #[must_use] + 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 0000000000..cf6d44bed3 --- /dev/null +++ b/examples/2d/sprite_slice.rs @@ -0,0 +1,144 @@ +//! 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, + mut position: Vec3, + slice_border: f32, + style: TextStyle, + gap: f32, +) { + let cases = [ + // Reference sprite + ( + "Original texture", + style.clone(), + Vec2::splat(100.0), + ImageScaleMode::default(), + ), + // Scaled regular sprite + ( + "Stretched texture", + style.clone(), + Vec2::new(100.0, 200.0), + ImageScaleMode::default(), + ), + // Stretched Scaled sliced sprite + ( + "Stretched and sliced", + style.clone(), + Vec2::new(100.0, 200.0), + ImageScaleMode::Sliced(TextureSlicer { + border: BorderRect::square(slice_border), + center_scale_mode: SliceScaleMode::Stretch, + ..default() + }), + ), + // Scaled sliced sprite + ( + "Sliced and Tiled", + style.clone(), + Vec2::new(100.0, 200.0), + ImageScaleMode::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() + }), + ), + // Scaled sliced sprite horizontally + ( + "Sliced and Tiled", + style.clone(), + Vec2::new(300.0, 200.0), + ImageScaleMode::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() + }), + ), + // Scaled sliced sprite horizontally with max scale + ( + "Sliced and Tiled with corner constraint", + style, + Vec2::new(300.0, 200.0), + ImageScaleMode::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, + }), + ), + ]; + + for (label, text_style, size, scale_mode) in cases { + position.x += 0.5 * size.x; + commands + .spawn(SpriteBundle { + transform: Transform::from_translation(position), + texture: texture_handle.clone(), + sprite: Sprite { + custom_size: Some(size), + ..default() + }, + scale_mode, + ..default() + }) + .with_children(|builder| { + builder.spawn(Text2dBundle { + text: Text::from_section(label, text_style).with_justify(JustifyText::Center), + transform: Transform::from_xyz(0., -0.5 * size.y - 10., 0.0), + text_anchor: bevy::sprite::Anchor::TopCenter, + ..default() + }); + }); + position.x += 0.5 * size.x + gap; + } +} + +fn setup(mut commands: Commands, asset_server: Res) { + commands.spawn(Camera2dBundle::default()); + let font = asset_server.load("fonts/FiraSans-Bold.ttf"); + let style = TextStyle { + font: font.clone(), + font_size: 16.0, + color: Color::WHITE, + }; + + // 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, + style.clone(), + 50., + ); + + spawn_sprites( + &mut commands, + handle_2, + Vec3::new(-600.0, -200.0, 0.0), + 80.0, + style, + 50., + ); +} diff --git a/examples/2d/sprite_tile.rs b/examples/2d/sprite_tile.rs new file mode 100644 index 0000000000..016517505e --- /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: ImageScaleMode::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