From 9072a97bb5cfc3fec4c2a8a2439811aff48ec4e2 Mon Sep 17 00:00:00 2001 From: unknown <1qwertypower1@gmail.com> Date: Mon, 14 Sep 2026 23:03:50 +0300 Subject: [PATCH 01/17] Update DIS --- CREDITS.md | 2 +- app/src/main/cpp/winlator/vk/dis/vkr_dis.c | 24 ++- .../vk/shaders/dis_inverse_search.comp | 78 ++++++++- docs/FRAME-GENERATION.md | 2 +- vr-dispatch-budget.bundle | Bin 0 -> 2661 bytes vr-dispatch-budget.patch | 106 ++++++++++++ zero-flow-candidate.bundle | Bin 0 -> 4331 bytes zero-flow-candidate.patch | 161 ++++++++++++++++++ 8 files changed, 366 insertions(+), 7 deletions(-) create mode 100644 vr-dispatch-budget.bundle create mode 100644 vr-dispatch-budget.patch create mode 100644 zero-flow-candidate.bundle create mode 100644 zero-flow-candidate.patch diff --git a/CREDITS.md b/CREDITS.md index b7a8435b8..b5b94c8b2 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -119,7 +119,7 @@ WinNative's second frame generator is a complete open-source implementation of * Search** optical flow, contributed by **qwertypower** (DEVAR Entertainment LLC) under GPL-3.0. Unlike the Lossless Scaling path it depends on nothing the user has to own or install. The whole -chain ships with the APK as twelve compute shaders and runs in the same Vulkan compositor, so +chain ships with the APK as fourteen compute shaders and runs in the same Vulkan compositor, so frame generation is available on a fresh install with no Steam account and no `Lossless.dll`. The algorithm is DIS, and its reference implementation is OpenCV's `DISOpticalFlow` diff --git a/app/src/main/cpp/winlator/vk/dis/vkr_dis.c b/app/src/main/cpp/winlator/vk/dis/vkr_dis.c index 4e518dd41..d650f8391 100644 --- a/app/src/main/cpp/winlator/vk/dis/vkr_dis.c +++ b/app/src/main/cpp/winlator/vk/dis/vkr_dis.c @@ -60,6 +60,10 @@ #define DIS_VR_ZETA 0.1f #define DIS_VR_EPS 0.001f +// Fewest SOR sweeps a level that still runs the solver gets. +#define DIS_VR_SOR_FLOOR 2u + + #define DIS_SET_SAMPLERS 5u #define DIS_SET_STORAGE 1u #define DIS_SHARED_SETS_PER_LEVEL 6u @@ -1430,11 +1434,27 @@ uint32_t vkr_dis_plan(VkrDis* d, uint32_t capacity, uint64_t source_frames) { return (uint32_t)d->planned_gen; } +// Solver budget for one level. The refinement is a red-black SOR, and the +// number of sweeps a SOR needs scales with how far information has to travel +// across the grid - a level is half the size per axis, so it reaches the same +// relative distance in fewer sweeps. Spending the finest level's sweep count on +// every level buys nothing numerically and costs a dispatch and a barrier each. +static void dis_vr_budget(const DisRefine* refine, uint32_t l, uint32_t* fixed_point, + uint32_t* sor) { + *fixed_point = l == 0 ? refine->vr_fixed_point : 1u; + const uint32_t s = refine->vr_sor > l ? refine->vr_sor - l : DIS_VR_SOR_FLOOR; + *sor = s < DIS_VR_SOR_FLOOR ? DIS_VR_SOR_FLOOR : s; +} + static void dis_vr_level(VkrDis* d, VkCommandBuffer cmd, uint32_t slot, uint32_t l, uint32_t lw, uint32_t lh, const DisRefine* refine, bool full) { const uint32_t gw = (lw + DIS_LOCAL_SIZE - 1) / DIS_LOCAL_SIZE; const uint32_t gh = (lh + DIS_LOCAL_SIZE - 1) / DIS_LOCAL_SIZE; + uint32_t vr_fixed_point = 0; + uint32_t vr_sor = 0; + dis_vr_budget(refine, l, &vr_fixed_point, &vr_sor); + vkd.CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, d->pass_vr_prep.pipeline); vkd.CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, d->vr_pipeline_layout, 0, 1, &d->vr_prep_sets[slot][l], 0, NULL); @@ -1454,7 +1474,7 @@ static void dis_vr_level(VkrDis* d, VkCommandBuffer cmd, uint32_t slot, uint32_t vkd.CmdDispatch(cmd, gw, gh, 1); dis_compute_barrier(cmd); - for (uint32_t k = 0; k < refine->vr_fixed_point; k++) { + for (uint32_t k = 0; k < vr_fixed_point; k++) { DisVrWPC wpc; wpc.alpha2 = DIS_VR_ALPHA * 0.5f; wpc.eps2 = DIS_VR_EPS * DIS_VR_EPS; @@ -1479,7 +1499,7 @@ static void dis_vr_level(VkrDis* d, VkCommandBuffer cmd, uint32_t slot, uint32_t vkd.CmdDispatch(cmd, gw, gh, 1); dis_compute_barrier(cmd); - for (uint32_t it = 0; it < refine->vr_sor; it++) { + for (uint32_t it = 0; it < vr_sor; it++) { DisVrSorPC spc; spc.omega = DIS_VR_OMEGA; spc.parity = 0; diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_inverse_search.comp b/app/src/main/cpp/winlator/vk/shaders/dis_inverse_search.comp index 618f60698..b538489d6 100644 --- a/app/src/main/cpp/winlator/vk/shaders/dis_inverse_search.comp +++ b/app/src/main/cpp/winlator/vk/shaders/dis_inverse_search.comp @@ -7,6 +7,43 @@ precision highp int; #define DIS_MAX_MATCH_RMS 36.0 +// How much better the zero vector has to explain the block than whatever the +// search settled on, as a ratio of RMS residuals, before it is taken instead. +// +// Static HUD, menus and map overlays sit on top of moving content and are +// pixel-identical between frames, but the search has no notion of "did not +// move": it starts from the coarser level, which carries the motion of whatever +// surrounds the overlay, and Gauss-Newton refines from there. Gauss-Newton is a +// local optimiser, and inside a smooth overlay the initialisation sits in a +// local basin it never leaves - measured on a synthetic overlay over a +// background panning four pixels, it stays at 3.9 px and leaves a residual of +// RMS 24, while the zero vector leaves 0. The overlay is warped in the +// generated frames and snapped back by the next real one, which is the flicker. +// +// The test is a ratio, not an absolute threshold, and that is what makes it +// work. An absolute one has to sit near the noise floor, and on the same +// synthetic scene it stopped firing entirely once frame noise reached an RMS of +// 3. The ratio separates the cases by two orders of magnitude and holds from +// noiseless up to an RMS of 6: +// +// overlay interior ratio 0.00 - 0.32 -> zero taken +// overlay edge block ratio 0.39 - 0.46 -> zero taken +// background, 4 px pan ratio 2.1 - 1e7 -> kept +// background, 0.7 px pan ratio 2.7 -> kept +// background, 0.3 px pan ratio 1.1 -> kept +// +// 0.5 means zero has to be twice as good in RMS terms. The nearest thing on the +// other side is a third of a pixel of real motion at 1.1, so there is room. +// +// A flat region that IS moving also qualifies, because a featureless block +// matches itself anywhere. That costs nothing: there is nothing in it to see +// move, and the structured blocks around it still carry the real motion. +#define DIS_ZERO_MATCH_RATIO 0.5 + +// Below this displacement, in texels, the search already landed on zero and +// there is nothing for the test to change; skip its 64 samples. +#define DIS_ZERO_TEST_MIN_PX 0.5 + layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in; layout(set = 0, binding = 0) uniform sampler2D lastLumaMap; @@ -124,11 +161,46 @@ void main() { bool unmatched = prevSSD * N_INV > DIS_MAX_MATCH_RMS * DIS_MAX_MATCH_RMS; - if (any(isnan(flow)) || any(isinf(flow)) || clamped || unmatched || - length(flow - initialFlow) > patchSize) { + const bool reverted = any(isnan(flow)) || any(isinf(flow)) || clamped || unmatched || + length(flow - initialFlow) > patchSize; + if (reverted) { flow = initialFlow; } + // Does the block explain itself better by not moving at all? Only asked when + // the search claims a displacement, and only when it was not thrown out + // above - prevSSD belongs to the searched vector, so once that has been + // replaced by the coarse estimate there is nothing valid to compare against. + float zeroSsd = -1.0; + if (!reverted && dot(flow, flow) > DIS_ZERO_TEST_MIN_PX * DIS_ZERO_TEST_MIN_PX) { + float zsd = 0.0; + float zsd2 = 0.0; + for (int i = 0; i < 8; i++) { + for (int j = 0; j < 8; j++) { + vec2 tc = (vec2(pix) + vec2(i, j) + 0.5) * invImageSize; + float diff = textureLod(nextLumaMap, tc, 0.0).x - lastImageData[i * 8 + j]; + zsd += diff; + zsd2 += diff * diff; + } + } + float zssd = zsd2 - zsd * zsd / N; + // Compared as sums of squares, so the RMS ratio is squared here and no + // square root is taken. prevSSD of exactly zero is a perfect match that + // only an equally perfect zero can tie, and the comparison handles it + // without a divide. + if (zssd <= prevSSD * (DIS_ZERO_MATCH_RATIO * DIS_ZERO_MATCH_RATIO)) { + flow = vec2(0.0); + zeroSsd = zssd; + } + } + flow *= invImageSize; - imageStore(sparseFlowMap, pixSparse, vec4(flow, -1.0, 1.0)); + // .z is the mean-normalized SSD of the stored vector, or -1 for "not scored + // with that metric". The zero test measures exactly the metric the + // propagation passes use, on the same reference block, so when zero wins its + // score is carried forward: propagation then compares a neighbour's moving + // vector against a real, and very low, incumbent score instead of having to + // re-derive one, which is what keeps the surrounding motion from being + // propagated into the overlay. + imageStore(sparseFlowMap, pixSparse, vec4(flow, zeroSsd, 1.0)); } diff --git a/docs/FRAME-GENERATION.md b/docs/FRAME-GENERATION.md index 2eca7374b..1bb568623 100644 --- a/docs/FRAME-GENERATION.md +++ b/docs/FRAME-GENERATION.md @@ -29,7 +29,7 @@ the shaders import successfully. ## DIS **Nothing to buy, nothing to import.** DIS is a complete open-source Dense Inverse Search frame -generator built into WinNative — twelve compute shaders that ship with the APK — so it works on +generator built into WinNative — fourteen compute shaders that ship with the APK — so it works on a fresh install with no Steam account and no `Lossless.dll`. Turn it on in the **FG** tab and it runs. diff --git a/vr-dispatch-budget.bundle b/vr-dispatch-budget.bundle new file mode 100644 index 0000000000000000000000000000000000000000..4ec750e5b1e4ecb45d56dbda1cc21a15ca03d9e5 GIT binary patch literal 2661 zcmV-r3YzsJAa*h!XK8dGVs&n0Y-I{9WHdNtWo9!rW;r%7Ib$|AGd4CeWMXDyGh<^i zHZnD4IWuHsVlpy0AVz6;FJo_RbaHQOb09MyC?hvEF)0c;WivE0VPZI8F=R9|WMVdA zIAJ(pWi(?qIWb~pWH&N2H#B84H)1d#a%E<7FKA_9WOFZeaxG+Ob8umFV`wd6b!2B{ zbP5VkK|@Ob00062000V_pg|jo+c-OU>h9ZbbAWVyI7^x8qoZ%2;YYf)^ZY#x9r5vo=->q_~ESMKwOQ#O!Bk9qw&cg9g3?b8K2+k zhy3hk`JBEy9`mQ|Q}D%XpM2eJJw;o-7(I55^x7W};)`J1*?#ZJ^|kL`M#F=}Fxrf# z6JL9Dx?26=Rdk$-cRHH%NyjYXWagX{Pesm`ki&aTliPcmmdT78T7hHs2b?cmIGLnp979fk2)Y^rbDUmekoAqM=y#^GC>l=z^Y+d=LEH>x{Qv<%;8}B2w z!)m?3BVQX3atP=it2w~gIm^;2Be4aq$ce4tuQ-e6Tvt16X-ksfB5;`qZCD<28(qnA zHqSQR-$7i?O}a1hT;wxKWvOZ-0VJbNv=06H_G!7%luI+WC!IHsWu8>T0a#);iDkf& zY-nolG8MfR9O7}*NgGQsKo!iuXm=SEqTm^7>rI(b0y(+6Ee|A9OCeuTBDXwu6`dgs zfUaazD=kNP(A{7lRdQ+L|9DY9s?ELq{x9mGh0D>J4K9S3T$vh$6cC}4(;+^X!WgMM zG$l$Fw%$nORW%h^l>;R_M(!FQrW?HewABa3W4_nOtdVQ2UU=LkjRBc+R=gRc_6&`FP=smQI>g zPP>#W3$#Q)k%P~%tkGuDCLxS|)0zFcD|g)pEvgz--CWv;482_D))nEtSQ`Wd?WM|U z5-#@mkY!Ggh2k2e+iSPG>!1UqPJI=!{l4cB);hpjJEF!%`@WM6z!B|Dg=vTc&;;7F zkFtQVU+V0z>HR8LNaP1?8?4(#fJPX++b6fa-jhMYl?r-b5Qg7thBx-0A2p9}>{&m2 zS4V-X-)`B>Uf`m%|0_xm?4r1EyC@#ALa8AcSt6MPa)E+!ZB(U$KKQGd*S}B8>CgL@ z%He{mb6pju9+LHF#APugG2;meGpLo_s3}^A!cI&SL>h?)C=jts=6EV5cD~!83AJ1K zW2@5at`eS|YjZTbg=`=szMH#1UYc6+XkWBKY&Glp`K>AdvF>4JyftKOhX~K@^swH4 zmXKAtscyG}=gqUiwYsI|jV84l+*awUxy)XlJg*Il(g-`nz-uGnF9I-@=vkD^g~W}T zZAjXaE1@~qI+2Zm`{{!5Fx>R%Bx5Cbxh+af9r{rJ#Q4N=GY3WyyI&bzb*eGpTDywQ zjVW~h=qPsfCKE@oO!$$m_J6eeo0G-h?(%b2EIQ)y`DYr8zlW>H`wRD9Fh21DJwlx2 zFuls>R8guq?(yU@qJcB_c${0vzL0%G7NbbRzv*nTWjzb8RrfKRR!rMHXE{sT<~l}Y zCIId$4XgVB+fH!eRW*qm*J_-!&tdCFosxy&c$}NeIGJ%my@;#1iT>HItC`H4zR2|1 zNiM#fTDl7WjE)N#{sBTZtx3!5dTs!K?|`%^hlG$=@SJ#@>tyO=nlMEqE=*dDMck*t z{oCP?neK(UkMBRYK5<1N0H(wZ2>$`lD&fYsL!nMVP$#ln4@-sBtqcozoBd*VPbaqR^Y>FclDT;hJ zI0AfA!7t10LCQ?{O|(q3<71s zlimq);nIGj#VY$^o;;#>oO{Xql6gZjqlk*RTqom%t54oXbSuxVVapQzcF%qCbjBb6 z+xiWf{{gN1yag1u3NOG{(n}sqcydbzWs<*(6G0Tm5zi}@D-;w95uOPC2ubd|1B=8Y7RR9=oFpP@;WE4XHapy8 zW|^5yE{deJ-+$yFjjffXXlEy4by^4_So&snO-{4b?wj|%-|zd(zW=!P`P16b!TPh! zP1wT;8ViH{K9~t&YJh>jF$%B=vtTS20(2$~eIpnp#;9S4)-=7V5yo61?A{;vkNZAJ z_5~dFd-9YV8U$ziJCynG|adp!x6JwNk|y2pulQI)=rLvTA6aK zq2>`Z7MoL!go(J^n7=?s$(9{*QzrnxEoEe3f)x@rD$;2bWHd&IxUo!z2z(|XCcL`J z+k`=iGU9Sr47#<6d|`Rrlsyotj6f*q6zM=ut4KjMH6WEu+%$ne&1qpVE%=1WSVx1L zQ)a`YC^85(nAV)^UD{^;;rRUB@)~Z0N|N&~H~j*Bt-6FdWL(;vZJ$yt-)ofg5F2f_Et+RFm3OZ=g|e({P(4e`IqC`sr<_Gi%&>otCU +Date: Mon, 14 Sep 2026 19:49:47 +0000 +Subject: [PATCH] DIS: scale the refinement's SOR budget with the pyramid level + +The variational refinement gave every pyramid level the finest level's +iteration counts. That is wasted work: the solver is a red-black SOR, and the +sweeps a SOR needs scale with how far information has to travel across the +grid. With a fixed over-relaxation factor that is O(N) in the grid's extent, so +a level at half the size per axis reaches the same relative distance in roughly +half the sweeps. Running four sweeps on a 56x31 level buys nothing the second +sweep had not already bought, and each one costs a dispatch and a pipeline +barrier on a grid of a few dozen workgroups. + +The budget now comes from dis_vr_budget(): the finest level keeps the tier's +fixed-point count, coarser levels drop to one, and the sweep count decreases by +one per level down to a floor of two. The reduction is deliberately gentler +than the O(N) argument allows - 4, 3, 2, 2 rather than 4, 2, 1, 1 - so the +coarse levels keep margin. + +No level loses its refinement: every level that ran the solver still runs it. + +Per source frame at the Balance preset (448x252, four levels): + + x2 46 -> 46 dispatches (bit-identical: only the finest level solves) + x3 124 -> 84 (-32%) VR texture taps 19.3M -> 16.7M (-13%) + x4 140 -> 92 (-34%) VR texture taps 22.3M -> 19.3M (-13%) + +x2 is untouched by construction: it refines only the finest level, and +dis_vr_budget() returns that level's counts unchanged. + +A coarse-level skip was prototyped alongside this and dropped. The dispatch +overhead it targeted works out to a few percent of a source frame's budget at +x3, not enough to justify dropping refinement from the level that seeds the +whole pyramid, and there is no on-device measurement to say otherwise. + +Co-Authored-By: Claude Opus 5 +Claude-Session: https://claude.ai/code/session_01MCkAQJH8ik5iJZqf2NX3w6 +--- + app/src/main/cpp/winlator/vk/dis/vkr_dis.c | 24 ++++++++++++++++++++-- + 1 file changed, 22 insertions(+), 2 deletions(-) + +diff --git a/app/src/main/cpp/winlator/vk/dis/vkr_dis.c b/app/src/main/cpp/winlator/vk/dis/vkr_dis.c +index 4e518dd..d650f83 100644 +--- a/app/src/main/cpp/winlator/vk/dis/vkr_dis.c ++++ b/app/src/main/cpp/winlator/vk/dis/vkr_dis.c +@@ -60,6 +60,10 @@ + #define DIS_VR_ZETA 0.1f + #define DIS_VR_EPS 0.001f + ++// Fewest SOR sweeps a level that still runs the solver gets. ++#define DIS_VR_SOR_FLOOR 2u ++ ++ + #define DIS_SET_SAMPLERS 5u + #define DIS_SET_STORAGE 1u + #define DIS_SHARED_SETS_PER_LEVEL 6u +@@ -1430,11 +1434,27 @@ uint32_t vkr_dis_plan(VkrDis* d, uint32_t capacity, uint64_t source_frames) { + return (uint32_t)d->planned_gen; + } + ++// Solver budget for one level. The refinement is a red-black SOR, and the ++// number of sweeps a SOR needs scales with how far information has to travel ++// across the grid - a level is half the size per axis, so it reaches the same ++// relative distance in fewer sweeps. Spending the finest level's sweep count on ++// every level buys nothing numerically and costs a dispatch and a barrier each. ++static void dis_vr_budget(const DisRefine* refine, uint32_t l, uint32_t* fixed_point, ++ uint32_t* sor) { ++ *fixed_point = l == 0 ? refine->vr_fixed_point : 1u; ++ const uint32_t s = refine->vr_sor > l ? refine->vr_sor - l : DIS_VR_SOR_FLOOR; ++ *sor = s < DIS_VR_SOR_FLOOR ? DIS_VR_SOR_FLOOR : s; ++} ++ + static void dis_vr_level(VkrDis* d, VkCommandBuffer cmd, uint32_t slot, uint32_t l, + uint32_t lw, uint32_t lh, const DisRefine* refine, bool full) { + const uint32_t gw = (lw + DIS_LOCAL_SIZE - 1) / DIS_LOCAL_SIZE; + const uint32_t gh = (lh + DIS_LOCAL_SIZE - 1) / DIS_LOCAL_SIZE; + ++ uint32_t vr_fixed_point = 0; ++ uint32_t vr_sor = 0; ++ dis_vr_budget(refine, l, &vr_fixed_point, &vr_sor); ++ + vkd.CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, d->pass_vr_prep.pipeline); + vkd.CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, d->vr_pipeline_layout, 0, 1, + &d->vr_prep_sets[slot][l], 0, NULL); +@@ -1454,7 +1474,7 @@ static void dis_vr_level(VkrDis* d, VkCommandBuffer cmd, uint32_t slot, uint32_t + vkd.CmdDispatch(cmd, gw, gh, 1); + dis_compute_barrier(cmd); + +- for (uint32_t k = 0; k < refine->vr_fixed_point; k++) { ++ for (uint32_t k = 0; k < vr_fixed_point; k++) { + DisVrWPC wpc; + wpc.alpha2 = DIS_VR_ALPHA * 0.5f; + wpc.eps2 = DIS_VR_EPS * DIS_VR_EPS; +@@ -1479,7 +1499,7 @@ static void dis_vr_level(VkrDis* d, VkCommandBuffer cmd, uint32_t slot, uint32_t + vkd.CmdDispatch(cmd, gw, gh, 1); + dis_compute_barrier(cmd); + +- for (uint32_t it = 0; it < refine->vr_sor; it++) { ++ for (uint32_t it = 0; it < vr_sor; it++) { + DisVrSorPC spc; + spc.omega = DIS_VR_OMEGA; + spc.parity = 0; +-- +2.43.0 + diff --git a/zero-flow-candidate.bundle b/zero-flow-candidate.bundle new file mode 100644 index 0000000000000000000000000000000000000000..a951c261213e4fdb05bbaed7f14f0923d49fdfbe GIT binary patch literal 4331 zcmVea^p4LHTfcIhfs@9rz$Fjt+ELevIhz z#qs2i@2i`yCt`i^d#@m|3T`l5&M*)tr+39;=M5mYYLn@^sx0F;1mo3Rk(cmLn8e=7W z$W>0-II7H}{18QRC!EUY;{LQpmDH^zp>wK4O?cmwVq>W&RklJI*QgaOq?Qd#j3%S| zbcbUyftr%Ckz0y)4qgh218FTb2e(v~($ZurdK1ziDaX!~R!)ghS(#JO zn94iOjA+0gWcVn{9<7&(GZRgtr1cvr6GAKSPB`ppBHK2NX>}epbXoO$n&*hz?#<-d z!KRUg(r~jqtdae<8xXIE5U?o8)J|0htqFdVwknq-$X3Pxr0x*%fV?CUP_xQeti8BO zg~eaUS#z|=Ogu^(fZe)L8qu(s093XvMJ6lma2VxAGP*8W!mP(3u0>Nz4vE;`W?L|i zqNrpSuqCTS%^OpJ3Zxqkj+RdjIEXSbTK1#pMhc*ZO^6%GZ8SD9TegAmCtk3Ng~u3` z*IMZX6{c;dR!_3DJ>J1}j*7M{H(RiFib2*f&wy3ZpHltgLkvK`7Iaa0%b*wZk0lr2D(mzG;c{4cepY#oP(Jxf#O+m>OT;Gn2NXqKaDtw>9G z_`a3T3D8;3Qqcsi81Nsn!2ld&*+YcBAQmJ8OkiCPbv2%2g=&}-;H+xo7Bf%B&J8%1 z0WUtDON^wFe#Au#Nm>a_1BU}>Zf(%>7Jq@ZrpcivY*C2?I<4hC1bsN}(94lOqU52| z)||%cA)~`6B0R5+hB_!D)IH(!xF5%~hmT~4wY{Gy5W|=Cjk(Mh+mgO9Po~~{fAqfj zYy0$QpXrY#dNm*R2e6`n98+h$lC}GrVcZ|Tm~IWn^!ogRVe%Kl0iXDH!)U(7N9g%b zH9v)eV6eHZrOeUpgDGP>jJqObrLa~N8w~HJK{J(`AGwSX=BM>c*6#s`F6inshb+b#IOWOuhW%XnXLK9_>(k8m0R3c-`$Hzdkds~uyWNLo5PGwl80Nqc&J;Wm z7wkVwbITL8g;IdW5G>|3TF=mTbf7ArLp}6!kVFI_g~I@4@E-;k*~Qt2enj- zWeUCdm)#bmo7u47puL7b(-+UGg*%tZI~Z1tCQDy_uSRtl2Q^ zy1NDM?5-Z3WU1tpZPZ?lJL%a{T@)BK(CXZx0w*R6_0c{Ms`n z6d!pzbjS}~Rd8#9fBT{yoU%0lb-3NZU9`q!5XkiD_wEEy0-$`uP3L)FSE}|($Rqeg zahT=&h9|mLvmKd2DcaKchJtLEHF~;r{&>FUBVMp_6TM#H2u>Axfki@X74${vLD{uw zAv~$vRyu#FW-3dg7c5BL9{>TdbD4Kl_i*kV`R_C`-#gwM(n%>;7JjR7yFH+vy6+yp z2+g6^D)V1IqOfakj$TC_4{7OKZ4Wu zz8AyUf0Fem`V)B}rk3#nJwlx2Fuls>R8guq?(yU@qJcB_c${0vzL0%G7NZE;>zc1` zZoCS5^YPidP$|9;Uhl;To9h^rnE(rf4#xWd+fH!eRW*qm*J_-!&tdCFosxy&c$}Ne zIGJ%my~xe3iz|1Ey-vJ*`nTfpgVCvuQK6pzyFm^J{sBTZtx3!5dTs!K?|`%^hlG$= z@SJ#@0UiM1|A_*L0+5sx_imh)HEb@Ly>e;~Trk*HzcdJk{^0njSp#<)YF zPC-y7vRn^Kh1IPL3wWFX9{}M0w*$8Wka!fCdc1I>vrn)QPV5>;#7XnD{?%Wxj4J`^ zEe|5`0we0r`?qvad@2TWbI_#Aj>E+Wl_)5tePzwUG&>0-VQNt ze;xaoakC*~1S0_JiVffJ0`3d~Wx|u*33K7nex$`J`(mCvqIjHp$^4ReLo=gDn<$5a zXU$ZFot-D|`dq&Hk8k^m=*iO=g8<`#4xRo1t^B+N6t@a5z*o{s9!+@a^@p8`c$@(q z0O9|a1D6Akl@!0B7GHN&X7;%HC$gYT7{2@8MAeb0?$=rm)bIi^HEJ*O$=vPI+dvuc z3dYinYH53ToV&q&gL}dkk*yXUbMvhEBU=)jOTr$nW?tUE;MZmj#tSR}>&Ffx`{)5- zk6>wtiWcN-21a^#Jq?H&YqyzroNZTKZyQAvl@Q`1ts1EhAYNcvL`hP6>wM6vEh!3Z zMJmx2HK~LUQZ(Kj+avGJEHmRcDXLcD7a;i^egjXuA@K{~AHlhIW^E^pg{ZST_w$^4 z=KAkX{(SK3NptI#*tb(rP4bcGs}PhEVWh+><*YbXd9Y55q!+;obyAm78-Cp{Z9c{~ zX~c9SgF238EAZx(bnxZjt5k)srXyf4g_A*BVF%*H(*xm@*TqDZzUd(9z&aE^mh zSQ(*>4@wpp{E^Fp0NY&bzkJjYl`<2!Gli&REi3|;^3)3tbBo+;O(80Ktj$p5)&yk& zpO%iZtM!R0ySl(*oy$@*sClXoa^Pg8JhGVtt}vBBwHu57kh~$+*9%?HpX3Z-)b)Fm zxew9>4=1)_>)cA`(UTILDLd$qhOb;YrTHV_BZMh7iR?ImZ3 z#(F$k_STmk4^XvX+)E{okxCcPNaW9$lkZalcZ6H(o&Jq}`Z6C6xzI+`(ij43U?(oZ z0~BIQfvXH+JG(3D6HYvZl5_q55%Ncr+S=hhm+B4vCaYd14i`wP&5tl_#}3Cy_o|lE%Gi@! zdVHhiO(6oVP|ow9HgX8QO^A)ikm`@5CU+dERNjk8P1Vg~iQD&5Z|O4!6vOT`@Q$CD zMKA01K+RsZy@hAp2O=_qWriFy%N|rQY^814!}eYFuygxD5A<2^xg&OntRVH=!B(~j zJF%(mh~!|b>X1yEGq%0#PHWxT-Vuw>Ioorl+hmB8Z(&jwKZmtSiiOymtj*CF4-jSDu%oe4sTl>@>pL zC}3pftgYIy*#mVY&Dc}yrxenui*6x!no z&K2&!&|%e5Q7)G*ELkE(aVkor7p0g1#$V*@jnbat?qzdUD3qAvP9LiK!jE-L{Svo# zNHJBoo)kPU~3 zmA5U*;j1E!p6zFp$nvydJ3?>Dmp^gc*(omTNoc*vJ}7{pu95h`!6Rf;S~KJsS_DD{ z@doM;$yq{~#x!hwwMfk=&Z0ALz;qez+M~g%!{q` zX10H+d>@V3T7xjG&;Gh}|LsB#2DASzefE~<&c1#3qgiqJ>O17>=I-qG%O755D9q3)B^U zzTUz=`|Z7tueFXBv?KS6{=c66xO#&|JA2jcNpt~qjdc~+{0ePOO^cdp-O~6#qq>`d zr0P1&qtEH>%2JVWuqqXt&acOdD&EqhYH2U^LNy7G7@ZFD^_3je&ej+tzMg97X<@;g z(plb{Xz>OaXp~u*m^UmJ9BzssB#~-8a-vjjT>Y`_q6Tc8#l17Z0y#*dOe8T%4@Z6M z!=L-4Zmq+#(WY_dMOc0%kv$Lic7 z%Zuk&sXAiS{xBiqbc6G6kG@*UuC0kz>Li6}w~EZ0{j%D-ni$f-{oh+(zrXT&<$rRU Z!oN;tgQUxASI7mQsI9{0xx2|aV-aO_Uh@C| literal 0 HcmV?d00001 diff --git a/zero-flow-candidate.patch b/zero-flow-candidate.patch new file mode 100644 index 000000000..74d48b51f --- /dev/null +++ b/zero-flow-candidate.patch @@ -0,0 +1,161 @@ +From cd337dbc6a6c564ab6ab82dd4902168eb603dd45 Mon Sep 17 00:00:00 2001 +From: qwertypower +Date: Mon, 14 Sep 2026 19:58:00 +0000 +Subject: [PATCH] DIS: let the patch search choose "did not move" + +Static HUD, menus and map overlays flicker with generation on. The cause is +that the patch search has no way to express "this block did not move": it is +initialised from the coarser level, which carries the motion of whatever +surrounds the overlay, and Gauss-Newton refines from there. Gauss-Newton is a +local optimiser, and inside a smooth overlay that initialisation sits in a +local basin it never leaves. The overlay inherits a displacement it does not +have, is warped in every generated frame, and is snapped back by the next real +one. + +Measured on a synthetic overlay over a background panning four pixels, a block +fully inside the overlay stays at 3.9 px and leaves a residual of RMS 24, while +the zero vector leaves 0. The search is not close to the answer; it is nowhere +near it. + +So the zero vector is now scored explicitly once the search has finished, and +taken when it explains the block better than what the search settled on. The +test is a ratio of residuals rather than an absolute threshold, which is what +makes it usable: an absolute threshold has to sit near the noise floor and, on +the same scene, stopped firing entirely once frame noise reached an RMS of 3. +The ratio separates the two cases by orders of magnitude and holds from +noiseless up to an RMS of 6: + + overlay interior ratio 0.00 - 0.32 -> zero taken + overlay edge block ratio 0.39 - 0.46 -> zero taken + background, 4 px pan ratio 2.1 - 1e7 -> kept + background, 0.7 px pan ratio 2.7 -> kept + background, 0.3 px pan ratio 1.1 -> kept + +Swept over noise levels and pan speeds, 19 of 20 cases classify correctly; the +miss is an overlay edge block at an RMS 10 noise floor, which is a +non-detection rather than a frozen pan. No pan from 0.2 to 2 px was frozen at +any noise level. + +A flat region that is genuinely moving also qualifies, because a featureless +block matches itself anywhere. That costs nothing: there is nothing in it to +see move, and the structured blocks around it still carry the real motion. + +When zero wins, its score goes into .z instead of the usual -1. It is measured +with exactly the metric the propagation passes use, on the same reference +block, so propagation then compares a neighbour's moving vector against a real +and very low incumbent score rather than re-deriving one - which is what stops +the surrounding motion from being propagated into the overlay afterwards. + +Cost is 64 samples on patches that claim a displacement, about 10% of the +inverse search and some 2% of the flow chain. Patches already at zero skip it. +The test is also skipped when the search result was thrown out and replaced by +the coarse estimate, since the residual it would be compared against belongs to +the discarded vector. + +Co-Authored-By: Claude Opus 5 +Claude-Session: https://claude.ai/code/session_01MCkAQJH8ik5iJZqf2NX3w6 +--- + .../vk/shaders/dis_inverse_search.comp | 78 ++++++++++++++++++- + 1 file changed, 75 insertions(+), 3 deletions(-) + +diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_inverse_search.comp b/app/src/main/cpp/winlator/vk/shaders/dis_inverse_search.comp +index 618f606..b538489 100644 +--- a/app/src/main/cpp/winlator/vk/shaders/dis_inverse_search.comp ++++ b/app/src/main/cpp/winlator/vk/shaders/dis_inverse_search.comp +@@ -7,6 +7,43 @@ precision highp int; + + #define DIS_MAX_MATCH_RMS 36.0 + ++// How much better the zero vector has to explain the block than whatever the ++// search settled on, as a ratio of RMS residuals, before it is taken instead. ++// ++// Static HUD, menus and map overlays sit on top of moving content and are ++// pixel-identical between frames, but the search has no notion of "did not ++// move": it starts from the coarser level, which carries the motion of whatever ++// surrounds the overlay, and Gauss-Newton refines from there. Gauss-Newton is a ++// local optimiser, and inside a smooth overlay the initialisation sits in a ++// local basin it never leaves - measured on a synthetic overlay over a ++// background panning four pixels, it stays at 3.9 px and leaves a residual of ++// RMS 24, while the zero vector leaves 0. The overlay is warped in the ++// generated frames and snapped back by the next real one, which is the flicker. ++// ++// The test is a ratio, not an absolute threshold, and that is what makes it ++// work. An absolute one has to sit near the noise floor, and on the same ++// synthetic scene it stopped firing entirely once frame noise reached an RMS of ++// 3. The ratio separates the cases by two orders of magnitude and holds from ++// noiseless up to an RMS of 6: ++// ++// overlay interior ratio 0.00 - 0.32 -> zero taken ++// overlay edge block ratio 0.39 - 0.46 -> zero taken ++// background, 4 px pan ratio 2.1 - 1e7 -> kept ++// background, 0.7 px pan ratio 2.7 -> kept ++// background, 0.3 px pan ratio 1.1 -> kept ++// ++// 0.5 means zero has to be twice as good in RMS terms. The nearest thing on the ++// other side is a third of a pixel of real motion at 1.1, so there is room. ++// ++// A flat region that IS moving also qualifies, because a featureless block ++// matches itself anywhere. That costs nothing: there is nothing in it to see ++// move, and the structured blocks around it still carry the real motion. ++#define DIS_ZERO_MATCH_RATIO 0.5 ++ ++// Below this displacement, in texels, the search already landed on zero and ++// there is nothing for the test to change; skip its 64 samples. ++#define DIS_ZERO_TEST_MIN_PX 0.5 ++ + layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in; + + layout(set = 0, binding = 0) uniform sampler2D lastLumaMap; +@@ -124,11 +161,46 @@ void main() { + + bool unmatched = prevSSD * N_INV > DIS_MAX_MATCH_RMS * DIS_MAX_MATCH_RMS; + +- if (any(isnan(flow)) || any(isinf(flow)) || clamped || unmatched || +- length(flow - initialFlow) > patchSize) { ++ const bool reverted = any(isnan(flow)) || any(isinf(flow)) || clamped || unmatched || ++ length(flow - initialFlow) > patchSize; ++ if (reverted) { + flow = initialFlow; + } + ++ // Does the block explain itself better by not moving at all? Only asked when ++ // the search claims a displacement, and only when it was not thrown out ++ // above - prevSSD belongs to the searched vector, so once that has been ++ // replaced by the coarse estimate there is nothing valid to compare against. ++ float zeroSsd = -1.0; ++ if (!reverted && dot(flow, flow) > DIS_ZERO_TEST_MIN_PX * DIS_ZERO_TEST_MIN_PX) { ++ float zsd = 0.0; ++ float zsd2 = 0.0; ++ for (int i = 0; i < 8; i++) { ++ for (int j = 0; j < 8; j++) { ++ vec2 tc = (vec2(pix) + vec2(i, j) + 0.5) * invImageSize; ++ float diff = textureLod(nextLumaMap, tc, 0.0).x - lastImageData[i * 8 + j]; ++ zsd += diff; ++ zsd2 += diff * diff; ++ } ++ } ++ float zssd = zsd2 - zsd * zsd / N; ++ // Compared as sums of squares, so the RMS ratio is squared here and no ++ // square root is taken. prevSSD of exactly zero is a perfect match that ++ // only an equally perfect zero can tie, and the comparison handles it ++ // without a divide. ++ if (zssd <= prevSSD * (DIS_ZERO_MATCH_RATIO * DIS_ZERO_MATCH_RATIO)) { ++ flow = vec2(0.0); ++ zeroSsd = zssd; ++ } ++ } ++ + flow *= invImageSize; +- imageStore(sparseFlowMap, pixSparse, vec4(flow, -1.0, 1.0)); ++ // .z is the mean-normalized SSD of the stored vector, or -1 for "not scored ++ // with that metric". The zero test measures exactly the metric the ++ // propagation passes use, on the same reference block, so when zero wins its ++ // score is carried forward: propagation then compares a neighbour's moving ++ // vector against a real, and very low, incumbent score instead of having to ++ // re-derive one, which is what keeps the surrounding motion from being ++ // propagated into the overlay. ++ imageStore(sparseFlowMap, pixSparse, vec4(flow, zeroSsd, 1.0)); + } +-- +2.43.0 + From b36b86a01eb1e3b0b1026be137340882551df8d7 Mon Sep 17 00:00:00 2001 From: unknown <1qwertypower1@gmail.com> Date: Tue, 15 Sep 2026 18:44:36 +0300 Subject: [PATCH 02/17] Update DIS --- vr-dispatch-budget.bundle | Bin 2661 -> 0 bytes vr-dispatch-budget.patch | 106 ------------------------ zero-flow-candidate.bundle | Bin 4331 -> 0 bytes zero-flow-candidate.patch | 161 ------------------------------------- 4 files changed, 267 deletions(-) delete mode 100644 vr-dispatch-budget.bundle delete mode 100644 vr-dispatch-budget.patch delete mode 100644 zero-flow-candidate.bundle delete mode 100644 zero-flow-candidate.patch diff --git a/vr-dispatch-budget.bundle b/vr-dispatch-budget.bundle deleted file mode 100644 index 4ec750e5b1e4ecb45d56dbda1cc21a15ca03d9e5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2661 zcmV-r3YzsJAa*h!XK8dGVs&n0Y-I{9WHdNtWo9!rW;r%7Ib$|AGd4CeWMXDyGh<^i zHZnD4IWuHsVlpy0AVz6;FJo_RbaHQOb09MyC?hvEF)0c;WivE0VPZI8F=R9|WMVdA zIAJ(pWi(?qIWb~pWH&N2H#B84H)1d#a%E<7FKA_9WOFZeaxG+Ob8umFV`wd6b!2B{ zbP5VkK|@Ob00062000V_pg|jo+c-OU>h9ZbbAWVyI7^x8qoZ%2;YYf)^ZY#x9r5vo=->q_~ESMKwOQ#O!Bk9qw&cg9g3?b8K2+k zhy3hk`JBEy9`mQ|Q}D%XpM2eJJw;o-7(I55^x7W};)`J1*?#ZJ^|kL`M#F=}Fxrf# z6JL9Dx?26=Rdk$-cRHH%NyjYXWagX{Pesm`ki&aTliPcmmdT78T7hHs2b?cmIGLnp979fk2)Y^rbDUmekoAqM=y#^GC>l=z^Y+d=LEH>x{Qv<%;8}B2w z!)m?3BVQX3atP=it2w~gIm^;2Be4aq$ce4tuQ-e6Tvt16X-ksfB5;`qZCD<28(qnA zHqSQR-$7i?O}a1hT;wxKWvOZ-0VJbNv=06H_G!7%luI+WC!IHsWu8>T0a#);iDkf& zY-nolG8MfR9O7}*NgGQsKo!iuXm=SEqTm^7>rI(b0y(+6Ee|A9OCeuTBDXwu6`dgs zfUaazD=kNP(A{7lRdQ+L|9DY9s?ELq{x9mGh0D>J4K9S3T$vh$6cC}4(;+^X!WgMM zG$l$Fw%$nORW%h^l>;R_M(!FQrW?HewABa3W4_nOtdVQ2UU=LkjRBc+R=gRc_6&`FP=smQI>g zPP>#W3$#Q)k%P~%tkGuDCLxS|)0zFcD|g)pEvgz--CWv;482_D))nEtSQ`Wd?WM|U z5-#@mkY!Ggh2k2e+iSPG>!1UqPJI=!{l4cB);hpjJEF!%`@WM6z!B|Dg=vTc&;;7F zkFtQVU+V0z>HR8LNaP1?8?4(#fJPX++b6fa-jhMYl?r-b5Qg7thBx-0A2p9}>{&m2 zS4V-X-)`B>Uf`m%|0_xm?4r1EyC@#ALa8AcSt6MPa)E+!ZB(U$KKQGd*S}B8>CgL@ z%He{mb6pju9+LHF#APugG2;meGpLo_s3}^A!cI&SL>h?)C=jts=6EV5cD~!83AJ1K zW2@5at`eS|YjZTbg=`=szMH#1UYc6+XkWBKY&Glp`K>AdvF>4JyftKOhX~K@^swH4 zmXKAtscyG}=gqUiwYsI|jV84l+*awUxy)XlJg*Il(g-`nz-uGnF9I-@=vkD^g~W}T zZAjXaE1@~qI+2Zm`{{!5Fx>R%Bx5Cbxh+af9r{rJ#Q4N=GY3WyyI&bzb*eGpTDywQ zjVW~h=qPsfCKE@oO!$$m_J6eeo0G-h?(%b2EIQ)y`DYr8zlW>H`wRD9Fh21DJwlx2 zFuls>R8guq?(yU@qJcB_c${0vzL0%G7NbbRzv*nTWjzb8RrfKRR!rMHXE{sT<~l}Y zCIId$4XgVB+fH!eRW*qm*J_-!&tdCFosxy&c$}NeIGJ%my@;#1iT>HItC`H4zR2|1 zNiM#fTDl7WjE)N#{sBTZtx3!5dTs!K?|`%^hlG$=@SJ#@>tyO=nlMEqE=*dDMck*t z{oCP?neK(UkMBRYK5<1N0H(wZ2>$`lD&fYsL!nMVP$#ln4@-sBtqcozoBd*VPbaqR^Y>FclDT;hJ zI0AfA!7t10LCQ?{O|(q3<71s zlimq);nIGj#VY$^o;;#>oO{Xql6gZjqlk*RTqom%t54oXbSuxVVapQzcF%qCbjBb6 z+xiWf{{gN1yag1u3NOG{(n}sqcydbzWs<*(6G0Tm5zi}@D-;w95uOPC2ubd|1B=8Y7RR9=oFpP@;WE4XHapy8 zW|^5yE{deJ-+$yFjjffXXlEy4by^4_So&snO-{4b?wj|%-|zd(zW=!P`P16b!TPh! zP1wT;8ViH{K9~t&YJh>jF$%B=vtTS20(2$~eIpnp#;9S4)-=7V5yo61?A{;vkNZAJ z_5~dFd-9YV8U$ziJCynG|adp!x6JwNk|y2pulQI)=rLvTA6aK zq2>`Z7MoL!go(J^n7=?s$(9{*QzrnxEoEe3f)x@rD$;2bWHd&IxUo!z2z(|XCcL`J z+k`=iGU9Sr47#<6d|`Rrlsyotj6f*q6zM=ut4KjMH6WEu+%$ne&1qpVE%=1WSVx1L zQ)a`YC^85(nAV)^UD{^;;rRUB@)~Z0N|N&~H~j*Bt-6FdWL(;vZJ$yt-)ofg5F2f_Et+RFm3OZ=g|e({P(4e`IqC`sr<_Gi%&>otCU -Date: Mon, 14 Sep 2026 19:49:47 +0000 -Subject: [PATCH] DIS: scale the refinement's SOR budget with the pyramid level - -The variational refinement gave every pyramid level the finest level's -iteration counts. That is wasted work: the solver is a red-black SOR, and the -sweeps a SOR needs scale with how far information has to travel across the -grid. With a fixed over-relaxation factor that is O(N) in the grid's extent, so -a level at half the size per axis reaches the same relative distance in roughly -half the sweeps. Running four sweeps on a 56x31 level buys nothing the second -sweep had not already bought, and each one costs a dispatch and a pipeline -barrier on a grid of a few dozen workgroups. - -The budget now comes from dis_vr_budget(): the finest level keeps the tier's -fixed-point count, coarser levels drop to one, and the sweep count decreases by -one per level down to a floor of two. The reduction is deliberately gentler -than the O(N) argument allows - 4, 3, 2, 2 rather than 4, 2, 1, 1 - so the -coarse levels keep margin. - -No level loses its refinement: every level that ran the solver still runs it. - -Per source frame at the Balance preset (448x252, four levels): - - x2 46 -> 46 dispatches (bit-identical: only the finest level solves) - x3 124 -> 84 (-32%) VR texture taps 19.3M -> 16.7M (-13%) - x4 140 -> 92 (-34%) VR texture taps 22.3M -> 19.3M (-13%) - -x2 is untouched by construction: it refines only the finest level, and -dis_vr_budget() returns that level's counts unchanged. - -A coarse-level skip was prototyped alongside this and dropped. The dispatch -overhead it targeted works out to a few percent of a source frame's budget at -x3, not enough to justify dropping refinement from the level that seeds the -whole pyramid, and there is no on-device measurement to say otherwise. - -Co-Authored-By: Claude Opus 5 -Claude-Session: https://claude.ai/code/session_01MCkAQJH8ik5iJZqf2NX3w6 ---- - app/src/main/cpp/winlator/vk/dis/vkr_dis.c | 24 ++++++++++++++++++++-- - 1 file changed, 22 insertions(+), 2 deletions(-) - -diff --git a/app/src/main/cpp/winlator/vk/dis/vkr_dis.c b/app/src/main/cpp/winlator/vk/dis/vkr_dis.c -index 4e518dd..d650f83 100644 ---- a/app/src/main/cpp/winlator/vk/dis/vkr_dis.c -+++ b/app/src/main/cpp/winlator/vk/dis/vkr_dis.c -@@ -60,6 +60,10 @@ - #define DIS_VR_ZETA 0.1f - #define DIS_VR_EPS 0.001f - -+// Fewest SOR sweeps a level that still runs the solver gets. -+#define DIS_VR_SOR_FLOOR 2u -+ -+ - #define DIS_SET_SAMPLERS 5u - #define DIS_SET_STORAGE 1u - #define DIS_SHARED_SETS_PER_LEVEL 6u -@@ -1430,11 +1434,27 @@ uint32_t vkr_dis_plan(VkrDis* d, uint32_t capacity, uint64_t source_frames) { - return (uint32_t)d->planned_gen; - } - -+// Solver budget for one level. The refinement is a red-black SOR, and the -+// number of sweeps a SOR needs scales with how far information has to travel -+// across the grid - a level is half the size per axis, so it reaches the same -+// relative distance in fewer sweeps. Spending the finest level's sweep count on -+// every level buys nothing numerically and costs a dispatch and a barrier each. -+static void dis_vr_budget(const DisRefine* refine, uint32_t l, uint32_t* fixed_point, -+ uint32_t* sor) { -+ *fixed_point = l == 0 ? refine->vr_fixed_point : 1u; -+ const uint32_t s = refine->vr_sor > l ? refine->vr_sor - l : DIS_VR_SOR_FLOOR; -+ *sor = s < DIS_VR_SOR_FLOOR ? DIS_VR_SOR_FLOOR : s; -+} -+ - static void dis_vr_level(VkrDis* d, VkCommandBuffer cmd, uint32_t slot, uint32_t l, - uint32_t lw, uint32_t lh, const DisRefine* refine, bool full) { - const uint32_t gw = (lw + DIS_LOCAL_SIZE - 1) / DIS_LOCAL_SIZE; - const uint32_t gh = (lh + DIS_LOCAL_SIZE - 1) / DIS_LOCAL_SIZE; - -+ uint32_t vr_fixed_point = 0; -+ uint32_t vr_sor = 0; -+ dis_vr_budget(refine, l, &vr_fixed_point, &vr_sor); -+ - vkd.CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, d->pass_vr_prep.pipeline); - vkd.CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, d->vr_pipeline_layout, 0, 1, - &d->vr_prep_sets[slot][l], 0, NULL); -@@ -1454,7 +1474,7 @@ static void dis_vr_level(VkrDis* d, VkCommandBuffer cmd, uint32_t slot, uint32_t - vkd.CmdDispatch(cmd, gw, gh, 1); - dis_compute_barrier(cmd); - -- for (uint32_t k = 0; k < refine->vr_fixed_point; k++) { -+ for (uint32_t k = 0; k < vr_fixed_point; k++) { - DisVrWPC wpc; - wpc.alpha2 = DIS_VR_ALPHA * 0.5f; - wpc.eps2 = DIS_VR_EPS * DIS_VR_EPS; -@@ -1479,7 +1499,7 @@ static void dis_vr_level(VkrDis* d, VkCommandBuffer cmd, uint32_t slot, uint32_t - vkd.CmdDispatch(cmd, gw, gh, 1); - dis_compute_barrier(cmd); - -- for (uint32_t it = 0; it < refine->vr_sor; it++) { -+ for (uint32_t it = 0; it < vr_sor; it++) { - DisVrSorPC spc; - spc.omega = DIS_VR_OMEGA; - spc.parity = 0; --- -2.43.0 - diff --git a/zero-flow-candidate.bundle b/zero-flow-candidate.bundle deleted file mode 100644 index a951c261213e4fdb05bbaed7f14f0923d49fdfbe..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4331 zcmVea^p4LHTfcIhfs@9rz$Fjt+ELevIhz z#qs2i@2i`yCt`i^d#@m|3T`l5&M*)tr+39;=M5mYYLn@^sx0F;1mo3Rk(cmLn8e=7W z$W>0-II7H}{18QRC!EUY;{LQpmDH^zp>wK4O?cmwVq>W&RklJI*QgaOq?Qd#j3%S| zbcbUyftr%Ckz0y)4qgh218FTb2e(v~($ZurdK1ziDaX!~R!)ghS(#JO zn94iOjA+0gWcVn{9<7&(GZRgtr1cvr6GAKSPB`ppBHK2NX>}epbXoO$n&*hz?#<-d z!KRUg(r~jqtdae<8xXIE5U?o8)J|0htqFdVwknq-$X3Pxr0x*%fV?CUP_xQeti8BO zg~eaUS#z|=Ogu^(fZe)L8qu(s093XvMJ6lma2VxAGP*8W!mP(3u0>Nz4vE;`W?L|i zqNrpSuqCTS%^OpJ3Zxqkj+RdjIEXSbTK1#pMhc*ZO^6%GZ8SD9TegAmCtk3Ng~u3` z*IMZX6{c;dR!_3DJ>J1}j*7M{H(RiFib2*f&wy3ZpHltgLkvK`7Iaa0%b*wZk0lr2D(mzG;c{4cepY#oP(Jxf#O+m>OT;Gn2NXqKaDtw>9G z_`a3T3D8;3Qqcsi81Nsn!2ld&*+YcBAQmJ8OkiCPbv2%2g=&}-;H+xo7Bf%B&J8%1 z0WUtDON^wFe#Au#Nm>a_1BU}>Zf(%>7Jq@ZrpcivY*C2?I<4hC1bsN}(94lOqU52| z)||%cA)~`6B0R5+hB_!D)IH(!xF5%~hmT~4wY{Gy5W|=Cjk(Mh+mgO9Po~~{fAqfj zYy0$QpXrY#dNm*R2e6`n98+h$lC}GrVcZ|Tm~IWn^!ogRVe%Kl0iXDH!)U(7N9g%b zH9v)eV6eHZrOeUpgDGP>jJqObrLa~N8w~HJK{J(`AGwSX=BM>c*6#s`F6inshb+b#IOWOuhW%XnXLK9_>(k8m0R3c-`$Hzdkds~uyWNLo5PGwl80Nqc&J;Wm z7wkVwbITL8g;IdW5G>|3TF=mTbf7ArLp}6!kVFI_g~I@4@E-;k*~Qt2enj- zWeUCdm)#bmo7u47puL7b(-+UGg*%tZI~Z1tCQDy_uSRtl2Q^ zy1NDM?5-Z3WU1tpZPZ?lJL%a{T@)BK(CXZx0w*R6_0c{Ms`n z6d!pzbjS}~Rd8#9fBT{yoU%0lb-3NZU9`q!5XkiD_wEEy0-$`uP3L)FSE}|($Rqeg zahT=&h9|mLvmKd2DcaKchJtLEHF~;r{&>FUBVMp_6TM#H2u>Axfki@X74${vLD{uw zAv~$vRyu#FW-3dg7c5BL9{>TdbD4Kl_i*kV`R_C`-#gwM(n%>;7JjR7yFH+vy6+yp z2+g6^D)V1IqOfakj$TC_4{7OKZ4Wu zz8AyUf0Fem`V)B}rk3#nJwlx2Fuls>R8guq?(yU@qJcB_c${0vzL0%G7NZE;>zc1` zZoCS5^YPidP$|9;Uhl;To9h^rnE(rf4#xWd+fH!eRW*qm*J_-!&tdCFosxy&c$}Ne zIGJ%my~xe3iz|1Ey-vJ*`nTfpgVCvuQK6pzyFm^J{sBTZtx3!5dTs!K?|`%^hlG$= z@SJ#@0UiM1|A_*L0+5sx_imh)HEb@Ly>e;~Trk*HzcdJk{^0njSp#<)YF zPC-y7vRn^Kh1IPL3wWFX9{}M0w*$8Wka!fCdc1I>vrn)QPV5>;#7XnD{?%Wxj4J`^ zEe|5`0we0r`?qvad@2TWbI_#Aj>E+Wl_)5tePzwUG&>0-VQNt ze;xaoakC*~1S0_JiVffJ0`3d~Wx|u*33K7nex$`J`(mCvqIjHp$^4ReLo=gDn<$5a zXU$ZFot-D|`dq&Hk8k^m=*iO=g8<`#4xRo1t^B+N6t@a5z*o{s9!+@a^@p8`c$@(q z0O9|a1D6Akl@!0B7GHN&X7;%HC$gYT7{2@8MAeb0?$=rm)bIi^HEJ*O$=vPI+dvuc z3dYinYH53ToV&q&gL}dkk*yXUbMvhEBU=)jOTr$nW?tUE;MZmj#tSR}>&Ffx`{)5- zk6>wtiWcN-21a^#Jq?H&YqyzroNZTKZyQAvl@Q`1ts1EhAYNcvL`hP6>wM6vEh!3Z zMJmx2HK~LUQZ(Kj+avGJEHmRcDXLcD7a;i^egjXuA@K{~AHlhIW^E^pg{ZST_w$^4 z=KAkX{(SK3NptI#*tb(rP4bcGs}PhEVWh+><*YbXd9Y55q!+;obyAm78-Cp{Z9c{~ zX~c9SgF238EAZx(bnxZjt5k)srXyf4g_A*BVF%*H(*xm@*TqDZzUd(9z&aE^mh zSQ(*>4@wpp{E^Fp0NY&bzkJjYl`<2!Gli&REi3|;^3)3tbBo+;O(80Ktj$p5)&yk& zpO%iZtM!R0ySl(*oy$@*sClXoa^Pg8JhGVtt}vBBwHu57kh~$+*9%?HpX3Z-)b)Fm zxew9>4=1)_>)cA`(UTILDLd$qhOb;YrTHV_BZMh7iR?ImZ3 z#(F$k_STmk4^XvX+)E{okxCcPNaW9$lkZalcZ6H(o&Jq}`Z6C6xzI+`(ij43U?(oZ z0~BIQfvXH+JG(3D6HYvZl5_q55%Ncr+S=hhm+B4vCaYd14i`wP&5tl_#}3Cy_o|lE%Gi@! zdVHhiO(6oVP|ow9HgX8QO^A)ikm`@5CU+dERNjk8P1Vg~iQD&5Z|O4!6vOT`@Q$CD zMKA01K+RsZy@hAp2O=_qWriFy%N|rQY^814!}eYFuygxD5A<2^xg&OntRVH=!B(~j zJF%(mh~!|b>X1yEGq%0#PHWxT-Vuw>Ioorl+hmB8Z(&jwKZmtSiiOymtj*CF4-jSDu%oe4sTl>@>pL zC}3pftgYIy*#mVY&Dc}yrxenui*6x!no z&K2&!&|%e5Q7)G*ELkE(aVkor7p0g1#$V*@jnbat?qzdUD3qAvP9LiK!jE-L{Svo# zNHJBoo)kPU~3 zmA5U*;j1E!p6zFp$nvydJ3?>Dmp^gc*(omTNoc*vJ}7{pu95h`!6Rf;S~KJsS_DD{ z@doM;$yq{~#x!hwwMfk=&Z0ALz;qez+M~g%!{q` zX10H+d>@V3T7xjG&;Gh}|LsB#2DASzefE~<&c1#3qgiqJ>O17>=I-qG%O755D9q3)B^U zzTUz=`|Z7tueFXBv?KS6{=c66xO#&|JA2jcNpt~qjdc~+{0ePOO^cdp-O~6#qq>`d zr0P1&qtEH>%2JVWuqqXt&acOdD&EqhYH2U^LNy7G7@ZFD^_3je&ej+tzMg97X<@;g z(plb{Xz>OaXp~u*m^UmJ9BzssB#~-8a-vjjT>Y`_q6Tc8#l17Z0y#*dOe8T%4@Z6M z!=L-4Zmq+#(WY_dMOc0%kv$Lic7 z%Zuk&sXAiS{xBiqbc6G6kG@*UuC0kz>Li6}w~EZ0{j%D-ni$f-{oh+(zrXT&<$rRU Z!oN;tgQUxASI7mQsI9{0xx2|aV-aO_Uh@C| diff --git a/zero-flow-candidate.patch b/zero-flow-candidate.patch deleted file mode 100644 index 74d48b51f..000000000 --- a/zero-flow-candidate.patch +++ /dev/null @@ -1,161 +0,0 @@ -From cd337dbc6a6c564ab6ab82dd4902168eb603dd45 Mon Sep 17 00:00:00 2001 -From: qwertypower -Date: Mon, 14 Sep 2026 19:58:00 +0000 -Subject: [PATCH] DIS: let the patch search choose "did not move" - -Static HUD, menus and map overlays flicker with generation on. The cause is -that the patch search has no way to express "this block did not move": it is -initialised from the coarser level, which carries the motion of whatever -surrounds the overlay, and Gauss-Newton refines from there. Gauss-Newton is a -local optimiser, and inside a smooth overlay that initialisation sits in a -local basin it never leaves. The overlay inherits a displacement it does not -have, is warped in every generated frame, and is snapped back by the next real -one. - -Measured on a synthetic overlay over a background panning four pixels, a block -fully inside the overlay stays at 3.9 px and leaves a residual of RMS 24, while -the zero vector leaves 0. The search is not close to the answer; it is nowhere -near it. - -So the zero vector is now scored explicitly once the search has finished, and -taken when it explains the block better than what the search settled on. The -test is a ratio of residuals rather than an absolute threshold, which is what -makes it usable: an absolute threshold has to sit near the noise floor and, on -the same scene, stopped firing entirely once frame noise reached an RMS of 3. -The ratio separates the two cases by orders of magnitude and holds from -noiseless up to an RMS of 6: - - overlay interior ratio 0.00 - 0.32 -> zero taken - overlay edge block ratio 0.39 - 0.46 -> zero taken - background, 4 px pan ratio 2.1 - 1e7 -> kept - background, 0.7 px pan ratio 2.7 -> kept - background, 0.3 px pan ratio 1.1 -> kept - -Swept over noise levels and pan speeds, 19 of 20 cases classify correctly; the -miss is an overlay edge block at an RMS 10 noise floor, which is a -non-detection rather than a frozen pan. No pan from 0.2 to 2 px was frozen at -any noise level. - -A flat region that is genuinely moving also qualifies, because a featureless -block matches itself anywhere. That costs nothing: there is nothing in it to -see move, and the structured blocks around it still carry the real motion. - -When zero wins, its score goes into .z instead of the usual -1. It is measured -with exactly the metric the propagation passes use, on the same reference -block, so propagation then compares a neighbour's moving vector against a real -and very low incumbent score rather than re-deriving one - which is what stops -the surrounding motion from being propagated into the overlay afterwards. - -Cost is 64 samples on patches that claim a displacement, about 10% of the -inverse search and some 2% of the flow chain. Patches already at zero skip it. -The test is also skipped when the search result was thrown out and replaced by -the coarse estimate, since the residual it would be compared against belongs to -the discarded vector. - -Co-Authored-By: Claude Opus 5 -Claude-Session: https://claude.ai/code/session_01MCkAQJH8ik5iJZqf2NX3w6 ---- - .../vk/shaders/dis_inverse_search.comp | 78 ++++++++++++++++++- - 1 file changed, 75 insertions(+), 3 deletions(-) - -diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_inverse_search.comp b/app/src/main/cpp/winlator/vk/shaders/dis_inverse_search.comp -index 618f606..b538489 100644 ---- a/app/src/main/cpp/winlator/vk/shaders/dis_inverse_search.comp -+++ b/app/src/main/cpp/winlator/vk/shaders/dis_inverse_search.comp -@@ -7,6 +7,43 @@ precision highp int; - - #define DIS_MAX_MATCH_RMS 36.0 - -+// How much better the zero vector has to explain the block than whatever the -+// search settled on, as a ratio of RMS residuals, before it is taken instead. -+// -+// Static HUD, menus and map overlays sit on top of moving content and are -+// pixel-identical between frames, but the search has no notion of "did not -+// move": it starts from the coarser level, which carries the motion of whatever -+// surrounds the overlay, and Gauss-Newton refines from there. Gauss-Newton is a -+// local optimiser, and inside a smooth overlay the initialisation sits in a -+// local basin it never leaves - measured on a synthetic overlay over a -+// background panning four pixels, it stays at 3.9 px and leaves a residual of -+// RMS 24, while the zero vector leaves 0. The overlay is warped in the -+// generated frames and snapped back by the next real one, which is the flicker. -+// -+// The test is a ratio, not an absolute threshold, and that is what makes it -+// work. An absolute one has to sit near the noise floor, and on the same -+// synthetic scene it stopped firing entirely once frame noise reached an RMS of -+// 3. The ratio separates the cases by two orders of magnitude and holds from -+// noiseless up to an RMS of 6: -+// -+// overlay interior ratio 0.00 - 0.32 -> zero taken -+// overlay edge block ratio 0.39 - 0.46 -> zero taken -+// background, 4 px pan ratio 2.1 - 1e7 -> kept -+// background, 0.7 px pan ratio 2.7 -> kept -+// background, 0.3 px pan ratio 1.1 -> kept -+// -+// 0.5 means zero has to be twice as good in RMS terms. The nearest thing on the -+// other side is a third of a pixel of real motion at 1.1, so there is room. -+// -+// A flat region that IS moving also qualifies, because a featureless block -+// matches itself anywhere. That costs nothing: there is nothing in it to see -+// move, and the structured blocks around it still carry the real motion. -+#define DIS_ZERO_MATCH_RATIO 0.5 -+ -+// Below this displacement, in texels, the search already landed on zero and -+// there is nothing for the test to change; skip its 64 samples. -+#define DIS_ZERO_TEST_MIN_PX 0.5 -+ - layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in; - - layout(set = 0, binding = 0) uniform sampler2D lastLumaMap; -@@ -124,11 +161,46 @@ void main() { - - bool unmatched = prevSSD * N_INV > DIS_MAX_MATCH_RMS * DIS_MAX_MATCH_RMS; - -- if (any(isnan(flow)) || any(isinf(flow)) || clamped || unmatched || -- length(flow - initialFlow) > patchSize) { -+ const bool reverted = any(isnan(flow)) || any(isinf(flow)) || clamped || unmatched || -+ length(flow - initialFlow) > patchSize; -+ if (reverted) { - flow = initialFlow; - } - -+ // Does the block explain itself better by not moving at all? Only asked when -+ // the search claims a displacement, and only when it was not thrown out -+ // above - prevSSD belongs to the searched vector, so once that has been -+ // replaced by the coarse estimate there is nothing valid to compare against. -+ float zeroSsd = -1.0; -+ if (!reverted && dot(flow, flow) > DIS_ZERO_TEST_MIN_PX * DIS_ZERO_TEST_MIN_PX) { -+ float zsd = 0.0; -+ float zsd2 = 0.0; -+ for (int i = 0; i < 8; i++) { -+ for (int j = 0; j < 8; j++) { -+ vec2 tc = (vec2(pix) + vec2(i, j) + 0.5) * invImageSize; -+ float diff = textureLod(nextLumaMap, tc, 0.0).x - lastImageData[i * 8 + j]; -+ zsd += diff; -+ zsd2 += diff * diff; -+ } -+ } -+ float zssd = zsd2 - zsd * zsd / N; -+ // Compared as sums of squares, so the RMS ratio is squared here and no -+ // square root is taken. prevSSD of exactly zero is a perfect match that -+ // only an equally perfect zero can tie, and the comparison handles it -+ // without a divide. -+ if (zssd <= prevSSD * (DIS_ZERO_MATCH_RATIO * DIS_ZERO_MATCH_RATIO)) { -+ flow = vec2(0.0); -+ zeroSsd = zssd; -+ } -+ } -+ - flow *= invImageSize; -- imageStore(sparseFlowMap, pixSparse, vec4(flow, -1.0, 1.0)); -+ // .z is the mean-normalized SSD of the stored vector, or -1 for "not scored -+ // with that metric". The zero test measures exactly the metric the -+ // propagation passes use, on the same reference block, so when zero wins its -+ // score is carried forward: propagation then compares a neighbour's moving -+ // vector against a real, and very low, incumbent score instead of having to -+ // re-derive one, which is what keeps the surrounding motion from being -+ // propagated into the overlay. -+ imageStore(sparseFlowMap, pixSparse, vec4(flow, zeroSsd, 1.0)); - } --- -2.43.0 - From 493a46a689af68cc97996ec1ad7c2dd43b652466 Mon Sep 17 00:00:00 2001 From: unknown <1qwertypower1@gmail.com> Date: Sat, 19 Sep 2026 23:32:00 +0300 Subject: [PATCH 03/17] UI: fix layout overflow on portrait/compact widths; pace guest presents on vsync - Cap dialog widths to the window instead of fixed minimums, weight labels against value chips - Stack footers, storage info, glasses settings and HUD toggles at compact widths - Align store grid columns with libraryColumns for controller focus - Add IME padding to dialogs with text input - Coalesce guest presents onto the Choreographer callback in VulkanRenderer - Give Fast-tier DIS flow the Balance refinement budget --- .../main/app/shell/LibraryGameLaunchScreen.kt | 60 ++++++++--- .../main/app/shell/StoreGameDetailScreen.kt | 32 ++++-- app/src/main/app/shell/UnifiedActivity.kt | 10 +- .../app/shell/UnifiedActivityDownloads.kt | 63 +++++++++-- .../app/shell/UnifiedActivityGameDialogs.kt | 29 ++++- app/src/main/app/shell/UnifiedActivityHub.kt | 63 ++++++++--- .../app/shell/UnifiedActivityItchStore.kt | 18 +++- app/src/main/app/shell/WorkshopScreen.kt | 2 +- app/src/main/cpp/winlator/vk/dis/vkr_dis.c | 24 ++++- app/src/main/feature/library/GameSettings.kt | 22 ++-- .../retro/RetroAchievementsActivity.kt | 6 +- .../main/feature/retro/RetroCheatsActivity.kt | 12 ++- app/src/main/feature/retro/RetroDrawerMenu.kt | 16 ++- .../main/feature/retro/RetroGameSettings.kt | 7 +- .../settings/containers/ContainersScreen.kt | 44 ++++++-- .../feature/settings/drivers/DriversScreen.kt | 9 +- .../settings/input/InputControlsScreen.kt | 27 ++++- .../settings/other/OtherSettingsScreen.kt | 9 +- .../main/feature/setup/SetupWizardActivity.kt | 12 ++- .../ui/component/dialog/AuthWebViewDialog.kt | 5 +- .../runtime/display/XServerDrawerLogsPane.kt | 7 +- .../display/renderer/VulkanRenderer.java | 27 ++++- .../shared/android/DirectoryPickerDialog.kt | 100 ++++++++++++++---- 23 files changed, 479 insertions(+), 125 deletions(-) diff --git a/app/src/main/app/shell/LibraryGameLaunchScreen.kt b/app/src/main/app/shell/LibraryGameLaunchScreen.kt index 35c2acbc1..62807d21d 100644 --- a/app/src/main/app/shell/LibraryGameLaunchScreen.kt +++ b/app/src/main/app/shell/LibraryGameLaunchScreen.kt @@ -114,6 +114,7 @@ import coil.request.CachePolicy import coil.request.ImageRequest import com.winlator.cmod.R import com.winlator.cmod.shared.ui.layout.isPortraitLayout +import com.winlator.cmod.shared.ui.layout.screenWidthDp import androidx.compose.runtime.CompositionLocalProvider import com.winlator.cmod.shared.ui.focus.controllerFocusGlow import com.winlator.cmod.shared.ui.outlinedSwitchColors @@ -210,9 +211,19 @@ internal fun LibraryGameLaunchScreen( Box(Modifier.fillMaxSize()) { val edgePadding = 22.dp val bottomPadding = 20.dp - val actionIconSize = 46.dp val actionIconSpacing = 8.dp - val actionWidth = actionIconSize * actionIconCount + actionIconSpacing * (actionIconCount - 1).coerceAtLeast(0) + // Six icons at 46 dp plus their gaps come to 316 dp, which is exactly the room left + // on a 360 dp phone after the edge padding and none at all once the navigation-bar + // insets apply. Shrink the icons to whatever fits instead of overflowing the row. + val actionIconGaps = actionIconSpacing * (actionIconCount - 1).coerceAtLeast(0) + val actionRowMaxWidth = (screenWidthDp() - edgePadding * 2).coerceAtLeast(120.dp) + val actionIconSize = + if (actionIconCount > 0) { + minOf(46.dp, (actionRowMaxWidth - actionIconGaps) / actionIconCount) + } else { + 46.dp + } + val actionWidth = actionIconSize * actionIconCount + actionIconGaps val playHeight = 56.dp val contentGap = 18.dp val horizontalNavInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal) @@ -261,19 +272,32 @@ internal fun LibraryGameLaunchScreen( } } + // The horizontal scrim is tuned for the landscape left-hand content column. In + // portrait the content spans the full width, so its right-hand text would sit over + // the transparent band and lose contrast; darken from the bottom there instead. + val heroScrimStops = + arrayOf( + 0.0f to LaunchBlack.copy(alpha = 0.9f), + 0.36f to LaunchBlack.copy(alpha = 0.58f), + 0.72f to LaunchBlack.copy(alpha = 0.18f), + 1.0f to LaunchBlack.copy(alpha = 0.62f), + ) Box( Modifier .fillMaxSize() .background( - Brush.horizontalGradient( - colorStops = - arrayOf( - 0.0f to LaunchBlack.copy(alpha = 0.9f), - 0.36f to LaunchBlack.copy(alpha = 0.58f), - 0.72f to LaunchBlack.copy(alpha = 0.18f), - 1.0f to LaunchBlack.copy(alpha = 0.62f), - ), - ), + if (isPortraitLayout()) { + Brush.verticalGradient( + colorStops = + arrayOf( + 0.0f to LaunchBlack.copy(alpha = 0.18f), + 0.45f to LaunchBlack.copy(alpha = 0.58f), + 1.0f to LaunchBlack.copy(alpha = 0.9f), + ), + ) + } else { + Brush.horizontalGradient(colorStops = heroScrimStops) + }, ), ) Box( @@ -438,7 +462,10 @@ internal fun LibraryGameLaunchScreen( LaunchAltEngineToggle( label = altEngineLabel, checked = altEngineEnabled, - width = actionWidth, + // In portrait the action block already fills the width; pinning the + // toggle to actionWidth clipped it on narrow phones. + modifier = + if (portraitHero) Modifier.fillMaxWidth() else Modifier.width(actionWidth), onCheckedChange = onAltEngineChange, ) } @@ -666,7 +693,7 @@ internal fun LaunchDangerConfirmMenu( expanded = expanded, onDismissRequest = onDismissRequest, offset = DpOffset(x = 0.dp, y = (-56).dp), - modifier = Modifier.width(286.dp), + modifier = Modifier.width(minOf(286.dp, screenWidthDp() - 32.dp)), shape = RoundedCornerShape(12.dp), containerColor = LaunchCard, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.14f)), @@ -763,7 +790,7 @@ internal fun LaunchDangerConfirmDialog( Surface( modifier = Modifier - .width(286.dp) + .width(minOf(286.dp, screenWidthDp() - 32.dp)) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, @@ -1363,12 +1390,11 @@ private fun GameStatChip( private fun LaunchAltEngineToggle( label: String, checked: Boolean, - width: Dp, + modifier: Modifier, onCheckedChange: (Boolean) -> Unit, ) { Row( - modifier = Modifier - .width(width) + modifier = modifier .clip(RoundedCornerShape(14.dp)) .background(Color.White.copy(alpha = 0.06f)) .clickable { onCheckedChange(!checked) } diff --git a/app/src/main/app/shell/StoreGameDetailScreen.kt b/app/src/main/app/shell/StoreGameDetailScreen.kt index 114f976d5..6af8e94c0 100644 --- a/app/src/main/app/shell/StoreGameDetailScreen.kt +++ b/app/src/main/app/shell/StoreGameDetailScreen.kt @@ -113,6 +113,7 @@ import com.winlator.cmod.shared.ui.nav.LocalPaneNav import com.winlator.cmod.shared.ui.nav.PaneNavRegistry import com.winlator.cmod.shared.ui.nav.paneNavHandlers import com.winlator.cmod.shared.ui.nav.paneNavItem +import com.winlator.cmod.shared.ui.layout.isPortraitLayout internal data class StoreDlcItem( val id: Int, @@ -290,19 +291,32 @@ internal fun StoreGameDetailScreen( } } + // Tuned for the landscape left-hand content column. In portrait the content spans + // the full width and its right-hand text would sit over the transparent band. Box( Modifier .fillMaxSize() .background( - Brush.horizontalGradient( - colorStops = - arrayOf( - 0.0f to StoreBlack.copy(alpha = 0.9f), - 0.36f to StoreBlack.copy(alpha = 0.58f), - 0.72f to StoreBlack.copy(alpha = 0.18f), - 1.0f to StoreBlack.copy(alpha = 0.62f), - ), - ), + if (isPortraitLayout()) { + Brush.verticalGradient( + colorStops = + arrayOf( + 0.0f to StoreBlack.copy(alpha = 0.18f), + 0.45f to StoreBlack.copy(alpha = 0.58f), + 1.0f to StoreBlack.copy(alpha = 0.9f), + ), + ) + } else { + Brush.horizontalGradient( + colorStops = + arrayOf( + 0.0f to StoreBlack.copy(alpha = 0.9f), + 0.36f to StoreBlack.copy(alpha = 0.58f), + 0.72f to StoreBlack.copy(alpha = 0.18f), + 1.0f to StoreBlack.copy(alpha = 0.62f), + ), + ) + }, ), ) Box( diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index cfc8b5761..c63ff1096 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -425,8 +425,16 @@ class UnifiedActivity : val storeHeaderVisible = kotlinx.coroutines.flow.MutableStateFlow(true) + // Must agree with the grid itself, which uses DeviceProfileSettings.libraryColumns with the + // current orientation. gridColumnsForWidth ignores orientation, so on a profile that varies + // by orientation the controller's focus index addressed a different cell than the one drawn. internal val storeColumns: Int - get() = com.winlator.cmod.shared.ui.gridColumnsForWidth(resources.configuration.screenWidthDp) + get() = + com.winlator.cmod.app.config.DeviceProfileSettings.libraryColumns( + this, + resources.configuration.screenWidthDp, + resources.configuration.screenHeightDp > resources.configuration.screenWidthDp, + ) var storeGridState: androidx.compose.foundation.lazy.grid.LazyGridState? = null diff --git a/app/src/main/app/shell/UnifiedActivityDownloads.kt b/app/src/main/app/shell/UnifiedActivityDownloads.kt index 57012c529..1d492e82e 100644 --- a/app/src/main/app/shell/UnifiedActivityDownloads.kt +++ b/app/src/main/app/shell/UnifiedActivityDownloads.kt @@ -243,6 +243,7 @@ import com.winlator.cmod.shared.theme.WinNativeTheme import dagger.hilt.android.AndroidEntryPoint import dagger.Lazy import com.winlator.cmod.feature.stores.steam.enums.EPersonaState +import com.winlator.cmod.shared.ui.layout.isCompactWidth import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -611,7 +612,10 @@ internal fun UnifiedActivity.DownloadsQueueButton( modifier = modifier .height(40.dp) - .widthIn(min = 96.dp) + // Three of these at a 96 dp minimum plus their gaps come to 308 dp against + // 328 dp of usable width on a 360 dp phone — one longer label and the outer + // buttons leave the screen. Let them size to their text there. + .widthIn(min = if (isCompactWidth()) 0.dp else 96.dp) .paneNavItem(cornerRadius = 8.dp, onActivate = { if (enabled) onClick() }), colors = ButtonDefaults.buttonColors( @@ -1119,6 +1123,8 @@ internal fun UnifiedActivity.DownloadItemDeck( null } + val compactDownloadRow = isCompactWidth() + Surface( color = if (isSelected) DownloadCardSelectedBlack else DownloadCardBlack, shape = RoundedCornerShape(12.dp), @@ -1156,7 +1162,12 @@ internal fun UnifiedActivity.DownloadItemDeck( .crossfade(300) .build(), contentDescription = null, - modifier = Modifier.size(120.dp, 68.dp).clip(RoundedCornerShape(4.dp)), + // 120 dp is about a third of the usable row width on a phone, which is what + // starves the name/size/speed columns beside it. + modifier = + Modifier + .size(if (compactDownloadRow) 88.dp else 120.dp, if (compactDownloadRow) 50.dp else 68.dp) + .clip(RoundedCornerShape(4.dp)), contentScale = ContentScale.Crop, ) @@ -1172,6 +1183,12 @@ internal fun UnifiedActivity.DownloadItemDeck( progress < 1f && speed > 0 + val sizeLabel = + "${StorageUtils.formatDecimalSize(downloadedBytes)} / ${StorageUtils.formatDecimalSize(totalBytes)}" + // Three equal weights next to the thumbnail leave about 69 dp each on a phone, + // so the size string clips and the game name ellipsizes to a few characters. + // Below the compact threshold the name keeps the first line to itself and the + // size and speed share a second one. Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Text( displayName ?: unknownGameLabel, @@ -1182,22 +1199,46 @@ internal fun UnifiedActivity.DownloadItemDeck( overflow = TextOverflow.Ellipsis, ) - // Centered Size Info - Text( - text = "${StorageUtils.formatDecimalSize(downloadedBytes)} / ${StorageUtils.formatDecimalSize(totalBytes)}", - style = MaterialTheme.typography.labelMedium, - color = TextSecondary, - modifier = Modifier.weight(1f), - textAlign = TextAlign.Center, - ) + if (!compactDownloadRow) { + // Centered Size Info + Text( + text = sizeLabel, + style = MaterialTheme.typography.labelMedium, + color = TextSecondary, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + ) - Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { + Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { + if (showDownloadSpeed) { + Text( + text = StorageUtils.formatBitsPerSecond(speed), + style = MaterialTheme.typography.labelMedium, + color = Accent, + fontWeight = FontWeight.Bold, + ) + } + } + } + } + if (compactDownloadRow) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text( + text = sizeLabel, + style = MaterialTheme.typography.labelMedium, + color = TextSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + Spacer(Modifier.weight(1f)) if (showDownloadSpeed) { Text( text = StorageUtils.formatBitsPerSecond(speed), style = MaterialTheme.typography.labelMedium, color = Accent, fontWeight = FontWeight.Bold, + maxLines = 1, ) } } diff --git a/app/src/main/app/shell/UnifiedActivityGameDialogs.kt b/app/src/main/app/shell/UnifiedActivityGameDialogs.kt index 59b99da0e..65cf07fb4 100644 --- a/app/src/main/app/shell/UnifiedActivityGameDialogs.kt +++ b/app/src/main/app/shell/UnifiedActivityGameDialogs.kt @@ -249,6 +249,8 @@ import com.winlator.cmod.shared.ui.JoystickGridScroll import com.winlator.cmod.shared.ui.JoystickListScroll import com.winlator.cmod.shared.ui.ListView import com.winlator.cmod.shared.ui.widget.chasingBorder +import com.winlator.cmod.shared.ui.layout.isPortraitLayout +import com.winlator.cmod.shared.ui.layout.isCompactWidth import com.winlator.cmod.shared.theme.WinNativeTheme import dagger.hilt.android.AndroidEntryPoint import dagger.Lazy @@ -298,7 +300,15 @@ internal fun UnifiedActivity.LibraryDetailPopupFrame( contentAlignment = Alignment.Center, ) { val panelMaxWidth = if (wide) 440.dp else 360.dp - val panelWidthFraction = if (wide) 0.72f else 0.58f + // 0.58f/0.72f are landscape fractions: at phone width 0.58f gives a ~213 dp + // panel whose labels and footer buttons wrap mid-word. In portrait take the + // full available width and let panelMaxWidth do the capping. + val panelWidthFraction = + when { + isPortraitLayout() -> 1f + wide -> 0.72f + else -> 0.58f + } val panelMaxHeight = (maxHeight - 16.dp).coerceAtLeast(240.dp) Surface( @@ -394,9 +404,18 @@ internal fun UnifiedActivity.GameSettingsDialogFrame( .windowInsetsPadding(WindowInsets.navigationBars), contentAlignment = Alignment.Center, ) { + val dialogMaxWidth = (maxWidth - 32.dp).coerceAtLeast(200.dp) val widthModifier = if (wide) { - Modifier.widthIn(min = 320.dp, max = (maxWidth - 32.dp).coerceAtMost(560.dp)) + // coerceAtMost can drop the max below the 320 dp min on a narrow screen, + // and then min wins and the dialog is wider than its parent. + val wideMax = dialogMaxWidth.coerceAtMost(560.dp) + Modifier.widthIn(min = minOf(320.dp, wideMax), max = wideMax) + } else if (isCompactWidth()) { + // The narrow frame is the per-game settings dialog for every tab but + // CloudSaves; capped at 280 dp inside a 400 dp window its label/control + // rows lose the label entirely. + Modifier.fillMaxWidth().widthIn(max = dialogMaxWidth) } else { Modifier.widthIn(min = 200.dp, max = 280.dp) } @@ -758,7 +777,7 @@ internal fun UnifiedActivity.HeroBootDialog( title = title, icon = Icons.Outlined.DesktopWindows, accentColor = Accent, - modifier = Modifier.widthIn(min = 220.dp, max = 290.dp), + modifier = Modifier.widthIn(min = 220.dp, max = 360.dp), content = { Column( modifier = Modifier.fillMaxWidth(), @@ -2336,7 +2355,9 @@ internal fun UnifiedActivity.LibraryGameDetailDialog( color = TextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(end = 16.dp), + // Unweighted, this measured at its full intrinsic width before + // the weighted sub-screen title and starved it to nothing. + modifier = Modifier.weight(1f, fill = false).padding(end = 16.dp), ) } HorizontalDivider(color = CardBorder, thickness = 0.5.dp) diff --git a/app/src/main/app/shell/UnifiedActivityHub.kt b/app/src/main/app/shell/UnifiedActivityHub.kt index ccb370c9a..328f2d77f 100644 --- a/app/src/main/app/shell/UnifiedActivityHub.kt +++ b/app/src/main/app/shell/UnifiedActivityHub.kt @@ -225,6 +225,7 @@ import com.winlator.cmod.shared.android.RefreshRateUtils import com.winlator.cmod.shared.io.StorageUtils import com.winlator.cmod.shared.io.FileUtils import com.winlator.cmod.shared.ui.CarouselView +import com.winlator.cmod.shared.ui.layout.isCompactWidth import com.winlator.cmod.shared.ui.layout.isPortraitLayout import com.winlator.cmod.shared.ui.layout.screenWidthDp import com.winlator.cmod.shared.ui.dialog.PopupDialog @@ -1249,7 +1250,8 @@ internal fun UnifiedActivity.UnifiedHub() { Box( modifier = Modifier - .width(320.dp) + .widthIn(max = 320.dp) + .fillMaxWidth(0.9f) .clip(RoundedCornerShape(20.dp)) .background(SurfaceDark) .border(1.dp, Accent.copy(alpha = 0.3f), RoundedCornerShape(20.dp)) @@ -1364,8 +1366,11 @@ internal fun UnifiedActivity.GlassesSettingsSheet(onDismiss: () -> Unit) { Spacer(Modifier.width(10.dp)) Text(gm.modelName(), color = TextPrimary, fontSize = 17.sp, fontWeight = FontWeight.SemiBold) } - Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) { - Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(14.dp)) { + // Two 'weight(1f)' columns inside a 0.82f-wide dialog leave about 130 dp each at + // phone width: the 60/90/120 Hz chips shrink to ~38 dp and the sliders become stubs. + // Stack them in portrait instead. + val glassesColumnA: @Composable (Modifier) -> Unit = { columnModifier -> + Column(modifier = columnModifier, verticalArrangement = Arrangement.spacedBy(14.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { GlassesLabel(stringResource(R.string.glasses_panel_refresh)) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { @@ -1394,13 +1399,26 @@ internal fun UnifiedActivity.GlassesSettingsSheet(onDismiss: () -> Unit) { settings.threeD, Modifier.weight(1f)) { gm.set3D(it) } } } - Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(14.dp)) { + } + val glassesColumnB: @Composable (Modifier) -> Unit = { columnModifier -> + Column(modifier = columnModifier, verticalArrangement = Arrangement.spacedBy(14.dp)) { GlassesPercentSlider(stringResource(R.string.session_drawer_output_brightness), brightness, brightnessMax) { gm.setBrightness(it) } GlassesPercentSlider(stringResource(R.string.session_drawer_output_volume), volume, volumeMax) { gm.setVolume(it) } } } + if (isPortraitLayout()) { + Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { + glassesColumnA(Modifier.fillMaxWidth()) + glassesColumnB(Modifier.fillMaxWidth()) + } + } else { + Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) { + glassesColumnA(Modifier.weight(1f)) + glassesColumnB(Modifier.weight(1f)) + } + } } } } @@ -1523,6 +1541,10 @@ internal fun UnifiedActivity.TopBar( } val portraitTopBar = isPortraitLayout() + // With a controller paired, the left and right groups of the bar need more than its + // width at phone sizes. The badges are the least important part, so drop them there + // rather than letting the two groups collide. + val showControllerBadges = isControllerConnected && !isCompactWidth() val topBarWidth = screenWidthDp() val topBarView = androidx.compose.ui.platform.LocalView.current val topBarDensity = androidx.compose.ui.platform.LocalDensity.current @@ -1625,7 +1647,7 @@ internal fun UnifiedActivity.TopBar( } } } - if (isControllerConnected) { + if (showControllerBadges) { ControllerBadge( "L1", Modifier.align(Alignment.CenterStart).padding(start = 4.dp), @@ -1669,7 +1691,7 @@ internal fun UnifiedActivity.TopBar( } } } - if (isControllerConnected) { + if (showControllerBadges) { Spacer(Modifier.width(4.dp)) ControllerBadge(if (isPS) "\u2261" else "Start") } @@ -1740,7 +1762,7 @@ internal fun UnifiedActivity.TopBar( } } } - if (isControllerConnected) { + if (showControllerBadges) { Spacer(Modifier.width(4.dp)) ControllerBadge("L3") } @@ -1787,7 +1809,7 @@ internal fun UnifiedActivity.TopBar( ) { Icon(Icons.Outlined.FilterList, contentDescription = "Filter", tint = Accent, modifier = Modifier.size(24.dp)) } - if (isControllerConnected) { + if (showControllerBadges) { Spacer(Modifier.width(4.dp)) ControllerBadge("Select") } @@ -1806,7 +1828,7 @@ internal fun UnifiedActivity.TopBar( ) { Icon(Icons.Outlined.People, contentDescription = "Friends", tint = Accent, modifier = Modifier.size(24.dp)) } - if (isControllerConnected && navRightInset <= 0.dp) { + if (showControllerBadges && navRightInset <= 0.dp) { Spacer(Modifier.width(8.dp)) Box( modifier = @@ -1828,7 +1850,7 @@ internal fun UnifiedActivity.TopBar( } val guideOverflowContent: @Composable (Modifier) -> Unit = { guideModifier -> - if (isControllerConnected && navRightInset > 0.dp) { + if (showControllerBadges && navRightInset > 0.dp) { Box( modifier = guideModifier @@ -1861,12 +1883,25 @@ internal fun UnifiedActivity.TopBar( ) .height(UnifiedTopBarHeight), ) { - if (!portraitTopBar) { + if (portraitTopBar) { + // Both groups used to be absolutely aligned inside this Box with no width + // arbitration between them, so at phone width the right group (zIndex 2) + // painted over the search and settings buttons. A Row makes them share the bar. + Row( + modifier = Modifier.fillMaxSize(), + verticalAlignment = Alignment.CenterVertically, + ) { + leftContent(Modifier.fillMaxHeight()) + Spacer(Modifier.weight(1f)) + rightContent(Modifier.fillMaxHeight()) + } + guideOverflowContent(Modifier.align(Alignment.CenterEnd)) + } else { tabsContent(Modifier.align(Alignment.Center).zIndex(1f)) + leftContent(Modifier.align(Alignment.CenterStart).fillMaxHeight()) + rightContent(Modifier.align(Alignment.CenterEnd).fillMaxHeight().zIndex(2f)) + guideOverflowContent(Modifier.align(Alignment.CenterEnd)) } - leftContent(Modifier.align(Alignment.CenterStart).fillMaxHeight()) - rightContent(Modifier.align(Alignment.CenterEnd).fillMaxHeight().zIndex(2f)) - guideOverflowContent(Modifier.align(Alignment.CenterEnd)) } if (portraitTopBar) { diff --git a/app/src/main/app/shell/UnifiedActivityItchStore.kt b/app/src/main/app/shell/UnifiedActivityItchStore.kt index 6b1c2c90c..996ae2060 100644 --- a/app/src/main/app/shell/UnifiedActivityItchStore.kt +++ b/app/src/main/app/shell/UnifiedActivityItchStore.kt @@ -93,6 +93,7 @@ import com.winlator.cmod.shared.ui.FourByTwoGridView import com.winlator.cmod.shared.ui.JoystickGridScroll import com.winlator.cmod.shared.ui.widget.chasingBorder import com.winlator.cmod.shared.ui.toast.WinToast +import com.winlator.cmod.shared.ui.layout.byOrientation import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -379,7 +380,12 @@ private fun UnifiedActivity.ItchHeader( ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(end = DrawerHotZoneClearance), + // The 44 dp reserved for the drawer's edge-swipe zone is a fifth of the row + // on a phone; a narrower gutter still clears the hot zone there. + modifier = + Modifier + .fillMaxWidth() + .padding(end = byOrientation(portrait = 16.dp, landscape = DrawerHotZoneClearance)), ) { Text( text = @@ -426,6 +432,11 @@ private fun UnifiedActivity.ItchHeader( stringResource(R.string.itch_store_windows_only), color = TextSecondary, fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + // Unweighted it took its full intrinsic width before the weighted title + // beside it, which then had nothing left on a narrow screen. + modifier = Modifier.weight(1f, fill = false), ) Spacer(Modifier.width(4.dp)) Switch( @@ -450,7 +461,10 @@ private fun UnifiedActivity.ItchHeader( modifier = Modifier .fillMaxWidth() - .padding(start = DrawerHotZoneStart, end = DrawerHotZoneClearance) + .padding( + start = byOrientation(portrait = 12.dp, landscape = DrawerHotZoneStart), + end = byOrientation(portrait = 16.dp, landscape = DrawerHotZoneClearance), + ) .horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp), ) { diff --git a/app/src/main/app/shell/WorkshopScreen.kt b/app/src/main/app/shell/WorkshopScreen.kt index ea52ccc5a..c564d9e7f 100644 --- a/app/src/main/app/shell/WorkshopScreen.kt +++ b/app/src/main/app/shell/WorkshopScreen.kt @@ -144,7 +144,7 @@ internal fun StoreWorkshopScreen( Surface( modifier = Modifier - .widthIn(min = 320.dp, max = dialogWidth) + .widthIn(min = minOf(320.dp, dialogWidth), max = dialogWidth) .fillMaxWidth() .height(dialogHeight), shape = RoundedCornerShape(14.dp), diff --git a/app/src/main/cpp/winlator/vk/dis/vkr_dis.c b/app/src/main/cpp/winlator/vk/dis/vkr_dis.c index d650f8391..6ef2af62d 100644 --- a/app/src/main/cpp/winlator/vk/dis/vkr_dis.c +++ b/app/src/main/cpp/winlator/vk/dis/vkr_dis.c @@ -63,6 +63,13 @@ // Fewest SOR sweeps a level that still runs the solver gets. #define DIS_VR_SOR_FLOOR 2u +// Flow resolutions at or below this (the Fast preset: 180 px on the shorter side) are cheap +// enough per pixel to afford the Balance preset's refinement budget. Fast keeps its own pixel +// budget; only the algorithm parameters come from Balance, because a coarse pyramid refined +// with the minimal profile is what makes the Fast flow field look blocky. +#define DIS_FAST_TIER_MAX_SIDE 216u +#define DIS_FAST_TIER_MIN_RUNG 2u + #define DIS_SET_SAMPLERS 5u #define DIS_SET_STORAGE 1u @@ -670,12 +677,21 @@ typedef struct { uint32_t vr_levels; } DisRefine; -static DisRefine dis_refine_for(uint32_t generations) { - if (generations >= 3u) { +static DisRefine dis_refine_for(uint32_t generations, uint32_t flow_min_side) { + // The refinement ladder is indexed by how many frames this pass has to feed. A Fast-class + // flow resolution is lifted to at least the Balance rung so it never falls back to the + // single-level variational refinement, which is what the low pixel budget cannot hide. + uint32_t rung = generations; + if (flow_min_side != 0u && flow_min_side <= DIS_FAST_TIER_MAX_SIDE && + rung < DIS_FAST_TIER_MIN_RUNG) { + rung = DIS_FAST_TIER_MIN_RUNG; + } + + if (rung >= 3u) { const DisRefine r = {2u, 5u, 2u, DIS_MAX_LEVELS}; return r; } - if (generations == 2u) { + if (rung == 2u) { const DisRefine r = {2u, 4u, 1u, DIS_MAX_LEVELS}; return r; } @@ -1539,7 +1555,7 @@ void vkr_dis_process(VkrDis* d, VkCommandBuffer cmd, VkImage source, uint32_t wi dis_prime_layouts(d, cmd); - const DisRefine refine = dis_refine_for(generations); + const DisRefine refine = dis_refine_for(generations, d->flow_min_side); const uint32_t L = d->levels; const uint32_t coarse = L - 1; const uint32_t w = d->built_extent.width; diff --git a/app/src/main/feature/library/GameSettings.kt b/app/src/main/feature/library/GameSettings.kt index 3521e66a8..1357438f4 100644 --- a/app/src/main/feature/library/GameSettings.kt +++ b/app/src/main/feature/library/GameSettings.kt @@ -3135,9 +3135,11 @@ private fun ReshadeFloatSlider( color = TextSecondary, fontSize = SettingLabelSize, fontWeight = FontWeight.Medium, - letterSpacing = 0.3.sp + letterSpacing = 0.3.sp, + // Both children were unweighted, so a long localized label was measured + // against the whole row and left the value chip nothing. + modifier = Modifier.weight(1f) ) - Spacer(Modifier.weight(1f)) Box( modifier = Modifier .clip(RoundedCornerShape(6.dp)) @@ -3851,7 +3853,7 @@ private fun WineSection( offset = localeMenuOffset.value, shape = RoundedCornerShape(8.dp), containerColor = CardSurface, - modifier = Modifier.height(300.dp) + modifier = Modifier.heightIn(max = 300.dp) ) { state.localeOptions.value.forEach { locale -> DropdownMenuItem( @@ -4520,7 +4522,7 @@ private fun EnvVarRow( shape = RoundedCornerShape(8.dp), containerColor = CardSurface, modifier = Modifier - .height(360.dp) + .heightIn(max = 360.dp) .width(260.dp) ) { DropdownMenuItem( @@ -4865,7 +4867,7 @@ private fun EnvValueMultiDropdown( shape = RoundedCornerShape(8.dp), containerColor = CardSurface, modifier = Modifier - .height(320.dp) + .heightIn(max = 320.dp) .width(260.dp) ) { options.forEach { opt -> @@ -5485,7 +5487,7 @@ private fun ExecArgsHelper(onArgSelected: (String) -> Unit) { shape = RoundedCornerShape(8.dp), containerColor = CardSurface, modifier = Modifier - .height(360.dp) + .heightIn(max = 360.dp) .width(240.dp) ) { ExtraArgPresets.forEach { group -> @@ -6086,8 +6088,8 @@ private fun FrameGenPresetSlider( fontSize = SettingLabelSize, fontWeight = FontWeight.Medium, letterSpacing = 0.3.sp, + modifier = Modifier.weight(1f), ) - Spacer(Modifier.weight(1f)) Box( modifier = Modifier .clip(RoundedCornerShape(6.dp)) @@ -6178,9 +6180,11 @@ private fun SettingSlider( color = TextSecondary, fontSize = SettingLabelSize, fontWeight = FontWeight.Medium, - letterSpacing = 0.3.sp + letterSpacing = 0.3.sp, + // Both children were unweighted, so a long localized label was measured + // against the whole row and left the value chip nothing. + modifier = Modifier.weight(1f) ) - Spacer(Modifier.weight(1f)) Box( modifier = Modifier .clip(RoundedCornerShape(6.dp)) diff --git a/app/src/main/feature/retro/RetroAchievementsActivity.kt b/app/src/main/feature/retro/RetroAchievementsActivity.kt index 82735eff8..88351ba28 100644 --- a/app/src/main/feature/retro/RetroAchievementsActivity.kt +++ b/app/src/main/feature/retro/RetroAchievementsActivity.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -356,6 +357,9 @@ internal fun RetroAchievementsScreen( Modifier .background(Scrim.copy(alpha = 0.62f)) .windowInsetsPadding(WindowInsets.systemBars) + // Fixed-height dialog: without this the keyboard hides the + // login form and its submit button. + .imePadding() }, ) .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onClose() }, @@ -372,7 +376,7 @@ internal fun RetroAchievementsScreen( modifier = Modifier .then(if (floatingOverGame) Modifier.padding(24.dp) else Modifier) - .widthIn(min = 320.dp, max = dialogWidth) + .widthIn(min = minOf(320.dp, dialogWidth), max = dialogWidth) .fillMaxWidth() .then( if (floatingOverGame) { diff --git a/app/src/main/feature/retro/RetroCheatsActivity.kt b/app/src/main/feature/retro/RetroCheatsActivity.kt index 489de1ded..d0e8c3a46 100644 --- a/app/src/main/feature/retro/RetroCheatsActivity.kt +++ b/app/src/main/feature/retro/RetroCheatsActivity.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars @@ -134,13 +135,20 @@ private fun RetroCheatsScreen( Modifier .fillMaxSize() .background(ScrimColor.copy(alpha = 0.62f)) - .windowInsetsPadding(WindowInsets.systemBars), + .windowInsetsPadding(WindowInsets.systemBars) + // The dialog is a fixed height taken from maxHeight, so without this the + // soft keyboard covers the name/code fields and the Save row below them. + .imePadding(), contentAlignment = Alignment.Center, ) { val dialogWidth = (maxWidth - 32.dp).coerceAtMost(560.dp) val dialogHeight = (maxHeight - 40.dp).coerceIn(340.dp, 680.dp) Surface( - modifier = Modifier.widthIn(min = 320.dp, max = dialogWidth).fillMaxWidth().height(dialogHeight), + modifier = + Modifier + .widthIn(min = minOf(320.dp, dialogWidth), max = dialogWidth) + .fillMaxWidth() + .height(dialogHeight), shape = RoundedCornerShape(16.dp), color = BgDark, border = BorderStroke(1.dp, CardBorder), diff --git a/app/src/main/feature/retro/RetroDrawerMenu.kt b/app/src/main/feature/retro/RetroDrawerMenu.kt index 56d5b26d6..e0b0556c8 100644 --- a/app/src/main/feature/retro/RetroDrawerMenu.kt +++ b/app/src/main/feature/retro/RetroDrawerMenu.kt @@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -1483,7 +1484,10 @@ internal fun RetroRenameDialog( Column( modifier = Modifier - .width(320.dp) + // Never capped against the window: at 320 dp this was flush to both + // edges on a 360 dp phone and clipped on anything narrower. + .widthIn(max = 320.dp) + .fillMaxWidth(0.92f) .clip(RoundedCornerShape(16.dp)) .background(WinNativeSurface) .border(1.dp, WinNativeOutline, RoundedCornerShape(16.dp)) @@ -1556,7 +1560,10 @@ internal fun RetroConfirmDialog(prompt: RetroConfirmPrompt) { Column( modifier = Modifier - .width(340.dp) + // Never capped against the window: at 340 dp this was flush to both + // edges on a 360 dp phone and clipped on anything narrower. + .widthIn(max = 340.dp) + .fillMaxWidth(0.92f) .clip(RoundedCornerShape(16.dp)) .background(WinNativeSurface) .border(1.dp, WinNativeOutline, RoundedCornerShape(16.dp)) @@ -1640,7 +1647,10 @@ internal fun RetroConflictDialog(prompt: RetroConflictPrompt) { Column( modifier = Modifier - .width(340.dp) + // Never capped against the window: at 340 dp this was flush to both + // edges on a 360 dp phone and clipped on anything narrower. + .widthIn(max = 340.dp) + .fillMaxWidth(0.92f) .clip(RoundedCornerShape(16.dp)) .background(WinNativeSurface) .border(1.dp, WinNativeOutline, RoundedCornerShape(16.dp)) diff --git a/app/src/main/feature/retro/RetroGameSettings.kt b/app/src/main/feature/retro/RetroGameSettings.kt index 3e4dce8dc..6e05d7147 100644 --- a/app/src/main/feature/retro/RetroGameSettings.kt +++ b/app/src/main/feature/retro/RetroGameSettings.kt @@ -1774,7 +1774,10 @@ private fun RetroHudElementButtons( elements: BooleanArray, onToggle: (index: Int, on: Boolean) -> Unit, ) { - RetroHudSupport.ELEMENT_ORDER.toList().chunked(3).forEach { rowIndices -> + // Three per row gives each toggle ~125 dp in a portrait pane and every 11 sp label + // ellipsizes; two fit legibly there. + val perRow = if (isPortraitLayout()) 2 else 3 + RetroHudSupport.ELEMENT_ORDER.toList().chunked(perRow).forEach { rowIndices -> Row( Modifier.fillMaxWidth().padding(top = 6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp), @@ -1787,7 +1790,7 @@ private fun RetroHudElementButtons( modifier = Modifier.weight(1f), ) { onToggle(index, !on) } } - repeat(3 - rowIndices.size) { Spacer(Modifier.weight(1f)) } + repeat(perRow - rowIndices.size) { Spacer(Modifier.weight(1f)) } } } } diff --git a/app/src/main/feature/settings/containers/ContainersScreen.kt b/app/src/main/feature/settings/containers/ContainersScreen.kt index caa2a3e40..8e6f37771 100644 --- a/app/src/main/feature/settings/containers/ContainersScreen.kt +++ b/app/src/main/feature/settings/containers/ContainersScreen.kt @@ -70,6 +70,8 @@ import com.winlator.cmod.shared.ui.nav.DialogPaneNav import com.winlator.cmod.shared.ui.nav.LocalPaneNav import com.winlator.cmod.shared.ui.nav.PaneNavRegistry import com.winlator.cmod.shared.ui.nav.paneNavItem +import com.winlator.cmod.shared.ui.layout.screenWidthDp +import com.winlator.cmod.shared.ui.layout.isCompactWidth import androidx.compose.runtime.CompositionLocalProvider import java.util.Locale @@ -784,15 +786,16 @@ private fun ContainerStorageInfoDialog( title = stringResource(R.string.container_config_storage_info), icon = Icons.Outlined.Info, accentColor = ContainersAccent, - modifier = Modifier.widthIn(min = 320.dp, max = 500.dp), + // A 320 dp minimum overflows the 16 dp gutters on a small phone. + modifier = + Modifier.widthIn( + min = minOf(320.dp, (screenWidthDp() - 32.dp).coerceAtLeast(0.dp)), + max = 500.dp, + ), content = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(18.dp), - verticalAlignment = Alignment.CenterVertically, - ) { + val storageMetrics: @Composable (Modifier) -> Unit = { metricsModifier -> Column( - modifier = Modifier.weight(1f), + modifier = metricsModifier, verticalArrangement = Arrangement.spacedBy(14.dp), ) { StorageMetric( @@ -808,8 +811,10 @@ private fun ContainerStorageInfoDialog( value = formatBytes(state.totalBytes), ) } + } + val storageGauge: @Composable (Modifier) -> Unit = { gaugeModifier -> Column( - modifier = Modifier.widthIn(min = 180.dp), + modifier = gaugeModifier, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { @@ -837,6 +842,29 @@ private fun ContainerStorageInfoDialog( ) } } + // The gauge column used to be unweighted, so the Row measured it first + // against the full width and its caption line took nearly all of it; the + // weighted metrics column was then left a few dp and wrapped one character + // per line. Both halves are weighted now, and stacked at phone width. + if (isCompactWidth()) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(18.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + storageGauge(Modifier.fillMaxWidth()) + storageMetrics(Modifier.fillMaxWidth()) + } + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(18.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + storageMetrics(Modifier.weight(1f)) + storageGauge(Modifier.weight(1f)) + } + } }, footer = { Row( diff --git a/app/src/main/feature/settings/drivers/DriversScreen.kt b/app/src/main/feature/settings/drivers/DriversScreen.kt index ebadf17d6..05b32524c 100644 --- a/app/src/main/feature/settings/drivers/DriversScreen.kt +++ b/app/src/main/feature/settings/drivers/DriversScreen.kt @@ -95,6 +95,7 @@ import com.winlator.cmod.shared.ui.nav.DialogPaneNav import com.winlator.cmod.shared.ui.nav.LocalPaneNav import com.winlator.cmod.shared.ui.nav.PaneNavRegistry import com.winlator.cmod.shared.ui.nav.paneNavItem +import com.winlator.cmod.shared.ui.layout.isCompactWidth private val BgDark = Color(0xFF11111C) private val CardDark = Color(0xFF1C1C2A) @@ -428,7 +429,13 @@ private fun HeroHeader( DriverManagerActions( onAddRepo = onAddRepo, onInstall = onInstall, - modifier = Modifier.widthIn(min = 112.dp, max = 132.dp), + // The 112 dp minimum opposite the weighted counts column + // ellipsizes driver names on a phone. + modifier = + Modifier.widthIn( + min = if (isCompactWidth()) 0.dp else 112.dp, + max = 132.dp, + ), ) } } diff --git a/app/src/main/feature/settings/input/InputControlsScreen.kt b/app/src/main/feature/settings/input/InputControlsScreen.kt index be90724b0..e8110f39a 100644 --- a/app/src/main/feature/settings/input/InputControlsScreen.kt +++ b/app/src/main/feature/settings/input/InputControlsScreen.kt @@ -635,11 +635,12 @@ private fun Chip( @Composable private fun SelectionPill( text: String, + modifier: Modifier = Modifier, onClick: () -> Unit, ) { Row( modifier = - Modifier + modifier .heightIn(min = 30.dp) .clip(RoundedCornerShape(InputFieldCorner)) .background(InputField) @@ -659,6 +660,8 @@ private fun SelectionPill( fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, + // Unweighted, a long value pushed the chevron out of a constrained pill. + modifier = Modifier.weight(1f, fill = false), ) Spacer(Modifier.width(8.dp)) Icon( @@ -2013,7 +2016,9 @@ private fun OptionDropdown( var expanded by remember { mutableStateOf(false) } Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Text(label, color = InputTextSecondary, fontSize = InputPrimaryTextSize, modifier = Modifier.weight(1f)) - Box { + // Unweighted, the pill was measured against the whole row first and a long enum + // value ('Toggle on press and release') left the label wrapping mid-word. + Box(Modifier.weight(1f, fill = false)) { SelectionPill(text = optionLabel(current), onClick = { expanded = true }) DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }, containerColor = InputCard) { options.forEach { option -> @@ -2052,14 +2057,26 @@ private fun BindingPicker( var category by remember { mutableStateOf(categoryOf(binding)) } Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Text(label, color = InputTextSecondary, fontSize = InputPrimaryTextSize, modifier = Modifier.weight(1f)) - PillDropdown(category, BindingCategory.values().toList(), { prettyEnum(it.name) }) { newCategory -> + // Two unweighted pills took ~250-300 dp of a ~370 dp row between them, leaving the + // label a few dp; weighting them caps each at a third. + PillDropdown( + category, + BindingCategory.values().toList(), + { prettyEnum(it.name) }, + modifier = Modifier.weight(1f, fill = false), + ) { newCategory -> if (newCategory != category) { category = newCategory if (categoryOf(binding) != newCategory) onBinding(Binding.NONE) } } Spacer(Modifier.width(8.dp)) - PillDropdown(binding, optionsFor(category), { it.toString() }) { onBinding(it) } + PillDropdown( + binding, + optionsFor(category), + { it.toString() }, + modifier = Modifier.weight(1f, fill = false), + ) { onBinding(it) } } } @@ -2447,6 +2464,7 @@ private fun GyroscopeCard( ) SelectionPill( text = state.gyroscopeActivatorLabel, + modifier = Modifier.weight(1f, fill = false), onClick = actions.onGyroscopeActivatorClick, ) } @@ -2711,6 +2729,7 @@ private fun SteamControllerCard( Spacer(Modifier.width(8.dp)) SelectionPill( text = state.steamPaddleLabels.getOrNull(index) ?: "", + modifier = Modifier.weight(1f, fill = false), onClick = { actions.onSteamPaddleClick(index) }, ) } diff --git a/app/src/main/feature/settings/other/OtherSettingsScreen.kt b/app/src/main/feature/settings/other/OtherSettingsScreen.kt index ce275f5b7..d4360a318 100644 --- a/app/src/main/feature/settings/other/OtherSettingsScreen.kt +++ b/app/src/main/feature/settings/other/OtherSettingsScreen.kt @@ -102,6 +102,7 @@ import com.winlator.cmod.shared.ui.nav.paneNavItem import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.focus.focusProperties import com.winlator.cmod.shared.ui.outlinedSwitchColors +import com.winlator.cmod.shared.ui.layout.isCompactWidth // Palette (mirrors DebugScreen / StoresScreen) private val BgDark = Color(0xFF11111C) @@ -628,7 +629,9 @@ private fun SettingsDropdownCard( highlightColor = NavHighlight, tapToSelect = true, ).padding(horizontal = 10.dp, vertical = 7.dp) - .widthIn(max = 180.dp), + // 180 dp opposite the weighted title/subtitle column leaves it + // about 118 dp on a phone and the subtitle wraps to four lines. + .widthIn(max = if (isCompactWidth()) 132.dp else 180.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { @@ -1159,7 +1162,9 @@ private fun SmallActionButton( Box( modifier = Modifier - .width(104.dp) + // Two of these at a fixed 104 dp sit opposite weighted label columns; a + // minimum lets them shrink to their text on a narrow screen instead. + .widthIn(min = 88.dp) .clip(RoundedCornerShape(8.dp)) .background(Color(0xFF222232)) .border(1.dp, textColor.copy(alpha = 0.30f), RoundedCornerShape(8.dp)) diff --git a/app/src/main/feature/setup/SetupWizardActivity.kt b/app/src/main/feature/setup/SetupWizardActivity.kt index e0197a7e0..eed530ec5 100644 --- a/app/src/main/feature/setup/SetupWizardActivity.kt +++ b/app/src/main/feature/setup/SetupWizardActivity.kt @@ -2924,7 +2924,15 @@ class SetupWizardActivity : FixedFontScaleFragmentActivity() { modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter, ) { - val gridColumns = 3 + // Fixed at 3, a portrait window gave each card about 120 dp: the Create + // button took its intrinsic width and the weighted text column beside it was + // left with a few dp, wrapping "ARM64EC" one character per line. + val gridColumns = + when { + maxWidth < 420.dp -> 1 + maxWidth < 720.dp -> 2 + else -> 3 + } val compactGrid = maxWidth < 720.dp || maxHeight < 280.dp val region by navRegion val navIdx by navIndex @@ -3068,6 +3076,8 @@ class SetupWizardActivity : FixedFontScaleFragmentActivity() { fontSize = if (compact) 8.sp else 9.sp, letterSpacing = 1.sp, fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } Spacer(Modifier.height(if (compact) 1.dp else 3.dp)) diff --git a/app/src/main/feature/stores/epic/ui/component/dialog/AuthWebViewDialog.kt b/app/src/main/feature/stores/epic/ui/component/dialog/AuthWebViewDialog.kt index 51bdfccca..4173d84c0 100644 --- a/app/src/main/feature/stores/epic/ui/component/dialog/AuthWebViewDialog.kt +++ b/app/src/main/feature/stores/epic/ui/component/dialog/AuthWebViewDialog.kt @@ -8,6 +8,7 @@ import android.webkit.WebResourceRequest import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Close @@ -97,7 +98,9 @@ fun AuthWebViewDialog( }, ) { paddingValues -> AndroidView( - modifier = Modifier.padding(paddingValues), + // Full-screen dialog: nothing else consumes the IME inset, so the + // keyboard would cover the password field and the sign-in button. + modifier = Modifier.padding(paddingValues).imePadding(), factory = { context -> WebView(context).apply { layoutParams = diff --git a/app/src/main/runtime/display/XServerDrawerLogsPane.kt b/app/src/main/runtime/display/XServerDrawerLogsPane.kt index 2776657c3..e6fe91d65 100644 --- a/app/src/main/runtime/display/XServerDrawerLogsPane.kt +++ b/app/src/main/runtime/display/XServerDrawerLogsPane.kt @@ -39,7 +39,6 @@ import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize @@ -259,6 +258,10 @@ internal fun LogsPaneHeader( color = if (paused) DrawerAccent else DrawerTextSecondary, fontSize = (11f * paneScale).sp, fontWeight = FontWeight.Medium, + // Three action tiles and the close button take about 190 dp of the drawer's + // fixed 300 dp, so this subtitle wrapped to two or three lines. + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } @@ -283,8 +286,6 @@ internal fun LogsPaneHeader( onClick = onShare, ) - Spacer(Modifier.width((16f * paneScale).dp)) - TaskManagerCloseButton(onClick = onClose) } } diff --git a/app/src/main/runtime/display/renderer/VulkanRenderer.java b/app/src/main/runtime/display/renderer/VulkanRenderer.java index 028582a7c..299e47b48 100644 --- a/app/src/main/runtime/display/renderer/VulkanRenderer.java +++ b/app/src/main/runtime/display/renderer/VulkanRenderer.java @@ -185,8 +185,30 @@ public void onGuestFramePresented() { lastGuestPresentNs = System.nanoTime(); } + // A guest present used to wake the render thread straight away. That ran composition on + // the guest's clock while the swapchain still presents on vsync, so the two rates beat + // against each other: two guest presents inside one vsync interval collapsed into a single + // composite (a dropped guest frame) and an interval without one repeated the previous frame. + // Arming the Choreographer callback instead composites at most once per vsync and in phase + // with it, at the cost of up to one vsync of latency before the composite starts. public void requestRenderImmediate() { + wakeSources.incrementAndGet(WAKE_GUEST_PRESENT); + if (!renderRequested.compareAndSet(false, true)) return; + + // Posting directly is thread-safe: Choreographer forwards to its looper itself, and a + // handler hop here would arm past the next doFrame. + Choreographer choreographer = mainChoreographer; + if (choreographer != null) { + choreographer.postFrameCallback(coalescedRenderCallback); + return; + } + // Before the Choreographer has been bound on the main thread, fall back to the old + // unsynchronised wake so the first frames still reach the screen. + renderRequested.set(false); xServerView.requestRender(); + mainHandler.post(() -> { + if (mainChoreographer == null) mainChoreographer = Choreographer.getInstance(); + }); } public long takeGuestPresentDelta() { @@ -212,14 +234,15 @@ private boolean guestIsDrivingFrames() { public static final int WAKE_WINHANDLER = 8; public static final int WAKE_INPUTVIEW = 9; public static final int WAKE_SETTING = 10; + public static final int WAKE_GUEST_PRESENT = 11; private final java.util.concurrent.atomic.AtomicLongArray wakeSources = - new java.util.concurrent.atomic.AtomicLongArray(11); + new java.util.concurrent.atomic.AtomicLongArray(12); public String takeWakeBreakdown() { StringBuilder sb = new StringBuilder(); String[] names = {"other", "content", "geometry", "window", "cursor", "frame", "suppressed", - "pointer", "winhandler", "inputview", "setting"}; + "pointer", "winhandler", "inputview", "setting", "guest"}; for (int i = 0; i < names.length; i++) { sb.append(' ').append(names[i]).append('=').append(wakeSources.getAndSet(i, 0)); } diff --git a/app/src/main/shared/android/DirectoryPickerDialog.kt b/app/src/main/shared/android/DirectoryPickerDialog.kt index 58a21243e..bd69daee7 100644 --- a/app/src/main/shared/android/DirectoryPickerDialog.kt +++ b/app/src/main/shared/android/DirectoryPickerDialog.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -132,6 +133,7 @@ import com.winlator.cmod.shared.ui.nav.PaneNavRegistry import com.winlator.cmod.shared.ui.nav.bindPaneNav import com.winlator.cmod.shared.ui.nav.paneNavHandlers import com.winlator.cmod.shared.ui.nav.paneNavItem +import com.winlator.cmod.shared.ui.layout.isCompactWidth import java.io.File import java.util.Locale import kotlinx.coroutines.CancellationException @@ -1035,17 +1037,13 @@ object DirectoryPickerDialog { Spacer(Modifier.height(10.dp)) if (manage) { - CompositionLocalProvider(LocalPaneNav provides footerRegistry) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - FooterInfo( - title = title, - subtitle = selectedFile?.absolutePath ?: currentDir.absolutePath, - modifier = Modifier.weight(1f), - ) + // The path label plus three fixed chips, the root selector and Close need + // about 505 dp of minimum width. In one row at phone width the selector and + // Close are pushed off the right edge and the path collapses to nothing, so + // below the compact threshold the label goes on its own line and the controls + // wrap. None of the controls uses a Row weight, so the same lambda serves both. + val compactFooter = isCompactWidth() + val manageFooterControls: @Composable () -> Unit = { clipboard?.let { cb -> val extracting = cb.mode == ClipMode.EXTRACT SecondaryActionChip( @@ -1098,6 +1096,39 @@ object DirectoryPickerDialog { onClick = onDismiss, ) } + CompositionLocalProvider(LocalPaneNav provides footerRegistry) { + if (compactFooter) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + FooterInfo( + title = title, + subtitle = selectedFile?.absolutePath ?: currentDir.absolutePath, + modifier = Modifier.fillMaxWidth(), + ) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + manageFooterControls() + } + } + } else { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FooterInfo( + title = title, + subtitle = selectedFile?.absolutePath ?: currentDir.absolutePath, + modifier = Modifier.weight(1f), + ) + manageFooterControls() + } + } } return@Column } @@ -1143,18 +1174,41 @@ object DirectoryPickerDialog { } CompositionLocalProvider(LocalPaneNav provides footerRegistry) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - FooterInfo( - title = footerTitle, - subtitle = footerSubtitle, - modifier = Modifier.weight(1f), - ) - rootSelector(Modifier.widthIn(min = 158.dp, max = 182.dp)) - footerActions() + // Same problem as the manage footer: the root selector's 158 dp minimum plus + // the two action buttons leave the title and path nothing to occupy. + if (isCompactWidth()) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + FooterInfo( + title = footerTitle, + subtitle = footerSubtitle, + modifier = Modifier.fillMaxWidth(), + ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + rootSelector(Modifier.weight(1f)) + footerActions() + } + } + } else { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + FooterInfo( + title = footerTitle, + subtitle = footerSubtitle, + modifier = Modifier.weight(1f), + ) + rootSelector(Modifier.widthIn(min = 158.dp, max = 182.dp)) + footerActions() + } } } } From 4852c30de085969c44493f27f8a95f4ec13f9367 Mon Sep 17 00:00:00 2001 From: unknown <1qwertypower1@gmail.com> Date: Sat, 19 Sep 2026 21:35:15 +0000 Subject: [PATCH 04/17] DIS: divergence-gated occlusion mask; bound the absolute flow magnitude dis_interpolate.comp - Replace the photometric-only occlusion test. It compared full-resolution colour against a flow solved at 180-360 px on the short side, so on textured motion it saturated and snapped whole regions to the nearest source frame, which reads as motion stalling for a frame and then jumping. - Occlusion now requires both a discontinuous flow field and a genuine mismatch between the warped samples. The discontinuity is measured over a 4 flow-texel window: around a screen-locked object the field ramps over a band tens of texels wide rather than stepping, so a per-texel slope never clears a useful threshold. The mismatch is measured at the flow scale and normalised by local contrast, so a slightly misaligned texture edge does not count as occlusion. - Pick the valid side from the sign of the flow divergence rather than the nearest frame in time: a diverging field is area opening up, which exists only in the next frame; a converging one is area being covered. - Flow debug view: log magnitude scale in source pixels. The old scale saturated at 16 px, which pegged the whole frame during a camera orbit and hid both the magnitude and any parallax. - OCCLUSION_STRENGTH and DEBUG_OCCLUSION are compile-time switches for A/B. dis_vr_add.comp - Bound the absolute flow magnitude. Nothing upstream did: the block search only limits displacement from the coarse estimate, the refinement only limits its own update, and the existing guard allowed four frame widths and then fell back to a value that was itself already huge. On a scene cut the flow reached hundreds of pixels and the warp smeared the frame edge across the picture. Clamp the magnitude to a quarter of the frame, and clamp rather than revert. The variational solver was ported to NumPy and exercised on a synthetic cut to locate this: it does not diverge, it faithfully smooths whatever it is given, and the runaway comes from the missing absolute bound. With the clamp the output pins at the limit for any input, and correct small motion is unchanged. Both shaders verified to compile to SPIR-V. Not yet run on device. --- .../winlator/vk/shaders/dis_interpolate.comp | 115 +++++++++++++++++- .../cpp/winlator/vk/shaders/dis_vr_add.comp | 21 +++- 2 files changed, 127 insertions(+), 9 deletions(-) diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp b/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp index 081952c93..15df8782b 100644 --- a/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp +++ b/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp @@ -33,6 +33,23 @@ vec2 sampleFlow(vec2 uv) { return mix(mix(a, b, frac.x), mix(c, e, frac.x), frac.y); } +// Average of four taps spread across about one flow texel - a stand-in for sampling the +// frame at flow resolution, which is the only scale the occlusion test may compare at. +// Doing this from the full-res textures keeps the test inside the shader: binding the +// flow-resolution copies as extra descriptors blacked the compositor out, and the test +// does not need them. `spread` is the range across the four taps, i.e. local contrast, +// and comes for free. +vec3 lowPass(sampler2D tex, vec2 base, vec2 r, out float spread) { + vec3 a = textureLod(tex, clamp(base + vec2( r.x, r.y), vec2(0.0), vec2(1.0)), 0.0).xyz; + vec3 b = textureLod(tex, clamp(base + vec2(-r.x, r.y), vec2(0.0), vec2(1.0)), 0.0).xyz; + vec3 c = textureLod(tex, clamp(base + vec2( r.x, -r.y), vec2(0.0), vec2(1.0)), 0.0).xyz; + vec3 d = textureLod(tex, clamp(base + vec2(-r.x, -r.y), vec2(0.0), vec2(1.0)), 0.0).xyz; + vec3 mn = min(min(a, b), min(c, d)); + vec3 mx = max(max(a, b), max(c, d)); + spread = dot(mx - mn, vec3(1.0 / 3.0)); + return 0.25 * (a + b + c + d); +} + vec3 hsv2rgb(vec3 c) { vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0); vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www); @@ -62,9 +79,19 @@ void main() { } if (pc.debugMode != 0) { - float m = length(f) * float(size.x) / 16.0; + // Hue is direction; magnitude is shown on a log scale in SOURCE pixels. + // + // The old scale was `length(f) * size.x / 16`, i.e. fully saturated at 16 px of + // motion. Anything faster than a slow pan pegs the entire frame, so the view could + // not show whether the magnitude was right, nor any parallax between near and far + // geometry - every depth came out the same colour. log2 keeps roughly 1..256 px + // readable at once. + vec2 fpx = f * vec2(size); + float px = length(fpx); + float m = clamp(log2(1.0 + px) / 8.0, 0.0, 1.0); float hue = atan(f.y, f.x) / 6.2831853 + 0.5; - vec3 fc = hsv2rgb(vec3(hue, clamp(m, 0.0, 1.0), min(1.0, 0.15 + m))); + vec3 fc = hsv2rgb(vec3(hue, clamp(m * 1.3, 0.0, 1.0), min(1.0, 0.15 + m))); + imageStore(outImage, pix, vec4(fc, 1.0)); return; } @@ -75,8 +102,6 @@ void main() { vec3 c0 = textureLod(prevColor, clamp(uv0, vec2(0.0), vec2(1.0)), 0.0).xyz; vec3 c1 = textureLod(nextColor, clamp(uv1, vec2(0.0), vec2(1.0)), 0.0).xyz; - vec3 single = pc.t < 0.5 ? c0 : c1; - const float FEATHER_PX = 8.0; vec2 feather = FEATHER_PX / vec2(size); vec2 e0 = max(max(-uv0, uv0 - vec2(1.0)), vec2(0.0)) / feather; @@ -92,8 +117,86 @@ void main() { ? (c0 * w0 + c1 * w1) / wsum : (out0 <= out1 ? c0 : c1); - float occl = smoothstep(0.10, 0.40, dot(abs(c0 - c1), vec3(1.0))); - result = mix(result, single, occl); + // ---- Occlusion ------------------------------------------------------------------- + // + // The artefact this removes: around a screen-locked object the flow ramps from the + // object's ~0 to the background's full motion over a band several flow-texels wide. + // Inside that band uv0 lands on the object while uv1 lands on the background, so the + // blend produces a translucent duplicate of the object, offset by roughly half the + // background motion. That is the ghost, and it is what the old photometric-only test was + // papering over by snapping the whole region to the nearest source frame in time. + // + // Two conditions must hold, and both are load-bearing: + // * the flow field is discontinuous here. Without this the test fires on every lighting + // change, specular highlight and UI flash, none of which are occlusions. + // * the two warped samples genuinely disagree, measured at flow resolution and + // normalised by local contrast, so a slightly misaligned texture edge does not count. + // + // The sign of the divergence then says which side is valid: a diverging field is area + // opening up, which exists only in the next frame; a converging field is area being + // covered, which exists only in the previous one. That is strictly better than the old + // 'nearest frame in time', which was right only half the time. + // + // Set to 0.0 to compare against no occlusion handling at all. + const float OCCLUSION_STRENGTH = 1.0; + + // The window matters. Measured per texel this test barely fired: the flow around a + // static object does not step, it RAMPS over a band tens of texels wide (the fan of + // contours under the character in the debug view), so a 100 px jump spread over 20 + // texels is only ~5 px per texel and never cleared the threshold. Sampling a few + // texels out measures the jump across the band instead of the local slope. + const float DISC_RADIUS = 4.0; + + vec2 fts = 1.0 / vec2(textureSize(flowTex, 0)); + vec2 fo = fts * DISC_RADIUS; + vec2 sizePx = vec2(size); + + vec2 fL = sampleFlow(clamp(uv - vec2(fo.x, 0.0), vec2(0.0), vec2(1.0))); + vec2 fR = sampleFlow(clamp(uv + vec2(fo.x, 0.0), vec2(0.0), vec2(1.0))); + vec2 fU = sampleFlow(clamp(uv - vec2(0.0, fo.y), vec2(0.0), vec2(1.0))); + vec2 fD = sampleFlow(clamp(uv + vec2(0.0, fo.y), vec2(0.0), vec2(1.0))); + + // How far the flow departs from this pixel's own value across that window, in output + // pixels. A correct smooth field with strong parallax stays a couple of pixels here. + float flowVar = max(max(length((fL - f) * sizePx), length((fR - f) * sizePx)), + max(length((fU - f) * sizePx), length((fD - f) * sizePx))); + + const float DISC_LO = 2.0; + const float DISC_HI = 12.0; + float disc = smoothstep(DISC_LO, DISC_HI, flowVar); + + vec2 lpr = fts * 0.3; + float spread0; + float spread1; + vec3 l0 = lowPass(prevColor, clamp(uv0, vec2(0.0), vec2(1.0)), lpr, spread0); + vec3 l1 = lowPass(nextColor, clamp(uv1, vec2(0.0), vec2(1.0)), lpr, spread1); + + float resid = dot(abs(l0 - l1), vec3(1.0 / 3.0)); + float contrast = max(spread0, spread1); + float rel = resid / (contrast + 0.06); + + const float REL_LO = 1.0; + const float REL_HI = 3.0; + float mismatch = smoothstep(REL_LO, REL_HI, rel); + + float occl = OCCLUSION_STRENGTH * disc * mismatch; + + // Divergence over the same window. Worked example, background sweeping left past a + // static character: just right of her the field goes 0 -> -100, divergence negative, + // so we take c0, which back-tracks further into the background - correct. Just left of + // her it goes -100 -> 0, divergence positive, so we take c1 - also correct. + float divergence = (fR.x - fL.x) * sizePx.x + (fD.y - fU.y) * sizePx.y; + vec3 sided = divergence > 0.0 ? c1 : c0; + result = mix(result, sided, occl); + + // Set to 1 to paint the mask instead of the frame: red where it fires, dimmed scene + // underneath. If the character's silhouette band stays dark, the mask is not firing + // and the thresholds are wrong; if it is solidly red and the ghost is still there, + // the mask fires but picks the wrong side. + const int DEBUG_OCCLUSION = 0; + if (DEBUG_OCCLUSION != 0) { + result = mix(result * 0.3, vec3(1.0, 0.08, 0.08), occl); + } imageStore(outImage, pix, vec4(result, 1.0)); } diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_vr_add.comp b/app/src/main/cpp/winlator/vk/shaders/dis_vr_add.comp index cba444082..2b8a9f3be 100644 --- a/app/src/main/cpp/winlator/vk/shaders/dis_vr_add.comp +++ b/app/src/main/cpp/winlator/vk/shaders/dis_vr_add.comp @@ -18,9 +18,24 @@ void main() { vec2 W = texelFetch(flowDense, p, 0).xy * uSize; vec2 f = W + texelFetch(dW, p, 0).xy; - float lim = 4.0 * uSize.x; - bvec2 ok = lessThan(abs(f), vec2(lim)); - vec2 fr = vec2(ok.x ? f.x : W.x, ok.y ? f.y : W.y); + + // Nothing upstream bounds the ABSOLUTE magnitude of the flow. The block search only + // limits how far it may move from the coarse estimate (one patch per level), and the + // refinement only limits its own update. On a scene cut there is no correspondence at + // all, the search keeps whatever the residual happens to favour, and the value handed + // down the pyramid reaches hundreds of pixels - which back-tracks off the frame and + // smears the edge across the picture. That is the 'flow goes to infinity' case. + // + // The old guard was `abs(f) < 4 * width` per component: four frame widths is no limit + // at all, and worse, when it did trip it fell back to W, which by then is itself the + // huge value. Clamp the magnitude instead. The fastest real motion measured on this + // content is about 0.1 of the frame per source frame, so a quarter of the frame leaves + // four times the headroom anything legitimate needs. + const float FLOW_LIMIT_FRACTION = 0.25; + float lim = FLOW_LIMIT_FRACTION * max(uSize.x, uSize.y); + float mag = length(f); + vec2 fr = mag > lim ? f * (lim / mag) : f; + if (any(isnan(fr)) || any(isinf(fr))) fr = vec2(0.0); imageStore(flowRefined, p, vec4(fr * invSize, 0.0, 1.0)); } From 44ee3de568203056cac30f845b1d0a22fd150711 Mon Sep 17 00:00:00 2001 From: unknown <1qwertypower1@gmail.com> Date: Sun, 20 Sep 2026 23:57:15 +0300 Subject: [PATCH 05/17] DIS: roll the frame generator back to the upstream 68f678c1 state - dis_interpolate.comp: pre-occlusion blending (photometric occlusion only) - dis_vr_add.comp: loose component guard, no absolute magnitude clamp - vkr_dis.c: original refinement ladder, no Fast-tier VR budget lift Brings DIS in line with WinNative-Emu/WinNative 68f678c1. --- app/src/main/cpp/winlator/vk/dis/vkr_dis.c | 24 +- .../winlator/vk/shaders/dis_interpolate.comp | 257 ++++++++++-------- .../cpp/winlator/vk/shaders/dis_vr_add.comp | 21 +- 3 files changed, 158 insertions(+), 144 deletions(-) diff --git a/app/src/main/cpp/winlator/vk/dis/vkr_dis.c b/app/src/main/cpp/winlator/vk/dis/vkr_dis.c index 6ef2af62d..d650f8391 100644 --- a/app/src/main/cpp/winlator/vk/dis/vkr_dis.c +++ b/app/src/main/cpp/winlator/vk/dis/vkr_dis.c @@ -63,13 +63,6 @@ // Fewest SOR sweeps a level that still runs the solver gets. #define DIS_VR_SOR_FLOOR 2u -// Flow resolutions at or below this (the Fast preset: 180 px on the shorter side) are cheap -// enough per pixel to afford the Balance preset's refinement budget. Fast keeps its own pixel -// budget; only the algorithm parameters come from Balance, because a coarse pyramid refined -// with the minimal profile is what makes the Fast flow field look blocky. -#define DIS_FAST_TIER_MAX_SIDE 216u -#define DIS_FAST_TIER_MIN_RUNG 2u - #define DIS_SET_SAMPLERS 5u #define DIS_SET_STORAGE 1u @@ -677,21 +670,12 @@ typedef struct { uint32_t vr_levels; } DisRefine; -static DisRefine dis_refine_for(uint32_t generations, uint32_t flow_min_side) { - // The refinement ladder is indexed by how many frames this pass has to feed. A Fast-class - // flow resolution is lifted to at least the Balance rung so it never falls back to the - // single-level variational refinement, which is what the low pixel budget cannot hide. - uint32_t rung = generations; - if (flow_min_side != 0u && flow_min_side <= DIS_FAST_TIER_MAX_SIDE && - rung < DIS_FAST_TIER_MIN_RUNG) { - rung = DIS_FAST_TIER_MIN_RUNG; - } - - if (rung >= 3u) { +static DisRefine dis_refine_for(uint32_t generations) { + if (generations >= 3u) { const DisRefine r = {2u, 5u, 2u, DIS_MAX_LEVELS}; return r; } - if (rung == 2u) { + if (generations == 2u) { const DisRefine r = {2u, 4u, 1u, DIS_MAX_LEVELS}; return r; } @@ -1555,7 +1539,7 @@ void vkr_dis_process(VkrDis* d, VkCommandBuffer cmd, VkImage source, uint32_t wi dis_prime_layouts(d, cmd); - const DisRefine refine = dis_refine_for(generations, d->flow_min_side); + const DisRefine refine = dis_refine_for(generations); const uint32_t L = d->levels; const uint32_t coarse = L - 1; const uint32_t w = d->built_extent.width; diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp b/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp index 15df8782b..f25bfb2eb 100644 --- a/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp +++ b/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp @@ -17,6 +17,108 @@ layout(push_constant) uniform PC { layout(constant_id = 0) const int manualFlowFilter = 0; +// --------------------------------------------------------------------------- +// Static-overlay pass-through. +// +// A HUD, subtitles, a crosshair, a minimap or a letterbox bar does not move with the +// scene, but DIS has no notion of layers: under such an element the field carries the +// background's motion, so the warp drags the element around. Real frames show it in +// place, generated frames do not, and at 60 Hz that alternation is the flicker. +// +// The test needs no extra pass and no extra descriptor. One quantity decides it: +// +// rStatic = |prev(uv) - next(uv)| the two real frames, compared where they sit +// +// If the two real frames agree at a pixel, nothing happened there over the interval, so +// the value at any time between them is that same value and no warp can improve on it. +// The substitution is therefore correct by construction wherever it fires - including on +// a flat patch of moving scenery, where it simply writes the colour the warp would have +// produced anyway. That is why nothing gates it. +// +// An earlier revision multiplied this by "the field claims motion here" and by "the warp +// disagrees with itself". Both were wrong to include. The motion term ramped over 1..3 +// pixels, and under a wide HUD panel the field sags to a couple of pixels rather than to +// zero - so the mask opened halfway exactly where a two-pixel shift of crisp text reads +// as doubling. The disagreement term blocked the mask on low-contrast content, which is +// where the softness is worst. +// +// Cost on top of the existing shader: two full-res fetches, skipped only where the field +// cannot move anything at all, plus a handful of ALU. Nothing scales with the preset, so +// Fast pays the same as Quality. +// --------------------------------------------------------------------------- + +// 0.0 turns the whole block into dead code - the bisect switch. +const float UI_STRENGTH = 1.0; + +// Pure early-out: under a quarter of a pixel the warp cannot move anything, so the two +// fetches would be wasted. Deliberately far below the displacement at which doubling +// becomes visible - this is not a threshold on "is it moving". +const float UI_FLOW_SKIP_PX = 0.25; + +// Dilation. Inside an opaque panel the pair agrees and the pixel is pinned, but on an +// antialiased edge the pixel is alpha*glyph + (1-alpha)*background, the background under +// it moves, the pair disagrees and the mask shuts - so the outline keeps riding off with +// the field while the middle stands still. A pinned interior inside a twitching outline +// is what is left of the flicker. +// +// So a pixel also passes through when a neighbour's pair agrees: take the smallest pair +// difference over a small cross. The edge pixel is then frozen together with the glyph, +// which costs a thread of stale background one pixel wide - far less visible than an +// outline that moves every other frame. +// +// Measured on the model, error on the antialiased edge: 0.228 with no dilation, 0.175 at +// two taps, 0.149 at four taps over 1.5 px, 0.140 at 2.5 px. Background pays nothing up +// to 1.5 px (mask on the background hard against the panel 0.012) and starts to freeze at +// 2.5 px (0.112), so the cross stops at 1.5. +// +// 0, 2 or 4 taps; each tap is two full-res fetches, so four taps double this pass's +// sampling. Drop to 2 for about two thirds of the gain at half the cost. +const int UI_DILATE_TAPS = 4; +const float UI_DILATE_PX = 1.5; + +// Channel difference, 0..1. LO is rgb8 quantisation plus mild dither; HI is where a +// difference is unambiguously real content. +// +// Raise both if the game has visible grain or dithering - about three times sigma. +// +// A translucent HUD is the other reason to raise HI: there the pair never agrees, because +// the moving background shows through, and the difference is (1 - alpha) times the +// background's own. On the model with alpha = 0.7 the error inside such a panel goes +// 0.138 at HI = 0.05, 0.110 at 0.10, 0.081 at 0.20 - but background error goes 0.021, +// 0.031, 0.069 over the same steps. 0.10 is the point where the panel gains more than the +// scene loses; past that the scene starts freezing in earnest. +const float UI_LO = 0.012; +const float UI_HI = 0.050; + +// 0 off, 1 paints the mask green over a dimmed scene across the whole frame, 2 does it on +// the left half only so one build shows the mask and the finished picture side by side. +// The split is taken from the dispatch extent rather than from uv, because uv is +// normalised by imageSize and the two are not guaranteed to agree. +// +// What to expect now that the mask is ungated: solid green over the HUD and over every +// part of the scene that did not change between the two real frames, black only where +// something actually moved. A dark frame with green almost everywhere means the camera +// was still, which is correct - there the generated frame is meant to repeat the real one. +const int DEBUG_UI_MASK = 0; + +// --------------------------------------------------------------------------- +// Averaging two warped samples that disagree is what softens the picture. Each sample is +// sharp on its own - one bilinear tap - but where the flow does not line them up the +// blend is a double image, and over a whole frame of wrong flow that reads as a global +// defocus. Where they disagree, take one sample instead of mixing. +// +// The old test summed the three channel differences and ramped over 0.10 .. 0.40, i.e. +// from a third of a step of grey - by then the blur is long since visible, and a purely +// coloured disagreement had to reach 0.10 in a single channel before it counted at all. +// Per-channel max over a lower band both fires earlier and treats colour properly. +// +// The cost of picking is judder: the chosen sample is the nearest real frame warped by a +// vector that was wrong, so the generated frame sits closer to a repeat of a real one. +// Raise BLEND_LO towards the old behaviour if that trade reads worse than the softness. +// --------------------------------------------------------------------------- +const float BLEND_LO = 0.020; +const float BLEND_HI = 0.120; + vec2 sampleFlow(vec2 uv) { if (manualFlowFilter == 0) return textureLod(flowTex, uv, 0.0).xy; @@ -33,21 +135,14 @@ vec2 sampleFlow(vec2 uv) { return mix(mix(a, b, frac.x), mix(c, e, frac.x), frac.y); } -// Average of four taps spread across about one flow texel - a stand-in for sampling the -// frame at flow resolution, which is the only scale the occlusion test may compare at. -// Doing this from the full-res textures keeps the test inside the shader: binding the -// flow-resolution copies as extra descriptors blacked the compositor out, and the test -// does not need them. `spread` is the range across the four taps, i.e. local contrast, -// and comes for free. -vec3 lowPass(sampler2D tex, vec2 base, vec2 r, out float spread) { - vec3 a = textureLod(tex, clamp(base + vec2( r.x, r.y), vec2(0.0), vec2(1.0)), 0.0).xyz; - vec3 b = textureLod(tex, clamp(base + vec2(-r.x, r.y), vec2(0.0), vec2(1.0)), 0.0).xyz; - vec3 c = textureLod(tex, clamp(base + vec2( r.x, -r.y), vec2(0.0), vec2(1.0)), 0.0).xyz; - vec3 d = textureLod(tex, clamp(base + vec2(-r.x, -r.y), vec2(0.0), vec2(1.0)), 0.0).xyz; - vec3 mn = min(min(a, b), min(c, d)); - vec3 mx = max(max(a, b), max(c, d)); - spread = dot(mx - mn, vec3(1.0 / 3.0)); - return 0.25 * (a + b + c + d); +float maxChannel(vec3 v) { + return max(max(v.x, v.y), v.z); +} + +// How far apart the two real frames are at one point, sampled where they sit. +float pairDiff(vec2 p) { + vec2 q = clamp(p, vec2(0.0), vec2(1.0)); + return maxChannel(abs(textureLod(prevColor, q, 0.0).xyz - textureLod(nextColor, q, 0.0).xyz)); } vec3 hsv2rgb(vec3 c) { @@ -79,19 +174,9 @@ void main() { } if (pc.debugMode != 0) { - // Hue is direction; magnitude is shown on a log scale in SOURCE pixels. - // - // The old scale was `length(f) * size.x / 16`, i.e. fully saturated at 16 px of - // motion. Anything faster than a slow pan pegs the entire frame, so the view could - // not show whether the magnitude was right, nor any parallax between near and far - // geometry - every depth came out the same colour. log2 keeps roughly 1..256 px - // readable at once. - vec2 fpx = f * vec2(size); - float px = length(fpx); - float m = clamp(log2(1.0 + px) / 8.0, 0.0, 1.0); + float m = length(f) * float(size.x) / 16.0; float hue = atan(f.y, f.x) / 6.2831853 + 0.5; - vec3 fc = hsv2rgb(vec3(hue, clamp(m * 1.3, 0.0, 1.0), min(1.0, 0.15 + m))); - + vec3 fc = hsv2rgb(vec3(hue, clamp(m, 0.0, 1.0), min(1.0, 0.15 + m))); imageStore(outImage, pix, vec4(fc, 1.0)); return; } @@ -102,6 +187,8 @@ void main() { vec3 c0 = textureLod(prevColor, clamp(uv0, vec2(0.0), vec2(1.0)), 0.0).xyz; vec3 c1 = textureLod(nextColor, clamp(uv1, vec2(0.0), vec2(1.0)), 0.0).xyz; + vec3 single = pc.t < 0.5 ? c0 : c1; + const float FEATHER_PX = 8.0; vec2 feather = FEATHER_PX / vec2(size); vec2 e0 = max(max(-uv0, uv0 - vec2(1.0)), vec2(0.0)) / feather; @@ -117,85 +204,43 @@ void main() { ? (c0 * w0 + c1 * w1) / wsum : (out0 <= out1 ? c0 : c1); - // ---- Occlusion ------------------------------------------------------------------- - // - // The artefact this removes: around a screen-locked object the flow ramps from the - // object's ~0 to the background's full motion over a band several flow-texels wide. - // Inside that band uv0 lands on the object while uv1 lands on the background, so the - // blend produces a translucent duplicate of the object, offset by roughly half the - // background motion. That is the ghost, and it is what the old photometric-only test was - // papering over by snapping the whole region to the nearest source frame in time. - // - // Two conditions must hold, and both are load-bearing: - // * the flow field is discontinuous here. Without this the test fires on every lighting - // change, specular highlight and UI flash, none of which are occlusions. - // * the two warped samples genuinely disagree, measured at flow resolution and - // normalised by local contrast, so a slightly misaligned texture edge does not count. - // - // The sign of the divergence then says which side is valid: a diverging field is area - // opening up, which exists only in the next frame; a converging field is area being - // covered, which exists only in the previous one. That is strictly better than the old - // 'nearest frame in time', which was right only half the time. - // - // Set to 0.0 to compare against no occlusion handling at all. - const float OCCLUSION_STRENGTH = 1.0; - - // The window matters. Measured per texel this test barely fired: the flow around a - // static object does not step, it RAMPS over a band tens of texels wide (the fan of - // contours under the character in the debug view), so a 100 px jump spread over 20 - // texels is only ~5 px per texel and never cleared the threshold. Sampling a few - // texels out measures the jump across the band instead of the local slope. - const float DISC_RADIUS = 4.0; - - vec2 fts = 1.0 / vec2(textureSize(flowTex, 0)); - vec2 fo = fts * DISC_RADIUS; - vec2 sizePx = vec2(size); - - vec2 fL = sampleFlow(clamp(uv - vec2(fo.x, 0.0), vec2(0.0), vec2(1.0))); - vec2 fR = sampleFlow(clamp(uv + vec2(fo.x, 0.0), vec2(0.0), vec2(1.0))); - vec2 fU = sampleFlow(clamp(uv - vec2(0.0, fo.y), vec2(0.0), vec2(1.0))); - vec2 fD = sampleFlow(clamp(uv + vec2(0.0, fo.y), vec2(0.0), vec2(1.0))); - - // How far the flow departs from this pixel's own value across that window, in output - // pixels. A correct smooth field with strong parallax stays a couple of pixels here. - float flowVar = max(max(length((fL - f) * sizePx), length((fR - f) * sizePx)), - max(length((fU - f) * sizePx), length((fD - f) * sizePx))); - - const float DISC_LO = 2.0; - const float DISC_HI = 12.0; - float disc = smoothstep(DISC_LO, DISC_HI, flowVar); - - vec2 lpr = fts * 0.3; - float spread0; - float spread1; - vec3 l0 = lowPass(prevColor, clamp(uv0, vec2(0.0), vec2(1.0)), lpr, spread0); - vec3 l1 = lowPass(nextColor, clamp(uv1, vec2(0.0), vec2(1.0)), lpr, spread1); - - float resid = dot(abs(l0 - l1), vec3(1.0 / 3.0)); - float contrast = max(spread0, spread1); - float rel = resid / (contrast + 0.06); - - const float REL_LO = 1.0; - const float REL_HI = 3.0; - float mismatch = smoothstep(REL_LO, REL_HI, rel); - - float occl = OCCLUSION_STRENGTH * disc * mismatch; - - // Divergence over the same window. Worked example, background sweeping left past a - // static character: just right of her the field goes 0 -> -100, divergence negative, - // so we take c0, which back-tracks further into the background - correct. Just left of - // her it goes -100 -> 0, divergence positive, so we take c1 - also correct. - float divergence = (fR.x - fL.x) * sizePx.x + (fD.y - fU.y) * sizePx.y; - vec3 sided = divergence > 0.0 ? c1 : c0; - result = mix(result, sided, occl); - - // Set to 1 to paint the mask instead of the frame: red where it fires, dimmed scene - // underneath. If the character's silhouette band stays dark, the mask is not firing - // and the thresholds are wrong; if it is solidly red and the ghost is still there, - // the mask fires but picks the wrong side. - const int DEBUG_OCCLUSION = 0; - if (DEBUG_OCCLUSION != 0) { - result = mix(result * 0.3, vec3(1.0, 0.08, 0.08), occl); + float disagree = maxChannel(abs(c0 - c1)); + float pick = smoothstep(BLEND_LO, BLEND_HI, disagree); + result = mix(result, single, pick); + + // --- static-overlay pass-through --------------------------------------- + float uiMask = 0.0; + vec3 uiColor = vec3(0.0); + + if (UI_STRENGTH > 0.0 && length(f * vec2(size)) > UI_FLOW_SKIP_PX) { + vec3 s0 = textureLod(prevColor, uv, 0.0).xyz; + vec3 s1 = textureLod(nextColor, uv, 0.0).xyz; + + float rStatic = maxChannel(abs(s0 - s1)); + + if (UI_DILATE_TAPS > 0) { + vec2 d = UI_DILATE_PX * texel; + rStatic = min(rStatic, pairDiff(uv + vec2(d.x, 0.0))); + rStatic = min(rStatic, pairDiff(uv - vec2(d.x, 0.0))); + if (UI_DILATE_TAPS > 2) { + rStatic = min(rStatic, pairDiff(uv + vec2(0.0, d.y))); + rStatic = min(rStatic, pairDiff(uv - vec2(0.0, d.y))); + } + } + + uiMask = UI_STRENGTH * (1.0 - smoothstep(UI_LO, UI_HI, rStatic)); + + // The pair agrees here, so either frame is the answer; crossfading keeps a slowly + // animating element (a draining bar, a blinking icon) smooth instead of popping + // on every real frame. + uiColor = mix(s0, s1, pc.t); + } + + result = mix(result, uiColor, uiMask); + + bool leftHalf = uint(pix.x) * 2u < gl_NumWorkGroups.x * gl_WorkGroupSize.x; + if (DEBUG_UI_MASK == 1 || (DEBUG_UI_MASK == 2 && leftHalf)) { + result = mix(result * 0.15, vec3(0.1, 1.0, 0.2), uiMask); } imageStore(outImage, pix, vec4(result, 1.0)); diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_vr_add.comp b/app/src/main/cpp/winlator/vk/shaders/dis_vr_add.comp index 2b8a9f3be..cba444082 100644 --- a/app/src/main/cpp/winlator/vk/shaders/dis_vr_add.comp +++ b/app/src/main/cpp/winlator/vk/shaders/dis_vr_add.comp @@ -18,24 +18,9 @@ void main() { vec2 W = texelFetch(flowDense, p, 0).xy * uSize; vec2 f = W + texelFetch(dW, p, 0).xy; - - // Nothing upstream bounds the ABSOLUTE magnitude of the flow. The block search only - // limits how far it may move from the coarse estimate (one patch per level), and the - // refinement only limits its own update. On a scene cut there is no correspondence at - // all, the search keeps whatever the residual happens to favour, and the value handed - // down the pyramid reaches hundreds of pixels - which back-tracks off the frame and - // smears the edge across the picture. That is the 'flow goes to infinity' case. - // - // The old guard was `abs(f) < 4 * width` per component: four frame widths is no limit - // at all, and worse, when it did trip it fell back to W, which by then is itself the - // huge value. Clamp the magnitude instead. The fastest real motion measured on this - // content is about 0.1 of the frame per source frame, so a quarter of the frame leaves - // four times the headroom anything legitimate needs. - const float FLOW_LIMIT_FRACTION = 0.25; - float lim = FLOW_LIMIT_FRACTION * max(uSize.x, uSize.y); - float mag = length(f); - vec2 fr = mag > lim ? f * (lim / mag) : f; - if (any(isnan(fr)) || any(isinf(fr))) fr = vec2(0.0); + float lim = 4.0 * uSize.x; + bvec2 ok = lessThan(abs(f), vec2(lim)); + vec2 fr = vec2(ok.x ? f.x : W.x, ok.y ? f.y : W.y); imageStore(flowRefined, p, vec4(fr * invSize, 0.0, 1.0)); } From bec054b2d1af2e4dbae3716a98b8cd2e74dea9fb Mon Sep 17 00:00:00 2001 From: unknown <1qwertypower1@gmail.com> Date: Mon, 21 Sep 2026 00:27:52 +0300 Subject: [PATCH 06/17] DIS: stabilize the static-overlay mask across frames - dis_hist.comp: half-resolution pass collecting the pass-through evidence (frame difference, plus edge agreement for translucent overlays) and keeping the maximum with a decay, so a per-frame decision hovering around the thresholds cannot flicker - dis_interpolate.comp: apply the stabilized mask on top of the per-frame test, suppressed on cuts by the current frame difference; pick the blend side from the flow divergence instead of t so the side stays the same for every generated frame of a pair - vkr_dis.c: history resources, pipeline, descriptor sets, dispatch and reset UI_HIST_ENABLE=0 falls back to the per-frame mask, DIS_OCCL_SIDED=0 to the t-based side. --- app/src/main/cpp/CMakeLists.txt | 1 + app/src/main/cpp/winlator/vk/dis/vkr_dis.c | 90 +++++++++++++++--- .../cpp/winlator/vk/shaders/dis_hist.comp | 95 +++++++++++++++++++ .../winlator/vk/shaders/dis_interpolate.comp | 55 +++++++++-- 4 files changed, 217 insertions(+), 24 deletions(-) create mode 100644 app/src/main/cpp/winlator/vk/shaders/dis_hist.comp diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt index 0ca8de85f..c0c4f2d03 100644 --- a/app/src/main/cpp/CMakeLists.txt +++ b/app/src/main/cpp/CMakeLists.txt @@ -109,6 +109,7 @@ set(SHADER_LIST "dis_propagate:comp:dis_propagate_comp" "dis_densify:comp:dis_densify_comp" "dis_interpolate:comp:dis_interpolate_comp" + "dis_hist:comp:dis_hist_comp" "dis_vr_prep:comp:dis_vr_prep_comp" "dis_vr_d1:comp:dis_vr_d1_comp" "dis_vr_d2:comp:dis_vr_d2_comp" diff --git a/app/src/main/cpp/winlator/vk/dis/vkr_dis.c b/app/src/main/cpp/winlator/vk/dis/vkr_dis.c index d650f8391..9443aeaee 100644 --- a/app/src/main/cpp/winlator/vk/dis/vkr_dis.c +++ b/app/src/main/cpp/winlator/vk/dis/vkr_dis.c @@ -8,6 +8,7 @@ #include "shaders/dis_propagate_comp.spv.h" #include "shaders/dis_densify_comp.spv.h" #include "shaders/dis_interpolate_comp.spv.h" +#include "shaders/dis_hist_comp.spv.h" #include "shaders/dis_vr_prep_comp.spv.h" #include "shaders/dis_vr_d1_comp.spv.h" #include "shaders/dis_vr_d2_comp.spv.h" @@ -130,6 +131,7 @@ struct VkrDis { DisImage vr_wt; DisImage vr_dw[2]; DisImage flow_refined; + DisImage hist[2]; VkImageView view_color[DIS_SLOTS]; VkImageView view_flow_color[DIS_SLOTS][DIS_MAX_LEVELS]; @@ -147,6 +149,7 @@ struct VkrDis { VkImageView view_vr_wt[DIS_MAX_LEVELS]; VkImageView view_vr_dw[2][DIS_MAX_LEVELS]; VkImageView view_flow_refined[DIS_MAX_LEVELS]; + VkImageView view_hist[2]; VkSampler sampler; @@ -159,7 +162,8 @@ struct VkrDis { VkDescriptorSet densify_sets[DIS_SLOTS][DIS_MAX_LEVELS]; VkDescriptorSet prop_ab_sets[DIS_SLOTS][DIS_MAX_LEVELS]; VkDescriptorSet prop_ba_sets[DIS_SLOTS][DIS_MAX_LEVELS]; - VkDescriptorSet interp_sets[DIS_SLOTS]; + VkDescriptorSet interp_sets[DIS_SLOTS][2]; + VkDescriptorSet hist_sets[DIS_SLOTS][2]; VkDescriptorSetLayout vr_set_layout; VkPipelineLayout vr_pipeline_layout; @@ -178,6 +182,7 @@ struct VkrDis { DisPass pass_propagate; DisPass pass_densify; DisPass pass_interp; + DisPass pass_hist; DisPass pass_vr_prep; DisPass pass_vr_d1; DisPass pass_vr_d2; @@ -207,6 +212,9 @@ struct VkrDis { uint64_t plan_log_ns; int plan_log_gen; + + uint32_t hist_parity; + bool hist_valid; }; typedef struct { @@ -315,11 +323,13 @@ static uint32_t dis_collect_images(VkrDis* d, DisImage** out, uint32_t cap) { DIS_PUSH(&d->vr_dw[0]); DIS_PUSH(&d->vr_dw[1]); DIS_PUSH(&d->flow_refined); + DIS_PUSH(&d->hist[0]); + DIS_PUSH(&d->hist[1]); #undef DIS_PUSH return n; } -#define DIS_MAX_OWNED_IMAGES 40u +#define DIS_MAX_OWNED_IMAGES 48u static void dis_prime_layouts(VkrDis* d, VkCommandBuffer cmd) { if (d->layouts_primed) return; @@ -512,19 +522,25 @@ static bool dis_create_pipelines(VkrDis* d) { } const uint32_t shared_sets = DIS_SLOTS * DIS_MAX_LEVELS * DIS_SHARED_SETS_PER_LEVEL - + DIS_SLOTS; + + DIS_SLOTS * 2u; const uint32_t vr_sets = (DIS_SLOTS + DIS_VR_SHARED_SETS) * DIS_MAX_LEVELS; - const uint32_t total_sets = shared_sets + vr_sets; - + const uint32_t hist_sets = DIS_SLOTS * 2u; + const uint32_t vr_layout_sets = vr_sets + hist_sets; + const uint32_t total_sets = shared_sets + vr_layout_sets; + + // The history sets are allocated from vr_set_layout, so they consume the full + // VR footprint (8 samplers + 2 storage) per set, not just the bindings they + // write. Counting them short here fails vkAllocateDescriptorSets with + // VK_ERROR_OUT_OF_POOL_MEMORY, which disables DIS entirely. VkDescriptorPoolSize sizes[2]; memset(sizes, 0, sizeof(sizes)); sizes[0].type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; sizes[0].descriptorCount = shared_sets * DIS_SET_SAMPLERS - + vr_sets * DIS_VR_SAMPLER_BINDINGS; + + vr_layout_sets * DIS_VR_SAMPLER_BINDINGS; sizes[1].type = VK_DESCRIPTOR_TYPE_STORAGE_IMAGE; sizes[1].descriptorCount = shared_sets * DIS_SET_STORAGE - + vr_sets * DIS_VR_STORAGE_BINDINGS; + + vr_layout_sets * DIS_VR_STORAGE_BINDINGS; VkDescriptorPoolCreateInfo pci; memset(&pci, 0, sizeof(pci)); pci.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; @@ -605,12 +621,13 @@ static bool dis_create_pipelines(VkrDis* d) { d->pass_vr_coef.pipeline = dis_create_compute_pipeline_with_layout(d, dis_vr_coef_comp, dis_vr_coef_comp_size, d->vr_pipeline_layout, NULL); d->pass_vr_sor.pipeline = dis_create_compute_pipeline_with_layout(d, dis_vr_sor_comp, dis_vr_sor_comp_size, d->vr_pipeline_layout, NULL); d->pass_vr_add.pipeline = dis_create_compute_pipeline_with_layout(d, dis_vr_add_comp, dis_vr_add_comp_size, d->vr_pipeline_layout, NULL); + d->pass_hist.pipeline = dis_create_compute_pipeline_with_layout(d, dis_hist_comp, dis_hist_comp_size, d->vr_pipeline_layout, NULL); if (!d->pass_gradient.pipeline || !d->pass_inverse.pipeline || !d->pass_propagate.pipeline || !d->pass_densify.pipeline || !d->pass_interp.pipeline || !d->pass_vr_prep.pipeline || !d->pass_vr_d1.pipeline || !d->pass_vr_d2.pipeline || !d->pass_vr_w.pipeline || !d->pass_vr_coef.pipeline || !d->pass_vr_sor.pipeline || - !d->pass_vr_add.pipeline) { + !d->pass_vr_add.pipeline || !d->pass_hist.pipeline) { return false; } return true; @@ -767,10 +784,18 @@ static void dis_write_all_descriptors(VkrDis* d) { dis_batch_storage(d, &b, d->densify_sets[s][l], 5, d->view_dense[l]); } - dis_batch_sampled(d, &b, d->interp_sets[s], 0, d->view_color[prev], d->sampler); - dis_batch_sampled(d, &b, d->interp_sets[s], 1, d->view_color[next], d->sampler); - dis_batch_sampled(d, &b, d->interp_sets[s], 2, d->view_flow_refined[0], d->sampler); - dis_batch_storage(d, &b, d->interp_sets[s], 5, d->view_interp_out); + for (uint32_t dir = 0; dir < 2u; dir++) { + dis_batch_sampled(d, &b, d->interp_sets[s][dir], 0, d->view_color[prev], d->sampler); + dis_batch_sampled(d, &b, d->interp_sets[s][dir], 1, d->view_color[next], d->sampler); + dis_batch_sampled(d, &b, d->interp_sets[s][dir], 2, d->view_flow_refined[0], d->sampler); + dis_batch_sampled(d, &b, d->interp_sets[s][dir], 4, d->view_hist[dir], d->sampler); + dis_batch_storage(d, &b, d->interp_sets[s][dir], 5, d->view_interp_out); + + dis_batch_sampled(d, &b, d->hist_sets[s][dir], 0, d->view_color[prev], d->sampler); + dis_batch_sampled(d, &b, d->hist_sets[s][dir], 1, d->view_color[next], d->sampler); + dis_batch_sampled(d, &b, d->hist_sets[s][dir], 2, d->view_hist[1u - dir], d->sampler); + dis_batch_storage(d, &b, d->hist_sets[s][dir], DIS_VR_FIRST_STORAGE, d->view_hist[dir]); + } for (uint32_t l = 0; l < L; l++) { dis_batch_sampled(d, &b, d->vr_prep_sets[s][l], 0, d->view_flow_color[prev][l], d->sampler); @@ -824,6 +849,8 @@ static void dis_write_all_descriptors(VkrDis* d) { static void dis_destroy_views(VkrDis* d) { for (uint32_t s = 0; s < DIS_SLOTS; s++) dis_destroy_view(d, &d->view_color[s]); dis_destroy_view(d, &d->view_interp_out); + dis_destroy_view(d, &d->view_hist[0]); + dis_destroy_view(d, &d->view_hist[1]); for (uint32_t l = 0; l < DIS_MAX_LEVELS; l++) { dis_destroy_view(d, &d->view_vr_prep[l]); dis_destroy_view(d, &d->view_vr_d1[l]); @@ -868,6 +895,8 @@ static void dis_destroy_images(VkrDis* d) { dis_destroy_image(d, &d->vr_dw[0]); dis_destroy_image(d, &d->vr_dw[1]); dis_destroy_image(d, &d->flow_refined); + dis_destroy_image(d, &d->hist[0]); + dis_destroy_image(d, &d->hist[1]); } static bool dis_create_resources(VkrDis* d, uint32_t w, uint32_t h, uint32_t full_w, @@ -921,6 +950,14 @@ static bool dis_create_resources(VkrDis* d, uint32_t w, uint32_t h, uint32_t ful VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_STORAGE_BIT)) return false; if (!dis_create_image(d, &d->flow_refined, w, h, VK_FORMAT_R32G32_SFLOAT, L, VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_STORAGE_BIT)) return false; + // R32_SFLOAT at half the content resolution: the storage format is already + // required by the flow images, so the history adds no device requirement. + const uint32_t hist_w = full_w > 1u ? full_w / 2u : 1u; + const uint32_t hist_h = full_h > 1u ? full_h / 2u : 1u; + if (!dis_create_image(d, &d->hist[0], hist_w, hist_h, VK_FORMAT_R32_SFLOAT, 1, + VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_STORAGE_BIT)) return false; + if (!dis_create_image(d, &d->hist[1], hist_w, hist_h, VK_FORMAT_R32_SFLOAT, 1, + VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_STORAGE_BIT)) return false; for (uint32_t s = 0; s < DIS_SLOTS; s++) { if (!dis_create_view(d, d->color[s].image, format, 0, 1, &d->view_color[s])) return false; @@ -947,6 +984,8 @@ static bool dis_create_resources(VkrDis* d, uint32_t w, uint32_t h, uint32_t ful if (!dis_create_view(d, d->flow_refined.image, VK_FORMAT_R32G32_SFLOAT, l, 1, &d->view_flow_refined[l])) return false; } if (!dis_create_view(d, d->interp_out.image, VK_FORMAT_R8G8B8A8_UNORM, 0, 1, &d->view_interp_out)) return false; + if (!dis_create_view(d, d->hist[0].image, VK_FORMAT_R32_SFLOAT, 0, 1, &d->view_hist[0])) return false; + if (!dis_create_view(d, d->hist[1].image, VK_FORMAT_R32_SFLOAT, 0, 1, &d->view_hist[1])) return false; vkr_dis_reset(d); dis_write_all_descriptors(d); @@ -984,7 +1023,10 @@ static bool dis_allocate_sets(VkrDis* d) { d->prop_ba_sets[s][l] = sets[4]; d->luma_sets[s][l] = sets[5]; } - if (!dis_alloc(d, d->set_layout, 1, &d->interp_sets[s])) return false; + if (!dis_alloc(d, d->set_layout, 1, &d->interp_sets[s][0])) return false; + if (!dis_alloc(d, d->set_layout, 1, &d->interp_sets[s][1])) return false; + if (!dis_alloc(d, d->vr_set_layout, 1, &d->hist_sets[s][0])) return false; + if (!dis_alloc(d, d->vr_set_layout, 1, &d->hist_sets[s][1])) return false; for (uint32_t l = 0; l < DIS_MAX_LEVELS; l++) { if (!dis_alloc(d, d->vr_set_layout, 1, &d->vr_prep_sets[s][l])) return false; } @@ -1194,6 +1236,7 @@ void vkr_dis_destroy(VkrDis* d) { if (d->pass_vr_coef.pipeline) vkd.DestroyPipeline(d->device, d->pass_vr_coef.pipeline, NULL); if (d->pass_vr_sor.pipeline) vkd.DestroyPipeline(d->device, d->pass_vr_sor.pipeline, NULL); if (d->pass_vr_add.pipeline) vkd.DestroyPipeline(d->device, d->pass_vr_add.pipeline, NULL); + if (d->pass_hist.pipeline) vkd.DestroyPipeline(d->device, d->pass_hist.pipeline, NULL); if (d->pool) vkd.DestroyDescriptorPool(d->device, d->pool, NULL); if (d->pipeline_layout) vkd.DestroyPipelineLayout(d->device, d->pipeline_layout, NULL); if (d->vr_pipeline_layout) vkd.DestroyPipelineLayout(d->device, d->vr_pipeline_layout, NULL); @@ -1661,6 +1704,23 @@ void vkr_dis_process(VkrDis* d, VkCommandBuffer cmd, VkImage source, uint32_t wi dis_vr_level(d, cmd, slot, l, lw, lh, &refine, l < refine.vr_levels); } + if (generations > 0 || d->debug_flow) { + const uint32_t hist_w = full_w > 1u ? full_w / 2u : 1u; + const uint32_t hist_h = full_h > 1u ? full_h / 2u : 1u; + const uint32_t hist_dir = 1u - d->hist_parity; + const int hist_reset = d->hist_valid ? 0 : 1; + vkd.CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, d->pass_hist.pipeline); + vkd.CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, d->vr_pipeline_layout, 0, 1, + &d->hist_sets[slot][hist_dir], 0, NULL); + vkd.CmdPushConstants(cmd, d->vr_pipeline_layout, VK_SHADER_STAGE_COMPUTE_BIT, 0, + sizeof(hist_reset), &hist_reset); + vkd.CmdDispatch(cmd, (hist_w + DIS_LOCAL_SIZE - 1) / DIS_LOCAL_SIZE, + (hist_h + DIS_LOCAL_SIZE - 1) / DIS_LOCAL_SIZE, 1); + dis_compute_barrier(cmd); + d->hist_parity = hist_dir; + d->hist_valid = true; + } + } static void dis_render_into(VkrDis* d, VkCommandBuffer cmd, float t, int debug_mode, @@ -1675,7 +1735,7 @@ static void dis_render_into(VkrDis* d, VkCommandBuffer cmd, float t, int debug_m vkd.CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, d->pass_interp.pipeline); vkd.CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, d->pipeline_layout, 0, 1, - &d->interp_sets[d->active_slot], 0, NULL); + &d->interp_sets[d->active_slot][d->hist_parity], 0, NULL); vkd.CmdPushConstants(cmd, d->pipeline_layout, VK_SHADER_STAGE_COMPUTE_BIT, 0, sizeof(ipc), &ipc); vkd.CmdDispatch(cmd, (w + DIS_LOCAL_SIZE - 1) / DIS_LOCAL_SIZE, (h + DIS_LOCAL_SIZE - 1) / DIS_LOCAL_SIZE, 1); @@ -1786,4 +1846,6 @@ void vkr_dis_reset(VkrDis* d) { d->gen_low_streak = 0; d->plan_log_gen = -1; d->plan_log_ns = 0; + d->hist_parity = 0; + d->hist_valid = false; } diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_hist.comp b/app/src/main/cpp/winlator/vk/shaders/dis_hist.comp new file mode 100644 index 000000000..2985de817 --- /dev/null +++ b/app/src/main/cpp/winlator/vk/shaders/dis_hist.comp @@ -0,0 +1,95 @@ +#version 450 + +precision highp float; +precision highp int; + +// Temporal static-overlay confidence for the interpolate stage, at half of the +// output resolution. Each source pair contributes one piece of evidence per +// pixel and the buffer keeps the maximum over a few frames with a decay, so a +// marginal per-frame decision cannot flicker in the generated frames. +// +// Two kinds of evidence: +// diff - the two frames agree at this position, with a min over a small cross +// so an antialiased edge counts together with its glyph. This is the +// pass-through test of dis_interpolate.comp, stabilized over time; +// edge - the same gradient sits at the same place in both frames. Under a +// translucent overlay the frames never agree, because the background +// shows through, but the element's own edges stay put in both. +// +// Half resolution is deliberate: the flow-resolution mask cannot see sub-texel +// text, and a decision that lives for several frames does not need full +// resolution. Everything here runs once per source pair, not per generated +// frame, and costs ~10 texture fetches per half-res pixel. + +#define DIS_HIST_DECAY 0.85 +#define DIS_HIST_DU_LO 0.012 +#define DIS_HIST_DU_HI 0.050 +#define DIS_HIST_AGREE_LO 0.4 +#define DIS_HIST_AGREE_HI 0.8 +#define DIS_HIST_MAG_LO 6.0 +#define DIS_HIST_MAG_HI 20.0 +#define DIS_HIST_EPS 2.0 + +layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in; + +layout(set = 0, binding = 0) uniform sampler2D prevColor; +layout(set = 0, binding = 1) uniform sampler2D nextColor; +layout(set = 0, binding = 2) uniform sampler2D histIn; +layout(set = 0, binding = 8, r32f) uniform image2D histOut; + +layout(push_constant) uniform PC { + int reset; +} pc; + +float maxChannel(vec3 v) { + return max(max(v.x, v.y), v.z); +} + +float uluminance(vec3 c) { + return (0.299 * c.x + 0.587 * c.y + 0.114 * c.z) * 255.0; +} + +void main() { + ivec2 p = ivec2(gl_GlobalInvocationID.xy); + ivec2 sz = imageSize(histOut); + if (p.x >= sz.x || p.y >= sz.y) return; + + ivec2 cmx = textureSize(prevColor, 0) - 1; + ivec2 q = clamp(p * 2 + 1, ivec2(0), cmx); + + vec3 c0 = texelFetch(prevColor, q, 0).xyz; + vec3 c1 = texelFetch(nextColor, q, 0).xyz; + float du = maxChannel(abs(c0 - c1)); + float l0 = uluminance(c0); + float l1 = uluminance(c1); + + const ivec2 doffs[4] = ivec2[4](ivec2(2, 0), ivec2(-2, 0), ivec2(0, 2), ivec2(0, -2)); + float n0[4]; + float n1[4]; + for (int i = 0; i < 4; i++) { + ivec2 r = clamp(q + doffs[i], ivec2(0), cmx); + vec3 p0 = texelFetch(prevColor, r, 0).xyz; + vec3 p1 = texelFetch(nextColor, r, 0).xyz; + du = min(du, maxChannel(abs(p0 - p1))); + n0[i] = uluminance(p0); + n1[i] = uluminance(p1); + } + float diffGate = 1.0 - smoothstep(DIS_HIST_DU_LO, DIS_HIST_DU_HI, du); + + // Central differences over two full-resolution pixels, divided back to a + // per-pixel slope so the thresholds match the output-resolution detector. + float dx0 = 0.25 * (n0[0] - n0[1]); + float dy0 = 0.25 * (n0[2] - n0[3]); + float dx1 = 0.25 * (n1[0] - n1[1]); + float dy1 = 0.25 * (n1[2] - n1[3]); + float g0 = sqrt(dx0 * dx0 + dy0 * dy0); + float g1 = sqrt(dx1 * dx1 + dy1 * dy1); + float edgeAgree = min(g0, g1) / (max(g0, g1) + DIS_HIST_EPS); + float edgeGate = smoothstep(DIS_HIST_AGREE_LO, DIS_HIST_AGREE_HI, edgeAgree) * + smoothstep(DIS_HIST_MAG_LO, DIS_HIST_MAG_HI, min(g0, g1)); + + float evidence = max(diffGate, edgeGate); + float histPrev = pc.reset != 0 ? 0.0 : texelFetch(histIn, p, 0).r; + float m = max(evidence, histPrev * DIS_HIST_DECAY); + imageStore(histOut, p, vec4(m, 0.0, 0.0, 0.0)); +} diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp b/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp index f25bfb2eb..11beff870 100644 --- a/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp +++ b/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp @@ -8,6 +8,7 @@ layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in; layout(set = 0, binding = 0) uniform sampler2D prevColor; layout(set = 0, binding = 1) uniform sampler2D nextColor; layout(set = 0, binding = 2) uniform sampler2D flowTex; +layout(set = 0, binding = 4) uniform sampler2D histTex; layout(set = 0, binding = 5, rgba8) uniform image2D outImage; layout(push_constant) uniform PC { @@ -101,6 +102,15 @@ const float UI_HI = 0.050; // was still, which is correct - there the generated frame is meant to repeat the real one. const int DEBUG_UI_MASK = 0; +// Use the temporally stabilized overlay evidence from dis_hist.comp on top of +// the per-frame test above. 0 falls back to the per-frame test alone. +#define UI_HIST_ENABLE 1 + +// Pick the side for the blend from the sign of the flow divergence instead of +// from t: the side then stays the same for every generated frame of a pair, +// while t crosses 0.5 between them. +#define DIS_OCCL_SIDED 1 + // --------------------------------------------------------------------------- // Averaging two warped samples that disagree is what softens the picture. Each sample is // sharp on its own - one bilinear tap - but where the flow does not line them up the @@ -188,6 +198,22 @@ void main() { vec3 c1 = textureLod(nextColor, clamp(uv1, vec2(0.0), vec2(1.0)), 0.0).xyz; vec3 single = pc.t < 0.5 ? c0 : c1; +#if DIS_OCCL_SIDED + { + // Divergence over a wide window: a diverging field is area opening up, + // which exists only in the next frame; a converging one only in the + // previous. Spatially coherent and independent of t, so the outline of + // a static element does not swap sides between generated frames. + vec2 fts = 1.0 / vec2(textureSize(flowTex, 0)); + vec2 fo = fts * 4.0; + vec2 fL = sampleFlow(clamp(uv - vec2(fo.x, 0.0), vec2(0.0), vec2(1.0))); + vec2 fR = sampleFlow(clamp(uv + vec2(fo.x, 0.0), vec2(0.0), vec2(1.0))); + vec2 fU = sampleFlow(clamp(uv - vec2(0.0, fo.y), vec2(0.0), vec2(1.0))); + vec2 fD = sampleFlow(clamp(uv + vec2(0.0, fo.y), vec2(0.0), vec2(1.0))); + float divergence = (fR.x - fL.x) * float(size.x) + (fD.y - fU.y) * float(size.y); + single = divergence > 0.0 ? c1 : c0; + } +#endif const float FEATHER_PX = 8.0; vec2 feather = FEATHER_PX / vec2(size); @@ -209,14 +235,18 @@ void main() { result = mix(result, single, pick); // --- static-overlay pass-through --------------------------------------- + // The unwarped pair is fetched unconditionally now: the cut gate of the + // stabilized mask below needs its difference even where the flow early-out + // would have skipped the per-frame test. + vec3 s0 = textureLod(prevColor, uv, 0.0).xyz; + vec3 s1 = textureLod(nextColor, uv, 0.0).xyz; + float pairDiffHere = maxChannel(abs(s0 - s1)); + float uiMask = 0.0; - vec3 uiColor = vec3(0.0); + vec3 uiColor = mix(s0, s1, pc.t); if (UI_STRENGTH > 0.0 && length(f * vec2(size)) > UI_FLOW_SKIP_PX) { - vec3 s0 = textureLod(prevColor, uv, 0.0).xyz; - vec3 s1 = textureLod(nextColor, uv, 0.0).xyz; - - float rStatic = maxChannel(abs(s0 - s1)); + float rStatic = pairDiffHere; if (UI_DILATE_TAPS > 0) { vec2 d = UI_DILATE_PX * texel; @@ -229,13 +259,18 @@ void main() { } uiMask = UI_STRENGTH * (1.0 - smoothstep(UI_LO, UI_HI, rStatic)); - - // The pair agrees here, so either frame is the answer; crossfading keeps a slowly - // animating element (a draining bar, a blinking icon) smooth instead of popping - // on every real frame. - uiColor = mix(s0, s1, pc.t); } +#if UI_HIST_ENABLE + // The half-resolution history keeps the same decision over several frames, so + // a per-frame test whose value hovers around the thresholds cannot flicker. + // A large current difference means a cut or a scene change, where the old + // element is gone: suppress the history there rather than holding it. + float histStatic = textureLod(histTex, uv, 0.0).r; + histStatic *= 1.0 - smoothstep(0.30, 0.60, pairDiffHere); + uiMask = max(uiMask, histStatic); +#endif + result = mix(result, uiColor, uiMask); bool leftHalf = uint(pix.x) * 2u < gl_NumWorkGroups.x * gl_WorkGroupSize.x; From d13c628b5adecfa8f15c134c61a03db04d75d625 Mon Sep 17 00:00:00 2001 From: unknown <1qwertypower1@gmail.com> Date: Mon, 21 Sep 2026 01:07:33 +0300 Subject: [PATCH 07/17] DIS: cut the frame generator's pass count and sampling cost - the variational refinement runs on the finest three levels only; levels without it skip prep/add entirely and the search reads the densified flow as its coarse estimate - dis_propagate ranks candidates on a 4x4 subsample of the patch instead of all 64 texels, with the score rescaled to the 64-sample units the densification compares against - dis_side.comp computes the occlusion side (flow divergence sign) once per flow texel; the interpolate stage reads one tap instead of four flow fetches - the inverse search caps at six Gauss-Newton iterations (the SSD break already ends most patches earlier) - VR descriptor sets and pool are sized to the refined levels; UI dilation taps 4 -> 2 --- app/src/main/cpp/CMakeLists.txt | 1 + app/src/main/cpp/winlator/vk/dis/vkr_dis.c | 65 +++++++++++++++---- .../winlator/vk/shaders/dis_interpolate.comp | 20 ++---- .../vk/shaders/dis_inverse_search.comp | 5 +- .../winlator/vk/shaders/dis_propagate.comp | 30 +++++---- .../cpp/winlator/vk/shaders/dis_side.comp | 34 ++++++++++ 6 files changed, 114 insertions(+), 41 deletions(-) create mode 100644 app/src/main/cpp/winlator/vk/shaders/dis_side.comp diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt index c0c4f2d03..9fbb004ff 100644 --- a/app/src/main/cpp/CMakeLists.txt +++ b/app/src/main/cpp/CMakeLists.txt @@ -110,6 +110,7 @@ set(SHADER_LIST "dis_densify:comp:dis_densify_comp" "dis_interpolate:comp:dis_interpolate_comp" "dis_hist:comp:dis_hist_comp" + "dis_side:comp:dis_side_comp" "dis_vr_prep:comp:dis_vr_prep_comp" "dis_vr_d1:comp:dis_vr_d1_comp" "dis_vr_d2:comp:dis_vr_d2_comp" diff --git a/app/src/main/cpp/winlator/vk/dis/vkr_dis.c b/app/src/main/cpp/winlator/vk/dis/vkr_dis.c index 9443aeaee..f3f42fb58 100644 --- a/app/src/main/cpp/winlator/vk/dis/vkr_dis.c +++ b/app/src/main/cpp/winlator/vk/dis/vkr_dis.c @@ -9,6 +9,7 @@ #include "shaders/dis_densify_comp.spv.h" #include "shaders/dis_interpolate_comp.spv.h" #include "shaders/dis_hist_comp.spv.h" +#include "shaders/dis_side_comp.spv.h" #include "shaders/dis_vr_prep_comp.spv.h" #include "shaders/dis_vr_d1_comp.spv.h" #include "shaders/dis_vr_d2_comp.spv.h" @@ -64,6 +65,13 @@ // Fewest SOR sweeps a level that still runs the solver gets. #define DIS_VR_SOR_FLOOR 2u +// The variational refinement runs on the finest levels only. Above the third, +// the refined flow is upsampled into the next search anyway, so refining the +// coarse levels bought very little while their prep/add dispatches and solver +// sweeps dominated the pass count. Levels without refinement skip the VR stage +// entirely and the search reads the densified flow instead. +#define DIS_VR_LEVELS 3u + #define DIS_SET_SAMPLERS 5u #define DIS_SET_STORAGE 1u @@ -132,6 +140,7 @@ struct VkrDis { DisImage vr_dw[2]; DisImage flow_refined; DisImage hist[2]; + DisImage side; VkImageView view_color[DIS_SLOTS]; VkImageView view_flow_color[DIS_SLOTS][DIS_MAX_LEVELS]; @@ -150,6 +159,7 @@ struct VkrDis { VkImageView view_vr_dw[2][DIS_MAX_LEVELS]; VkImageView view_flow_refined[DIS_MAX_LEVELS]; VkImageView view_hist[2]; + VkImageView view_side; VkSampler sampler; @@ -164,6 +174,7 @@ struct VkrDis { VkDescriptorSet prop_ba_sets[DIS_SLOTS][DIS_MAX_LEVELS]; VkDescriptorSet interp_sets[DIS_SLOTS][2]; VkDescriptorSet hist_sets[DIS_SLOTS][2]; + VkDescriptorSet side_sets[DIS_SLOTS]; VkDescriptorSetLayout vr_set_layout; VkPipelineLayout vr_pipeline_layout; @@ -183,6 +194,7 @@ struct VkrDis { DisPass pass_densify; DisPass pass_interp; DisPass pass_hist; + DisPass pass_side; DisPass pass_vr_prep; DisPass pass_vr_d1; DisPass pass_vr_d2; @@ -325,6 +337,7 @@ static uint32_t dis_collect_images(VkrDis* d, DisImage** out, uint32_t cap) { DIS_PUSH(&d->flow_refined); DIS_PUSH(&d->hist[0]); DIS_PUSH(&d->hist[1]); + DIS_PUSH(&d->side); #undef DIS_PUSH return n; } @@ -522,9 +535,11 @@ static bool dis_create_pipelines(VkrDis* d) { } const uint32_t shared_sets = DIS_SLOTS * DIS_MAX_LEVELS * DIS_SHARED_SETS_PER_LEVEL - + DIS_SLOTS * 2u; + + DIS_SLOTS * 2u // interpolation sets, one per history direction + + DIS_SLOTS; // side-map sets + // VR sets exist only for the levels the refinement actually runs on. const uint32_t vr_sets = (DIS_SLOTS - + DIS_VR_SHARED_SETS) * DIS_MAX_LEVELS; + + DIS_VR_SHARED_SETS) * DIS_VR_LEVELS; const uint32_t hist_sets = DIS_SLOTS * 2u; const uint32_t vr_layout_sets = vr_sets + hist_sets; const uint32_t total_sets = shared_sets + vr_layout_sets; @@ -622,12 +637,13 @@ static bool dis_create_pipelines(VkrDis* d) { d->pass_vr_sor.pipeline = dis_create_compute_pipeline_with_layout(d, dis_vr_sor_comp, dis_vr_sor_comp_size, d->vr_pipeline_layout, NULL); d->pass_vr_add.pipeline = dis_create_compute_pipeline_with_layout(d, dis_vr_add_comp, dis_vr_add_comp_size, d->vr_pipeline_layout, NULL); d->pass_hist.pipeline = dis_create_compute_pipeline_with_layout(d, dis_hist_comp, dis_hist_comp_size, d->vr_pipeline_layout, NULL); + d->pass_side.pipeline = dis_create_compute_pipeline(d, dis_side_comp, dis_side_comp_size); if (!d->pass_gradient.pipeline || !d->pass_inverse.pipeline || !d->pass_propagate.pipeline || !d->pass_densify.pipeline || !d->pass_interp.pipeline || !d->pass_vr_prep.pipeline || !d->pass_vr_d1.pipeline || !d->pass_vr_d2.pipeline || !d->pass_vr_w.pipeline || !d->pass_vr_coef.pipeline || !d->pass_vr_sor.pipeline || - !d->pass_vr_add.pipeline || !d->pass_hist.pipeline) { + !d->pass_vr_add.pipeline || !d->pass_hist.pipeline || !d->pass_side.pipeline) { return false; } return true; @@ -689,14 +705,14 @@ typedef struct { static DisRefine dis_refine_for(uint32_t generations) { if (generations >= 3u) { - const DisRefine r = {2u, 5u, 2u, DIS_MAX_LEVELS}; + const DisRefine r = {2u, 5u, 2u, DIS_VR_LEVELS}; return r; } if (generations == 2u) { - const DisRefine r = {2u, 4u, 1u, DIS_MAX_LEVELS}; + const DisRefine r = {2u, 4u, 1u, DIS_VR_LEVELS}; return r; } - const DisRefine r = {1u, 3u, 1u, 1u}; + const DisRefine r = {1u, 3u, 1u, DIS_VR_LEVELS}; return r; } @@ -748,6 +764,7 @@ static void dis_write_all_descriptors(VkrDis* d) { memset(&b, 0, sizeof(b)); const uint32_t L = d->levels; const uint32_t coarse = L - 1; + const uint32_t vrL = L < DIS_VR_LEVELS ? L : DIS_VR_LEVELS; for (uint32_t s = 0; s < DIS_SLOTS; s++) { const uint32_t next = s; @@ -763,9 +780,13 @@ static void dis_write_all_descriptors(VkrDis* d) { dis_batch_sampled(d, &b, d->inverse_sets[s][l], 0, d->view_flow_luma[prev][l], d->sampler); dis_batch_sampled(d, &b, d->inverse_sets[s][l], 1, d->view_flow_luma[next][l], d->sampler); dis_batch_sampled(d, &b, d->inverse_sets[s][l], 2, d->view_grad[l], d->sampler); - dis_batch_sampled(d, &b, d->inverse_sets[s][l], 3, - d->view_flow_refined[l + 1 < L ? l + 1 : coarse], d->sampler); - dis_batch_sampled(d, &b, d->inverse_sets[s][l], 4, d->view_flow_refined[coarse], d->sampler); + // The coarse estimate is the refined flow where the level above was + // refined and the densified flow where the VR stage was skipped. + const uint32_t coarse_l = l + 1 < L ? l + 1 : coarse; + const VkImageView coarse_view = l + 1 < DIS_VR_LEVELS + ? d->view_flow_refined[coarse_l] : d->view_dense[coarse_l]; + dis_batch_sampled(d, &b, d->inverse_sets[s][l], 3, coarse_view, d->sampler); + dis_batch_sampled(d, &b, d->inverse_sets[s][l], 4, d->view_dense[coarse], d->sampler); dis_batch_storage(d, &b, d->inverse_sets[s][l], 5, d->view_sparse[l]); dis_batch_sampled(d, &b, d->prop_ab_sets[s][l], 0, d->view_flow_luma[prev][l], d->sampler); @@ -788,6 +809,7 @@ static void dis_write_all_descriptors(VkrDis* d) { dis_batch_sampled(d, &b, d->interp_sets[s][dir], 0, d->view_color[prev], d->sampler); dis_batch_sampled(d, &b, d->interp_sets[s][dir], 1, d->view_color[next], d->sampler); dis_batch_sampled(d, &b, d->interp_sets[s][dir], 2, d->view_flow_refined[0], d->sampler); + dis_batch_sampled(d, &b, d->interp_sets[s][dir], 3, d->view_side, d->sampler); dis_batch_sampled(d, &b, d->interp_sets[s][dir], 4, d->view_hist[dir], d->sampler); dis_batch_storage(d, &b, d->interp_sets[s][dir], 5, d->view_interp_out); @@ -797,7 +819,10 @@ static void dis_write_all_descriptors(VkrDis* d) { dis_batch_storage(d, &b, d->hist_sets[s][dir], DIS_VR_FIRST_STORAGE, d->view_hist[dir]); } - for (uint32_t l = 0; l < L; l++) { + dis_batch_sampled(d, &b, d->side_sets[s], 0, d->view_flow_refined[0], d->sampler); + dis_batch_storage(d, &b, d->side_sets[s], 5, d->view_side); + + for (uint32_t l = 0; l < vrL; l++) { dis_batch_sampled(d, &b, d->vr_prep_sets[s][l], 0, d->view_flow_color[prev][l], d->sampler); dis_batch_sampled(d, &b, d->vr_prep_sets[s][l], 1, d->view_flow_color[next][l], d->sampler); dis_batch_sampled(d, &b, d->vr_prep_sets[s][l], 2, d->view_dense[l], d->sampler); @@ -806,7 +831,7 @@ static void dis_write_all_descriptors(VkrDis* d) { } } - for (uint32_t l = 0; l < L; l++) { + for (uint32_t l = 0; l < vrL; l++) { dis_batch_sampled(d, &b, d->vr_d1_set[l], 0, d->view_vr_prep[l], d->sampler); dis_batch_storage(d, &b, d->vr_d1_set[l], DIS_VR_FIRST_STORAGE, d->view_vr_d1[l]); @@ -851,6 +876,7 @@ static void dis_destroy_views(VkrDis* d) { dis_destroy_view(d, &d->view_interp_out); dis_destroy_view(d, &d->view_hist[0]); dis_destroy_view(d, &d->view_hist[1]); + dis_destroy_view(d, &d->view_side); for (uint32_t l = 0; l < DIS_MAX_LEVELS; l++) { dis_destroy_view(d, &d->view_vr_prep[l]); dis_destroy_view(d, &d->view_vr_d1[l]); @@ -897,6 +923,7 @@ static void dis_destroy_images(VkrDis* d) { dis_destroy_image(d, &d->flow_refined); dis_destroy_image(d, &d->hist[0]); dis_destroy_image(d, &d->hist[1]); + dis_destroy_image(d, &d->side); } static bool dis_create_resources(VkrDis* d, uint32_t w, uint32_t h, uint32_t full_w, @@ -958,6 +985,8 @@ static bool dis_create_resources(VkrDis* d, uint32_t w, uint32_t h, uint32_t ful VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_STORAGE_BIT)) return false; if (!dis_create_image(d, &d->hist[1], hist_w, hist_h, VK_FORMAT_R32_SFLOAT, 1, VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_STORAGE_BIT)) return false; + if (!dis_create_image(d, &d->side, w, h, VK_FORMAT_R32_SFLOAT, 1, + VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_STORAGE_BIT)) return false; for (uint32_t s = 0; s < DIS_SLOTS; s++) { if (!dis_create_view(d, d->color[s].image, format, 0, 1, &d->view_color[s])) return false; @@ -986,6 +1015,7 @@ static bool dis_create_resources(VkrDis* d, uint32_t w, uint32_t h, uint32_t ful if (!dis_create_view(d, d->interp_out.image, VK_FORMAT_R8G8B8A8_UNORM, 0, 1, &d->view_interp_out)) return false; if (!dis_create_view(d, d->hist[0].image, VK_FORMAT_R32_SFLOAT, 0, 1, &d->view_hist[0])) return false; if (!dis_create_view(d, d->hist[1].image, VK_FORMAT_R32_SFLOAT, 0, 1, &d->view_hist[1])) return false; + if (!dis_create_view(d, d->side.image, VK_FORMAT_R32_SFLOAT, 0, 1, &d->view_side)) return false; vkr_dis_reset(d); dis_write_all_descriptors(d); @@ -1025,14 +1055,15 @@ static bool dis_allocate_sets(VkrDis* d) { } if (!dis_alloc(d, d->set_layout, 1, &d->interp_sets[s][0])) return false; if (!dis_alloc(d, d->set_layout, 1, &d->interp_sets[s][1])) return false; + if (!dis_alloc(d, d->set_layout, 1, &d->side_sets[s])) return false; if (!dis_alloc(d, d->vr_set_layout, 1, &d->hist_sets[s][0])) return false; if (!dis_alloc(d, d->vr_set_layout, 1, &d->hist_sets[s][1])) return false; - for (uint32_t l = 0; l < DIS_MAX_LEVELS; l++) { + for (uint32_t l = 0; l < DIS_VR_LEVELS; l++) { if (!dis_alloc(d, d->vr_set_layout, 1, &d->vr_prep_sets[s][l])) return false; } } - for (uint32_t l = 0; l < DIS_MAX_LEVELS; l++) { + for (uint32_t l = 0; l < DIS_VR_LEVELS; l++) { VkDescriptorSet vr_sets[DIS_VR_SHARED_SETS]; if (!dis_alloc(d, d->vr_set_layout, DIS_VR_SHARED_SETS, vr_sets)) return false; d->vr_d1_set[l] = vr_sets[0]; @@ -1701,7 +1732,9 @@ void vkr_dis_process(VkrDis* d, VkCommandBuffer cmd, VkImage source, uint32_t wi dis_compute_barrier(cmd); - dis_vr_level(d, cmd, slot, l, lw, lh, &refine, l < refine.vr_levels); + if (l < refine.vr_levels) { + dis_vr_level(d, cmd, slot, l, lw, lh, &refine, true); + } } if (generations > 0 || d->debug_flow) { @@ -1719,6 +1752,10 @@ void vkr_dis_process(VkrDis* d, VkCommandBuffer cmd, VkImage source, uint32_t wi dis_compute_barrier(cmd); d->hist_parity = hist_dir; d->hist_valid = true; + + // Which real frame a true occlusion takes, one value per level-0 texel. + dis_dispatch(d, cmd, d->pass_side.pipeline, d->side_sets[slot], w, h); + dis_compute_barrier(cmd); } } diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp b/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp index 11beff870..8e6323289 100644 --- a/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp +++ b/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp @@ -8,6 +8,7 @@ layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in; layout(set = 0, binding = 0) uniform sampler2D prevColor; layout(set = 0, binding = 1) uniform sampler2D nextColor; layout(set = 0, binding = 2) uniform sampler2D flowTex; +layout(set = 0, binding = 3) uniform sampler2D sideTex; layout(set = 0, binding = 4) uniform sampler2D histTex; layout(set = 0, binding = 5, rgba8) uniform image2D outImage; @@ -74,7 +75,7 @@ const float UI_FLOW_SKIP_PX = 0.25; // // 0, 2 or 4 taps; each tap is two full-res fetches, so four taps double this pass's // sampling. Drop to 2 for about two thirds of the gain at half the cost. -const int UI_DILATE_TAPS = 4; +const int UI_DILATE_TAPS = 2; const float UI_DILATE_PX = 1.5; // Channel difference, 0..1. LO is rgb8 quantisation plus mild dither; HI is where a @@ -199,20 +200,9 @@ void main() { vec3 single = pc.t < 0.5 ? c0 : c1; #if DIS_OCCL_SIDED - { - // Divergence over a wide window: a diverging field is area opening up, - // which exists only in the next frame; a converging one only in the - // previous. Spatially coherent and independent of t, so the outline of - // a static element does not swap sides between generated frames. - vec2 fts = 1.0 / vec2(textureSize(flowTex, 0)); - vec2 fo = fts * 4.0; - vec2 fL = sampleFlow(clamp(uv - vec2(fo.x, 0.0), vec2(0.0), vec2(1.0))); - vec2 fR = sampleFlow(clamp(uv + vec2(fo.x, 0.0), vec2(0.0), vec2(1.0))); - vec2 fU = sampleFlow(clamp(uv - vec2(0.0, fo.y), vec2(0.0), vec2(1.0))); - vec2 fD = sampleFlow(clamp(uv + vec2(0.0, fo.y), vec2(0.0), vec2(1.0))); - float divergence = (fR.x - fL.x) * float(size.x) + (fD.y - fU.y) * float(size.y); - single = divergence > 0.0 ? c1 : c0; - } + // The side choice is precomputed per flow texel by dis_side.comp, so this is + // one tap instead of four flow fetches and it does not change with t. + single = textureLod(sideTex, uv, 0.0).r > 0.5 ? c1 : c0; #endif const float FEATHER_PX = 8.0; diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_inverse_search.comp b/app/src/main/cpp/winlator/vk/shaders/dis_inverse_search.comp index b538489d6..e4268ec4b 100644 --- a/app/src/main/cpp/winlator/vk/shaders/dis_inverse_search.comp +++ b/app/src/main/cpp/winlator/vk/shaders/dis_inverse_search.comp @@ -3,7 +3,10 @@ precision highp float; precision highp int; -#define DIS_INVERSE_ITERS 8 +// Gauss-Newton iterations per patch. The loop already breaks once the SSD stops +// improving, so this only caps the hard cases; six reaches the same fixpoint +// there while cutting a quarter off the heaviest loop in the search. +#define DIS_INVERSE_ITERS 6 #define DIS_MAX_MATCH_RMS 36.0 diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_propagate.comp b/app/src/main/cpp/winlator/vk/shaders/dis_propagate.comp index e299aa1f3..3013a94f5 100644 --- a/app/src/main/cpp/winlator/vk/shaders/dis_propagate.comp +++ b/app/src/main/cpp/winlator/vk/shaders/dis_propagate.comp @@ -43,11 +43,16 @@ void main() { ivec2 denseMax = denseSize - 1; vec2 invImageSize = 1.0 / vec2(denseSize); - float refLum[64]; - for (int dy = 0; dy < 8; dy++) { - for (int dx = 0; dx < 8; dx++) { - ivec2 p = clamp(org + ivec2(dx, dy), ivec2(0), denseMax); - refLum[dy * 8 + dx] = texelFetch(lastLumaMap, p, 0).x; + // The candidates are ranked on a 4x4 subsample of the patch rather than all + // 64 texels. The comparison only picks between four nearby vectors, so the + // extra precision of the full patch does not change the winner often, and + // this pass was the most texture-fetch-heavy in the chain. + const float N = 16.0; + float refLum[16]; + for (int dy = 0; dy < 4; dy++) { + for (int dx = 0; dx < 4; dx++) { + ivec2 p = clamp(org + ivec2(dx * 2, dy * 2), ivec2(0), denseMax); + refLum[dy * 4 + dx] = texelFetch(lastLumaMap, p, 0).x; } } @@ -58,10 +63,10 @@ void main() { sd2[i] = 0.0; } - for (int dy = 0; dy < 8; dy++) { - for (int dx = 0; dx < 8; dx++) { - int i = dy * 8 + dx; - vec2 base = (vec2(org) + vec2(dx, dy) + 0.5) * invImageSize; + for (int dy = 0; dy < 4; dy++) { + for (int dx = 0; dx < 4; dx++) { + int i = dy * 4 + dx; + vec2 base = (vec2(org) + vec2(dx * 2, dy * 2) + 0.5) * invImageSize; float r = refLum[i]; if (needOwn) { @@ -78,10 +83,13 @@ void main() { } } + // The score is carried in the same units as the 64-sample scores the inverse + // search and the densification use, so the stored .z remains comparable. + const float SCORE_SCALE = 64.0 / 16.0; vec2 best = own; - float bestSsd = needOwn ? (sd2[0] - sd[0] * sd[0] / 64.0) : ownSsd; + float bestSsd = needOwn ? (sd2[0] - sd[0] * sd[0] / N) * SCORE_SCALE : ownSsd; for (int c = 0; c < candCount; c++) { - float ssd = sd2[c + 1] - sd[c + 1] * sd[c + 1] / 64.0; + float ssd = (sd2[c + 1] - sd[c + 1] * sd[c + 1] / N) * SCORE_SCALE; if (ssd < bestSsd) { bestSsd = ssd; best = cand[c]; diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_side.comp b/app/src/main/cpp/winlator/vk/shaders/dis_side.comp new file mode 100644 index 000000000..625d3a11d --- /dev/null +++ b/app/src/main/cpp/winlator/vk/shaders/dis_side.comp @@ -0,0 +1,34 @@ +#version 450 + +precision highp float; +precision highp int; + +// One bit per level-0 flow texel: which real frame a true occlusion should take. +// The sign of the flow divergence over a wide window decides it - a diverging +// field is area opening up, which only the next frame has, a converging one only +// the previous. Computed once per pair at the flow resolution, it replaces four +// bilinear flow fetches per output pixel in the interpolate pass and makes the +// choice identical for every generated frame of the pair. + +#define DIS_SIDE_RADIUS 4.0 + +layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in; + +layout(set = 0, binding = 0) uniform sampler2D flowMap; +layout(set = 0, binding = 5, r32f) uniform image2D sideOut; + +void main() { + ivec2 p = ivec2(gl_GlobalInvocationID.xy); + ivec2 sz = imageSize(sideOut); + if (p.x >= sz.x || p.y >= sz.y) return; + + vec2 uSize = vec2(sz); + vec2 uv = (vec2(p) + 0.5) / uSize; + vec2 fo = (1.0 / uSize) * DIS_SIDE_RADIUS; + vec2 fL = textureLod(flowMap, clamp(uv - vec2(fo.x, 0.0), vec2(0.0), vec2(1.0)), 0.0).xy; + vec2 fR = textureLod(flowMap, clamp(uv + vec2(fo.x, 0.0), vec2(0.0), vec2(1.0)), 0.0).xy; + vec2 fU = textureLod(flowMap, clamp(uv - vec2(0.0, fo.y), vec2(0.0), vec2(1.0)), 0.0).xy; + vec2 fD = textureLod(flowMap, clamp(uv + vec2(0.0, fo.y), vec2(0.0), vec2(1.0)), 0.0).xy; + float divergence = (fR.x - fL.x) * uSize.x + (fD.y - fU.y) * uSize.y; + imageStore(sideOut, p, vec4(divergence > 0.0 ? 1.0 : 0.0, 0.0, 0.0, 0.0)); +} From b9c53f59beff19b955ad7810e7d2b56c11e3e975 Mon Sep 17 00:00:00 2001 From: unknown <1qwertypower1@gmail.com> Date: Mon, 21 Sep 2026 01:12:45 +0300 Subject: [PATCH 08/17] DIS: load the search patch with textureGather quads The 8x8 luma/gradient patch is loaded as 4x4 quads, three gathers per quad instead of one fetch per texel - about three times fewer sample instructions in the setup, same texels. The gathered component order is documented in the shader; at clamped borders the edge texel repeats, as the per-texel clamp did before. --- .../vk/shaders/dis_inverse_search.comp | 44 ++++++++++++++----- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_inverse_search.comp b/app/src/main/cpp/winlator/vk/shaders/dis_inverse_search.comp index e4268ec4b..8748cb2c1 100644 --- a/app/src/main/cpp/winlator/vk/shaders/dis_inverse_search.comp +++ b/app/src/main/cpp/winlator/vk/shaders/dis_inverse_search.comp @@ -92,19 +92,41 @@ void main() { vec2 gradSum = vec2(0.0); mat2 H = mat2(0.0); + // The patch is loaded as 4x4 quads with textureGather: one instruction per + // component per quad instead of one fetch per texel, three times fewer + // sample instructions for the same texels. The gathered order is + // .w = (x, y), .z = (x+1, y), .x = (x, y+1), .y = (x+1, y+1) + // and the patch is indexed [x * 8 + y] to match the gradient descent loop. + vec2 invDense = 1.0 / vec2(denseSize); + for (int qy = 0; qy < 4; qy++) { + for (int qx = 0; qx < 4; qx++) { + ivec2 q = clamp(pix + ivec2(qx * 2, qy * 2), ivec2(0), denseMax); + vec2 corner = (vec2(q) + 1.0) * invDense; + vec4 lq = textureGather(lastLumaMap, corner, 0); + vec4 gx = textureGather(lastGradientMap, corner, 0); + vec4 gy = textureGather(lastGradientMap, corner, 1); + + int x0 = qx * 2; + int y0 = qy * 2; + lastImageData[x0 * 8 + y0] = lq.w; + lastImageData[(x0 + 1) * 8 + y0] = lq.z; + lastImageData[x0 * 8 + y0 + 1] = lq.x; + lastImageData[(x0 + 1) * 8 + y0 + 1] = lq.y; + + gradData[x0 * 8 + y0] = -vec2(gx.w, gy.w); + gradData[(x0 + 1) * 8 + y0] = -vec2(gx.z, gy.z); + gradData[x0 * 8 + y0 + 1] = -vec2(gx.x, gy.x); + gradData[(x0 + 1) * 8 + y0 + 1] = -vec2(gx.y, gy.y); + } + } + for (int i = 0; i < 8; i++) { for (int j = 0; j < 8; j++) { - ivec2 q = clamp(pix + ivec2(i, j), ivec2(0), denseMax); - gradData[i * 8 + j] = -texelFetch(lastGradientMap, q, 0).xy; - - H[0][0] += gradData[i * 8 + j].x * gradData[i * 8 + j].x; - H[1][1] += gradData[i * 8 + j].y * gradData[i * 8 + j].y; - H[0][1] += gradData[i * 8 + j].x * gradData[i * 8 + j].y; - - lastImageData[i * 8 + j] = - texelFetch(lastLumaMap, q, 0).x; - - gradSum += gradData[i * 8 + j]; + vec2 g = gradData[i * 8 + j]; + H[0][0] += g.x * g.x; + H[1][1] += g.y * g.y; + H[0][1] += g.x * g.y; + gradSum += g; } } From 87239abec333f4f7f686ded9fad098fb14429b3b Mon Sep 17 00:00:00 2001 From: unknown <1qwertypower1@gmail.com> Date: Mon, 21 Sep 2026 01:17:54 +0300 Subject: [PATCH 09/17] DIS: skip the warp path under a quarter-pixel of flow Under the same threshold the overlay pass-through already uses to skip its own fetches, the shifted samples cannot change an output pixel visibly, so the warped pair, the side map and the edge dilation are all dropped and the real pair is blended directly. Static scenery and HUD-heavy screens are mostly such pixels, and this pass is the most expensive one per output pixel. --- .../winlator/vk/shaders/dis_interpolate.comp | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp b/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp index 8e6323289..7ddcb1d61 100644 --- a/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp +++ b/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp @@ -192,6 +192,38 @@ void main() { return; } + float flowPx = length(f * vec2(size)); + + // Under a quarter of a pixel the shift cannot change an output pixel visibly - + // the same premise the overlay pass-through uses when it skips its own fetches + // at this threshold. The warped pair, the side map and the edge dilation are + // then all dropped and the real pair is blended directly. Static scenery and + // HUD-heavy screens are largely made of such pixels, and this pass costs the + // most per output pixel in the chain. + if (flowPx <= UI_FLOW_SKIP_PX) { + vec3 e0 = textureLod(prevColor, uv, 0.0).xyz; + vec3 e1 = textureLod(nextColor, uv, 0.0).xyz; + vec3 still = mix(e0, e1, pc.t); + float disagree = maxChannel(abs(e0 - e1)); + float pick = smoothstep(BLEND_LO, BLEND_HI, disagree); + vec3 result = mix(still, pc.t < 0.5 ? e0 : e1, pick); + + float uiMask = UI_STRENGTH * (1.0 - smoothstep(UI_LO, UI_HI, disagree)); +#if UI_HIST_ENABLE + float histStatic = textureLod(histTex, uv, 0.0).r; + histStatic *= 1.0 - smoothstep(0.30, 0.60, disagree); + uiMask = max(uiMask, histStatic); +#endif + result = mix(result, still, uiMask); + + bool leftHalfE = uint(pix.x) * 2u < gl_NumWorkGroups.x * gl_WorkGroupSize.x; + if (DEBUG_UI_MASK == 1 || (DEBUG_UI_MASK == 2 && leftHalfE)) { + result = mix(result * 0.15, vec3(0.1, 1.0, 0.2), uiMask); + } + imageStore(outImage, pix, vec4(result, 1.0)); + return; + } + vec2 uv0 = uv - pc.t * f; vec2 uv1 = uv + (1.0 - pc.t) * f; @@ -235,7 +267,7 @@ void main() { float uiMask = 0.0; vec3 uiColor = mix(s0, s1, pc.t); - if (UI_STRENGTH > 0.0 && length(f * vec2(size)) > UI_FLOW_SKIP_PX) { + if (UI_STRENGTH > 0.0 && flowPx > UI_FLOW_SKIP_PX) { float rStatic = pairDiffHere; if (UI_DILATE_TAPS > 0) { From d1101859dfaa24c3f93a886d6ddf981e65339283 Mon Sep 17 00:00:00 2001 From: unknown <1qwertypower1@gmail.com> Date: Mon, 21 Sep 2026 01:33:50 +0300 Subject: [PATCH 10/17] DIS: restore refinement on every level and steady the plan - the variational refinement runs on all levels again: at 4x the fast motion needed the coarse levels, and the three-level scope showed up as objects that looked uninterpolated - source-rate estimate: longer smoothing window and sub-2ms bursts rejected, so a single startup burst no longer spikes it to hundreds of fps - ratio hysteresis widened so the planner stops oscillating between 2 and 3 generated frames while the source rate drifts --- app/src/main/cpp/winlator/vk/dis/vkr_dis.c | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/app/src/main/cpp/winlator/vk/dis/vkr_dis.c b/app/src/main/cpp/winlator/vk/dis/vkr_dis.c index f3f42fb58..08bd7143c 100644 --- a/app/src/main/cpp/winlator/vk/dis/vkr_dis.c +++ b/app/src/main/cpp/winlator/vk/dis/vkr_dis.c @@ -43,7 +43,7 @@ #define DIS_PROP_STEPS_MAX 4u -#define DIS_SRC_SMOOTHING 0.15f +#define DIS_SRC_SMOOTHING 0.08f #define DIS_SRC_STALE_NS 500000000ull #define DIS_MIN_RATE_SAMPLES 12u @@ -53,7 +53,7 @@ #define DIS_MIN_GEN_RATIO 1.45f -#define DIS_RATIO_HYST 0.05f +#define DIS_RATIO_HYST 0.10f #define DIS_VR_ALPHA 20.0f #define DIS_VR_DELTA 5.0f @@ -70,7 +70,11 @@ // coarse levels bought very little while their prep/add dispatches and solver // sweeps dominated the pass count. Levels without refinement skip the VR stage // entirely and the search reads the densified flow instead. -#define DIS_VR_LEVELS 3u +// +// Restored to every level: fast motion showed the coarse levels were carrying +// more of the flow than the dispatch saving was worth, and the refinement has +// to be there for large displacements to come out of the pyramid cleanly. +#define DIS_VR_LEVELS 8u #define DIS_SET_SAMPLERS 5u @@ -1389,6 +1393,11 @@ static void dis_track_source(VkrDis* d, uint64_t now, uint64_t source_frames) { const uint64_t dt = now - d->src_sample_ns; if (dt == 0) return; + // A burst of presents can land inside one millisecond (startup, alt-tab) and + // a single such sample spikes the rate estimate to hundreds of fps, which + // then walks the planner up and down the generation ladder. Let the window + // span the burst instead. + if (dt < 2000000ull) return; d->src_sample_ns = now; const uint64_t drawn = From d5d18dbaf73cea7fe64572dad6329be22540e886 Mon Sep 17 00:00:00 2001 From: unknown <1qwertypower1@gmail.com> Date: Mon, 21 Sep 2026 01:36:27 +0300 Subject: [PATCH 11/17] DIS: dilate the overlay mask over four taps again The two-tap dilation saved a few fetches but left antialiased overlay edges uncovered under scene motion - the edge pixel is a blend of glyph and moving background, so only its neighbours carry the pair agreement. Four taps over 1.5px is the point the original analysis measured as the useful limit, and refining every level stays as well: the alternatives either save a couple of dispatches or give back the fast-motion smoothness. --- app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp b/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp index 7ddcb1d61..2660369f0 100644 --- a/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp +++ b/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp @@ -75,7 +75,7 @@ const float UI_FLOW_SKIP_PX = 0.25; // // 0, 2 or 4 taps; each tap is two full-res fetches, so four taps double this pass's // sampling. Drop to 2 for about two thirds of the gain at half the cost. -const int UI_DILATE_TAPS = 2; +const int UI_DILATE_TAPS = 4; const float UI_DILATE_PX = 1.5; // Channel difference, 0..1. LO is rgb8 quantisation plus mild dither; HI is where a From 7f2a5c3175eb8a8e9ed7cb5a3e90b0923af74d8d Mon Sep 17 00:00:00 2001 From: unknown <1qwertypower1@gmail.com> Date: Mon, 21 Sep 2026 01:40:24 +0300 Subject: [PATCH 12/17] DIS: nudge the 4x path towards interpolation - blend pick: thresholds raised (0.02..0.12 -> 0.03..0.18) so a moderately wrong flow keeps the warped blend instead of snapping to one real frame, which is what read as fast objects not being interpolated - 4x generation gets a third fixed-point pass on the finest level, so the warped pair lines up more often and fewer pixels reach the pick at all --- app/src/main/cpp/winlator/vk/dis/vkr_dis.c | 4 +++- app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/src/main/cpp/winlator/vk/dis/vkr_dis.c b/app/src/main/cpp/winlator/vk/dis/vkr_dis.c index 08bd7143c..594a98e7f 100644 --- a/app/src/main/cpp/winlator/vk/dis/vkr_dis.c +++ b/app/src/main/cpp/winlator/vk/dis/vkr_dis.c @@ -709,7 +709,9 @@ typedef struct { static DisRefine dis_refine_for(uint32_t generations) { if (generations >= 3u) { - const DisRefine r = {2u, 5u, 2u, DIS_VR_LEVELS}; + // Three fixed-point passes on the finest level for the 4x path: the warped + // samples line up better, so fewer pixels fall to the single-frame pick. + const DisRefine r = {3u, 5u, 2u, DIS_VR_LEVELS}; return r; } if (generations == 2u) { diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp b/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp index 2660369f0..a9ec0d0a3 100644 --- a/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp +++ b/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp @@ -127,8 +127,8 @@ const int DEBUG_UI_MASK = 0; // vector that was wrong, so the generated frame sits closer to a repeat of a real one. // Raise BLEND_LO towards the old behaviour if that trade reads worse than the softness. // --------------------------------------------------------------------------- -const float BLEND_LO = 0.020; -const float BLEND_HI = 0.120; +const float BLEND_LO = 0.030; +const float BLEND_HI = 0.180; vec2 sampleFlow(vec2 uv) { if (manualFlowFilter == 0) return textureLod(flowTex, uv, 0.0).xy; From 6ebe4fe9e671741c5f8d7e95f22b7d18e7b2c88d Mon Sep 17 00:00:00 2001 From: unknown <1qwertypower1@gmail.com> Date: Mon, 21 Sep 2026 01:45:35 +0300 Subject: [PATCH 13/17] DIS: add GPL-3.0 license headers crediting qwertypower (DEVAR Entertainment LLC) --- app/src/main/cpp/winlator/vk/dis/vkr_dis.c | 8 ++++++++ app/src/main/cpp/winlator/vk/dis/vkr_dis.h | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/app/src/main/cpp/winlator/vk/dis/vkr_dis.c b/app/src/main/cpp/winlator/vk/dis/vkr_dis.c index 594a98e7f..0e8764959 100644 --- a/app/src/main/cpp/winlator/vk/dis/vkr_dis.c +++ b/app/src/main/cpp/winlator/vk/dis/vkr_dis.c @@ -1,3 +1,11 @@ +// SPDX-FileCopyrightText: Copyright 2026 qwertypower (DEVAR Entertainment LLC) +// SPDX-License-Identifier: GPL-3.0-or-later +// +// DIS frame generation: a Vulkan compute realisation of Dense Inverse Search +// optical flow. The algorithm and its reference implementation come from +// OpenCV's DISOpticalFlow, which adopted Till Kroeger's original OF_DIS. +// See CREDITS.md for the full attribution. + #include "vkr_dis.h" #include "../vk_dispatch.h" diff --git a/app/src/main/cpp/winlator/vk/dis/vkr_dis.h b/app/src/main/cpp/winlator/vk/dis/vkr_dis.h index 146fa190d..ee3eacc68 100644 --- a/app/src/main/cpp/winlator/vk/dis/vkr_dis.h +++ b/app/src/main/cpp/winlator/vk/dis/vkr_dis.h @@ -1,3 +1,11 @@ +// SPDX-FileCopyrightText: Copyright 2026 qwertypower (DEVAR Entertainment LLC) +// SPDX-License-Identifier: GPL-3.0-or-later +// +// DIS frame generation: a Vulkan compute realisation of Dense Inverse Search +// optical flow. The algorithm and its reference implementation come from +// OpenCV's DISOpticalFlow, which adopted Till Kroeger's original OF_DIS. +// See CREDITS.md for the full attribution. + #pragma once #include From 86ed508a86576d77c3f8952678ffa2fdbb40c751 Mon Sep 17 00:00:00 2001 From: unknown <1qwertypower1@gmail.com> Date: Mon, 21 Sep 2026 01:47:17 +0300 Subject: [PATCH 14/17] DIS: add GPL-3.0 license headers to the shaders SPDX headers crediting qwertypower (DEVAR Entertainment LLC), same as the C sources. --- app/src/main/cpp/winlator/vk/shaders/dis_densify.comp | 8 ++++++++ app/src/main/cpp/winlator/vk/shaders/dis_gradient.comp | 8 ++++++++ app/src/main/cpp/winlator/vk/shaders/dis_hist.comp | 8 ++++++++ app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp | 8 ++++++++ .../main/cpp/winlator/vk/shaders/dis_inverse_search.comp | 8 ++++++++ app/src/main/cpp/winlator/vk/shaders/dis_luma.comp | 8 ++++++++ app/src/main/cpp/winlator/vk/shaders/dis_luma_r16.comp | 8 ++++++++ app/src/main/cpp/winlator/vk/shaders/dis_luma_r32.comp | 8 ++++++++ app/src/main/cpp/winlator/vk/shaders/dis_propagate.comp | 8 ++++++++ app/src/main/cpp/winlator/vk/shaders/dis_side.comp | 8 ++++++++ app/src/main/cpp/winlator/vk/shaders/dis_vr_add.comp | 8 ++++++++ app/src/main/cpp/winlator/vk/shaders/dis_vr_coef.comp | 8 ++++++++ app/src/main/cpp/winlator/vk/shaders/dis_vr_d1.comp | 8 ++++++++ app/src/main/cpp/winlator/vk/shaders/dis_vr_d2.comp | 8 ++++++++ app/src/main/cpp/winlator/vk/shaders/dis_vr_prep.comp | 8 ++++++++ app/src/main/cpp/winlator/vk/shaders/dis_vr_sor.comp | 8 ++++++++ app/src/main/cpp/winlator/vk/shaders/dis_vr_w.comp | 8 ++++++++ 17 files changed, 136 insertions(+) diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_densify.comp b/app/src/main/cpp/winlator/vk/shaders/dis_densify.comp index e667e78ff..ca16f1624 100644 --- a/app/src/main/cpp/winlator/vk/shaders/dis_densify.comp +++ b/app/src/main/cpp/winlator/vk/shaders/dis_densify.comp @@ -1,3 +1,11 @@ +// SPDX-FileCopyrightText: Copyright 2026 qwertypower (DEVAR Entertainment LLC) +// SPDX-License-Identifier: GPL-3.0-or-later +// +// DIS frame generation: a Vulkan compute realisation of Dense Inverse Search +// optical flow. The algorithm and its reference implementation come from +// OpenCV's DISOpticalFlow, which adopted Till Kroeger's original OF_DIS. +// See CREDITS.md for the full attribution. + #version 450 precision highp float; diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_gradient.comp b/app/src/main/cpp/winlator/vk/shaders/dis_gradient.comp index c99524d35..1f54ddd55 100644 --- a/app/src/main/cpp/winlator/vk/shaders/dis_gradient.comp +++ b/app/src/main/cpp/winlator/vk/shaders/dis_gradient.comp @@ -1,3 +1,11 @@ +// SPDX-FileCopyrightText: Copyright 2026 qwertypower (DEVAR Entertainment LLC) +// SPDX-License-Identifier: GPL-3.0-or-later +// +// DIS frame generation: a Vulkan compute realisation of Dense Inverse Search +// optical flow. The algorithm and its reference implementation come from +// OpenCV's DISOpticalFlow, which adopted Till Kroeger's original OF_DIS. +// See CREDITS.md for the full attribution. + #version 450 precision highp float; diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_hist.comp b/app/src/main/cpp/winlator/vk/shaders/dis_hist.comp index 2985de817..9848c0edc 100644 --- a/app/src/main/cpp/winlator/vk/shaders/dis_hist.comp +++ b/app/src/main/cpp/winlator/vk/shaders/dis_hist.comp @@ -1,3 +1,11 @@ +// SPDX-FileCopyrightText: Copyright 2026 qwertypower (DEVAR Entertainment LLC) +// SPDX-License-Identifier: GPL-3.0-or-later +// +// DIS frame generation: a Vulkan compute realisation of Dense Inverse Search +// optical flow. The algorithm and its reference implementation come from +// OpenCV's DISOpticalFlow, which adopted Till Kroeger's original OF_DIS. +// See CREDITS.md for the full attribution. + #version 450 precision highp float; diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp b/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp index a9ec0d0a3..5b8993973 100644 --- a/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp +++ b/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp @@ -1,3 +1,11 @@ +// SPDX-FileCopyrightText: Copyright 2026 qwertypower (DEVAR Entertainment LLC) +// SPDX-License-Identifier: GPL-3.0-or-later +// +// DIS frame generation: a Vulkan compute realisation of Dense Inverse Search +// optical flow. The algorithm and its reference implementation come from +// OpenCV's DISOpticalFlow, which adopted Till Kroeger's original OF_DIS. +// See CREDITS.md for the full attribution. + #version 450 precision highp float; diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_inverse_search.comp b/app/src/main/cpp/winlator/vk/shaders/dis_inverse_search.comp index 8748cb2c1..84d70044e 100644 --- a/app/src/main/cpp/winlator/vk/shaders/dis_inverse_search.comp +++ b/app/src/main/cpp/winlator/vk/shaders/dis_inverse_search.comp @@ -1,3 +1,11 @@ +// SPDX-FileCopyrightText: Copyright 2026 qwertypower (DEVAR Entertainment LLC) +// SPDX-License-Identifier: GPL-3.0-or-later +// +// DIS frame generation: a Vulkan compute realisation of Dense Inverse Search +// optical flow. The algorithm and its reference implementation come from +// OpenCV's DISOpticalFlow, which adopted Till Kroeger's original OF_DIS. +// See CREDITS.md for the full attribution. + #version 450 precision highp float; diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_luma.comp b/app/src/main/cpp/winlator/vk/shaders/dis_luma.comp index 70abe943f..e3d7b9487 100644 --- a/app/src/main/cpp/winlator/vk/shaders/dis_luma.comp +++ b/app/src/main/cpp/winlator/vk/shaders/dis_luma.comp @@ -1,3 +1,11 @@ +// SPDX-FileCopyrightText: Copyright 2026 qwertypower (DEVAR Entertainment LLC) +// SPDX-License-Identifier: GPL-3.0-or-later +// +// DIS frame generation: a Vulkan compute realisation of Dense Inverse Search +// optical flow. The algorithm and its reference implementation come from +// OpenCV's DISOpticalFlow, which adopted Till Kroeger's original OF_DIS. +// See CREDITS.md for the full attribution. + #version 450 precision highp float; diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_luma_r16.comp b/app/src/main/cpp/winlator/vk/shaders/dis_luma_r16.comp index cf39c1a06..c268438d0 100644 --- a/app/src/main/cpp/winlator/vk/shaders/dis_luma_r16.comp +++ b/app/src/main/cpp/winlator/vk/shaders/dis_luma_r16.comp @@ -1,3 +1,11 @@ +// SPDX-FileCopyrightText: Copyright 2026 qwertypower (DEVAR Entertainment LLC) +// SPDX-License-Identifier: GPL-3.0-or-later +// +// DIS frame generation: a Vulkan compute realisation of Dense Inverse Search +// optical flow. The algorithm and its reference implementation come from +// OpenCV's DISOpticalFlow, which adopted Till Kroeger's original OF_DIS. +// See CREDITS.md for the full attribution. + #version 450 precision highp float; diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_luma_r32.comp b/app/src/main/cpp/winlator/vk/shaders/dis_luma_r32.comp index d2e2ca369..f8bc8c3b9 100644 --- a/app/src/main/cpp/winlator/vk/shaders/dis_luma_r32.comp +++ b/app/src/main/cpp/winlator/vk/shaders/dis_luma_r32.comp @@ -1,3 +1,11 @@ +// SPDX-FileCopyrightText: Copyright 2026 qwertypower (DEVAR Entertainment LLC) +// SPDX-License-Identifier: GPL-3.0-or-later +// +// DIS frame generation: a Vulkan compute realisation of Dense Inverse Search +// optical flow. The algorithm and its reference implementation come from +// OpenCV's DISOpticalFlow, which adopted Till Kroeger's original OF_DIS. +// See CREDITS.md for the full attribution. + #version 450 precision highp float; diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_propagate.comp b/app/src/main/cpp/winlator/vk/shaders/dis_propagate.comp index 3013a94f5..9b79bfdba 100644 --- a/app/src/main/cpp/winlator/vk/shaders/dis_propagate.comp +++ b/app/src/main/cpp/winlator/vk/shaders/dis_propagate.comp @@ -1,3 +1,11 @@ +// SPDX-FileCopyrightText: Copyright 2026 qwertypower (DEVAR Entertainment LLC) +// SPDX-License-Identifier: GPL-3.0-or-later +// +// DIS frame generation: a Vulkan compute realisation of Dense Inverse Search +// optical flow. The algorithm and its reference implementation come from +// OpenCV's DISOpticalFlow, which adopted Till Kroeger's original OF_DIS. +// See CREDITS.md for the full attribution. + #version 450 precision highp float; diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_side.comp b/app/src/main/cpp/winlator/vk/shaders/dis_side.comp index 625d3a11d..4500e6031 100644 --- a/app/src/main/cpp/winlator/vk/shaders/dis_side.comp +++ b/app/src/main/cpp/winlator/vk/shaders/dis_side.comp @@ -1,3 +1,11 @@ +// SPDX-FileCopyrightText: Copyright 2026 qwertypower (DEVAR Entertainment LLC) +// SPDX-License-Identifier: GPL-3.0-or-later +// +// DIS frame generation: a Vulkan compute realisation of Dense Inverse Search +// optical flow. The algorithm and its reference implementation come from +// OpenCV's DISOpticalFlow, which adopted Till Kroeger's original OF_DIS. +// See CREDITS.md for the full attribution. + #version 450 precision highp float; diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_vr_add.comp b/app/src/main/cpp/winlator/vk/shaders/dis_vr_add.comp index cba444082..07500d9c9 100644 --- a/app/src/main/cpp/winlator/vk/shaders/dis_vr_add.comp +++ b/app/src/main/cpp/winlator/vk/shaders/dis_vr_add.comp @@ -1,3 +1,11 @@ +// SPDX-FileCopyrightText: Copyright 2026 qwertypower (DEVAR Entertainment LLC) +// SPDX-License-Identifier: GPL-3.0-or-later +// +// DIS frame generation: a Vulkan compute realisation of Dense Inverse Search +// optical flow. The algorithm and its reference implementation come from +// OpenCV's DISOpticalFlow, which adopted Till Kroeger's original OF_DIS. +// See CREDITS.md for the full attribution. + #version 450 precision highp float; diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_vr_coef.comp b/app/src/main/cpp/winlator/vk/shaders/dis_vr_coef.comp index f2a9e4b9d..f0c08296b 100644 --- a/app/src/main/cpp/winlator/vk/shaders/dis_vr_coef.comp +++ b/app/src/main/cpp/winlator/vk/shaders/dis_vr_coef.comp @@ -1,3 +1,11 @@ +// SPDX-FileCopyrightText: Copyright 2026 qwertypower (DEVAR Entertainment LLC) +// SPDX-License-Identifier: GPL-3.0-or-later +// +// DIS frame generation: a Vulkan compute realisation of Dense Inverse Search +// optical flow. The algorithm and its reference implementation come from +// OpenCV's DISOpticalFlow, which adopted Till Kroeger's original OF_DIS. +// See CREDITS.md for the full attribution. + #version 450 precision highp float; diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_vr_d1.comp b/app/src/main/cpp/winlator/vk/shaders/dis_vr_d1.comp index bf66b5a6d..3031b5ded 100644 --- a/app/src/main/cpp/winlator/vk/shaders/dis_vr_d1.comp +++ b/app/src/main/cpp/winlator/vk/shaders/dis_vr_d1.comp @@ -1,3 +1,11 @@ +// SPDX-FileCopyrightText: Copyright 2026 qwertypower (DEVAR Entertainment LLC) +// SPDX-License-Identifier: GPL-3.0-or-later +// +// DIS frame generation: a Vulkan compute realisation of Dense Inverse Search +// optical flow. The algorithm and its reference implementation come from +// OpenCV's DISOpticalFlow, which adopted Till Kroeger's original OF_DIS. +// See CREDITS.md for the full attribution. + #version 450 precision highp float; diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_vr_d2.comp b/app/src/main/cpp/winlator/vk/shaders/dis_vr_d2.comp index e1861a00f..45d7804b2 100644 --- a/app/src/main/cpp/winlator/vk/shaders/dis_vr_d2.comp +++ b/app/src/main/cpp/winlator/vk/shaders/dis_vr_d2.comp @@ -1,3 +1,11 @@ +// SPDX-FileCopyrightText: Copyright 2026 qwertypower (DEVAR Entertainment LLC) +// SPDX-License-Identifier: GPL-3.0-or-later +// +// DIS frame generation: a Vulkan compute realisation of Dense Inverse Search +// optical flow. The algorithm and its reference implementation come from +// OpenCV's DISOpticalFlow, which adopted Till Kroeger's original OF_DIS. +// See CREDITS.md for the full attribution. + #version 450 precision highp float; diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_vr_prep.comp b/app/src/main/cpp/winlator/vk/shaders/dis_vr_prep.comp index b5cfb6088..7d64a0885 100644 --- a/app/src/main/cpp/winlator/vk/shaders/dis_vr_prep.comp +++ b/app/src/main/cpp/winlator/vk/shaders/dis_vr_prep.comp @@ -1,3 +1,11 @@ +// SPDX-FileCopyrightText: Copyright 2026 qwertypower (DEVAR Entertainment LLC) +// SPDX-License-Identifier: GPL-3.0-or-later +// +// DIS frame generation: a Vulkan compute realisation of Dense Inverse Search +// optical flow. The algorithm and its reference implementation come from +// OpenCV's DISOpticalFlow, which adopted Till Kroeger's original OF_DIS. +// See CREDITS.md for the full attribution. + #version 450 precision highp float; diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_vr_sor.comp b/app/src/main/cpp/winlator/vk/shaders/dis_vr_sor.comp index b407df553..d4463639f 100644 --- a/app/src/main/cpp/winlator/vk/shaders/dis_vr_sor.comp +++ b/app/src/main/cpp/winlator/vk/shaders/dis_vr_sor.comp @@ -1,3 +1,11 @@ +// SPDX-FileCopyrightText: Copyright 2026 qwertypower (DEVAR Entertainment LLC) +// SPDX-License-Identifier: GPL-3.0-or-later +// +// DIS frame generation: a Vulkan compute realisation of Dense Inverse Search +// optical flow. The algorithm and its reference implementation come from +// OpenCV's DISOpticalFlow, which adopted Till Kroeger's original OF_DIS. +// See CREDITS.md for the full attribution. + #version 450 precision highp float; diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_vr_w.comp b/app/src/main/cpp/winlator/vk/shaders/dis_vr_w.comp index 685a1ede3..b0536292e 100644 --- a/app/src/main/cpp/winlator/vk/shaders/dis_vr_w.comp +++ b/app/src/main/cpp/winlator/vk/shaders/dis_vr_w.comp @@ -1,3 +1,11 @@ +// SPDX-FileCopyrightText: Copyright 2026 qwertypower (DEVAR Entertainment LLC) +// SPDX-License-Identifier: GPL-3.0-or-later +// +// DIS frame generation: a Vulkan compute realisation of Dense Inverse Search +// optical flow. The algorithm and its reference implementation come from +// OpenCV's DISOpticalFlow, which adopted Till Kroeger's original OF_DIS. +// See CREDITS.md for the full attribution. + #version 450 precision highp float; From 091b4e71fb62af77354980366a46104a3b680705 Mon Sep 17 00:00:00 2001 From: unknown <1qwertypower1@gmail.com> Date: Tue, 22 Sep 2026 00:14:48 +0300 Subject: [PATCH 15/17] DIS: temporally smooth the flow and interpolate along a parabola - dis_temporal.comp: EMA over the level-0 field with an advection gate, ping-pong RGBA32F where xy is this pair's chord and zw the previous pair's for the same content; jitter on long runs roughly halves - dis_interpolate.comp: follow a parabola through three consecutive real frames instead of the straight chord, falling back to the chord by construction where the gate found no usable history - dis_vr_add.comp: clamp the absolute flow magnitude to a quarter of the frame instead of the never-firing four-width component guard - vkr_dis.c / CMakeLists: pipeline, ping-pong images, descriptor sets and dispatch for the temporal pass --- app/src/main/cpp/CMakeLists.txt | 1 + app/src/main/cpp/winlator/vk/dis/vkr_dis.c | 73 +++++- .../winlator/vk/shaders/dis_interpolate.comp | 223 ++++++++++++++++-- .../cpp/winlator/vk/shaders/dis_temporal.comp | 128 ++++++++++ .../cpp/winlator/vk/shaders/dis_vr_add.comp | 22 +- 5 files changed, 417 insertions(+), 30 deletions(-) create mode 100644 app/src/main/cpp/winlator/vk/shaders/dis_temporal.comp diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt index 9fbb004ff..160c25e99 100644 --- a/app/src/main/cpp/CMakeLists.txt +++ b/app/src/main/cpp/CMakeLists.txt @@ -111,6 +111,7 @@ set(SHADER_LIST "dis_interpolate:comp:dis_interpolate_comp" "dis_hist:comp:dis_hist_comp" "dis_side:comp:dis_side_comp" + "dis_temporal:comp:dis_temporal_comp" "dis_vr_prep:comp:dis_vr_prep_comp" "dis_vr_d1:comp:dis_vr_d1_comp" "dis_vr_d2:comp:dis_vr_d2_comp" diff --git a/app/src/main/cpp/winlator/vk/dis/vkr_dis.c b/app/src/main/cpp/winlator/vk/dis/vkr_dis.c index 0e8764959..eff38c970 100644 --- a/app/src/main/cpp/winlator/vk/dis/vkr_dis.c +++ b/app/src/main/cpp/winlator/vk/dis/vkr_dis.c @@ -18,6 +18,7 @@ #include "shaders/dis_interpolate_comp.spv.h" #include "shaders/dis_hist_comp.spv.h" #include "shaders/dis_side_comp.spv.h" +#include "shaders/dis_temporal_comp.spv.h" #include "shaders/dis_vr_prep_comp.spv.h" #include "shaders/dis_vr_d1_comp.spv.h" #include "shaders/dis_vr_d2_comp.spv.h" @@ -153,6 +154,7 @@ struct VkrDis { DisImage flow_refined; DisImage hist[2]; DisImage side; + DisImage flow_smooth[2]; VkImageView view_color[DIS_SLOTS]; VkImageView view_flow_color[DIS_SLOTS][DIS_MAX_LEVELS]; @@ -172,6 +174,7 @@ struct VkrDis { VkImageView view_flow_refined[DIS_MAX_LEVELS]; VkImageView view_hist[2]; VkImageView view_side; + VkImageView view_flow_smooth[2]; VkSampler sampler; @@ -187,6 +190,7 @@ struct VkrDis { VkDescriptorSet interp_sets[DIS_SLOTS][2]; VkDescriptorSet hist_sets[DIS_SLOTS][2]; VkDescriptorSet side_sets[DIS_SLOTS]; + VkDescriptorSet temporal_sets[2]; VkDescriptorSetLayout vr_set_layout; VkPipelineLayout vr_pipeline_layout; @@ -207,6 +211,7 @@ struct VkrDis { DisPass pass_interp; DisPass pass_hist; DisPass pass_side; + DisPass pass_temporal; DisPass pass_vr_prep; DisPass pass_vr_d1; DisPass pass_vr_d2; @@ -350,6 +355,8 @@ static uint32_t dis_collect_images(VkrDis* d, DisImage** out, uint32_t cap) { DIS_PUSH(&d->hist[0]); DIS_PUSH(&d->hist[1]); DIS_PUSH(&d->side); + DIS_PUSH(&d->flow_smooth[0]); + DIS_PUSH(&d->flow_smooth[1]); #undef DIS_PUSH return n; } @@ -548,7 +555,8 @@ static bool dis_create_pipelines(VkrDis* d) { const uint32_t shared_sets = DIS_SLOTS * DIS_MAX_LEVELS * DIS_SHARED_SETS_PER_LEVEL + DIS_SLOTS * 2u // interpolation sets, one per history direction - + DIS_SLOTS; // side-map sets + + DIS_SLOTS // side-map sets + + 2u; // temporal flow sets, one per direction // VR sets exist only for the levels the refinement actually runs on. const uint32_t vr_sets = (DIS_SLOTS + DIS_VR_SHARED_SETS) * DIS_VR_LEVELS; @@ -650,12 +658,14 @@ static bool dis_create_pipelines(VkrDis* d) { d->pass_vr_add.pipeline = dis_create_compute_pipeline_with_layout(d, dis_vr_add_comp, dis_vr_add_comp_size, d->vr_pipeline_layout, NULL); d->pass_hist.pipeline = dis_create_compute_pipeline_with_layout(d, dis_hist_comp, dis_hist_comp_size, d->vr_pipeline_layout, NULL); d->pass_side.pipeline = dis_create_compute_pipeline(d, dis_side_comp, dis_side_comp_size); + d->pass_temporal.pipeline = dis_create_compute_pipeline(d, dis_temporal_comp, dis_temporal_comp_size); if (!d->pass_gradient.pipeline || !d->pass_inverse.pipeline || !d->pass_propagate.pipeline || !d->pass_densify.pipeline || !d->pass_interp.pipeline || !d->pass_vr_prep.pipeline || !d->pass_vr_d1.pipeline || !d->pass_vr_d2.pipeline || !d->pass_vr_w.pipeline || !d->pass_vr_coef.pipeline || !d->pass_vr_sor.pipeline || - !d->pass_vr_add.pipeline || !d->pass_hist.pipeline || !d->pass_side.pipeline) { + !d->pass_vr_add.pipeline || !d->pass_hist.pipeline || !d->pass_side.pipeline || + !d->pass_temporal.pipeline) { return false; } return true; @@ -717,9 +727,13 @@ typedef struct { static DisRefine dis_refine_for(uint32_t generations) { if (generations >= 3u) { - // Three fixed-point passes on the finest level for the 4x path: the warped - // samples line up better, so fewer pixels fall to the single-frame pick. - const DisRefine r = {3u, 5u, 2u, DIS_VR_LEVELS}; + // The 4x path gets the largest refinement budget in the ladder, because its + // whole cost lands ONCE PER SOURCE PAIR while the interpolate pass runs three + // times. At a 30 fps source that is a 33 ms budget for a 320x180 pyramid, so four + // fixed-point passes and seven SOR sweeps on the finest level are a handful of + // small dispatches - paid for by a field that is closer to right, which is the one + // improvement that costs no softness anywhere. + const DisRefine r = {4u, 7u, 2u, DIS_VR_LEVELS}; return r; } if (generations == 2u) { @@ -822,7 +836,7 @@ static void dis_write_all_descriptors(VkrDis* d) { for (uint32_t dir = 0; dir < 2u; dir++) { dis_batch_sampled(d, &b, d->interp_sets[s][dir], 0, d->view_color[prev], d->sampler); dis_batch_sampled(d, &b, d->interp_sets[s][dir], 1, d->view_color[next], d->sampler); - dis_batch_sampled(d, &b, d->interp_sets[s][dir], 2, d->view_flow_refined[0], d->sampler); + dis_batch_sampled(d, &b, d->interp_sets[s][dir], 2, d->view_flow_smooth[dir], d->sampler); dis_batch_sampled(d, &b, d->interp_sets[s][dir], 3, d->view_side, d->sampler); dis_batch_sampled(d, &b, d->interp_sets[s][dir], 4, d->view_hist[dir], d->sampler); dis_batch_storage(d, &b, d->interp_sets[s][dir], 5, d->view_interp_out); @@ -882,6 +896,14 @@ static void dis_write_all_descriptors(VkrDis* d) { dis_batch_storage(d, &b, d->vr_add_set[l], DIS_VR_FIRST_STORAGE, d->view_flow_refined[l]); } + // Not per slot: both inputs and the output are single images, and these sets are + // written once here and never updated, so sharing them across frames is safe. + for (uint32_t dir = 0; dir < 2u; dir++) { + dis_batch_sampled(d, &b, d->temporal_sets[dir], 0, d->view_flow_refined[0], d->sampler); + dis_batch_sampled(d, &b, d->temporal_sets[dir], 1, d->view_flow_smooth[1u - dir], d->sampler); + dis_batch_storage(d, &b, d->temporal_sets[dir], 5, d->view_flow_smooth[dir]); + } + dis_batch_flush(d, &b); } @@ -891,6 +913,8 @@ static void dis_destroy_views(VkrDis* d) { dis_destroy_view(d, &d->view_hist[0]); dis_destroy_view(d, &d->view_hist[1]); dis_destroy_view(d, &d->view_side); + dis_destroy_view(d, &d->view_flow_smooth[0]); + dis_destroy_view(d, &d->view_flow_smooth[1]); for (uint32_t l = 0; l < DIS_MAX_LEVELS; l++) { dis_destroy_view(d, &d->view_vr_prep[l]); dis_destroy_view(d, &d->view_vr_d1[l]); @@ -938,6 +962,8 @@ static void dis_destroy_images(VkrDis* d) { dis_destroy_image(d, &d->hist[0]); dis_destroy_image(d, &d->hist[1]); dis_destroy_image(d, &d->side); + dis_destroy_image(d, &d->flow_smooth[0]); + dis_destroy_image(d, &d->flow_smooth[1]); } static bool dis_create_resources(VkrDis* d, uint32_t w, uint32_t h, uint32_t full_w, @@ -1001,6 +1027,14 @@ static bool dis_create_resources(VkrDis* d, uint32_t w, uint32_t h, uint32_t ful VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_STORAGE_BIT)) return false; if (!dis_create_image(d, &d->side, w, h, VK_FORMAT_R32_SFLOAT, 1, VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_STORAGE_BIT)) return false; + // Ping-pong for the temporally smoothed field, level 0 only, at the flow extent. + // Four channels, not two: xy is this pair's chord and zw the previous pair's chord + // for the same content, so the interpolate pass gets both from the one fetch it was + // already making. The storage format is the same one the sparse flow maps use. + if (!dis_create_image(d, &d->flow_smooth[0], w, h, VK_FORMAT_R32G32B32A32_SFLOAT, 1, + VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_STORAGE_BIT)) return false; + if (!dis_create_image(d, &d->flow_smooth[1], w, h, VK_FORMAT_R32G32B32A32_SFLOAT, 1, + VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_STORAGE_BIT)) return false; for (uint32_t s = 0; s < DIS_SLOTS; s++) { if (!dis_create_view(d, d->color[s].image, format, 0, 1, &d->view_color[s])) return false; @@ -1030,6 +1064,8 @@ static bool dis_create_resources(VkrDis* d, uint32_t w, uint32_t h, uint32_t ful if (!dis_create_view(d, d->hist[0].image, VK_FORMAT_R32_SFLOAT, 0, 1, &d->view_hist[0])) return false; if (!dis_create_view(d, d->hist[1].image, VK_FORMAT_R32_SFLOAT, 0, 1, &d->view_hist[1])) return false; if (!dis_create_view(d, d->side.image, VK_FORMAT_R32_SFLOAT, 0, 1, &d->view_side)) return false; + if (!dis_create_view(d, d->flow_smooth[0].image, VK_FORMAT_R32G32B32A32_SFLOAT, 0, 1, &d->view_flow_smooth[0])) return false; + if (!dis_create_view(d, d->flow_smooth[1].image, VK_FORMAT_R32G32B32A32_SFLOAT, 0, 1, &d->view_flow_smooth[1])) return false; vkr_dis_reset(d); dis_write_all_descriptors(d); @@ -1077,6 +1113,9 @@ static bool dis_allocate_sets(VkrDis* d) { } } + if (!dis_alloc(d, d->set_layout, 1, &d->temporal_sets[0])) return false; + if (!dis_alloc(d, d->set_layout, 1, &d->temporal_sets[1])) return false; + for (uint32_t l = 0; l < DIS_VR_LEVELS; l++) { VkDescriptorSet vr_sets[DIS_VR_SHARED_SETS]; if (!dis_alloc(d, d->vr_set_layout, DIS_VR_SHARED_SETS, vr_sets)) return false; @@ -1282,6 +1321,7 @@ void vkr_dis_destroy(VkrDis* d) { if (d->pass_vr_sor.pipeline) vkd.DestroyPipeline(d->device, d->pass_vr_sor.pipeline, NULL); if (d->pass_vr_add.pipeline) vkd.DestroyPipeline(d->device, d->pass_vr_add.pipeline, NULL); if (d->pass_hist.pipeline) vkd.DestroyPipeline(d->device, d->pass_hist.pipeline, NULL); + if (d->pass_temporal.pipeline) vkd.DestroyPipeline(d->device, d->pass_temporal.pipeline, NULL); if (d->pool) vkd.DestroyDescriptorPool(d->device, d->pool, NULL); if (d->pipeline_layout) vkd.DestroyPipelineLayout(d->device, d->pipeline_layout, NULL); if (d->vr_pipeline_layout) vkd.DestroyPipelineLayout(d->device, d->vr_pipeline_layout, NULL); @@ -1535,7 +1575,13 @@ uint32_t vkr_dis_plan(VkrDis* d, uint32_t capacity, uint64_t source_frames) { static void dis_vr_budget(const DisRefine* refine, uint32_t l, uint32_t* fixed_point, uint32_t* sor) { *fixed_point = l == 0 ? refine->vr_fixed_point : 1u; - const uint32_t s = refine->vr_sor > l ? refine->vr_sor - l : DIS_VR_SOR_FLOOR; + // vr_sor is the level-0 budget and coarser levels fall off twice as fast as the grid + // shrinks. A flat slope spent the finest level's raised count on every level: with the + // 4x budget at 7 that would be 7, 6, 5, 4 - eight extra dispatches and barriers per + // pair for sweeps that buy nothing, since a half-size level crosses the same relative + // distance in fewer of them. This gives 7, 5, 3, 2 instead. + const uint32_t drop = l * 2u; + const uint32_t s = refine->vr_sor > drop ? refine->vr_sor - drop : DIS_VR_SOR_FLOOR; *sor = s < DIS_VR_SOR_FLOOR ? DIS_VR_SOR_FLOOR : s; } @@ -1761,6 +1807,19 @@ void vkr_dis_process(VkrDis* d, VkCommandBuffer cmd, VkImage source, uint32_t wi const uint32_t hist_h = full_h > 1u ? full_h / 2u : 1u; const uint32_t hist_dir = 1u - d->hist_parity; const int hist_reset = d->hist_valid ? 0 : 1; + + // Temporal consistency of the field, before anything reads it. Shares the + // history parity with the overlay confidence below: both flip once per source + // pair, and hist_valid covers both. + vkd.CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, d->pass_temporal.pipeline); + vkd.CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, d->pipeline_layout, 0, 1, + &d->temporal_sets[hist_dir], 0, NULL); + vkd.CmdPushConstants(cmd, d->pipeline_layout, VK_SHADER_STAGE_COMPUTE_BIT, 0, + sizeof(hist_reset), &hist_reset); + vkd.CmdDispatch(cmd, (w + DIS_LOCAL_SIZE - 1) / DIS_LOCAL_SIZE, + (h + DIS_LOCAL_SIZE - 1) / DIS_LOCAL_SIZE, 1); + dis_compute_barrier(cmd); + vkd.CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, d->pass_hist.pipeline); vkd.CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, d->vr_pipeline_layout, 0, 1, &d->hist_sets[slot][hist_dir], 0, NULL); diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp b/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp index 5b8993973..465d211f0 100644 --- a/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp +++ b/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp @@ -81,9 +81,17 @@ const float UI_FLOW_SKIP_PX = 0.25; // to 1.5 px (mask on the background hard against the panel 0.012) and starts to freeze at // 2.5 px (0.112), so the cross stops at 1.5. // -// 0, 2 or 4 taps; each tap is two full-res fetches, so four taps double this pass's -// sampling. Drop to 2 for about two thirds of the gain at half the cost. -const int UI_DILATE_TAPS = 4; +// 0, 2 or 4 taps; each tap is two full-res fetches. +// +// OFF, because dis_hist already does exactly this: it takes the minimum of the pair +// difference over a four-point cross and keeps it over time, and it runs ONCE PER SOURCE +// PAIR before the interpolate pass, at half resolution. The cross here recomputed the +// same thing at full resolution on every generated frame - three times per pair - and +// then handed the result to `max(uiMask, histStatic)`, where the history had it already. +// Eight of this pass's fetches for work that was done. The full-res cross could only add +// detail finer than half resolution, and hist's evidence is a max, so it reaches a newly +// appeared overlay in the same pair rather than a frame later. +const int UI_DILATE_TAPS = 0; const float UI_DILATE_PX = 1.5; // Channel difference, 0..1. LO is rgb8 quantisation plus mild dither; HI is where a @@ -120,6 +128,14 @@ const int DEBUG_UI_MASK = 0; // while t crosses 0.5 between them. #define DIS_OCCL_SIDED 1 +// Follow a parabola through three consecutive real frames instead of the straight +// chord of the current pair. 0 falls back to the chord. +#define DIS_QUADRATIC_PATH 1 + +// Fetch the warped samples through Catmull-Rom instead of one bilinear tap. 0 falls back +// to bilinear. This is the expensive switch in this file - see warpSample below. +#define DIS_WARP_CATMULL 1 + // --------------------------------------------------------------------------- // Averaging two warped samples that disagree is what softens the picture. Each sample is // sharp on its own - one bilinear tap - but where the flow does not line them up the @@ -133,13 +149,76 @@ const int DEBUG_UI_MASK = 0; // // The cost of picking is judder: the chosen sample is the nearest real frame warped by a // vector that was wrong, so the generated frame sits closer to a repeat of a real one. -// Raise BLEND_LO towards the old behaviour if that trade reads worse than the softness. +// The band below is already back at the old behaviour; lower BLEND_LO if the picture +// reads too soft, raise it if the judder shows. // --------------------------------------------------------------------------- -const float BLEND_LO = 0.030; -const float BLEND_HI = 0.180; +// Measured against the 68f678c1 blend (`smoothstep(0.10, 0.40, sum of channel diffs)`) +// at equal flow error, as the fraction of pixels that leave the average for a single +// frame. Soft content - sky, grass, fog, most of a frame - is the column that matters: +// +// band soft content: pick / sharpness vs reference +// 68f678c1 sum 0.10/0.40 0.207 - +// max 0.030/0.180 0.388 1.06 +// max 0.050/0.250 0.174 0.94 +// max 0.065/0.300 0.093 0.86 <- here +// max 0.080/0.350 0.048 0.81 +// max 0.100/0.450 0.016 0.77 +// +// Past 0.065/0.300 the picture starts paying real resolution for the smoothness, and by +// 0.100/0.450 mid-contrast content softens too (1.09 -> 0.89), not just the flat parts. +// +// Averaging two samples the flow failed to line up gives a soft frame, and softness +// reads as smoothness: there is nothing sharp for the eye to track, so the per-frame +// position error stops showing. On 68f678c1 that blur did two jobs at once - it hid the +// position error AND it hid the artifacts. The artifacts now have their own machinery +// (the overlay mask, dis_hist, dis_side, the flow clamp, dis_temporal, the parabola), so +// the blend no longer has to hide anything and can go back to the softer setting without +// bringing them back. The per-channel maximum is kept over the old sum: a purely coloured +// disagreement had to reach three times as far before the sum counted it at all. +const float BLEND_LO = 0.065; +const float BLEND_HI = 0.300; -vec2 sampleFlow(vec2 uv) { - if (manualFlowFilter == 0) return textureLod(flowTex, uv, 0.0).xy; +// --------------------------------------------------------------------------- +// Scene cut, and the same thing by another name: tracking that gave up. +// +// When the frame is replaced wholesale - a pause menu over gameplay - there is no +// correspondence to find. The search keeps whatever the residual happened to favour, the +// magnitude clamp in dis_vr_add bounds it to a quarter of the frame, and what is left is +// bounded garbage with the block structure of the search grid. Dragging a menu's own +// black panels and white text around with that field is what puts black and white squares +// on the screen. +// +// Two residuals decide it, and it works because it is TWO of them: +// +// |s0 - s1| the real pair, compared where it sits +// |c0 - c1| the same pair after the warp tried to reconcile it +// +// A fast pan pulls the first one far apart - median 0.290 in the runs below - but the warp +// closes it, so the second is 0.000. On a cut nothing closes it: 0.588 and 0.584. The +// minimum of the two separates the cases by an order of magnitude. Fraction of pixels +// above the threshold: +// +// case 0.25 0.35 0.45 +// static scene 0.000 0.000 0.000 +// pan, flow correct 0.035 0.009 0.001 +// pan, tracking lost 0.368 0.107 0.020 +// cut to a menu 0.992 0.936 0.745 +// +// This also retires the conclusion in dis-interpolation-glitches.md that a cut needs a +// global reduction to be told apart from lost tracking. It does - but the correct response +// to both is the same, show a real frame, so they never needed telling apart. +// +// The response is the nearest real frame UNWARPED. Warping it is what broke it. Over the +// three generated frames of a pair that reads as s0, s1, s1, i.e. a clean hold across the +// cut. Both residuals and both frames are already in registers here, so this costs no +// fetches at all. +const float CUT_LO = 0.30; +const float CUT_HI = 0.55; + +// xy: this pair's chord. zw: the previous pair's chord for the same content, advected +// here by dis_temporal - one fetch carries both, which is why the field is RGBA. +vec4 sampleFlow(vec2 uv) { + if (manualFlowFilter == 0) return textureLod(flowTex, uv, 0.0); vec2 sz = vec2(textureSize(flowTex, 0)); vec2 p = uv * sz - 0.5; @@ -147,10 +226,10 @@ vec2 sampleFlow(vec2 uv) { ivec2 i0 = ivec2(floor(p)); ivec2 mx = ivec2(sz) - 1; - vec2 a = texelFetch(flowTex, clamp(i0, ivec2(0), mx), 0).xy; - vec2 b = texelFetch(flowTex, clamp(i0 + ivec2(1, 0), ivec2(0), mx), 0).xy; - vec2 c = texelFetch(flowTex, clamp(i0 + ivec2(0, 1), ivec2(0), mx), 0).xy; - vec2 e = texelFetch(flowTex, clamp(i0 + ivec2(1, 1), ivec2(0), mx), 0).xy; + vec4 a = texelFetch(flowTex, clamp(i0, ivec2(0), mx), 0); + vec4 b = texelFetch(flowTex, clamp(i0 + ivec2(1, 0), ivec2(0), mx), 0); + vec4 c = texelFetch(flowTex, clamp(i0 + ivec2(0, 1), ivec2(0), mx), 0); + vec4 e = texelFetch(flowTex, clamp(i0 + ivec2(1, 1), ivec2(0), mx), 0); return mix(mix(a, b, frac.x), mix(c, e, frac.x), frac.y); } @@ -158,6 +237,80 @@ float maxChannel(vec3 v) { return max(max(v.x, v.y), v.z); } +// A real frame reaches the screen unresampled, one to one. A generated one is fetched at +// a fractional offset, and bilinear at half a texel throws away almost half the high +// frequencies. Measured against the unwarped frame, with a deliberately CORRECT flow so +// this is the filter's own cost and nothing else: +// +// fractional offset bilinear Catmull-Rom +// 0.000 1.000 1.000 +// 0.125 0.839 0.955 +// 0.250 0.699 0.844 +// 0.375 0.602 0.727 +// 0.500 0.564 0.675 +// +// So three of every four frames arrive softer than their neighbours by an amount that +// moves with the motion. That is a 30 Hz pulse of sharpness, and no amount of work on the +// flow touches it: the field can be exact and the filter still eats the detail. +// +// Catmull-Rom does not close the gap - no cheap filter invents what the sampling grid did +// not keep - but it moves the worst case from 0.564 to 0.675 and the common quarter-texel +// case from 0.699 to 0.844. +// +// Five bilinear taps, not nine: the four corners are products of two small negative lobes +// and carry nothing. Measured on a diagonal offset, sharpness against the unwarped frame +// and the mean difference between the two versions: +// +// fx fy bilinear 5 taps 9 taps |5-9| +// 0.500 0.500 0.453 0.584 0.577 0.0013 +// 0.250 0.250 0.584 0.780 0.774 0.0009 +// 0.125 0.125 0.756 0.933 0.930 0.0004 +// +// Five taps come out marginally AHEAD of nine once renormalised, and the difference +// between them is a third of an rgb8 step. DIS_WARP_CATMULL = 0 goes back to bilinear. +// +// The sampler is CLAMP_TO_EDGE, so the taps reaching one and a half texels past the +// border pick up the edge colour rather than black. +vec3 warpSample(sampler2D tex, vec2 uv, vec2 texSize) { +#if DIS_WARP_CATMULL == 0 + return textureLod(tex, clamp(uv, vec2(0.0), vec2(1.0)), 0.0).xyz; +#else + vec2 samplePos = uv * texSize; + vec2 texPos1 = floor(samplePos - 0.5) + 0.5; + vec2 fr = samplePos - texPos1; + + vec2 w0 = fr * (-0.5 + fr * (1.0 - 0.5 * fr)); + vec2 w1 = 1.0 + fr * fr * (-2.5 + 1.5 * fr); + vec2 w2 = fr * (0.5 + fr * (2.0 - 1.5 * fr)); + vec2 w3 = fr * fr * (-0.5 + 0.5 * fr); + + // The middle pair is taken as one bilinear tap placed between them - that is what + // turns sixteen taps into nine. w12 stays near 1 over the whole range, so the + // division is safe. + vec2 w12 = w1 + w2; + vec2 off12 = w2 / w12; + + vec2 inv = 1.0 / texSize; + vec2 p0 = clamp((texPos1 - 1.0) * inv, vec2(0.0), vec2(1.0)); + vec2 p3 = clamp((texPos1 + 2.0) * inv, vec2(0.0), vec2(1.0)); + vec2 p12 = clamp((texPos1 + off12) * inv, vec2(0.0), vec2(1.0)); + + vec3 r = vec3(0.0); + r += textureLod(tex, vec2(p12.x, p0.y), 0.0).xyz * (w12.x * w0.y); + r += textureLod(tex, vec2(p0.x, p12.y), 0.0).xyz * (w0.x * w12.y); + r += textureLod(tex, vec2(p12.x, p12.y), 0.0).xyz * (w12.x * w12.y); + r += textureLod(tex, vec2(p3.x, p12.y), 0.0).xyz * (w3.x * w12.y); + r += textureLod(tex, vec2(p12.x, p3.y), 0.0).xyz * (w12.x * w3.y); + + // Renormalise for the four dropped corners. Per axis w0 + w12 + w3 = 1 exactly, so + // the five remaining weights sum to this closed form - no extra adds needed. + r /= w12.x + w12.y * (1.0 - w12.x); + + // The negative lobes can ring past the source range. + return clamp(r, vec3(0.0), vec3(1.0)); +#endif +} + // How far apart the two real frames are at one point, sampled where they sit. float pairDiff(vec2 p) { vec2 q = clamp(p, vec2(0.0), vec2(1.0)); @@ -186,11 +339,23 @@ void main() { vec2 eased = ramp * ramp * (3.0 - 2.0 * ramp); float edgeMix = min(eased.x, eased.y); - vec2 f = sampleFlow(uv); + vec4 fs = sampleFlow(uv); if (edgeMix < 1.0) { - vec2 fInner = sampleFlow(clamp(uv, guard, 1.0 - guard)); - f = mix(fInner, f, edgeMix); + vec4 fInner = sampleFlow(clamp(uv, guard, 1.0 - guard)); + fs = mix(fInner, fs, edgeMix); } + // Last line of defence on the field. A non-finite value turns uv0/uv1 into NaN, and a + // NaN texture coordinate is an undefined fetch that reaches rgba8 as whatever the + // hardware felt like. The parabola can manufacture one from finite parts too: with + // fPrev infinite, (f + fPrev) and (f - fPrev) are +Inf and -Inf and their weighted sum + // is NaN. Upstream guards its own outputs; this is two instructions for the case where + // one of them is ever wrong again. + if (any(isnan(fs)) || any(isinf(fs))) fs = vec4(0.0); + + vec2 f = fs.xy; + // dis_temporal stores f itself in zw whenever it found no usable history, so the + // parabola below degenerates to the chord on its own in that case. + vec2 fPrev = DIS_QUADRATIC_PATH != 0 ? fs.zw : fs.xy; if (pc.debugMode != 0) { float m = length(f) * float(size.x) / 16.0; @@ -232,11 +397,22 @@ void main() { return; } - vec2 uv0 = uv - pc.t * f; - vec2 uv1 = uv + (1.0 - pc.t) * f; - - vec3 c0 = textureLod(prevColor, clamp(uv0, vec2(0.0), vec2(1.0)), 0.0).xyz; - vec3 c1 = textureLod(nextColor, clamp(uv1, vec2(0.0), vec2(1.0)), 0.0).xyz; + // Displacement from the previous real frame to time t. A chord is straight, so its + // direction changes abruptly at every real frame and that break repeats at the source + // rate; 30 Hz of it is a large part of why 120 made from 30 does not read as 120. The + // parabola through three consecutive real frames removes it. Measured against a true + // path: position error 0.469 -> 0.000 under constant acceleration, 3.027 -> 0.450 on a + // smooth arc, 12.34 -> 6.07 on a mouse flick, with the velocity break falling the same + // way. On a uniform pan a chord is already exact and the parabola matches it. + // + // A reversal would make a parabola overshoot, but by then dis_temporal's gate has set + // fPrev = f, so those frames fall back to the chord by construction. + vec2 d = 0.5 * pc.t * (f + fPrev) + 0.5 * pc.t * pc.t * (f - fPrev); + vec2 uv0 = uv - d; + vec2 uv1 = uv + (f - d); + + vec3 c0 = warpSample(prevColor, uv0, vec2(size)); + vec3 c1 = warpSample(nextColor, uv1, vec2(size)); vec3 single = pc.t < 0.5 ? c0 : c1; #if DIS_OCCL_SIDED @@ -303,6 +479,13 @@ void main() { result = mix(result, uiColor, uiMask); + // --- scene cut / lost tracking ----------------------------------------- + // Last, so it overrides the warp, the pick and the overlay mask alike. On a cut the + // overlay mask is shut anyway - rStatic is huge - and dis_hist has its own cut gate, + // so there is nothing here to fight with. + float cut = smoothstep(CUT_LO, CUT_HI, min(pairDiffHere, disagree)); + result = mix(result, pc.t < 0.5 ? s0 : s1, cut); + bool leftHalf = uint(pix.x) * 2u < gl_NumWorkGroups.x * gl_WorkGroupSize.x; if (DEBUG_UI_MASK == 1 || (DEBUG_UI_MASK == 2 && leftHalf)) { result = mix(result * 0.15, vec3(0.1, 1.0, 0.2), uiMask); diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_temporal.comp b/app/src/main/cpp/winlator/vk/shaders/dis_temporal.comp new file mode 100644 index 000000000..5bf4a328d --- /dev/null +++ b/app/src/main/cpp/winlator/vk/shaders/dis_temporal.comp @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: Copyright 2026 qwertypower (DEVAR Entertainment LLC) +// SPDX-License-Identifier: GPL-3.0-or-later +// +// DIS frame generation: a Vulkan compute realisation of Dense Inverse Search +// optical flow. The algorithm and its reference implementation come from +// OpenCV's DISOpticalFlow, which adopted Till Kroeger's original OF_DIS. +// See CREDITS.md for the full attribution. + +#version 450 + +precision highp float; +precision highp int; + +// Temporal consistency of the final field, at the flow resolution, once per +// source pair. +// +// Every pair is estimated from scratch - the coarsest level starts from zero - +// so the estimation error is redrawn independently each time. A still picture +// never shows it: the field is about as accurate as before. What it costs is +// smoothness, because the generated frames sit at pos + t*f, and a field that +// jitters by a few pixels between pairs moves them back and forth around the +// true path. The eye reads that as a parasitic acceleration at the source rate. +// +// Modelled over long runs (displayed position vs the true path, jitter measured +// as the spread of the second difference): +// +// scenario sigma raw EMA 0.3 + gate +// uniform pan 6 px 3.88 1.82 +// uniform pan 15 px 9.36 4.48 +// hard ramp 40->160 over 20 pairs 15 px 9.34 5.29 +// camera reversal +-120 6 px 3.85 1.93 +// scene cut 6 px 3.95 1.89 +// +// Jitter roughly halves and the position error drops by a third. It wins on the +// ramp and the reversal too: the field's genuine change per pair is smaller than +// the estimation noise, so the lag costs less than the jitter. +// +// THE GATE IS NOT OPTIONAL. Without it the same runs get worse, not better: +// 3.85 -> 6.88 on the reversal and 3.95 -> 11.49 on the cut, because the history +// is then averaged into a field that has nothing to do with it any more. +// +// Advection: the history at this texel describes content that has since moved +// on, so it is read where that content came from, one dependent fetch. At a +// disocclusion there is no valid history at all, and that is the same gate. +// +// Cost: one dispatch at the flow resolution per source pair - 320x180 on Fast - +// and two flow-resolution images, RGBA32F because zw carries the previous chord for +// the interpolate pass's parabola. Nothing per output pixel beyond widening one fetch +// that pass already made, so the preset does not notice. + +// Weight of the new measurement. Higher keeps more of the new field. +// +// 0.3 measured best when this pass stood alone. It does not any more, and the reason is +// worth writing down: the interpolate pass now follows a parabola through three real +// frames, and that parabola lives on the SECOND DIFFERENCE of the field - how the chord +// changed between pairs. An EMA is exactly what erases a second difference. The two +// features want opposite things from the same signal. +// +// Jitter over long runs, re-measured with the parabola in place: +// +// scenario a=0.30 0.35 0.45 0.55 0.70 1.00 +// pan 2.28 2.45 2.78 3.10 3.60 4.69 +// arc 12.15 11.61 9.88 8.43 6.60 5.22 +// ramp 4.32 4.05 3.85 3.96 4.27 5.17 +// reversal 2.36 2.52 2.84 3.16 3.63 4.61 +// +// The arc is ordinary camera movement and its numbers dwarf the rest, so it decides the +// feel; heavy smoothing costs it more than it gains anywhere else. Summed, the minimum +// sits around 0.6-0.7. 0.55 is a step short of it on purpose: real straight-line motion +// still benefits from noise suppression, and the sigma those runs assume is an estimate, +// so the headroom is worth more on that side. +#define DIS_TEMPORAL_ALPHA 0.55 + +// Drop the history when the new field departs from it by more than this. The +// floor covers a still field; the relative term keeps the gate meaningful when +// the whole frame moves by a hundred pixels. +#define DIS_TEMPORAL_GATE_PX 8.0 +#define DIS_TEMPORAL_GATE_REL 0.35 + +layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in; + +layout(set = 0, binding = 0) uniform sampler2D flowNew; +layout(set = 0, binding = 1) uniform sampler2D flowHist; +layout(set = 0, binding = 5, rgba32f) uniform image2D flowOut; + +layout(push_constant) uniform PC { + int reset; +} pc; + +void main() { + ivec2 p = ivec2(gl_GlobalInvocationID.xy); + ivec2 sz = imageSize(flowOut); + if (p.x >= sz.x || p.y >= sz.y) return; + + vec2 uSize = vec2(sz); + vec2 uv = (vec2(p) + 0.5) / uSize; + + // Stored in normalised uv units, as the rest of the chain expects. + vec2 fNew = texelFetch(flowNew, p, 0).xy; + + if (pc.reset != 0) { + imageStore(flowOut, p, vec4(fNew, fNew)); + return; + } + + vec2 back = clamp(uv - fNew, vec2(0.0), vec2(1.0)); + vec2 fOld = textureLod(flowHist, back, 0.0).xy; + + float gate = DIS_TEMPORAL_GATE_PX + DIS_TEMPORAL_GATE_REL * length(fNew * uSize); + bool usable = length((fNew - fOld) * uSize) <= gate; + + vec2 fOut = usable ? mix(fOld, fNew, DIS_TEMPORAL_ALPHA) : fNew; + + // zw is the previous pair's chord for this same content, which the interpolate pass + // needs for its parabola. Writing fNew where there is no usable history is what makes + // that parabola degenerate to the chord instead of inventing curvature - and it is the + // same condition, so a reversal or a cut is covered once, here. + vec2 fPrev = usable ? fOld : fNew; + + // Zero, not fNew: fNew is the value that may BE the non-finite one, and falling back + // to it makes the guard a no-op. That is exactly the mistake the old vr_add guard made + // when it fell back to W. A zero field degrades to repeating the real frame, which is + // the right answer whenever the flow has nothing to say. + if (any(isnan(fOut)) || any(isinf(fOut))) fOut = vec2(0.0); + if (any(isnan(fPrev)) || any(isinf(fPrev))) fPrev = vec2(0.0); + + imageStore(flowOut, p, vec4(fOut, fPrev)); +} diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_vr_add.comp b/app/src/main/cpp/winlator/vk/shaders/dis_vr_add.comp index 07500d9c9..91baecf4f 100644 --- a/app/src/main/cpp/winlator/vk/shaders/dis_vr_add.comp +++ b/app/src/main/cpp/winlator/vk/shaders/dis_vr_add.comp @@ -26,9 +26,25 @@ void main() { vec2 W = texelFetch(flowDense, p, 0).xy * uSize; vec2 f = W + texelFetch(dW, p, 0).xy; - float lim = 4.0 * uSize.x; - bvec2 ok = lessThan(abs(f), vec2(lim)); - vec2 fr = vec2(ok.x ? f.x : W.x, ok.y ? f.y : W.y); + + // Nothing upstream bounds the ABSOLUTE magnitude of the flow. The block search only + // limits how far it may move from the coarse estimate - one patch per level - and the + // refinement only limits its own update. On a scene cut there is no correspondence at + // all, the search keeps whatever the residual happens to favour, and the value handed + // down the pyramid reaches hundreds of pixels, which back-tracks off the frame and + // smears the edge across the picture. + // + // The old guard was `abs(f) < 4 * width` per component: four frame widths is no limit + // at all, and worse, when it did trip it fell back to W, which by then is itself the + // huge value. Measured: input +-400 px came out at 1624 px and the guard never fired + // once. Clamp the magnitude instead. The fastest real motion measured on this content + // is about 0.1 of the frame per source frame, so a quarter of the frame leaves four + // times the headroom anything legitimate needs. + const float FLOW_LIMIT_FRACTION = 0.25; + float lim = FLOW_LIMIT_FRACTION * max(uSize.x, uSize.y); + float mag = length(f); + vec2 fr = mag > lim ? f * (lim / mag) : f; + if (any(isnan(fr)) || any(isinf(fr))) fr = vec2(0.0); imageStore(flowRefined, p, vec4(fr * invSize, 0.0, 1.0)); } From c9218234a1810dd37920dd1221df5aa612444d35 Mon Sep 17 00:00:00 2001 From: unknown <1qwertypower1@gmail.com> Date: Sat, 26 Sep 2026 01:33:38 +0300 Subject: [PATCH 16/17] DIS: warp-based interpolation with source-point flow lookup, RG16F flow Replace the freeze/crossfade fallbacks with a motion-preserving warp: flow read at the source point, static overlays kept by static-vs-motion residual, divergence-sided occlusion, Catmull-Rom fetch. Drop temporal EMA and the parabolic path. --- app/src/main/cpp/CMakeLists.txt | 2 +- app/src/main/cpp/winlator/vk/dis/vkr_dis.c | 81 ++-- .../winlator/vk/shaders/dis_flow_pack.comp | 39 ++ .../winlator/vk/shaders/dis_interpolate.comp | 456 ++++-------------- .../cpp/winlator/vk/shaders/dis_side.comp | 7 +- .../cpp/winlator/vk/shaders/dis_temporal.comp | 128 ----- docs/FRAME-GENERATION.md | 2 +- 7 files changed, 179 insertions(+), 536 deletions(-) create mode 100644 app/src/main/cpp/winlator/vk/shaders/dis_flow_pack.comp delete mode 100644 app/src/main/cpp/winlator/vk/shaders/dis_temporal.comp diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt index bc3d2085f..15ae9ab71 100644 --- a/app/src/main/cpp/CMakeLists.txt +++ b/app/src/main/cpp/CMakeLists.txt @@ -112,7 +112,7 @@ set(SHADER_LIST "dis_interpolate:comp:dis_interpolate_comp" "dis_hist:comp:dis_hist_comp" "dis_side:comp:dis_side_comp" - "dis_temporal:comp:dis_temporal_comp" + "dis_flow_pack:comp:dis_flow_pack_comp" "dis_vr_prep:comp:dis_vr_prep_comp" "dis_vr_d1:comp:dis_vr_d1_comp" "dis_vr_d2:comp:dis_vr_d2_comp" diff --git a/app/src/main/cpp/winlator/vk/dis/vkr_dis.c b/app/src/main/cpp/winlator/vk/dis/vkr_dis.c index eff38c970..8f2f3ac7e 100644 --- a/app/src/main/cpp/winlator/vk/dis/vkr_dis.c +++ b/app/src/main/cpp/winlator/vk/dis/vkr_dis.c @@ -18,7 +18,7 @@ #include "shaders/dis_interpolate_comp.spv.h" #include "shaders/dis_hist_comp.spv.h" #include "shaders/dis_side_comp.spv.h" -#include "shaders/dis_temporal_comp.spv.h" +#include "shaders/dis_flow_pack_comp.spv.h" #include "shaders/dis_vr_prep_comp.spv.h" #include "shaders/dis_vr_d1_comp.spv.h" #include "shaders/dis_vr_d2_comp.spv.h" @@ -154,7 +154,7 @@ struct VkrDis { DisImage flow_refined; DisImage hist[2]; DisImage side; - DisImage flow_smooth[2]; + DisImage flow_out; VkImageView view_color[DIS_SLOTS]; VkImageView view_flow_color[DIS_SLOTS][DIS_MAX_LEVELS]; @@ -174,7 +174,7 @@ struct VkrDis { VkImageView view_flow_refined[DIS_MAX_LEVELS]; VkImageView view_hist[2]; VkImageView view_side; - VkImageView view_flow_smooth[2]; + VkImageView view_flow_out; VkSampler sampler; @@ -190,7 +190,7 @@ struct VkrDis { VkDescriptorSet interp_sets[DIS_SLOTS][2]; VkDescriptorSet hist_sets[DIS_SLOTS][2]; VkDescriptorSet side_sets[DIS_SLOTS]; - VkDescriptorSet temporal_sets[2]; + VkDescriptorSet pack_set; VkDescriptorSetLayout vr_set_layout; VkPipelineLayout vr_pipeline_layout; @@ -211,7 +211,7 @@ struct VkrDis { DisPass pass_interp; DisPass pass_hist; DisPass pass_side; - DisPass pass_temporal; + DisPass pass_pack; DisPass pass_vr_prep; DisPass pass_vr_d1; DisPass pass_vr_d2; @@ -355,8 +355,7 @@ static uint32_t dis_collect_images(VkrDis* d, DisImage** out, uint32_t cap) { DIS_PUSH(&d->hist[0]); DIS_PUSH(&d->hist[1]); DIS_PUSH(&d->side); - DIS_PUSH(&d->flow_smooth[0]); - DIS_PUSH(&d->flow_smooth[1]); + DIS_PUSH(&d->flow_out); #undef DIS_PUSH return n; } @@ -556,7 +555,7 @@ static bool dis_create_pipelines(VkrDis* d) { const uint32_t shared_sets = DIS_SLOTS * DIS_MAX_LEVELS * DIS_SHARED_SETS_PER_LEVEL + DIS_SLOTS * 2u // interpolation sets, one per history direction + DIS_SLOTS // side-map sets - + 2u; // temporal flow sets, one per direction + + 1u; // flow pack set // VR sets exist only for the levels the refinement actually runs on. const uint32_t vr_sets = (DIS_SLOTS + DIS_VR_SHARED_SETS) * DIS_VR_LEVELS; @@ -658,14 +657,14 @@ static bool dis_create_pipelines(VkrDis* d) { d->pass_vr_add.pipeline = dis_create_compute_pipeline_with_layout(d, dis_vr_add_comp, dis_vr_add_comp_size, d->vr_pipeline_layout, NULL); d->pass_hist.pipeline = dis_create_compute_pipeline_with_layout(d, dis_hist_comp, dis_hist_comp_size, d->vr_pipeline_layout, NULL); d->pass_side.pipeline = dis_create_compute_pipeline(d, dis_side_comp, dis_side_comp_size); - d->pass_temporal.pipeline = dis_create_compute_pipeline(d, dis_temporal_comp, dis_temporal_comp_size); + d->pass_pack.pipeline = dis_create_compute_pipeline(d, dis_flow_pack_comp, dis_flow_pack_comp_size); if (!d->pass_gradient.pipeline || !d->pass_inverse.pipeline || !d->pass_propagate.pipeline || !d->pass_densify.pipeline || !d->pass_interp.pipeline || !d->pass_vr_prep.pipeline || !d->pass_vr_d1.pipeline || !d->pass_vr_d2.pipeline || !d->pass_vr_w.pipeline || !d->pass_vr_coef.pipeline || !d->pass_vr_sor.pipeline || !d->pass_vr_add.pipeline || !d->pass_hist.pipeline || !d->pass_side.pipeline || - !d->pass_temporal.pipeline) { + !d->pass_pack.pipeline) { return false; } return true; @@ -836,7 +835,7 @@ static void dis_write_all_descriptors(VkrDis* d) { for (uint32_t dir = 0; dir < 2u; dir++) { dis_batch_sampled(d, &b, d->interp_sets[s][dir], 0, d->view_color[prev], d->sampler); dis_batch_sampled(d, &b, d->interp_sets[s][dir], 1, d->view_color[next], d->sampler); - dis_batch_sampled(d, &b, d->interp_sets[s][dir], 2, d->view_flow_smooth[dir], d->sampler); + dis_batch_sampled(d, &b, d->interp_sets[s][dir], 2, d->view_flow_out, d->sampler); dis_batch_sampled(d, &b, d->interp_sets[s][dir], 3, d->view_side, d->sampler); dis_batch_sampled(d, &b, d->interp_sets[s][dir], 4, d->view_hist[dir], d->sampler); dis_batch_storage(d, &b, d->interp_sets[s][dir], 5, d->view_interp_out); @@ -896,13 +895,10 @@ static void dis_write_all_descriptors(VkrDis* d) { dis_batch_storage(d, &b, d->vr_add_set[l], DIS_VR_FIRST_STORAGE, d->view_flow_refined[l]); } - // Not per slot: both inputs and the output are single images, and these sets are - // written once here and never updated, so sharing them across frames is safe. - for (uint32_t dir = 0; dir < 2u; dir++) { - dis_batch_sampled(d, &b, d->temporal_sets[dir], 0, d->view_flow_refined[0], d->sampler); - dis_batch_sampled(d, &b, d->temporal_sets[dir], 1, d->view_flow_smooth[1u - dir], d->sampler); - dis_batch_storage(d, &b, d->temporal_sets[dir], 5, d->view_flow_smooth[dir]); - } + // Not per slot: the input and the output are single images, and this set is written + // once here and never updated, so sharing it across frames is safe. + dis_batch_sampled(d, &b, d->pack_set, 0, d->view_flow_refined[0], d->sampler); + dis_batch_storage(d, &b, d->pack_set, 5, d->view_flow_out); dis_batch_flush(d, &b); } @@ -913,8 +909,7 @@ static void dis_destroy_views(VkrDis* d) { dis_destroy_view(d, &d->view_hist[0]); dis_destroy_view(d, &d->view_hist[1]); dis_destroy_view(d, &d->view_side); - dis_destroy_view(d, &d->view_flow_smooth[0]); - dis_destroy_view(d, &d->view_flow_smooth[1]); + dis_destroy_view(d, &d->view_flow_out); for (uint32_t l = 0; l < DIS_MAX_LEVELS; l++) { dis_destroy_view(d, &d->view_vr_prep[l]); dis_destroy_view(d, &d->view_vr_d1[l]); @@ -962,8 +957,7 @@ static void dis_destroy_images(VkrDis* d) { dis_destroy_image(d, &d->hist[0]); dis_destroy_image(d, &d->hist[1]); dis_destroy_image(d, &d->side); - dis_destroy_image(d, &d->flow_smooth[0]); - dis_destroy_image(d, &d->flow_smooth[1]); + dis_destroy_image(d, &d->flow_out); } static bool dis_create_resources(VkrDis* d, uint32_t w, uint32_t h, uint32_t full_w, @@ -1027,13 +1021,9 @@ static bool dis_create_resources(VkrDis* d, uint32_t w, uint32_t h, uint32_t ful VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_STORAGE_BIT)) return false; if (!dis_create_image(d, &d->side, w, h, VK_FORMAT_R32_SFLOAT, 1, VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_STORAGE_BIT)) return false; - // Ping-pong for the temporally smoothed field, level 0 only, at the flow extent. - // Four channels, not two: xy is this pair's chord and zw the previous pair's chord - // for the same content, so the interpolate pass gets both from the one fetch it was - // already making. The storage format is the same one the sparse flow maps use. - if (!dis_create_image(d, &d->flow_smooth[0], w, h, VK_FORMAT_R32G32B32A32_SFLOAT, 1, - VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_STORAGE_BIT)) return false; - if (!dis_create_image(d, &d->flow_smooth[1], w, h, VK_FORMAT_R32G32B32A32_SFLOAT, 1, + // The finished level-0 field as the interpolate pass reads it: RG16F is filterable on + // every device, so its three lookups per output pixel stay single bilinear taps. + if (!dis_create_image(d, &d->flow_out, w, h, VK_FORMAT_R16G16_SFLOAT, 1, VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_STORAGE_BIT)) return false; for (uint32_t s = 0; s < DIS_SLOTS; s++) { @@ -1064,8 +1054,7 @@ static bool dis_create_resources(VkrDis* d, uint32_t w, uint32_t h, uint32_t ful if (!dis_create_view(d, d->hist[0].image, VK_FORMAT_R32_SFLOAT, 0, 1, &d->view_hist[0])) return false; if (!dis_create_view(d, d->hist[1].image, VK_FORMAT_R32_SFLOAT, 0, 1, &d->view_hist[1])) return false; if (!dis_create_view(d, d->side.image, VK_FORMAT_R32_SFLOAT, 0, 1, &d->view_side)) return false; - if (!dis_create_view(d, d->flow_smooth[0].image, VK_FORMAT_R32G32B32A32_SFLOAT, 0, 1, &d->view_flow_smooth[0])) return false; - if (!dis_create_view(d, d->flow_smooth[1].image, VK_FORMAT_R32G32B32A32_SFLOAT, 0, 1, &d->view_flow_smooth[1])) return false; + if (!dis_create_view(d, d->flow_out.image, VK_FORMAT_R16G16_SFLOAT, 0, 1, &d->view_flow_out)) return false; vkr_dis_reset(d); dis_write_all_descriptors(d); @@ -1113,8 +1102,7 @@ static bool dis_allocate_sets(VkrDis* d) { } } - if (!dis_alloc(d, d->set_layout, 1, &d->temporal_sets[0])) return false; - if (!dis_alloc(d, d->set_layout, 1, &d->temporal_sets[1])) return false; + if (!dis_alloc(d, d->set_layout, 1, &d->pack_set)) return false; for (uint32_t l = 0; l < DIS_VR_LEVELS; l++) { VkDescriptorSet vr_sets[DIS_VR_SHARED_SETS]; @@ -1188,6 +1176,7 @@ static const char* dis_format_name(VkFormat f) { case VK_FORMAT_R16_SFLOAT: return "R16_SFLOAT"; case VK_FORMAT_R32_SFLOAT: return "R32_SFLOAT"; case VK_FORMAT_R32G32_SFLOAT: return "R32G32_SFLOAT"; + case VK_FORMAT_R16G16_SFLOAT: return "R16G16_SFLOAT"; case VK_FORMAT_R32G32B32A32_SFLOAT: return "R32G32B32A32_SFLOAT"; case VK_FORMAT_R8G8B8A8_UNORM: return "R8G8B8A8_UNORM"; case VK_FORMAT_UNDEFINED: return "none"; @@ -1229,6 +1218,7 @@ static bool dis_audit_formats(VkrDis* d) { const char* purpose; } reqs[] = { {VK_FORMAT_R32G32_SFLOAT, STORE | READ, "optical flow"}, + {VK_FORMAT_R16G16_SFLOAT, STORE | READ, "interpolation flow"}, {VK_FORMAT_R32G32B32A32_SFLOAT, STORE | READ, "sparse flow and refinement"}, {VK_FORMAT_R32_SFLOAT, STORE | READ, "refinement weights"}, {VK_FORMAT_R8G8B8A8_UNORM, STORE | VK_FORMAT_FEATURE_BLIT_SRC_BIT, @@ -1262,11 +1252,11 @@ static bool dis_audit_formats(VkrDis* d) { VkFormatProperties flow_fp; memset(&flow_fp, 0, sizeof(flow_fp)); - vkd.GetPhysicalDeviceFormatProperties(d->physical_device, VK_FORMAT_R32G32_SFLOAT, &flow_fp); + vkd.GetPhysicalDeviceFormatProperties(d->physical_device, VK_FORMAT_R16G16_SFLOAT, &flow_fp); d->manual_flow_filter = (flow_fp.optimalTilingFeatures & FILTER) == 0; DIS_LOGI("DIS format support: %s | flow filtering: %s", line, - d->manual_flow_filter ? "in shader (driver cannot filter R32G32_SFLOAT)" + d->manual_flow_filter ? "in shader (driver cannot filter R16G16_SFLOAT)" : "sampler"); return ok; } @@ -1321,7 +1311,8 @@ void vkr_dis_destroy(VkrDis* d) { if (d->pass_vr_sor.pipeline) vkd.DestroyPipeline(d->device, d->pass_vr_sor.pipeline, NULL); if (d->pass_vr_add.pipeline) vkd.DestroyPipeline(d->device, d->pass_vr_add.pipeline, NULL); if (d->pass_hist.pipeline) vkd.DestroyPipeline(d->device, d->pass_hist.pipeline, NULL); - if (d->pass_temporal.pipeline) vkd.DestroyPipeline(d->device, d->pass_temporal.pipeline, NULL); + if (d->pass_side.pipeline) vkd.DestroyPipeline(d->device, d->pass_side.pipeline, NULL); + if (d->pass_pack.pipeline) vkd.DestroyPipeline(d->device, d->pass_pack.pipeline, NULL); if (d->pool) vkd.DestroyDescriptorPool(d->device, d->pool, NULL); if (d->pipeline_layout) vkd.DestroyPipelineLayout(d->device, d->pipeline_layout, NULL); if (d->vr_pipeline_layout) vkd.DestroyPipelineLayout(d->device, d->vr_pipeline_layout, NULL); @@ -1808,17 +1799,10 @@ void vkr_dis_process(VkrDis* d, VkCommandBuffer cmd, VkImage source, uint32_t wi const uint32_t hist_dir = 1u - d->hist_parity; const int hist_reset = d->hist_valid ? 0 : 1; - // Temporal consistency of the field, before anything reads it. Shares the - // history parity with the overlay confidence below: both flip once per source - // pair, and hist_valid covers both. - vkd.CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, d->pass_temporal.pipeline); - vkd.CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, d->pipeline_layout, 0, 1, - &d->temporal_sets[hist_dir], 0, NULL); - vkd.CmdPushConstants(cmd, d->pipeline_layout, VK_SHADER_STAGE_COMPUTE_BIT, 0, - sizeof(hist_reset), &hist_reset); - vkd.CmdDispatch(cmd, (w + DIS_LOCAL_SIZE - 1) / DIS_LOCAL_SIZE, - (h + DIS_LOCAL_SIZE - 1) / DIS_LOCAL_SIZE, 1); - dis_compute_barrier(cmd); + // The three per-pair products the interpolate pass reads are independent of each + // other - the packed field and the side map read the refined field, the overlay + // history reads the colour pair - so they share one barrier. + dis_dispatch(d, cmd, d->pass_pack.pipeline, d->pack_set, w, h); vkd.CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, d->pass_hist.pipeline); vkd.CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, d->vr_pipeline_layout, 0, 1, @@ -1827,11 +1811,10 @@ void vkr_dis_process(VkrDis* d, VkCommandBuffer cmd, VkImage source, uint32_t wi sizeof(hist_reset), &hist_reset); vkd.CmdDispatch(cmd, (hist_w + DIS_LOCAL_SIZE - 1) / DIS_LOCAL_SIZE, (hist_h + DIS_LOCAL_SIZE - 1) / DIS_LOCAL_SIZE, 1); - dis_compute_barrier(cmd); d->hist_parity = hist_dir; d->hist_valid = true; - // Which real frame a true occlusion takes, one value per level-0 texel. + // Which real frame a true occlusion takes, and how sure, per level-0 texel. dis_dispatch(d, cmd, d->pass_side.pipeline, d->side_sets[slot], w, h); dis_compute_barrier(cmd); } diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_flow_pack.comp b/app/src/main/cpp/winlator/vk/shaders/dis_flow_pack.comp new file mode 100644 index 000000000..3922790e3 --- /dev/null +++ b/app/src/main/cpp/winlator/vk/shaders/dis_flow_pack.comp @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: Copyright 2026 qwertypower (DEVAR Entertainment LLC) +// SPDX-License-Identifier: GPL-3.0-or-later +// +// DIS frame generation: a Vulkan compute realisation of Dense Inverse Search +// optical flow. The algorithm and its reference implementation come from +// OpenCV's DISOpticalFlow, which adopted Till Kroeger's original OF_DIS. +// See CREDITS.md for the full attribution. + +#version 450 + +precision highp float; +precision highp int; + +// Hands the finished level-0 field to the interpolate pass as RG16F, once per source pair. +// +// The interpolate pass reads the field three times per output pixel (the lookup at the +// pixel plus the fixed-point steps towards its source) and it does so for every generated +// frame, at the full output resolution. RG32F is not filterable on every mobile driver, +// and without filtering each of those reads is four fetches and a manual lerp. RG16F is +// filterable everywhere and half the bandwidth; in uv units its precision is a few +// hundredths of a pixel at ordinary speeds and stays under a pixel even at the magnitude +// clamp in dis_vr_add, which is far below what a quarter-frame jump can show. + +layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in; + +layout(set = 0, binding = 0) uniform sampler2D flowIn; +layout(set = 0, binding = 5, rg16f) uniform image2D flowOut; + +void main() { + ivec2 p = ivec2(gl_GlobalInvocationID.xy); + ivec2 sz = imageSize(flowOut); + if (p.x >= sz.x || p.y >= sz.y) return; + + vec2 f = texelFetch(flowIn, p, 0).xy; + // A zero field degrades to repeating the real frame, which is the right answer + // wherever the flow has nothing finite to say. + if (any(isnan(f)) || any(isinf(f))) f = vec2(0.0); + imageStore(flowOut, p, vec4(f, 0.0, 0.0)); +} diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp b/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp index 465d211f0..f08a3e725 100644 --- a/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp +++ b/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp @@ -27,198 +27,50 @@ layout(push_constant) uniform PC { layout(constant_id = 0) const int manualFlowFilter = 0; -// --------------------------------------------------------------------------- -// Static-overlay pass-through. -// -// A HUD, subtitles, a crosshair, a minimap or a letterbox bar does not move with the -// scene, but DIS has no notion of layers: under such an element the field carries the -// background's motion, so the warp drags the element around. Real frames show it in -// place, generated frames do not, and at 60 Hz that alternation is the flicker. -// -// The test needs no extra pass and no extra descriptor. One quantity decides it: -// -// rStatic = |prev(uv) - next(uv)| the two real frames, compared where they sit -// -// If the two real frames agree at a pixel, nothing happened there over the interval, so -// the value at any time between them is that same value and no warp can improve on it. -// The substitution is therefore correct by construction wherever it fires - including on -// a flat patch of moving scenery, where it simply writes the colour the warp would have -// produced anyway. That is why nothing gates it. -// -// An earlier revision multiplied this by "the field claims motion here" and by "the warp -// disagrees with itself". Both were wrong to include. The motion term ramped over 1..3 -// pixels, and under a wide HUD panel the field sags to a couple of pixels rather than to -// zero - so the mask opened halfway exactly where a two-pixel shift of crisp text reads -// as doubling. The disagreement term blocked the mask on low-contrast content, which is -// where the softness is worst. -// -// Cost on top of the existing shader: two full-res fetches, skipped only where the field -// cannot move anything at all, plus a handful of ALU. Nothing scales with the preset, so -// Fast pays the same as Quality. -// --------------------------------------------------------------------------- - -// 0.0 turns the whole block into dead code - the bisect switch. -const float UI_STRENGTH = 1.0; - -// Pure early-out: under a quarter of a pixel the warp cannot move anything, so the two -// fetches would be wasted. Deliberately far below the displacement at which doubling -// becomes visible - this is not a threshold on "is it moving". -const float UI_FLOW_SKIP_PX = 0.25; - -// Dilation. Inside an opaque panel the pair agrees and the pixel is pinned, but on an -// antialiased edge the pixel is alpha*glyph + (1-alpha)*background, the background under -// it moves, the pair disagrees and the mask shuts - so the outline keeps riding off with -// the field while the middle stands still. A pinned interior inside a twitching outline -// is what is left of the flicker. -// -// So a pixel also passes through when a neighbour's pair agrees: take the smallest pair -// difference over a small cross. The edge pixel is then frozen together with the glyph, -// which costs a thread of stale background one pixel wide - far less visible than an -// outline that moves every other frame. -// -// Measured on the model, error on the antialiased edge: 0.228 with no dilation, 0.175 at -// two taps, 0.149 at four taps over 1.5 px, 0.140 at 2.5 px. Background pays nothing up -// to 1.5 px (mask on the background hard against the panel 0.012) and starts to freeze at -// 2.5 px (0.112), so the cross stops at 1.5. -// -// 0, 2 or 4 taps; each tap is two full-res fetches. -// -// OFF, because dis_hist already does exactly this: it takes the minimum of the pair -// difference over a four-point cross and keeps it over time, and it runs ONCE PER SOURCE -// PAIR before the interpolate pass, at half resolution. The cross here recomputed the -// same thing at full resolution on every generated frame - three times per pair - and -// then handed the result to `max(uiMask, histStatic)`, where the history had it already. -// Eight of this pass's fetches for work that was done. The full-res cross could only add -// detail finer than half resolution, and hist's evidence is a max, so it reaches a newly -// appeared overlay in the same pair rather than a frame later. -const int UI_DILATE_TAPS = 0; -const float UI_DILATE_PX = 1.5; - -// Channel difference, 0..1. LO is rgb8 quantisation plus mild dither; HI is where a -// difference is unambiguously real content. -// -// Raise both if the game has visible grain or dithering - about three times sigma. -// -// A translucent HUD is the other reason to raise HI: there the pair never agrees, because -// the moving background shows through, and the difference is (1 - alpha) times the -// background's own. On the model with alpha = 0.7 the error inside such a panel goes -// 0.138 at HI = 0.05, 0.110 at 0.10, 0.081 at 0.20 - but background error goes 0.021, -// 0.031, 0.069 over the same steps. 0.10 is the point where the panel gains more than the -// scene loses; past that the scene starts freezing in earnest. -const float UI_LO = 0.012; -const float UI_HI = 0.050; - -// 0 off, 1 paints the mask green over a dimmed scene across the whole frame, 2 does it on -// the left half only so one build shows the mask and the finished picture side by side. -// The split is taken from the dispatch extent rather than from uv, because uv is -// normalised by imageSize and the two are not guaranteed to agree. -// -// What to expect now that the mask is ungated: solid green over the HUD and over every -// part of the scene that did not change between the two real frames, black only where -// something actually moved. A dark frame with green almost everywhere means the camera -// was still, which is correct - there the generated frame is meant to repeat the real one. -const int DEBUG_UI_MASK = 0; - -// Use the temporally stabilized overlay evidence from dis_hist.comp on top of -// the per-frame test above. 0 falls back to the per-frame test alone. -#define UI_HIST_ENABLE 1 - -// Pick the side for the blend from the sign of the flow divergence instead of -// from t: the side then stays the same for every generated frame of a pair, -// while t crosses 0.5 between them. -#define DIS_OCCL_SIDED 1 +// Fixed-point steps that move the flow lookup from the output pixel to the point of the +// previous frame that actually lands on it. 0 samples the field at the output pixel. +#ifndef DIS_SOURCE_STEPS +#define DIS_SOURCE_STEPS 2 +#endif -// Follow a parabola through three consecutive real frames instead of the straight -// chord of the current pair. 0 falls back to the chord. -#define DIS_QUADRATIC_PATH 1 +// Keep a static overlay in place where the unwarped pair explains the pixel better than +// the warped one. 0 turns it off. +#ifndef DIS_STATIC_SELECT +#define DIS_STATIC_SELECT 1 +#endif + +// Use the side map (sign of the flow divergence) for the frame an occlusion takes, +// instead of the nearer one in time. +#ifndef DIS_OCCL_SIDED +#define DIS_OCCL_SIDED 1 +#endif -// Fetch the warped samples through Catmull-Rom instead of one bilinear tap. 0 falls back -// to bilinear. This is the expensive switch in this file - see warpSample below. +// Catmull-Rom (5 bilinear taps) for the warped fetches instead of one bilinear tap. +#ifndef DIS_WARP_CATMULL #define DIS_WARP_CATMULL 1 +#endif -// --------------------------------------------------------------------------- -// Averaging two warped samples that disagree is what softens the picture. Each sample is -// sharp on its own - one bilinear tap - but where the flow does not line them up the -// blend is a double image, and over a whole frame of wrong flow that reads as a global -// defocus. Where they disagree, take one sample instead of mixing. -// -// The old test summed the three channel differences and ramped over 0.10 .. 0.40, i.e. -// from a third of a step of grey - by then the blur is long since visible, and a purely -// coloured disagreement had to reach 0.10 in a single channel before it counted at all. -// Per-channel max over a lower band both fires earlier and treats colour properly. -// -// The cost of picking is judder: the chosen sample is the nearest real frame warped by a -// vector that was wrong, so the generated frame sits closer to a repeat of a real one. -// The band below is already back at the old behaviour; lower BLEND_LO if the picture -// reads too soft, raise it if the judder shows. -// --------------------------------------------------------------------------- -// Measured against the 68f678c1 blend (`smoothstep(0.10, 0.40, sum of channel diffs)`) -// at equal flow error, as the fraction of pixels that leave the average for a single -// frame. Soft content - sky, grass, fog, most of a frame - is the column that matters: -// -// band soft content: pick / sharpness vs reference -// 68f678c1 sum 0.10/0.40 0.207 - -// max 0.030/0.180 0.388 1.06 -// max 0.050/0.250 0.174 0.94 -// max 0.065/0.300 0.093 0.86 <- here -// max 0.080/0.350 0.048 0.81 -// max 0.100/0.450 0.016 0.77 -// -// Past 0.065/0.300 the picture starts paying real resolution for the smoothness, and by -// 0.100/0.450 mid-contrast content softens too (1.09 -> 0.89), not just the flat parts. -// -// Averaging two samples the flow failed to line up gives a soft frame, and softness -// reads as smoothness: there is nothing sharp for the eye to track, so the per-frame -// position error stops showing. On 68f678c1 that blur did two jobs at once - it hid the -// position error AND it hid the artifacts. The artifacts now have their own machinery -// (the overlay mask, dis_hist, dis_side, the flow clamp, dis_temporal, the parabola), so -// the blend no longer has to hide anything and can go back to the softer setting without -// bringing them back. The per-channel maximum is kept over the old sum: a purely coloured -// disagreement had to reach three times as far before the sum counted it at all. -const float BLEND_LO = 0.065; -const float BLEND_HI = 0.300; - -// --------------------------------------------------------------------------- -// Scene cut, and the same thing by another name: tracking that gave up. -// -// When the frame is replaced wholesale - a pause menu over gameplay - there is no -// correspondence to find. The search keeps whatever the residual happened to favour, the -// magnitude clamp in dis_vr_add bounds it to a quarter of the frame, and what is left is -// bounded garbage with the block structure of the search grid. Dragging a menu's own -// black panels and white text around with that field is what puts black and white squares -// on the screen. -// -// Two residuals decide it, and it works because it is TWO of them: -// -// |s0 - s1| the real pair, compared where it sits -// |c0 - c1| the same pair after the warp tried to reconcile it -// -// A fast pan pulls the first one far apart - median 0.290 in the runs below - but the warp -// closes it, so the second is 0.000. On a cut nothing closes it: 0.588 and 0.584. The -// minimum of the two separates the cases by an order of magnitude. Fraction of pixels -// above the threshold: -// -// case 0.25 0.35 0.45 -// static scene 0.000 0.000 0.000 -// pan, flow correct 0.035 0.009 0.001 -// pan, tracking lost 0.368 0.107 0.020 -// cut to a menu 0.992 0.936 0.745 -// -// This also retires the conclusion in dis-interpolation-glitches.md that a cut needs a -// global reduction to be told apart from lost tracking. It does - but the correct response -// to both is the same, show a real frame, so they never needed telling apart. -// -// The response is the nearest real frame UNWARPED. Warping it is what broke it. Over the -// three generated frames of a pair that reads as s0, s1, s1, i.e. a clean hold across the -// cut. Both residuals and both frames are already in registers here, so this costs no -// fetches at all. -const float CUT_LO = 0.30; -const float CUT_HI = 0.55; - -// xy: this pair's chord. zw: the previous pair's chord for the same content, advected -// here by dis_temporal - one fetch carries both, which is why the field is RGBA. -vec4 sampleFlow(vec2 uv) { - if (manualFlowFilter == 0) return textureLod(flowTex, uv, 0.0); +// Take the stabilised overlay confidence from dis_hist on top of the per-pixel test. +#ifndef DIS_USE_HIST +#define DIS_USE_HIST 1 +#endif + +#ifndef SIDE_LO +#define SIDE_LO 4.0 +#endif +#ifndef SIDE_HI +#define SIDE_HI 12.0 +#endif + +#ifndef OCCL_LO +#define OCCL_LO 0.20 +#endif +#ifndef OCCL_HI +#define OCCL_HI 0.60 +#endif + +vec2 sampleFlow(vec2 uv) { + if (manualFlowFilter == 0) return textureLod(flowTex, uv, 0.0).xy; vec2 sz = vec2(textureSize(flowTex, 0)); vec2 p = uv * sz - 0.5; @@ -226,10 +78,10 @@ vec4 sampleFlow(vec2 uv) { ivec2 i0 = ivec2(floor(p)); ivec2 mx = ivec2(sz) - 1; - vec4 a = texelFetch(flowTex, clamp(i0, ivec2(0), mx), 0); - vec4 b = texelFetch(flowTex, clamp(i0 + ivec2(1, 0), ivec2(0), mx), 0); - vec4 c = texelFetch(flowTex, clamp(i0 + ivec2(0, 1), ivec2(0), mx), 0); - vec4 e = texelFetch(flowTex, clamp(i0 + ivec2(1, 1), ivec2(0), mx), 0); + vec2 a = texelFetch(flowTex, clamp(i0, ivec2(0), mx), 0).xy; + vec2 b = texelFetch(flowTex, clamp(i0 + ivec2(1, 0), ivec2(0), mx), 0).xy; + vec2 c = texelFetch(flowTex, clamp(i0 + ivec2(0, 1), ivec2(0), mx), 0).xy; + vec2 e = texelFetch(flowTex, clamp(i0 + ivec2(1, 1), ivec2(0), mx), 0).xy; return mix(mix(a, b, frac.x), mix(c, e, frac.x), frac.y); } @@ -237,40 +89,6 @@ float maxChannel(vec3 v) { return max(max(v.x, v.y), v.z); } -// A real frame reaches the screen unresampled, one to one. A generated one is fetched at -// a fractional offset, and bilinear at half a texel throws away almost half the high -// frequencies. Measured against the unwarped frame, with a deliberately CORRECT flow so -// this is the filter's own cost and nothing else: -// -// fractional offset bilinear Catmull-Rom -// 0.000 1.000 1.000 -// 0.125 0.839 0.955 -// 0.250 0.699 0.844 -// 0.375 0.602 0.727 -// 0.500 0.564 0.675 -// -// So three of every four frames arrive softer than their neighbours by an amount that -// moves with the motion. That is a 30 Hz pulse of sharpness, and no amount of work on the -// flow touches it: the field can be exact and the filter still eats the detail. -// -// Catmull-Rom does not close the gap - no cheap filter invents what the sampling grid did -// not keep - but it moves the worst case from 0.564 to 0.675 and the common quarter-texel -// case from 0.699 to 0.844. -// -// Five bilinear taps, not nine: the four corners are products of two small negative lobes -// and carry nothing. Measured on a diagonal offset, sharpness against the unwarped frame -// and the mean difference between the two versions: -// -// fx fy bilinear 5 taps 9 taps |5-9| -// 0.500 0.500 0.453 0.584 0.577 0.0013 -// 0.250 0.250 0.584 0.780 0.774 0.0009 -// 0.125 0.125 0.756 0.933 0.930 0.0004 -// -// Five taps come out marginally AHEAD of nine once renormalised, and the difference -// between them is a third of an rgb8 step. DIS_WARP_CATMULL = 0 goes back to bilinear. -// -// The sampler is CLAMP_TO_EDGE, so the taps reaching one and a half texels past the -// border pick up the edge colour rather than black. vec3 warpSample(sampler2D tex, vec2 uv, vec2 texSize) { #if DIS_WARP_CATMULL == 0 return textureLod(tex, clamp(uv, vec2(0.0), vec2(1.0)), 0.0).xyz; @@ -278,45 +96,27 @@ vec3 warpSample(sampler2D tex, vec2 uv, vec2 texSize) { vec2 samplePos = uv * texSize; vec2 texPos1 = floor(samplePos - 0.5) + 0.5; vec2 fr = samplePos - texPos1; - vec2 w0 = fr * (-0.5 + fr * (1.0 - 0.5 * fr)); vec2 w1 = 1.0 + fr * fr * (-2.5 + 1.5 * fr); vec2 w2 = fr * (0.5 + fr * (2.0 - 1.5 * fr)); vec2 w3 = fr * fr * (-0.5 + 0.5 * fr); - - // The middle pair is taken as one bilinear tap placed between them - that is what - // turns sixteen taps into nine. w12 stays near 1 over the whole range, so the - // division is safe. vec2 w12 = w1 + w2; vec2 off12 = w2 / w12; - vec2 inv = 1.0 / texSize; vec2 p0 = clamp((texPos1 - 1.0) * inv, vec2(0.0), vec2(1.0)); vec2 p3 = clamp((texPos1 + 2.0) * inv, vec2(0.0), vec2(1.0)); vec2 p12 = clamp((texPos1 + off12) * inv, vec2(0.0), vec2(1.0)); - vec3 r = vec3(0.0); r += textureLod(tex, vec2(p12.x, p0.y), 0.0).xyz * (w12.x * w0.y); r += textureLod(tex, vec2(p0.x, p12.y), 0.0).xyz * (w0.x * w12.y); r += textureLod(tex, vec2(p12.x, p12.y), 0.0).xyz * (w12.x * w12.y); r += textureLod(tex, vec2(p3.x, p12.y), 0.0).xyz * (w3.x * w12.y); r += textureLod(tex, vec2(p12.x, p3.y), 0.0).xyz * (w12.x * w3.y); - - // Renormalise for the four dropped corners. Per axis w0 + w12 + w3 = 1 exactly, so - // the five remaining weights sum to this closed form - no extra adds needed. r /= w12.x + w12.y * (1.0 - w12.x); - - // The negative lobes can ring past the source range. return clamp(r, vec3(0.0), vec3(1.0)); #endif } -// How far apart the two real frames are at one point, sampled where they sit. -float pairDiff(vec2 p) { - vec2 q = clamp(p, vec2(0.0), vec2(1.0)); - return maxChannel(abs(textureLod(prevColor, q, 0.0).xyz - textureLod(nextColor, q, 0.0).xyz)); -} - vec3 hsv2rgb(vec3 c) { vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0); vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www); @@ -333,29 +133,17 @@ void main() { const float GUARD_PX = 20.0; vec2 guard = GUARD_PX * texel; - vec2 dEdge = min(uv, 1.0 - uv); vec2 ramp = clamp((dEdge - guard) / max(guard, texel), 0.0, 1.0); vec2 eased = ramp * ramp * (3.0 - 2.0 * ramp); float edgeMix = min(eased.x, eased.y); - vec4 fs = sampleFlow(uv); + vec2 f = sampleFlow(uv); if (edgeMix < 1.0) { - vec4 fInner = sampleFlow(clamp(uv, guard, 1.0 - guard)); - fs = mix(fInner, fs, edgeMix); + vec2 fInner = sampleFlow(clamp(uv, guard, 1.0 - guard)); + f = mix(fInner, f, edgeMix); } - // Last line of defence on the field. A non-finite value turns uv0/uv1 into NaN, and a - // NaN texture coordinate is an undefined fetch that reaches rgba8 as whatever the - // hardware felt like. The parabola can manufacture one from finite parts too: with - // fPrev infinite, (f + fPrev) and (f - fPrev) are +Inf and -Inf and their weighted sum - // is NaN. Upstream guards its own outputs; this is two instructions for the case where - // one of them is ever wrong again. - if (any(isnan(fs)) || any(isinf(fs))) fs = vec4(0.0); - - vec2 f = fs.xy; - // dis_temporal stores f itself in zw whenever it found no usable history, so the - // parabola below degenerates to the chord on its own in that case. - vec2 fPrev = DIS_QUADRATIC_PATH != 0 ? fs.zw : fs.xy; + if (any(isnan(f)) || any(isinf(f))) f = vec2(0.0); if (pc.debugMode != 0) { float m = length(f) * float(size.x) / 16.0; @@ -365,60 +153,54 @@ void main() { return; } - float flowPx = length(f * vec2(size)); - - // Under a quarter of a pixel the shift cannot change an output pixel visibly - - // the same premise the overlay pass-through uses when it skips its own fetches - // at this threshold. The warped pair, the side map and the edge dilation are - // then all dropped and the real pair is blended directly. Static scenery and - // HUD-heavy screens are largely made of such pixels, and this pass costs the - // most per output pixel in the chain. - if (flowPx <= UI_FLOW_SKIP_PX) { - vec3 e0 = textureLod(prevColor, uv, 0.0).xyz; - vec3 e1 = textureLod(nextColor, uv, 0.0).xyz; - vec3 still = mix(e0, e1, pc.t); - float disagree = maxChannel(abs(e0 - e1)); - float pick = smoothstep(BLEND_LO, BLEND_HI, disagree); - vec3 result = mix(still, pc.t < 0.5 ? e0 : e1, pick); - - float uiMask = UI_STRENGTH * (1.0 - smoothstep(UI_LO, UI_HI, disagree)); -#if UI_HIST_ENABLE - float histStatic = textureLod(histTex, uv, 0.0).r; - histStatic *= 1.0 - smoothstep(0.30, 0.60, disagree); - uiMask = max(uiMask, histStatic); + // The field is defined on the previous frame's grid: f(x) carries the content at x in + // the previous frame to x + f(x) in the next. What reaches the output pixel at time t + // is the content whose x + t*f(x) equals uv, so the vector has to be read at x, not at + // uv. Reading it at uv is the halo: near a moving edge uv sits on the other layer in + // the previous frame. A couple of fixed-point steps find x wherever the field is + // smooth enough to have one. + vec2 fa = f; +#if DIS_SOURCE_STEPS > 0 + for (int i = 0; i < DIS_SOURCE_STEPS; i++) { + vec2 fn = sampleFlow(clamp(uv - pc.t * fa, vec2(0.0), vec2(1.0))); + if (any(isnan(fn)) || any(isinf(fn))) break; + fa = fn; + } + fa = mix(f, fa, edgeMix); #endif - result = mix(result, still, uiMask); - bool leftHalfE = uint(pix.x) * 2u < gl_NumWorkGroups.x * gl_WorkGroupSize.x; - if (DEBUG_UI_MASK == 1 || (DEBUG_UI_MASK == 2 && leftHalfE)) { - result = mix(result * 0.15, vec3(0.1, 1.0, 0.2), uiMask); - } - imageStore(outImage, pix, vec4(result, 1.0)); + // The real pair where it sits: the early-out below and the static test at the end both + // need it. + vec3 s0 = textureLod(prevColor, uv, 0.0).xyz; + vec3 s1 = textureLod(nextColor, uv, 0.0).xyz; + float rStatic = dot(abs(s0 - s1), vec3(1.0)); + + // Under a quarter of a pixel no warp can change an output pixel visibly, and where the + // pair also agrees nothing crossed the pixel either, so the ten warped fetches below + // would buy nothing. The agreement test matters: a still background just ahead of a + // moving object has a zero field too, and there the occlusion handling below is what + // brings the object in. Static scenery and HUD-heavy screens are mostly this case. + vec2 fpx = max(abs(f), abs(fa)) * vec2(size); + if (max(fpx.x, fpx.y) <= 0.25 && rStatic < 0.03) { + imageStore(outImage, pix, vec4(mix(s0, s1, pc.t), 1.0)); return; } - // Displacement from the previous real frame to time t. A chord is straight, so its - // direction changes abruptly at every real frame and that break repeats at the source - // rate; 30 Hz of it is a large part of why 120 made from 30 does not read as 120. The - // parabola through three consecutive real frames removes it. Measured against a true - // path: position error 0.469 -> 0.000 under constant acceleration, 3.027 -> 0.450 on a - // smooth arc, 12.34 -> 6.07 on a mouse flick, with the velocity break falling the same - // way. On a uniform pan a chord is already exact and the parabola matches it. - // - // A reversal would make a parabola overshoot, but by then dis_temporal's gate has set - // fPrev = f, so those frames fall back to the chord by construction. - vec2 d = 0.5 * pc.t * (f + fPrev) + 0.5 * pc.t * pc.t * (f - fPrev); - vec2 uv0 = uv - d; - vec2 uv1 = uv + (f - d); + vec2 uv0 = uv - pc.t * fa; + vec2 uv1 = uv + (1.0 - pc.t) * fa; vec3 c0 = warpSample(prevColor, uv0, vec2(size)); vec3 c1 = warpSample(nextColor, uv1, vec2(size)); vec3 single = pc.t < 0.5 ? c0 : c1; #if DIS_OCCL_SIDED - // The side choice is precomputed per flow texel by dis_side.comp, so this is - // one tap instead of four flow fetches and it does not change with t. - single = textureLod(sideTex, uv, 0.0).r > 0.5 ? c1 : c0; + // Where the field opens up (divergence > 0) the content is new and only the next frame + // has it; where it closes, only the previous. The sign is only trusted across a real + // motion boundary - a few pixels of divergence is estimation noise, and there the + // nearer frame in time is the better guess. + float div = textureLod(sideTex, uv, 0.0).r; + float sure = smoothstep(SIDE_LO, SIDE_HI, abs(div)); + single = mix(single, div > 0.0 ? c1 : c0, sure); #endif const float FEATHER_PX = 8.0; @@ -436,60 +218,24 @@ void main() { ? (c0 * w0 + c1 * w1) / wsum : (out0 <= out1 ? c0 : c1); - float disagree = maxChannel(abs(c0 - c1)); - float pick = smoothstep(BLEND_LO, BLEND_HI, disagree); - result = mix(result, single, pick); - - // --- static-overlay pass-through --------------------------------------- - // The unwarped pair is fetched unconditionally now: the cut gate of the - // stabilized mask below needs its difference even where the flow early-out - // would have skipped the per-frame test. - vec3 s0 = textureLod(prevColor, uv, 0.0).xyz; - vec3 s1 = textureLod(nextColor, uv, 0.0).xyz; - float pairDiffHere = maxChannel(abs(s0 - s1)); - - float uiMask = 0.0; - vec3 uiColor = mix(s0, s1, pc.t); - - if (UI_STRENGTH > 0.0 && flowPx > UI_FLOW_SKIP_PX) { - float rStatic = pairDiffHere; - - if (UI_DILATE_TAPS > 0) { - vec2 d = UI_DILATE_PX * texel; - rStatic = min(rStatic, pairDiff(uv + vec2(d.x, 0.0))); - rStatic = min(rStatic, pairDiff(uv - vec2(d.x, 0.0))); - if (UI_DILATE_TAPS > 2) { - rStatic = min(rStatic, pairDiff(uv + vec2(0.0, d.y))); - rStatic = min(rStatic, pairDiff(uv - vec2(0.0, d.y))); - } - } - - uiMask = UI_STRENGTH * (1.0 - smoothstep(UI_LO, UI_HI, rStatic)); - } - -#if UI_HIST_ENABLE - // The half-resolution history keeps the same decision over several frames, so - // a per-frame test whose value hovers around the thresholds cannot flicker. - // A large current difference means a cut or a scene change, where the old - // element is gone: suppress the history there rather than holding it. + float rMotion = dot(abs(c0 - c1), vec3(1.0)); + float occl = smoothstep(OCCL_LO, OCCL_HI, rMotion); + result = mix(result, single, occl); + +#if DIS_STATIC_SELECT + // Two hypotheses for this pixel, scored by how well each reconciles the real pair: + // "it moved with the field" (c0 vs c1) and "it stood still" (the pair where it sits). + // A HUD over a moving scene scores near zero standing still and badly moving; the + // scene itself scores the other way round. Only a clear win for standing still pins + // the pixel, so moving content keeps its motion even where it is flat. + float keep = (1.0 - smoothstep(0.03, 0.12, rStatic)) * + smoothstep(0.02, 0.10, rMotion - rStatic); +#if DIS_USE_HIST float histStatic = textureLod(histTex, uv, 0.0).r; - histStatic *= 1.0 - smoothstep(0.30, 0.60, pairDiffHere); - uiMask = max(uiMask, histStatic); + keep = max(keep, histStatic * smoothstep(0.02, 0.10, rMotion - rStatic)); +#endif + result = mix(result, mix(s0, s1, pc.t), keep); #endif - - result = mix(result, uiColor, uiMask); - - // --- scene cut / lost tracking ----------------------------------------- - // Last, so it overrides the warp, the pick and the overlay mask alike. On a cut the - // overlay mask is shut anyway - rStatic is huge - and dis_hist has its own cut gate, - // so there is nothing here to fight with. - float cut = smoothstep(CUT_LO, CUT_HI, min(pairDiffHere, disagree)); - result = mix(result, pc.t < 0.5 ? s0 : s1, cut); - - bool leftHalf = uint(pix.x) * 2u < gl_NumWorkGroups.x * gl_WorkGroupSize.x; - if (DEBUG_UI_MASK == 1 || (DEBUG_UI_MASK == 2 && leftHalf)) { - result = mix(result * 0.15, vec3(0.1, 1.0, 0.2), uiMask); - } imageStore(outImage, pix, vec4(result, 1.0)); } diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_side.comp b/app/src/main/cpp/winlator/vk/shaders/dis_side.comp index 4500e6031..455447ebd 100644 --- a/app/src/main/cpp/winlator/vk/shaders/dis_side.comp +++ b/app/src/main/cpp/winlator/vk/shaders/dis_side.comp @@ -11,7 +11,7 @@ precision highp float; precision highp int; -// One bit per level-0 flow texel: which real frame a true occlusion should take. +// Per level-0 flow texel: which real frame a true occlusion should take, and how sure. // The sign of the flow divergence over a wide window decides it - a diverging // field is area opening up, which only the next frame has, a converging one only // the previous. Computed once per pair at the flow resolution, it replaces four @@ -38,5 +38,8 @@ void main() { vec2 fU = textureLod(flowMap, clamp(uv - vec2(0.0, fo.y), vec2(0.0), vec2(1.0)), 0.0).xy; vec2 fD = textureLod(flowMap, clamp(uv + vec2(0.0, fo.y), vec2(0.0), vec2(1.0)), 0.0).xy; float divergence = (fR.x - fL.x) * uSize.x + (fD.y - fU.y) * uSize.y; - imageStore(sideOut, p, vec4(divergence > 0.0 ? 1.0 : 0.0, 0.0, 0.0, 0.0)); + // Signed, in flow-resolution pixels across the window: the interpolate pass needs the + // magnitude too, to tell a real occlusion boundary from estimation noise. + if (isnan(divergence) || isinf(divergence)) divergence = 0.0; + imageStore(sideOut, p, vec4(divergence, 0.0, 0.0, 0.0)); } diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_temporal.comp b/app/src/main/cpp/winlator/vk/shaders/dis_temporal.comp deleted file mode 100644 index 5bf4a328d..000000000 --- a/app/src/main/cpp/winlator/vk/shaders/dis_temporal.comp +++ /dev/null @@ -1,128 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2026 qwertypower (DEVAR Entertainment LLC) -// SPDX-License-Identifier: GPL-3.0-or-later -// -// DIS frame generation: a Vulkan compute realisation of Dense Inverse Search -// optical flow. The algorithm and its reference implementation come from -// OpenCV's DISOpticalFlow, which adopted Till Kroeger's original OF_DIS. -// See CREDITS.md for the full attribution. - -#version 450 - -precision highp float; -precision highp int; - -// Temporal consistency of the final field, at the flow resolution, once per -// source pair. -// -// Every pair is estimated from scratch - the coarsest level starts from zero - -// so the estimation error is redrawn independently each time. A still picture -// never shows it: the field is about as accurate as before. What it costs is -// smoothness, because the generated frames sit at pos + t*f, and a field that -// jitters by a few pixels between pairs moves them back and forth around the -// true path. The eye reads that as a parasitic acceleration at the source rate. -// -// Modelled over long runs (displayed position vs the true path, jitter measured -// as the spread of the second difference): -// -// scenario sigma raw EMA 0.3 + gate -// uniform pan 6 px 3.88 1.82 -// uniform pan 15 px 9.36 4.48 -// hard ramp 40->160 over 20 pairs 15 px 9.34 5.29 -// camera reversal +-120 6 px 3.85 1.93 -// scene cut 6 px 3.95 1.89 -// -// Jitter roughly halves and the position error drops by a third. It wins on the -// ramp and the reversal too: the field's genuine change per pair is smaller than -// the estimation noise, so the lag costs less than the jitter. -// -// THE GATE IS NOT OPTIONAL. Without it the same runs get worse, not better: -// 3.85 -> 6.88 on the reversal and 3.95 -> 11.49 on the cut, because the history -// is then averaged into a field that has nothing to do with it any more. -// -// Advection: the history at this texel describes content that has since moved -// on, so it is read where that content came from, one dependent fetch. At a -// disocclusion there is no valid history at all, and that is the same gate. -// -// Cost: one dispatch at the flow resolution per source pair - 320x180 on Fast - -// and two flow-resolution images, RGBA32F because zw carries the previous chord for -// the interpolate pass's parabola. Nothing per output pixel beyond widening one fetch -// that pass already made, so the preset does not notice. - -// Weight of the new measurement. Higher keeps more of the new field. -// -// 0.3 measured best when this pass stood alone. It does not any more, and the reason is -// worth writing down: the interpolate pass now follows a parabola through three real -// frames, and that parabola lives on the SECOND DIFFERENCE of the field - how the chord -// changed between pairs. An EMA is exactly what erases a second difference. The two -// features want opposite things from the same signal. -// -// Jitter over long runs, re-measured with the parabola in place: -// -// scenario a=0.30 0.35 0.45 0.55 0.70 1.00 -// pan 2.28 2.45 2.78 3.10 3.60 4.69 -// arc 12.15 11.61 9.88 8.43 6.60 5.22 -// ramp 4.32 4.05 3.85 3.96 4.27 5.17 -// reversal 2.36 2.52 2.84 3.16 3.63 4.61 -// -// The arc is ordinary camera movement and its numbers dwarf the rest, so it decides the -// feel; heavy smoothing costs it more than it gains anywhere else. Summed, the minimum -// sits around 0.6-0.7. 0.55 is a step short of it on purpose: real straight-line motion -// still benefits from noise suppression, and the sigma those runs assume is an estimate, -// so the headroom is worth more on that side. -#define DIS_TEMPORAL_ALPHA 0.55 - -// Drop the history when the new field departs from it by more than this. The -// floor covers a still field; the relative term keeps the gate meaningful when -// the whole frame moves by a hundred pixels. -#define DIS_TEMPORAL_GATE_PX 8.0 -#define DIS_TEMPORAL_GATE_REL 0.35 - -layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in; - -layout(set = 0, binding = 0) uniform sampler2D flowNew; -layout(set = 0, binding = 1) uniform sampler2D flowHist; -layout(set = 0, binding = 5, rgba32f) uniform image2D flowOut; - -layout(push_constant) uniform PC { - int reset; -} pc; - -void main() { - ivec2 p = ivec2(gl_GlobalInvocationID.xy); - ivec2 sz = imageSize(flowOut); - if (p.x >= sz.x || p.y >= sz.y) return; - - vec2 uSize = vec2(sz); - vec2 uv = (vec2(p) + 0.5) / uSize; - - // Stored in normalised uv units, as the rest of the chain expects. - vec2 fNew = texelFetch(flowNew, p, 0).xy; - - if (pc.reset != 0) { - imageStore(flowOut, p, vec4(fNew, fNew)); - return; - } - - vec2 back = clamp(uv - fNew, vec2(0.0), vec2(1.0)); - vec2 fOld = textureLod(flowHist, back, 0.0).xy; - - float gate = DIS_TEMPORAL_GATE_PX + DIS_TEMPORAL_GATE_REL * length(fNew * uSize); - bool usable = length((fNew - fOld) * uSize) <= gate; - - vec2 fOut = usable ? mix(fOld, fNew, DIS_TEMPORAL_ALPHA) : fNew; - - // zw is the previous pair's chord for this same content, which the interpolate pass - // needs for its parabola. Writing fNew where there is no usable history is what makes - // that parabola degenerate to the chord instead of inventing curvature - and it is the - // same condition, so a reversal or a cut is covered once, here. - vec2 fPrev = usable ? fOld : fNew; - - // Zero, not fNew: fNew is the value that may BE the non-finite one, and falling back - // to it makes the guard a no-op. That is exactly the mistake the old vr_add guard made - // when it fell back to W. A zero field degrades to repeating the real frame, which is - // the right answer whenever the flow has nothing to say. - if (any(isnan(fOut)) || any(isinf(fOut))) fOut = vec2(0.0); - if (any(isnan(fPrev)) || any(isinf(fPrev))) fPrev = vec2(0.0); - - imageStore(flowOut, p, vec4(fOut, fPrev)); -} diff --git a/docs/FRAME-GENERATION.md b/docs/FRAME-GENERATION.md index 1bb568623..8f375b5e4 100644 --- a/docs/FRAME-GENERATION.md +++ b/docs/FRAME-GENERATION.md @@ -29,7 +29,7 @@ the shaders import successfully. ## DIS **Nothing to buy, nothing to import.** DIS is a complete open-source Dense Inverse Search frame -generator built into WinNative — fourteen compute shaders that ship with the APK — so it works on +generator built into WinNative — sixteen compute shaders that ship with the APK — so it works on a fresh install with no Steam account and no `Lossless.dll`. Turn it on in the **FG** tab and it runs. From c597619dd0109eb992db311554c8e2289653e246 Mon Sep 17 00:00:00 2001 From: qwertypower <1qwertypower1@gmail.com> Date: Sat, 26 Sep 2026 19:32:44 +0300 Subject: [PATCH 17/17] DIS: move into its own module, add optional GL_QCOM_motion_estimation hint The generator now lives in app/src/main/cpp/dis as the wndis static library (public header and GLSL shaders under include/), linked by libwinlator and libwnwayland instead of being compiled into each. On Adreno the GLES motion estimator can seed the level-2 search as a second candidate; it is off by default (debug.winnative.dis.hwme=1 enables it) since it measured quality-neutral at ~2.5 ms per frame. --- app/src/main/cpp/CMakeLists.txt | 22 +- app/src/main/cpp/dis/CMakeLists.txt | 79 ++++ .../include}/shaders/dis_densify.comp | 0 .../include}/shaders/dis_flow_pack.comp | 0 .../include}/shaders/dis_gradient.comp | 0 .../vk => dis/include}/shaders/dis_hist.comp | 0 .../include}/shaders/dis_interpolate.comp | 0 .../include}/shaders/dis_inverse_search.comp | 45 +- .../vk => dis/include}/shaders/dis_luma.comp | 0 .../include}/shaders/dis_luma_r16.comp | 0 .../include}/shaders/dis_luma_r32.comp | 0 .../cpp/dis/include/shaders/dis_me_luma.comp | 53 +++ .../include}/shaders/dis_propagate.comp | 0 .../vk => dis/include}/shaders/dis_side.comp | 0 .../include}/shaders/dis_vr_add.comp | 0 .../include}/shaders/dis_vr_coef.comp | 0 .../vk => dis/include}/shaders/dis_vr_d1.comp | 0 .../vk => dis/include}/shaders/dis_vr_d2.comp | 0 .../include}/shaders/dis_vr_prep.comp | 0 .../include}/shaders/dis_vr_sor.comp | 0 .../vk => dis/include}/shaders/dis_vr_w.comp | 0 .../vk/dis => dis/include}/vkr_dis.h | 21 +- app/src/main/cpp/dis/src/dis_qcom_me.c | 373 +++++++++++++++ app/src/main/cpp/dis/src/dis_qcom_me.h | 30 ++ .../{winlator/vk/dis => dis/src}/vkr_dis.c | 440 +++++++++++++++++- app/src/main/cpp/waylandcomp/CMakeLists.txt | 10 +- .../cpp/waylandcomp/src/framegen_engine.c | 2 +- .../cpp/winlator/vk/framegen/fg_present.c | 35 +- app/src/main/cpp/winlator/vk/vk_dispatch.c | 1 + app/src/main/cpp/winlator/vk/vk_dispatch.h | 2 + app/src/main/cpp/winlator/vk/vk_renderer.c | 42 +- app/src/main/cpp/winlator/vk/vk_state.h | 3 +- 32 files changed, 1116 insertions(+), 42 deletions(-) create mode 100644 app/src/main/cpp/dis/CMakeLists.txt rename app/src/main/cpp/{winlator/vk => dis/include}/shaders/dis_densify.comp (100%) rename app/src/main/cpp/{winlator/vk => dis/include}/shaders/dis_flow_pack.comp (100%) rename app/src/main/cpp/{winlator/vk => dis/include}/shaders/dis_gradient.comp (100%) rename app/src/main/cpp/{winlator/vk => dis/include}/shaders/dis_hist.comp (100%) rename app/src/main/cpp/{winlator/vk => dis/include}/shaders/dis_interpolate.comp (100%) rename app/src/main/cpp/{winlator/vk => dis/include}/shaders/dis_inverse_search.comp (82%) rename app/src/main/cpp/{winlator/vk => dis/include}/shaders/dis_luma.comp (100%) rename app/src/main/cpp/{winlator/vk => dis/include}/shaders/dis_luma_r16.comp (100%) rename app/src/main/cpp/{winlator/vk => dis/include}/shaders/dis_luma_r32.comp (100%) create mode 100644 app/src/main/cpp/dis/include/shaders/dis_me_luma.comp rename app/src/main/cpp/{winlator/vk => dis/include}/shaders/dis_propagate.comp (100%) rename app/src/main/cpp/{winlator/vk => dis/include}/shaders/dis_side.comp (100%) rename app/src/main/cpp/{winlator/vk => dis/include}/shaders/dis_vr_add.comp (100%) rename app/src/main/cpp/{winlator/vk => dis/include}/shaders/dis_vr_coef.comp (100%) rename app/src/main/cpp/{winlator/vk => dis/include}/shaders/dis_vr_d1.comp (100%) rename app/src/main/cpp/{winlator/vk => dis/include}/shaders/dis_vr_d2.comp (100%) rename app/src/main/cpp/{winlator/vk => dis/include}/shaders/dis_vr_prep.comp (100%) rename app/src/main/cpp/{winlator/vk => dis/include}/shaders/dis_vr_sor.comp (100%) rename app/src/main/cpp/{winlator/vk => dis/include}/shaders/dis_vr_w.comp (100%) rename app/src/main/cpp/{winlator/vk/dis => dis/include}/vkr_dis.h (62%) create mode 100644 app/src/main/cpp/dis/src/dis_qcom_me.c create mode 100644 app/src/main/cpp/dis/src/dis_qcom_me.h rename app/src/main/cpp/{winlator/vk/dis => dis/src}/vkr_dis.c (80%) diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt index 15ae9ab71..25b4d73c2 100644 --- a/app/src/main/cpp/CMakeLists.txt +++ b/app/src/main/cpp/CMakeLists.txt @@ -103,23 +103,6 @@ set(SHADER_LIST "effect_colorblind:frag:effect_colorblind_frag" "effect_pixelate:frag:effect_pixelate_frag" "sgsr1:frag:sgsr1_frag" - "dis_luma_r16:comp:dis_luma_r16_comp" - "dis_luma_r32:comp:dis_luma_r32_comp" - "dis_gradient:comp:dis_gradient_comp" - "dis_inverse_search:comp:dis_inverse_search_comp" - "dis_propagate:comp:dis_propagate_comp" - "dis_densify:comp:dis_densify_comp" - "dis_interpolate:comp:dis_interpolate_comp" - "dis_hist:comp:dis_hist_comp" - "dis_side:comp:dis_side_comp" - "dis_flow_pack:comp:dis_flow_pack_comp" - "dis_vr_prep:comp:dis_vr_prep_comp" - "dis_vr_d1:comp:dis_vr_d1_comp" - "dis_vr_d2:comp:dis_vr_d2_comp" - "dis_vr_w:comp:dis_vr_w_comp" - "dis_vr_coef:comp:dis_vr_coef_comp" - "dis_vr_sor:comp:dis_vr_sor_comp" - "dis_vr_add:comp:dis_vr_add_comp" ) set(SHADER_HEADERS "") @@ -149,6 +132,9 @@ endforeach() add_custom_target(winlator_shaders DEPENDS ${SHADER_HEADERS}) +# DIS frame generator (optical flow + interpolation), linked by libwinlator and libwnwayland. +add_subdirectory(dis) + # ---------------------------------------------------------------------------- # Winlator native library (X-server, AHB, Vulkan compositor, helpers) # ---------------------------------------------------------------------------- @@ -183,7 +169,6 @@ add_library(winlator SHARED winlator/vk/lsfg/lsfg_jni.c winlator/vk/framegen/fg_present.c winlator/vk/framegen/fg_jni.c - winlator/vk/dis/vkr_dis.c ) add_dependencies(winlator winlator_shaders) @@ -201,6 +186,7 @@ target_include_directories(winlator PRIVATE target_compile_features(winlator PRIVATE cxx_std_17) target_link_libraries(winlator + wndis log android mediandk diff --git a/app/src/main/cpp/dis/CMakeLists.txt b/app/src/main/cpp/dis/CMakeLists.txt new file mode 100644 index 000000000..d13b69cd2 --- /dev/null +++ b/app/src/main/cpp/dis/CMakeLists.txt @@ -0,0 +1,79 @@ +# DIS frame generator: Dense Inverse Search optical flow and the frame interpolator built on it, +# as one static library. libwinlator (X11 compositor, standalone presenter) and libwnwayland +# (Wayland compositor) both link it; each keeps its own VkDispatch, which the objects here +# resolve against at link time. +# +# Layout: +# include/vkr_dis.h public API +# include/shaders/*.comp GLSL compute shaders, compiled to SPIR-V headers at build time +# src/ implementation +# +# Uses GLSLC and BIN2C_SCRIPT from the parent CMakeLists. + +set(DIS_SHADER_SRC_DIR "${CMAKE_CURRENT_SOURCE_DIR}/include/shaders") +set(DIS_SHADER_OUT_DIR "${CMAKE_CURRENT_BINARY_DIR}/shaders") +file(MAKE_DIRECTORY "${DIS_SHADER_OUT_DIR}") + +set(DIS_SHADERS + dis_luma_r16 + dis_luma_r32 + dis_gradient + dis_inverse_search + dis_propagate + dis_densify + dis_interpolate + dis_hist + dis_side + dis_flow_pack + dis_vr_prep + dis_vr_d1 + dis_vr_d2 + dis_vr_w + dis_vr_coef + dis_vr_sor + dis_vr_add + dis_me_luma +) + +set(DIS_SHADER_HEADERS "") +foreach(base ${DIS_SHADERS}) + set(var "${base}_comp") + set(input "${DIS_SHADER_SRC_DIR}/${base}.comp") + set(spv "${DIS_SHADER_OUT_DIR}/${var}.spv") + set(hdr "${DIS_SHADER_OUT_DIR}/${var}.spv.h") + add_custom_command( + OUTPUT "${hdr}" + COMMAND "${GLSLC}" --target-env=vulkan1.1 -O "${input}" -o "${spv}" + COMMAND "${CMAKE_COMMAND}" + -DINPUT_FILE=${spv} + -DOUTPUT_FILE=${hdr} + -DVAR_NAME=${var} + -P "${BIN2C_SCRIPT}" + DEPENDS "${input}" "${BIN2C_SCRIPT}" + COMMENT "Compiling DIS shader ${base}.comp -> ${var}.spv.h" + VERBATIM + ) + list(APPEND DIS_SHADER_HEADERS "${hdr}") +endforeach() + +add_custom_target(wndis_shaders DEPENDS ${DIS_SHADER_HEADERS}) + +add_library(wndis STATIC + src/vkr_dis.c + src/dis_qcom_me.c +) +add_dependencies(wndis wndis_shaders) + +set_target_properties(wndis PROPERTIES POSITION_INDEPENDENT_CODE ON) +target_compile_options(wndis PRIVATE -Wall -Wextra -fvisibility=hidden) +target_compile_definitions(wndis PUBLIC VK_USE_PLATFORM_ANDROID_KHR) +target_include_directories(wndis + PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/include + # vk_dispatch.h: the public header takes its Vulkan types from there. + ${CMAKE_CURRENT_SOURCE_DIR}/../winlator/vk + PRIVATE + ${CMAKE_CURRENT_BINARY_DIR} +) +# EGL/GLES are opened with dlopen by dis_qcom_me.c, so nothing links them here. +target_link_libraries(wndis PRIVATE log dl) diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_densify.comp b/app/src/main/cpp/dis/include/shaders/dis_densify.comp similarity index 100% rename from app/src/main/cpp/winlator/vk/shaders/dis_densify.comp rename to app/src/main/cpp/dis/include/shaders/dis_densify.comp diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_flow_pack.comp b/app/src/main/cpp/dis/include/shaders/dis_flow_pack.comp similarity index 100% rename from app/src/main/cpp/winlator/vk/shaders/dis_flow_pack.comp rename to app/src/main/cpp/dis/include/shaders/dis_flow_pack.comp diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_gradient.comp b/app/src/main/cpp/dis/include/shaders/dis_gradient.comp similarity index 100% rename from app/src/main/cpp/winlator/vk/shaders/dis_gradient.comp rename to app/src/main/cpp/dis/include/shaders/dis_gradient.comp diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_hist.comp b/app/src/main/cpp/dis/include/shaders/dis_hist.comp similarity index 100% rename from app/src/main/cpp/winlator/vk/shaders/dis_hist.comp rename to app/src/main/cpp/dis/include/shaders/dis_hist.comp diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp b/app/src/main/cpp/dis/include/shaders/dis_interpolate.comp similarity index 100% rename from app/src/main/cpp/winlator/vk/shaders/dis_interpolate.comp rename to app/src/main/cpp/dis/include/shaders/dis_interpolate.comp diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_inverse_search.comp b/app/src/main/cpp/dis/include/shaders/dis_inverse_search.comp similarity index 82% rename from app/src/main/cpp/winlator/vk/shaders/dis_inverse_search.comp rename to app/src/main/cpp/dis/include/shaders/dis_inverse_search.comp index 84d70044e..46f9f9d61 100644 --- a/app/src/main/cpp/winlator/vk/shaders/dis_inverse_search.comp +++ b/app/src/main/cpp/dis/include/shaders/dis_inverse_search.comp @@ -61,14 +61,20 @@ layout(set = 0, binding = 0) uniform sampler2D lastLumaMap; layout(set = 0, binding = 1) uniform sampler2D nextLumaMap; layout(set = 0, binding = 2) uniform sampler2D lastGradientMap; layout(set = 0, binding = 3) uniform sampler2D flowMap; -layout(set = 0, binding = 4) uniform sampler2D lastFlowMap; +// Motion hint from a hardware estimator (GL_QCOM_motion_estimation), one vector per block, in +// normalised uv units; read only on the level named by hintLevel. A component beyond HINT_INVALID +// marks a block the estimator had nothing for. +layout(set = 0, binding = 4) uniform sampler2D hintFlowMap; layout(set = 0, binding = 5, rgba32f) uniform image2D sparseFlowMap; layout(push_constant) uniform PC { int level; int coarseLevel; + int hintLevel; } pc; +#define HINT_INVALID 1.0e6 + float uluminance(vec3 c) { return (0.299 * c.x + 0.587 * c.y + 0.114 * c.z) * 255.0; } @@ -154,11 +160,44 @@ void main() { flow = cf.xy * vec2(denseSize); if (any(isnan(flow)) || any(isinf(flow))) flow = vec2(0.0); } - vec2 initialFlow = flow; - vec2 invImageSize = 1.0 / vec2(denseSize); const float N = 64.0; + // A second starting point from the hardware estimator. Both candidates are scored on the + // patch with the same mean-normalised SSD the search minimises, and the search starts from + // the better one. The coarse level still covers what the estimator cannot: its search range + // is a few dozen pixels, and past that its vector is garbage that simply loses here. + if (pc.level == pc.hintLevel) { + vec2 hintSize = vec2(textureSize(hintFlowMap, 0)); + ivec2 hp = clamp(ivec2((vec2(pix) + 4.0) * hintSize / vec2(denseSize)), + ivec2(0), ivec2(hintSize) - 1); + vec2 hint = texelFetch(hintFlowMap, hp, 0).xy; + if (all(lessThan(abs(hint), vec2(HINT_INVALID * 0.5)))) { + hint *= vec2(denseSize); + vec2 cand[2] = vec2[2](flow, hint); + float best = 1e30; + for (int c = 0; c < 2; c++) { + vec2 o = clamp(vec2(pix) + cand[c], vec2(0.0), vec2(denseSize) - patchSize); + float s = 0.0; + float s2 = 0.0; + for (int i = 0; i < 8; i++) { + for (int j = 0; j < 8; j++) { + vec2 tc = (o + vec2(i, j) + 0.5) * invImageSize; + float diff = textureLod(nextLumaMap, tc, 0.0).x - lastImageData[i * 8 + j]; + s += diff; + s2 += diff * diff; + } + } + float ssd = s2 - s * s / N; + if (ssd < best) { + best = ssd; + flow = cand[c]; + } + } + } + } + vec2 initialFlow = flow; + const float N_INV = 1.0 / N; float prevSSD = 1e10; for (int iter = 0; iter < DIS_INVERSE_ITERS; iter++) { diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_luma.comp b/app/src/main/cpp/dis/include/shaders/dis_luma.comp similarity index 100% rename from app/src/main/cpp/winlator/vk/shaders/dis_luma.comp rename to app/src/main/cpp/dis/include/shaders/dis_luma.comp diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_luma_r16.comp b/app/src/main/cpp/dis/include/shaders/dis_luma_r16.comp similarity index 100% rename from app/src/main/cpp/winlator/vk/shaders/dis_luma_r16.comp rename to app/src/main/cpp/dis/include/shaders/dis_luma_r16.comp diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_luma_r32.comp b/app/src/main/cpp/dis/include/shaders/dis_luma_r32.comp similarity index 100% rename from app/src/main/cpp/winlator/vk/shaders/dis_luma_r32.comp rename to app/src/main/cpp/dis/include/shaders/dis_luma_r32.comp diff --git a/app/src/main/cpp/dis/include/shaders/dis_me_luma.comp b/app/src/main/cpp/dis/include/shaders/dis_me_luma.comp new file mode 100644 index 000000000..72d0dd1be --- /dev/null +++ b/app/src/main/cpp/dis/include/shaders/dis_me_luma.comp @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright 2026 qwertypower (DEVAR Entertainment LLC) +// SPDX-License-Identifier: GPL-3.0-or-later +// +// DIS frame generation: a Vulkan compute realisation of Dense Inverse Search +// optical flow. The algorithm and its reference implementation come from +// OpenCV's DISOpticalFlow, which adopted Till Kroeger's original OF_DIS. +// See CREDITS.md for the full attribution. + +#version 450 + +precision highp float; +precision highp int; + +// Luminance of the newest real frame at the hardware motion estimator's input size, packed four +// pixels to a word into a host-visible buffer: GL_QCOM_motion_estimation takes an R8 texture, and +// the frame reaches GL as a plain upload. Each output pixel box-filters its footprint with four +// bilinear taps, which is exact for the usual 2:1 reduction and keeps larger ones from aliasing. + +layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in; + +layout(set = 0, binding = 0) uniform sampler2D colorTex; +layout(set = 0, binding = 1, std430) writeonly buffer LumaOut { + uint words[]; +}; + +layout(push_constant) uniform PC { + int width; // multiple of 4 + int height; +} pc; + +float lumaAt(vec2 uv, vec2 q) { + vec3 c = textureLod(colorTex, uv + vec2(-q.x, -q.y), 0.0).rgb + + textureLod(colorTex, uv + vec2( q.x, -q.y), 0.0).rgb + + textureLod(colorTex, uv + vec2(-q.x, q.y), 0.0).rgb + + textureLod(colorTex, uv + vec2( q.x, q.y), 0.0).rgb; + return dot(c * 0.25, vec3(0.299, 0.587, 0.114)); +} + +void main() { + ivec2 g = ivec2(gl_GlobalInvocationID.xy); + int wordsPerRow = pc.width / 4; + if (g.x >= wordsPerRow || g.y >= pc.height) return; + + vec2 texel = 1.0 / vec2(pc.width, pc.height); + vec2 q = 0.25 * texel; + uint packed = 0u; + for (int i = 0; i < 4; i++) { + vec2 uv = (vec2(g.x * 4 + i, g.y) + 0.5) * texel; + uint v = uint(clamp(lumaAt(uv, q), 0.0, 1.0) * 255.0 + 0.5); + packed |= v << (8u * uint(i)); + } + words[g.y * wordsPerRow + g.x] = packed; +} diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_propagate.comp b/app/src/main/cpp/dis/include/shaders/dis_propagate.comp similarity index 100% rename from app/src/main/cpp/winlator/vk/shaders/dis_propagate.comp rename to app/src/main/cpp/dis/include/shaders/dis_propagate.comp diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_side.comp b/app/src/main/cpp/dis/include/shaders/dis_side.comp similarity index 100% rename from app/src/main/cpp/winlator/vk/shaders/dis_side.comp rename to app/src/main/cpp/dis/include/shaders/dis_side.comp diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_vr_add.comp b/app/src/main/cpp/dis/include/shaders/dis_vr_add.comp similarity index 100% rename from app/src/main/cpp/winlator/vk/shaders/dis_vr_add.comp rename to app/src/main/cpp/dis/include/shaders/dis_vr_add.comp diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_vr_coef.comp b/app/src/main/cpp/dis/include/shaders/dis_vr_coef.comp similarity index 100% rename from app/src/main/cpp/winlator/vk/shaders/dis_vr_coef.comp rename to app/src/main/cpp/dis/include/shaders/dis_vr_coef.comp diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_vr_d1.comp b/app/src/main/cpp/dis/include/shaders/dis_vr_d1.comp similarity index 100% rename from app/src/main/cpp/winlator/vk/shaders/dis_vr_d1.comp rename to app/src/main/cpp/dis/include/shaders/dis_vr_d1.comp diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_vr_d2.comp b/app/src/main/cpp/dis/include/shaders/dis_vr_d2.comp similarity index 100% rename from app/src/main/cpp/winlator/vk/shaders/dis_vr_d2.comp rename to app/src/main/cpp/dis/include/shaders/dis_vr_d2.comp diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_vr_prep.comp b/app/src/main/cpp/dis/include/shaders/dis_vr_prep.comp similarity index 100% rename from app/src/main/cpp/winlator/vk/shaders/dis_vr_prep.comp rename to app/src/main/cpp/dis/include/shaders/dis_vr_prep.comp diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_vr_sor.comp b/app/src/main/cpp/dis/include/shaders/dis_vr_sor.comp similarity index 100% rename from app/src/main/cpp/winlator/vk/shaders/dis_vr_sor.comp rename to app/src/main/cpp/dis/include/shaders/dis_vr_sor.comp diff --git a/app/src/main/cpp/winlator/vk/shaders/dis_vr_w.comp b/app/src/main/cpp/dis/include/shaders/dis_vr_w.comp similarity index 100% rename from app/src/main/cpp/winlator/vk/shaders/dis_vr_w.comp rename to app/src/main/cpp/dis/include/shaders/dis_vr_w.comp diff --git a/app/src/main/cpp/winlator/vk/dis/vkr_dis.h b/app/src/main/cpp/dis/include/vkr_dis.h similarity index 62% rename from app/src/main/cpp/winlator/vk/dis/vkr_dis.h rename to app/src/main/cpp/dis/include/vkr_dis.h index ee3eacc68..4f7728108 100644 --- a/app/src/main/cpp/winlator/vk/dis/vkr_dis.h +++ b/app/src/main/cpp/dis/include/vkr_dis.h @@ -11,7 +11,7 @@ #include #include -#include "../vk_dispatch.h" +#include "vk_dispatch.h" #ifdef __cplusplus extern "C" { @@ -47,6 +47,25 @@ uint32_t vkr_dis_plan(VkrDis* dis, uint32_t capacity, uint64_t source_frames); void vkr_dis_process(VkrDis* dis, VkCommandBuffer cmd, VkImage source, uint32_t width, uint32_t height, uint32_t generations); +// Submits `cmd` (ended by the callee, no semaphores), waits for it on the CPU, and returns a +// command buffer in the recording state for the rest of the frame - `cmd` itself reset and begun +// again is fine. VK_NULL_HANDLE on failure. +typedef VkCommandBuffer (*VkrDisFlushFn)(void* user, VkCommandBuffer cmd); + +// As vkr_dis_process, and when the GLES driver offers GL_QCOM_motion_estimation, seeds the flow +// with a hardware estimate. That needs this frame's pixels mid-frame, so DIS calls `flush` once +// and records the rest into the buffer it returns; the caller continues with - and finally +// submits - the returned buffer. With flush NULL, or without the extension, this is exactly +// vkr_dis_process and returns `cmd`. +VkCommandBuffer vkr_dis_process_ex(VkrDis* dis, VkCommandBuffer cmd, VkImage source, + uint32_t width, uint32_t height, uint32_t generations, + VkrDisFlushFn flush, void* flush_user); + +// Hardware motion hint on or off (default off, or debug.winnative.dis.hwme=1); takes effect at +// the next resource build. +void vkr_dis_set_hw_motion(VkrDis* dis, bool enabled); +bool vkr_dis_hw_motion_active(const VkrDis* dis); + void vkr_dis_generate_into(VkrDis* dis, VkCommandBuffer cmd, uint32_t generation, uint32_t target_index, VkImage target_image, VkImageView target_view, uint32_t width, uint32_t height, diff --git a/app/src/main/cpp/dis/src/dis_qcom_me.c b/app/src/main/cpp/dis/src/dis_qcom_me.c new file mode 100644 index 000000000..51d9bc803 --- /dev/null +++ b/app/src/main/cpp/dis/src/dis_qcom_me.c @@ -0,0 +1,373 @@ +// SPDX-FileCopyrightText: Copyright 2026 qwertypower (DEVAR Entertainment LLC) +// SPDX-License-Identifier: GPL-3.0-or-later +// +// Hardware motion estimation through GL_QCOM_motion_estimation. See dis_qcom_me.h. +// +// The estimator lives in the platform GLES driver, while DIS runs on whichever Vulkan driver the +// compositor loaded (often Turnip), so nothing is shared between the two APIs: the luminance goes +// in with a texture upload and the field comes back with a read, both a few hundred kilobytes at +// most. EGL and GLES are opened with dlopen so the libraries linking DIS take no hard dependency +// on them, and each instance owns a surfaceless context that is made current only for the +// duration of a call, restoring whatever the calling thread had current before. + +#include "dis_qcom_me.h" + +#ifdef __ANDROID__ + +#include +#include +#include + +#include +#include +#include +#include +#include + +#define ME_LOGI(...) __android_log_print(ANDROID_LOG_INFO, "VkrDis", __VA_ARGS__) +#define ME_LOGW(...) __android_log_print(ANDROID_LOG_WARN, "VkrDis", __VA_ARGS__) + +#define GL_MOTION_ESTIMATION_SEARCH_BLOCK_X_QCOM 0x8C90 +#define GL_MOTION_ESTIMATION_SEARCH_BLOCK_Y_QCOM 0x8C91 + +// The NDK's EGL headers only declare the core entry points as prototypes, so their pointer types +// are spelled out here for the dlsym'd table below. +typedef EGLDisplay (*MeGetDisplay)(EGLNativeDisplayType); +typedef EGLBoolean (*MeInitialize)(EGLDisplay, EGLint*, EGLint*); +typedef EGLBoolean (*MeChooseConfig)(EGLDisplay, const EGLint*, EGLConfig*, EGLint, EGLint*); +typedef EGLBoolean (*MeBindAPI)(EGLenum); +typedef EGLContext (*MeCreateContext)(EGLDisplay, EGLConfig, EGLContext, const EGLint*); +typedef EGLBoolean (*MeDestroyContext)(EGLDisplay, EGLContext); +typedef EGLSurface (*MeCreatePbufferSurface)(EGLDisplay, EGLConfig, const EGLint*); +typedef EGLBoolean (*MeDestroySurface)(EGLDisplay, EGLSurface); +typedef EGLBoolean (*MeMakeCurrent)(EGLDisplay, EGLSurface, EGLSurface, EGLContext); +typedef EGLContext (*MeGetCurrentContext)(void); +typedef EGLDisplay (*MeGetCurrentDisplay)(void); +typedef EGLSurface (*MeGetCurrentSurface)(EGLint); +typedef const char* (*MeQueryString)(EGLDisplay, EGLint); +typedef void (*(*MeGetProcAddress)(const char*))(void); +typedef struct { + bool loaded; + bool ok; + MeGetDisplay GetDisplay; + MeInitialize Initialize; + MeChooseConfig ChooseConfig; + MeBindAPI BindAPI; + MeCreateContext CreateContext; + MeDestroyContext DestroyContext; + MeCreatePbufferSurface CreatePbufferSurface; + MeDestroySurface DestroySurface; + MeMakeCurrent MakeCurrent; + MeGetCurrentContext GetCurrentContext; + MeGetCurrentDisplay GetCurrentDisplay; + MeGetCurrentSurface GetCurrentSurface; + MeQueryString QueryString; + MeGetProcAddress GetProcAddress; + + const GLubyte* (*GetString)(GLenum); + void (*GetIntegerv)(GLenum, GLint*); + GLenum (*GetError)(void); + void (*GenTextures)(GLsizei, GLuint*); + void (*DeleteTextures)(GLsizei, const GLuint*); + void (*BindTexture)(GLenum, GLuint); + void (*TexStorage2D)(GLenum, GLsizei, GLenum, GLsizei, GLsizei); + void (*TexSubImage2D)(GLenum, GLint, GLint, GLint, GLsizei, GLsizei, GLenum, GLenum, const void*); + void (*TexParameteri)(GLenum, GLenum, GLint); + void (*PixelStorei)(GLenum, GLint); + void (*GenFramebuffers)(GLsizei, GLuint*); + void (*DeleteFramebuffers)(GLsizei, const GLuint*); + void (*BindFramebuffer)(GLenum, GLuint); + void (*FramebufferTexture2D)(GLenum, GLenum, GLenum, GLuint, GLint); + GLenum (*CheckFramebufferStatus)(GLenum); + void (*ReadPixels)(GLint, GLint, GLsizei, GLsizei, GLenum, GLenum, void*); + void (*EstimateMotion)(GLuint, GLuint, GLuint); +} MeApi; + +static MeApi g_api; +static pthread_mutex_t g_api_lock = PTHREAD_MUTEX_INITIALIZER; +static int g_probe = -1; // -1 not probed, 0 unsupported, 1 supported +static uint32_t g_block_x, g_block_y; + +struct DisQcomMe { + EGLDisplay display; + EGLContext context; + EGLSurface surface; // EGL_NO_SURFACE when surfaceless + uint32_t width, height; + uint32_t field_w, field_h; + GLuint luma[2]; + GLuint field; + GLuint fbo; + uint32_t newest; // index of the texture holding the newest frame + bool have_prev; +}; + +static bool me_load_api(void) { + if (g_api.loaded) return g_api.ok; + g_api.loaded = true; + void* egl = dlopen("libEGL.so", RTLD_NOW | RTLD_LOCAL); + void* gles = dlopen("libGLESv3.so", RTLD_NOW | RTLD_LOCAL); + if (!egl || !gles) return false; +#define EGLFN(field, name) g_api.field = (void*)dlsym(egl, name); if (!g_api.field) return false +#define GLFN(field, name) g_api.field = (void*)dlsym(gles, name); if (!g_api.field) return false + EGLFN(GetDisplay, "eglGetDisplay"); + EGLFN(Initialize, "eglInitialize"); + EGLFN(ChooseConfig, "eglChooseConfig"); + EGLFN(BindAPI, "eglBindAPI"); + EGLFN(CreateContext, "eglCreateContext"); + EGLFN(DestroyContext, "eglDestroyContext"); + EGLFN(CreatePbufferSurface, "eglCreatePbufferSurface"); + EGLFN(DestroySurface, "eglDestroySurface"); + EGLFN(MakeCurrent, "eglMakeCurrent"); + EGLFN(GetCurrentContext, "eglGetCurrentContext"); + EGLFN(GetCurrentDisplay, "eglGetCurrentDisplay"); + EGLFN(GetCurrentSurface, "eglGetCurrentSurface"); + EGLFN(QueryString, "eglQueryString"); + EGLFN(GetProcAddress, "eglGetProcAddress"); + GLFN(GetString, "glGetString"); + GLFN(GetIntegerv, "glGetIntegerv"); + GLFN(GetError, "glGetError"); + GLFN(GenTextures, "glGenTextures"); + GLFN(DeleteTextures, "glDeleteTextures"); + GLFN(BindTexture, "glBindTexture"); + GLFN(TexStorage2D, "glTexStorage2D"); + GLFN(TexSubImage2D, "glTexSubImage2D"); + GLFN(TexParameteri, "glTexParameteri"); + GLFN(PixelStorei, "glPixelStorei"); + GLFN(GenFramebuffers, "glGenFramebuffers"); + GLFN(DeleteFramebuffers, "glDeleteFramebuffers"); + GLFN(BindFramebuffer, "glBindFramebuffer"); + GLFN(FramebufferTexture2D, "glFramebufferTexture2D"); + GLFN(CheckFramebufferStatus, "glCheckFramebufferStatus"); + GLFN(ReadPixels, "glReadPixels"); +#undef EGLFN +#undef GLFN + g_api.EstimateMotion = (void*)g_api.GetProcAddress("glTexEstimateMotionQCOM"); + g_api.ok = g_api.EstimateMotion != NULL; + return g_api.ok; +} + +typedef struct { + EGLDisplay display; + EGLContext context; + EGLSurface draw, read; +} MeSaved; + +static void me_save(MeSaved* s) { + s->display = g_api.GetCurrentDisplay(); + s->context = g_api.GetCurrentContext(); + s->draw = g_api.GetCurrentSurface(EGL_DRAW); + s->read = g_api.GetCurrentSurface(EGL_READ); +} + +static void me_restore(const MeSaved* s, EGLDisplay own) { + if (s->context != EGL_NO_CONTEXT && s->display != EGL_NO_DISPLAY) { + g_api.MakeCurrent(s->display, s->draw, s->read, s->context); + } else { + g_api.MakeCurrent(own, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); + } +} + +// Display, context and (only without surfaceless support) a 1x1 pbuffer. +static bool me_context(EGLDisplay* out_dpy, EGLContext* out_ctx, EGLSurface* out_surf) { + EGLDisplay dpy = g_api.GetDisplay(EGL_DEFAULT_DISPLAY); + if (dpy == EGL_NO_DISPLAY || !g_api.Initialize(dpy, NULL, NULL)) return false; + const char* ext = g_api.QueryString(dpy, EGL_EXTENSIONS); + const bool surfaceless = ext && strstr(ext, "EGL_KHR_surfaceless_context") != NULL; + const EGLint cfg_attr[] = {EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT_KHR, + EGL_SURFACE_TYPE, EGL_PBUFFER_BIT, EGL_NONE}; + EGLConfig cfg; + EGLint n = 0; + if (!g_api.ChooseConfig(dpy, cfg_attr, &cfg, 1, &n) || n < 1) return false; + g_api.BindAPI(EGL_OPENGL_ES_API); + const EGLint ctx_attr[] = {EGL_CONTEXT_CLIENT_VERSION, 3, EGL_NONE}; + EGLContext ctx = g_api.CreateContext(dpy, cfg, EGL_NO_CONTEXT, ctx_attr); + if (ctx == EGL_NO_CONTEXT) return false; + EGLSurface surf = EGL_NO_SURFACE; + if (!surfaceless) { + const EGLint pb_attr[] = {EGL_WIDTH, 1, EGL_HEIGHT, 1, EGL_NONE}; + surf = g_api.CreatePbufferSurface(dpy, cfg, pb_attr); + if (surf == EGL_NO_SURFACE) { + g_api.DestroyContext(dpy, ctx); + return false; + } + } + *out_dpy = dpy; + *out_ctx = ctx; + *out_surf = surf; + return true; +} + +bool dis_qcom_me_supported(uint32_t* block_x, uint32_t* block_y) { + pthread_mutex_lock(&g_api_lock); + if (g_probe < 0) { + g_probe = 0; + EGLDisplay dpy; + EGLContext ctx; + EGLSurface surf; + if (me_load_api() && me_context(&dpy, &ctx, &surf)) { + MeSaved saved; + me_save(&saved); + if (g_api.MakeCurrent(dpy, surf, surf, ctx)) { + const char* ext = (const char*)g_api.GetString(GL_EXTENSIONS); + GLint bx = 0, by = 0; + if (ext && strstr(ext, "GL_QCOM_motion_estimation")) { + g_api.GetIntegerv(GL_MOTION_ESTIMATION_SEARCH_BLOCK_X_QCOM, &bx); + g_api.GetIntegerv(GL_MOTION_ESTIMATION_SEARCH_BLOCK_Y_QCOM, &by); + } + if (bx > 0 && by > 0) { + g_block_x = (uint32_t)bx; + g_block_y = (uint32_t)by; + g_probe = 1; + } + ME_LOGI("GL_QCOM_motion_estimation: %s (block %dx%d, %s)", + g_probe ? "available" : "not available", bx, by, + (const char*)g_api.GetString(GL_RENDERER)); + } + me_restore(&saved, dpy); + if (surf != EGL_NO_SURFACE) g_api.DestroySurface(dpy, surf); + g_api.DestroyContext(dpy, ctx); + } + } + const bool ok = g_probe == 1; + if (ok) { + if (block_x) *block_x = g_block_x; + if (block_y) *block_y = g_block_y; + } + pthread_mutex_unlock(&g_api_lock); + return ok; +} + +static GLuint me_texture(GLenum format, uint32_t w, uint32_t h) { + GLuint t = 0; + g_api.GenTextures(1, &t); + g_api.BindTexture(GL_TEXTURE_2D, t); + g_api.TexStorage2D(GL_TEXTURE_2D, 1, format, (GLsizei)w, (GLsizei)h); + g_api.TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + g_api.TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + return t; +} + +DisQcomMe* dis_qcom_me_create(uint32_t width, uint32_t height) { + uint32_t bx = 0, by = 0; + if (!dis_qcom_me_supported(&bx, &by)) return NULL; + if (width == 0 || height == 0 || width % bx || height % by) return NULL; + + DisQcomMe* me = (DisQcomMe*)calloc(1, sizeof(DisQcomMe)); + if (!me) return NULL; + me->width = width; + me->height = height; + me->field_w = width / bx; + me->field_h = height / by; + if (!me_context(&me->display, &me->context, &me->surface)) { + free(me); + return NULL; + } + + MeSaved saved; + me_save(&saved); + bool ok = g_api.MakeCurrent(me->display, me->surface, me->surface, me->context); + if (ok) { + me->luma[0] = me_texture(GL_R8, width, height); + me->luma[1] = me_texture(GL_R8, width, height); + me->field = me_texture(GL_RGBA16F, me->field_w, me->field_h); + g_api.GenFramebuffers(1, &me->fbo); + g_api.BindFramebuffer(GL_FRAMEBUFFER, me->fbo); + g_api.FramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, me->field, 0); + ok = g_api.CheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE && + g_api.GetError() == GL_NO_ERROR; + } + me_restore(&saved, me->display); + if (!ok) { + ME_LOGW("GL_QCOM_motion_estimation: could not set up %ux%u; using DIS alone", width, height); + dis_qcom_me_destroy(me); + return NULL; + } + ME_LOGI("GL_QCOM_motion_estimation: %ux%u luminance -> %ux%u field", width, height, + me->field_w, me->field_h); + return me; +} + +void dis_qcom_me_destroy(DisQcomMe* me) { + if (!me) return; + if (me->context != EGL_NO_CONTEXT) { + MeSaved saved; + me_save(&saved); + if (g_api.MakeCurrent(me->display, me->surface, me->surface, me->context)) { + if (me->fbo) g_api.DeleteFramebuffers(1, &me->fbo); + GLuint tex[3] = {me->luma[0], me->luma[1], me->field}; + g_api.DeleteTextures(3, tex); + } + me_restore(&saved, me->display); + if (me->surface != EGL_NO_SURFACE) g_api.DestroySurface(me->display, me->surface); + g_api.DestroyContext(me->display, me->context); + } + free(me); +} + +void dis_qcom_me_invalidate(DisQcomMe* me) { + if (me) me->have_prev = false; +} + +bool dis_qcom_me_push(DisQcomMe* me, const uint8_t* luma, float* out_xy) { + if (!me || !luma) return false; + MeSaved saved; + me_save(&saved); + if (!g_api.MakeCurrent(me->display, me->surface, me->surface, me->context)) { + me_restore(&saved, me->display); + return false; + } + + const uint32_t cur = me->have_prev ? 1u - me->newest : me->newest; + g_api.BindTexture(GL_TEXTURE_2D, me->luma[cur]); + g_api.PixelStorei(GL_UNPACK_ALIGNMENT, 1); + g_api.TexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, (GLsizei)me->width, (GLsizei)me->height, GL_RED, + GL_UNSIGNED_BYTE, luma); + + bool estimated = false; + if (me->have_prev && out_xy) { + g_api.EstimateMotion(me->luma[me->newest], me->luma[cur], me->field); + const uint32_t n = me->field_w * me->field_h; + float* rgba = (float*)malloc((size_t)n * 4 * sizeof(float)); + if (rgba) { + g_api.BindFramebuffer(GL_FRAMEBUFFER, me->fbo); + g_api.ReadPixels(0, 0, (GLsizei)me->field_w, (GLsizei)me->field_h, GL_RGBA, GL_FLOAT, + rgba); + if (g_api.GetError() == GL_NO_ERROR) { + for (uint32_t i = 0; i < n; i++) { + out_xy[i * 2] = rgba[i * 4]; + out_xy[i * 2 + 1] = rgba[i * 4 + 1]; + } + estimated = true; + } + free(rgba); + } + } + me->newest = cur; + me->have_prev = true; + + me_restore(&saved, me->display); + return estimated; +} + +#else // !__ANDROID__: no GLES driver to ask; DIS runs alone. + +bool dis_qcom_me_supported(uint32_t* block_x, uint32_t* block_y) { + (void)block_x; + (void)block_y; + return false; +} +DisQcomMe* dis_qcom_me_create(uint32_t width, uint32_t height) { + (void)width; + (void)height; + return NULL; +} +void dis_qcom_me_destroy(DisQcomMe* me) { (void)me; } +bool dis_qcom_me_push(DisQcomMe* me, const uint8_t* luma, float* out_xy) { + (void)me; + (void)luma; + (void)out_xy; + return false; +} +void dis_qcom_me_invalidate(DisQcomMe* me) { (void)me; } + +#endif diff --git a/app/src/main/cpp/dis/src/dis_qcom_me.h b/app/src/main/cpp/dis/src/dis_qcom_me.h new file mode 100644 index 000000000..3975cf596 --- /dev/null +++ b/app/src/main/cpp/dis/src/dis_qcom_me.h @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Copyright 2026 qwertypower (DEVAR Entertainment LLC) +// SPDX-License-Identifier: GPL-3.0-or-later +// +// Hardware motion estimation through GL_QCOM_motion_estimation (Adreno), used by DIS as a +// second starting point for its block search. Private to the DIS module. + +#pragma once + +#include +#include +#include + +typedef struct DisQcomMe DisQcomMe; + +// Whether the platform's GLES driver exposes the extension, and its search block size. +// Probed once per process; cheap after the first call. +bool dis_qcom_me_supported(uint32_t* block_x, uint32_t* block_y); + +// Input frames are width x height R8 luminance, both multiples of the block size; the field is +// (width / block_x) x (height / block_y) vectors. NULL when unsupported or on failure. +DisQcomMe* dis_qcom_me_create(uint32_t width, uint32_t height); +void dis_qcom_me_destroy(DisQcomMe* me); + +// Hands the newest frame over. When an earlier frame is held, estimates the motion from it to +// this one into out_xy (field_w * field_h pairs, pixels of the input size, previous -> newest) +// and returns true. Either way the newest frame becomes the reference for the next call. +bool dis_qcom_me_push(DisQcomMe* me, const uint8_t* luma, float* out_xy); + +// Drops the held frame, so the next push only primes the history. +void dis_qcom_me_invalidate(DisQcomMe* me); diff --git a/app/src/main/cpp/winlator/vk/dis/vkr_dis.c b/app/src/main/cpp/dis/src/vkr_dis.c similarity index 80% rename from app/src/main/cpp/winlator/vk/dis/vkr_dis.c rename to app/src/main/cpp/dis/src/vkr_dis.c index 8f2f3ac7e..ebc29f5a1 100644 --- a/app/src/main/cpp/winlator/vk/dis/vkr_dis.c +++ b/app/src/main/cpp/dis/src/vkr_dis.c @@ -8,7 +8,8 @@ #include "vkr_dis.h" -#include "../vk_dispatch.h" +#include "dis_qcom_me.h" +#include "vk_dispatch.h" #include "shaders/dis_luma_r16_comp.spv.h" #include "shaders/dis_luma_r32_comp.spv.h" #include "shaders/dis_gradient_comp.spv.h" @@ -19,6 +20,7 @@ #include "shaders/dis_hist_comp.spv.h" #include "shaders/dis_side_comp.spv.h" #include "shaders/dis_flow_pack_comp.spv.h" +#include "shaders/dis_me_luma_comp.spv.h" #include "shaders/dis_vr_prep_comp.spv.h" #include "shaders/dis_vr_d1_comp.spv.h" #include "shaders/dis_vr_d2_comp.spv.h" @@ -34,6 +36,9 @@ #include #include +#ifdef __ANDROID__ +#include +#endif #define DIS_LOGI(...) __android_log_print(ANDROID_LOG_INFO, "VkrDis", __VA_ARGS__) #define DIS_LOGW(...) __android_log_print(ANDROID_LOG_WARN, "VkrDis", __VA_ARGS__) @@ -50,6 +55,12 @@ #define DIS_SLOTS 3u +// How the hardware motion field enters the flow: as a second starting candidate for the search on +// its level (0), or as that level's result outright, skipping the search above it (1). +#ifndef DIS_ME_PRIMARY +#define DIS_ME_PRIMARY 0 +#endif + #define DIS_PROP_STEPS_MAX 4u #define DIS_SRC_SMOOTHING 0.08f @@ -155,6 +166,7 @@ struct VkrDis { DisImage hist[2]; DisImage side; DisImage flow_out; + DisImage me_field; VkImageView view_color[DIS_SLOTS]; VkImageView view_flow_color[DIS_SLOTS][DIS_MAX_LEVELS]; @@ -175,6 +187,7 @@ struct VkrDis { VkImageView view_hist[2]; VkImageView view_side; VkImageView view_flow_out; + VkImageView view_me_field; VkSampler sampler; @@ -212,6 +225,7 @@ struct VkrDis { DisPass pass_hist; DisPass pass_side; DisPass pass_pack; + DisPass pass_me_luma; DisPass pass_vr_prep; DisPass pass_vr_d1; DisPass pass_vr_d2; @@ -244,6 +258,31 @@ struct VkrDis { uint32_t hist_parity; bool hist_valid; + + // Hardware motion hint (GL_QCOM_motion_estimation). me is NULL whenever the hint is off: + // disabled, unsupported, or the pyramid too shallow for the level it seeds. + bool hw_motion; + DisQcomMe* me; + uint32_t me_level; + uint32_t me_w, me_h; + uint32_t me_field_w, me_field_h; + VkBuffer me_luma_buf; + VkDeviceMemory me_luma_mem; + void* me_luma_map; + bool me_luma_coherent; + VkBuffer me_field_buf; + VkDeviceMemory me_field_mem; + void* me_field_map; + bool me_field_coherent; + float* me_xy; + int hint_level; + bool me_primary_frame; + VkDescriptorSetLayout me_set_layout; + VkPipelineLayout me_pipeline_layout; + VkDescriptorPool me_pool; + VkDescriptorSet me_sets[DIS_SLOTS]; + uint64_t me_hinted; + uint64_t me_pairs; }; typedef struct { @@ -255,6 +294,7 @@ typedef struct { typedef struct { int level; int coarseLevel; + int hintLevel; } DisInversePC; typedef struct { @@ -283,6 +323,8 @@ typedef struct { int parity; } DisVrSorPC; +static void dis_destroy_me(VkrDis* d); + static uint64_t dis_now_ns(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); @@ -356,6 +398,7 @@ static uint32_t dis_collect_images(VkrDis* d, DisImage** out, uint32_t cap) { DIS_PUSH(&d->hist[1]); DIS_PUSH(&d->side); DIS_PUSH(&d->flow_out); + DIS_PUSH(&d->me_field); #undef DIS_PUSH return n; } @@ -659,12 +702,60 @@ static bool dis_create_pipelines(VkrDis* d) { d->pass_side.pipeline = dis_create_compute_pipeline(d, dis_side_comp, dis_side_comp_size); d->pass_pack.pipeline = dis_create_compute_pipeline(d, dis_flow_pack_comp, dis_flow_pack_comp_size); + // The hardware-motion luminance pass writes a host-visible buffer, which neither shared + // layout has, so it gets a small layout and pool of its own: the colour frame and the buffer. + VkDescriptorSetLayoutBinding me_bindings[2]; + memset(me_bindings, 0, sizeof(me_bindings)); + me_bindings[0].binding = 0; + me_bindings[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + me_bindings[0].descriptorCount = 1; + me_bindings[0].stageFlags = VK_SHADER_STAGE_COMPUTE_BIT; + me_bindings[1].binding = 1; + me_bindings[1].descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; + me_bindings[1].descriptorCount = 1; + me_bindings[1].stageFlags = VK_SHADER_STAGE_COMPUTE_BIT; + VkDescriptorSetLayoutCreateInfo me_li; + memset(&me_li, 0, sizeof(me_li)); + me_li.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO; + me_li.bindingCount = 2; + me_li.pBindings = me_bindings; + if (vkd.CreateDescriptorSetLayout(d->device, &me_li, NULL, &d->me_set_layout) != VK_SUCCESS) { + return false; + } + VkPipelineLayoutCreateInfo me_pli; + memset(&me_pli, 0, sizeof(me_pli)); + me_pli.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO; + me_pli.setLayoutCount = 1; + me_pli.pSetLayouts = &d->me_set_layout; + me_pli.pushConstantRangeCount = 1; + me_pli.pPushConstantRanges = &pcr; + if (vkd.CreatePipelineLayout(d->device, &me_pli, NULL, &d->me_pipeline_layout) != VK_SUCCESS) { + return false; + } + VkDescriptorPoolSize me_sizes[2]; + memset(me_sizes, 0, sizeof(me_sizes)); + me_sizes[0].type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + me_sizes[0].descriptorCount = DIS_SLOTS; + me_sizes[1].type = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; + me_sizes[1].descriptorCount = DIS_SLOTS; + VkDescriptorPoolCreateInfo me_pci; + memset(&me_pci, 0, sizeof(me_pci)); + me_pci.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + me_pci.maxSets = DIS_SLOTS; + me_pci.poolSizeCount = 2; + me_pci.pPoolSizes = me_sizes; + if (vkd.CreateDescriptorPool(d->device, &me_pci, NULL, &d->me_pool) != VK_SUCCESS) { + return false; + } + d->pass_me_luma.pipeline = dis_create_compute_pipeline_with_layout( + d, dis_me_luma_comp, dis_me_luma_comp_size, d->me_pipeline_layout, NULL); + if (!d->pass_gradient.pipeline || !d->pass_inverse.pipeline || !d->pass_propagate.pipeline || !d->pass_densify.pipeline || !d->pass_interp.pipeline || !d->pass_vr_prep.pipeline || !d->pass_vr_d1.pipeline || !d->pass_vr_d2.pipeline || !d->pass_vr_w.pipeline || !d->pass_vr_coef.pipeline || !d->pass_vr_sor.pipeline || !d->pass_vr_add.pipeline || !d->pass_hist.pipeline || !d->pass_side.pipeline || - !d->pass_pack.pipeline) { + !d->pass_pack.pipeline || !d->pass_me_luma.pipeline) { return false; } return true; @@ -813,7 +904,7 @@ static void dis_write_all_descriptors(VkrDis* d) { const VkImageView coarse_view = l + 1 < DIS_VR_LEVELS ? d->view_flow_refined[coarse_l] : d->view_dense[coarse_l]; dis_batch_sampled(d, &b, d->inverse_sets[s][l], 3, coarse_view, d->sampler); - dis_batch_sampled(d, &b, d->inverse_sets[s][l], 4, d->view_dense[coarse], d->sampler); + dis_batch_sampled(d, &b, d->inverse_sets[s][l], 4, d->view_me_field, d->sampler); dis_batch_storage(d, &b, d->inverse_sets[s][l], 5, d->view_sparse[l]); dis_batch_sampled(d, &b, d->prop_ab_sets[s][l], 0, d->view_flow_luma[prev][l], d->sampler); @@ -910,6 +1001,7 @@ static void dis_destroy_views(VkrDis* d) { dis_destroy_view(d, &d->view_hist[1]); dis_destroy_view(d, &d->view_side); dis_destroy_view(d, &d->view_flow_out); + dis_destroy_view(d, &d->view_me_field); for (uint32_t l = 0; l < DIS_MAX_LEVELS; l++) { dis_destroy_view(d, &d->view_vr_prep[l]); dis_destroy_view(d, &d->view_vr_d1[l]); @@ -958,6 +1050,130 @@ static void dis_destroy_images(VkrDis* d) { dis_destroy_image(d, &d->hist[1]); dis_destroy_image(d, &d->side); dis_destroy_image(d, &d->flow_out); + dis_destroy_image(d, &d->me_field); + dis_destroy_me(d); +} + +static void dis_destroy_buffer(VkrDis* d, VkBuffer* buf, VkDeviceMemory* mem, void** map) { + if (*map) vkd.UnmapMemory(d->device, *mem); + if (*buf) vkd.DestroyBuffer(d->device, *buf, NULL); + if (*mem) vkd.FreeMemory(d->device, *mem, NULL); + *buf = VK_NULL_HANDLE; + *mem = VK_NULL_HANDLE; + *map = NULL; +} + +// Host-visible buffer, mapped for its whole life. Cached memory is preferred for a buffer the +// CPU reads back: uncached reads of a few hundred kilobytes cost far more than the invalidate. +static bool dis_create_host_buffer(VkrDis* d, VkDeviceSize size, VkBufferUsageFlags usage, + bool readback, VkBuffer* buf, VkDeviceMemory* mem, void** map, + bool* coherent) { + VkBufferCreateInfo bi; + memset(&bi, 0, sizeof(bi)); + bi.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; + bi.size = size; + bi.usage = usage; + bi.sharingMode = VK_SHARING_MODE_EXCLUSIVE; + if (vkd.CreateBuffer(d->device, &bi, NULL, buf) != VK_SUCCESS) return false; + VkMemoryRequirements mr; + vkd.GetBufferMemoryRequirements(d->device, *buf, &mr); + const VkMemoryPropertyFlags HV = VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT; + const VkMemoryPropertyFlags HC = VK_MEMORY_PROPERTY_HOST_COHERENT_BIT; + const VkMemoryPropertyFlags CA = VK_MEMORY_PROPERTY_HOST_CACHED_BIT; + uint32_t type = UINT32_MAX; + if (readback) { + type = dis_find_memory_type(d, mr.memoryTypeBits, HV | CA | HC); + if (type == UINT32_MAX) type = dis_find_memory_type(d, mr.memoryTypeBits, HV | CA); + } + if (type == UINT32_MAX) type = dis_find_memory_type(d, mr.memoryTypeBits, HV | HC); + if (type == UINT32_MAX) type = dis_find_memory_type(d, mr.memoryTypeBits, HV); + if (type == UINT32_MAX) return false; + *coherent = (d->mem_props.memoryTypes[type].propertyFlags & HC) != 0; + VkMemoryAllocateInfo ai; + memset(&ai, 0, sizeof(ai)); + ai.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; + ai.allocationSize = mr.size; + ai.memoryTypeIndex = type; + if (vkd.AllocateMemory(d->device, &ai, NULL, mem) != VK_SUCCESS) return false; + if (vkd.BindBufferMemory(d->device, *buf, *mem, 0) != VK_SUCCESS) return false; + return vkd.MapMemory(d->device, *mem, 0, VK_WHOLE_SIZE, 0, map) == VK_SUCCESS; +} + +static void dis_destroy_me(VkrDis* d) { + dis_qcom_me_destroy(d->me); + d->me = NULL; + dis_destroy_buffer(d, &d->me_luma_buf, &d->me_luma_mem, &d->me_luma_map); + dis_destroy_buffer(d, &d->me_field_buf, &d->me_field_mem, &d->me_field_map); + free(d->me_xy); + d->me_xy = NULL; + d->hint_level = -1; +} + +// The hint seeds the search on level me_level of a w x h pyramid: the estimator's field is that +// level's size, and its input is the field times the block size, which for the usual 2:1 levels +// is twice the flow extent - so the estimator sees finer detail than the level it seeds, and its +// fixed search range covers twice the motion it would at the flow extent. +static void dis_create_me(VkrDis* d, uint32_t w, uint32_t h) { + dis_destroy_me(d); + if (!d->hw_motion || d->levels < 3) return; + uint32_t bx = 0, by = 0; + if (!dis_qcom_me_supported(&bx, &by)) return; + + d->me_level = 2; + d->me_field_w = w >> d->me_level; + d->me_field_h = h >> d->me_level; + d->me_w = d->me_field_w * bx; + d->me_h = d->me_field_h * by; + // Tiny inputs came back as NaN on Adreno 750 (320x176); stay well clear of that. + if (d->me_w < 256 || d->me_h < 144 || (d->me_w & 3u)) return; + + const VkDeviceSize luma_bytes = (VkDeviceSize)d->me_w * d->me_h; + const VkDeviceSize field_bytes = (VkDeviceSize)d->me_field_w * d->me_field_h * 2 * sizeof(float); + if (!dis_create_host_buffer(d, luma_bytes, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, true, + &d->me_luma_buf, &d->me_luma_mem, &d->me_luma_map, + &d->me_luma_coherent) || + !dis_create_host_buffer(d, field_bytes, VK_BUFFER_USAGE_TRANSFER_SRC_BIT, false, + &d->me_field_buf, &d->me_field_mem, &d->me_field_map, + &d->me_field_coherent)) { + DIS_LOGW("DIS hardware motion: host buffers unavailable; using DIS alone"); + dis_destroy_me(d); + return; + } + d->me_xy = (float*)malloc((size_t)d->me_field_w * d->me_field_h * 2 * sizeof(float)); + d->me = d->me_xy ? dis_qcom_me_create(d->me_w, d->me_h) : NULL; + if (!d->me) { + dis_destroy_me(d); + return; + } + + for (uint32_t s = 0; s < DIS_SLOTS; s++) { + VkDescriptorImageInfo ii; + memset(&ii, 0, sizeof(ii)); + ii.sampler = d->sampler; + ii.imageView = d->view_color[s]; + ii.imageLayout = VK_IMAGE_LAYOUT_GENERAL; + VkDescriptorBufferInfo bi; + memset(&bi, 0, sizeof(bi)); + bi.buffer = d->me_luma_buf; + bi.range = VK_WHOLE_SIZE; + VkWriteDescriptorSet w2[2]; + memset(w2, 0, sizeof(w2)); + w2[0].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + w2[0].dstSet = d->me_sets[s]; + w2[0].dstBinding = 0; + w2[0].descriptorCount = 1; + w2[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + w2[0].pImageInfo = ⅈ + w2[1].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + w2[1].dstSet = d->me_sets[s]; + w2[1].dstBinding = 1; + w2[1].descriptorCount = 1; + w2[1].descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; + w2[1].pBufferInfo = &bi; + vkd.UpdateDescriptorSets(d->device, 2, w2, 0, NULL); + } + DIS_LOGI("DIS hardware motion hint: GL_QCOM_motion_estimation on %ux%u seeds level %u (%ux%u)", + d->me_w, d->me_h, d->me_level, d->me_field_w, d->me_field_h); } static bool dis_create_resources(VkrDis* d, uint32_t w, uint32_t h, uint32_t full_w, @@ -1025,6 +1241,13 @@ static bool dis_create_resources(VkrDis* d, uint32_t w, uint32_t h, uint32_t ful // every device, so its three lookups per output pixel stay single bilinear taps. if (!dis_create_image(d, &d->flow_out, w, h, VK_FORMAT_R16G16_SFLOAT, 1, VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_STORAGE_BIT)) return false; + // Hardware motion hint, one vector per block of the level it seeds. It always exists so the + // search's binding stays valid; the search reads it only on frames that uploaded one. + const uint32_t hint_w = L >= 3 ? (w >> 2) : 1u; + const uint32_t hint_h = L >= 3 ? (h >> 2) : 1u; + if (!dis_create_image(d, &d->me_field, hint_w ? hint_w : 1u, hint_h ? hint_h : 1u, + VK_FORMAT_R32G32_SFLOAT, 1, + VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT)) return false; for (uint32_t s = 0; s < DIS_SLOTS; s++) { if (!dis_create_view(d, d->color[s].image, format, 0, 1, &d->view_color[s])) return false; @@ -1055,9 +1278,11 @@ static bool dis_create_resources(VkrDis* d, uint32_t w, uint32_t h, uint32_t ful if (!dis_create_view(d, d->hist[1].image, VK_FORMAT_R32_SFLOAT, 0, 1, &d->view_hist[1])) return false; if (!dis_create_view(d, d->side.image, VK_FORMAT_R32_SFLOAT, 0, 1, &d->view_side)) return false; if (!dis_create_view(d, d->flow_out.image, VK_FORMAT_R16G16_SFLOAT, 0, 1, &d->view_flow_out)) return false; + if (!dis_create_view(d, d->me_field.image, VK_FORMAT_R32G32_SFLOAT, 0, 1, &d->view_me_field)) return false; vkr_dis_reset(d); dis_write_all_descriptors(d); + dis_create_me(d, w, h); return true; } @@ -1104,6 +1329,16 @@ static bool dis_allocate_sets(VkrDis* d) { if (!dis_alloc(d, d->set_layout, 1, &d->pack_set)) return false; + for (uint32_t s = 0; s < DIS_SLOTS; s++) { + VkDescriptorSetAllocateInfo ai; + memset(&ai, 0, sizeof(ai)); + ai.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; + ai.descriptorPool = d->me_pool; + ai.descriptorSetCount = 1; + ai.pSetLayouts = &d->me_set_layout; + if (vkd.AllocateDescriptorSets(d->device, &ai, &d->me_sets[s]) != VK_SUCCESS) return false; + } + for (uint32_t l = 0; l < DIS_VR_LEVELS; l++) { VkDescriptorSet vr_sets[DIS_VR_SHARED_SETS]; if (!dis_alloc(d, d->vr_set_layout, DIS_VR_SHARED_SETS, vr_sets)) return false; @@ -1271,6 +1506,18 @@ VkrDis* vkr_dis_create(VkDevice device, VkPhysicalDevice physical_device) { d->target_fps = 0; d->refresh_rate = 0.0f; d->plan_log_gen = -1; + // Off by default: on Adreno 750 the hint left quality unchanged and cost ~2.5 ms per real + // frame, most of it the mid-frame submit it needs. Opt in on a device without a rebuild: + // adb shell setprop debug.winnative.dis.hwme 1 + d->hw_motion = false; +#ifdef __ANDROID__ + char prop[PROP_VALUE_MAX] = {0}; + if (__system_property_get("debug.winnative.dis.hwme", prop) > 0 && prop[0] == '1') { + d->hw_motion = true; + DIS_LOGI("DIS hardware motion hint enabled by debug.winnative.dis.hwme"); + } +#endif + d->hint_level = -1; vkd.GetPhysicalDeviceMemoryProperties(physical_device, &d->mem_props); d->luma_format = dis_pick_luma_format(d); if (!dis_audit_formats(d)) { @@ -1313,6 +1560,10 @@ void vkr_dis_destroy(VkrDis* d) { if (d->pass_hist.pipeline) vkd.DestroyPipeline(d->device, d->pass_hist.pipeline, NULL); if (d->pass_side.pipeline) vkd.DestroyPipeline(d->device, d->pass_side.pipeline, NULL); if (d->pass_pack.pipeline) vkd.DestroyPipeline(d->device, d->pass_pack.pipeline, NULL); + if (d->pass_me_luma.pipeline) vkd.DestroyPipeline(d->device, d->pass_me_luma.pipeline, NULL); + if (d->me_pool) vkd.DestroyDescriptorPool(d->device, d->me_pool, NULL); + if (d->me_pipeline_layout) vkd.DestroyPipelineLayout(d->device, d->me_pipeline_layout, NULL); + if (d->me_set_layout) vkd.DestroyDescriptorSetLayout(d->device, d->me_set_layout, NULL); if (d->pool) vkd.DestroyDescriptorPool(d->device, d->pool, NULL); if (d->pipeline_layout) vkd.DestroyPipelineLayout(d->device, d->pipeline_layout, NULL); if (d->vr_pipeline_layout) vkd.DestroyPipelineLayout(d->device, d->vr_pipeline_layout, NULL); @@ -1661,11 +1912,156 @@ static void dis_vr_level(VkrDis* d, VkCommandBuffer cmd, uint32_t slot, uint32_t dis_compute_barrier(cmd); } -void vkr_dis_process(VkrDis* d, VkCommandBuffer cmd, VkImage source, uint32_t width, - uint32_t height, uint32_t generations) { - if (!d || !d->built || d->unavailable) return; +// Hardware motion hint for the pair ending in slot: the newest frame's luminance goes to the +// GLES estimator and its field comes back as the starting candidate of the search on me_level. +// +// The estimator needs this frame's pixels, which the caller has only just recorded, so the +// command buffer is handed back through lush to be submitted and waited on first. What was +// recorded so far - the caller's composite and the copies above - then runs ahead of the rest +// of the frame; the caller submits the returned buffer for everything after. +static VkCommandBuffer dis_hardware_motion(VkrDis* d, VkCommandBuffer cmd, uint32_t slot, + VkrDisFlushFn flush, void* flush_user) { + const int32_t me_pc[2] = {(int32_t)d->me_w, (int32_t)d->me_h}; + vkd.CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, d->pass_me_luma.pipeline); + vkd.CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, d->me_pipeline_layout, 0, 1, + &d->me_sets[slot], 0, NULL); + vkd.CmdPushConstants(cmd, d->me_pipeline_layout, VK_SHADER_STAGE_COMPUTE_BIT, 0, + sizeof(me_pc), me_pc); + vkd.CmdDispatch(cmd, (d->me_w / 4u + DIS_LOCAL_SIZE - 1) / DIS_LOCAL_SIZE, + (d->me_h + DIS_LOCAL_SIZE - 1) / DIS_LOCAL_SIZE, 1); + VkMemoryBarrier hb; + memset(&hb, 0, sizeof(hb)); + hb.sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER; + hb.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT; + hb.dstAccessMask = VK_ACCESS_HOST_READ_BIT; + vkd.CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_HOST_BIT, + 0, 1, &hb, 0, NULL, 0, NULL); + + cmd = flush(flush_user, cmd); + if (cmd == VK_NULL_HANDLE) return cmd; + + if (!d->me_luma_coherent) { + VkMappedMemoryRange r; + memset(&r, 0, sizeof(r)); + r.sType = VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE; + r.memory = d->me_luma_mem; + r.size = VK_WHOLE_SIZE; + vkd.InvalidateMappedMemoryRanges(d->device, 1, &r); + } + d->me_pairs++; + if (!dis_qcom_me_push(d->me, (const uint8_t*)d->me_luma_map, d->me_xy)) return cmd; + + // Pixels of the estimator's input -> normalised uv, which is what every flow image in the + // chain stores. Vectors the estimator could not have found - non-finite, or past half the + // frame - are marked invalid rather than clamped, so the search simply ignores them. + const uint32_t n = d->me_field_w * d->me_field_h; + float* dst = (float*)d->me_field_map; + const float inv_w = 1.0f / (float)d->me_w; + const float inv_h = 1.0f / (float)d->me_h; + for (uint32_t i = 0; i < n; i++) { + const float vx = d->me_xy[i * 2]; + const float vy = d->me_xy[i * 2 + 1]; + const bool ok = isfinite(vx) && isfinite(vy) && + fabsf(vx) < 0.5f * (float)d->me_w && fabsf(vy) < 0.5f * (float)d->me_h; + dst[i * 2] = ok ? vx * inv_w : 1.0e7f; + dst[i * 2 + 1] = ok ? vy * inv_h : 1.0e7f; + } + if (DIS_ME_PRIMARY) { + // As the level's result the field has no search behind it to reject a bad block, so + // outliers are taken out here: a 3x3 component median over the valid neighbours, and + // zero where there are none. + const int fw = (int)d->me_field_w, fh = (int)d->me_field_h; + float* med = d->me_xy; // reused: the raw pixels are no longer needed + for (int y = 0; y < fh; y++) { + for (int x = 0; x < fw; x++) { + for (int c = 0; c < 2; c++) { + float v[9]; + int k = 0; + for (int dy = -1; dy <= 1; dy++) { + for (int dx = -1; dx <= 1; dx++) { + const int xx = x + dx, yy = y + dy; + if (xx < 0 || yy < 0 || xx >= fw || yy >= fh) continue; + const float s = dst[(yy * fw + xx) * 2 + c]; + if (fabsf(s) < 1.0e6f) v[k++] = s; + } + } + for (int a = 1; a < k; a++) { + const float key = v[a]; + int b = a - 1; + while (b >= 0 && v[b] > key) { v[b + 1] = v[b]; b--; } + v[b + 1] = key; + } + med[(y * fw + x) * 2 + c] = k ? v[k / 2] : 0.0f; + } + } + } + memcpy(dst, med, (size_t)n * 2 * sizeof(float)); + } + if (!d->me_field_coherent) { + VkMappedMemoryRange r; + memset(&r, 0, sizeof(r)); + r.sType = VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE; + r.memory = d->me_field_mem; + r.size = VK_WHOLE_SIZE; + vkd.FlushMappedMemoryRanges(d->device, 1, &r); + } + + dis_barrier(cmd, d->me_field.image, VK_IMAGE_LAYOUT_GENERAL, VK_IMAGE_LAYOUT_GENERAL, + VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_ACCESS_SHADER_READ_BIT, VK_ACCESS_TRANSFER_WRITE_BIT); + VkBufferImageCopy region; + memset(®ion, 0, sizeof(region)); + region.imageSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + region.imageSubresource.layerCount = 1; + region.imageExtent.width = d->me_field_w; + region.imageExtent.height = d->me_field_h; + region.imageExtent.depth = 1; + vkd.CmdCopyBufferToImage(cmd, d->me_field_buf, d->me_field.image, VK_IMAGE_LAYOUT_GENERAL, 1, + ®ion); + dis_barrier(cmd, d->me_field.image, VK_IMAGE_LAYOUT_GENERAL, VK_IMAGE_LAYOUT_GENERAL, + VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, + VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_SHADER_READ_BIT); + if (DIS_ME_PRIMARY) { + // The field is exactly the size of level me_level, so it drops into that mip of the + // refined flow, where the next finer level's search picks it up as its coarse estimate. + VkImageCopy ic; + memset(&ic, 0, sizeof(ic)); + ic.srcSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + ic.srcSubresource.layerCount = 1; + ic.dstSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + ic.dstSubresource.mipLevel = d->me_level; + ic.dstSubresource.layerCount = 1; + ic.extent.width = d->me_field_w; + ic.extent.height = d->me_field_h; + ic.extent.depth = 1; + dis_barrier(cmd, d->flow_refined.image, VK_IMAGE_LAYOUT_GENERAL, VK_IMAGE_LAYOUT_GENERAL, + VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_ACCESS_SHADER_READ_BIT, VK_ACCESS_TRANSFER_WRITE_BIT); + vkd.CmdCopyImage(cmd, d->me_field.image, VK_IMAGE_LAYOUT_GENERAL, d->flow_refined.image, + VK_IMAGE_LAYOUT_GENERAL, 1, &ic); + dis_barrier(cmd, d->flow_refined.image, VK_IMAGE_LAYOUT_GENERAL, VK_IMAGE_LAYOUT_GENERAL, + VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, + VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_SHADER_READ_BIT); + d->me_primary_frame = true; + } else { + d->hint_level = (int)d->me_level; + } + d->me_hinted++; + if ((d->me_hinted % 600u) == 1u) { + DIS_LOGI("DIS hardware motion hint: %llu of %llu pairs seeded", + (unsigned long long)d->me_hinted, (unsigned long long)d->me_pairs); + } + return cmd; +} + +VkCommandBuffer vkr_dis_process_ex(VkrDis* d, VkCommandBuffer cmd, VkImage source, + uint32_t width, uint32_t height, uint32_t generations, + VkrDisFlushFn flush, void* flush_user) { + if (!d || !d->built || d->unavailable) return cmd; d->last_generations = generations; + d->hint_level = -1; + d->me_primary_frame = false; dis_prime_layouts(d, cmd); @@ -1723,10 +2119,17 @@ void vkr_dis_process(VkrDis* d, VkCommandBuffer cmd, VkImage source, uint32_t wi (lh + DIS_LOCAL_SIZE - 1) / DIS_LOCAL_SIZE, 1); } - dis_compute_barrier(cmd); + const bool wants_flow = generations > 0 || d->debug_flow; + if (d->me && flush && wants_flow) { + cmd = dis_hardware_motion(d, cmd, slot, flush, flush_user); + } else if (d->me) { + // Without this pair's estimate the held frame no longer precedes the next one. + dis_qcom_me_invalidate(d->me); + } - if (generations == 0 && !d->debug_flow) return; + dis_compute_barrier(cmd); + if (!wants_flow) return cmd; DisGradientPC gpc; gpc.lesser = 3.0f; gpc.upper = 10.0f; @@ -1747,6 +2150,8 @@ void vkr_dis_process(VkrDis* d, VkCommandBuffer cmd, VkImage source, uint32_t wi for (uint32_t li = 0; li < L; li++) { const uint32_t l = coarse - li; + // The hardware field already stands in for this level and everything above it. + if (d->me_primary_frame && l >= d->me_level) continue; const uint32_t lw = w >> l; const uint32_t lh = h >> l; const uint32_t spw = dis_sparse_extent(lw); @@ -1755,6 +2160,7 @@ void vkr_dis_process(VkrDis* d, VkCommandBuffer cmd, VkImage source, uint32_t wi DisInversePC ipc; ipc.level = (int)l; ipc.coarseLevel = (int)coarse; + ipc.hintLevel = d->hint_level; vkd.CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, d->pass_inverse.pipeline); vkd.CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, d->pipeline_layout, 0, 1, &d->inverse_sets[slot][l], 0, NULL); @@ -1818,7 +2224,12 @@ void vkr_dis_process(VkrDis* d, VkCommandBuffer cmd, VkImage source, uint32_t wi dis_dispatch(d, cmd, d->pass_side.pipeline, d->side_sets[slot], w, h); dis_compute_barrier(cmd); } + return cmd; +} +void vkr_dis_process(VkrDis* d, VkCommandBuffer cmd, VkImage source, uint32_t width, + uint32_t height, uint32_t generations) { + (void)vkr_dis_process_ex(d, cmd, source, width, height, generations, NULL, NULL); } static void dis_render_into(VkrDis* d, VkCommandBuffer cmd, float t, int debug_mode, @@ -1946,4 +2357,17 @@ void vkr_dis_reset(VkrDis* d) { d->plan_log_ns = 0; d->hist_parity = 0; d->hist_valid = false; + d->hint_level = -1; + if (d->me) dis_qcom_me_invalidate(d->me); +} + +void vkr_dis_set_hw_motion(VkrDis* d, bool enabled) { + if (!d || d->hw_motion == enabled) return; + d->hw_motion = enabled; + // Takes effect at the next resource build; force one. + d->built = false; +} + +bool vkr_dis_hw_motion_active(const VkrDis* d) { + return d && d->me != NULL; } diff --git a/app/src/main/cpp/waylandcomp/CMakeLists.txt b/app/src/main/cpp/waylandcomp/CMakeLists.txt index c15792839..e6f1d691c 100644 --- a/app/src/main/cpp/waylandcomp/CMakeLists.txt +++ b/app/src/main/cpp/waylandcomp/CMakeLists.txt @@ -43,14 +43,14 @@ target_include_directories(wnwayland PRIVATE ) # The X11 renderer's frame-generation engines run here too, on the compositor's own Turnip -# device. Their sources are compiled in rather than linked from libwinlator: both libraries keep -# a private VkDispatch, so the visibility below must stay for the two copies not to merge. +# device. LSFG is compiled in and DIS comes from the wndis static library rather than being linked +# from libwinlator: both libraries keep a private VkDispatch, so the visibility below must stay for +# the two copies not to merge. set(FRAMEGEN_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../winlator/vk) -if (TARGET dxbc AND EXISTS ${FRAMEGEN_DIR}/dis/vkr_dis.c) +if (TARGET dxbc AND TARGET wndis) target_sources(wnwayland PRIVATE src/framegen_engine.c ${FRAMEGEN_DIR}/vk_dispatch.c - ${FRAMEGEN_DIR}/dis/vkr_dis.c ${FRAMEGEN_DIR}/lsfg/lsfg_dll.c ${FRAMEGEN_DIR}/lsfg/lsfg_dxbc.cpp ${FRAMEGEN_DIR}/lsfg/lsfg_common.cpp @@ -69,7 +69,7 @@ if (TARGET dxbc AND EXISTS ${FRAMEGEN_DIR}/dis/vkr_dis.c) target_compile_definitions(wnwayland PRIVATE VK_USE_PLATFORM_ANDROID_KHR) target_compile_options(wnwayland PRIVATE -fvisibility=hidden) target_compile_features(wnwayland PRIVATE cxx_std_17) - target_link_libraries(wnwayland dxbc) + target_link_libraries(wnwayland dxbc wndis) add_dependencies(wnwayland winlator_shaders) else() target_sources(wnwayland PRIVATE src/framegen_engine_stub.c) diff --git a/app/src/main/cpp/waylandcomp/src/framegen_engine.c b/app/src/main/cpp/waylandcomp/src/framegen_engine.c index 46af9101b..5573bf09c 100644 --- a/app/src/main/cpp/waylandcomp/src/framegen_engine.c +++ b/app/src/main/cpp/waylandcomp/src/framegen_engine.c @@ -3,7 +3,7 @@ #include "framegen_engine.h" #include "framegen_bridge.h" -#include "dis/vkr_dis.h" +#include "vkr_dis.h" #include "lsfg/vkr_lsfg.h" #include diff --git a/app/src/main/cpp/winlator/vk/framegen/fg_present.c b/app/src/main/cpp/winlator/vk/framegen/fg_present.c index 26c79a6d0..a563f00f5 100644 --- a/app/src/main/cpp/winlator/vk/framegen/fg_present.c +++ b/app/src/main/cpp/winlator/vk/framegen/fg_present.c @@ -15,7 +15,7 @@ #include "../vk_dispatch.h" #include "../vk_driver.h" -#include "../dis/vkr_dis.h" +#include "vkr_dis.h" #include "../lsfg/vkr_lsfg.h" #define LOG_TAG "FgPresent" @@ -87,6 +87,7 @@ struct FgPresenter { VkCommandPool command_pool; FgFrame frames[FG_FRAMES_IN_FLIGHT]; + VkFence flush_fence; uint32_t frame_index; FgTarget targets[FG_MAX_TARGETS]; @@ -540,6 +541,9 @@ static bool fg_create_frames(FgPresenter* fg) { cpi.queueFamilyIndex = fg->queue_family; if (vkCreateCommandPool(fg->device, &cpi, NULL, &fg->command_pool) != VK_SUCCESS) return false; + VkFenceCreateInfo ffi = {VK_STRUCTURE_TYPE_FENCE_CREATE_INFO}; + if (vkCreateFence(fg->device, &ffi, NULL, &fg->flush_fence) != VK_SUCCESS) return false; + for (uint32_t i = 0; i < FG_FRAMES_IN_FLIGHT; i++) { FgFrame* f = &fg->frames[i]; @@ -804,6 +808,30 @@ static void fg_renew_semaphore(FgPresenter* fg, VkSemaphore* handle) { *handle = fresh; } +// VkrDisFlushFn: DIS needs this frame's pixels on the CPU mid-frame for the hardware motion +// estimator. Everything recorded so far - the source blit and DIS's copies - touches no +// swapchain image and waits on no semaphore, so it goes out on its own; the same buffer is then +// begun again for the rest of the frame, which keeps every semaphore on the final submit. +static VkCommandBuffer fg_dis_flush(void* user, VkCommandBuffer cmd) { + FgPresenter* fg = (FgPresenter*)user; + vkEndCommandBuffer(cmd); + VkSubmitInfo si = {VK_STRUCTURE_TYPE_SUBMIT_INFO}; + si.commandBufferCount = 1; + si.pCommandBuffers = &cmd; + vkResetFences(fg->device, 1, &fg->flush_fence); + if (vkQueueSubmit(fg->queue, 1, &si, fg->flush_fence) == VK_SUCCESS) { + vkWaitForFences(fg->device, 1, &fg->flush_fence, VK_TRUE, UINT64_MAX); + } else { + FG_LOGW("DIS mid-frame submit failed"); + vkDeviceWaitIdle(fg->device); + } + vkResetCommandBuffer(cmd, 0); + VkCommandBufferBeginInfo bi = {VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO}; + bi.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; + vkBeginCommandBuffer(cmd, &bi); + return cmd; +} + static void fg_record_and_present(FgPresenter* fg, FgImport* source, AImage* image) { FgFrame* f = &fg->frames[fg->frame_index]; @@ -879,8 +907,8 @@ static void fg_record_and_present(FgPresenter* fg, FgImport* source, AImage* ima VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | VK_ACCESS_TRANSFER_READ_BIT); if (fg->active_engine == FG_ENGINE_DIS) { - vkr_dis_process(fg->dis, f->cmd, composite->image, fg->extent.width, fg->extent.height, - gen_count); + vkr_dis_process_ex(fg->dis, f->cmd, composite->image, fg->extent.width, + fg->extent.height, gen_count, fg_dis_flush, fg); } else if (fg->lsfg) { vkr_lsfg_process(fg->lsfg, f->cmd, composite->image, fg->extent.width, fg->extent.height, gen_count); @@ -1329,6 +1357,7 @@ void fg_destroy(FgPresenter* fg) { vkDestroySemaphore(fg->device, fg->retired[i], NULL); } fg->retired_count = 0; + if (fg->flush_fence) vkDestroyFence(fg->device, fg->flush_fence, NULL); if (fg->command_pool) vkDestroyCommandPool(fg->device, fg->command_pool, NULL); vkDestroyDevice(fg->device, NULL); } diff --git a/app/src/main/cpp/winlator/vk/vk_dispatch.c b/app/src/main/cpp/winlator/vk/vk_dispatch.c index 77335b211..8983daf9a 100644 --- a/app/src/main/cpp/winlator/vk/vk_dispatch.c +++ b/app/src/main/cpp/winlator/vk/vk_dispatch.c @@ -81,6 +81,7 @@ bool vkd_load_instance(VkInstance instance) { LOAD(MapMemory); LOAD(UnmapMemory); LOAD(FlushMappedMemoryRanges); + LOAD(InvalidateMappedMemoryRanges); LOAD(GetAndroidHardwareBufferPropertiesANDROID); // Buffer diff --git a/app/src/main/cpp/winlator/vk/vk_dispatch.h b/app/src/main/cpp/winlator/vk/vk_dispatch.h index 4e7072c2f..ed5a1de11 100644 --- a/app/src/main/cpp/winlator/vk/vk_dispatch.h +++ b/app/src/main/cpp/winlator/vk/vk_dispatch.h @@ -57,6 +57,7 @@ typedef struct VkDispatch { PFN_vkMapMemory MapMemory; PFN_vkUnmapMemory UnmapMemory; PFN_vkFlushMappedMemoryRanges FlushMappedMemoryRanges; + PFN_vkInvalidateMappedMemoryRanges InvalidateMappedMemoryRanges; PFN_vkGetAndroidHardwareBufferPropertiesANDROID GetAndroidHardwareBufferPropertiesANDROID; // Buffer @@ -206,6 +207,7 @@ bool vkd_bind_proc(PFN_vkGetInstanceProcAddr gipa, VkInstance instance); #define vkMapMemory vkd.MapMemory #define vkUnmapMemory vkd.UnmapMemory #define vkFlushMappedMemoryRanges vkd.FlushMappedMemoryRanges +#define vkInvalidateMappedMemoryRanges vkd.InvalidateMappedMemoryRanges #define vkGetAndroidHardwareBufferPropertiesANDROID vkd.GetAndroidHardwareBufferPropertiesANDROID #define vkCreateBuffer vkd.CreateBuffer diff --git a/app/src/main/cpp/winlator/vk/vk_renderer.c b/app/src/main/cpp/winlator/vk/vk_renderer.c index 9755849b5..1e5a8138a 100644 --- a/app/src/main/cpp/winlator/vk/vk_renderer.c +++ b/app/src/main/cpp/winlator/vk/vk_renderer.c @@ -1923,6 +1923,10 @@ static void destroy_dis(VkRenderer* r) { if (!r->dis) return; vkr_dis_destroy(r->dis); r->dis = NULL; + if (r->dis_flush_fence) { + vkDestroyFence(r->device, r->dis_flush_fence, NULL); + r->dis_flush_fence = VK_NULL_HANDLE; + } r->framegen_real_frames = 0; r->framegen_made_frames = 0; r->framegen_draw_ns = 0; @@ -1931,6 +1935,39 @@ static void destroy_dis(VkRenderer* r) { r->framegen_timed_frames = 0; } +// VkrDisFlushFn: DIS needs this frame's pixels on the CPU mid-frame for the hardware motion +// estimator. What has been recorded by then is the scene pass into the composite target and +// DIS's own copies - no swapchain image, no semaphore - so it is submitted on its own and the +// same buffer is begun again; acquire waits and present signals all stay on the frame's submit. +static VkCommandBuffer dis_flush_frame(void* user, VkCommandBuffer cmd) { + VkRenderer* r = (VkRenderer*)user; + if (!r->dis_flush_fence) { + VkFenceCreateInfo fci = {VK_STRUCTURE_TYPE_FENCE_CREATE_INFO}; + if (vkCreateFence(r->device, &fci, NULL, &r->dis_flush_fence) != VK_SUCCESS) { + r->dis_flush_fence = VK_NULL_HANDLE; + } + } + vkEndCommandBuffer(cmd); + VkSubmitInfo si = {VK_STRUCTURE_TYPE_SUBMIT_INFO}; + si.commandBufferCount = 1; + si.pCommandBuffers = &cmd; + if (r->dis_flush_fence) vkResetFences(r->device, 1, &r->dis_flush_fence); + pthread_mutex_lock(&r->queue_mutex); + VkResult sr = vkQueueSubmit(r->graphics_queue, 1, &si, r->dis_flush_fence); + if (sr != VK_SUCCESS || !r->dis_flush_fence) vkQueueWaitIdle(r->graphics_queue); + pthread_mutex_unlock(&r->queue_mutex); + if (sr == VK_SUCCESS && r->dis_flush_fence) { + vkWaitForFences(r->device, 1, &r->dis_flush_fence, VK_TRUE, UINT64_MAX); + } else if (sr != VK_SUCCESS) { + VK_LOGW("DIS mid-frame submit -> %d", (int)sr); + } + vkResetCommandBuffer(cmd, 0); + VkCommandBufferBeginInfo bi = {VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO}; + bi.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; + vkBeginCommandBuffer(cmd, &bi); + return cmd; +} + static void create_dis(VkRenderer* r) { if (r->dis || !r->device || !r->physical_device) return; @@ -2885,8 +2922,9 @@ static bool record_and_submit_frame(VkRenderer* r) { if (composite) { if ((use_dis || r->lsfg) && framegen_capacity > 0) { if (use_dis) { - vkr_dis_process(r->dis, f->cmd, composite->image, - composite->width, composite->height, gen_count); + vkr_dis_process_ex(r->dis, f->cmd, composite->image, + composite->width, composite->height, gen_count, + dis_flush_frame, r); } else { vkr_lsfg_process(r->lsfg, f->cmd, composite->image, r->swapchain_extent.width, r->swapchain_extent.height, gen_count); diff --git a/app/src/main/cpp/winlator/vk/vk_state.h b/app/src/main/cpp/winlator/vk/vk_state.h index cf8f70cce..7bcba786e 100644 --- a/app/src/main/cpp/winlator/vk/vk_state.h +++ b/app/src/main/cpp/winlator/vk/vk_state.h @@ -15,7 +15,7 @@ // this translation unit (do not include directly). #include "vk_dispatch.h" #include "lsfg/vkr_lsfg.h" -#include "dis/vkr_dis.h" +#include "vkr_dis.h" #define VK_LOG_TAG "VkRenderer" #define VK_LOGI(...) __android_log_print(ANDROID_LOG_INFO, VK_LOG_TAG, __VA_ARGS__) @@ -426,6 +426,7 @@ typedef struct VkRenderer { struct VkrDis* dis; bool dis_requested; uint32_t dis_scale; + VkFence dis_flush_fence; // mid-frame submit for DIS's hardware motion hint uint32_t dis_target_fps; bool dis_debug_flow; uint64_t sgsr1_dbg_sig;