From 4db97e37b70f2cb004b28196fb87a5a30e445c59 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 23 Feb 2026 07:18:44 -0800 Subject: [PATCH] Add ambient insect particles near water vegetation, fix firefly particles, and improve water foam - Spawn dark point-sprite insects buzzing around cattails/reeds/kelp/seaweed - Fix firefly M2 particles: exempt from alpha dampening and forced gravity - Make water shoreline/crest foam more irregular with UV warping and bluer tint --- assets/shaders/swim_insect.frag.glsl | 14 ++ assets/shaders/swim_insect.frag.spv | Bin 0 -> 1064 bytes assets/shaders/water.frag.glsl | 40 +++-- assets/shaders/water.frag.spv | Bin 30696 -> 32584 bytes include/rendering/m2_renderer.hpp | 4 + include/rendering/swim_effects.hpp | 29 ++++ src/rendering/m2_renderer.cpp | 53 ++++++- src/rendering/renderer.cpp | 3 + src/rendering/swim_effects.cpp | 209 ++++++++++++++++++++++++++- 9 files changed, 332 insertions(+), 20 deletions(-) create mode 100644 assets/shaders/swim_insect.frag.glsl create mode 100644 assets/shaders/swim_insect.frag.spv diff --git a/assets/shaders/swim_insect.frag.glsl b/assets/shaders/swim_insect.frag.glsl new file mode 100644 index 00000000..06ab430a --- /dev/null +++ b/assets/shaders/swim_insect.frag.glsl @@ -0,0 +1,14 @@ +#version 450 + +layout(location = 0) in float vAlpha; + +layout(location = 0) out vec4 outColor; + +void main() { + vec2 p = gl_PointCoord - vec2(0.5); + float dist = length(p); + if (dist > 0.5) discard; + float alpha = smoothstep(0.5, 0.05, dist) * vAlpha; + // Dark brown/black insect color + outColor = vec4(0.12, 0.08, 0.05, alpha); +} diff --git a/assets/shaders/swim_insect.frag.spv b/assets/shaders/swim_insect.frag.spv new file mode 100644 index 0000000000000000000000000000000000000000..6e849c37db723841574670ad733e44dbaaaf9eaf GIT binary patch literal 1064 zcmYk4+iDY06oz-3q{cKo+1iT7)M{0fqM#y36cx$MKnp&=5R#w+V8ErMr^#>-_mlCUH%*QP>gqGPWY|j&`?G=?irL;ZdYq@n2-KM0S>C7dL6!`sZ?bIM z&-rRt!}gQO6oalBE@Qjt=tH-No&V_78Bs&wF4-X^(rkX7iSToUn$o zmVGwpbdk8sy@LP!ThvzP+}5?Gy^P&Y&RK6_J@f9>?_m8a 0.01 && push.waveAmp > 0.0) { float foamDepthMask = 1.0 - smoothstep(0.0, 1.8, verticalDepth); + // Warp UV coords with noise to break up cellular regularity + vec2 warpOffset = vec2( + noiseValue(FragPos.xy * 2.5 + time * 0.08) - 0.5, + noiseValue(FragPos.xy * 2.5 + vec2(37.0) + time * 0.06) - 0.5 + ) * 1.6; + vec2 foamUV = FragPos.xy + warpOffset; + // Fine scattered particles - float cells1 = cellularFoam(FragPos.xy * 14.0 + time * vec2(0.15, 0.08)); - float foam1 = (1.0 - smoothstep(0.0, 0.10, cells1)) * 0.5; + float cells1 = cellularFoam(foamUV * 14.0 + time * vec2(0.15, 0.08)); + float foam1 = (1.0 - smoothstep(0.0, 0.12, cells1)) * 0.45; // Tiny spray dots - float cells2 = cellularFoam(FragPos.xy * 30.0 + time * vec2(-0.12, 0.22)); - float foam2 = (1.0 - smoothstep(0.0, 0.06, cells2)) * 0.35; + float cells2 = cellularFoam(foamUV * 28.0 + time * vec2(-0.12, 0.22)); + float foam2 = (1.0 - smoothstep(0.0, 0.07, cells2)) * 0.3; // Micro specks - float cells3 = cellularFoam(FragPos.xy * 55.0 + time * vec2(0.25, -0.1)); - float foam3 = (1.0 - smoothstep(0.0, 0.04, cells3)) * 0.2; + float cells3 = cellularFoam(foamUV * 50.0 + time * vec2(0.25, -0.1)); + float foam3 = (1.0 - smoothstep(0.0, 0.05, cells3)) * 0.18; // Noise breakup for clumping float noiseMask = noiseValue(FragPos.xy * 3.0 + time * 0.15); float foam = (foam1 + foam2 + foam3) * foamDepthMask * smoothstep(0.3, 0.6, noiseMask); foam *= smoothstep(0.0, 0.1, verticalDepth); - color = mix(color, vec3(0.92, 0.95, 0.98), clamp(foam, 0.0, 0.45)); + // Bluer foam tint instead of near-white + color = mix(color, vec3(0.68, 0.78, 0.88), clamp(foam, 0.0, 0.40)); } // ============================================================ @@ -324,11 +338,15 @@ void main() { // ============================================================ if (basicType > 0.5 && basicType < 1.5 && push.waveAmp > 0.0) { float crestMask = smoothstep(0.5, 1.0, WaveOffset); - float crestCells = cellularFoam(FragPos.xy * 6.0 + time * vec2(0.12, 0.08)); + vec2 crestWarp = vec2( + noiseValue(FragPos.xy * 1.8 + time * 0.1) - 0.5, + noiseValue(FragPos.xy * 1.8 + vec2(53.0) + time * 0.07) - 0.5 + ) * 2.0; + float crestCells = cellularFoam((FragPos.xy + crestWarp) * 6.0 + time * vec2(0.12, 0.08)); float crestFoam = (1.0 - smoothstep(0.0, 0.18, crestCells)) * crestMask; float crestNoise = noiseValue(FragPos.xy * 3.0 - time * 0.3); crestFoam *= smoothstep(0.3, 0.6, crestNoise); - color = mix(color, vec3(0.92, 0.95, 0.98), crestFoam * 0.35); + color = mix(color, vec3(0.68, 0.78, 0.88), crestFoam * 0.30); } // ============================================================ diff --git a/assets/shaders/water.frag.spv b/assets/shaders/water.frag.spv index c437d847f782e0d759583c9d2e35da5f48c5a3a3..5f1d56b1b98de9dc2b06e9828729bd0a7d61ea76 100644 GIT binary patch literal 32584 zcmZ{s2b^71^|lXWCP3)DNiWg`LJutglAwWv4uZlYbCV2AGQ&(l6D3FyQ3MoJiVA`> zX^Iq4s)&Ffhzcs8pwbjjQ4lQfJMl8JEB2~3e zwRp8;wR3mXJeI8%MyV?6t*IBUMmG6`Ne4{YVt7vP7TavQrH)HgBdnUXrK=^XF0f@* zPv30yMHa5Al_{%J7QwJJ@pqz(p)5mNed!!Df}X1Cemd={MpY9g9XVn0YF-uPT#=n2@?*b`RFkgL)d;feD-bnkEoWXp4i0Q)e6)@19NB0oZT26Zfc8Gt5Z*C z49sfG89MQRS$%V6ZbX#U53#zcRj}{dJ22-MPZHVT2^bGZ# z)Y!8zxIog_oP+hKdz-kcTAx~Ps-|rN>giD9Zvvj#Gdy$at(vRQv~P^on*Wj2=CN;m zLdyYe{LKm?M)?GE6h`{vAPFw$;z_RjxTTQvK$^VS$gRJ($Ew`%F* z#GG{cj2Dxf_`~4owU28*z4mdRO|N~XfQM_J{l&G*PCTKR|8-4#ViP|OcC$IIUx=G+yQ-(b?)BFB z(p5bdUfW-Q_cmq^%%ZmKo76)!evA5;Chn^KLp@{031p`q*MWtaoG7h!d%^o^JQ3^| zjdu{Zr-{3&gTaG6Lp|y1F!g}=_dGz%|1p{cY<9v&pWTRvm1RgW=xz+lg`#y&kY7O&J(&$M;?~ePBl6 z+sb#*mOjO)8wMx-V&LJKJ-q|Rw@pMJQT@vSa=1?a15fQ4?wfYtiGxmz>+&Ajl)1w* z^)Vm0$E<R@_jh!{v%uzq*NNxO3<5*x@cW%@V7;H3LLvz%>H8;o3?du)a z&cYlFG+5_gp55@_X+w?1?1Q|-pb!CVM*oqlDRVL~WLQ0`CD9L<)|lO>XG(2Fv{{Ye znck6GeqGIHht?gs)owL>dbv;a^|!pt!)FUSa2@0yfR}r8tq#5}e0IB!?rLN0{XHi( zhK$h4dn>e|zQMu%#sPDN8nb82VWwS|ZO|Qr`R>&A*=u&|9PX|rsP**r_4KzknEC8y zn~A;cb8HaqzR^SbiST}&Ycl3}@bZ3tM$KFI`>yJ2c&{gSGmoz7e0Y7YC*H{FBKS~a z`cTibIg_VPA8xeH-R|o0`FeAK^t~2s*mc;y)u0u#ySg!Y^WNSX?@egUHEU~K)%VcK z`}(c$)_r|sb$en=;$q|@LY?m&=;iv{*};Ff03TW113$ipTWUv~dl&HQsvhXz4=%vF ztB2ve=CNnr^wza_nns~3w?g>Bde#;d!`N#3=MkS*<+wV zB=^G0(Wf>~9h60?*U{$A?rjV;lONx{Dj5F_Y=<}|E>VpkwhMFPwMgBg*`$3fYU!bGk?O0~jC|5BKoj>ORcz9qJhU1PYW zVB=agqtV-3b=r=sx(LYis=t5W`1Uo<@sC39Z8QcWsx8yhnC~Ly%4p58jT`7~-IGRE zYoOKpwb9$Y9*nFuK_6hA$vjIy)UIj=^!~m%T#Op% zRj+pNzszq(9PXX+;o*A!jHtS~mBE|y>1R|w73RX}nl9P0XKOceSrN^>!R9)|N2t~{ zwyRnVeOl`gqS@C5az2N(>?5n4;M4gG(cIAc^bEJ2SG%j-(2wsK8l2y6S2Yo>T&sgR z_~CV|*52!?j)ME_)_VT$s%CchA6@(VQ0zPm#DD7+Kfc5N#M&Qo)BlwCH=pghsueoo4LH}%+)|qJ z;k%E4x$~bRog?3c9N0KvfoCJ%jqrY={TwyX9K859WBV>){&VqqEqMXWXV7IRX=hVH zwteKb+r2~UZ0BvFY$vzfJW6sP(_k5xGOs)2}LNmUa z5B=%a=V`TN3e9I}wYAYEaG%TPfeFl?&(rH-^9Wz3MVs@K=jV(!yxA{)_8YAnuNpti zes?eYGFN*R+KAe3VxeVTe6}-Z zuS09wi&p}9)7LhxiEY%^!FyS1 z+o)UgQ(u`{U7n)82Hdu4^ZmQ3jo|v*mwkH=2-kO0`j+oTIcBRPb_?Gfe$)jE@3t9j z%xf$~pWP|(*#o@Rwtv4n+;imS$9vjs_JZr99-j%|YX+~_HGCp`$4iFpNk4nT^-+({ zN5Ds%JFeYkANboFJ=^A!;jittLp%5V;G;(mxA_6^D+ixz^Ml|QU$pA(iGK+E#tA*` zc!$9sd-Ty=Vn4jeC$;%hcva2YeMRQ(T+rH54*lilHQ9WAJ$#|s?p#08w9jkj^GuV^ zAMY#hXO`Z(o!{&53-5Vqbnd>8m*leTZi*cGmidVKZ;vqRc>Om6aO_w;)>+_5{}Xgz3h z^=LC{KefdE6xziXUDRHq)8QQLIs9ys-`no{{3f5*_WyE|@7|uXi{PV2k8ZdBM$=yH z>YCmFUYp=bo|`uUJO|Xx$NFZ<`yAo7!};Mlx^Q&|AY*w7Y_1ls1^=LS4|uIvh5!E( zt|niTZ!5glsikQ9{}pq48@tyb+l{HY_eizm_72?hU+(^Vx3=5<-!(R;H5sM%E&G<2 zMc?<)y=LfRUvfLjb;NmD2<}+)U%0jzM?Mm66USqFx%Wo3Rf#eRtgpO0!dK@Cx;Q@i z8E*+{4>jY-y_b4q>`T|&xnCA+j!L#)31I9|6x++a_o{_^@0A>iZ|Cp>TmE;29}Fkk-R1UoEIji$ z1lH_)#(ok)+5Z>eqn^H^9sf)4i7%Yg-uIWn$#3WE*Kfhi(UKgl0v~_(Dedd})o?XS z=G|u!kF>c>e$=1GJJ0ulokR1E{nuz~o$;6U`S~08Yx~{No~Pf!&%J-{E}PNH^?3$v z8~wB;&hNohb#Hq-e}a!5eM>v;i*V+*9p@#uKE}|tCh>i~aSqKd#k~KD&QH_sIORU? zsHOk6AdfvdY1icWHr%-0L$t+jQ5J>k7jxJe&r)!&X=f>eBpZ2iaQ1F^)$tC?1$#{%Q3FW?(kX5b6nejVA5=Aj#2LOmzws2Ypj2A zn1jaJwD;7>@QW`xti1+j!v~fdF(!NJb8t0FVtgL_#>w;d)H!f9OZJq{bslN+75HaX zntu=YD*T&!t<>)KBKWUPc(uLO7sG9%pSHxg63qR*J;ztWd46cG;r(#Wo0QD$v*_N_ zQWEd4=+mda)Qsmj`X=1@a~$q{pGT3~YqS*naQFt;+>41b2Aw$V@$3ob+-;u|`@*@y zjLW=EhO1c;<1jE|YsZ)h=R_QvHVwFY%-CsjY}4oYw$B{6*CC&a;&T$(#z&mn&gIl* zn~CjbjML%tdsq571MWRoJu$8Ye`19#+PPf^U*X0HZT@XI&tdIz8xiYTwuyRn1&{YYu|`;n6S z9ZAXkZlvUQc5uHNDeZnYQu2E{_yZmM!4B?sB4zuZb?_$(?s)zFBmVN2JNWAbcRu`n zq4f7Vg_3u%X-ht;;I?0;;P&VD2c^H?9fa$@Nx}8sq2Sv6t|0!}{hlD)`<>qrguC9o z1-JbP1-HHL_T#VJ_xC0Dy?x1jUmtFJ-_?iP-gouk+I?SN@|z2;zwhf~m;1iHOS|vw!?pX)KHT~AU46KA-_@7gcl9OrU46;B zx!8r9kMHKgjpuv$aP7X257)jy2lst^?B?ft_>%h$zU01xFS+mF!;ODb!HwrT_|on> z_>vD4Tz}uemv-O5m)v*o;rjayKHUC&2Ve4Y3$DNK-%Gpi-%IZM_i+7v_g-?}yO-Se z?&0?Dd-rhLzgTeXzH^UV?)&y|{e9OSuHBni$$igWa^JJd$MPKOXVU*r{2c3NS3j@r z!hO$Y^Z$ai?aH0jd!l@-_fM`zBPiZT7ox#H2r`7rF0M=jKd2!$G2)4~Qigx?-nM*z68Ur?u z@SVZ>oI*dw*afV12K~r)1FQL*oPKr(+t2E?J-%bnjj!FleZEsmU*o`PcM{w8UweYp ze2?%i{Kr#!{F_{~?L|@ZIah2P_s2wVId6Nz)hr(6ynPt0oVSm_^;b_`lfcfw63nz? z-WP0)jVRjop=2LS25YzEoSgzTt~SR#fm$u`_Xj)f%+mp2HRm)w2Z7BYK8JwS4yMHC zP_XA?d=7)F`Cij~VeTJ=+g4lF>u|8+S+cgL{SoK~QnaU!kAcfPJ`Pta^EeV-=5Z8U zt<0kbZd+~1V=CA@yl!Yu9@D_a*M4d}j$W|&X^US2?A&Q{p7*0xGk5pJbnv=0_nesl zb}ii7@|j?@jI|GJj4#lq_M^f2H1#@1tvQxl%VweJtIhW+j>DX0!_7;( z^Pn~WwyoDM?T*E~)!${WXU|)E-x{JGrg(ihmfHNiH_fT_$@O(}E?mvxVIRWG&v6jX z8~2KN91pgQdU87foZPgV+aR@ia`^<Q4lglT;Y8H>=;#%1Er_l9Dzo&rH zuQvNVky<_Zp9;?U8spP&bGx8km(#%dsOMZh9c*0BW$k-Vj6IK9TVkIHE@OWNZvTmW z7FZwk#QrQev9-JI#y$hAE%Ws`aOc{69!)*-bq-j~;*t4EeqTT@*X~@nZPasqod>qv zIEr?2Ih$JDI9?wv06X?vFD?W>QtKJlm(h&l-x_FN1Cn#_EATH-^l^_H^Q+)5QncCU z`P6F3{bI0lo9B;9z-kr``$~RaL$iJQ`#ShiihBC{2H5_z<=njtY+P;Ta1phdaXq)c z3GOcV6<~dgsr^c@KI%S)s9gg#w@qvNx8PS(jDI<`cH=KjeH~@Vn%nl!?TLRg_@pYr?I+;m^B`EBd>#UukMV4$-T3!VYfFqrz~&hK zQ?Ncce;x(vqn`8UF|cdtHAj1{3lD>}IaWXKJPsbDlw*AYuAX!H=U`*;QK`~qjz6PT zPfou88$0}$V11I$ufWMio9mwQ>nXVH^)dF7)N10Vsh^=F?z3P&ALaS&Ik?*CXz}@7 z?Q;iX^cnAYxZ3Y2SJ&DLVB2c*x$lqE9zOSJ`vXPI=RR?Ac?s0R@{>|+Tu_4KhMSj|%Q;XJ!eOQY-K zcXRgPJ506A&9dNfotA^EWt~<4tNHy()@em>)=7KTX(h1hq}{%JKdP2>S_SMF!&e2H zPjXoeY@4jp>R`2$tkVb3vQEY{j=uKkJ6P@MV@? zoV(F*+obPp!D`!3GI!g7)l)Ke+oNUf?8n&pX703mea#$LC$AmB=8~d=J)!p zi_bnc96`Ft4oVBs6_evYz|Y?YzF4hkfgt_0%rjmpacNlfhFc&hNq0hfofp z=_Ml^=zxc|<$B)*MH)BWlexpTcvXYxglU+v~60bK!h)(e`n; zed}Z1`%|l#tItbEg4H&!_kweG6kI*eBt2j?f49POd>Yupb6ne0ikj!R*fH9718nSk zMwt#)vv@eJUmGp2UfG>J<-u%<7&(2mSe!`t@Unw7U{2f_NLGKv(WU_ z=G^pBt0kw|U^Rb_FnezRuAXbpAlUg({v30&=BivAZ; zt7Xo=23E`5d>x#*IU9U9G4+Z6H^9zE*5ER*ZPbl_DYaVixg2ahxmSD>tmgTs|75UQ z;$HzaM&f@9tac?u|9z>|ythuH_S$2u?JDZ4De3!K@QL;H&AGV_J_tXRxsiVxu8(>? zw_Fc){Ju}rZrp3A)f4w7u;;P18^Q9}z5~`@+s)MSjO`Y%IcobZwLG7pzX$f;$1tAl zwCi^RwYJ2#9Xzk@`!=vVw(o>q)(i65Z8pORc31Si)YgXQLG4nF~} zLv4H8X*b4Q)Y=l`VX$)-{s`FdWMBOhtdDxe^C;Lj*{hF%)$~oApMjnK#CaS(m3-9W z^90y+h|kaA`l-A3yv95UR^J1^Je&LiuAbt(&N2TI&0Ms5og1RfufUzpa!;YD=eqGU zSk2<$I?%M4mvMiMZfyOM`)|PJo;mm}Tt9W^`5|hx%-J(wHP_pi&w=e{1B$k1DQTxI z?S2pTIVktS=izD=4`bwB_yU^tsc6ab5AgDPlRv`MysqTA;ZJaNi}UXF_C>Jqv?bO{ z;4;?Ba5dkvnENYW+lgPL*6w$=uH&D<{`)BUXw&C+)ar@<8dxpQ4zGjNHXwe^lQ-aw z+4DrZ=ZWiYAAf^u|0^Z_e+TRDdu8qZEr5H&@ACfv_p|w%)XCwWVD;?Dx4_0wkM=fL z-RC{W@fWaKV*CrNmOc0mxN{G_i>97E_;0Y9#lxIQt-ZhggRYOU?Z-XzU$A@1HIx4j ztfsGdX;X{;`(Up_;e=^@-%-a~$QOQ;T&ETW+eSU}IRb2M&cAl&Ken#g=3MJHmD)Vs zBZjuj&q%OZ_@Z!s7d7*{7+B4hQ>Us896>L8~M`?FH{A_1@e?LjP@$G+g@EY}8%Rd00T+IC%aNFeCx~Bd` zu}|Brg=U|A?lY!w^tDfa2TFVTTpR4(3ttEBxU)~!1?!_8pY_1TiO>3AHGR{En*Pqy zhG6$h=H!E5^Yi)qbigCEPv&tW?E1XOTpQ2d(^5;kjlpV}hfTn0DamtFv@*}l;A;9O zPqoC|9PIn+ybsy}uI4o}J|BYn&M2QJw}h+t&M5D%w}RVNo1Za9Q+xOsQ`^=QHJ|gu z?%{=LzYW+8c{@3zzirXf)8BSrHTRLvi-}|X7Iy>Rsc#RqpJ}YC{`O%WJ5qa?hdw({ z)XYPiK6eH?#;n&aaKg4fPmO`=lWYI3VAoJT=hfe%Q!}3Xad&WeUmOcpvv?Rg_r-B& z+7G7xOR}p$I)Q5jCBxPj&%sGc5LnA z-W&#(W1S11Ls8FIj|1CQoBbS5t(LK#0CueE+6SoBGS*Lk)iTzT!R1&_g6os9eiH0h z^~+d41y;{J?L;vpW1R;s$NFiw+NrgVV?7OAj`d9V85H%5^)q1GYI7`SQLAOFp9MQs zb?v86t7WX81FL1Mp9hy?J)E}sWUS|a9jksB>leW4SJ&%$HdxI*yw05mF3*qi;c6BS z`^(<_BARxeo08*~;N>;p0=U}p^q2GYLb$pmbMs}e@w7SiuTZOHZoUfknxL-zTxzxS zdokEO8h#1bbu_-~b17IK_4s@Z?D*sJb+CTw@%aXLCrak;GO&K?@wpsqK3SJ67NcIdGGobJn_6|`Z++K_+JG!M&e%$RQyt27|t>5js2~D3A+Zp%fx}DE#_HjK$UvscO?cVQqr1pMo zoiTg|Tz>EVUAWrmBod!nYM(sYd=IYXvrV2`ZiU-co6ifkQ+xQlpzSt_n*EE%vOj!o zxdV)}z;ny@(bV(Yawk|V&n<~#o#&PxfbGYz>2DwAaTm3RdFb;)ikf+dGsb(si%_x_ z_ky$b&V}c*KF;ag)Y`MI_k+uG_eXFwi-&P??mmE~eGDai{}^1JyAQ(E+;2H|e*#yx zn|^)R(s&fQ1Ap1bO~HvJT=p7nMAJPI%Ke+;f>@hJ2E8Jc$UPaZ!9t3N@> zn4SceWBLVL?IcRZ^h>b1C9!@5Hl8-e^c1yP#`HAUF{x)vzXq#2ro{dY+_7rQ-2WD= z?%z)u`#!K**6~?zISM=Z$^88p?Ecm-G5!K} zpXb{48dyJd`+S*NO@H^$8{l&8{t8#Kc-VjDDZm^oy{=Bmp2{*3|DDESDGGB{;mtntUeiyBM z9fSSbRv*Ves+IP%9R*ekU%ckYWl6Z2e^;AamI9Z#=#%zKgM9~+T$ZVQ&BeZKt54=* zS+MU7PogBoa%k$wVR^7|)E$F=SELsImB9XfNBUeDZhoGN{{63i^Q%w%R{!E9l-};5$dE}TrHbB>wJ~jl~Rz2^1KM1yOZ5i)I;EY$h&j{{8$Lrs8 zX?H#Sj$jk8Ic`jCzkWBrDOf%4`8ETqSv-tQ)26@Ixy{kb^LY!n{p5Yphrntn=4rbv z(TwF@vk&9wYo5l_Zch4+2A6$o19y${yARuf_3?Gyw)sD?cauL-<9IEIec6PA75}| ze7J*8>frl!@F^X9{|20;MdjMb#U&+QpC>NUNv^k_O7w>^^qDoSNqi1d74~f=V-qgJ3sy#RK|2}{P(BC z&Wr!Xg4j99_Z-g0;S~3y>r)@~Xmi2pWp4hP(Z<%6 z+>Qs^zczDo&#EQ26T!}9_(@>bG~Wq*0<4dE@;w>s-b*f@1nZ}s-%b1!*lW;I6z%ry z`JtY^=7F87@KeFAS^EAocpgPPeV+!NL`mPLgY{ES-)Df+w|4vXTvShAp8-1;;b(ze zll1*rus-VP`)si1R{H)NSU>gj{dsWu)^6WtQmZG<7r@Si_oKzA&!;%&=TYMG#ad5a zUxM3CJw6wJ%XPRAuAh2*z6|#KPMoiR^;3_}SHaFt^0^4?I+Xcb3|CK_OTe~MPn=7^ z<=T7=uAh2*z79T=k~rT0>!?^^xQ%{_4 zfy*_#3a+1ee69w6l#)2tfb~;Pj@N=OqiA!D&!JXJjBkU>HNK(gx8U>DjcDrW=O(c2 z)D!1saJk0cf$OIppYMXpHNFL|pL%@02QJt6R=8_?ec^K(SUvgN4z`_o;@kl)*ZBKz z{nX=gC%9bWAHelf&;0!mY@XWE?k;e-#&^SA<1)v4;OfcoUa;-d6X!l~xyJXy@1>~6 z=SSdjjURyPr=A>t3^q?~uJLu$YKie6*mckEZ2ttT7W+eB+r<7bSS`tBZhPnIc}o2M z09FhCBiQ`&8Rt)6HTOVbz6iFRw(Q53!0P(hk87ZoxG#f^8~!R-pTzt#_!WwJeEtG1 zeO`m>r=EV_0IR=F$!D;?g6&V6`%_--&(+}`D^uK`?!#3IylR101G_&zK%M>hCb-<6 zZ*}nhbnq?~QvZzWA7JBV4F3eHW$uix7XP=wYT^H?dFJjNxIXHcgMWk7-=(-7=BSn! z{{=hd@c(u2_u#h4K6oE&e(LGh4Qe}WiL(%z?J~B7;rgj3w-I3b*Jf_cms(+$hwG=F+?D{_zczDo?bH%uNwE2bF9p{p{VomG zN8PdCjqn_V}&>*4KXYT@|jsdU9C}>{yb^>Tq*W&%2Nhfca^@ z`_cXkSf5WwLmdsAw2_FANmT(<{n+l~_79l-oF*G#*)sg=2Hj@_foZ8NyJZ9$#f#?VeZx$Op5+m(|3 zb_es*%uRc88w=J}<~9z_Pct{|=B8HWwk39tGPe)G&21~{yhqxLcDXk=w-XEQedeHo zYd^T)+7BzZ_Ky`@`;i6LKCR%|8wJc({2xPv$uR&QEh6X!Dxq7}V2dZ*bY> z!*G6@^QFx;j#E8tJ_1$?pHy?#!G8CF>!Ti@DYZ}Dwd@C1^SiKozp_8r?_?~V2gW)8 ztge6J9|*P&ZOKn9eH{XJoZ4IidA^T16zqATuH7|LOWTivZR<72{*M5w9Zu2THB(Ex zkAsb;uH7|OOT44NWxT0ywH}K8uCZF;^@5G3uH8MMmUz>_WxSbiwHXxs-4kkwcQn{| z>e?IBYR2;#dJNb(2=50whOvy_JvR&NTAv4w&uqAQuKNREewyd8@r4w#>2AI39=K8Z0GY>exRJdcB`C&uw$ews1N*_irxP0&_e6Sl?A zV{1yTrK4-?ylg}9`mh~!o*hpjhI=O0i%)m(b36FgYVNw&*88P%WQDC%>%1jvZqf48&8{ko2Od#%xPe+iRy`c23Y-cO8P$&Y~IFo zFMS5ArfT9k1;+6F30qFxSIWDOy_{rExAs70c<>N_U&A&WlZOR z9g}*-^hL1x`IL<5OW<-$7r@o@&6w0OrVGL4n7#s6`*Q8$n7#@w$8<4V?IKFXbO~79 zl6ATiY&>oD?S4_qn7$5nOzIiaWnlGhP%@^=!R45~30KoMV^WL%6=3~6FT~|}u_OI? zl;_3v@H}(v0QS7ti8|-ORm3RIi)#z+xpaNOZv@}e!S5`%_r3c&_(L81(GLDt2Y;gA z_Ww-Hv!++W9aHY3*TDH{-bb}LXO2ldZLR~CZN3fXr+FXMW*f(?E_Te1U>7^yn<;*G z{vB%P&-!hy1-DRtkK&>I*4l1u-#1X-Mlr@MoQHQ%f4|mlr}p=beVtG@u|r}@rT zyM24Usb@@o0DC@!{}HTDJ~#de%un;PnZDYMV;gOr7w%Q}n6+^{FJ7d$$L4Wf$X}{8 z_owZ(so6gJ-@PjLoHL$v;=K%ZPbS_gV70{4re^z`$DVT;*Q;Q2_PQ!Aud6#VK97wk zdEOWUHiuoPb1i+1cDa^%?Rul;_M!ed+;)ynU%6O6_euUv?yqpK<@#tdCpB~Wf4AHJ AqyPW_ literal 30696 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 x}ejJnUmwbnT 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 Vw$JtDwUBZB5^T<%X=2Z`{|D`|UjYCB diff --git a/include/rendering/m2_renderer.hpp b/include/rendering/m2_renderer.hpp index 784f357e..c2a663b5 100644 --- a/include/rendering/m2_renderer.hpp +++ b/include/rendering/m2_renderer.hpp @@ -76,6 +76,8 @@ struct M2ModelGPU { bool isSmallFoliage = false; // Small foliage (bushes, grass, plants) - skip during taxi bool isInvisibleTrap = false; // Invisible trap objects (don't render, no collision) bool isGroundDetail = false; // Ground clutter/detail doodads (special fallback render path) + bool isWaterVegetation = false; // Cattails, reeds, kelp etc. near water (insect spawning) + bool isFireflyEffect = false; // Firefly/fireflies M2 (exempt from particle dampeners) // Collision mesh with spatial grid (from M2 bounding geometry) struct CollisionMesh { @@ -307,6 +309,8 @@ public: void setInsideInterior(bool inside) { insideInterior = inside; } void setOnTaxi(bool onTaxi) { onTaxi_ = onTaxi; } + std::vector getWaterVegetationPositions(const glm::vec3& camPos, float maxDist) const; + private: bool initialized_ = false; bool insideInterior = false; diff --git a/include/rendering/swim_effects.hpp b/include/rendering/swim_effects.hpp index 15858258..20d63176 100644 --- a/include/rendering/swim_effects.hpp +++ b/include/rendering/swim_effects.hpp @@ -11,6 +11,7 @@ namespace rendering { class Camera; class CameraController; class WaterRenderer; +class M2Renderer; class VkContext; class SwimEffects { @@ -25,6 +26,7 @@ public: const WaterRenderer& water, float deltaTime); void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet); void spawnFootSplash(const glm::vec3& footPos, float waterH); + void setM2Renderer(M2Renderer* renderer) { m2Renderer = renderer; } private: struct Particle { @@ -36,14 +38,30 @@ private: float alpha; }; + struct InsectParticle { + glm::vec3 position; + glm::vec3 orbitCenter; // vegetation position to orbit around + float lifetime; + float maxLifetime; + float size; + float alpha; + float phase; // random phase offset for erratic motion + float orbitRadius; + float orbitSpeed; + float heightOffset; // height above plant + }; + static constexpr int MAX_RIPPLE_PARTICLES = 200; static constexpr int MAX_BUBBLE_PARTICLES = 150; + static constexpr int MAX_INSECT_PARTICLES = 50; std::vector ripples; std::vector bubbles; + std::vector insects; // Vulkan objects VkContext* vkCtx = nullptr; + M2Renderer* m2Renderer = nullptr; // Ripple pipeline + dynamic buffer VkPipeline ripplePipeline = VK_NULL_HANDLE; @@ -61,14 +79,25 @@ private: VmaAllocationInfo bubbleDynamicVBAllocInfo{}; VkDeviceSize bubbleDynamicVBSize = 0; + // Insect pipeline + dynamic buffer + VkPipeline insectPipeline = VK_NULL_HANDLE; + VkPipelineLayout insectPipelineLayout = VK_NULL_HANDLE; + ::VkBuffer insectDynamicVB = VK_NULL_HANDLE; + VmaAllocation insectDynamicVBAlloc = VK_NULL_HANDLE; + VmaAllocationInfo insectDynamicVBAllocInfo{}; + VkDeviceSize insectDynamicVBSize = 0; + std::vector rippleVertexData; std::vector bubbleVertexData; + std::vector insectVertexData; float rippleSpawnAccum = 0.0f; float bubbleSpawnAccum = 0.0f; + float insectSpawnAccum = 0.0f; void spawnRipple(const glm::vec3& pos, const glm::vec3& moveDir, float waterH); void spawnBubble(const glm::vec3& pos, float waterH); + void spawnInsect(const glm::vec3& vegPos); }; } // namespace rendering diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index f41390f3..ab53532e 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -1105,6 +1105,19 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { // Spell effect models: particle-dominated with minimal geometry (e.g. LevelUp.m2) gpuModel.isSpellEffect = hasParticles && model.vertices.size() <= 200 && model.particleEmitters.size() >= 3; + // Water vegetation: cattails, reeds, bulrushes, kelp, seaweed, lilypad near water + gpuModel.isWaterVegetation = + (lowerName.find("cattail") != std::string::npos) || + (lowerName.find("reed") != std::string::npos) || + (lowerName.find("bulrush") != std::string::npos) || + (lowerName.find("seaweed") != std::string::npos) || + (lowerName.find("kelp") != std::string::npos) || + (lowerName.find("lilypad") != std::string::npos); + // Firefly effect models: particle-based ambient glow (exempt from dampeners) + gpuModel.isFireflyEffect = + (lowerName.find("firefly") != std::string::npos) || + (lowerName.find("fireflies") != std::string::npos) || + (lowerName.find("fireflys") != std::string::npos); // Build collision mesh + spatial grid from M2 bounding geometry gpuModel.collision.vertices = model.collisionVertices; @@ -2803,6 +2816,20 @@ glm::vec3 M2Renderer::interpFBlockVec3(const pipeline::M2FBlock& fb, float lifeR return fb.vec3Values.back(); } +std::vector M2Renderer::getWaterVegetationPositions(const glm::vec3& camPos, float maxDist) const { + std::vector result; + float maxDistSq = maxDist * maxDist; + for (const auto& inst : instances) { + auto it = models.find(inst.modelId); + if (it == models.end() || !it->second.isWaterVegetation) continue; + glm::vec3 diff = inst.position - camPos; + if (glm::dot(diff, diff) <= maxDistSq) { + result.push_back(inst.position); + } + } + return result; +} + void M2Renderer::emitParticles(M2Instance& inst, const M2ModelGPU& gpu, float dt) { if (inst.emitterAccumulators.size() != gpu.particleEmitters.size()) { inst.emitterAccumulators.resize(gpu.particleEmitters.size(), 0.0f); @@ -2867,11 +2894,20 @@ void M2Renderer::emitParticles(M2Instance& inst, const M2ModelGPU& gpu, float dt // particles pile up at the same position. Give them a drift so they // spread outward like a mist/spray effect instead of clustering. if (std::abs(speed) < 0.01f) { - p.velocity = rotMat * glm::vec3( - distN(particleRng_) * 1.0f, - distN(particleRng_) * 1.0f, - -dist01(particleRng_) * 0.5f - ); + if (gpu.isFireflyEffect) { + // Fireflies: gentle random drift in all directions + p.velocity = rotMat * glm::vec3( + distN(particleRng_) * 0.6f, + distN(particleRng_) * 0.6f, + distN(particleRng_) * 0.3f + ); + } else { + p.velocity = rotMat * glm::vec3( + distN(particleRng_) * 1.0f, + distN(particleRng_) * 1.0f, + -dist01(particleRng_) * 0.5f + ); + } } const uint32_t tilesX = std::max(em.textureCols, 1); @@ -2917,7 +2953,8 @@ void M2Renderer::updateParticles(M2Instance& inst, float dt) { gpu.sequences, gpu.globalSequenceDurations); // When M2 gravity is 0, apply default gravity so particles arc downward. // Many fountain M2s rely on bone animation (.anim files) we don't load yet. - if (grav == 0.0f) { + // Firefly/ambient glow particles intentionally have zero gravity — skip fallback. + if (grav == 0.0f && !gpu.isFireflyEffect) { float emSpeed = interpFloat(pem.emissionSpeed, inst.animTime, inst.currentSequenceIndex, gpu.sequences, gpu.globalSequenceDurations); @@ -2985,12 +3022,12 @@ void M2Renderer::renderM2Particles(VkCommandBuffer cmd, VkDescriptorSet perFrame float alpha = std::min(interpFBlockFloat(em.particleAlpha, lifeRatio), 1.0f); float rawScale = interpFBlockFloat(em.particleScale, lifeRatio); - if (!gpu.isSpellEffect) { + if (!gpu.isSpellEffect && !gpu.isFireflyEffect) { color = glm::mix(color, glm::vec3(1.0f), 0.7f); if (rawScale > 2.0f) alpha *= 0.02f; if (em.blendingType == 3 || em.blendingType == 4) alpha *= 0.05f; } - float scale = gpu.isSpellEffect ? rawScale : std::min(rawScale, 1.5f); + float scale = (gpu.isSpellEffect || gpu.isFireflyEffect) ? rawScale : std::min(rawScale, 1.5f); VkTexture* tex = whiteTexture_.get(); if (p.emitterIndex < static_cast(gpu.particleTextures.size())) { diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index ba26f5bb..bd66a99a 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -3359,6 +3359,9 @@ bool Renderer::loadTestTerrain(pipeline::AssetManager* assetManager, const std:: if (!m2Renderer) { m2Renderer = std::make_unique(); m2Renderer->initialize(vkCtx, perFrameSetLayout, assetManager); + if (swimEffects) { + swimEffects->setM2Renderer(m2Renderer.get()); + } } if (!wmoRenderer) { wmoRenderer = std::make_unique(); diff --git a/src/rendering/swim_effects.cpp b/src/rendering/swim_effects.cpp index b2837167..804917c3 100644 --- a/src/rendering/swim_effects.cpp +++ b/src/rendering/swim_effects.cpp @@ -2,6 +2,7 @@ #include "rendering/camera.hpp" #include "rendering/camera_controller.hpp" #include "rendering/water_renderer.hpp" +#include "rendering/m2_renderer.hpp" #include "rendering/vk_context.hpp" #include "rendering/vk_shader.hpp" #include "rendering/vk_pipeline.hpp" @@ -152,6 +153,50 @@ bool SwimEffects::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayou } } + // ---- Insect pipeline (dark point sprites) ---- + { + VkShaderModule vertModule; + if (!vertModule.loadFromFile(device, "assets/shaders/swim_ripple.vert.spv")) { + LOG_ERROR("Failed to load insect vertex shader"); + return false; + } + VkShaderModule fragModule; + if (!fragModule.loadFromFile(device, "assets/shaders/swim_insect.frag.spv")) { + LOG_ERROR("Failed to load insect fragment shader"); + return false; + } + + VkPipelineShaderStageCreateInfo vertStage = vertModule.stageInfo(VK_SHADER_STAGE_VERTEX_BIT); + VkPipelineShaderStageCreateInfo fragStage = fragModule.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT); + + insectPipelineLayout = createPipelineLayout(device, {perFrameLayout}, {}); + if (insectPipelineLayout == VK_NULL_HANDLE) { + LOG_ERROR("Failed to create insect pipeline layout"); + return false; + } + + insectPipeline = PipelineBuilder() + .setShaders(vertStage, fragStage) + .setVertexInput({binding}, attrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_POINT_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS) + .setColorBlendAttachment(PipelineBuilder::blendAlpha()) + .setMultisample(vkCtx->getMsaaSamples()) + .setLayout(insectPipelineLayout) + .setRenderPass(vkCtx->getImGuiRenderPass()) + .setDynamicStates(dynamicStates) + .build(device); + + vertModule.destroy(); + fragModule.destroy(); + + if (insectPipeline == VK_NULL_HANDLE) { + LOG_ERROR("Failed to create insect pipeline"); + return false; + } + } + // ---- Create dynamic mapped vertex buffers ---- rippleDynamicVBSize = MAX_RIPPLE_PARTICLES * 5 * sizeof(float); { @@ -179,10 +224,25 @@ bool SwimEffects::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayou } } + insectDynamicVBSize = MAX_INSECT_PARTICLES * 5 * sizeof(float); + { + AllocatedBuffer buf = createBuffer(vkCtx->getAllocator(), insectDynamicVBSize, + VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, VMA_MEMORY_USAGE_CPU_TO_GPU); + insectDynamicVB = buf.buffer; + insectDynamicVBAlloc = buf.allocation; + insectDynamicVBAllocInfo = buf.info; + if (insectDynamicVB == VK_NULL_HANDLE) { + LOG_ERROR("Failed to create insect dynamic vertex buffer"); + return false; + } + } + ripples.reserve(MAX_RIPPLE_PARTICLES); bubbles.reserve(MAX_BUBBLE_PARTICLES); + insects.reserve(MAX_INSECT_PARTICLES); rippleVertexData.reserve(MAX_RIPPLE_PARTICLES * 5); bubbleVertexData.reserve(MAX_BUBBLE_PARTICLES * 5); + insectVertexData.reserve(MAX_INSECT_PARTICLES * 5); LOG_INFO("Swim effects initialized"); return true; @@ -220,11 +280,26 @@ void SwimEffects::shutdown() { bubbleDynamicVB = VK_NULL_HANDLE; bubbleDynamicVBAlloc = VK_NULL_HANDLE; } + + if (insectPipeline != VK_NULL_HANDLE) { + vkDestroyPipeline(device, insectPipeline, nullptr); + insectPipeline = VK_NULL_HANDLE; + } + if (insectPipelineLayout != VK_NULL_HANDLE) { + vkDestroyPipelineLayout(device, insectPipelineLayout, nullptr); + insectPipelineLayout = VK_NULL_HANDLE; + } + if (insectDynamicVB != VK_NULL_HANDLE) { + vmaDestroyBuffer(allocator, insectDynamicVB, insectDynamicVBAlloc); + insectDynamicVB = VK_NULL_HANDLE; + insectDynamicVBAlloc = VK_NULL_HANDLE; + } } vkCtx = nullptr; ripples.clear(); bubbles.clear(); + insects.clear(); } void SwimEffects::recreatePipelines() { @@ -240,6 +315,10 @@ void SwimEffects::recreatePipelines() { vkDestroyPipeline(device, bubblePipeline, nullptr); bubblePipeline = VK_NULL_HANDLE; } + if (insectPipeline != VK_NULL_HANDLE) { + vkDestroyPipeline(device, insectPipeline, nullptr); + insectPipeline = VK_NULL_HANDLE; + } // Shared vertex input: pos(vec3) + size(float) + alpha(float) = 5 floats VkVertexInputBindingDescription binding{}; @@ -319,6 +398,33 @@ void SwimEffects::recreatePipelines() { vertModule.destroy(); fragModule.destroy(); } + + // ---- Rebuild insect pipeline ---- + { + VkShaderModule vertModule; + vertModule.loadFromFile(device, "assets/shaders/swim_ripple.vert.spv"); + VkShaderModule fragModule; + fragModule.loadFromFile(device, "assets/shaders/swim_insect.frag.spv"); + + VkPipelineShaderStageCreateInfo vertStage = vertModule.stageInfo(VK_SHADER_STAGE_VERTEX_BIT); + VkPipelineShaderStageCreateInfo fragStage = fragModule.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT); + + insectPipeline = PipelineBuilder() + .setShaders(vertStage, fragStage) + .setVertexInput({binding}, attrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_POINT_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS) + .setColorBlendAttachment(PipelineBuilder::blendAlpha()) + .setMultisample(vkCtx->getMsaaSamples()) + .setLayout(insectPipelineLayout) + .setRenderPass(vkCtx->getImGuiRenderPass()) + .setDynamicStates(dynamicStates) + .build(device); + + vertModule.destroy(); + fragModule.destroy(); + } } void SwimEffects::spawnRipple(const glm::vec3& pos, const glm::vec3& moveDir, float waterH) { @@ -384,6 +490,31 @@ void SwimEffects::spawnBubble(const glm::vec3& pos, float /*waterH*/) { bubbles.push_back(p); } +void SwimEffects::spawnInsect(const glm::vec3& vegPos) { + if (static_cast(insects.size()) >= MAX_INSECT_PARTICLES) return; + + InsectParticle p; + p.orbitCenter = vegPos; + p.phase = randFloat(0.0f, 6.2832f); + p.orbitRadius = randFloat(0.5f, 2.0f); + p.orbitSpeed = randFloat(1.5f, 4.0f); + p.heightOffset = randFloat(0.5f, 3.0f); + p.lifetime = 0.0f; + p.maxLifetime = randFloat(3.0f, 8.0f); + p.size = randFloat(2.0f, 3.0f); + p.alpha = randFloat(0.6f, 0.9f); + + // Start at orbit position + float angle = p.phase; + p.position = vegPos + glm::vec3( + std::cos(angle) * p.orbitRadius, + std::sin(angle) * p.orbitRadius, + p.heightOffset + ); + + insects.push_back(p); +} + void SwimEffects::update(const Camera& camera, const CameraController& cc, const WaterRenderer& water, float deltaTime) { glm::vec3 camPos = camera.getPosition(); @@ -438,6 +569,23 @@ void SwimEffects::update(const Camera& camera, const CameraController& cc, bubbles.clear(); } + // --- Insect spawning near water vegetation --- + if (m2Renderer) { + auto vegPositions = m2Renderer->getWaterVegetationPositions(camPos, 60.0f); + if (!vegPositions.empty()) { + // Spawn rate: ~4/sec per nearby vegetation cluster (capped by MAX_INSECT_PARTICLES) + float spawnRate = std::min(static_cast(vegPositions.size()) * 4.0f, 20.0f); + insectSpawnAccum += spawnRate * deltaTime; + while (insectSpawnAccum >= 1.0f && static_cast(insects.size()) < MAX_INSECT_PARTICLES) { + // Pick a random vegetation position to spawn near + int idx = static_cast(randFloat(0.0f, static_cast(vegPositions.size()) - 0.01f)); + spawnInsect(vegPositions[idx]); + insectSpawnAccum -= 1.0f; + } + if (insectSpawnAccum > 2.0f) insectSpawnAccum = 0.0f; + } + } + // --- Update ripples (splash droplets with gravity) --- for (int i = static_cast(ripples.size()) - 1; i >= 0; --i) { auto& p = ripples[i]; @@ -487,6 +635,42 @@ void SwimEffects::update(const Camera& camera, const CameraController& cc, } } + // --- Update insects (erratic orbiting flight) --- + for (int i = static_cast(insects.size()) - 1; i >= 0; --i) { + auto& p = insects[i]; + p.lifetime += deltaTime; + if (p.lifetime >= p.maxLifetime) { + insects[i] = insects.back(); + insects.pop_back(); + continue; + } + + float t = p.lifetime / p.maxLifetime; + float time = p.lifetime * p.orbitSpeed + p.phase; + + // Erratic looping: primary orbit + secondary wobble + float primaryAngle = time; + float wobbleAngle = std::sin(time * 2.3f) * 0.8f; + float radius = p.orbitRadius + std::sin(time * 1.7f) * 0.3f; + + float heightWobble = std::sin(time * 1.1f + p.phase * 0.5f) * 0.5f; + + p.position = p.orbitCenter + glm::vec3( + std::cos(primaryAngle + wobbleAngle) * radius, + std::sin(primaryAngle + wobbleAngle) * radius, + p.heightOffset + heightWobble + ); + + // Fade in/out + if (t < 0.1f) { + p.alpha = glm::mix(0.0f, 0.8f, t / 0.1f); + } else if (t > 0.85f) { + p.alpha = glm::mix(0.8f, 0.0f, (t - 0.85f) / 0.15f); + } else { + p.alpha = 0.8f; + } + } + // --- Build vertex data --- rippleVertexData.clear(); for (const auto& p : ripples) { @@ -505,10 +689,19 @@ void SwimEffects::update(const Camera& camera, const CameraController& cc, bubbleVertexData.push_back(p.size); bubbleVertexData.push_back(p.alpha); } + + insectVertexData.clear(); + for (const auto& p : insects) { + insectVertexData.push_back(p.position.x); + insectVertexData.push_back(p.position.y); + insectVertexData.push_back(p.position.z); + insectVertexData.push_back(p.size); + insectVertexData.push_back(p.alpha); + } } void SwimEffects::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) { - if (rippleVertexData.empty() && bubbleVertexData.empty()) return; + if (rippleVertexData.empty() && bubbleVertexData.empty() && insectVertexData.empty()) return; VkDeviceSize offset = 0; @@ -539,6 +732,20 @@ void SwimEffects::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) { vkCmdBindVertexBuffers(cmd, 0, 1, &bubbleDynamicVB, &offset); vkCmdDraw(cmd, static_cast(bubbleVertexData.size() / 5), 1, 0, 0); } + + // --- Render insects --- + if (!insectVertexData.empty() && insectPipeline != VK_NULL_HANDLE) { + VkDeviceSize uploadSize = insectVertexData.size() * sizeof(float); + if (insectDynamicVBAllocInfo.pMappedData) { + std::memcpy(insectDynamicVBAllocInfo.pMappedData, insectVertexData.data(), uploadSize); + } + + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, insectPipeline); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, insectPipelineLayout, + 0, 1, &perFrameSet, 0, nullptr); + vkCmdBindVertexBuffers(cmd, 0, 1, &insectDynamicVB, &offset); + vkCmdDraw(cmd, static_cast(insectVertexData.size() / 5), 1, 0, 0); + } } } // namespace rendering