From a441be8da6c4b23a16a56901f84367a125e9c7d9 Mon Sep 17 00:00:00 2001 From: Johnathon Selstad Date: Tue, 30 Jun 2026 04:05:47 -0700 Subject: [PATCH] webgl_postprocessing_pixel: Snap Objects to Pixel Grid (#33908) Co-authored-by: Claude Opus 4.8 (1M context) --- .../webgl_postprocessing_pixel.jpg | Bin 16884 -> 16125 bytes examples/webgl_postprocessing_pixel.html | 154 +++++++++++++++++- 2 files changed, 145 insertions(+), 9 deletions(-) diff --git a/examples/screenshots/webgl_postprocessing_pixel.jpg b/examples/screenshots/webgl_postprocessing_pixel.jpg index 6787a0f3ff817fb2ccc346dff6b8fbece2be3a6a..eb014505323c08fc0cc90fd2eb4b2bdc6cad2a08 100644 GIT binary patch literal 16125 zcmeIZcUV(hw=Wu`N)@EHC`Cki??gpFKtxb_iAwL%I}wpyq7;!X0@6Xc)JO-Z(wjo) zorD@7BzO6~-#z!+_w47}d!K#&x%WAXCo3dtu9Z3FoTL1HV@&)sei3v{Q%yq+L_h!n z5deQ6{2b^l=nBD~(bcn1tl77wJ_p64I-rBqU@MWLL@m9Ke5+ z6y%hDKKy;#6=Fg{VscUv(!VbMmrnTaAX+leIl((Z0$R`&S^`2^0(>XIXP}S7e{~J$ z=-&sy6+$9n5>lWG6xV=m6A}WQB?fv5v28YMBe|~of|SdRUOeP|4jK3T z$~FeQpC}%Qm+o(_k~1 zBM-q9AK*nuOGJEAjD+rs;a?Wc2sn=Tx>`<(AMxF}!sDNzTY4iR4B95$P|9 z{&NDo`CpRge+cwHydBYk9B^;hUjMh)Lo2vw8oG*Q?hz#f86VBCK!-KkpX9$;|ZE#XZ99Fm7 z?&++$8~ZHjP@mjAu&c=xV$MQJGLz#&wNlUP5dD5v8Y;dQmD z(c==L$Omtootj;TXdp63i(19vnxaFE;M~)F6?)5p-%on*XppdgsNgd$n4fbZtl=tC4s0f znW@J=lWZAyt8eB#c|vfP7(@^Huf7rFAcx6)3%Z$xzxZl(n1mO3u&Wu?kCp!Rc}8@l z>j@o+v6{aX+$8n!HN(WOoJf?&2?!H{4rn*zRBWd_L*7^h4U4XBx;Z3=n5h zUv-y-5=564n_+}O47_ZMO<0l@)gxrr$ zzF!e?{2cV1@KFjJ>-}YeMjaD?Qs^}J;GM-4(LL4pY>YS{owvq!j-a{{{_*POyw9fI zG>2_0j@`GQb9T-lO{ephisswyVj-c9T_P`tV6oh}-ZxH;G7Mk0yw#MU431-bK(x18 ztFP2b|3!AIQLM+5+`i!}0eb{RE&Q4`@u)B7nvqK$^r$UgqKIhOd-vOopa9)h3iOwu zt>Agz3LKrY-;uLMAY5?H!q_YGKw+AR6YUWBLB$9t`}qYh4xIJJTp%X2Pst9GHtGBF0v} zF)0{zS=_JY;?r}=Zy(Qc1rC#X*iS2RzrWy(PDt=JF`nJbqV`Dt`F+IAAkjzLV$l8& z@(e4KyA2_`VNTkC<8O@3vAu3wZF;$vOWt#K2z_33ar;2V)*UXj&ZC1P0o-Dm8V7FTq-$=i%_qnQdYUF}#Ddl?wVW8m%7W6sWC8iRniM^&d4q_J>^4Mv7BR{-oNpBCljq=*;ts8;?6;Qd*vUrPI{E@7_tmzLpYXkVZVfZ>MCxtR%+ti5ua(h=xD0^{SZr5p5|*jXl|6k z1Baxav|!%n%^2G&Q=)|iGxMuozf94tb^QhNWHgZ5UO)p)9~SIypT6fmQQ}7W>>4kD znJ^)O<;Pk*QJ+03cZ84Q8@yWBgr$V}pmY$pS@?@Q6lOj3Qya z`isJ?b6@kvOQxyNA+rpsa%02ny$~ox>Qs^9qoTfztcV%wac*`&2News2iIZ70;IW) zl@HmitEbCs;WQWR&6g}AXGOi(R={BF1%gIfiLoH`gEZ&l8lfdH#r(pMbNO0IKzdl8 ztHfTGzX(Hd_p>g9-$Kgc?3_6jL1S(M3WJ}|5v=kpXJUOcOeGrz3{<< zp8G+Lob)rHZ=-gC;*2wT7|>a3S_0bQ2RA5G16ou(c_<$Nya`*@@yO>YEQSIJ1fD&IJXZefF; z48v^G=QQn5B1t?bOy;@+qn<@&(>=DJV&C~!9^G9GKYsPIp#_lC$!J9T{-XBFfZoLf ztBcXXB82!=0O`TM7&5bj*B|K;+b2&Twd|vvi9h2JVlO5h~<3g!*;ol+r z9F?;zjqY(qzgaT|zwP<8<`AGJc0LGX*Kj+h$x(abxT6C|J8gfNYxsy{-T*Cwg$Ol! z#v#=!m2GH>4hhX^{WS5%km>xGDGCu4Sr{$nc7C5NYIw=Jep!V6k+XBN%AKyf9~}eN z``+E1eO(n~dZo1~?2wY@>-*37`>FyZ%D4{@nWcdycC>IwMB~cNUX%a)X7jyr$ERwZ zWqPB`Ln_x&hwBw0D+)65tCf{AZnSKq;HY4Ix<6stM=)+W=aqJm`J+KCTfD z(p*aEw5JK2XHJS3`b6T!GR6DObawz+>Zh*&QLb`rlN=v{B598o*nC( zwjjQYrb!LX-w*VdX%p{ID;!s(^dQB2DZOsL$Q^ua(pOjBA_YxUKIADel&|BXZ?M+L z=QK~NH(fYXM>2S28~xVog$I$<#|TT&RQg>Ut;20o>sb;cG8WGbq!t9P zCakGXWl^Jlr9^x7Nl|=UsyM4k{!JYH+`y2%~U+g=ur=KM|QWE-Kp&9^BGe!ex>f= zK~#8|R^M&m>Bhp_ z-&~a#IBG_j+hjw`KK|6z^~7#kj_t8T^PS_PF5hp30pzVir}8(2edo`rmwUS8a9o%; zZgZiX6rDro8P%D0S`iSLUuK@0g8{zs)L}~dL6030*KB{_LDvu~p5w4>z%$%5>0i17 zxf-XZd!+Lko>{mlE$Wy(bNJ(ZWVxPpd4#KOFQm-eSWPnBVU1r#h??eP1~awXB7M=G zMfDY3UO{tW7{y>c{$kP^S|iMqK(Gk=9%q^K%&lAV$*HSzi)PnIzA5>{!y;p)iF5E% zEFq@MdO3(ugvwFA(@?x7q1S6J^Wmdu4(@&rJP01la2`fpS;QuD2x00$THg=sGyO4% zYh9iRW-ywW!|II;r5CIEXFIa3gc99sTU<;V&C`pIU(Z_Qw^Z`}#=nX~8zvukSB4 zVpi7Y_Hhg`HJ08(%~C9rMFG$sQ;=)QLcUX7!P4l-f}o`e#x40K^}=}yzgmhjVe*{h zc={%G!l~%9wCbd_y}jVUH#utIoOft-8x5=KRZ>fbzW%QuW=Y8kDu=YeCUavhdc!+D z%5vW!lhv6I@6CkR;;!jjgAL0f_IGvazmQbC4U{Ujn@#*>@tMeuJgy?kz2MwHzxcje z72#wFw?r-KfynYNc^zs;kfe!M-S$nESrztp9sh3^KRUI3`G_3@p;|Bx>UWlx(qq{N z_u3N@R#uMkmPW=C<<>yPR2?eXAn71UK*UrDa^kF@Hkw-gls_0>YimwE0D%jyenz-69 z`{LIG?Uq->wnB4KF+VxhL-8Q%miwpb#$FeaGRl^Os}_rv^$y4nmTwF4Do#1HNF%c< zD^q|VAG2(XE-SAH{Lnr1piwlToRElxLm}-)t8$o$i$GzJNj_gu8*b8qcna&~M)f6x z{SgY<)VJ1O|K_!B%48UejBuOMV_T$B!L9^SfCm$_7?Mjik>zEYQ0ndn5vy=eRMDc~ ze1HcL4wt=qGH@rrxBzDA1o-D66TYr&GMJ4+HqD2wm!0C{+b6Y?)tE)0mwBW(wvEo<-1pg1yDb$8^M&TQ*qL zC12k=K#1b>N4~*nV&S|%S7$mV%!0 zRO3P4b8z&3H3T3Faf$Dfrwj%^f4o0d^p>Qv3JeAom zW0YlmOCGV-C0c&@sFh5jeV4y?yPF?o_o4~$O|Tz$LK~z8d`Wmzfn}+{=w9Z8@8K4! zX)w>TXK8RD=Y}}SDeP_BNQV#X70?+`on2Bq=$22XB*yq^DkL2z$1ogmgkt?13M(Xfoy&(m&b!TE#3rh#+?Mf&K9#zdrNmB`M3S0 zeizt%wKpKqAPPyf)Xn_H4JpiT5MwX&pT%Y7s8m|X?Z?+MI}IwG3L>liSpOd=b??2eu9#fHEpG&*6r|jJ7z1Drqh&#Kd|vmS3&s`^ts=zO!8= zZ&%P8n79n4+fMsXRBF6n&ov~sZ^Ky4ozWaH#2(CC9^LRJmi~opPAxl=#M+05uYtFI z9(_|J$k&{_);#E-*f-1jdz~-AEzyF(q1INnsd~CVGvj%3{AUg4GVb3yg$+lg^tLkA zMI{;h-8%;@d}^5FTyrR_oqO@Bmz|4nng(yTG8Pa*((b)r4zSX6-?3pn4sgE=?E5Pu<*ChUqUqv2dOFXNkTED><0_BKeIAxEA z_v||rz9dWCTOA9V7G}8?P^*1?d){2-L<|8cp!0-D*AaKCWxSNN+f&p;AuvUtzG$sE z?U-RFzR}Iqzzj2k%I?-FtdDGFD{`nGh0a%I{op5i$mHtc|LkcIoAJ>d-GJO)sfxEa zi777_=0V=i^5uy~FQG#8EZ)@q>jlf^v?a{KR!0)A)<%0gPyvhC@mO$yg~?VHL(@0= z0t17lI1P}n?0UXEW&ezjlz7(FXFmQM=Ii0*o?gaM`&2y+@osF20*7GD?T)o4CAYqe z%9T8~*5>1QIHoh2XWnP&qFxvY3-iBL#QJ_MrcpMZl+>Pk6?)BR2Igvk2jz4Dw$~lj zFLt?XY93j1!X|Qd7=ycv7XGf>VT}hVyTKMJRi{a(oe=9oA{QsOn?M&-DcCdMuHY`F z=K=dWhhH`aQNOt+uO*%|_;)I)*FH-B#xZ5LJ2<sb%E{&Eh5gDoDutp zCI_AUX==1JHJBF_TE!oecWdPyh~A^=UpdUkI4Q!gKv@;YTz6c+{q-$jb6_tXB3!Z$ zS5GDu_>p&I5tjk=xG>{kkB3gLYEyHL)OSv8rWQy)*Bg>+V+(j-H3FRjwtWV;2z|1{ z5_)CPj)Jx#W8RQR9I|;>sQS>h#V^U!zIrUWs&WfP$)k!1%4d|lGGdiApB7c&`&*_G zj;`;5%;1O<*6&VnYlV0)lUz-zlNj_~{Thqb(D1aqB@f*>_fg0u!;OKdB!$RlXGOw& zIPL4Qytll^@`~?iZa*DP)JGG^Bd|QxzXwn7Ad&tHV`k^p0N&fs| zStGmjmB0tlTIC&lu0HBb2`Vobq+Wp=P<1aF+m$2AMe&G7svIJnhXBJyVx71>Q) zDi75fa<-p38YXn*{t~Yp4>NY2enY{j&ajcurGrsF-ao%$0b7)Sg@M2*`1ZmPQ8VEF zY=G4cf*`?Dh>fd$0HZvJqX)s-Ar-I@xY%;6YbO z0LON(34*-}MSmWkiN%8!i_<=21}sL>4dGaHu)K^Bw3^JzJ5LYWDxM19B#th1#iub2 zE$#RJr7u$kbo-69@AVFx7)t2Jw(gJbn6y=z6ye{*8I%x{1F_=*%|^Ff+>744TIE=D z!Oo>O&s)r`8E==wp$pY17wKh4oUJ-%?Mb-a=W|XUAO<9IFd^>P^gpuWM}!{}6m7=7c@lQdtGBIm z%sx#rO(wW!StlBS7VWCOOgs&`@3oFD*necPL+ztKCR`JA-(39Y_6Md}THuJN?DGPYJ}1)P3Pu@L4!zo|g6LXAZj$@~LBp;hUPdd9i*e(v$p zm8l@bhP+?Uwv5E?-@gae_v4*0#|(0?iyudp-T6Dz=RG*Z96YF83tO@=*%NuH7|>ns zVH9|;xwZa=2Jy!Gt1K+c+-HVL5Ar<+7k3hsN=_UvKRE^3qLqcNFYoq)X{3^dZ;bAG z_piN{cBgjBSZfwEc-#WN5I=36+0g5eg8beI+D0{XDHCDZ@>+V@Wf}ssMe|#F=H7L; z)vdZ^HB;9fVCFfw33y6ejz^Ba9&avj&C7GV#E4Hgf2l>AIj@*Ku;hG@aUkj+U`XEN z&(@^wu?CJ;W<{NL=x}NvVMIJd&+96COWarIJP5P2eMVz4T!n^=`#)H0sw^gzE-=*$@JhsXSWmZps76QkJfYo-2S0r ztRY&*7lj95Upv9JheR%k47KLhghu{KRuC+LYt*qxsedl4I81(jHU9mtRGTvFvI?<6 zhd5yZ*1yK@7QuSV@YwN#e#`a7lc;~4vb?CkT~)z@xTJb6;|yWvs>Hs21?ZH5tsn6{ zvr?-`%3YrQj$fJ@e>%Iw+#2NzGabuPuhq*Kt$JCdxZMeWp!x_wNs5wT3QXgn?l^ah zblrV=5?kD^|Fc6<{(PPUQBS8DWw*;OI1bEQn?H4`*HQ)5{B)1fT%L~nYnC@FA%orl z4qlQm4YaYj4a=U4{)XR&&xjUaQvf>OkSdiH&sw*=m0qB^rr~tMhVO%22;Wgj71z!= zg?i(tCwyAc3H2*BUhxskcSLv(I>oR!)vng+?7f?_pnm<1Yyj^#s;FXbU66I@ZmiDU zX$HY&^hvH}23?ixs-C-lb!W3P9>ja)5C=?CX)#t@s$c}xWxp2MPQ!Cu9IT{l<-}Qw zT&yYg|Cnh{v51KBuL!GItG|$KJoK*HTy-iZ5fxa7O;b-;`Z#E)f`&%z*`PJ;=pxHK zgjZJY_CQC&)5ouC-Ct;1IODjEYstgi&rrUpgH$?1)-{ydXWg!@9X(EOvRnN|@^E*L zg)oEj6Q^T}_*>r4bbIOdzV;dh8WMZ1;Y@};5SpVR;qtuX!9u}NxtEW1%%;|l>DIiJ zT<1ImM1jB zL;DAvSv{S$gErcB84rq5=CTv8KH(i}9pDK%@e!t2bqn3q%)H%}v?UFv9XT>UbEb*x zAG4(0U)7SEf2B0&qt0+gc}q=%y0~fn0!Sg5x@0j_XU*d5*^N5&rKumcb$O`Eq!-!J zzjyCn%Jm$WgA-vs@H4CD4)wOM5$5~T_8?6r9oE^uCXbB@4mQ>_RHgJbzRg`$Flo$@ z9XE_=lv`y{oq0b&cAM}!#14MOFAiu&2DEpg>@~9J^Z1jaDc>ItlU4wHxnsf4Xx9zoh;Zh&dJ!BLY3r6}JawE1KG zP-=*#;Ol~=eO3oNh|W2u5|bJrTuWS?D)h4PM&x`fBXf_-on<){r1y1=)JD>s3*>NY z<42U?+vk-(MR-)i7aoTt-ElVrotEW9W$^kbd^Xdsk#Yez0X&E?knBaCR zsnhX`QAtZDVF@|*{K1DR+{%ik9!aS+*<9EFluG#O@ZaIr)^@ARSNgD zrv@;ONpCDOh_8j#{%8zHgRoeJ&ml=_9j_2SJld@++yLDmcuKtgMJ|x9@4(1GJ}kN2 z$XHwMA@kR8u4!haR|4hdAG69XcmoAdE)A$)Hav*I_dZMcAa=`QO`58P7Osix8qaVv zmZ*C3m`RT6>C%2@i-dmTSZSc8A3fu#2)md7C6~Z8(DJ7%MNhv>%z`B&Bf7Jr!(Iit z2E36wi@@ma*q?wUo041fv!ur?$1Bo&;>uk}jEb`WRwMmuJRHZs*Wr48eFqQPPsB-_?<>B8^SJrutmmMTJ&F4{)+w{?E|kU&dXFV)A*5)|Gh=af1OT8Gcz>dp(bKr6vMOQjZn2 z+D=A)ZF4c-{k2-TGAU+gQ#E~TZu$Kj9L-&qVid3DdxNaiEZw)v?CJb)>gPO8X_QYm zpAK0M?{`PrYsDY?J+h(Q@m4j|2FEPu2;74P$o3Ls{Q>S;M5n?Dx$Nav960+NUVwW& zvAPD^V^E@=mq9NDsa|Nn`aIDxmu~^=dXM2i5)lqJG0j3;h)65bx6@#8gV?SYrptHu zd31cxK1f(6TSGAOu+p{$jN7Dh$0EpDkQ{% zAV7q@Vbi#b`fRakl0%*eBaiq-cSCOQ=7I`SXBIp{=MSj2-u;!ZBkfv1>F=M>mH}xjhZ;O+lz)312r6PP zAGEQZ?uw`om(wBc*f%x3)_U{2D!203 z6)TPd;w}}{^~*DpL%C=gjP)fcE<9%V;-JO@1nUslfE-C$z!s8oKN8|tU;wJg`Fx1O zma%`oSBRk=TU4z?;}T)K*q`q~_1~TptDqhmW{Iipk-)H0BrSN*{g&)YoP*pGa)<** zAUm957PhdSn+&SB(DRKpSl|mUGdi!?vs=9@t~2Xll1%B2dEA#2ub=;tO2LS7&~RxA zdn-DP=r_&SZrGfkkfgxjZCHwueC%3Hm`2;4R<33wFClmm0dh(O1sGWD&e8$C?b z#3M?q{0*Hc9g?+l9$3q}%mRvoWmu{p)?uiK_LSSPN~L&^_?qIjY;T1c!%bZa;dOnHC}5i!SL+CFyH;PR>;hd0PU2HWs5cbjMXAw@1-$hFHFol30N1;kf< z$tTu}25js*y4X>yv)=tYO7q%X_Um)em@pY)N6w|VV@23faA*Lkq6?1XxZXK6eC2LJ z_@SnI2Va)Esj2S*46H^!rVRxKTNZH8;!FP1rR{FQ=%-lqa(n?a-unbAP# ziOGoK{g{furIOy?XR00DUvtRKTy6wO7&kx4&5ui#oaj^E2}8EK0a!Kc^b(^9HBEU@ zEb#5<5{{M0K4TX_?6*G)a?$y88SC6|C%9LcZ3u!C1yT@*s~SryA39DbzM(HTG4^ew z-AvN}db+B6+~6%RUA~@xHA7n=s_SE+$hy#it{_*AW}hodi#so^Shy*A9L_U}^GcqN z=l{y0enHkjYdyUNK^98wpELZi^@oP}KjLoy)c7X=ts4T!8!@PU2e9q`@-yM;0_HZf zt4w`~G(7if^XScbii$byMn0YulONyKFSm{kx*Yz-AS<&ceg7>CLJwLEw_!2*c*&R| zZmw=KNCBf7c5|t|%rSg5G+MybkRKgu-EV0(l9cN)gs@jqUYbJAS5`^D@;eTRWw#tZ zCqEoC`|%{n_;AAM;a>ry=^uC%Q3~5W5V<6KLOLm3?<1`T(Dw9!mhO|!CGTWq~@5T@yZz@haHnz{iEZpTTEmJ0G63YSU3 zt9wV)ic=3Tiiz;aCi`WEgoN=3k~&2Jq)14hv*R++hwZl6Qu`A>l1<68yPI1EO-Na& z2mcn_45cUT+t|nPkZYNUwpVe|_Y~Iyx!ZvUvGUCWWe-o0zmo>;UKyd&anTNB(22{! zT10VPztED020r-^z;~T*I)`mZA^cT9^kn*}M%wtQWJ(jNYe)I!*YS33hxP{t!vi$+ zE&SS2j;w0wk5`8o0}as7n6jMuaj%$EMkc|}2Bbu>{swoH2EC`(*1u(K=}0f#-;K2z znfM5{@rB}4cOkgu&j9^G>?dK8S?SNJQg}Y;`m&FHaqUxyIUV2cyp!8GOF&?^F{u}P z{3y1RUhp-8?6x_xnc()giJiw(1?!Wb;Yfsi=3_j_5R)&omhU;H>6AmfP!5thrMG{Y zOKl{;y;h*-#XHF7?RV^O_rDwIpF**8fJ1bIU$BzbJbVvOTde?Hs`CyHk}%Q3yggBN`1bR;rzJ>Xm|LjZu~lj%t+hw8u&Yewwz+-1 zv;*t&<}6XiFAUr=Id5}ryBL;{k%Q`y0P?3X0RvpeHMt8}LH*Sel@AQKb; zMYSEv539UEN9?RFLTT(ye@l(-x~Sz}so5Cz*7SS3f{xEfn=SWX9XA))U%CC9?`jb< zBeaGu`Up$|Cy)7frNiY#&&<8W&;@h_m#&_V!l;JBE61hGCwLG&?6gbxVamXd;1@oE zzl;*>Yp%lYKkU*_DYpK)pL609)~C!i2GDxSy0DEa%|&I#Q`a^8$$LlqGh)Y;ShPIS zCULhn>P}LwZ6<1*)DT-P(DZ*}H!M@RT_M*sZn1qRD_|p;%b0`q=Y$n+ou&=W{r+8* z^1fbr;`?9_2kW&QV&)w_!c7 zB^3_OEU$-J2wP+S_VGu2SRU5XOOkyEiWkw{JQ~J%bE-$%9gz776l+)e{R5cMH;u(W z2lG%Qo<_wRi>sy1V6BbTv!Aa+TvOCEBAFqor&aZcf=qv+A&iW!jdq z8XJ9c9m?#%;Eof%ZM$rwF~5e>py0<9R7DsX;Br>OVcUa{_ISTjYNHb-94(X*Hq!6{ zU}5cX^d7_CZg*cW94}D%x^R#j*flG?EU!you&%j#kKB)x;f}}JJZp4o1Q03B$@g3m zyTJA@U~%5RQV)76WzpcsXU`q$b2mNlpk?E}n=ybF(;)E~#@#~}O z9j#!Yh8h8J9X;o#Sb^}J4Fk?r4%9vrqrQVcF+kd@oy&8pZs|~AT$yp-D*=}I8K8q`fALQdQsgR&TP2B2A#K!Nvf>BzJXk1LAD_lNuv6un!0kll6OY=u)7++=9+O(H$5$)6}bfsA?dzyVo>~z3PhoMB|E~wY4wxy`u2TWKy9hZv0dK ztB^TREM=Xx);#qvjuwa~Uw?pMC=tk~-UL7u&!2ent`-DK0!4k^A^hj-e^46tIn5=3 zAdoF)`ICZDmH^o6V*5s~ls7j56v$peP)R0~UDuw%D|LFVv^=W#2UUnHKE3*{-SqkO z6ChpuP75||0lVbWM0<$*!=Qc=7`*)#52=tnvw+il1U&}e7k$?EAW~N!4+jrdvBw@= z*0>ed}@1h&X-z-M&+p@yIig&t--blykU(^WTz{cIzi>Y6UUyOzV|G@!h{zFfbFW z%@DDo2FP1kJcb87EcmCShu(h(zkzatH?~*_RDB$lTWYU~eqeh^t1u#2Ot;3kz2o>+ zv*KW_v43!B;ZBe%P~)%<@Bl#-5EQWN!u&D@J^|sQ4+fxDm8Y^XjIj3jKk1o&(};Zl z07DDNdh7*|Ju(IqS?(i?g#^kb3g!C1mCg5-9CvHHYM9BAj+i%sG*nw51mMa$9s~k& zoShEA5`mW=_Y&e*xtDaXSb&dM1mag#!PgMvXJsCsDq}DkcZCN1`QK^rVR#ti*`X*6#ugU7aJUO3u|jOI zx7VKkznbU{05nM^jtw?kyN)ByM&+i4=yXEKn|V=(4;s+*;mAzanrZB|O`7`_vs>@} zWSS3QI50Mmf5=3LGz>!q>CSvdij`FF%sxA-2JF{8oTM=T8vj{R)&IBwXQ(ZT`#Wn_ zo=NR)%g?kesWiQM9;yKucIx>w!{@-D{hP5D{?E(?DOLi};d*xRC!JRfI1W)c8Wzm3 zDMqHO{!QR@9=GWp)`1c?@W;9A#J=Kr%501sJPLAnXbbq6P^?|m9{};sQpVh%`43wt z+g`RR-vTyQ;5g&^%|9@zP6@4YKD+rC>Bt2iNjs{V_y?p|E&gvq3Lw5@7ulu7gLr14 z=h&FDG`D#gf|9@YcKuJmnWN}mmXX?HX#tz)Y>8_gKBS*q44g3d#}*ROiB^DQeMbeq z^aJVzK1C1i5jXz5QB0BWBog2!5=DS3*f^7EYSxc`vlBoXv(6Cv#t_$zf^8~H<$u6~ zSk8kBQ=I}&Eq>nompfB9k1y`vrv24-HW&M|spl~^xVTRQuQe>)q)!h|stIWYt3Rf#)roYnRKzmU}ctnK$eY0``b z&Wec!<3Ct%N$$UnBT)s4hOCFr-kCxE`F7pQ6v)~=4~iu|EPXKz8^*cjcC$?B0~4#D zQFAqQ@I7toM5sOWI-u`Vk+2Q6LVzqhD!5Ds?im21rXz46jqppL;ucbEi|v&Q#j#xj zjF}8zH4&TYCr&`WNJU{CFTv*|TaeSs+ql8(!y9Y20PE$nYz-qujvilMeN+)5@9~BT zJX4LcV(%$|bxb-#Hl9vFP~sw}B3ys=;mv=q!>@okztUOQNI^Ml(FzLGc>RkpkZ3jq zcub{wv`F~ABoC^)CVY!7Bx{e06ru^KTF3VKC<5h^|4+a5|9jHWV4PF{cm7KqhX>{V z-%f2Xpq4w*^J@Blstf3J{Mp+|LT?De{$;n z{gD_VT;khZYn4HBJ63Kq=H1JAFvr_zg;ENW`k)<@r)){_&l2lgNW%s~KSGc|(G;N< z_yU&%vjG;02PsziCZGT=xp)w46utu(W^wB0fFbCF98rzK_Rn?y9x1m4CY-cM;Nzc` zV^fd5MO(FK{I<_~`t6SRGW;}R)m+w$=j$+}8TS5&GlDpjdoOhgk-*24q-tYc#zj4Dz!peG9)|~U1^*3ioKS}eTi;onQ6+vWV zAP^bw2O>>@f(x693?g7d8*$J z;J*vhR2P09{CU|K3UYD^D)2e*AE*DL7g7_5juLcC_MV)K4s?c&jGT^))Jm2K&`9wI zX#k;rKFH3HpQShl21ua32#`%q4iHNLPztQ{1GqbXDKIi7;7nE02R(-AhR#V&D z(%RPE(fOlmXn16FZ2afMBpS2uYjJ6LWpxd=v%9x{aEL!TCIgZG$qw-P-}nI-Cp+_7 z=HTD_kezV{p5%0ADXxl~yL49_Z0bbMb^8q^!@cN?vhU})MK!RDFPsOdE?*Nv^Wc87 z^an@(9zy~DBaZ%yq5tBCGzq#uP6likIUNWNBAAqBOE>#dfo_04o%#Rz+c*{yu&)-@ zC^Af-%QQ4-bI&)Vd>*qpVW#~ZF7mXMR+d~{!{#G<`W;xwX$opSrCKL>HXFs9HBe=1 zUZr5kkyhvtB02LzRznWAW!u$iA^Gk=$ApIjI-4C;xG+bc*J-|-4tbe^wg}F)POhHJ zJn!mSG$(WR$MmhK4*1sCGf~!ehq z-`Q)aQ6qgVBh4($VVT2EC3D3i)jX$lfyI{%yY^{;fK zFb!0?#~&~mIgKwXJsiXQm_3%8YpqB3Pa&PvI#e%D9;^OXZ7vgE8*Uve6w+<*dr_fC z;c(>>L=kU&!b2P|VU`n06T`dCLpC{XE2mwnA&ypt3WZ*f=;o`2KAL3U8;QgqW$BaW zZ^ikh>P;L=9&yi+Ko%(cHJ$eUcgLK$c^)TC$yFc560A9DS7*6~@0G{>;B|pZA2QAb zcc|he4%l#yBls=C<`Mf zR*+{HAW`m;?xM&q^g8pS5Yg&(BY)Q0e4e*9Hw6W907b+?-Xh9PHYu=(=IsSQ(?P_( zVeTX@@AORra(RE{GXe7Mb64` zWA!V0F(baE9WB*O#v&SSicXg10H+w~!|X|e;0@m_YzOhYNip8BW74<|lj^s1qE=N4 zaktL%5DB{d8A5BU@$ER!Sp0EsAI*)4V-l!||HH9+6A4tl{k{LJk(#S5cD+T&GU6&7 z-eFem`SKU(QW@8RUhiI1;>H6^|3u6Ead9^kmY89iWxzc8ATBK?gN(yq(VPYTD9SWV zJ;Oui#IfDULur$!lj1Spo7FWFaY?kAd-$`B|A9#;33TQ)^4wgnZ^7)8&D7w1ey7iQ zJBjRDX90P&DLl6&+{asHdz`4+`hFcth*&{X!dlB8F(N{x!O+O_KOr1=B-UF8OaFi; zyILl$?w+xC-UiDhc6QS+0Wju)hC3OIoxnco<7aNWDkQdUO6lQXK0L9I&daRvC=gGt z;6~n{HsOLU?g&7kFOAoWXRK-XX7x%|r$K{%uyh(qc==klXU}glJ(rU_hy)bGVU<&E zyz+-1hmnC*beqkp9IJ2;!`qKv?>h3`UuJivaW~yPKNPNU*q|q9c%;xu0#yKJ2J(4M z0tIm{H0ht77MD~oerrU~_C-$%E&iBPxbS@MN2xm)UM%&Ezd%O!|+^Xq{a^E8v zS)-|3g?;)1gK}&~Q$MA`CmZ;AfooCHOD?A30R}>b$Q08EqQ_F>QXG|fPIqVQ9-O%y z!;dNB9GdR6wkhwKqSE-dYLq?S4Oc_Ef%-F?eA zzv~I1eo&9th0Dd~f|)|Ms}eCu7q-4aJic1>Fm$zmtvGWzWX9!fDp#9#ZI>MHr7Axe z89w5yIC)G0eU~L(!dD!;NSW6{w#?Du6Q0>yuzfFWnNJjQU?IP}Lny2HB4gY;)%kt#>=$YI0?(F!I_5W|@?_rIN-3n8eXX%rsP*)lmz zbRa$}+gyvh{*r>R>Bxgaii2OeU3sR^@RaSmaik4@jE%OktnB*ok~1Ut=~J>h6d(rB zfBHtYGFQPwKvp9Tz_BEdVw>J5{$X0j3Ni#`Jr}lMR0Wg7T^mnUOChG)o=bNqz2@6= z^;zIBKG<{gmY5-H!1!oVK}BQHmJq_KKe#qCY)=?sd#jec)8d^R9SMX`^{o0*b>rHv z!9l8b-kjbz@`ciHrLaT~xK8{~cbDpyUTbQJt>tTyyG3^4QfxHtTEpVmIGV_@?$*Sv z3OmhE`6rYeh)2)8ApRtfmA!#l-8TeucE;r1;2VedukQnj)0Er$=JezJSGG_#@X&*3 z&mpVh@1TXn>TnV$YT(lpc}u<4iRs#8Q^TO6IL+OSTz7@@!<(T$>fXF}S)HvNqqR5E zje6}oMgmn;x2v*x>}}KFyHtFygU`&4R(5Nfhs%}d-&S59A&QT(3kqrVR2{uvI_8X? zpPNkRXp|aZQXTTS>%>+rO74_9_2$iZX7Z%eV}aBU2qvP%6IUk96+|GMK~56Wb5}5O z-Q>aT_Ukd?T65L0JE+IYRi24OvRP$S(z~2S3$ot*mwdd&1h*D-!qrAKbO##^of{;g z5UeLej%L&Rd9bY(@2Q=iYir`heH?w(P4E0VexW(fwb?5ppGIBj-5sI|o?egw z;r7J$MQt={aAKd(Y@$OXkfpC*pCFq$He5+~(p5G`mz}K4&jcggNgylARqYbY*7jb&-wt;rf4W{eRfL+uLmCrvA#AQ|F2(qFk4c{bv4Ug=8l zgK<*cI+gLEg?KaV>Oohn##*J9lr41rYH$ty4zH}C@CrCRe7-cj%>9jz^gN20po5zW z-Pm^R6xeq3f%{tL&p^rx4VNz@-8YM0QGj^uIq4R7gi@RxjZ5tY%y2p=R2aBFzLB66n1u^3+z(K#+0pm~?7kTjzg75hIyL>>*=ipTjED7BdP{?4ddDhows&IIq(*XwKe}Y% z^9!8ZU`cpS+(H`(L`JYKvYEX&Tsf5_JZPHadgG-lRqm^)vf+=%OZ9Tbm(%#V>{oy8 zy-IwGUhvqL*Fjfrb`*Lnx2}F7n5(iW53(|O3X649$KH4kdGC89#ar5qf475UF}yk4 zz+b&IP9Pu5NtEszwM*zHp5GZKu$$wAo6;{ff(+H>BVW(9*v~63ME6#f%_j{Y8w=Cq z`8%=#ThUXl+>Xhx0=*E(tsZPYM99I2GtzeVA|}=f*w<^>SyqHlsuI~XUlwex(U2dnkGNVG0vj=< zyIt=A>j+L@dm@UmA?lX0(L)MHe@(9h)hxIyLy&XyCLKhscQ?c;ULSQh=bd8JnH1dx(Vbtb}SGL_=|B36QP3lHj`CpUj|it zlGuB{a_PDZCXP+eT53mKSFY~bh-nNrPK5}|{J8aVjj3Ei$A0JYY&!R<+8KPpeS8Lb zA7>z6^3GTrxpCdq$6jhCJ+^cR^(eTYBgJfobs+7sPjYwvam~07+nafORVFt%vf8Ie zQSt)k150_X?{%$*9BxA@D0;*8jDVvS@8Hh69lVqEs}7D}8>=iFyd#YPPdz`5`*q2B zme-@?5_DsvE{bcaPtxIZS?v(P_dS<4Pt&8gOE#7?=u zHC3_7_Rc$NlNfH1D-4r`l^_r^caDctk)ciBltIs_4$qIg=Qr|YmFr}8vd7Xzn^0&9 z9P0G^WGRt)kp#lnCZur;Zif=CG)j^{8M0bHBIAQ1sIp_X{EKEeac5$RGiJ}wQcCo9 ztSZULexfJRoq7}f6VmY)=Fq=CfZ&z(w~x;lBj!aB!N0G9qoL!dmGhn?kbf_c!KI}Z z1o#GoF$wgEXPHPYiC6EZT{%CGbqwFi+O@Ce&wp6czOwO>d++yBG7<9x?eW^6@WKfs(A%sujsfuhie&g-nX_6BeNEx3(&EQ8IM1Zt_{Q6o z4K^CHG&-pA{G+-5G6=VM?}Kp8!k<>c-_)U$!sf_b5L+d> zMB}F*QRgkPcIXW4jr9I9M7iXhrsO#fy@&^PownE%{S#w-HBt1f`Q)uv6fR?htoofR zIp?1Za?FLGUiKQU`gr=K9934hmD-0Q{9Jge05-_#8t#}ixw;`~vIs2w( zy~Q@~l=uyHlFQeP!+39hLU*MR@3QmgHmF8g-mPxH*>A`M#5_-N-mr6NGrgBnEETJr zIvi1(Ok^4@tuncUJY)5MW-ex!BJNe467N!JWi!s0Gm7(P?zSiQLYX1&+frlvvl^d? zz40bNCCrWo>JbhRsTz8q80s9bTHLgI_O99;nY6eGl?dk^Z7wmcnZ?XQH3-{xy(nsG znO=S#_ifJ7g>(*r?TEv66(nfLy1nHYZP48Pj0hg5xr}u~<`Tg!?6;bsf*H&SW8nOB zKTBY){&~GZf>Phy&+0MMC18)WJyvx#p=Kv4JQYq6FMV2$w56g1n=w|1^6K8D#@&(T zSAN$+xbT%Va@nsJ(3U~|O(BRAqV1x&hGuN+n(xOe85E4=5WH3zJqeTsZ1dcLW>42& zj+I2o7kj;%jG;!(`}dvajed|of{01+oS$9k*Z>a%(X$&!pskjkL;vj~?~aXA>~QlT zoHi?7_jCW`;;P6#<86m|w>fN~94`qZXGsE~+H;e_-~fASM2~*p29fMN}Y z{d0?2fu55^jE|%r3+V75Fv54`kq?W)IG~|daI(Gum_-bwTvI2RG`m@P@#SyE?QzQi zK03LR4Vzn`a!!gh?$2*gz_f#Vl7@XQ00~<$33TdRN(|Pa=}ha$(GIaB!^vvLHqY`7 zi_^$lIiuKz&7Ul12YjZ?n6sNjw9W$*m5)*7XvuYC>zUa$I)oHblZWHHv&wO)G}s_Rb}SD7yBH|<9o=rp$nHT*E? zY>x97!H==o>i=OCs|; zUhHf?8Fh%!+jca8Y1qxbiH#nrUmJZfCuWqq;gE29F{ajHxqTHT;1RH-%B_fX9sQd6 z`N=lT{>NaSUsEIVQ8Hts$F`;YwSx8_Y>io57@LJ zm5dclS-n2HeTp4Rg|}P|1-|@^@wX!%UjJ3n$CqgHCANIP)%9aXm+=)qFZK^l{ZxRM z#yUXt;m=Cg`*35M1iCgUd`w65ley30@**{@oAn(m`yR=rsppin34Gi7PGP$H`OKt2g6UrUkR?5lF;!z?V9;d_F` zNW?Gq{?%un25L&YC>B#oGHecXz7$12{Px-*NINBI!=-lfFa*Zj!f~1q+9avESpEK$ z$MVOKy8AhvtekAPv^b2QfQ|7B1M{vU&Bt}$wpEqIcrPcl5uC49shrcGl;{nB&lWA$ zWJ^fd1oUSB#o?5uJJwlBtCDMs)o-oE-bXFhqWN}zR{ zzpXW0x+C4@x$A7a7nxa;8oeyTvHhkJ5th}e<97j)O|1Yu&~`d$h7nag6O-4fV7e>) z_ad1dxjU>J3!`<~kPx4{<H>+uJ7jtixfBc}1d5UjA*Wn4aKd^gWqYsSIC+>zG z_xK*KI_3YcT{HKXGh0ay;1nAm@~;k`hFy<7kk+8IceF4XqN!n1dL^Snq5CLh>-G11 zp$?-G4}NryHdBlZ=f$tz8XvMZEW5wTU0UDE53N!aYt{(K&sv1C5gdmI)FofvcxDIm)FQk-wdXQ^>i6L73_&9Cw zMeFJjIZZ!v=J*)|-i8KD4EF9nAyhj1BU+wX!T0!Q5p#=KAIOQUfp-BzHQprxQIGu- z6g$ESIi)&GKRmKpBwrN9-`H*|mc@XBGrv>}U9aZHuvu3>nvbAX)?r697^mqL>mB>$ zCE57|8c$sPr=S4T707OHAbu%+BZ0~a09WwvQz=A@czf?uPyOfdcsm^8BfU^sK>|6p ziTWlW;uat2Y}@zw`mL1k>Aw5m*gc{VWM0#>o?fBdP_VW$g{K|krkorMPwC;PyW@~` z(%!(Tdu-h6((sr^QDW!K`xQsYrUtSh3Jc1)rj2Ipz6C_vZIXPKDWb&7JcXf7E@n$Z5;`5Gx;xPRoLa zqAWg)zuuu}8l53?iVm2K+pP)85wPS{;T~#kb9Q08iucW%uyu3y6MhTpaPwL9dF$K; zku%HHzhPQ{;hKSk?AvN)4kdN@Ji;dwNz?#2r$mwfVnW7zwwuwz?^QN!@m#UHl5o(? zSp;L0QpCqxs|_WertkjV@0NiaWL76jqJ8RT>OPX-sXzi5CLHMu79;7lJiC1!2^jeF(|I_ZIWN(di>S4Sr?7+f?1&)RkTi7qrCYtj6(pLqmC9Uy|de*_>>#*kB9 zuf6iQ1-RNMy&y7eee&8r?Rt`Lq3}EqLfAO;K-`D~T4i8FFQvB|rx)_cLaoN*pRO#% zaM#{sc_I=g^@+SyMkl1+EOQ$FVp*Z=OVj~eu=L7TRUAaULw_vQy4gmjA?yRaQa`+k zX(PG>_9h1zq>|?mzr2Y&=;R6p$D~L(WaR@5Ess(4NiUWYm=Q9`$97(gi6Ar1)?zF< zz&q?X#uHuaaOVPJEhoflH6lygql_cBssHHg9AX^ca7Dac;&$4yy?k-i0JV!FyFVxS zCn>^Or#$AyULN8(yu2f05)2d-Sul?`3W%cBk2>Lebn#AoBv67`nRZO`PRVk}53%xj zW$s(*!51c)_DWxfL}-Woa{f?l8SXJfd_=K)>IYfX)xy7YRLjW08MOFa4@7(8n`vOL zhl!KXzE!kLeoocWy&fLCb`R>2I`q)#=?1h~giGgvj6;su*;{At$Io}p-Jl+83=oyG zBq#4U(9y81W<9>An)>V4-N6xLq~} zKS4mJneboPfP*o1LU3z;B@g-hM+bv!mjv33BY~~}&oA}R<0t#8=BU4AX7b~e{~|LR zhJNXfAZVf+Xf`Va9c-V;jBIIhiF}(r5_HEKvV)iB>cxE58l)3``F;~pSNug9KBggL-<|LP zFXHQPs^ZWHPIhFgr0~nNUE`fcc}HO8Reipyt85S6w%zU^h&WVZrxeEJR4p+xur+XM zNpa=6iu-JyS~v6lm5Xqyy&GmPeq{~@t=1W0N?-dLs6L2QAKJ1XjbhAw^PYY)+Bg7! zEt-2dM@b+FC<){uN$fXvB=o`*Q9HkIvfejyRaXUl(0?u3MCX7QO~2tTO6QIR#;!eL&IB-ErYz3+203 zkkkf+_>&Z!GQ%NmlSmWBJ0wu_?BQ<>Q~Noz!M?Ngz#C_ac~aZZH$!Hv0- zaPvFg?WK^2B;t-ld|vaooHC9IH>4`sPCr$UKaHA&$9XaPxCGdI&gC#8*die&`9#ooBNr3U0e$QCGGI~|+<)fi!RTCpiB z*Rtf9)IYm-P;lI)X20pRA#BNMtl0&-;=??;lq_p<<}P&Nu^rEeYDe#Qgym%2$`WOX@?9cw z#@%{Cn4wc`h=9=e_4r*3ZP6ZDKZqrPY>QY@y6Ryj$)XWX!#SFHQv=G19VYWe*T#SqkG?WTSRVYC=`D9_>M=Xvc(E72->4kAaKL(ZQ!e>`COVLjc&)f$~ zXUnc=;?5y03-##SihOb`REwjW^wEWVk!tSwsWwjw)n-@R5fwvX2wE;}5-7a}kyx7T zupC@At?_66g|8!0H2^Iwanxy~cKY!Hd9NfBTzc->+xU*=e+gvmb!>>v_cP#Q6}(fa z?yet{8kx2E?6MAO@WW>Yz>%h*ijc^|vJ(;5{)Tzb$z}Y>M&CZ%Yoo(DJ6E z81f99O9C}d{WesE;PlfyMDu3=%)2}XSTg(X0xpZlpa)p;KQ0Kr1;HC2084)L+mexZ zb&GQ*Z{>`zxverTTEqaT2k`Zg$--97$Xb!BjD0_RP z>-xOHt1N6^B)lFME|wEsN&vS!Pt_tV4+?VL~drv8Bq0fMday zO3GC?ap zAwE>=rlQzJf3mAdkjbTe!57QwU$Cyfz8D(Hz3(cxwbJvDJ8gdaQK2fogpl5CVZ25< zBVt9V5!fd%esDA2omSb6GyN=)-wcWkG(pU96Kptabf*3KeBaW~r?=Ue5ii7t}ZNO`Jz~>?3I;bqD>J^^sKg*&RW?Q|8XG1om6n zD<1o$nce1_x5l3wSG2Zw#5WWHzLDAyU42S>8F4rWXy$f>^na{ee})Mbyg_RkO~BN> zF?6jH##V=)PI^*c=`8Uj5GI8G?J)5#>Yk@(3M$z7Vthe4uLIgr1g~Ru5zl_s9DUf; zdsfWqwq{M`9M=y!&74n$hm=<|cflr@cph%qT6GDobXLIpiqHOmUSB4_fnc%@Jtl+$ zcFI7uA6Y!p`=Qads13?IU6puYq_Pz3ku$c?Va$fqO1}E_Jnz~&Pww5~tNJG1vh4nKPvZ$Mu9^A>Ak-f6#cqx*v1%@VBbr5=Y&k{7|r){vfM-dGdzYafb` z8BbQQ@JX+m+4y`>0lSusr#nnP$M!b6EM=#Ey-pIA6ZM9K*}2||)&CMB%5wDP;4tUr z=VG&m-*epzaF%V3jQH9~HeV&(nrKt!E}rMF9(Lrs$eJFta&ehfbLCBOx@ysW+saF% zLGQpa7zoYA!+`tl57*Ol=5fW%uL0~4FxQ_ z?1K|&@5qa4DbnWLk7>+)78Ar)gYDmxFPL42GhS~%eY1Wk@id^%v*}#pK``s*k2 zp!^!yYu(9DHg>yy`v1|9|9`QpR!2M>zJ#lOQlk+RA?Y(N?^Qyr+H3t?dCk^fWlX!| zTA0MMU*?4|S_P_lsp>hDb(u3R^1V;U50byWTSq@DdqfMHIzNAsJQ?(n&xlScU$N&X zZTr6Snm|H?OHJWmu&blTQYv)uhARDceg)oH&A@jn;Tk%MG-4a=!xx`Ypddi$>D56* zagjg+!607)J^!a?+BX@(xm-0UKb@CTJr{?AJchC38V|}WFWveo%jUfIOZ!7X*N2*{ zn%nPUDx}gG{dz1P)&_PUXf{Q%t(}s@wd?D9{0}CxUB2g-NaP5}ni zG{GTH0(GnquFk7MRj~U%48DEH8HgWuUSNMd-Y2!I{Bxhdu1angXwWja_A{^d7R{*A zOTX3RryYE=se0Eyqtrgi#WOl-s!-&5hz5I5^g9I(&1z%hDsfT}CP7Lt#76_lij4)!tP1M)l1oG*d2Jy1a zS0hb-peiuLX*ufGCDZ{^APJ;A+aOSl?;qa3-)**Bd+_$35279m9|(+8u?cd-^bxH> z4_cS$9zC<%!0mnq>dLSjSCu8BsO5wdRU=WvGrV{mlkw%B%g06D$WSyQ z7!&uB*k3IrPm}3#tPw9`uip}NS#r9n$V0P5JB_2xs*?H&ZlY7GE%OYeX5dHH5=jjQ z_nLtW%0j3>q^%B^crO8dc#3MT6Y4oK`-wPcF9y&vmGl$h+``l8gr|#0Q^m4E%T$d8 z0QarN4=p7)<#l`k3jRm~Vn71)_mxrB?i7@BW(9cJ;&n+Ay1hu5W$oISS=%O_0F{s% z3*dn4`7texUv^`%>5nA>GhlMPtb_jYo4^F~qus5(99vp78oZ0#ii8QCDjluvcKLoj zx|G(8c<1sTA80J{Z;8clHnL+^;5(plVBYr`5Zcl#r`>>cC|XWP=mrYQ2yf1*ap&ti zl5K6gIrjS0hQQ&sb>l_d2awQ53bKA?aE{ll-2Rtw6Ftk_ZW9T-t?SuuljKaCdl)vM zc7AHh21Oj&`)^WOhg6i=w+eA)?X0OumBgR*;R3XKjj?im;;VyA8aXg~q7ubp^GDHN z+wafb#EJi)al*(RQ8H6(voGw5PcSP&smi(*Z#n>(Ao>b0Um*!pM{fj);`iiH^AA!# zm(5ddyV~k*eU{V+6l@0Dow;>;LR|QjEF7=22R%NcM*_{?CV_%ge}W0z$BhDEAYLuKtk9AK$TQNP56EyRsQGIF=NU%8yi={fE-iZN|fR56IdIZ zoofF!>TghGNa6qE%8m-O4fLQm?j6iw?%$&hG1*hD^`)?sc@;Ctkb7gJKhut&;CUfq z>$)dMF5=x%8tbLVn6hkJ)kmAaNC;ZwE0Ehu!?!aG=hPEi5XDMmf~DbtpJTgEL`ykT zsFI#9GkSk06LA<58`_x|kxUEZ&Tuj;q2G9Yv%@N#1%*M5HUJQ)WLD2jw3f5_mRZs;@tENw&rj*BX_HC=5~%+*kdpzFnpS9R4sqlt~X{((cBo+&6obcw$8qS+SM*l2GBr#G-hTu60`5O+)}b92HPc zR6ej+%Vttn%z9Z!0zsV+bKg}ao;g}*=AW&;ZHbRIx4(%zfhm6HzS80hu6Oh-=j_;o&eQeIUnLSWRntc6Itl7u2)wtYCT?Ymvi8(cc}hmR>DA#pje8u z%>rMSd$=|93O?O%w4|yKUm`p+y0thBL3|v4{c7*|Hgn>mDm{LQmt*awzhP^#?YRmR zA=V1DzGJe)$McP2^vWN85fYA?FPz)q2KfIu3XB3~YmVmw5FN{(jhLIp=zr0O(I7Sf zI6Ogf0Rd6=&Uir0wVpr+m3W@}swxt>^1vf0w#t05V z1}GK)gfAhMC;Ne}YX@HaccFA)8wUXKMSxNZAcC3h{I@0NssrU%iV19EY;bh6uX&%?iP!SH)X zDwTK*(4DGuA_Wqgxk654nZu|NPJSDz?r*dEZ!4%9Iq+JTxzOa9>d1QK3)E(xe=HkT ziiSSViURvp8r_mb{)HqP&wqnxD0UD^gIZqN1;#ER_`$9wI64!?hUo8ICZ2)fvf|z> zAEuv?LlhZx7wQ>ICOTwWX9_x#O?JHM-27fpdqx;>JGw{XSr z6y^2?is{R=Y-S(JM;m*!)=K8IqD85{!^XMWCV+s zUr`?L^AcmdyMI_sGZAR@r~>eJX(uWvm!k>G`sEL^q4opN+V}g&Q@;lQ-bMvF{#x-T z2C)nS5U(E+WAODJ5=cbV0o<$g{BIL9z6NL#=m_+a1RDQna!gq?J8$qOh2%|eE9B8l zz^?-*I!BBB(fR&=Oe*-0KquyiP9DUDFqjzI;HBsIH#no;b@N}$3CX5k)KjgVRzLQ{=~|GAX&LLcT9N~m=@9@*mhTN3?QsmQF|8yiNsR;pas+2 z)7;NMUV0C5a*SGdkZ1%xNCT$h?oz_H*MQBrZ2J1uT%|-#u4Ieny}4$!NzCeveb~PG zlLa~I%`ubRBc@XVqxN&C18|@8PSo~%W)Jz_KL_9o-~V!%;kN|we#hM>UfriaaeRU# z>O;1v1e%UZPt;U_QNtkU;Xb0hqJr^1rOpL)xk^xqAUOsaxx%{#b;O&^v1 zoeV~|e~UawwH~7Xm&qYk-2y!72OZg)+h7yMCjxTgj3$A))lUbvpALUBy8IV;TI;=A zf&YVittzNL5r>BnD_6m%rzW_}9-+X0-S!)m8l`G z=Kf27xfWyx>%vEba3T?q4{Qbq>hNeFzC1_-PZgn-cZ5&yH!8>hZ*1qTEcV)C`gW=@ zJEJrdxAhO57MRYz>a=_!+&KN*c+-a&yD`gzeJ~oMujVzbL7gJP?HLgk8slZk^lrI0 zILp{@h+|R)fp>JAgb)K*b%@kV1Y6+^f~4hP@J~dGx8DF_@8S>&tBYDtI`CtwIBDL1 zqZ?3G@MB7A6TE;4F6#tNGjWP`gJNgrDxs%m#Yzd6h4F)@=^-0LY~2kHBeE@No#VN2rC!%7lRv2or#5-vInQ zEF*N68puOKg7HW|S`r966}%mpXG-`@O@<#g;J16ziD!_wtTkrDtvaaViK5S=s&6WZ zC3c63=md$tJn8+M{R{9!qf|$NWc4P{)4WT=4-B-16U7jg=sI{ka7(tS1EA>m6aqxe zfHvT!@Cxiq`Ye+v;LSEq04*6%0Q$k-0R%S^I)QiS-~NN3gVRqy8}(`qV!jN~j{H+@ z69&4c-~43PlV47%o)QyHQzbf?RG&KS_x|m=ip2jLC+Q=%h^Bx9|00ROKj;l5hzYkM zj*fmuTXmwCuL5N04~nR9P@`GFwdMK-e|`GT7Z+AY}Px5v))DGY0>cGD|WR#^+-C*{$=l*N2)0 zI;4{BKMj@*DBnL8DZXi%ubv=wZ}a2gYs!Dx$Nw`5{Qpb-kNA!E|LNOE0+A;EFWU_l ATmS$7 diff --git a/examples/webgl_postprocessing_pixel.html b/examples/webgl_postprocessing_pixel.html index b25e296e64c4ed..a2fe638ea6e81c 100644 --- a/examples/webgl_postprocessing_pixel.html +++ b/examples/webgl_postprocessing_pixel.html @@ -39,7 +39,7 @@ import { OutputPass } from 'three/addons/postprocessing/OutputPass.js'; import { GUI } from 'three/addons/libs/lil-gui.module.min.js'; - let camera, scene, renderer, composer, crystalMesh, timer; + let camera, scene, renderer, composer, controls, crystalMesh, timer; let gui, params; init(); @@ -74,13 +74,13 @@ window.addEventListener( 'resize', onWindowResize ); - const controls = new OrbitControls( camera, renderer.domElement ); + controls = new OrbitControls( camera, renderer.domElement ); controls.maxZoom = 2; // gui gui = new GUI(); - params = { pixelSize: 6, normalEdgeStrength: .3, depthEdgeStrength: .4, pixelAlignedPanning: true }; + params = { pixelSize: 6, normalEdgeStrength: .3, depthEdgeStrength: .4, snapping: true, pixelAlignedPanning: true, pixelAlignedObjects: true, rotationSnap: 15, cameraRotationSnap: 9, cameraZoomSnap: 0.1 }; gui.add( params, 'pixelSize' ).min( 1 ).max( 16 ).step( 1 ) .onChange( () => { @@ -89,7 +89,15 @@ } ); gui.add( renderPixelatedPass, 'normalEdgeStrength' ).min( 0 ).max( 2 ).step( .05 ); gui.add( renderPixelatedPass, 'depthEdgeStrength' ).min( 0 ).max( 1 ).step( .05 ); - gui.add( params, 'pixelAlignedPanning' ); + + const snappingControl = gui.add( params, 'snapping' ).name( 'Enable Pixel Snapping' ); + const snappingFolder = gui.addFolder( 'Snapping' ).close(); + snappingControl.onChange( enabled => snappingFolder.controllers.forEach( c => c.enable( enabled ) ) ); + snappingFolder.add( params, 'pixelAlignedPanning' ); + snappingFolder.add( params, 'pixelAlignedObjects' ); + snappingFolder.add( params, 'rotationSnap' ).min( 0 ).max( 90 ).step( 1 ).name( 'rotationSnap (deg, 0 = off)' ); + snappingFolder.add( params, 'cameraRotationSnap' ).min( 0 ).max( 90 ).step( 1 ).name( 'cameraRotationSnap (deg, 0 = off)' ); + snappingFolder.add( params, 'cameraZoomSnap' ).min( 0 ).max( 1 ).step( .05 ).name( 'cameraZoomSnap (0 = off)' ); // textures @@ -181,16 +189,37 @@ const t = timer.getElapsed(); - crystalMesh.material.emissiveIntensity = Math.sin( t * 3 ) * .5 + .5; + // Quantize the pulsing glow to a few discrete brightness levels to match the pixelated aesthetic + const brightnessLevels = 6; + crystalMesh.material.emissiveIntensity = + Math.round( ( Math.sin( t * 3 ) * .5 + .5 ) * ( brightnessLevels - 1 ) ) / ( brightnessLevels - 1 ); crystalMesh.position.y = .7 + Math.sin( t * 2 ) * .05; - crystalMesh.rotation.y = stopGoEased( t, 2, 4 ) * 2 * Math.PI; + crystalMesh.rotation.y = stopGoEased( t, 6, 8 ) * 2 * Math.PI; const rendererSize = renderer.getSize( new THREE.Vector2() ); const aspectRatio = rendererSize.x / rendererSize.y; - if ( params[ 'pixelAlignedPanning' ] ) { + const pixelsPerScreenWidth = Math.floor( rendererSize.x / params[ 'pixelSize' ] ); + const pixelsPerScreenHeight = Math.floor( rendererSize.y / params[ 'pixelSize' ] ); + + // The top-level 'snapping' checkbox gates every individual snapping effect at once + const snapping = params[ 'snapping' ]; + + // Remember the camera's true (unsnapped) transform so OrbitControls interaction is unaffected by snapping + const cameraPosition = camera.position.clone(); + const cameraQuaternion = camera.quaternion.clone(); + const cameraZoom = camera.zoom; + + // Snap the camera's rotation and scale first, so the translation snap below derives its pixel grid + // and screen-space basis from the already-snapped orientation and zoom + if ( snapping && ( params[ 'cameraRotationSnap' ] > 0 || params[ 'cameraZoomSnap' ] > 0 ) ) { + + pixelAlignCamera( camera, controls.target, params[ 'cameraRotationSnap' ], params[ 'cameraZoomSnap' ] ); + + } - pixelAlignFrustum( camera, aspectRatio, Math.floor( rendererSize.x / params[ 'pixelSize' ] ), - Math.floor( rendererSize.y / params[ 'pixelSize' ] ) ); + if ( snapping && params[ 'pixelAlignedPanning' ] ) { + + pixelAlignFrustum( camera, aspectRatio, pixelsPerScreenWidth, pixelsPerScreenHeight ); } else if ( camera.left != - aspectRatio || camera.top != 1.0 ) { @@ -203,8 +232,39 @@ } + // Remember the crystal's true (unsnapped) transform so its bulk movement is unaffected by snapping. + // We snapshot the euler rotation (not the quaternion) because that's what drives the spin: restoring + // the quaternion would let three.js re-derive a gimbal-equivalent euler and corrupt the y rotation. + const crystalPosition = crystalMesh.position.clone(); + const crystalRotation = crystalMesh.rotation.clone(); + + if ( snapping && params[ 'pixelAlignedObjects' ] ) { + + // Snap the moving objects to the screen-space pixel grid so they don't shimmer when pixelated + const worldScreenWidth = ( ( camera.right - camera.left ) / camera.zoom ); + const worldScreenHeight = ( ( camera.top - camera.bottom ) / camera.zoom ); + pixelAlignObject( crystalMesh, camera, worldScreenWidth / pixelsPerScreenWidth, + worldScreenHeight / pixelsPerScreenHeight ); + + } + + if ( snapping && params[ 'rotationSnap' ] > 0 ) { + + // Snap the moving objects' rotation to the nearest camera-relative euler increment + snapObjectRotation( crystalMesh, camera, params[ 'rotationSnap' ] ); + + } + composer.render(); + // Restore the true transforms after rendering so the next frame's motion builds on the real trajectory + crystalMesh.position.copy( crystalPosition ); + crystalMesh.rotation.copy( crystalRotation ); + camera.position.copy( cameraPosition ); + camera.quaternion.copy( cameraQuaternion ); + camera.zoom = cameraZoom; + camera.updateProjectionMatrix(); + } // Helper functions @@ -245,6 +305,36 @@ } + function pixelAlignCamera( camera, target, rotationIncrementDegrees, zoomIncrement ) { + + // Snap the camera's orbit to the nearest increment of azimuth/polar angle around the target. Working + // in spherical coordinates (rather than snapping the euler angles) keeps the orbit translation locked + // onto the target and guarantees a roll-free orientation, since we re-aim with lookAt and world up. + if ( rotationIncrementDegrees > 0 ) { + + const increment = THREE.MathUtils.degToRad( rotationIncrementDegrees ); + const offset = new THREE.Vector3().subVectors( camera.position, target ); + const spherical = new THREE.Spherical().setFromVector3( offset ); + spherical.theta = Math.round( spherical.theta / increment ) * increment; + spherical.phi = Math.round( spherical.phi / increment ) * increment; + spherical.makeSafe(); + offset.setFromSpherical( spherical ); + camera.position.copy( target ).add( offset ); + camera.up.set( 0.0, 1.0, 0.0 ); + camera.lookAt( target ); + + } + + // Snap the camera's zoom to the nearest increment so the pixel grid stays a stable size while scaling + if ( zoomIncrement > 0 ) { + + camera.zoom = Math.max( zoomIncrement, Math.round( camera.zoom / zoomIncrement ) * zoomIncrement ); + camera.updateProjectionMatrix(); + + } + + } + function pixelAlignFrustum( camera, aspectRatio, pixelsPerScreenWidth, pixelsPerScreenHeight ) { // 0. Get Pixel Grid Units @@ -278,6 +368,52 @@ } + function pixelAlignObject( object, camera, pixelWidth, pixelHeight ) { + + // 1. Build the camera's orthonormal screen-space basis + const camRot = new THREE.Quaternion(); camera.getWorldQuaternion( camRot ); + const camRight = new THREE.Vector3( 1.0, 0.0, 0.0 ).applyQuaternion( camRot ); + const camUp = new THREE.Vector3( 0.0, 1.0, 0.0 ).applyQuaternion( camRot ); + const camForward = new THREE.Vector3( 0.0, 0.0, - 1.0 ).applyQuaternion( camRot ); + + // 2. Decompose the object's world position onto that basis + const pos = object.position; + const posRight = pos.dot( camRight ); + const posUp = pos.dot( camUp ); + const posForward = pos.dot( camForward ); + + // 3. Snap the screen-space (right/up) components to the pixel grid, leaving depth untouched + const snappedRight = Math.round( posRight / pixelWidth ) * pixelWidth; + const snappedUp = Math.round( posUp / pixelHeight ) * pixelHeight; + + // 4. Recompose the world position from the snapped basis components + pos.set( 0, 0, 0 ); + pos.addScaledVector( camRight, snappedRight ); + pos.addScaledVector( camUp, snappedUp ); + pos.addScaledVector( camForward, posForward ); + + } + + function snapObjectRotation( object, camera, incrementDegrees ) { + + const increment = THREE.MathUtils.degToRad( incrementDegrees ); + + // 1. Express the object's rotation relative to the camera + const camRot = new THREE.Quaternion(); camera.getWorldQuaternion( camRot ); + const relative = camRot.clone().invert().multiply( object.quaternion ); + + // 2. Snap each euler axis (in camera space) to the nearest increment + const euler = new THREE.Euler().setFromQuaternion( relative ); + euler.x = Math.round( euler.x / increment ) * increment; + euler.y = Math.round( euler.y / increment ) * increment; + euler.z = Math.round( euler.z / increment ) * increment; + + // 3. Convert back to a world-space rotation + relative.setFromEuler( euler ); + object.quaternion.copy( camRot.multiply( relative ) ); + + } +