From 55e98d7ff6224aa3bf4b5d4260a594705ee2133f Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sun, 1 Nov 2026 14:39:37 +0400 Subject: [PATCH] fix(render): draw translucent water into planar reflections --- .../shaders/triangle.frag | 11 +++-- .../shaders/triangle.frag.spv | Bin 14716 -> 15060 bytes .../fparkan-render-vulkan/src/ffi/smoke.rs | 40 +++++++++++++++--- crates/fparkan-render/src/water.rs | 37 ++++++++++++++++ docs/appendices/knowledge-boundaries.md | 1 + docs/reference/render-frame.md | 17 ++++++++ docs/tomes/05-render.md | 15 +++++++ 7 files changed, 112 insertions(+), 9 deletions(-) diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.frag b/adapters/fparkan-render-vulkan/shaders/triangle.frag index 2c71278..b945905 100644 --- a/adapters/fparkan-render-vulkan/shaders/triangle.frag +++ b/adapters/fparkan-render-vulkan/shaders/triangle.frag @@ -75,7 +75,9 @@ void main() { if ((mode & 32) != 0 && (mode & 4) == 0 && in_world_position.z < frame.camera_position.w) { discard; } - if ((mode & 64) != 0) { + vec2 base_uv = vec2(material.base_page_x, material.base_page_y) + + in_uv * vec2(material.base_page_w, material.base_page_h); + if ((mode & (64 | 256)) != 0) { vec2 reflection_uv = gl_FragCoord.xy / vec2(textureSize(reflection_color, 0)); vec2 span = frame.embm_bounds.zw - frame.embm_bounds.xy; vec2 plane_uv = vec2(1.0) - (in_world_position.yx - frame.embm_bounds.yx) / span.yx; @@ -92,15 +94,16 @@ void main() { reflection_uv = displaced_clip.xy / displaced_clip.w * 0.5 + 0.5; } } - vec3 rgb = texture(reflection_color, reflection_uv).rgb * in_base_diffuse + in_base_specular; + vec3 water_color = (mode & 256) != 0 + ? texture(base_color, base_uv).rgb + : texture(reflection_color, reflection_uv).rgb; + vec3 rgb = water_color * in_base_diffuse + in_base_specular; out_color = vec4(mix(rgb * in_color, frame.fog_color.rgb, in_fog_factor), material.diffuse_alpha); if (out_color.a < material.alpha_cutoff) { discard; } return; } - vec2 base_uv = vec2(material.base_page_x, material.base_page_y) - + in_uv * vec2(material.base_page_w, material.base_page_h); vec2 detail_uv = vec2(material.detail_page_x, material.detail_page_y) + in_detail_uv * vec2(material.detail_page_w, material.detail_page_h); vec2 overlay_uv = vec2(material.overlay_page_x, material.overlay_page_y) diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv b/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv index 6deef9d51afc9622557cc104e0f9c6e88c5c2720..02596d1583652f0bfa9e15bde612b07805637422 100644 GIT binary patch literal 15060 zcmc(kX@HzXwT3&%%p`#j0%2dXu?UD5!lDQXge4$L5ER1J>CBdfndxz6k`VPu0)i-l zAfkw*L)9 zR{Cr80r?roJmf^=F=RFJB=QvU9I^?W3?L684IsjUG<^v&XIa!pw?F%D%zJe^UiI(nXSFWwy3am z6#aI9Yh7LSk^0hZ19tE~$LQQNd4dJ+d^C9ibyUjy$HTA5zct6w$GETLQ|4OwtotfH zWzOS@+$Ll`&0LDyOZ$4SWG}{0+9$q}y%CLfET8m%aoNShGhqYOyUxk9Hfk*p_9sO?b8(cRNi@2q#1S5LDa?|{KZ zePE=Scy*{Y&|BPH9o%H*;$2Ys&_lJxRkAeM%f7r1N_*bR8pp`j`=PJiyLhBN&|B^4 zYc$f`VISTRJ&oRKXQNNLIY#dY`L4RxW@mTYo3`<8=wxb#YR%+nSI)V2LU;e7{_3K} zl7X(_Chz(c{IXgx2=D8ea6PUOwfb!8;w&#&TIlo8SzokrO&3rPb@%j@m+Bhcx1_hP z%5wM4WpG`nv>#ikt?eH}EMmLoh6iiKo8@#xEv=AVE5eR+UvuF>Fn-j!BGqTnvCyC`%V8F*5cif`nOl^hF|6xNUI`uKU^_4 zX%y!3D4cuGSiYsqRpFA}B>1t#`x^Z4V0Y(|zM4bD)?U23jU^+^wU>LUjFZh(NAWHb z!|l++E-dzmz7sht>CR}bk?%LYIbHNM6+Z4Yoy~3cjLRFi7Y@SaV9H$EDu<$%K4tEQ zC41_Lcx{y<*Th5bs2sIM&&D{ea!kgjeur!QgMH;b?5G_3GP`|tRE~%5Z1gXx53n@N zyQ!^GgC9O^d2tUoF27%xgBWgnJ``g<6I*c>`)h;MIFxBUF2u%X#Yp$*MJW8+E0@9b z)Qc|&R=UjNI=I&0_IndNyJm<_t#TtY2Kv0Cjk5|{u{qo@iMuO{6Z`1i@awCOlv$L0 zK9Ko_$v*u~%f=d8j~>Ss-*1_%x&2(LM`*VuPJ88X^wzOI0k`(p+bb_MxpG<$;T(7$ zc_+`KSFvS!Sv5JD6`c2O_}NB3@4(=^zvaBIg0qd>q>O7%oZkT9=Y6c7?{sk9y>h;x z!P%eOl#KJfm79@qjzwi#`*mu_oj?Ob_mJF48Rz`UEzG#h6W5h- ze)s6NIOF`@k?YGi*G6tIIXg%(&R^E8v{dndCbaG4If)fi3Fp zPuB$ZLic;YxbA)JHEP>iLbrYR`_^mE_1u1cvQri9)${&+o9*8@H2&(OJAdj=CY`F# zeS`J4Xn&4cJ;vu7t3Eo}eGBva)bsv*2epU4dT#f<)9zldKlR-1duD%O_s&;$t@Zcs zbamHxGQHT(RJ`>UZ;X#)or2&>-Bz@+t9Q7K>hzy%+Wn!4(78@(%pM< zJEt*u&w2;w@BJD%Z^X#G3&YR*Zc3(muUW$G9pw6M%72}E<2ZlDolZRpkv9+Z8Hsal zw*yZ>EXGsc9&zq%zXP@HjcNRy61Kn1(08VmQ;+t$Bz-Gb?=bCh>fT|w?j08THk>N& zBxBl6->~cRe~G!i9v|~IhGSLte9OgL?G9ci>5kPmF6L?vu)Zb1nV)?HxFSU!JsboaLHPfa%SF<<-kZ@o%o#q3?U zu^hK=pqvt8S_$?nsJjMdBBL_>9Iz!~exT4++!8U*N0(C~Z|_-4w7Iy@&X^neC52vT z>X#P!e-6;@7_UTDAdc(H)UNY1B-&pKwrIbeTD#*`zmd9}qf);u*`1GQe>?c*z0TPq z^t;e0v!nle)R=@AkMDp*eLD4H2!(ZHT@Ry7AoSzBBx5+BZY-9t;01(fwu%eHyy=TIk!M z`>huGPUwE8h5lM}ztKXUh3@xR=zF1iFNXdm^sO^}HoEVQ`7WgP-Lr)5JD2OeYnF(2 z0=jb&`pM|-ftXLE=I?f#>6y+M@g0K-lfi-ve^p-ve^}$QIqd@xyNI-|nHCZ>>f5@ABM!VT-k}?RRf~RWi+)#&?%&;cd;i|fb^l(@b^l(@^(R{Nr!w7q{ku8* z)km}8)c0qX`Q76CHvw4>*_S=${`Czkd4DY~-#Ee)dsf>3ex3*4I^Vzj*tzagbz^Ar zK9ReFe$>4$X=a~>kZ10LZws(V^cg9sIw)ca) zv7e)T#Ib&w8L2DwAF_qe49{Nl{cK8`{EDTw**Lv1l%ecp)3nXmW-R!%Gz z>%KqOb)SPoP6vSH!{jKp9)slj=dFo5mOA$4 z+raX%H;)HfoMUarA#%>E*x0_|Cm|;yN3qA8i<1*4ZyAN?Tcuuv)Q~RpQ>Z%=*ULD% zz}<;6hXy{z>q-2%X{^2I_95?B7f{PNucuO2UJVemPi9 ziT07pJK&73kNrEJ??lWg+P^Dt^3gu#`hVbzp-;qoH+Tfm=J39ejfv0 znk44_GIV{lO-N_!<6!&T5jh{x$FccMqb=6p6JWX6N1p`8JnG~4K847~*se&Ne4L56 zb3P4c%qtN4yPR6iSl%z6LB=8R_I);SS5n9Q@;R{lrigd=7pN`XFWNqjT#b0Yh+`~Y z1P^8Um%#QP>wFbhA9-_IKrQE+VjspcuJJs7UqNOh-F5#eV!W7#uO&|2xwb?uUk5Kn zVqU%h)?fZ;_RV%R&aMlK>3ZMP!! zt&h3hkT`R_4g5VsZWdy0&d>J|`5W<>OnpbIabf zlfEzY?~vI@+d0APbP_ z?~h=8K_VH-q!vF8+a-shN{1g&>{1dE?ynFOi>VF}g>!%Uh?L_?yA|G?|Y~tiC zF(>+uM}Ho94v9PF--(k~CZx4|0c_h{5pyxFzUDHK`aj47JcDBsrRZ}5uzdJTPCowD6h0e)<>TJi z7;HOveLNeRg8e-z;%pAKFMpGYI9q__!)L4Hc`X&-Te@yUb??uzcjRBiMHGG4|Jh=Mg*N?2K+- z{+%j8S*KT0j$;a4V54JyTE7RC#g3U{tW1mVb7k%vkHXnb# zi@x>*%ZJb2$;aRH!siWO`N-#uVB5*-T~-B%j%t&;DTf z=<5Km?c`(ZbHVnfEyjKz*u1nkc7KtUrwijLA_riB& zF_?eFx2Cph!NW+rGj%w9LgJk{6^?(!J7eDFqObE+qtGp!oAcR5y##4} zvz8`4&h0XE&vyQ1oen1-Z`N|K?d07r@1(Z4U$nggk#oO@n{EA8`T*E(rHJo2IUi0w_WcE5ImKe-diuAt`|X1eayugSqrdBUCbc%l zX55Rw2O$ynLtr_@VrAU$zZl|BB>X=NmQyTN`p386B@o8c&lu+R5wLlL&qu-f$%oIS z;QV}i3|&8Y$8jOGT=;(+EEjWlIk@$E^Am9Lk^3jXa*D+&#_D=~3L^jByaHV=B<_Jv z!||`U2h78``kLou)Y>D@&w%C3)BEX4u=AhaPoG7XkG=FcuzT`F#5m^mc{q9JQ2h&F z+sVf{|03A_EWv##aniwEl{m*9->_H1*G+Tt9@--g_QI0s_ec!s|Zb`QnAz8x&5Sgc%I`@0oIyMJ36-&n?sSU&*gv3`gy zr&z3vU*ss4=V-3Rx6hw}9f!WF zsMVwIpM#G?qVHdTFSqC|DnL@1Q?}JrnW1JqDHwiTm;|aQrLoOJf>GU;BK7 zT6^@d8Y~z3U%~mi@HcdQM1iRve3Yn}6i}BG|mcS3UQ&P5Ax?Y&?CRpjOxBJeqhukE0w2;+y0=wy9H`M{Q$} z35fZKoj2dRcChzL{@!)K$?N0qGh@N6@7*{!`M9gcgKZ}t^SKUqfY{-)F1mj5j>-S8 zT+Z>Eueq6H4#^nHo^)niR2 zgI$a8-3VQuc)K@7*T)jvCW*tX$!(fAUE_`448`{PYd5wz_?~NX%xwhO9NhZGZviJC s^Rgw_dC9-|w}O+8H-2ld?d07v+frNHGupO6OBkT0js8w57}x8CB$Pnw+FZlAM^NEk%@822m8m z87ELLs30QX00=T@rD{bGK~dBy4)=m0T9H90_j$f=uXOhS_wU_wvETKswbx#I{&L2Q zo-wXc8C9838DH6abfqYpRYt>9D%N{aPpGsP`uwAgKWg`(;jZ0h&E7-DNtH2Hg>6&3 z$AXox(jqdKn zL-lImQ`j~tVwyv9Nqw-lwygSMpR!+Pp{p@i?;LJ4`)j?`!J>U>GwERO8cajvKM11$G$1{g*6swpw?5bo;fo1$s6#?M)=J( z;Fpf@n{U7`f-l!<3Vc_6xYp>69@g)XEm~~L(4*a!Ew-hZZL1dBqRh56IOaVKX(Z-0 ztu{K0{GR5$@hQh$`t0yBKKb}}#HTNv z*=jn>ZJcKJQSiXxe0!HP`m0NugS}nVf#whgqv<8n#^}6^4jruZ)w6@|d=!@yw$9$h zK(%|Y*;h6%=0jU~?$@7*k#mYI+K&d0@Sg3Sh1fPOVz-|A&St&4yV2RGmsih7Ki&ZY z%|`$5NaEGOT7OS*ceQhqnTvNp=|d0I8du4ZWH0;jJ}B*ZFY6s6U+;(BM$e+*Mt@JW zySLd)cZYp=M|3xPs-4YV>5(ydN62?Iyf!=Qac|njyP=b*9juKcPrGu?y%Xwv3;U`I zn~VFqhDLbTui%%~ia~f^?+MrK8d1Au^QZ^w-M!_dSzWv&@$;#BdaEpF&m5BPLM_(+ zXzI*AhJbBsoGI&E>(?R=kMm0#hVJ}GIq0jiuHAF9>urluo&yu;@!nB&39X6 z9rkn=B#zbPVBnL}MJ}7c$9<)5;iU`@xm%N}v71Cz1Lb z1ef|HPJ5+ey&j?9$5-ZNe9C30);G{=0=Ap?B74an4&T}ATiEEY53aw5##Y`0KXk^j z;>05eomd%0+P9jpA#8-BIRo z6f+ouVTxL zvKrxNR&d_2;b$BDyzheZu9ov|3eGlilQXU@alZHA=iRHHZ*p+nvvR(d!P%eO)Qt0P zm7AGyjzw;ljPov)+b!eFRnB`j`tTb{ZdS(m-6Xef#`&!z_r{EKoN^r*=Qonv+>F~i zaffA`-$wc!m2u9I+=7hrTS)GNjPn~v?v#x4+ehxyjB|eFPR}^Mb>zA-&TkyKMH%O} zja+ZWxi)eG8Rxf)+;GPEts-}3#`$d`cQ!JgeeL?`=X$EYmfC&coT~5MqQ7nfeN3^2 zUk>M-`pqy6G4If)gDvWQD;USUuI@L2 z{+7`7CtRWX&TG&0+`bZoJzJ=PU3cGr4?@0EDr=Ht=@21h#y8`c@{!Omkb)G`j z_A?D{{l#12|xu>Z-uA(VJfl%J-ZLo|3AeX-81H3KKm!#y(hP88k6^| zcYyxhuaWZ>jNH30{Jig`X1e#9CG6fo?uV`UzjJRK=g+t^s7E34=Ak|_an9|lz*7;6 z@zi%hoO|2vOl^B(8viv3+ut_myHd-kNBiB9zCEmWn07gJ@336=4hww;PMLR-F>R-B z*!B6p#9UvGk9ixzv8sE%s$QckaT1ErV;b@i0OBuoDz9^ zFIpmxa|-R8Iias8^vZ~SUZJl$Byze0eL3Q|K1uDoPe-Ett;s$JApG`_rzzI&tBD54!#_p8LSw zdD?fT_Fm8J4<+6BYrw{jd|mU$kWG<@_c(YmqW?dsjWq``zPj&39{)*nzssWi)8NfB z{RMD-2aTq&-(nHZcP5WF9%aj{{RDKs$s+#d=-ykQZ;8H5roRH+@364H65VgG&}X9i z{T2Go=-zvw?}qMoSLm-p_gyhx&yDY(C3N4pT<>Vnea9>j??`mlAoOF<-BZSM{e0(a z682#f*D3U+=acs4_&0UvuC0Gdhi-iT ze$Mq9Tl70ybpMXd{r&qn*Zuo9*Zuo9*VnY@k7T;}KGCADP5Qy?qDk1jSG@l>K@MOK zxj(&IO5R_3%e#YS#h%pGkDvS9JH&hCKz644P~8~Xyg%gbrXO|h2RZMJYv4Q&+aU7Z z6;BbvJHYZRF|>I{$hqgm5!<^VkL_I`=N)l%itT+MZ|rAiA91XoWCrbjN5paVRJ3luTKY9KGx?D zu*H5ls1HWuj4d{{@8V&|eB@Z>*>~yi#K~JmA^ILkeGKAvP8a$S)W;^Shk5av=eWd~ zLlYn4y(#e*;-h{%VjuGEMejg4*XbnclM&b8L?m)NCGnBto6+SI?_BTNX#W;)Yx}pt z$w&KB!E%blF`k?9dmGsJ`k0@0vYfe0r>-IHqlmRIaq`BoL@u4+)?B*aOp1yr@#&9pb6FDpC z&db?|J}1-Oef=&(ANiQ8cPCz(#rcZ-mxHyRgG65M0k23B^LH-#aztDBoew@QNzDBP z==y4#n9kOF!S=ZevJBD3u`Q+67HjZ6uw3k;_k&{|^>KU`A@VV{4rlX3|~vPb0>QdH77?0EOD;oRp2inuBG#6 zUb|9%8Ig}Meg$m1=>KZ4oF%wxz;fmk+*cDfntB0y&V6z%Vtf6ySCLD}Mcda9`_{)? zuS}e|UI+d$H|OViME(|hrcmFQxErY57vH3|xG%JQ1Cetb#m2GkZz0Bxb^CVW z@^J=l0m~_I2EPL~uC_Smw}ItvMWXF@lP(wg@^-Mk+G1bIxi4cp z*7-hlzdF7nsqaLNPP+T_E@U1O_tW>l`p6&4>6=ddeMDZdI0vp}?28|OS0e6T{qII> zBOg9L1g}CIhdw_-^pkfScTmfP|4+bjq5l+o4-$Rc3)V;8^*D+8XNY_FKE!se=lzI$ zjOFKvlegq!aUVU1Jb=V``bFa8m5FIiehIei?ufYfA|L1C zVX$#5!Tkmtv4i_<;+*q1KfeRpUVrVzb{QR}18h5aedbXA3t5XqoPUFj^CS{+o&w8<&of|so<_pwS+IQc^&hb9 z4s3tgRxs!G|2$$|+FX-I6Bm8CaqQ~_B>EcVWi1$Y&d{?d0`w?AwC#b>9x%zWl8y;%pC=kG^(D zKK_0bK0AWtqpzu8+sVh+r-AKHTa0}=*u1nkc7KIJ;AnsR8;@MHKN#GRbm!|3umzUb zxzuvDZ^!=#@ObL@mOTnx?np%c!>Q%M|7fsW{1$O6Sne3a`}a6%i}$a#1&EyYtk^id zf5(HJ%Xt4z0Lv*BE8msyKM}&Y3;&bAa*D-DfA`RJ)F(sepT94spv(JKy6@f$=3ntm zsqJ!b6^S?YEpYmT#2fooIQ|uHta+P@zRs7w!D%<|>G+%qmJ9uD;QV{xG<1FB^)av0 z!Br&ktbzGgf@&?u)_ZK1?0!ummh0K4&JMrJ2t%uzdKOm3-n`&-HvKoP6x7 zv%$u;tv1`9gx|ZsekVqJ&&j*tNfYPsO@PpviZ|#fWz;cSkD#q%1eF!4|*1i;7E+p=O55w`V zxChL`xcZvsMbz3O&yRrR%+venqhRMhKTjV+myf;laj<(b|5pD5oV;^ruAcN)+P;9ud0xeFAIQah;CXhQosTbpt4QSVWw1H;K84R$!16JNSA%UQ zA9wCGVEz?%uJ*Wdor|l`wHwbn=xgBo4ZRLs&c6@h_n@z%%X>E(<65wsF{Z%X0G4wO zzDaFy4&=Xq+=Ms>V%vCzzXf&=#lHSFSWdB6xwiIqBZ~Io?{R8l88c$t0?uQ72VG9F zSQ*RT1*TKq3Zc)1UISgec@=l^yH zqwl-G2O-h-_rP+B#VXJL`w;r%`L9Hui$shcfaMg6Ri6Ld5XRBZobIHSi+q0wHqX$1 z1kQht_%XUZ^5L@@oc|v26LkIL?PC?ST=@SKY`>x32fi1HIsO?~ANlb4Irx5LMOw=T z!1~GC$34_?;r|P;{f7Qaus-UZxre}>iFn_B1(pkm`|{Ut{44HDV;V?0Rak!0e;}fTAyzv{M*j|6_#x@7vb8U`!4R~X)T)t)#;N)XoCW2kF m{F{FhIV9frNnqQ_yJsd-Tii3+HbvxI7jeAta=!8VBL5470yqNz diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs index c4f0264..8499c54 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs @@ -1936,7 +1936,16 @@ impl VulkanSmokeRenderer { &[], ); let mut viewport_depth = None; - for &range_index in &self.draw_order { + // Native reflection queues the translucent water after the reflected world. + let draw_order = + self.draw_order + .iter() + .copied() + .filter(|&index| !reflected || !self.draw_ranges[index].terrain_reflection) + .chain(self.draw_order.iter().copied().filter(|&index| { + reflected && self.draw_ranges[index].terrain_reflection + })); + for range_index in draw_order { let range = self.draw_ranges.get(range_index).ok_or( VulkanSmokeRendererError::InvariantViolation { context: "stable draw order range handle", @@ -1948,11 +1957,17 @@ impl VulkanSmokeRenderer { }, )?; let mode = self.material_unlit[texture_index].round() as i32; - if reflected && (range.terrain_reflection || mode & 8 != 0) { + if reflected && mode & 8 != 0 { continue; } let state = if reflected { - super::reflection::reflected_pipeline_state(range.pipeline_state) + let state = + super::reflection::reflected_pipeline_state(range.pipeline_state); + if range.terrain_reflection { + fparkan_render::water::legacy_reflected_water_pipeline(state) + } else { + state + } } else { range.pipeline_state }; @@ -1998,7 +2013,12 @@ impl VulkanSmokeRenderer { std::slice::from_ref(descriptor_set), &[frame_offset, material_offset, point_light_offset], ); - let alpha_cutoff = range.alpha_test_cutoff().to_ne_bytes(); + let alpha_cutoff = if state.alpha_test { + f32::from(range.alpha_test_reference) / 255.0 + } else { + 0.0 + } + .to_ne_bytes(); device.device().cmd_push_constants( command_buffer, resources.pipeline_layout, @@ -2017,8 +2037,13 @@ impl VulkanSmokeRenderer { .ok_or(VulkanSmokeRendererError::InvariantViolation { context: "draw-range diffuse-alpha override", })?; + let diffuse_alpha_override = if reflected && range.terrain_reflection { + Some(fparkan_render::water::REFLECTED_WATER_ALPHA) + } else { + *diffuse_alpha_override + }; let material_alphas = - draw_material_alphas(*material_alpha, *diffuse_alpha_override) + draw_material_alphas(*material_alpha, diffuse_alpha_override) .map(f32::to_ne_bytes) .concat(); device.device().cmd_push_constants( @@ -2075,6 +2100,11 @@ impl VulkanSmokeRenderer { }; let material_mode = (material_mode.round() as i32 | if reflected { 32 } else { 0 } + | if reflected && range.terrain_reflection { + 256 + } else { + 0 + } | if !reflected && reflection_plane.is_some() && self.reflection_planes[range_index] == reflection_plane diff --git a/crates/fparkan-render/src/water.rs b/crates/fparkan-render/src/water.rs index 58a2817..7a5ccbd 100644 --- a/crates/fparkan-render/src/water.rs +++ b/crates/fparkan-render/src/water.rs @@ -7,6 +7,21 @@ pub const EMBM_BUMP_TILE: f32 = 100.0; /// Native diagonal bump matrix, without luminance modulation. pub const EMBM_COEFFICIENTS: [f32; 2] = [0.01, 0.01]; +/// Terrain draws ordinary textured water into its reflection with this alpha. +pub const REFLECTED_WATER_ALPHA: f32 = 0.1; + +/// Native packet category 4 enables source-alpha blending without changing depth. +#[must_use] +pub fn legacy_reflected_water_pipeline( + state: crate::LegacyPipelineState, +) -> crate::LegacyPipelineState { + crate::LegacyPipelineState { + blend: crate::LegacyBlendMode::SourceAlpha, + alpha_test: true, + ..state + } +} + /// Square reflection size chosen by Terrain.dll RVA 0x27FC7 from renderer dimensions. #[must_use] pub fn legacy_embm_reflection_size(extent: [u32; 2]) -> u32 { @@ -127,6 +142,28 @@ pub fn legacy_embm_reflection_clip( mod tests { use super::*; + #[test] + fn reflected_water_blends_material_alpha_and_preserves_depth_and_cull() { + use crate::{LegacyBlendMode, LegacyCullMode, LegacyDepthMode, LegacyPipelineState}; + for depth in [ + LegacyDepthMode::Disabled, + LegacyDepthMode::TestWrite, + LegacyDepthMode::TestReadOnly, + ] { + let original = LegacyPipelineState { + depth, + cull: LegacyCullMode::FrontFace, + ..LegacyPipelineState::default() + }; + let fallback = legacy_reflected_water_pipeline(original); + assert_eq!(fallback.blend, LegacyBlendMode::SourceAlpha); + assert!(fallback.alpha_test); + assert_eq!(fallback.depth, original.depth); + assert_eq!(fallback.cull, original.cull); + } + assert_eq!(REFLECTED_WATER_ALPHA.to_bits(), 0x3dcc_cccd); + } + #[test] fn reflection_size_matches_native_renderer_dimension_vectors() { for (extent, expected) in [ diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md index ed821e4..f0c0b0c 100644 --- a/docs/appendices/knowledge-boundaries.md +++ b/docs/appendices/knowledge-boundaries.md @@ -78,6 +78,7 @@ MSH-запрос со свободной preview-камерой; это не п log на минимальных сценах с одним типом материала. Горизонтальные грани Land.msh с флагом `0x20000` используют исходную карту Du/Dv, окно shifted-камеры и квадратный размер текстуры, проверенные исполнением DLL. Диапазон глубины, небо/туман/FX основного вида и соответствие размеров при обрезанном capture остаются приближениями: `planar_reflection_render_context_approx`; неперспективная камера API использует обычное зеркало. +Обычный полупрозрачный слой воды внутри отражения восстановлен по живым пакетам и таблицам Ngi32. Ближняя вода в согласованных кадрах ещё отличается по яркости; полный контекст отражённого вида остаётся открытым. Raw queue clock задаёт фазу волн отдельно от времени суток и sky elapsed; старые захваты без него считаются в `captured_water_phase_unverified`. Один target отражает ближайшую активную высоту воды; остальные высоты и неплоские грани считаются в `planar_reflection_other_planes_unsupported` и `planar_reflection_nonplanar_unsupported` и сохраняют обычный terrain shader. `terrain_face8_pipeline_unverified` теперь считает только исходные грани с `0x8` без `0x20000`; сама семантика оставшихся специальных проходов ещё не установлена. diff --git a/docs/reference/render-frame.md b/docs/reference/render-frame.md index b3c0be5..4ee0cc3 100644 --- a/docs/reference/render-frame.md +++ b/docs/reference/render-frame.md @@ -404,3 +404,20 @@ D3D7 в `fparkan-render`, а затем один раз меняет знак в GPU может содержать результат смешивания поверхностей; повторное применение его к готовому цвету затемнило бы кадр ещё раз. Поэтому конвертер и сборщик сравнительных пар игнорируют эту альфу, а raw-файл сохраняют без изменений. + +### Вода внутри отражённого вида + +В эталонных DLL из [таблицы сборки](original-binaries.md) Terrain RVA +`0x28A50` задаёт водному пакету material alpha `0,1` и packet category `4`, +когда уже строится отражение. Живой CShade-захват подтверждает одинаковые +водные вершины при `eye_z=115` и `185`, высоте воды `150`: отражённые пакеты +имеют alpha `0,1`, основные — `1`. Это общий проход водных граней, +а не поправка цвета карты. + +Ngi32 mode 7, descriptors RVA `0x36C14` / `0x36C40`, содержит одну текстурную +стадию: `COLOR = TEXTURE × DIFFUSE`, `ALPHA = DIFFUSE`. Packet category 4 +в таблице RVA `0x346E0 + category × 40` включает SRCALPHA / INVSRCALPHA +и альфа-тест GREATEREQUAL. Это отдельная таблица от MAT0 categories. +Depth enable/write передаются из байтов пакета `+0x12C/+0x12D` отдельно; +в захваченном водном проходе оба равны `1`. Начальные ZFUNC=LESSEQUAL, +ALPHAREF=1 задаются конструктором Ngi32; категория их не меняет. diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index 8c059f2..387db11 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -1197,6 +1197,21 @@ Rust-тест отдельно проверяет углы водоёма и г водоёма с добавленным смещением волн. Для неперспективной камеры API остаётся обычное зеркало с экранными координатами. +В отражённом виде видна и сама вода. После остального мира оригинал рисует +её обычной текстурой с непрозрачностью `0,1`; этот слой приглушает отражение +и сохраняет оттенок поверхности. Подготовка ландшафта задаёт такую альфу +и отдельную категорию пакета, когда уже строится отражение. Живой захват +подтверждает те же водные вершины у зеркальной камеры и у основной камеры: +в первом случае альфа равна `0,1`, во втором — `1`. + +Внутренний водный слой использует texture mode 7: цвет текстуры умножается +на diffuse, а альфа берётся только из diffuse. Затем обычное смешивание +`src × alpha + dst × (1 − alpha)` соединяет его с отражённым миром. +Проверка и запись глубины сохраняются, альфа-тест пропускает значения +не меньше `1/255`. FParkan воспроизводит этот проход после отражённой сцены +и читает исходную текстуру материала, поэтому изображение отражения +можно закончить до его чтения основной камерой. + Размер изображения отражения выбирается отдельно от размера основного кадра. Terrain.dll в конструкторе CShade (RVA `0x278E0`, цикл с `0x27FC7`) берёт ширину и высоту renderer через Ngi32.dll RVA `0x7FB0`. Сторона квадратной