mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-08 08:07:53 -10:00
Compare commits
675
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aedfd10c93 | ||
|
|
f64f181d1d | ||
|
|
fe020bb113 | ||
|
|
0ae694bb66 | ||
|
|
25b7c04314 | ||
|
|
1a8589a0dd | ||
|
|
7176e0df23 | ||
|
|
fad4b0f5dd | ||
|
|
8ca3dc19dd | ||
|
|
26ee23d376 | ||
|
|
fe524fefb2 | ||
|
|
bb26451389 | ||
|
|
2e2635f83d | ||
|
|
aee2c83c2f | ||
|
|
6ef71901bf | ||
|
|
da954fba54 | ||
|
|
4f54fcf47b | ||
|
|
22e6e5c8a4 | ||
|
|
d77d31fff1 | ||
|
|
2c04e1ba85 | ||
|
|
edddce4485 | ||
|
|
6ebe4a33c3 | ||
|
|
4018f7925e | ||
|
|
cbb2b99022 | ||
|
|
7fdce634b4 | ||
|
|
7d084fc8aa | ||
|
|
edb0905d0f | ||
|
|
f6dda117a8 | ||
|
|
5c0ac57986 | ||
|
|
a12e378c46 | ||
|
|
c1e81319cb | ||
|
|
7232e9f6bd | ||
|
|
fe63866acc | ||
|
|
4c66382f4e | ||
|
|
604e9cb82f | ||
|
|
205a2387c3 | ||
|
|
1d56d438df | ||
|
|
7a87965ec8 | ||
|
|
1334feb5fa | ||
|
|
fb75635ec3 | ||
|
|
af4a313bc7 | ||
|
|
9b0c6f48ea | ||
|
|
f030d9c807 | ||
|
|
bf3191ed83 | ||
|
|
631eef433b | ||
|
|
7ba6d345b0 | ||
|
|
2d29c7f8e9 | ||
|
|
76bfbf1ad8 | ||
|
|
87b5ae27a6 | ||
|
|
28acf8303c | ||
|
|
2a66276bcc | ||
|
|
38f9ce31e7 | ||
|
|
67f84002d3 | ||
|
|
97f4aa5e7f | ||
|
|
3b89ded9f9 | ||
|
|
5c9bf7ad11 | ||
|
|
de12dbd275 | ||
|
|
957475f644 | ||
|
|
33e0bf2aec | ||
|
|
dca25b7662 | ||
|
|
964e31a3c3 | ||
|
|
da4e8d8d49 | ||
|
|
efe5e70188 | ||
|
|
901e640591 | ||
|
|
e7c443cdf6 | ||
|
|
baf9a32a0e | ||
|
|
854a5dc246 | ||
|
|
8c672e7f9e | ||
|
|
4a1f7dfad4 | ||
|
|
e6d44af84e | ||
|
|
23ea8d66a6 | ||
|
|
8abbc6b9e4 | ||
|
|
f2fe9c1dfe | ||
|
|
6a11db7ef7 | ||
|
|
7a5d306949 | ||
|
|
66d06e23f7 | ||
|
|
417677fdda | ||
|
|
8721db0e28 | ||
|
|
190c17fc7a | ||
|
|
9e1abc64d1 | ||
|
|
d1782f8954 | ||
|
|
00c184e250 | ||
|
|
c4b55f1113 | ||
|
|
7054def60b | ||
|
|
776a3b08a0 | ||
|
|
f79643821e | ||
|
|
3b52978005 | ||
|
|
f4c7a94ad6 | ||
|
|
252f02e02a | ||
|
|
1d267df0b3 | ||
|
|
9651982e21 | ||
|
|
3e3a0ffa10 | ||
|
|
11a7d2cd89 | ||
|
|
2415fb9e91 | ||
|
|
6f395191c2 | ||
|
|
01eefdcc63 | ||
|
|
cafee2f14d | ||
|
|
bd8e0da359 | ||
|
|
bff8ecf203 | ||
|
|
affb2b6924 | ||
|
|
d450bc3b5d | ||
|
|
3ac68d69d3 | ||
|
|
22c6c667a2 | ||
|
|
d91706378e | ||
|
|
92e96819f5 | ||
|
|
f9ed804bb7 | ||
|
|
2394ee861d | ||
|
|
f54f5c41b6 | ||
|
|
048eb18683 | ||
|
|
ace3883402 | ||
|
|
86e57cb74c | ||
|
|
33c96ac1f2 | ||
|
|
2bc3726b4e | ||
|
|
3a05ee18c6 | ||
|
|
e1f9a8c096 | ||
|
|
0b2270dd45 | ||
|
|
4dbb00502f | ||
|
|
6c75463502 | ||
|
|
c471036d29 | ||
|
|
41de65db6e | ||
|
|
1c15d2b68c | ||
|
|
0eb8b8ec26 | ||
|
|
1d0621ac38 | ||
|
|
1ebc813852 | ||
|
|
59994ad2a2 | ||
|
|
9a62e99376 | ||
|
|
0b10ddd665 | ||
|
|
406ac72d0a | ||
|
|
e2116fb8c2 | ||
|
|
acb17d5694 | ||
|
|
65a2bf4613 | ||
|
|
eb69b3986b | ||
|
|
389224abec | ||
|
|
3bea613053 | ||
|
|
a9d293cb18 | ||
|
|
cd8ebdff23 | ||
|
|
6edc5de20f | ||
|
|
314edb02fc | ||
|
|
5e2b6c675f | ||
|
|
01c53f23d2 | ||
|
|
40aed431b6 | ||
|
|
4c3cb9a51b | ||
|
|
2052b918ec | ||
|
|
c23236bceb | ||
|
|
4ca78266a5 | ||
|
|
cf9569747b | ||
|
|
423abdd863 | ||
|
|
8f0a7f4494 | ||
|
|
4b38881802 | ||
|
|
7daecbbce2 | ||
|
|
1f5cd1e43b | ||
|
|
d6be40bbf7 | ||
|
|
e020e1d440 | ||
|
|
01edf84d9f | ||
|
|
8540619240 | ||
|
|
97020c6abe | ||
|
|
0a9ce82a0b | ||
|
|
9dda4ce381 | ||
|
|
4d91ddc86c | ||
|
|
e1ae4fb750 | ||
|
|
d0dc8f1ad6 | ||
|
|
8ac6fa92a1 | ||
|
|
b1ebe3d6e0 | ||
|
|
8485f4c0ef | ||
|
|
f38098f921 | ||
|
|
87bb072d77 | ||
|
|
c7389334f1 | ||
|
|
733d34582d | ||
|
|
aac6bc2c2a | ||
|
|
af6fc73a51 | ||
|
|
8bf4597a57 | ||
|
|
cc3d62a7cd | ||
|
|
3042d4068b | ||
|
|
e1f01aff0b | ||
|
|
9c24961df0 | ||
|
|
bbabaf720b | ||
|
|
1e1c77126d | ||
|
|
933ab57aa1 | ||
|
|
c5067a988b | ||
|
|
3936cafd12 | ||
|
|
3b6d2fc635 | ||
|
|
bd6025952f | ||
|
|
98abaa9217 | ||
|
|
c6eef4b0b7 | ||
|
|
1fb469a969 | ||
|
|
7adf3992a5 | ||
|
|
8629067aa4 | ||
|
|
9f6d28e257 | ||
|
|
ec2c0c8d1f | ||
|
|
358c4e0c01 | ||
|
|
90b4e7d794 | ||
|
|
989afe1c00 | ||
|
|
0dc90c1d29 | ||
|
|
3e4f2a7305 | ||
|
|
1c9380fa56 | ||
|
|
6e5cd517d7 | ||
|
|
5a0906f5f4 | ||
|
|
b9404f32c3 | ||
|
|
951fe53102 | ||
|
|
7abea17aa3 | ||
|
|
0f56e3165a | ||
|
|
3943b0e8a7 | ||
|
|
f7a3c0f2e4 | ||
|
|
92b1ae1132 | ||
|
|
316752b706 | ||
|
|
aa3919d3ae | ||
|
|
a1afb474a5 | ||
|
|
fdb6ad8b8f | ||
|
|
7f505e07dd | ||
|
|
98b4d049fa | ||
|
|
32faed9621 | ||
|
|
af61f6751e | ||
|
|
a4df995446 | ||
|
|
6c2498236d | ||
|
|
7ddbb41f86 | ||
|
|
4b38e08b7f | ||
|
|
5c00a74d4e | ||
|
|
858f248202 | ||
|
|
290560bc52 | ||
|
|
30c20a7a54 | ||
|
|
f96d9e89b5 | ||
|
|
a45b450e56 | ||
|
|
e6281842d8 | ||
|
|
842c094de7 | ||
|
|
fc55599794 | ||
|
|
12af27a781 | ||
|
|
bca5833f8d | ||
|
|
ca0da4d9bb | ||
|
|
4a8a101391 | ||
|
|
07aab11e71 | ||
|
|
fdd3d1e59c | ||
|
|
bc21da03bc | ||
|
|
93fc4b2ba9 | ||
|
|
f3cc9257df | ||
|
|
8c1f6115d5 | ||
|
|
069cc39f05 | ||
|
|
0a407714e0 | ||
|
|
3bb4e7827c | ||
|
|
546eab04b7 | ||
|
|
a9fba955bd | ||
|
|
e74f789659 | ||
|
|
e02c201c97 | ||
|
|
139e83fff8 | ||
|
|
1d6e5d01b5 | ||
|
|
2adedf5570 | ||
|
|
4e6920a319 | ||
|
|
c214f59711 | ||
|
|
5e7e654901 | ||
|
|
7e4e421224 | ||
|
|
719fa5e2bd | ||
|
|
485323aaaf | ||
|
|
4a374440f4 | ||
|
|
b99e2087d3 | ||
|
|
b84c97088f | ||
|
|
57da7aecbc | ||
|
|
e93afc5ade | ||
|
|
a7296b75e0 | ||
|
|
9d25e30101 | ||
|
|
4543e2f454 | ||
|
|
36584650ea | ||
|
|
aee2c63c3b | ||
|
|
ef23545cef | ||
|
|
92c708b3fe | ||
|
|
c6f935414d | ||
|
|
73231e735d | ||
|
|
a71408736f | ||
|
|
fb4805072f | ||
|
|
111c22dd5e | ||
|
|
ce08c38337 | ||
|
|
8d2900e6ce | ||
|
|
cd1318bf02 | ||
|
|
2f661c08ed | ||
|
|
1bf8766c1d | ||
|
|
9f92bb23b4 | ||
|
|
3e06612e08 | ||
|
|
15c0246614 | ||
|
|
face534db3 | ||
|
|
64f4bab6f3 | ||
|
|
52d1a0b475 | ||
|
|
c0fe9adca6 | ||
|
|
992ea05825 | ||
|
|
2e11de8943 | ||
|
|
7351ad6d91 | ||
|
|
0a6eca805d | ||
|
|
e949fd4cc1 | ||
|
|
ed0be44325 | ||
|
|
97e46ce1d5 | ||
|
|
5669d66de3 | ||
|
|
f54fba8726 | ||
|
|
f86b98b203 | ||
|
|
9842e75046 | ||
|
|
99956fff22 | ||
|
|
b23ad347d1 | ||
|
|
350e156a34 | ||
|
|
502c15aba6 | ||
|
|
147980da63 | ||
|
|
97e99a45b4 | ||
|
|
37cda82c81 | ||
|
|
c76ed64570 | ||
|
|
f63ad014e1 | ||
|
|
7b33d702f5 | ||
|
|
dfae932fec | ||
|
|
bf3a5f162f | ||
|
|
14fc6d55e0 | ||
|
|
4e6a8131c2 | ||
|
|
01451da341 | ||
|
|
179ebb840a | ||
|
|
fca63b5eb8 | ||
|
|
c1cdeb19d4 | ||
|
|
b03127ab97 | ||
|
|
1f9493c4fc | ||
|
|
9524516752 | ||
|
|
a465c3efae | ||
|
|
904b3036ab | ||
|
|
85d56215e6 | ||
|
|
ebf06768f1 | ||
|
|
fd03679d85 | ||
|
|
27ed9c66f7 | ||
|
|
738861aae2 | ||
|
|
c7ff85fdf4 | ||
|
|
175c2d453a | ||
|
|
673d5cff49 | ||
|
|
ca896819d7 | ||
|
|
0a8df72a16 | ||
|
|
b13dbf0687 | ||
|
|
0cdb230b54 | ||
|
|
6e919e4356 | ||
|
|
92cf9ac888 | ||
|
|
0961939b71 | ||
|
|
2cd05e462e | ||
|
|
541f331f1e | ||
|
|
973287971d | ||
|
|
7648d874b9 | ||
|
|
b91f5f5892 | ||
|
|
f159a106a8 | ||
|
|
c805ee70c5 | ||
|
|
074aa1124f | ||
|
|
14bfb672f8 | ||
|
|
bf321a8425 | ||
|
|
2fdc1509ae | ||
|
|
703c0ea18b | ||
|
|
d15f455027 | ||
|
|
23f0fd6675 | ||
|
|
8b583a15e1 | ||
|
|
48782a8231 | ||
|
|
43832ec794 | ||
|
|
d2d9dd7ae1 | ||
|
|
c0a3af67b0 | ||
|
|
acf0cabce9 | ||
|
|
bc6016d19d | ||
|
|
72e6ef91db | ||
|
|
ab069b576c | ||
|
|
f521237d6c | ||
|
|
18bbc46240 | ||
|
|
a75a57bd18 | ||
|
|
aed804a26d | ||
|
|
8ce0848a31 | ||
|
|
48c080e387 | ||
|
|
02e546660a | ||
|
|
46e77b05a8 | ||
|
|
3c73be2517 | ||
|
|
917ce67f0d | ||
|
|
b11fe1da84 | ||
|
|
fdb23b8ac6 | ||
|
|
ac85e523c7 | ||
|
|
386ab14035 | ||
|
|
ca82b37eb0 | ||
|
|
a505caa32a | ||
|
|
9aa5c9fe4a | ||
|
|
c7a2daa593 | ||
|
|
3584268d11 | ||
|
|
bf1070413f | ||
|
|
2573d1c76a | ||
|
|
3a967d8838 | ||
|
|
000e13fbe6 | ||
|
|
40e3f368da | ||
|
|
bd967852e5 | ||
|
|
ecdecc8fd2 | ||
|
|
c4c084be94 | ||
|
|
6c5d89ffac | ||
|
|
6e72e2de7d | ||
|
|
8b0028bbeb | ||
|
|
e69f6a5050 | ||
|
|
7c34cfa6b2 | ||
|
|
b399f0ddc5 | ||
|
|
a3fd3597f4 | ||
|
|
1d9f455dac | ||
|
|
f97dffe988 | ||
|
|
c6f52257e2 | ||
|
|
07d0cfc344 | ||
|
|
949eef6772 | ||
|
|
ecce33154e | ||
|
|
d6f88d2483 | ||
|
|
0cdc4a49da | ||
|
|
fee1085bec | ||
|
|
272518c1f2 | ||
|
|
68459f0442 | ||
|
|
2877c7fbfa | ||
|
|
5be2aba2ae | ||
|
|
6b371cdc48 | ||
|
|
1b63e0abc2 | ||
|
|
9ab82c59cd | ||
|
|
e0cbbdb50a | ||
|
|
56ec50a875 | ||
|
|
066b6e9cef | ||
|
|
172d7c51e6 | ||
|
|
5cf4a1dec8 | ||
|
|
4bf9b604f7 | ||
|
|
b95e34a990 | ||
|
|
e86ce41888 | ||
|
|
458f0e4134 | ||
|
|
29b3374b5d | ||
|
|
02042bf6b4 | ||
|
|
79e277451b | ||
|
|
e5c1ea0612 | ||
|
|
2f39e246fe | ||
|
|
2cd9e12ecd | ||
|
|
6d32a88a50 | ||
|
|
124e88e731 | ||
|
|
1a004614f8 | ||
|
|
1edf540479 | ||
|
|
9bccc1056f | ||
|
|
d144af4a11 | ||
|
|
616707f3d7 | ||
|
|
1c5744ac24 | ||
|
|
c124718cbc | ||
|
|
0b8f18dd40 | ||
|
|
e51c277db7 | ||
|
|
fc61e4e9ff | ||
|
|
2c300290d5 | ||
|
|
16260920c3 | ||
|
|
d84428c781 | ||
|
|
e5f23f8b40 | ||
|
|
17e4124d04 | ||
|
|
37eafb6c1b | ||
|
|
c56e9312ee | ||
|
|
9f52bf9a83 | ||
|
|
a9612bdabc | ||
|
|
a20d3296b4 | ||
|
|
eadbb41095 | ||
|
|
bc59734a29 | ||
|
|
626c60740d | ||
|
|
0bd5a4f201 | ||
|
|
cf0b0d02c8 | ||
|
|
60fff3987f | ||
|
|
1b93764780 | ||
|
|
52aad2205d | ||
|
|
da4d26b00d | ||
|
|
342315073f | ||
|
|
de0132b638 | ||
|
|
30f1fc145d | ||
|
|
f509e284bf | ||
|
|
3d5216e717 | ||
|
|
9bc110bae6 | ||
|
|
14ba691e53 | ||
|
|
877cb37978 | ||
|
|
83535ddcc1 | ||
|
|
2c672eae2d | ||
|
|
12273a4e8e | ||
|
|
796047f1e8 | ||
|
|
34194ab7ec | ||
|
|
7803f1351e | ||
|
|
c9b07b1ffb | ||
|
|
ec77214342 | ||
|
|
bec6ac6448 | ||
|
|
a2b3f669ef | ||
|
|
7bfecd3fd1 | ||
|
|
43ef30f98c | ||
|
|
2dbcb75cfa | ||
|
|
ce4f0d7074 | ||
|
|
4ba236928d | ||
|
|
34c5bb84e6 | ||
|
|
e809bed768 | ||
|
|
34f528ffcb | ||
|
|
031037cee5 | ||
|
|
7f6f26a33a | ||
|
|
70342ed68b | ||
|
|
98d7553124 | ||
|
|
ef6fc399f5 | ||
|
|
94aeeac20d | ||
|
|
7f51885a85 | ||
|
|
5ecd155b76 | ||
|
|
5fe374444f | ||
|
|
c3f7e94775 | ||
|
|
ff50de2553 | ||
|
|
d4ea299fdd | ||
|
|
5383881562 | ||
|
|
1c402c47a7 | ||
|
|
6e5f7e88f7 | ||
|
|
75dd0dde7d | ||
|
|
8c4e3bb507 | ||
|
|
dabae908cd | ||
|
|
211b53f598 | ||
|
|
2d94afaa61 | ||
|
|
14a5193b41 | ||
|
|
00d050bb8f | ||
|
|
7225a84b10 | ||
|
|
40afd73cd8 | ||
|
|
c54c8c0f42 | ||
|
|
939d492535 | ||
|
|
41934f3d93 | ||
|
|
85a6a8b9dc | ||
|
|
4ca3d55e78 | ||
|
|
b97f0f61d7 | ||
|
|
0c40da1a82 | ||
|
|
e01adc67e3 | ||
|
|
32137ae44f | ||
|
|
9503a3cc7d | ||
|
|
7c4a6d1ea6 | ||
|
|
3c4d936707 | ||
|
|
44f5a37e64 | ||
|
|
5ffd5c6515 | ||
|
|
8cd7616054 | ||
|
|
ea99e6572c | ||
|
|
c5dbb620c2 | ||
|
|
642ac0d489 | ||
|
|
92bedb26d0 | ||
|
|
3d8924dcc8 | ||
|
|
8ad82f9ac0 | ||
|
|
77290ac3c1 | ||
|
|
daa43de927 | ||
|
|
3fb98b187f | ||
|
|
76db917ded | ||
|
|
721d4cfa44 | ||
|
|
78b21e4afb | ||
|
|
26496db7c3 | ||
|
|
37b79d1b63 | ||
|
|
d5f931ca64 | ||
|
|
702b0895bd | ||
|
|
a08827d6d5 | ||
|
|
4465238c9c | ||
|
|
43943df1ef | ||
|
|
8906102eb9 | ||
|
|
0a51d6dc5c | ||
|
|
21ca2fdd1d | ||
|
|
89a515a8d7 | ||
|
|
a0f9368edf | ||
|
|
1f86e14b9a | ||
|
|
5ec521bb7b | ||
|
|
80bbced3fc | ||
|
|
c30b863128 | ||
|
|
690d434afa | ||
|
|
c0806b9ee3 | ||
|
|
f57811dd0d | ||
|
|
70bc17720c | ||
|
|
73e054f827 | ||
|
|
e1434f2ee4 | ||
|
|
25ea320338 | ||
|
|
6fa7f8896a | ||
|
|
67a39ecaac | ||
|
|
199d9d74f8 | ||
|
|
1005076a59 | ||
|
|
94ed2f4cc8 | ||
|
|
517f54d076 | ||
|
|
9626c7446e | ||
|
|
da2b437f49 | ||
|
|
6e0f482e83 | ||
|
|
8189734106 | ||
|
|
1f29c88a49 | ||
|
|
bf0f0dd811 | ||
|
|
007458a627 | ||
|
|
9c30028167 | ||
|
|
0d49b282c0 | ||
|
|
099fb70e6a | ||
|
|
38a1babc5a | ||
|
|
c7aef39dd5 | ||
|
|
d7c3fefcfa | ||
|
|
8b48b0d74e | ||
|
|
3e5e8c4841 | ||
|
|
5664a950e8 | ||
|
|
538422f3c7 | ||
|
|
b528247824 | ||
|
|
ed1ce7495e | ||
|
|
d1f8cccb7c | ||
|
|
e8da3a8e21 | ||
|
|
3004b64927 | ||
|
|
60a2c3f164 | ||
|
|
abdf311e3b | ||
|
|
f96e0fba0f | ||
|
|
653a4a99fe | ||
|
|
65c35da0dc | ||
|
|
4fa237a12e | ||
|
|
772088790c | ||
|
|
aaa674f54b | ||
|
|
ad475b0573 | ||
|
|
aa019cfaa4 | ||
|
|
9e962f6350 | ||
|
|
78ea4b6d02 | ||
|
|
e2367a4b6a | ||
|
|
596fda5618 | ||
|
|
46b257b1d5 | ||
|
|
8b0c8e40a6 | ||
|
|
d980cd810d | ||
|
|
196bea6f1a | ||
|
|
cdabdddb6b | ||
|
|
78efabcfa7 | ||
|
|
8d783c0ff0 | ||
|
|
cbe9ddfa3f | ||
|
|
749c5405f6 | ||
|
|
bc8912c13f | ||
|
|
408e2606d8 | ||
|
|
40ad3a91c9 | ||
|
|
fb3f1bce90 | ||
|
|
833bb5cab4 | ||
|
|
5d9d9fb3ab | ||
|
|
65e2abae80 | ||
|
|
dddf351a05 | ||
|
|
570d53a780 | ||
|
|
8db7c3ddc0 | ||
|
|
4cc2a04c25 | ||
|
|
a61825404a | ||
|
|
ce25d100ef | ||
|
|
8b577fc24c | ||
|
|
974305151d | ||
|
|
e6941baa5a | ||
|
|
d72b2a555b | ||
|
|
7cd9dbc7a2 | ||
|
|
6d1746d729 | ||
|
|
2dd6879bc4 | ||
|
|
aa2220fcf6 | ||
|
|
8cd009e149 | ||
|
|
ebfcad98f6 | ||
|
|
d94fdae0b9 | ||
|
|
e16378accd | ||
|
|
d1dd934be2 | ||
|
|
a95c0e022d | ||
|
|
282a7005c8 | ||
|
|
2f3743d282 | ||
|
|
381c5a1e62 | ||
|
|
6a634e49e2 | ||
|
|
1eb3b7f6c3 | ||
|
|
2f7a46dfd3 | ||
|
|
dfe4c2cb51 | ||
|
|
3ed00f4071 | ||
|
|
36add718c0 | ||
|
|
077164ff2f | ||
|
|
c953fe79cb | ||
|
|
92282c7476 | ||
|
|
052c2eb962 | ||
|
|
1066efbafb | ||
|
|
fdc056c8ac | ||
|
|
e8a5564059 | ||
|
|
70a0a29eec | ||
|
|
5cffcf1370 | ||
|
|
2688eb16d6 | ||
|
|
ff28824d42 | ||
|
|
a0c5f26cc2 | ||
|
|
45978aaf67 | ||
|
|
24fa04d753 | ||
|
|
1429b27098 | ||
|
|
c78882c7da | ||
|
|
bb22bf6570 | ||
|
|
c7f54b7461 | ||
|
|
199e8193fd | ||
|
|
ac4859eab4 | ||
|
|
087c763432 | ||
|
|
55a54edebe | ||
|
|
c9f2efa210 | ||
|
|
d3e7858cda | ||
|
|
6d5c7c51a6 | ||
|
|
8e0b157254 | ||
|
|
d689881e68 | ||
|
|
dd4528f4e4 | ||
|
|
ab6900b4c9 | ||
|
|
58e32ea813 | ||
|
|
4304cc44b5 | ||
|
|
96c7d68d0d | ||
|
|
5988321440 | ||
|
|
47dc19fa20 | ||
|
|
9141f1d538 | ||
|
|
a33e5b574a | ||
|
|
78cf6e36a0 | ||
|
|
bd991e7917 | ||
|
|
18f1a16d12 | ||
|
|
c7fc74cb26 |
No files matched your search
@@ -168,7 +168,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
|
||||
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server`, `horizon-anticheat` (L: `lan/anticheat.rs`) |
|
||||
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities), `world_session.rs` (opening and closing a world); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
|
||||
| Input | game.rs `fn window_event`; G: `play_keys.rs` (`fn key_pressed`), `play_input.rs` (`fn play_mouse`, toggled keys, the hotbar wheel); crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs`, `pointer_capture.rs` (mouse look, pointer grab) |
|
||||
| Input | game.rs `fn window_event`; G: `play_keys.rs` (`fn key_pressed`), `play_input.rs` (`fn play_mouse`, toggled keys, the hotbar wheel); crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs`, `pad_craft.rs`, `pointer_capture.rs` (mouse look, pointer grab) |
|
||||
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
|
||||
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `mobs/cem.rs` + `mobs/cem_anim.rs` (OptiFine/EMF entity models and their animations); `horizon-client-core/src/lang.rs`; crates `horizon-assets`, `horizon-optifine`; G: `packs.rs` |
|
||||
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
|
||||
|
||||
@@ -309,3 +309,11 @@ Permission to use, copy, modify, and distribute this
|
||||
software is freely granted, provided that this notice
|
||||
is preserved.
|
||||
```
|
||||
|
||||
## Starling
|
||||
|
||||
`crates/horizon-client-render/src/touch/layout.rs` is adapted from the touch layout of
|
||||
[Starling](https://github.com/starling-client/starling), an Android port of Horizon, at commit
|
||||
[150a414b09a1](https://github.com/starling-client/starling/tree/150a414b09a18c900489d9462a7fc035dae744b7)
|
||||
(`android` branch), written mostly by Vasysik. Starling is licensed under the same MIT license as
|
||||
Horizon (Copyright (c) 2026 Ludovic Marie and the Horizon contributors).
|
||||
@@ -412,10 +412,11 @@ pub fn to_text_with(
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let translated = Text::translate(key, &args);
|
||||
// TranslatableContents.decompose: Language.getOrDefault(key, fallback), so the
|
||||
// fallback is the template itself, never looked up as another key.
|
||||
match o.get("fallback").and_then(Value::as_str) {
|
||||
Some(fallback) if translated.plain() == key => Text::translate(fallback, &args),
|
||||
_ => translated,
|
||||
Some(fallback) => Text::translate_with_fallback(key, fallback, &args),
|
||||
None => Text::translate(key, &args),
|
||||
}
|
||||
} else if let Some(sel) = o.get("selector").and_then(Value::as_str) {
|
||||
resolve(sel)
|
||||
@@ -513,6 +514,43 @@ pub fn name_text(tag: &Tag) -> Text {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::lang::{self, Language, ResourceSource};
|
||||
|
||||
struct Mem(std::collections::HashMap<String, Vec<u8>>);
|
||||
impl ResourceSource for Mem {
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
self.0.get(path).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
/// TranslatableContents.decompose: Language.getOrDefault(key, fallback). A missing key
|
||||
/// shows the fallback as written, even when the fallback is another key the language has.
|
||||
#[test]
|
||||
fn a_translatable_fallback_stays_literal() {
|
||||
let pack = Mem([(
|
||||
"assets/minecraft/lang/en_us.json".to_owned(),
|
||||
br#"{"real.key": "Translated", "greet": "Hello %s"}"#.to_vec(),
|
||||
)]
|
||||
.into());
|
||||
let ((), _) = lang::scoped(Language::load(&[&pack], "en_us"), || {
|
||||
let parse = |v: Value| to_text(&v, &mut |_| Text::empty()).plain();
|
||||
let missing = serde_json::json!({"translate": "missing.key", "fallback": "real.key"});
|
||||
assert_eq!(parse(missing), "real.key");
|
||||
// The fallback is a template: its arguments are filled in.
|
||||
let args = serde_json::json!({"translate": "missing.key", "fallback": "Hi %s", "with": ["Steve"]});
|
||||
assert_eq!(parse(args), "Hi Steve");
|
||||
// A key the language has wins over its fallback.
|
||||
let found =
|
||||
serde_json::json!({"translate": "greet", "fallback": "real.key", "with": ["Alex"]});
|
||||
assert_eq!(parse(found), "Hello Alex");
|
||||
// A component built with the constructor keeps its meaning through its JSON.
|
||||
let built = Text::translate_with_fallback("missing.key", "real.key", &[]);
|
||||
let again = to_text(&built.component(), &mut |_| Text::empty());
|
||||
assert_eq!(again.plain(), built.plain());
|
||||
assert_eq!(again.component(), built.component());
|
||||
});
|
||||
}
|
||||
|
||||
/// SnbtGrammar: a compound or list may end with one comma; two in a row
|
||||
/// stay an error.
|
||||
#[test]
|
||||
|
||||
@@ -46,6 +46,21 @@ thread_local! {
|
||||
}
|
||||
/// The current language's `bidirectional`, read for every drawn string.
|
||||
static BIDIRECTIONAL: AtomicBool = AtomicBool::new(false);
|
||||
/// Counts the languages set (`set`): another count is another language, so text looked up
|
||||
/// before it may read otherwise.
|
||||
static GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
thread_local! {
|
||||
/// Counts the scopes this thread entered and left (`scoped`), which no other thread sees.
|
||||
static SCOPE_GENERATION: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
/// Which language text looks up in now, on this thread, as a count that changes whenever that
|
||||
/// can: both counts only grow, so their sum changes when either does.
|
||||
pub fn generation() -> u64 {
|
||||
GENERATION.load(Ordering::Relaxed) + SCOPE_GENERATION.get()
|
||||
}
|
||||
fn scope_changed() {
|
||||
SCOPE_GENERATION.set(SCOPE_GENERATION.get() + 1);
|
||||
}
|
||||
|
||||
/// A source of resource files: the prepared root plus packs, highest priority last.
|
||||
pub trait ResourceSource {
|
||||
@@ -524,6 +539,7 @@ pub fn available(sources: &[&dyn ResourceSource]) -> Vec<LanguageInfo> {
|
||||
pub fn set(language: Language) {
|
||||
BIDIRECTIONAL.store(language.bidirectional, Ordering::Relaxed);
|
||||
*CURRENT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(language));
|
||||
GENERATION.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
pub fn current() -> Option<Arc<Language>> {
|
||||
if let Some(language) = SCOPE.with(|s| s.borrow().as_ref().map(|s| s.language.clone())) {
|
||||
@@ -543,6 +559,7 @@ pub fn scoped<R>(language: Language, f: impl FnOnce() -> R) -> (R, Vec<String>)
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
SCOPE.with(|s| *s.borrow_mut() = self.0.take());
|
||||
scope_changed();
|
||||
}
|
||||
}
|
||||
let outer = SCOPE.with(|s| {
|
||||
@@ -552,6 +569,7 @@ pub fn scoped<R>(language: Language, f: impl FnOnce() -> R) -> (R, Vec<String>)
|
||||
})
|
||||
});
|
||||
let _restore = Restore(outer);
|
||||
scope_changed();
|
||||
let result = f();
|
||||
let missed = SCOPE.with(|s| {
|
||||
s.borrow_mut()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! NBT paths (`NbtPathArgument`): `foo.bar[0]`, `[]`, `list[{id:1}]`,
|
||||
//! `{root:match}`, with get, set, insert and remove on a tag tree, and the
|
||||
//! partial NBT comparison (`NbtUtils.compareNbt`) selectors and conditions use.
|
||||
pub use horizon_world::nbt::compare_nbt;
|
||||
use horizon_world::nbt::{Compound, JavaString, Tag, parse_snbt};
|
||||
|
||||
/// An error carrying the translation key and its arguments.
|
||||
@@ -47,26 +48,6 @@ fn get_child<'a>(c: &'a Compound, name: &str) -> Option<&'a Tag> {
|
||||
c.get(&key(name))
|
||||
}
|
||||
|
||||
/// `NbtUtils.compareNbt(expected, actual, true)`.
|
||||
pub fn compare_nbt(expected: &Tag, actual: Option<&Tag>) -> bool {
|
||||
let Some(actual) = actual else { return false };
|
||||
if std::mem::discriminant(expected) != std::mem::discriminant(actual) {
|
||||
return false;
|
||||
}
|
||||
match (expected, actual) {
|
||||
(Tag::Compound(e), Tag::Compound(a)) => {
|
||||
a.len() >= e.len() && e.iter().all(|(k, v)| compare_nbt(v, a.get(k)))
|
||||
}
|
||||
(Tag::List(e), Tag::List(a)) => {
|
||||
if e.is_empty() {
|
||||
return a.is_empty();
|
||||
}
|
||||
a.len() >= e.len() && e.iter().all(|x| a.iter().any(|y| compare_nbt(x, Some(y))))
|
||||
}
|
||||
_ => expected == actual,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- list-like tags ----------------------------------------------------------
|
||||
|
||||
/// The elements of a list or array tag.
|
||||
|
||||
@@ -89,6 +89,31 @@ pub struct Registries {
|
||||
pub trigger_objectives: Vec<String>,
|
||||
/// Objectives with the `trigger` criterion -> holder -> whether its score is locked.
|
||||
pub trigger_scores: BTreeMap<String, BTreeMap<String, bool>>,
|
||||
/// The `ConfiguredFeature` codec for an inline `minecraft:worldgen/feature`
|
||||
/// value (`ResourceOrIdArgument`), where the source has the server's live
|
||||
/// features; None leaves the decode to when the command runs.
|
||||
pub inline_feature: Option<InlineFeatureCheck>,
|
||||
}
|
||||
|
||||
/// A codec check of an inline value: its error message when it does not decode.
|
||||
#[derive(Clone)]
|
||||
pub struct InlineFeatureCheck(
|
||||
std::sync::Arc<dyn Fn(&horizon_world::nbt::Tag) -> Result<(), String> + Send + Sync>,
|
||||
);
|
||||
impl InlineFeatureCheck {
|
||||
pub fn new(
|
||||
check: impl Fn(&horizon_world::nbt::Tag) -> Result<(), String> + Send + Sync + 'static,
|
||||
) -> Self {
|
||||
Self(std::sync::Arc::new(check))
|
||||
}
|
||||
pub fn check(&self, value: &horizon_world::nbt::Tag) -> Result<(), String> {
|
||||
(self.0)(value)
|
||||
}
|
||||
}
|
||||
impl std::fmt::Debug for InlineFeatureCheck {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("InlineFeatureCheck(..)")
|
||||
}
|
||||
}
|
||||
/// The ids and tag ids of one registry.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
|
||||
@@ -583,6 +583,10 @@ pub struct PadSettings {
|
||||
/// The sneak button flips sneaking (Bedrock's default) instead of
|
||||
/// holding it, unless vanilla's toggle crouch is on already.
|
||||
pub toggle_sneak: bool,
|
||||
/// LT + A on a recipe crafts on every server, not only in single player and on LAN worlds
|
||||
/// found in the server list. Off by default: a server's rules may count a repeated craft as a
|
||||
/// macro.
|
||||
pub craft_on_servers: bool,
|
||||
pub glyphs: GlyphStyle,
|
||||
/// A Nintendo pad's face buttons go by their letters (A confirms and jumps, as on the
|
||||
/// Switch's own menus and keyboard) instead of their places (B, at the bottom, does): on
|
||||
@@ -614,6 +618,7 @@ impl Default for PadSettings {
|
||||
look_deadzone: 0.12,
|
||||
trigger_threshold: 0.4,
|
||||
toggle_sneak: true,
|
||||
craft_on_servers: false,
|
||||
glyphs: GlyphStyle::Auto,
|
||||
nintendo_letters: cfg!(target_os = "horizon"),
|
||||
keyboard: TextKeyboard::Auto,
|
||||
@@ -684,6 +689,7 @@ impl PadSettings {
|
||||
s.trigger_threshold = unit(s.trigger_threshold).clamp(0.1, 0.9)
|
||||
}
|
||||
"toggleSneak" => s.toggle_sneak = flag(s.toggle_sneak),
|
||||
"craftOnServers" => s.craft_on_servers = flag(s.craft_on_servers),
|
||||
"glyphs" => s.glyphs = GlyphStyle::parse(value),
|
||||
"nintendoLetters" => s.nintendo_letters = flag(s.nintendo_letters),
|
||||
"keyboard" => s.keyboard = TextKeyboard::parse(value),
|
||||
@@ -745,7 +751,8 @@ impl PadSettings {
|
||||
self.keyboard.name(),
|
||||
);
|
||||
text.push_str(&format!(
|
||||
"gameplayGuide:{}\npointerSpeed:{}\nscrollSpeed:{}\nrepeatDelay:{}\nrepeatInterval:{}\n",
|
||||
"craftOnServers:{}\ngameplayGuide:{}\npointerSpeed:{}\nscrollSpeed:{}\nrepeatDelay:{}\nrepeatInterval:{}\n",
|
||||
bool_text(self.craft_on_servers),
|
||||
self.guide_detail.name(),
|
||||
self.pointer_speed, self.scroll_speed, self.repeat_delay, self.repeat_interval,
|
||||
));
|
||||
|
||||
@@ -32,6 +32,11 @@ fn pack(properties: &str, shadow: Option<&str>, reads_shadow: bool) -> horizon_s
|
||||
write("final.fsh", if reads_shadow { SHADOW_READ } else { COLOUR });
|
||||
write("gbuffers_entities.vsh", VERTEX);
|
||||
write("gbuffers_entities.fsh", COLOUR);
|
||||
// Terrain programs keep the load free of "nothing draws it" notes; no terrain is drawn.
|
||||
for terrain in ["gbuffers_terrain", "gbuffers_water"] {
|
||||
write(&format!("{terrain}.vsh"), VERTEX);
|
||||
write(&format!("{terrain}.fsh"), COLOUR);
|
||||
}
|
||||
if let Some(shadow) = shadow {
|
||||
write("shadow.vsh", VERTEX);
|
||||
write("shadow.fsh", shadow);
|
||||
|
||||
@@ -661,6 +661,7 @@ fn remesh(
|
||||
blend_radius,
|
||||
smooth_lighting,
|
||||
cutout_leaves,
|
||||
opaque_ice: false,
|
||||
translucent_layers: &[],
|
||||
leaf_culling,
|
||||
};
|
||||
|
||||
@@ -50,10 +50,14 @@ fn pack(renderer: &mut Renderer, folder: &Path, valid: bool) {
|
||||
std::fs::create_dir_all(&shaders).unwrap();
|
||||
let vertex = "#version 120\nvarying vec2 uv; void main(){gl_Position=ftransform();uv=gl_MultiTexCoord0.xy;}\n";
|
||||
let fragment = "#version 120\nuniform int entityId; uniform int blockEntityId; uniform vec4 entityColor; void main(){gl_FragData[0]=vec4(float(entityId)/255.0,entityColor.a,float(blockEntityId)/255.0,1.0);}\n";
|
||||
// The terrain programs keep the load free of "nothing draws it" notes; this
|
||||
// fixture draws no terrain.
|
||||
for program in [
|
||||
"gbuffers_entities",
|
||||
"gbuffers_block",
|
||||
"gbuffers_entities_translucent",
|
||||
"gbuffers_terrain",
|
||||
"gbuffers_water",
|
||||
] {
|
||||
std::fs::write(shaders.join(format!("{program}.vsh")), vertex).unwrap();
|
||||
std::fs::write(
|
||||
|
||||
@@ -47,6 +47,53 @@ struct Channel {
|
||||
world: u32,
|
||||
/// The sound file it started and when, to tell another player's copy of it.
|
||||
started: Option<(String, std::time::Instant)>,
|
||||
/// Its sound event plays clearly through a mod's muffle (`Audio::muffle`).
|
||||
unmuffled: bool,
|
||||
}
|
||||
/// The cutoff of the muffle's low-pass at full strength: the world heard through ringing ears.
|
||||
const MUFFLED_CUTOFF: f32 = 300.0;
|
||||
/// The cutoff it sweeps down from as its strength rises.
|
||||
const OPEN_CUTOFF: f32 = 20_000.0;
|
||||
/// How long the muffle takes to glide to a new level: a game tick's step (50 ms) would click.
|
||||
const MUFFLE_GLIDE: f32 = 0.02;
|
||||
/// A mod's muffle (`Audio::muffle`) in the mixer: the other sounds are mixed apart, low-passed by
|
||||
/// two one-pole stages and turned down, then added to the sounds it leaves clear. While it is off
|
||||
/// (never set, or faded out) the sounds mix straight into the output, as without it.
|
||||
#[derive(Default)]
|
||||
struct Muffle {
|
||||
/// `[amount, lowpass]`, each 0 to 1, as the game's tick last set them.
|
||||
target: [f32; 2],
|
||||
/// Where the output is now, gliding to `target`.
|
||||
current: [f32; 2],
|
||||
/// The low-pass stages' state for the left and right output channels.
|
||||
stages: [[f32; 2]; 2],
|
||||
/// The muffled sounds of one buffer, reused.
|
||||
bus: Vec<f32>,
|
||||
}
|
||||
impl Muffle {
|
||||
fn active(&self) -> bool {
|
||||
self.target != [0.0; 2] || self.current.iter().any(|v| *v > 1e-4)
|
||||
}
|
||||
/// Adds `bus` (the muffled sounds) to `data`, low-passed and turned down.
|
||||
fn apply(&mut self, bus: &[f32], data: &mut [f32], channels: usize, rate: u32) {
|
||||
let rate = rate as f32;
|
||||
let glide = 1.0 - (-1.0 / (MUFFLE_GLIDE * rate)).exp();
|
||||
for (frame, muffled) in data.chunks_mut(channels).zip(bus.chunks(channels)) {
|
||||
for (current, target) in self.current.iter_mut().zip(self.target) {
|
||||
*current += (target - *current) * glide;
|
||||
}
|
||||
let [amount, lowpass] = self.current;
|
||||
let gain = 1.0 - amount;
|
||||
let cutoff = OPEN_CUTOFF * (MUFFLED_CUTOFF / OPEN_CUTOFF).powf(lowpass);
|
||||
let a = 1.0 - (-std::f32::consts::TAU * cutoff / rate).exp();
|
||||
for ((out, s), stage) in frame.iter_mut().zip(muffled).zip(&mut self.stages) {
|
||||
stage[0] += (s - stage[0]) * a;
|
||||
stage[1] += (stage[0] - stage[1]) * a;
|
||||
// Dry at no strength, so a muffle that only turns sounds down leaves them as they were.
|
||||
*out += (s + (stage[1] - s) * lowpass) * gain;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Mixer {
|
||||
@@ -65,6 +112,8 @@ struct Mixer {
|
||||
cameras: Vec<([f64; 3], Basis, u32)>,
|
||||
/// Per-source gain/pan/step cache for one buffer, reused across callbacks.
|
||||
voices: Vec<(ChannelId, [f32; 3], f64)>,
|
||||
/// A mod's muffle of the sounds it does not exempt.
|
||||
muffle: Muffle,
|
||||
limiter_peak: f32,
|
||||
/// The output device the stream was opened on (`Library.currentDeviceName`).
|
||||
#[cfg(feature = "audio")]
|
||||
@@ -100,6 +149,21 @@ pub struct Heard {
|
||||
pub range: f32,
|
||||
pub looping: bool,
|
||||
}
|
||||
/// A mod's muffle (`Audio::muffle`): its full strength, and the ticks it fades over and has left.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct MuffleFade {
|
||||
amount: f32,
|
||||
lowpass: f32,
|
||||
ticks: u32,
|
||||
left: u32,
|
||||
}
|
||||
impl MuffleFade {
|
||||
/// `[amount, lowpass]` now: full when set, falling in a straight line to none.
|
||||
fn level(&self) -> [f32; 2] {
|
||||
let left = self.left as f32 / self.ticks as f32;
|
||||
[self.amount * left, self.lowpass * left]
|
||||
}
|
||||
}
|
||||
/// The mixer side of the engine (`Backend`).
|
||||
pub struct MixerBackend {
|
||||
mixer: Arc<Mutex<Mixer>>,
|
||||
@@ -247,6 +311,8 @@ pub struct Audio {
|
||||
pub stopped: std::collections::BTreeMap<String, u32>,
|
||||
/// The volume sliders' `SoundPreviewHandler`.
|
||||
preview: crate::sound_preview::SoundPreview,
|
||||
/// A mod's muffle fading out (`muffle`), advanced by `tick`.
|
||||
muffle: Option<MuffleFade>,
|
||||
/// `SoundEngine.lastSeenDevices`: the system default output last seen.
|
||||
#[cfg(feature = "audio")]
|
||||
default_seen: Option<String>,
|
||||
@@ -439,6 +505,7 @@ impl Backend for MixerBackend {
|
||||
owner: self.owner,
|
||||
world: self.world,
|
||||
started: None,
|
||||
unmuffled: false,
|
||||
},
|
||||
);
|
||||
Some(id)
|
||||
@@ -466,6 +533,9 @@ impl Backend for MixerBackend {
|
||||
fn set_relative(&mut self, channel: ChannelId, relative: bool) {
|
||||
self.with(channel, |c| c.relative = relative);
|
||||
}
|
||||
fn set_unmuffled(&mut self, channel: ChannelId, unmuffled: bool) {
|
||||
self.with(channel, |c| c.unmuffled = unmuffled);
|
||||
}
|
||||
/// `getCompleteBuffer` (cached, else decoded on the decoder thread) or
|
||||
/// `getStream` (decoded on a thread of its own), then attach and
|
||||
/// `alSourcePlay`. A sound that cannot load leaves its source initial,
|
||||
@@ -702,6 +772,13 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0));
|
||||
voices.push((*id, [left, right, gain], step));
|
||||
}
|
||||
// A mod's muffle: the sounds it doesn't exempt mix into a bus of their own first.
|
||||
let muffling = mixer.muffle.active();
|
||||
let mut bus = std::mem::take(&mut mixer.muffle.bus);
|
||||
if muffling {
|
||||
bus.clear();
|
||||
bus.resize(data.len(), 0.0);
|
||||
}
|
||||
for (id, [left, right, gain], step) in voices.iter().copied() {
|
||||
let Some(c) = mixer.channels.get_mut(&id) else {
|
||||
continue;
|
||||
@@ -711,7 +788,12 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
};
|
||||
let source_channels = source_channels as usize;
|
||||
let frames = samples.len() / source_channels;
|
||||
for frame in data.chunks_mut(channels) {
|
||||
let out: &mut [f32] = if muffling && !c.unmuffled {
|
||||
&mut bus
|
||||
} else {
|
||||
&mut *data
|
||||
};
|
||||
for frame in out.chunks_mut(channels) {
|
||||
let mut i = c.position as usize;
|
||||
if i + 1 >= frames {
|
||||
if c.looping && frames > 1 {
|
||||
@@ -746,6 +828,14 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
}
|
||||
}
|
||||
mixer.voices = voices;
|
||||
if muffling {
|
||||
mixer.muffle.apply(&bus, data, channels, rate);
|
||||
} else {
|
||||
// Off: the next muffle starts from silence, not from the last one's filter.
|
||||
mixer.muffle.current = [0.0; 2];
|
||||
mixer.muffle.stages = [[0.0; 2]; 2];
|
||||
}
|
||||
mixer.muffle.bus = bus;
|
||||
// Limit the whole buffer together, preserving waveform and stereo balance
|
||||
// instead of flattening each overloaded sample; release over 200 ms
|
||||
// (OpenAL Soft's output limiter, `ALC_OUTPUT_LIMITER_SOFT`, releases
|
||||
@@ -1077,6 +1167,7 @@ impl Audio {
|
||||
heard: Default::default(),
|
||||
stopped: Default::default(),
|
||||
preview: Default::default(),
|
||||
muffle: None,
|
||||
#[cfg(feature = "audio")]
|
||||
default_seen: if live { default_output_name() } else { None },
|
||||
#[cfg(feature = "audio")]
|
||||
@@ -1238,6 +1329,7 @@ impl Audio {
|
||||
heard: Default::default(),
|
||||
stopped: Default::default(),
|
||||
preview: Default::default(),
|
||||
muffle: None,
|
||||
// The device belongs to the first player's output, which looks after it.
|
||||
#[cfg(feature = "audio")]
|
||||
default_seen: self.default_seen.clone(),
|
||||
@@ -1370,6 +1462,50 @@ impl Audio {
|
||||
}
|
||||
self.sync_volumes();
|
||||
self.engine.tick(paused, ctx, &mut self.backend);
|
||||
// A paused game holds a mod's muffle where it is, as it holds the sounds.
|
||||
if !paused && let Some(fade) = &mut self.muffle {
|
||||
fade.left = fade.left.saturating_sub(1);
|
||||
let level = fade.level();
|
||||
if fade.left == 0 {
|
||||
self.muffle = None;
|
||||
self.engine.set_unmuffled(Vec::new(), &mut self.backend);
|
||||
}
|
||||
self.set_muffle_level(level);
|
||||
}
|
||||
}
|
||||
/// A mod's muffle (`native_muffle_sounds`): `amount` (0 to 1) of every sound's volume taken
|
||||
/// away and `lowpass` (0 to 1) dulling it, from untouched to a 300 Hz low-pass, fading in a
|
||||
/// straight line to none over `ticks` client ticks; the sound events in `exempt` (namespaced,
|
||||
/// `mymod:ear.ringing`) play clearly through it, those already playing too. It replaces any
|
||||
/// muffle in place; `ticks` 0 or no strength ends it. The output's, so in splitscreen every
|
||||
/// player hears it. False for an amount or strength outside 0 to 1.
|
||||
pub fn muffle(&mut self, amount: f32, lowpass: f32, ticks: u32, exempt: &[&str]) -> bool {
|
||||
if !(0.0..=1.0).contains(&amount) || !(0.0..=1.0).contains(&lowpass) {
|
||||
return false;
|
||||
}
|
||||
let on = ticks > 0 && (amount > 0.0 || lowpass > 0.0);
|
||||
let exempt = if on {
|
||||
exempt
|
||||
.iter()
|
||||
.map(|e| crate::sound_events::normalize(e))
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
self.engine.set_unmuffled(exempt, &mut self.backend);
|
||||
self.muffle = on.then_some(MuffleFade {
|
||||
amount,
|
||||
lowpass,
|
||||
ticks,
|
||||
left: ticks,
|
||||
});
|
||||
self.set_muffle_level(self.muffle.map_or([0.0; 2], |fade| fade.level()));
|
||||
true
|
||||
}
|
||||
fn set_muffle_level(&mut self, level: [f32; 2]) {
|
||||
if let Ok(mut m) = self.backend.mixer.lock() {
|
||||
m.muffle.target = level;
|
||||
}
|
||||
}
|
||||
/// The `uiSoundReserve` extra: positional sounds leave
|
||||
/// `sound_engine::HEARD_RESERVE` static channels free (off: vanilla's pool).
|
||||
@@ -1468,6 +1604,14 @@ impl Audio {
|
||||
let id = self.engine.add(instance, false);
|
||||
self.engine.play_delayed(id, ticks);
|
||||
}
|
||||
/// `SoundManager.playDelayed` of an instance the caller made: it starts
|
||||
/// `ticks` sound-engine ticks from now.
|
||||
pub fn play_instance_after(&mut self, instance: SoundInstance, ticks: i32) {
|
||||
*self.heard.entry(instance.identifier.clone()).or_default() += 1;
|
||||
self.sync_volumes();
|
||||
let id = self.engine.add(instance, false);
|
||||
self.engine.play_delayed(id, ticks);
|
||||
}
|
||||
/// `play` for this client only: a sound a packet's handler plays
|
||||
/// (`playLocalSound`), which the guests play from that packet themselves.
|
||||
pub fn play_unshared(&mut self, event: &str, at: Option<[f64; 3]>, volume: f32, pitch: f32) {
|
||||
@@ -1835,6 +1979,7 @@ mod tests {
|
||||
owner: 0,
|
||||
world: 0,
|
||||
started: None,
|
||||
unmuffled: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2255,6 +2400,123 @@ mod tests {
|
||||
assert_eq!(data, [0.5; 2]);
|
||||
}
|
||||
|
||||
/// One second of a `hz` sine at 48 kHz, amplitude 0.3 (looped, it repeats seamlessly).
|
||||
fn sine(hz: f32) -> Vec<f32> {
|
||||
(0..48_000)
|
||||
.map(|i| 0.3 * (std::f32::consts::TAU * hz * i as f32 / 48_000.0).sin())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn rms(data: &[f32]) -> f32 {
|
||||
(data.iter().map(|s| s * s).sum::<f32>() / data.len() as f32).sqrt()
|
||||
}
|
||||
|
||||
/// `seconds` of the mixer's stereo output, in the 10 ms buffers an output stream asks for.
|
||||
fn listen(m: &mut Mixer, seconds: f32) -> Vec<f32> {
|
||||
let mut out = Vec::new();
|
||||
let mut buffer = [0.0; 960];
|
||||
for _ in 0..(seconds * 100.0).round() as usize {
|
||||
mix(m, &mut buffer, 2, 48_000);
|
||||
out.extend_from_slice(&buffer);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_muffle_dulls_and_quietens_every_sound_but_the_exempt_ones() {
|
||||
// A looping tone heard through a muffle of `[amount, lowpass]`, once it has settled.
|
||||
let heard = |hz: f32, muffle: [f32; 2], unmuffled: bool| {
|
||||
let mut m = Mixer::default();
|
||||
let mut c = channel(sine(hz), None, [0.0, 0.0, 2.0], true);
|
||||
c.unmuffled = unmuffled;
|
||||
m.channels.insert(1, c);
|
||||
m.muffle.target = muffle;
|
||||
listen(&mut m, 0.2);
|
||||
listen(&mut m, 0.2)
|
||||
};
|
||||
let plain = |hz: f32| rms(&heard(hz, [0.0; 2], false));
|
||||
// Fully low-passed, a 6 kHz whine all but goes and a 100 Hz hum keeps most of its level.
|
||||
assert!(rms(&heard(6000.0, [0.0, 1.0], false)) < plain(6000.0) * 0.05);
|
||||
assert!(rms(&heard(100.0, [0.0, 1.0], false)) > plain(100.0) * 0.8);
|
||||
// Half the volume taken and no low-pass: half the level, the tone itself untouched.
|
||||
let ducked = rms(&heard(6000.0, [0.5, 0.0], false)) / plain(6000.0);
|
||||
assert!((ducked - 0.5).abs() < 1e-3, "{ducked}");
|
||||
// An exempt sound plays through as if there were no muffle, sample for sample.
|
||||
assert_eq!(
|
||||
heard(6000.0, [0.5, 1.0], true),
|
||||
heard(6000.0, [0.0; 2], false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_faded_muffle_leaves_the_sound_as_it_was() {
|
||||
let mixer = || {
|
||||
let mut m = Mixer::default();
|
||||
m.channels
|
||||
.insert(1, channel(sine(3000.0), None, [2.0, 0.0, 1.0], true));
|
||||
m
|
||||
};
|
||||
let (mut muffled, mut plain) = (mixer(), mixer());
|
||||
muffled.muffle.target = [0.7, 1.0];
|
||||
assert_ne!(listen(&mut muffled, 0.1), listen(&mut plain, 0.1));
|
||||
muffled.muffle.target = [0.0; 2];
|
||||
listen(&mut muffled, 0.5);
|
||||
listen(&mut plain, 0.5);
|
||||
assert_eq!(listen(&mut muffled, 0.1), listen(&mut plain, 0.1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mods_muffle_fades_with_the_game_ticks_and_spares_its_exempt_sounds() {
|
||||
let mut audio = Audio::silent(Path::new("/nonexistent/horizon-test-assets"));
|
||||
audio.engine.registry.add_file(
|
||||
"minecraft",
|
||||
br#"{"ambient.cave":{"sounds":["ambient/cave/cave1"]}}"#,
|
||||
&|_, _| true,
|
||||
);
|
||||
audio.engine.registry.add_file(
|
||||
"hztest",
|
||||
br#"{"ear.ringing":{"sounds":["ear/ringing"]}}"#,
|
||||
&|_, _| true,
|
||||
);
|
||||
// The engine makes its channels; with no output, none loads a file.
|
||||
audio.engine.loaded = true;
|
||||
let marks = |audio: &Audio| {
|
||||
let m = audio.backend.mixer.lock().unwrap();
|
||||
let mut marks: Vec<_> = m
|
||||
.channels
|
||||
.iter()
|
||||
.map(|(&id, c)| (id, c.unmuffled))
|
||||
.collect();
|
||||
marks.sort();
|
||||
marks
|
||||
};
|
||||
let level = |audio: &Audio| audio.backend.mixer.lock().unwrap().muffle.target;
|
||||
let close =
|
||||
|a: [f32; 2], b: [f32; 2]| (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6;
|
||||
// Two sounds already playing, and one exempt sound started under the muffle.
|
||||
audio.play("ambient.cave", None, 1.0, 1.0);
|
||||
audio.play("hztest:ear.ringing", None, 1.0, 1.0);
|
||||
assert!(audio.muffle(0.8, 1.0, 4, &["hztest:ear.ringing"]));
|
||||
audio.play("hztest:ear.ringing", None, 1.0, 1.0);
|
||||
assert_eq!(marks(&audio), [(1, false), (2, true), (3, true)]);
|
||||
assert!(close(level(&audio), [0.8, 1.0]));
|
||||
let ticks = TickContext::default();
|
||||
audio.tick(false, &ticks);
|
||||
assert!(close(level(&audio), [0.6, 0.75]), "{:?}", level(&audio));
|
||||
// A paused game holds it.
|
||||
audio.tick(true, &ticks);
|
||||
assert!(close(level(&audio), [0.6, 0.75]), "{:?}", level(&audio));
|
||||
for _ in 0..3 {
|
||||
audio.tick(false, &ticks);
|
||||
}
|
||||
assert_eq!(level(&audio), [0.0; 2]);
|
||||
// Over: its exemptions go with it.
|
||||
assert_eq!(marks(&audio), [(1, false), (2, false), (3, false)]);
|
||||
// Out of range changes nothing.
|
||||
assert!(!audio.muffle(1.5, 0.0, 10, &[]));
|
||||
assert_eq!(level(&audio), [0.0; 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_devices_are_looked_at_again_only_when_the_cards_change() {
|
||||
// No fingerprint (not Linux, or the cards could not be read): the list
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
//! GPU Culling's Auto mode: the setting switched by what limits the frame, measured here.
|
||||
//!
|
||||
//! GPU Culling moves the walk over the near sections and the listing of their draws from the
|
||||
//! render thread to the GPU. That pays where the CPU is the slower of the two and costs where
|
||||
//! the GPU is, and which a machine is can't be told from its parts: a fast CPU with a modest
|
||||
//! GPU, a slow one with a strong GPU and every mix between depend on the scene, the render
|
||||
//! distance and the resolution. So Auto watches the frames' own CPU and GPU times, and only
|
||||
//! where the CPU is clearly the slower one it tries GPU Culling, measures the frame again, and
|
||||
//! keeps it if the frame got faster. A trial that didn't help is undone and not repeated for
|
||||
//! minutes of play, then for longer, then not until the next start, so a machine or a scene it
|
||||
//! doesn't suit loses a few frames' time a few times, not every half minute.
|
||||
//!
|
||||
//! A verdict is the medians of a window of frames (`WINDOW`), which a few slow frames (a chunk
|
||||
//! load, say) don't move. Nothing here reads the GPU or draws anything: the caller feeds it the
|
||||
//! frames and applies what it answers.
|
||||
|
||||
/// Frames in one verdict.
|
||||
pub const WINDOW: usize = 240;
|
||||
/// The CPU's time must be this many times the GPU's for a window to count as CPU-bound.
|
||||
const CPU_BOUND: f32 = 1.3;
|
||||
/// Below this CPU time, in milliseconds, a frame is fast enough that neither matters.
|
||||
const WORTH_TRYING_MS: f32 = 1.5;
|
||||
/// A trial is kept when the frame's busier side dropped under this share of what it was.
|
||||
const KEEP_BELOW: f32 = 0.97;
|
||||
/// Windows of CPU-bound frames in a row before a trial.
|
||||
const STREAK: u8 = 2;
|
||||
/// Windows left out after a switch, while its effects settle.
|
||||
const SETTLE: u8 = 1;
|
||||
/// How long, in milliseconds of frames watched, after the first and the second trial that didn't
|
||||
/// help before another; after a third there is none.
|
||||
const RETRY_AFTER_MS: [f64; 2] = [5.0 * 60_000.0, 20.0 * 60_000.0];
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum State {
|
||||
/// Off, looking for a CPU-bound frame.
|
||||
Watching,
|
||||
/// On for a trial: left out `SETTLE` windows, then measured against the frame before.
|
||||
Trial,
|
||||
/// The trial made the frame faster: on from here.
|
||||
Kept,
|
||||
}
|
||||
|
||||
/// What Auto has decided and why, a step at a time.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GpuCullingAuto {
|
||||
cpu: Vec<f32>,
|
||||
gpu: Vec<f32>,
|
||||
on: bool,
|
||||
state: State,
|
||||
/// CPU-bound windows in a row.
|
||||
streak: u8,
|
||||
/// The frame's busier side before the trial, in milliseconds.
|
||||
before: f32,
|
||||
/// Windows still left out after a switch.
|
||||
settling: u8,
|
||||
/// The frames watched so far, in milliseconds of their own time.
|
||||
clock_ms: f64,
|
||||
/// When the next trial may start, on that clock.
|
||||
retry_at_ms: f64,
|
||||
/// Trials that didn't help.
|
||||
rejected: usize,
|
||||
}
|
||||
|
||||
impl Default for GpuCullingAuto {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl GpuCullingAuto {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
cpu: Vec::with_capacity(WINDOW),
|
||||
gpu: Vec::with_capacity(WINDOW),
|
||||
on: false,
|
||||
state: State::Watching,
|
||||
streak: 0,
|
||||
before: 0.0,
|
||||
settling: 0,
|
||||
clock_ms: 0.0,
|
||||
retry_at_ms: 0.0,
|
||||
rejected: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether Auto has GPU Culling on now.
|
||||
pub fn on(&self) -> bool {
|
||||
self.on
|
||||
}
|
||||
|
||||
/// Where Auto is, for the status: `watching`, `trial` or `kept`.
|
||||
pub fn state(&self) -> &'static str {
|
||||
match self.state {
|
||||
State::Watching => "watching",
|
||||
State::Trial => "trial",
|
||||
State::Kept => "kept",
|
||||
}
|
||||
}
|
||||
|
||||
/// Back to the start: off, with nothing measured (the mode changed, or the world did).
|
||||
pub fn reset(&mut self) {
|
||||
*self = Self::new();
|
||||
}
|
||||
|
||||
/// One frame's CPU time, a GPU time where its timestamps have arrived, and its whole time,
|
||||
/// in milliseconds. Answers the new state when this frame completed a window that changed it.
|
||||
pub fn observe(&mut self, cpu_ms: f32, gpu_ms: Option<f32>, frame_ms: f32) -> Option<bool> {
|
||||
self.clock_ms += f64::from(frame_ms);
|
||||
self.cpu.push(cpu_ms);
|
||||
if let Some(gpu) = gpu_ms {
|
||||
self.gpu.push(gpu);
|
||||
}
|
||||
if self.cpu.len() < WINDOW {
|
||||
return None;
|
||||
}
|
||||
let cpu = median(&mut self.cpu);
|
||||
// A GPU that can't be timed leaves nothing to compare with: Auto stays as it is.
|
||||
let gpu = (self.gpu.len() >= WINDOW / 4).then(|| median(&mut self.gpu));
|
||||
self.cpu.clear();
|
||||
self.gpu.clear();
|
||||
self.verdict(cpu, gpu?)
|
||||
}
|
||||
|
||||
fn verdict(&mut self, cpu: f32, gpu: f32) -> Option<bool> {
|
||||
if self.settling > 0 {
|
||||
self.settling -= 1;
|
||||
return None;
|
||||
}
|
||||
match self.state {
|
||||
State::Kept => None,
|
||||
State::Watching => {
|
||||
if self.clock_ms < self.retry_at_ms {
|
||||
return None;
|
||||
}
|
||||
let bound = cpu >= WORTH_TRYING_MS && cpu > gpu * CPU_BOUND;
|
||||
self.streak = if bound { self.streak + 1 } else { 0 };
|
||||
if self.streak < STREAK {
|
||||
return None;
|
||||
}
|
||||
self.streak = 0;
|
||||
self.before = cpu.max(gpu);
|
||||
self.state = State::Trial;
|
||||
self.settling = SETTLE;
|
||||
self.on = true;
|
||||
Some(true)
|
||||
}
|
||||
State::Trial => {
|
||||
if cpu.max(gpu) < self.before * KEEP_BELOW {
|
||||
self.state = State::Kept;
|
||||
return None;
|
||||
}
|
||||
self.state = State::Watching;
|
||||
self.retry_at_ms = RETRY_AFTER_MS
|
||||
.get(self.rejected)
|
||||
.map_or(f64::INFINITY, |wait| self.clock_ms + wait);
|
||||
self.rejected += 1;
|
||||
self.on = false;
|
||||
Some(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn median(values: &mut [f32]) -> f32 {
|
||||
values.sort_by(f32::total_cmp);
|
||||
values[values.len() / 2]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A window of frames at constant times, each as long as its busier side; the last answer.
|
||||
fn window(auto: &mut GpuCullingAuto, cpu: f32, gpu: f32) -> Option<bool> {
|
||||
(0..WINDOW)
|
||||
.filter_map(|_| auto.observe(cpu, Some(gpu), cpu.max(gpu)))
|
||||
.last()
|
||||
}
|
||||
|
||||
/// A machine whose CPU limits the frame is tried, and keeps GPU Culling when the frame
|
||||
/// gets faster.
|
||||
#[test]
|
||||
fn a_cpu_bound_frame_tries_it_and_keeps_it_when_it_helps() {
|
||||
let mut auto = GpuCullingAuto::new();
|
||||
assert!(!auto.on());
|
||||
// One CPU-bound window is not yet a verdict.
|
||||
assert_eq!(window(&mut auto, 5.0, 2.0), None);
|
||||
assert_eq!(window(&mut auto, 5.0, 2.0), Some(true));
|
||||
assert!(auto.on());
|
||||
assert_eq!(auto.state(), "trial");
|
||||
// The window after the switch is left out, whatever it shows.
|
||||
assert_eq!(window(&mut auto, 9.0, 9.0), None);
|
||||
// The frame's busier side went from 5.0 to 3.0 ms.
|
||||
assert_eq!(window(&mut auto, 3.0, 2.6), None);
|
||||
assert_eq!(auto.state(), "kept");
|
||||
assert!(auto.on());
|
||||
// Kept stays kept, even if a later scene is GPU-bound.
|
||||
assert_eq!(window(&mut auto, 1.0, 8.0), None);
|
||||
assert!(auto.on());
|
||||
}
|
||||
|
||||
/// Where it didn't help, it is undone, and not tried again for 5 minutes, then 20, then
|
||||
/// not at all: a scene it doesn't suit is not tried every half minute.
|
||||
#[test]
|
||||
fn a_trial_that_does_not_help_is_undone_and_waits_longer_each_time() {
|
||||
let mut auto = GpuCullingAuto::new();
|
||||
// One window is 240 frames of 5 ms: 1.2 s. A trial, and its rejection:
|
||||
let reject = |auto: &mut GpuCullingAuto| {
|
||||
window(auto, 5.0, 2.0);
|
||||
assert_eq!(window(auto, 5.0, 2.0), Some(true));
|
||||
window(auto, 5.0, 2.0);
|
||||
// The GPU took over the frame: 5.0 ms before, 5.4 after.
|
||||
assert_eq!(window(auto, 2.5, 5.4), Some(false));
|
||||
assert!(!auto.on());
|
||||
assert_eq!(auto.state(), "watching");
|
||||
};
|
||||
reject(&mut auto);
|
||||
// CPU-bound as ever, and left alone for 5 minutes of frames: 250 windows.
|
||||
let mut waited = 0;
|
||||
while window(&mut auto, 5.0, 2.0).is_none() {
|
||||
waited += 1;
|
||||
}
|
||||
assert!((248..=252).contains(&waited), "{waited}");
|
||||
// That window started the second trial; it is rejected too.
|
||||
window(&mut auto, 5.0, 2.0);
|
||||
assert_eq!(window(&mut auto, 2.5, 5.4), Some(false));
|
||||
// Now 20 minutes: 1000 windows.
|
||||
let mut waited = 0;
|
||||
while window(&mut auto, 5.0, 2.0).is_none() {
|
||||
waited += 1;
|
||||
}
|
||||
assert!((998..=1002).contains(&waited), "{waited}");
|
||||
window(&mut auto, 5.0, 2.0);
|
||||
assert_eq!(window(&mut auto, 2.5, 5.4), Some(false));
|
||||
// A third rejection is for good.
|
||||
for _ in 0..5000 {
|
||||
assert_eq!(window(&mut auto, 5.0, 2.0), None);
|
||||
}
|
||||
assert!(!auto.on());
|
||||
}
|
||||
|
||||
/// A GPU-bound machine is never switched: its frame is the GPU's.
|
||||
#[test]
|
||||
fn a_gpu_bound_frame_is_left_alone() {
|
||||
let mut auto = GpuCullingAuto::new();
|
||||
for _ in 0..20 {
|
||||
assert_eq!(window(&mut auto, 0.5, 3.6), None);
|
||||
}
|
||||
assert!(!auto.on());
|
||||
}
|
||||
|
||||
/// Within the margin neither side limits the frame, and a single CPU-bound window among
|
||||
/// others breaks no streak into a trial.
|
||||
#[test]
|
||||
fn neither_a_balanced_nor_an_occasional_cpu_bound_frame_is_tried() {
|
||||
let mut auto = GpuCullingAuto::new();
|
||||
for _ in 0..10 {
|
||||
assert_eq!(window(&mut auto, 3.0, 2.6), None);
|
||||
}
|
||||
for _ in 0..10 {
|
||||
assert_eq!(window(&mut auto, 5.0, 2.0), None);
|
||||
assert_eq!(window(&mut auto, 2.0, 2.0), None);
|
||||
}
|
||||
assert!(!auto.on());
|
||||
}
|
||||
|
||||
/// Fast frames aren't worth a trial, bound or not.
|
||||
#[test]
|
||||
fn a_frame_under_the_floor_is_not_tried() {
|
||||
let mut auto = GpuCullingAuto::new();
|
||||
for _ in 0..10 {
|
||||
assert_eq!(window(&mut auto, 1.2, 0.2), None);
|
||||
}
|
||||
assert!(!auto.on());
|
||||
}
|
||||
|
||||
/// Without GPU times (a GPU that can't be timed) there is nothing to compare.
|
||||
#[test]
|
||||
fn frames_without_gpu_times_decide_nothing() {
|
||||
let mut auto = GpuCullingAuto::new();
|
||||
for _ in 0..WINDOW * 10 {
|
||||
assert_eq!(auto.observe(8.0, None, 8.0), None);
|
||||
}
|
||||
assert!(!auto.on());
|
||||
}
|
||||
|
||||
/// A spike in a window doesn't move its verdict: the median ignores it.
|
||||
#[test]
|
||||
fn a_few_slow_frames_do_not_make_a_window_cpu_bound() {
|
||||
let mut auto = GpuCullingAuto::new();
|
||||
for _ in 0..4 {
|
||||
let mut answer = None;
|
||||
for i in 0..WINDOW {
|
||||
// One frame in ten is a 30 ms chunk load, the rest are GPU-bound.
|
||||
let cpu = if i % 10 == 0 { 30.0 } else { 0.8 };
|
||||
answer = answer.or(auto.observe(cpu, Some(3.0), 3.0));
|
||||
}
|
||||
assert_eq!(answer, None);
|
||||
}
|
||||
assert!(!auto.on());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_starts_over() {
|
||||
let mut auto = GpuCullingAuto::new();
|
||||
window(&mut auto, 5.0, 2.0);
|
||||
window(&mut auto, 5.0, 2.0);
|
||||
assert!(auto.on());
|
||||
auto.reset();
|
||||
assert!(!auto.on());
|
||||
assert_eq!(auto.state(), "watching");
|
||||
assert_eq!(window(&mut auto, 5.0, 2.0), None);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Biome colours from the pinned biome definitions and colormaps
|
||||
//! (`Biome.getGrassColor`/`getFoliageColor`/..., `BlockTintSources`).
|
||||
use crate::Result;
|
||||
use horizon_client_core::particle_type::ParticleType;
|
||||
use horizon_core::{noise::SimplexNoise, position::BlockPos, random::LegacyRandom};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
@@ -106,11 +107,28 @@ pub struct BiomeInfo {
|
||||
/// The biome's own fog colour attribute; otherwise the dimension's applies.
|
||||
pub fog: Option<u32>,
|
||||
pub water_fog: u32,
|
||||
/// `minecraft:visual/ambient_particles` entries and their probabilities.
|
||||
pub ambient_particles: Vec<(ParticleType, f32)>,
|
||||
/// `BiomeGenerationSettings.features`: the placed feature ids of each
|
||||
/// `GenerationStep.Decoration` step, in order.
|
||||
pub features: Vec<Vec<String>>,
|
||||
}
|
||||
impl BiomeInfo {
|
||||
fn ambient_particles(v: &Value) -> Vec<(ParticleType, f32)> {
|
||||
let list = &v["attributes"]["minecraft:visual/ambient_particles"];
|
||||
let entries = list["argument"].as_array().or_else(|| list.as_array());
|
||||
entries
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|entry| {
|
||||
Some((
|
||||
ParticleType::from_name(entry["particle"]["type"].as_str()?)?,
|
||||
entry["probability"].as_f64().unwrap_or(0.0) as f32,
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `Biome.getHeightAdjustedTemperature`: the frozen modifier, then cooler
|
||||
/// with height above `sea_level + 17`.
|
||||
pub fn temperature_at(&self, pos: BlockPos, sea_level: i32) -> f32 {
|
||||
@@ -245,6 +263,7 @@ impl BiomeInfo {
|
||||
sky: color(&attributes["minecraft:visual/sky_color"]),
|
||||
fog: color(&attributes["minecraft:visual/fog_color"]),
|
||||
water_fog: color(&attributes["minecraft:visual/water_fog_color"]).unwrap_or(0x050533),
|
||||
ambient_particles: Self::ambient_particles(v),
|
||||
features: v["features"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
@@ -563,7 +582,9 @@ mod pack_tests {
|
||||
#[test]
|
||||
fn packs_replace_in_place_and_add_after_the_builtin_biomes() {
|
||||
let base = builtin(&["minecraft:meadow", "minecraft:plains", "minecraft:river"]);
|
||||
let red = json!({"temperature": 2.0, "effects": {"water_color": "#ff0000"}});
|
||||
let red = json!({"temperature": 2.0, "effects": {"water_color": "#ff0000"},
|
||||
"attributes": {"minecraft:visual/ambient_particles": {
|
||||
"argument": [{"particle": {"type": "minecraft:ash"}, "probability": 0.25}]}}});
|
||||
let hot = json!({"effects": {"grass_color": 0x123456}});
|
||||
let files = [
|
||||
("pack:zeta", &hot),
|
||||
@@ -576,6 +597,9 @@ mod pack_tests {
|
||||
assert_eq!(world.id("minecraft:river"), 2);
|
||||
assert_eq!(world.get(2).water, 0xFF0000);
|
||||
assert_eq!(world.get(2).temperature, 2.0);
|
||||
assert_eq!(world.get(2).ambient_particles.len(), 1);
|
||||
assert_eq!(world.get(2).ambient_particles[0].0.name(), "ash");
|
||||
assert_eq!(world.get(2).ambient_particles[0].1, 0.25);
|
||||
// New biomes follow, in registry order.
|
||||
assert_eq!(world.id("minecraft:molten_peaks"), 3);
|
||||
assert_eq!(world.id("pack:zeta"), 4);
|
||||
|
||||
@@ -12,12 +12,13 @@ use crate::{
|
||||
block_entity_anim::{self, EnchantingBook},
|
||||
breaking::entity_geometry,
|
||||
fast_hash::FxHashMap,
|
||||
font::Glyph,
|
||||
font::{Font, Glyph},
|
||||
gfx::mark_oit,
|
||||
hud::Rect,
|
||||
mesher::{CULL_BACK, Vertex},
|
||||
models::load_visibility,
|
||||
signs::{self, SignData, SignText},
|
||||
text::{Style, Text},
|
||||
world::World,
|
||||
};
|
||||
use glam::{DVec3, Mat4, Quat, Vec3};
|
||||
@@ -620,30 +621,26 @@ fn frac(x: f32) -> f32 {
|
||||
x - x.floor()
|
||||
}
|
||||
|
||||
/// `StringSplitter.splitLines(...).get(0)` for plain text: the first line,
|
||||
/// wrapped at the last space that fits or mid-word when none does.
|
||||
fn first_line(line: &str, max_width: f32, advance: &dyn Fn(char) -> f32) -> String {
|
||||
/// `StringSplitter.findLineBreak`: first line, with styled advances.
|
||||
fn first_line(line: &Text, max_width: f32, advance: &dyn Fn(char, &Style) -> f32) -> Text {
|
||||
let mut width = 0.0;
|
||||
let mut visible = false;
|
||||
let mut last_space = None;
|
||||
for (i, c) in line.char_indices() {
|
||||
for (i, (c, style)) in line.chars().enumerate() {
|
||||
if c == '\n' {
|
||||
return line[..i].to_owned();
|
||||
return line.slice(0, i);
|
||||
}
|
||||
if c == ' ' {
|
||||
last_space = Some(i);
|
||||
}
|
||||
width += advance(c);
|
||||
if width > max_width {
|
||||
let end = match last_space {
|
||||
Some(s) if s > 0 => s,
|
||||
_ if i > 0 => i,
|
||||
// The first char alone is too wide: take that one char.
|
||||
_ => c.len_utf8(),
|
||||
};
|
||||
return line[..end].to_owned();
|
||||
let step = advance(c, style);
|
||||
width += step;
|
||||
if visible && width > max_width.max(1.0) {
|
||||
return line.slice(0, last_space.unwrap_or(i));
|
||||
}
|
||||
visible |= step != 0.0;
|
||||
}
|
||||
line.to_owned()
|
||||
line.clone()
|
||||
}
|
||||
|
||||
/// `AbstractSignRenderer.isOutlineVisible`: the camera within 16 blocks of
|
||||
@@ -661,7 +658,7 @@ fn sign_text_near(pos: [i32; 3], camera: DVec3) -> bool {
|
||||
/// `AbstractSignRenderer.submitSignText`: four centred lines, glowing text
|
||||
/// full-bright with an 8x outline in the dark colour. The font colours each
|
||||
/// glyph by its style (`Font.StringRenderOutput`): a line's own colour wins,
|
||||
/// else the sign's. A line is one call per run of one colour.
|
||||
/// else the sign's. Keep style boundaries for glyphs and decoration effects.
|
||||
fn sign_text_calls(
|
||||
scene: &Scene,
|
||||
data: &SignData,
|
||||
@@ -684,17 +681,32 @@ fn sign_text_calls(
|
||||
} else {
|
||||
(dark, 0, vertex_light(packed), 0)
|
||||
};
|
||||
let advance = |c: char| (scene.glyph)(c).advance;
|
||||
let advance = |c: char, style: &Style| {
|
||||
let g = scene
|
||||
.font
|
||||
.map_or_else(|| (scene.glyph)(c), |f| f.glyph_styled(c, style));
|
||||
g.advance + if style.is_bold() { g.offset } else { 0.0 }
|
||||
};
|
||||
let mut calls: Vec<TextCall> = Vec::with_capacity(text.lines.len());
|
||||
for i in 0..text.lines.len() {
|
||||
// The line that fits, in the order it is drawn (getVisualOrder).
|
||||
let line = text.line_text(i);
|
||||
let line = line.resolved();
|
||||
let flat: String = line.chars().map(|(c, _)| c).collect();
|
||||
let fits = first_line(&flat, data.max_line_width(), &advance);
|
||||
let shown = line.slice(0, fits.chars().count());
|
||||
let shown = if let Some(font) = scene.font {
|
||||
font.split_formatted(&line, data.max_line_width())
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.resolved()
|
||||
.into_owned()
|
||||
} else {
|
||||
first_line(&line.resolved(), data.max_line_width(), &advance)
|
||||
};
|
||||
let shown = visual(&shown);
|
||||
let width = shown.chars().map(|(c, _)| advance(c)).sum::<f32>().ceil();
|
||||
let width = shown
|
||||
.chars()
|
||||
.map(|(c, s)| advance(c, s))
|
||||
.sum::<f32>()
|
||||
.ceil();
|
||||
let x = -((width as i32) / 2) as f32;
|
||||
let y = i as f32 * line_height - midpoint;
|
||||
let first = calls.len();
|
||||
@@ -704,20 +716,22 @@ fn sign_text_calls(
|
||||
.style
|
||||
.color
|
||||
.map_or(color, |rgb| 0xFF00_0000 | (rgb & 0xFF_FFFF));
|
||||
match calls[first..].last_mut() {
|
||||
Some(last) if last.color == run => last.text.push_str(&span.text),
|
||||
_ => calls.push(TextCall {
|
||||
matrix: m,
|
||||
x: x + pen,
|
||||
y,
|
||||
text: span.text.clone(),
|
||||
color: run,
|
||||
outline,
|
||||
light,
|
||||
tier,
|
||||
}),
|
||||
}
|
||||
pen += span.text.chars().map(advance).sum::<f32>();
|
||||
calls.push(TextCall {
|
||||
matrix: m,
|
||||
x: x + pen,
|
||||
y,
|
||||
text: span.text.clone(),
|
||||
style: span.style.clone(),
|
||||
color: run,
|
||||
outline,
|
||||
light,
|
||||
tier,
|
||||
});
|
||||
pen += span
|
||||
.text
|
||||
.chars()
|
||||
.map(|c| advance(c, &span.style))
|
||||
.sum::<f32>();
|
||||
}
|
||||
if calls.len() == first {
|
||||
// A line with no text is still a call, of no glyphs.
|
||||
@@ -726,6 +740,7 @@ fn sign_text_calls(
|
||||
x,
|
||||
y,
|
||||
text: String::new(),
|
||||
style: Style::default(),
|
||||
color,
|
||||
outline,
|
||||
light,
|
||||
@@ -736,6 +751,17 @@ fn sign_text_calls(
|
||||
calls
|
||||
}
|
||||
|
||||
fn sign_text_animated(data: &SignData) -> bool {
|
||||
[&data.front, &data.back].into_iter().any(|side| {
|
||||
(0..side.lines.len()).any(|i| {
|
||||
side.line_text(i)
|
||||
.resolved()
|
||||
.chars()
|
||||
.any(|(c, s)| c != ' ' && s.obfuscated == Some(true))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// How far the text of an outlined call sits in front of its outline, in
|
||||
/// text units (`Font.DisplayMode.POLYGON_OFFSET`, see `draw_text`).
|
||||
const TEXT_OVER_OUTLINE: f32 = 0.05;
|
||||
@@ -749,72 +775,131 @@ const TEXT_OVER_OUTLINE: f32 = 0.05;
|
||||
/// reader instead (a twentieth of a font pixel, invisible) rather than
|
||||
/// z-fighting with the outline under it.
|
||||
fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
|
||||
text_vertices(scene.glyph, call, out);
|
||||
let glyph = |c| {
|
||||
let Some(font) = scene.font else {
|
||||
return (scene.glyph)(c);
|
||||
};
|
||||
let g = font.glyph_styled(c, &call.style);
|
||||
if call.style.obfuscated == Some(true) && c != ' ' {
|
||||
font.random_glyph(&call.style, g.advance)
|
||||
} else {
|
||||
g
|
||||
}
|
||||
};
|
||||
text_vertices(&glyph, scene.font.map(Font::white), call, out);
|
||||
}
|
||||
|
||||
/// `draw_text` with the font's glyph cells and advances from `glyph`: the
|
||||
/// `BakedSheetGlyph.renderChar` / `Font.PreparedTextBuilder.accept`: the
|
||||
/// text of `call` as glyph quads in its matrix's plane, 1 unit a font pixel,
|
||||
/// y down as the font's, with its outline when it has one. A native mod's
|
||||
/// block entity draws its text this way (`native_draw_text`).
|
||||
pub fn text_vertices(glyph: &dyn Fn(char) -> Glyph, call: &TextCall, out: &mut Vec<Vertex>) {
|
||||
let mut emit = |dx: f32, dy: f32, z: f32, argb: u32| {
|
||||
let mut x = call.x + dx;
|
||||
let y0 = call.y + dy;
|
||||
for ch in call.text.chars() {
|
||||
pub fn text_vertices(
|
||||
glyph: &dyn Fn(char) -> Glyph,
|
||||
white: Option<Rect>,
|
||||
call: &TextCall,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let bold = call.style.is_bold();
|
||||
let mut quad = |corners: [(f32, f32); 4], cell: Rect, z: f32, argb: u32| {
|
||||
let uvs = [
|
||||
[cell.u0, cell.v1],
|
||||
[cell.u1, cell.v1],
|
||||
[cell.u1, cell.v0],
|
||||
[cell.u0, cell.v0],
|
||||
];
|
||||
let color = 0xFF00_0000 | (argb & 255) << 16 | (argb >> 8 & 255) << 8 | (argb >> 16 & 255);
|
||||
for ((x, y), uv) in corners.into_iter().zip(uvs) {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: call.matrix.transform_point3(Vec3::new(x, y, z)).to_array(),
|
||||
uv,
|
||||
layer: u32::from(call.tier) << 16,
|
||||
color,
|
||||
light: call.light,
|
||||
glint_uv: [0.0; 2],
|
||||
});
|
||||
}
|
||||
};
|
||||
let mut emit = |dx: f32, dy: f32, z: f32, argb: u32, outline: bool| {
|
||||
let mut pen = call.x;
|
||||
for (i, ch) in call.text.chars().enumerate() {
|
||||
let g = glyph(ch);
|
||||
let x = pen + dx * g.offset;
|
||||
let y = call.y + dy * g.offset;
|
||||
let advance = g.advance + if bold { g.offset } else { 0.0 };
|
||||
if let Some(cell) = g.rect {
|
||||
// BakedSheetGlyph: the glyph's own box from the pen, not a
|
||||
// fixed 8x8 cell (a Unifont glyph is 0.5 units per texel).
|
||||
let (x0, x1) = (x + g.left, x + g.right);
|
||||
let (top, bottom) = (y0 + g.top, y0 + g.bottom);
|
||||
let corners = [(x0, bottom), (x1, bottom), (x1, top), (x0, top)];
|
||||
let uvs = [
|
||||
[cell.u0, cell.v1],
|
||||
[cell.u1, cell.v1],
|
||||
[cell.u1, cell.v0],
|
||||
[cell.u0, cell.v0],
|
||||
];
|
||||
let c =
|
||||
0xFF00_0000 | (argb & 255) << 16 | (argb >> 8 & 255) << 8 | (argb >> 16 & 255);
|
||||
for ((cx, cy), uv) in corners.into_iter().zip(uvs) {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: call
|
||||
.matrix
|
||||
.transform_point3(Vec3::new(cx, cy, z))
|
||||
.to_array(),
|
||||
uv,
|
||||
layer: u32::from(call.tier) << 16,
|
||||
color: c,
|
||||
light: call.light,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
let thick = if bold { 0.1 } else { 0.0 };
|
||||
let shear = |v| {
|
||||
if call.style.is_italic() {
|
||||
1.0 - 0.25 * v
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
};
|
||||
for copy in 0..=usize::from(bold) {
|
||||
let x0 = x + copy as f32 * g.offset + g.left - thick;
|
||||
let x1 = x + copy as f32 * g.offset + g.right + thick;
|
||||
let top = y + g.top - thick;
|
||||
let bottom = y + g.bottom + thick;
|
||||
quad(
|
||||
[
|
||||
(x0 + shear(g.bottom), bottom),
|
||||
(x1 + shear(g.bottom), bottom),
|
||||
(x1 + shear(g.top), top),
|
||||
(x0 + shear(g.top), top),
|
||||
],
|
||||
cell,
|
||||
z + copy as f32 * 0.001,
|
||||
argb,
|
||||
);
|
||||
}
|
||||
}
|
||||
x += g.advance;
|
||||
// `Font.prepare8xTextOutline` discards effects: only the front
|
||||
// text gets underline/strikethrough, including over spaces.
|
||||
if !outline && let Some(white) = white {
|
||||
let x0 = if i == 0 { x - 1.0 } else { x };
|
||||
for (enabled, top) in [
|
||||
(call.style.strikethrough, 3.5),
|
||||
(call.style.underlined, 8.0),
|
||||
] {
|
||||
if enabled == Some(true) {
|
||||
quad(
|
||||
[
|
||||
(x0, y + top + 1.0),
|
||||
(x + advance, y + top + 1.0),
|
||||
(x + advance, y + top),
|
||||
(x0, y + top),
|
||||
],
|
||||
white,
|
||||
z + 0.01,
|
||||
argb,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
pen += advance;
|
||||
}
|
||||
};
|
||||
if call.outline != 0 {
|
||||
for (dx, dy) in [
|
||||
(-1.0, -1.0),
|
||||
(0.0, -1.0),
|
||||
(1.0, -1.0),
|
||||
(-1.0, 0.0),
|
||||
(1.0, 0.0),
|
||||
(-1.0, 1.0),
|
||||
(0.0, 1.0),
|
||||
(1.0, 1.0),
|
||||
] {
|
||||
emit(dx, dy, 0.0, call.outline);
|
||||
for dx in -1..=1 {
|
||||
for dy in -1..=1 {
|
||||
if dx != 0 || dy != 0 {
|
||||
emit(dx as f32, dy as f32, 0.0, call.outline, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let z = if call.outline != 0 {
|
||||
TEXT_OVER_OUTLINE
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
emit(0.0, 0.0, z, call.color);
|
||||
emit(
|
||||
0.0,
|
||||
0.0,
|
||||
if call.outline != 0 {
|
||||
TEXT_OVER_OUTLINE
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
call.color,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Block-entity fields the level ticks, at one position (vanilla's own field
|
||||
@@ -847,6 +932,7 @@ pub struct TextCall {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub text: String,
|
||||
pub style: Style,
|
||||
/// ARGB.
|
||||
pub color: u32,
|
||||
/// ARGB outline colour, 0 for none.
|
||||
@@ -894,6 +980,8 @@ pub struct Scene<'a> {
|
||||
pub skull: &'a dyn Fn([i32; 3]) -> Option<String>,
|
||||
/// Font glyph: atlas cell (none for blank glyphs such as the space), box and advance.
|
||||
pub glyph: &'a dyn Fn(char) -> Glyph,
|
||||
/// Font providers, effects and random glyphs for styled sign text.
|
||||
pub font: Option<&'a Font>,
|
||||
/// Changes whenever `glyph` may answer differently (`Font::epoch`): sign
|
||||
/// text laid out under another value is laid out again.
|
||||
pub glyphs: u64,
|
||||
@@ -1055,6 +1143,7 @@ struct SignTextEntry {
|
||||
/// Game time it was last drawn.
|
||||
used: i64,
|
||||
vertices: Vec<Vertex>,
|
||||
animated: bool,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct StaticKey {
|
||||
@@ -2361,9 +2450,11 @@ impl BlockEntityRenderer {
|
||||
let entry = match signs.entry(pos) {
|
||||
std::collections::hash_map::Entry::Occupied(mut occupied) => {
|
||||
let entry = occupied.get_mut();
|
||||
let changed = entry.data != *data;
|
||||
if (entry.state, entry.packed, entry.near, entry.glyphs)
|
||||
!= (state, packed, near, glyphs)
|
||||
|| entry.data != *data
|
||||
|| changed
|
||||
|| entry.animated
|
||||
{
|
||||
entry.vertices.clear();
|
||||
self.render_one(
|
||||
@@ -2378,7 +2469,10 @@ impl BlockEntityRenderer {
|
||||
&mut entry.vertices,
|
||||
);
|
||||
entry.state = state;
|
||||
entry.data.clone_from(data);
|
||||
if changed {
|
||||
entry.animated = sign_text_animated(data);
|
||||
entry.data.clone_from(data);
|
||||
}
|
||||
entry.packed = packed;
|
||||
entry.near = near;
|
||||
entry.glyphs = glyphs;
|
||||
@@ -2399,6 +2493,7 @@ impl BlockEntityRenderer {
|
||||
&mut vertices,
|
||||
);
|
||||
vacant.insert(SignTextEntry {
|
||||
animated: sign_text_animated(data),
|
||||
state,
|
||||
data: data.clone(),
|
||||
packed,
|
||||
@@ -3638,6 +3733,7 @@ mod tests {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
// Compare the cached world path with the original model renderer,
|
||||
@@ -4032,6 +4128,7 @@ mod tests {
|
||||
skull: &|_| None,
|
||||
glyph: &glyph,
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let mut call = TextCall {
|
||||
@@ -4039,6 +4136,7 @@ mod tests {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
text: "Hi".into(),
|
||||
style: Style::default(),
|
||||
color: 0xFFFF_FF00,
|
||||
outline: 0xFF33_3300,
|
||||
light: 240 | 240 << 8,
|
||||
@@ -4106,6 +4204,7 @@ mod tests {
|
||||
skull: &|_| None,
|
||||
glyph: &glyph,
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let call = TextCall {
|
||||
@@ -4113,6 +4212,7 @@ mod tests {
|
||||
x: 10.0,
|
||||
y: 2.0,
|
||||
text: "ab".into(),
|
||||
style: Style::default(),
|
||||
color: 0xFF00_0000,
|
||||
outline: 0,
|
||||
light: 240 | 240 << 8,
|
||||
@@ -4136,6 +4236,83 @@ mod tests {
|
||||
assert_eq!(boxes, [[10.0, 14.0, 2.0, 10.0], [15.0, 19.0, 2.0, 10.0]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styled_sign_quads_follow_vanilla_glyph_and_effect_bounds() {
|
||||
let white = Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 0.1,
|
||||
v1: 0.1,
|
||||
w: 1,
|
||||
h: 1,
|
||||
};
|
||||
let glyph = |c| Glyph {
|
||||
rect: (c != ' ').then_some(white),
|
||||
left: 0.0,
|
||||
right: 4.0,
|
||||
top: 0.0,
|
||||
bottom: 8.0,
|
||||
advance: 5.0,
|
||||
offset: 0.5,
|
||||
};
|
||||
let mut call = TextCall {
|
||||
matrix: Mat4::IDENTITY,
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
text: "A ".into(),
|
||||
color: 0xff123456,
|
||||
outline: 0,
|
||||
light: 240,
|
||||
tier: crate::mesher::LOW,
|
||||
style: Style {
|
||||
bold: Some(true),
|
||||
italic: Some(true),
|
||||
underlined: Some(true),
|
||||
strikethrough: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
text_vertices(&glyph, Some(white), &call, &mut out);
|
||||
// BakedSheetGlyph: bold expands by 0.1, italic shears by 1 - y/4,
|
||||
// the second bold glyph is 0.5 along and 0.001 in front (Unihex).
|
||||
assert_eq!(out.len(), 24);
|
||||
for (got, expected) in out[0].pos.iter().zip([-1.1, 8.1, 0.0]) {
|
||||
assert!((got - expected).abs() < 1e-6);
|
||||
}
|
||||
assert!((out[4].pos[0] - out[0].pos[0] - 0.5).abs() < 1e-6);
|
||||
assert_eq!(out[4].pos[2], 0.001);
|
||||
assert_eq!(out[8].pos, [-1.0, 4.5, 0.01]);
|
||||
assert_eq!(out[12].pos, [-1.0, 9.0, 0.01]);
|
||||
assert_eq!(out[21].pos, [11.0, 9.0, 0.01]); // underline crosses the space
|
||||
assert!(out.iter().all(|v| v.color == 0xff563412 && v.light == 240));
|
||||
call.outline = 0xff010203;
|
||||
out.clear();
|
||||
text_vertices(&glyph, Some(white), &call, &mut out);
|
||||
// Eight outlines contain glyphs only; effects appear once, in front.
|
||||
assert_eq!(out.len(), 8 * 8 + 24);
|
||||
assert!((out[0].pos[0] - -1.6).abs() < 1e-6);
|
||||
assert!(out[64..].iter().all(|v| v.pos[2] >= TEXT_OVER_OUTLINE));
|
||||
// The glow tier reaches bold copies, outlines and decoration quads.
|
||||
assert!(out.iter().all(|v| v.layer >> 16 == 1));
|
||||
call.tier = 0;
|
||||
out.clear();
|
||||
text_vertices(&glyph, Some(white), &call, &mut out);
|
||||
assert!(out.iter().all(|v| v.layer >> 16 == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styled_sign_clipping_counts_bold_width_and_preserves_resets() {
|
||||
let line = Text::legacy("§lAB§r東Z");
|
||||
let advance = |_: char, s: &Style| 5.0 + if s.is_bold() { 0.5 } else { 0.0 };
|
||||
let shown = first_line(&line, 16.0, &advance);
|
||||
assert_eq!(shown.plain(), "AB東");
|
||||
assert_eq!(
|
||||
shown.chars().map(|(_, s)| s.is_bold()).collect::<Vec<_>>(),
|
||||
[true, true, false]
|
||||
);
|
||||
}
|
||||
|
||||
/// The front text calls of a sign whose every glyph advances 6.
|
||||
fn front_calls(data: &SignData, near: bool) -> Vec<(String, f32, f32, u32, u32)> {
|
||||
let world = World::new(BlockStateId(0), 1);
|
||||
@@ -4164,6 +4341,7 @@ mod tests {
|
||||
skull: &|_| None,
|
||||
glyph: &glyph,
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
sign_text_calls(&scene, data, &data.front, Mat4::IDENTITY, 0, near)
|
||||
@@ -4303,7 +4481,9 @@ mod tests {
|
||||
..changed.clone()
|
||||
};
|
||||
let wide = std::cell::Cell::new(6.0);
|
||||
let glyph_reads = std::cell::Cell::new(0);
|
||||
let glyph = |c: char| {
|
||||
glyph_reads.set(glyph_reads.get() + 1);
|
||||
let u = f32::from(c as u8 % 16) / 16.0;
|
||||
let rect = Rect {
|
||||
u0: u,
|
||||
@@ -4346,6 +4526,7 @@ mod tests {
|
||||
skull: &|_| None,
|
||||
glyph: &glyph,
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let check = |scene: &Scene, pos: [i32; 3], state: u32| {
|
||||
@@ -4389,6 +4570,23 @@ mod tests {
|
||||
wide.set(5.0);
|
||||
scene.glyphs = 1;
|
||||
check(&scene, pos, 1);
|
||||
// With the same game time and data, plain text reuses its vertices,
|
||||
// but obfuscated text must ask the font for fresh glyphs each frame.
|
||||
let mut vertices = Vec::new();
|
||||
let reads = glyph_reads.get();
|
||||
renderer.render(&scene, [(pos, 1)].iter(), &mut vertices);
|
||||
assert_eq!(glyph_reads.get(), reads);
|
||||
let animated = SignData {
|
||||
front: text("§kSecret", false),
|
||||
..Default::default()
|
||||
};
|
||||
let animated_sign = |_| Some(&animated);
|
||||
scene.sign = &animated_sign;
|
||||
renderer.render(&scene, [(pos, 0)].iter(), &mut vertices);
|
||||
let reads = glyph_reads.get();
|
||||
renderer.render(&scene, [(pos, 0)].iter(), &mut vertices);
|
||||
assert!(glyph_reads.get() > reads);
|
||||
scene.sign = &first_sign;
|
||||
// Signs out of view for a while are dropped.
|
||||
scene.game_time = 2 * SIGN_TEXT_KEEP;
|
||||
check(&scene, pos, 0);
|
||||
@@ -4396,11 +4594,23 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn first_line_wraps_on_bytes_without_splitting_a_char() {
|
||||
let advance = |c: char| if c == ' ' { 4.0 } else { 6.0 };
|
||||
assert_eq!(first_line("ab cd ef", 13.0, &advance), "ab");
|
||||
assert_eq!(first_line("abcdef", 13.0, &advance), "ab");
|
||||
assert_eq!(first_line("\u{6771}", 3.0, &advance), "\u{6771}");
|
||||
assert_eq!(first_line("ab\ncd", 100.0, &advance), "ab");
|
||||
let advance = |c: char, _: &Style| if c == ' ' { 4.0 } else { 6.0 };
|
||||
assert_eq!(
|
||||
first_line(&Text::literal("ab cd ef"), 13.0, &advance).plain(),
|
||||
"ab"
|
||||
);
|
||||
assert_eq!(
|
||||
first_line(&Text::literal("abcdef"), 13.0, &advance).plain(),
|
||||
"ab"
|
||||
);
|
||||
assert_eq!(
|
||||
first_line(&Text::literal("\u{6771}"), 3.0, &advance).plain(),
|
||||
"\u{6771}"
|
||||
);
|
||||
assert_eq!(
|
||||
first_line(&Text::literal("ab\ncd"), 100.0, &advance).plain(),
|
||||
"ab"
|
||||
);
|
||||
}
|
||||
/// `rendertype_end_portal` culls back faces, and `AbstractEndPortalRenderer`
|
||||
/// submits the unit cube's two Y faces. Both are wound outward, so of each
|
||||
@@ -4504,6 +4714,7 @@ mod tests {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let mut verts = Vec::new();
|
||||
@@ -4590,6 +4801,7 @@ mod tests {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
// A closed single chest of `block` drawn by `renderer`: positions and UVs.
|
||||
|
||||
@@ -358,6 +358,7 @@ fn batched_callbacks_preserve_entity_ranges_and_allow_immediate_culling() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
// Reverse local-position order to check that cache indexing does not
|
||||
@@ -447,6 +448,7 @@ fn static_cache_qualifies_local_positions_by_section_and_drops_old_entries() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let options = CacheOptions {
|
||||
@@ -502,6 +504,7 @@ fn gpu_static_cache_uses_section_local_chests_and_keeps_visibility_separate() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let blocks = [([100, 70, -32], 0)];
|
||||
@@ -689,6 +692,7 @@ fn sign_text_cache_can_be_disabled_and_reenabled_without_changing_output() {
|
||||
skull: &|_| None,
|
||||
glyph: &glyph,
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let blocks = [([0, 64, 0], 0)];
|
||||
@@ -743,6 +747,7 @@ fn shulker_cache_tracks_settled_poses_and_does_not_freeze_opening_animation() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let blocks = [([0, 64, 0], 0)];
|
||||
@@ -845,6 +850,7 @@ fn shulker_cache_covers_all_colors_and_facing_directions() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let options = CacheOptions {
|
||||
@@ -900,6 +906,7 @@ fn cached_bells_keep_shaking_until_the_animation_ends() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let blocks = [([0, 64, 0], 0)];
|
||||
@@ -962,6 +969,7 @@ fn cached_closed_chests_match_the_direct_model_and_rebuild_when_opening() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let blocks = [([0, 64, 0], 0)];
|
||||
@@ -1060,6 +1068,7 @@ fn closed_chest_cache_covers_all_materials_and_atlas_reloads() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let pos = [0, 64, 0];
|
||||
@@ -1149,6 +1158,7 @@ fn double_chest_cache_bypasses_both_halves_while_either_lid_is_open() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let pair = [([0, 64, 0], 0), ([1, 64, 0], 1)];
|
||||
@@ -1225,6 +1235,7 @@ fn decorated_pot_cache_tracks_sherds_and_bypasses_wobble_animation() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let blocks = [([0, 64, 0], 0)];
|
||||
@@ -1304,6 +1315,7 @@ fn banner_cache_keeps_the_pole_static_while_the_flag_and_patterns_update() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let blocks = [([0, 64, 0], 0)];
|
||||
@@ -1371,6 +1383,7 @@ fn cached_wall_banner_poles_use_the_wall_model() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let pos = [0, 64, 0];
|
||||
@@ -1419,6 +1432,7 @@ fn copper_statue_cache_rebuilds_when_its_pose_state_changes() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let options = CacheOptions {
|
||||
@@ -1525,6 +1539,7 @@ fn copper_statue_cache_covers_weathering_waxing_and_all_poses() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let options = CacheOptions {
|
||||
|
||||
@@ -576,7 +576,7 @@ pub struct Chunk {
|
||||
/// Block entities read from a save, moved into the world on arrival.
|
||||
pub block_entities: Vec<((i32, i32, i32), block_entity::BlockEntity)>,
|
||||
/// `CustomName` of the container block entities (shown as the screen title).
|
||||
pub block_names: Vec<((i32, i32, i32), String)>,
|
||||
pub block_names: Vec<((i32, i32, i32), Tag)>,
|
||||
/// Generation postprocessing marks (ProtoChunk.postProcessing) as world
|
||||
/// positions, applied once by LevelChunk.postProcessGeneration.
|
||||
pub postprocess: Vec<[i32; 3]>,
|
||||
|
||||
@@ -83,6 +83,8 @@ pub enum BlockEntity {
|
||||
/// `DaylightDetectorBlockEntity`: no data, only the ticker
|
||||
/// (`DaylightDetectorBlock.tickEntity`).
|
||||
DaylightDetector,
|
||||
/// `BellBlockEntity`: saved identity only; ringing state is kept separately.
|
||||
Bell,
|
||||
/// Suspicious sand and gravel: saved loot and the transient brushed face.
|
||||
Brushable(Box<Brushable>),
|
||||
/// A shelf holds three displayed item stacks.
|
||||
@@ -452,6 +454,9 @@ pub struct Jukebox {
|
||||
pub items: [Option<ItemStack>; 1],
|
||||
/// `ticks_since_song_started`, while a song plays.
|
||||
pub ticks: i64,
|
||||
/// `JukeboxBlockEntity.saveAdditional` writes the clock only when its song
|
||||
/// reference exists. Keep this separate from legacy playback adoption.
|
||||
pub song_clock_present: bool,
|
||||
pub playing: bool,
|
||||
}
|
||||
impl Jukebox {
|
||||
|
||||
@@ -19,26 +19,75 @@ pub struct ModMenuType {
|
||||
pub slots: usize,
|
||||
pub data_slots: usize,
|
||||
pub results: Vec<usize>,
|
||||
/// its crafting grid, if it has one (`crafting`).
|
||||
pub crafting: Option<ModCrafting>,
|
||||
/// its linked slots, if it has any (`links`).
|
||||
pub links: Option<ModLinks>,
|
||||
}
|
||||
|
||||
/// a mod menu type's linked slots: `count` container slots from `from` on,
|
||||
/// which show the slots of the containers a native mod links them to
|
||||
/// (`native_menu_links`) instead of the block entity's.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ModLinks {
|
||||
pub from: usize,
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
impl ModLinks {
|
||||
/// The linked container slots.
|
||||
pub fn slots(&self) -> std::ops::Range<usize> {
|
||||
self.from..self.from + self.count
|
||||
}
|
||||
}
|
||||
|
||||
/// a mod menu type's crafting grid: `size`x`size` container slots from
|
||||
/// `grid` on, row by row, and the slot showing what they craft. The block
|
||||
/// entity holds the grid; the result is the menu's.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ModCrafting {
|
||||
pub grid: usize,
|
||||
pub size: usize,
|
||||
pub result: usize,
|
||||
}
|
||||
|
||||
impl ModCrafting {
|
||||
/// The grid's container slots.
|
||||
pub fn grid_slots(&self) -> std::ops::Range<usize> {
|
||||
self.grid..self.grid + self.size * self.size
|
||||
}
|
||||
}
|
||||
|
||||
static MOD_MENU_TYPES: std::sync::OnceLock<Vec<ModMenuType>> = std::sync::OnceLock::new();
|
||||
|
||||
/// registers the content mods' menu types, `(id, slots, data slots,
|
||||
/// results)`, a list set once per process (false when it already was).
|
||||
pub fn register_mod_menu_types(
|
||||
menus: impl IntoIterator<Item = (String, usize, usize, Vec<usize>)>,
|
||||
) -> bool {
|
||||
/// One menu type as [`register_mod_menu_types`] takes it: `(id, slots, data
|
||||
/// slots, results, crafting, links)`.
|
||||
pub type ModMenuTypeEntry = (
|
||||
String,
|
||||
usize,
|
||||
usize,
|
||||
Vec<usize>,
|
||||
Option<ModCrafting>,
|
||||
Option<ModLinks>,
|
||||
);
|
||||
|
||||
/// registers the content mods' menu types, a list set once per process
|
||||
/// (false when it already was).
|
||||
pub fn register_mod_menu_types(menus: impl IntoIterator<Item = ModMenuTypeEntry>) -> bool {
|
||||
if MOD_MENU_TYPES.get().is_some() {
|
||||
return false;
|
||||
}
|
||||
let menus = menus
|
||||
.into_iter()
|
||||
.map(|(id, slots, data_slots, results)| ModMenuType {
|
||||
id: Box::leak(id.into_boxed_str()),
|
||||
slots,
|
||||
data_slots,
|
||||
results,
|
||||
});
|
||||
let menus =
|
||||
menus.into_iter().map(
|
||||
|(id, slots, data_slots, results, crafting, links)| ModMenuType {
|
||||
id: Box::leak(id.into_boxed_str()),
|
||||
slots,
|
||||
data_slots,
|
||||
results,
|
||||
crafting,
|
||||
links,
|
||||
},
|
||||
);
|
||||
MOD_MENU_TYPES.set(menus.collect()).is_ok()
|
||||
}
|
||||
|
||||
@@ -392,6 +441,9 @@ impl BlockEntity {
|
||||
if name == "minecraft:end_gateway" {
|
||||
return Some(Self::EndGateway(EndGateway::default()));
|
||||
}
|
||||
if name == "minecraft:bell" {
|
||||
return Some(Self::Bell);
|
||||
}
|
||||
if name == "minecraft:daylight_detector" {
|
||||
return Some(Self::DaylightDetector);
|
||||
}
|
||||
@@ -487,6 +539,7 @@ impl BlockEntity {
|
||||
Self::Crafter(_) => name == "minecraft:crafter",
|
||||
Self::EndGateway(_) => name == "minecraft:end_gateway",
|
||||
Self::DaylightDetector => name == "minecraft:daylight_detector",
|
||||
Self::Bell => name == "minecraft:bell",
|
||||
Self::StructureBlock(_) => name == "minecraft:structure_block",
|
||||
Self::TestBlock(_) => name == "minecraft:test_block",
|
||||
Self::TestInstanceBlock(_) => name == "minecraft:test_instance_block",
|
||||
@@ -525,6 +578,7 @@ impl BlockEntity {
|
||||
Self::Skull(_) => "minecraft:skull",
|
||||
Self::EndGateway(_) => "minecraft:end_gateway",
|
||||
Self::DaylightDetector => "minecraft:daylight_detector",
|
||||
Self::Bell => "minecraft:bell",
|
||||
Self::Brushable(_) => "minecraft:brushable_block",
|
||||
Self::Campfire(_) => "minecraft:campfire",
|
||||
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
|
||||
@@ -559,6 +613,7 @@ impl BlockEntity {
|
||||
| Self::TestBlock(_)
|
||||
| Self::TestInstanceBlock(_)
|
||||
| Self::DaylightDetector
|
||||
| Self::Bell
|
||||
| Self::Brushable(_) => Vec::new(),
|
||||
Self::Mod(m) => m.items.iter().flatten().cloned().collect(),
|
||||
Self::Lectern(l) => l.book.iter().cloned().collect(),
|
||||
@@ -589,6 +644,7 @@ impl BlockEntity {
|
||||
| Self::TestBlock(_)
|
||||
| Self::TestInstanceBlock(_)
|
||||
| Self::DaylightDetector
|
||||
| Self::Bell
|
||||
| Self::Brushable(_) => &[],
|
||||
Self::Jukebox(j) => &j.items,
|
||||
Self::Crafter(c) => &c.items,
|
||||
@@ -624,6 +680,7 @@ impl BlockEntity {
|
||||
| Self::TestBlock(_)
|
||||
| Self::TestInstanceBlock(_)
|
||||
| Self::DaylightDetector
|
||||
| Self::Bell
|
||||
| Self::Brushable(_) => &mut [],
|
||||
Self::Jukebox(j) => &mut j.items,
|
||||
Self::Crafter(c) => &mut c.items,
|
||||
|
||||
@@ -83,6 +83,13 @@ fn choice_description(opt: Opt, pad: &PadSettings) -> Option<&'static str> {
|
||||
GlyphStyle::PlayStation => "Always shows PlayStation buttons.",
|
||||
GlyphStyle::Nintendo => "Always shows Nintendo buttons.",
|
||||
},
|
||||
Opt::PadNintendoButtons => {
|
||||
if pad.nintendo_letters {
|
||||
"A, on the right, confirms and jumps and B goes back, as on a Switch."
|
||||
} else {
|
||||
"B, at the bottom, confirms and jumps and A goes back, as on other controllers."
|
||||
}
|
||||
}
|
||||
Opt::PadKeyboard => match pad.keyboard {
|
||||
gamepad::TextKeyboard::Auto => {
|
||||
"Opens Steam's keyboard on a Steam Deck under Steam, and Horizon's otherwise."
|
||||
@@ -184,6 +191,10 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
Opt::PadGameplayGuide,
|
||||
"Show the buttons for what you can do, while you play with a controller.",
|
||||
),
|
||||
option(
|
||||
Opt::PadCraftOnServers,
|
||||
"Lets LT + A craft on servers other than your own worlds. Some servers count it as a macro.",
|
||||
),
|
||||
option(
|
||||
Opt::PadTriggerThreshold,
|
||||
"How far a trigger is pulled before it counts as pressed.",
|
||||
@@ -261,6 +272,10 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
Opt::PadGlyphs,
|
||||
"Follow the controller, or always show one family's buttons.",
|
||||
),
|
||||
option(
|
||||
Opt::PadNintendoButtons,
|
||||
"Whether a Nintendo controller's A and B work by where they sit or by their letters.",
|
||||
),
|
||||
info(Info::Using, "The buttons menus show right now."),
|
||||
],
|
||||
),
|
||||
@@ -333,6 +348,7 @@ impl Item {
|
||||
Item::Option(Opt::PadAdaptiveTriggers) => Some(c.adaptive_triggers),
|
||||
Item::Option(Opt::PadInvertX) => Some(c.invert_x),
|
||||
Item::Option(Opt::PadInvertY) => Some(c.invert_y),
|
||||
Item::Option(Opt::PadCraftOnServers) => Some(c.craft_on_servers),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1332,6 +1348,7 @@ mod tests {
|
||||
Opt::PadLookDeadzone,
|
||||
Opt::PadTriggerThreshold,
|
||||
Opt::PadGlyphs,
|
||||
Opt::PadNintendoButtons,
|
||||
Opt::PadKeyboard,
|
||||
Opt::PadPointerSpeed,
|
||||
Opt::PadScrollSpeed,
|
||||
@@ -1429,6 +1446,53 @@ mod tests {
|
||||
assert!((s.controller.move_deadzone - before - 0.01).abs() < 0.001);
|
||||
}
|
||||
|
||||
/// The Device page's Nintendo Buttons row sits with the glyphs, steps between its two
|
||||
/// values rather than ticking a box, and what it sets is what the controller file keeps.
|
||||
#[test]
|
||||
fn the_nintendo_buttons_row_flips_letters_and_the_file_keeps_it() {
|
||||
let (page, group) = rows()
|
||||
.into_iter()
|
||||
.flat_map(|(page, groups)| groups.into_iter().map(move |g| (page, g)))
|
||||
.find(|(_, (_, rows))| {
|
||||
rows.iter()
|
||||
.any(|r| r.item == Item::Option(Opt::PadNintendoButtons))
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!((page, group.0), (Page::Device, "Glyphs"));
|
||||
let mut s = Settings::default();
|
||||
let item = Item::Option(Opt::PadNintendoButtons);
|
||||
assert!(item.steps(&s));
|
||||
s.controller.nintendo_letters = false;
|
||||
let mut ui = ControllerUi {
|
||||
focus: Some(Target::Item(item)),
|
||||
..ControllerUi::default()
|
||||
};
|
||||
// Vanilla's caption and value template; Horizon's own words come bundled.
|
||||
struct Vanilla;
|
||||
impl lang::ResourceSource for Vanilla {
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
(path == "assets/minecraft/lang/en_us.json")
|
||||
.then(|| br#"{"options.generic_value": "%s: %s"}"#.to_vec())
|
||||
}
|
||||
}
|
||||
let value = |ui: &ControllerUi, s: &Settings| {
|
||||
lang::scoped(lang::Language::load(&[&Vanilla], "en_us"), || {
|
||||
ui.parts(item, s)
|
||||
})
|
||||
.0
|
||||
};
|
||||
assert_eq!(value(&ui, &s).0, "Nintendo Buttons");
|
||||
assert_eq!(value(&ui, &s).1.as_deref(), Some("By Position"));
|
||||
let actions = key(&mut ui, &mut s, K::Enter);
|
||||
assert!(actions.contains(&Action::Changed), "{actions:?}");
|
||||
assert!(s.controller.nintendo_letters);
|
||||
assert_eq!(value(&ui, &s).1.as_deref(), Some("By Letter"));
|
||||
assert!(PadSettings::parse(&s.controller.serialize()).nintendo_letters);
|
||||
key(&mut ui, &mut s, K::Enter);
|
||||
assert!(!s.controller.nintendo_letters);
|
||||
assert!(!PadSettings::parse(&s.controller.serialize()).nintendo_letters);
|
||||
}
|
||||
|
||||
/// What the screen draws at the bottom of the page changes with the choice, in every one of
|
||||
/// these settings.
|
||||
#[test]
|
||||
@@ -1438,6 +1502,7 @@ mod tests {
|
||||
for (opt, choices) in [
|
||||
(Opt::PadGameplayGuide, 3),
|
||||
(Opt::PadGlyphs, 4),
|
||||
(Opt::PadNintendoButtons, 2),
|
||||
(Opt::PadKeyboard, 3),
|
||||
(Opt::PadLightbar, 3),
|
||||
] {
|
||||
@@ -1470,6 +1535,7 @@ mod tests {
|
||||
for opt in [
|
||||
Opt::PadGameplayGuide,
|
||||
Opt::PadGlyphs,
|
||||
Opt::PadNintendoButtons,
|
||||
Opt::PadKeyboard,
|
||||
Opt::PadLightbar,
|
||||
] {
|
||||
|
||||
@@ -278,6 +278,43 @@ pub fn screen_packs(dir: &Path, resources: &Path) -> Vec<crate::pack_screen::Pac
|
||||
}
|
||||
infos
|
||||
}
|
||||
/// The folder of a game folder whose packs every new world there starts with: a modpack's profile
|
||||
/// keeps the packs of its data-only jars here (`horizon-port-pack`). Each world gets its own copy,
|
||||
/// as Create World's packs do, so a world keeps working when the folder changes. Vanilla has no
|
||||
/// such folder, and without one nothing here happens.
|
||||
pub const NEW_WORLD_PACKS: &str = "new-world-datapacks";
|
||||
|
||||
/// Create World's temporary pack folder holding `game_dir`'s [`NEW_WORLD_PACKS`], and their ids,
|
||||
/// to show them selected in the Data Packs screen. `None` when there are none.
|
||||
pub fn seed_create_world(game_dir: &Path) -> Option<(PathBuf, Vec<String>)> {
|
||||
let from = game_dir.join(NEW_WORLD_PACKS);
|
||||
let ids: Vec<String> = discover_folder(&from).into_iter().map(|p| p.id).collect();
|
||||
if ids.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let seeded = temp_folder()
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|dir| copy(&from, &dir).map(|()| dir));
|
||||
match seeded {
|
||||
Ok(dir) => Some((dir, ids)),
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: data packs for new worlds: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A world about to be created without Create World (`--world`, the control socket) gets
|
||||
/// `game_dir`'s [`NEW_WORLD_PACKS`]. The game turns on every pack it finds in a world.
|
||||
pub fn install_new_world_packs(game_dir: &Path, world: &Path) -> Result<(), String> {
|
||||
let from = game_dir.join(NEW_WORLD_PACKS);
|
||||
let exists = |name: &str| world.join(name).exists();
|
||||
if !from.is_dir() || exists("level.dat") || exists("level.dat_old") {
|
||||
return Ok(());
|
||||
}
|
||||
copy(&from, &world.join("datapacks"))
|
||||
}
|
||||
|
||||
/// A new empty folder for Create World's packs
|
||||
/// (`Files.createTempDirectory("mcworld-")`).
|
||||
pub fn temp_folder() -> std::io::Result<PathBuf> {
|
||||
@@ -1101,6 +1138,35 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_game_folder_s_new_world_packs_go_into_new_worlds_only() {
|
||||
let root = std::env::temp_dir().join(format!("hz-new-world-packs-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let (game, world, old) = (root.join("game"), root.join("new"), root.join("old"));
|
||||
assert!(seed_create_world(&game).is_none());
|
||||
assert_eq!(install_new_world_packs(&game, &world), Ok(()));
|
||||
assert!(!world.join("datapacks").exists());
|
||||
for pack in ["void", "islands"] {
|
||||
let dir = game.join(NEW_WORLD_PACKS).join(pack);
|
||||
std::fs::create_dir_all(dir.join("data/v/function")).unwrap();
|
||||
std::fs::write(dir.join("pack.mcmeta"), r#"{"pack":{"description":""}}"#).unwrap();
|
||||
std::fs::write(dir.join("data/v/function/a.mcfunction"), "say a").unwrap();
|
||||
}
|
||||
let (temp, ids) = seed_create_world(&game).unwrap();
|
||||
assert_eq!(ids, ["file/islands", "file/void"]);
|
||||
assert!(temp.join("void/data/v/function/a.mcfunction").is_file());
|
||||
let _ = std::fs::remove_dir_all(&temp);
|
||||
install_new_world_packs(&game, &world).unwrap();
|
||||
let ids: Vec<String> = discover(&world).into_iter().map(|p| p.id).collect();
|
||||
assert_eq!(ids, ["file/islands", "file/void"]);
|
||||
// A world that exists already keeps the packs it has.
|
||||
std::fs::create_dir_all(&old).unwrap();
|
||||
std::fs::write(old.join("level.dat"), "").unwrap();
|
||||
install_new_world_packs(&game, &old).unwrap();
|
||||
assert!(discover(&old).is_empty());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_packs_override_worldgen_in_order() {
|
||||
let dir = std::env::temp_dir().join(format!("hz-datapack-worldgen-{}", std::process::id()));
|
||||
|
||||
@@ -33,7 +33,10 @@ struct Globals {
|
||||
hdr_windows: array<vec4<f32>, 2>,
|
||||
// HDR lighting: x how strongly the sky lights the world now (`Sky::sky_light_factor`),
|
||||
// y what the sky and fog get: the same, but none under water.
|
||||
// w: this entire frame writes light, so alpha is coverage rather than a mark.
|
||||
hdr_light: vec4<f32>,
|
||||
hdr_settings: vec4<f32>,
|
||||
hdr_shares: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||
@group(0) @binding(1) var atlas: texture_2d<f32>;
|
||||
@@ -276,6 +279,18 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
|
||||
}
|
||||
// CRUMBLING_UV_END
|
||||
@fragment fn fs_entity(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
return entity_written_color(in, front);
|
||||
}
|
||||
fn entity_written_color(in: Out, front: bool) -> vec4<f32> {
|
||||
let color = entity_color(in, front);
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 && in.overlay != 11u && in.overlay != 12u {
|
||||
var mark = hdr_entity_mark(in);
|
||||
if in.overlay == 4u { mark = hdr_scene_alpha(globals.hdr_light.y); }
|
||||
return vec4(hdr_written(color.rgb, mark, globals.hdr_settings, globals.hdr_shares), color.a);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
@fragment fn fs_entity_unmarked(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
return entity_color(in, front);
|
||||
}
|
||||
fn entity_color(in: Out, front: bool) -> vec4<f32> {
|
||||
@@ -358,15 +373,23 @@ fn shadow_color(in: Out) -> vec4<f32> {
|
||||
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
|
||||
let texel = textureSample(atlas, atlas_sampler, uv);
|
||||
if texel.a < 0.1 { discard; }
|
||||
return vec4(0.0, 0.0, 0.0, hdr_entity_mark(in));
|
||||
}
|
||||
fn hdr_entity_mark(in: Out) -> f32 {
|
||||
var uv = in.uv;
|
||||
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
|
||||
let texel = textureSample(atlas, atlas_sampler, uv);
|
||||
// Additive and procedural colour draws can survive where the legacy mark pass skips.
|
||||
if texel.a < 0.1 { return 1.0; }
|
||||
// Fog hides a light source and the light on a surface as it hides everything else.
|
||||
let fogged = max(linear_fog(in.fog_distance.x, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.fog_distance.y, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
|
||||
if in.tier == 0u {
|
||||
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(mix(in.scene, globals.hdr_light.y, fogged)));
|
||||
return hdr_scene_alpha(mix(in.scene, globals.hdr_light.y, fogged));
|
||||
}
|
||||
// A layer that fades (the warden's) shines by what's left of it.
|
||||
let y = brightness(texel.rgb * in.color.rgb) * in.color.a * (1.0 - fogged);
|
||||
return vec4(0.0, 0.0, 0.0, hdr_mark(in.tier, y));
|
||||
return hdr_mark(in.tier, y);
|
||||
}
|
||||
fn brightness(c: vec3<f32>) -> f32 {
|
||||
let l = pow(max(c, vec3(0.0)), vec3(2.2));
|
||||
@@ -407,7 +430,11 @@ fn celestial_color(in: Out) -> vec4<f32> {
|
||||
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
|
||||
}
|
||||
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
|
||||
return celestial_color(in);
|
||||
let color = celestial_color(in);
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(color.rgb, hdr_entity_mark(in), globals.hdr_settings, globals.hdr_shares), color.a);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
// The sky's celestial pass (sun, moon, the End sky): fs_celestial's light,
|
||||
@@ -424,7 +451,11 @@ fn celestial_color(in: Out) -> vec4<f32> {
|
||||
let y = brightness(added);
|
||||
// Only what's past the tier's window takes the mark: a soft edge keeps the sky's light,
|
||||
// or it would come out darker than the sky around it.
|
||||
if y < globals.hdr_windows[1][clamp(tier, 1u, 4u) - 1u] { return vec4(added, 0.0); }
|
||||
let full = y >= globals.hdr_windows[1][clamp(tier, 1u, 4u) - 1u];
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(added, select(1.0, hdr_mark(tier, y), full), globals.hdr_settings, globals.hdr_shares), color.a);
|
||||
}
|
||||
if !full { return vec4(added, 0.0); }
|
||||
return vec4(added, 1.0 - hdr_mark(tier, y));
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ use crate::{
|
||||
lang,
|
||||
options_ui::{Event, Page},
|
||||
settings::Settings,
|
||||
settings_shell::{Group, Output, Row, Screen, Sheet, Value},
|
||||
settings_shell::{self as shell, Group, Output, Row, Screen, Sheet, Value},
|
||||
};
|
||||
|
||||
const TITLE: &str = "horizon.pages.extras";
|
||||
@@ -85,6 +85,10 @@ impl Screen for ExtrasScreen {
|
||||
fn note_lines(&self) -> usize {
|
||||
NOTE_LINES
|
||||
}
|
||||
/// The rows read the extras, the mods and whether cosmetics are offered.
|
||||
fn stamp(&self, s: &Settings) -> u64 {
|
||||
shell::stamp_of(&(&s.extras, &s.mods, horizon_cosmetics::enabled()))
|
||||
}
|
||||
fn sheets(&self, s: &Settings) -> Vec<Sheet<(), ExtraRow>> {
|
||||
let mut groups = vec![Group {
|
||||
name: MODS,
|
||||
@@ -228,4 +232,66 @@ mod tests {
|
||||
assert!(s.extras.sort);
|
||||
assert_eq!(screen.wheel_step(&mut s, ExtraRow::Zoom(0), true), None);
|
||||
}
|
||||
|
||||
/// Extras has no Undo: a change leaves none to press or reach.
|
||||
#[test]
|
||||
fn a_change_on_extras_leaves_no_undo() {
|
||||
use crate::settings_shell::{Columns, Target};
|
||||
use winit::keyboard::KeyCode as K;
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<ExtrasScreen>::default();
|
||||
let at = shell.point_at(&s, SIZE, ExtraRow::Item(Item::Sort));
|
||||
assert!(
|
||||
!shell
|
||||
.click(&mut s, at, SIZE, false, false, false)
|
||||
.is_empty()
|
||||
);
|
||||
assert!(s.extras.sort && !shell.changed());
|
||||
let cols = Columns::of(SIZE.0, SIZE.1).with_note_lines(NOTE_LINES);
|
||||
let [x, y, ..] = cols.button(1);
|
||||
assert!(
|
||||
shell
|
||||
.click(&mut s, (x + 2.0, y + 2.0), SIZE, false, false, false)
|
||||
.is_empty()
|
||||
);
|
||||
assert!(s.extras.sort);
|
||||
let reached = (0..100).any(|_| {
|
||||
shell.key(&mut s, K::Tab, None, false, SIZE);
|
||||
shell.focused() == Some(Target::Undo)
|
||||
});
|
||||
assert!(!reached);
|
||||
}
|
||||
|
||||
/// A page with no page list has Done under the search field: Down goes to the first row
|
||||
/// instead, from the keys and, as the frame declares it, from a controller's focus.
|
||||
#[test]
|
||||
fn down_from_the_search_field_lands_on_the_first_row() {
|
||||
use crate::{hud::HudBatch, settings_shell::Columns, ui};
|
||||
use winit::keyboard::KeyCode as K;
|
||||
let atlas = crate::hud::HudAtlas::build(std::path::Path::new("/nonexistent")).unwrap();
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<ExtrasScreen>::default();
|
||||
let mut batch = HudBatch::new([1708, 960]);
|
||||
shell.draw(&s, &mut batch, &atlas, (0.0, 0.0), false, None);
|
||||
crate::widgets::end_frame(1);
|
||||
let size = (batch.width, batch.height);
|
||||
let cols = Columns::of(size.0, size.1).with_note_lines(NOTE_LINES);
|
||||
// The Mods group's note can't take the focus: the first item can.
|
||||
let first = ExtraRow::Item(Item::OwnNameTag);
|
||||
let at = shell.point_at(&s, size, first);
|
||||
let [x, y, w, h] = crate::widgets::linked_step(cols.search(), true).unwrap();
|
||||
assert!(ui::hovered(at, x, y, w, h));
|
||||
assert_eq!(crate::widgets::linked_step(cols.search(), false), None);
|
||||
let [x, y, w, h] = cols.search();
|
||||
shell.click(
|
||||
&mut s,
|
||||
(x + w / 2.0, y + h / 2.0),
|
||||
size,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
shell.key(&mut s, K::ArrowDown, None, false, size);
|
||||
assert_eq!(shell.focused_row(), Some(first));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -879,6 +879,7 @@ mod tests {
|
||||
solid,
|
||||
solid_slots: u64::from(solid) * 4,
|
||||
packed: false,
|
||||
packed_far: false,
|
||||
sides: crate::mesher::Sides::unsorted(solid),
|
||||
translucent,
|
||||
sort: super::super::translucency::SortState::new(Vec::new()),
|
||||
@@ -1047,7 +1048,15 @@ mod tests {
|
||||
hdr: None,
|
||||
}
|
||||
};
|
||||
let mut flat = flat::Flat::new(&device, &terrain_layout, 4 * GRAIN, 0, pipeline, solid);
|
||||
let mut flat = flat::Flat::new(
|
||||
&device,
|
||||
&terrain_layout,
|
||||
4 * GRAIN,
|
||||
0,
|
||||
false,
|
||||
pipeline,
|
||||
solid,
|
||||
);
|
||||
let stride = std::mem::size_of::<crate::mesher::TerrainVertex>() as u64;
|
||||
let mut translucent = Arena::translucent(&device, stride, 4 * GRAIN);
|
||||
translucent.page_limit = 4 * GRAIN;
|
||||
@@ -1083,6 +1092,7 @@ mod tests {
|
||||
solid: 64,
|
||||
solid_slots: 64 * 4,
|
||||
packed: false,
|
||||
packed_far: false,
|
||||
sides: crate::mesher::Sides::unsorted(64),
|
||||
translucent: 128,
|
||||
sort: super::super::translucency::SortState::new(Vec::new()),
|
||||
@@ -1310,6 +1320,7 @@ mod tests {
|
||||
solid,
|
||||
solid_slots: u64::from(solid) * 4,
|
||||
packed: false,
|
||||
packed_far: false,
|
||||
sides: crate::mesher::Sides::unsorted(solid),
|
||||
translucent,
|
||||
sort: super::super::translucency::SortState::new(Vec::new()),
|
||||
|
||||
@@ -67,6 +67,7 @@ fn copy(mesh: &SectionMesh) -> SectionMesh {
|
||||
sides: mesh.sides,
|
||||
translucent: mesh.translucent.clone(),
|
||||
packed: mesh.packed.clone(),
|
||||
packed_far: mesh.packed_far,
|
||||
solid_tints: Vec::new(),
|
||||
visibility: mesh.visibility,
|
||||
block_entities: mesh.block_entities.clone(),
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
//! A resource pack's `core/sky` and `core/stars` shaders: the overworld's sky disc and stars drawn
|
||||
//! with the pack's GLSL (`SkyRenderer.renderSkyDisc`, `renderStars`), as a pack that adds a haze
|
||||
//! to the horizon or tints the night sky expects. Without one the sky draws natively (`fs_sky` in
|
||||
//! terrain.wgsl, stars.rs).
|
||||
|
||||
use glam::Mat4;
|
||||
use horizon_shaders::{CoreProgram, Glue};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
/// `DefaultVertexFormat.POSITION`: the disc's and the stars' vertices.
|
||||
pub(crate) const GLUE: Glue = Glue {
|
||||
inputs: &[("Position", "vec3")],
|
||||
prelude: "layout(location = 0) in vec3 hz_position;",
|
||||
setup: "Position = hz_position;",
|
||||
// 26.3's ProjMat already has wgpu's reversed 0 to 1 depth (Projection: zZeroToOne).
|
||||
finish: "",
|
||||
vertex_id: "int(gl_VertexIndex)",
|
||||
};
|
||||
|
||||
/// Each uniform block's offset in the one buffer (`core_shader::BLOCKS` order).
|
||||
const BLOCK_OFFSETS: [u64; 4] = [0, 256, 512, 768];
|
||||
const BLOCK_SIZES: [u64; 4] = [48, 64, 160, 48];
|
||||
|
||||
/// `SkyRenderer.buildSkyDisc(16)`: a fan around the point 16 above the camera, out to 512 every
|
||||
/// 45 degrees, as a triangle list.
|
||||
pub(crate) fn disc_triangles() -> Vec<[f32; 3]> {
|
||||
let rim: Vec<[f32; 3]> = (-180..=180)
|
||||
.step_by(45)
|
||||
.map(|i: i32| {
|
||||
let a = (i as f32).to_radians();
|
||||
[512.0 * a.cos(), 16.0, 512.0 * a.sin()]
|
||||
})
|
||||
.collect();
|
||||
rim.windows(2)
|
||||
.flat_map(|edge| [[0.0, 16.0, 0.0], edge[0], edge[1]])
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One of the sky's pack pipelines with its vertices and uniforms.
|
||||
struct Part {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
group: wgpu::BindGroup,
|
||||
uniform: wgpu::Buffer,
|
||||
vertices: wgpu::Buffer,
|
||||
count: u32,
|
||||
}
|
||||
|
||||
impl Part {
|
||||
fn new(
|
||||
device: &wgpu::Device,
|
||||
format: wgpu::TextureFormat,
|
||||
program: &CoreProgram,
|
||||
name: &str,
|
||||
blend: Option<wgpu::BlendState>,
|
||||
triangles: &[[f32; 3]],
|
||||
) -> Option<Self> {
|
||||
// A pack's shader is untrusted: the backend failing to compile it is an internal error.
|
||||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
|
||||
let module = |stage, module: &naga::Module| {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(&format!("Resource pack {name} ({stage})")),
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module.clone())),
|
||||
})
|
||||
};
|
||||
let vertex = module("vertex", &program.vertex);
|
||||
let fragment = module("fragment", &program.fragment);
|
||||
// Vanilla's SKY and STARS pipelines have no samplers: a pack that reads one is not used.
|
||||
let entries: Vec<wgpu::BindGroupLayoutEntry> = (0..4)
|
||||
.map(|binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: wgpu::BufferSize::new(BLOCK_SIZES[binding as usize]),
|
||||
},
|
||||
count: None,
|
||||
})
|
||||
.collect();
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some(name),
|
||||
entries: &entries,
|
||||
});
|
||||
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some(name),
|
||||
size: 1024,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let resources: Vec<wgpu::BindGroupEntry> = (0..4)
|
||||
.map(|i| wgpu::BindGroupEntry {
|
||||
binding: i as u32,
|
||||
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||
buffer: &uniform,
|
||||
offset: BLOCK_OFFSETS[i],
|
||||
size: wgpu::BufferSize::new(BLOCK_SIZES[i]),
|
||||
}),
|
||||
})
|
||||
.collect();
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some(name),
|
||||
layout: &layout,
|
||||
entries: &resources,
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some(name),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(name),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &vertex,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[Some(wgpu::VertexBufferLayout {
|
||||
array_stride: 12,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &wgpu::vertex_attr_array![0 => Float32x3],
|
||||
})],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState::default(),
|
||||
// The sky is drawn first, behind everything: no depth test, no depth write.
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(false),
|
||||
depth_compare: Some(wgpu::CompareFunction::Always),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &fragment,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let internal = pollster::block_on(internal.pop());
|
||||
if let Some(e) = internal.or(pollster::block_on(validation.pop())) {
|
||||
log::warn!("the resource pack's {name} shader is not used: {e}");
|
||||
return None;
|
||||
}
|
||||
let vertices = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some(name),
|
||||
contents: bytemuck::cast_slice(triangles),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
Some(Self {
|
||||
pipeline,
|
||||
group,
|
||||
uniform,
|
||||
vertices,
|
||||
count: triangles.len() as u32,
|
||||
})
|
||||
}
|
||||
|
||||
fn draw(&self, pass: &mut wgpu::RenderPass<'_>) {
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.set_vertex_buffer(0, self.vertices.slice(..));
|
||||
pass.draw(0..self.count, 0..1);
|
||||
}
|
||||
}
|
||||
|
||||
/// The packs' sky disc and stars pipelines, each present when a pack replaces that shader.
|
||||
pub(super) struct CoreSky {
|
||||
disc: Option<Part>,
|
||||
stars: Option<Part>,
|
||||
}
|
||||
|
||||
impl CoreSky {
|
||||
/// `None` when neither shader is replaced or neither builds.
|
||||
pub(super) fn new(
|
||||
device: &wgpu::Device,
|
||||
format: wgpu::TextureFormat,
|
||||
disc: Option<&CoreProgram>,
|
||||
stars: Option<&CoreProgram>,
|
||||
) -> Option<Self> {
|
||||
let disc = disc.and_then(|program| {
|
||||
// RenderPipelines.SKY: ColorTargetState.DEFAULT, no blending.
|
||||
Part::new(device, format, program, "sky", None, &disc_triangles())
|
||||
});
|
||||
let stars = stars.and_then(|program| {
|
||||
// RenderPipelines.STARS: BlendFunction.OVERLAY (src alpha and one; alpha one, zero).
|
||||
let overlay = wgpu::BlendState {
|
||||
color: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::SrcAlpha,
|
||||
dst_factor: wgpu::BlendFactor::One,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
alpha: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::One,
|
||||
dst_factor: wgpu::BlendFactor::Zero,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
},
|
||||
};
|
||||
let triangles = super::stars::star_triangles();
|
||||
Part::new(device, format, program, "stars", Some(overlay), &triangles)
|
||||
});
|
||||
(disc.is_some() || stars.is_some()).then_some(Self { disc, stars })
|
||||
}
|
||||
|
||||
pub(super) fn draws_disc(&self) -> bool {
|
||||
self.disc.is_some()
|
||||
}
|
||||
|
||||
pub(super) fn draws_stars(&self) -> bool {
|
||||
self.stars.is_some()
|
||||
}
|
||||
|
||||
/// Uploads the disc's and the stars' blocks: `sky` and `stars` from [`uniforms`].
|
||||
pub(super) fn prepare(
|
||||
&self,
|
||||
uploads: &mut super::upload::Uploads,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
sky: &[u8],
|
||||
stars: Option<&[u8]>,
|
||||
) {
|
||||
if let Some(disc) = &self.disc {
|
||||
uploads.buffer(encoder, &disc.uniform, 0, sky);
|
||||
}
|
||||
if let (Some(part), Some(data)) = (&self.stars, stars) {
|
||||
uploads.buffer(encoder, &part.uniform, 0, data);
|
||||
}
|
||||
}
|
||||
|
||||
/// The disc, in place of the native sky colour. The caller sets its own group 0 again after.
|
||||
pub(super) fn draw_disc(&self, pass: &mut wgpu::RenderPass<'_>) {
|
||||
if let Some(disc) = &self.disc {
|
||||
disc.draw(pass);
|
||||
}
|
||||
}
|
||||
|
||||
/// The stars, in place of the native ones. The caller sets its own group 0 again after.
|
||||
pub(super) fn draw_stars(&self, pass: &mut wgpu::RenderPass<'_>) {
|
||||
if let Some(stars) = &self.stars {
|
||||
stars.draw(pass);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The std140 blocks for one sky draw: `Globals`, `Projection` (`projection`, far enough for the
|
||||
/// disc's 512-block rim), `DynamicTransforms` (`model_view`: the camera's rotation, times the
|
||||
/// stars' rotation for them; `color`: the sky colour, or the star brightness in every channel)
|
||||
/// and `Fog` (`fog_color`, `fog`: environmental start and end, render distance start and end,
|
||||
/// sky end, clouds end).
|
||||
pub(super) fn uniforms(
|
||||
globals: [u8; 48],
|
||||
projection: Mat4,
|
||||
model_view: Mat4,
|
||||
color: [f32; 4],
|
||||
fog_color: [f32; 4],
|
||||
fog: [f32; 6],
|
||||
) -> Vec<u8> {
|
||||
let mut data = vec![0f32; 256];
|
||||
data[64..80].copy_from_slice(&projection.to_cols_array());
|
||||
let transforms = 128;
|
||||
data[transforms..transforms + 16].copy_from_slice(&model_view.to_cols_array());
|
||||
data[transforms + 16..transforms + 32].copy_from_slice(&Mat4::IDENTITY.to_cols_array());
|
||||
data[transforms + 32..transforms + 36].copy_from_slice(&color);
|
||||
data[192..196].copy_from_slice(&fog_color);
|
||||
data[196..202].copy_from_slice(&fog);
|
||||
let mut bytes: Vec<u8> = bytemuck::cast_slice(&data).to_vec();
|
||||
bytes[..48].copy_from_slice(&globals);
|
||||
bytes
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `buildSkyDisc(16)`: eight triangles from the point above the camera to the rim, which
|
||||
/// starts and ends at x -512 (cos 180°) and passes through z -512, x 512 and z 512.
|
||||
#[test]
|
||||
fn the_disc_is_vanillas_fan_as_triangles() {
|
||||
let triangles = disc_triangles();
|
||||
assert_eq!(triangles.len(), 24);
|
||||
assert!(triangles.chunks(3).all(|t| t[0] == [0.0, 16.0, 0.0]));
|
||||
let rim: Vec<[f32; 3]> = triangles.chunks(3).map(|t| t[1]).collect();
|
||||
let near = |a: [f32; 3], b: [f32; 3]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-3);
|
||||
assert!(near(rim[0], [-512.0, 16.0, 0.0]));
|
||||
assert!(near(rim[2], [0.0, 16.0, -512.0]));
|
||||
assert!(near(rim[4], [512.0, 16.0, 0.0]));
|
||||
assert!(near(rim[6], [0.0, 16.0, 512.0]));
|
||||
assert!(near(triangles[23], [-512.0, 16.0, 0.0]));
|
||||
}
|
||||
|
||||
/// The prepared resources (`HORIZON_RESOURCES`, or `.local/horizon-client/<version>`), where
|
||||
/// vanilla's sky shader and its includes are.
|
||||
fn resources() -> std::path::PathBuf {
|
||||
std::env::var_os("HORIZON_RESOURCES")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION)
|
||||
})
|
||||
}
|
||||
|
||||
/// Vanilla's `sky.vsh` with a pack's fragment shader that paints `ColorModulator` (the sky
|
||||
/// colour) where the disc is: looking at the horizon, the top half of the target is the
|
||||
/// disc, the bottom half stays clear.
|
||||
#[test]
|
||||
fn a_pack_sky_shader_paints_the_disc_above_the_horizon() {
|
||||
let root = resources();
|
||||
let Ok(vertex) =
|
||||
std::fs::read_to_string(root.join("assets/minecraft/shaders/core/sky.vsh"))
|
||||
else {
|
||||
eprintln!("skipping: no prepared resources at {}", root.display());
|
||||
return;
|
||||
};
|
||||
let include = |id: &str| {
|
||||
let (namespace, path) = id.split_once(':')?;
|
||||
std::fs::read_to_string(root.join(format!("assets/{namespace}/{path}"))).ok()
|
||||
};
|
||||
let fragment = "#version 330\n#include <minecraft:dynamictransforms.glsl>\n\
|
||||
layout(location = 0) out vec4 fragColor;\n\
|
||||
void main() {\n fragColor = ColorModulator;\n}\n";
|
||||
let program = horizon_shaders::translate_core(&vertex, fragment, &[], &include, &GLUE)
|
||||
.expect("the sky shaders translate");
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Some((device, queue)) =
|
||||
pollster::block_on(instance.request_adapter(&Default::default()))
|
||||
.ok()
|
||||
.and_then(|adapter| {
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
})
|
||||
else {
|
||||
eprintln!("no GPU: skipping");
|
||||
return;
|
||||
};
|
||||
const SIZE: u32 = 64;
|
||||
let format = wgpu::TextureFormat::Rgba8Unorm;
|
||||
let sky = CoreSky::new(&device, format, Some(&program), None).expect("the sky pipeline");
|
||||
let projection = crate::gfx::world_projection(90.0, 1.0, 2048.0);
|
||||
let data = uniforms(
|
||||
[0; 48],
|
||||
projection,
|
||||
Mat4::IDENTITY,
|
||||
[0.25, 0.5, 1.0, 1.0],
|
||||
[0.0; 4],
|
||||
[1e6; 6],
|
||||
);
|
||||
queue.write_buffer(&sky.disc.as_ref().unwrap().uniform, 0, &data);
|
||||
let target = |format, usage| {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | usage,
|
||||
view_formats: &[],
|
||||
})
|
||||
};
|
||||
let color = target(format, wgpu::TextureUsages::COPY_SRC);
|
||||
let depth = target(
|
||||
wgpu::TextureFormat::Depth32Float,
|
||||
wgpu::TextureUsages::empty(),
|
||||
);
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(SIZE * SIZE * 4),
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let color_view = color.create_view(&Default::default());
|
||||
let depth_view = depth.create_view(&Default::default());
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &color_view,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &depth_view,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(0.0),
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
sky.draw_disc(&mut pass);
|
||||
}
|
||||
encoder.copy_texture_to_buffer(
|
||||
color.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &read,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(SIZE * 4),
|
||||
rows_per_image: Some(SIZE),
|
||||
},
|
||||
},
|
||||
color.size(),
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let pixels = read.slice(..).get_mapped_range().unwrap().to_vec();
|
||||
let at = |x: usize, y: usize| pixels[(y * 64 + x) * 4..(y * 64 + x) * 4 + 4].to_vec();
|
||||
// Row 0 is the top: the disc, in the sky colour; the bottom row is below the horizon.
|
||||
let top = at(32, 4);
|
||||
assert!(
|
||||
top.iter()
|
||||
.zip([64u8, 128, 255, 255])
|
||||
.all(|(&p, w)| p.abs_diff(w) <= 1),
|
||||
"{top:?}"
|
||||
);
|
||||
assert_eq!(at(32, 60), vec![0, 0, 0, 0]);
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,8 @@
|
||||
//! second pass, draws the sections whose box was seen; the others' draws have
|
||||
//! an instance count of 0.
|
||||
//!
|
||||
//! The picture stays the one without culling: the boxes (grown by a block,
|
||||
//! the farthest a block model reaches out of its cell) are drawn front faces
|
||||
//! The picture stays the one without culling: the boxes around the mesh's
|
||||
//! geometry (padded by a block) are drawn front faces
|
||||
//! only, so a fragment is the box's nearest point on its pixel, and the
|
||||
//! fragment shader runs only where the depth test passes (forced early), so a
|
||||
//! section is left out only where all of its fragments would fail the depth
|
||||
@@ -45,6 +45,15 @@ use std::sync::{
|
||||
const WORDS: u64 = 5 * 4;
|
||||
/// A tested section's box, around its origin: its cell grown by a block.
|
||||
pub const SECTION_BOX: [[f32; 3]; 2] = [[-1.0; 3], [17.0; 3]];
|
||||
/// Mesh bounds already contain `BOUNDS_PAD`; grow them to a block of padding
|
||||
/// in total, keeping the box's faces off the geometry. Without mesh bounds,
|
||||
/// keep the section cell grown by a block, as before.
|
||||
pub fn section_box(bounds: Option<[glam::Vec3; 2]>) -> [[f32; 3]; 2] {
|
||||
bounds.map_or(SECTION_BOX, |[low, high]| {
|
||||
let pad = glam::Vec3::splat(1.0 - crate::mesher::SectionMesh::BOUNDS_PAD);
|
||||
[(low - pad).to_array(), (high + pad).to_array()]
|
||||
})
|
||||
}
|
||||
/// The bit that marks a far tile's slot (`Renderer::tile_cull_slot`): tiles
|
||||
/// count their slots apart from the sections' resident ones.
|
||||
pub const TILE: u32 = 1 << 31;
|
||||
|
||||
@@ -16,8 +16,8 @@ struct Globals {
|
||||
// `place`): the first pass's first draw, both passes' draw counts (the
|
||||
// second's in the high 16 bits), the second pass's first draw. No draws:
|
||||
// phase 1 drew it, and the test only tells whether it is still in sight.
|
||||
// Then its box's lowest and highest corner around the origin (a section's:
|
||||
// -1 and 17, `SECTION_BOX`).
|
||||
// Then its box's lowest and highest corner around the origin: a section's
|
||||
// padded mesh bounds, or -1 and 17 (`SECTION_BOX`) without mesh bounds.
|
||||
struct Test {
|
||||
data: vec4<u32>,
|
||||
low: vec4<f32>,
|
||||
@@ -53,8 +53,8 @@ fn corner(origin: vec3<f32>, low: vec3<f32>, high: vec3<f32>, c: u32) -> vec3<f3
|
||||
);
|
||||
}
|
||||
|
||||
// Each tested section's or far tile's box, grown by a block (block models
|
||||
// reach one block out of their cell), drawn after phase 1 against its depth
|
||||
// Each tested section's or far tile's geometry box, padded by a block,
|
||||
// drawn after phase 1 against its depth
|
||||
// with back faces culled: a front face fragment that passes the depth test
|
||||
// is the box's nearest point on that pixel in front of phase 1's depth, so
|
||||
// the section may show there. A box failing everywhere would have every
|
||||
|
||||
@@ -33,6 +33,9 @@ struct Uniform {
|
||||
brightness: f32,
|
||||
mode: u32,
|
||||
pad: [u32; 2],
|
||||
hdr_settings: [f32; 4],
|
||||
hdr_shares: [f32; 4],
|
||||
hdr_light: [f32; 4],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
@@ -45,6 +48,8 @@ struct Vertex {
|
||||
|
||||
pub(super) struct CustomSky {
|
||||
pipelines: Vec<wgpu::RenderPipeline>,
|
||||
written_gain: Option<f32>,
|
||||
written_debug: bool,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
sampler: wgpu::Sampler,
|
||||
uniform: wgpu::Buffer,
|
||||
@@ -136,7 +141,10 @@ impl CustomSky {
|
||||
});
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Custom sky shader"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("custom_sky.wgsl").into()),
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
(include_str!("../hdr_light.wgsl").to_owned() + include_str!("custom_sky.wgsl"))
|
||||
.into(),
|
||||
),
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Custom sky layout"),
|
||||
@@ -146,7 +154,11 @@ impl CustomSky {
|
||||
let attributes = wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2, 2 => Float32x4];
|
||||
let pipelines = MODES
|
||||
.iter()
|
||||
.map(|&mode| {
|
||||
.copied()
|
||||
.map(|mode| (mode, false, false))
|
||||
.chain((format == super::LINEAR_FRAME).then_some((Blend::Screen, true, false)))
|
||||
.chain((format == super::LINEAR_FRAME).then_some((Blend::Add, false, true)))
|
||||
.map(|(mode, written_screen, debug_add)| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Custom sky layer"),
|
||||
layout: Some(&pipeline_layout),
|
||||
@@ -174,14 +186,31 @@ impl CustomSky {
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_custom_sky"),
|
||||
compilation_options: Default::default(),
|
||||
entry_point: Some(if debug_add {
|
||||
"fs_custom_sky_screen_light"
|
||||
} else {
|
||||
"fs_custom_sky"
|
||||
}),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &[(
|
||||
"HDR_WRITE_LIGHT",
|
||||
f64::from(u8::from(format == super::LINEAR_FRAME)),
|
||||
)],
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
// The frame's alpha is HDR output's mark (hdr.wgsl): the sky
|
||||
// under the layers keeps its own.
|
||||
blend: Some(wgpu::BlendState {
|
||||
color: blend(mode),
|
||||
color: if written_screen {
|
||||
wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::Constant,
|
||||
..blend(mode)
|
||||
}
|
||||
} else {
|
||||
blend(mode)
|
||||
},
|
||||
alpha: wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::Zero,
|
||||
dst_factor: wgpu::BlendFactor::One,
|
||||
@@ -215,6 +244,8 @@ impl CustomSky {
|
||||
});
|
||||
Self {
|
||||
pipelines,
|
||||
written_gain: None,
|
||||
written_debug: false,
|
||||
layout,
|
||||
sampler,
|
||||
uniform,
|
||||
@@ -295,7 +326,20 @@ impl CustomSky {
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
clip: Mat4,
|
||||
draws: &[Draw],
|
||||
globals: &super::Globals,
|
||||
) {
|
||||
self.written_gain = (globals.hdr_light[3] != 0.0).then(|| {
|
||||
let mark =
|
||||
((1.0 - 0.24 * globals.hdr_light[1]).clamp(0.0, 1.0) * 255.0).round() / 255.0;
|
||||
if globals.hdr_settings[2] > 0.5 && globals.hdr_settings[0] > 1.0 {
|
||||
(1.0 + (globals.hdr_settings[1] - 1.0) * (1.0 - mark) / 0.24).powf(1.0 / 2.2)
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
});
|
||||
self.written_debug = self.written_gain.is_some()
|
||||
&& globals.hdr_settings[3] > 0.5
|
||||
&& globals.hdr_settings[2] > 0.5;
|
||||
self.frame.clear();
|
||||
for draw in draws.iter().take(MAX_DRAWS) {
|
||||
if usize::from(draw.image) >= self.groups.len() {
|
||||
@@ -307,6 +351,9 @@ impl CustomSky {
|
||||
brightness: draw.brightness.clamp(0.0, 1.0),
|
||||
mode: mode as u32,
|
||||
pad: [0; 2],
|
||||
hdr_settings: globals.hdr_settings,
|
||||
hdr_shares: globals.hdr_shares,
|
||||
hdr_light: globals.hdr_light,
|
||||
};
|
||||
let offset = STRIDE * self.frame.len() as u64;
|
||||
uploads.buffer(encoder, &self.uniform, offset, bytemuck::bytes_of(&uniform));
|
||||
@@ -323,7 +370,35 @@ impl CustomSky {
|
||||
pub(super) fn draw(&self, pass: &mut wgpu::RenderPass<'_>) {
|
||||
pass.set_vertex_buffer(0, self.vertices.slice(..));
|
||||
for (i, &(image, mode)) in self.frame.iter().enumerate() {
|
||||
pass.set_pipeline(&self.pipelines[usize::from(mode)]);
|
||||
let screen_debug = usize::from(mode) == 5 && self.written_debug;
|
||||
if screen_debug {
|
||||
// Greyscale debug is not a uniform RGB gain. Attenuate with the original
|
||||
// source first, then add its converted light, without changing the factor.
|
||||
pass.set_pipeline(&self.pipelines[4]);
|
||||
pass.set_bind_group(
|
||||
0,
|
||||
&self.groups[usize::from(image)],
|
||||
&[(STRIDE * i as u64) as u32],
|
||||
);
|
||||
pass.draw(0..36, 0..1);
|
||||
}
|
||||
let index = if screen_debug {
|
||||
10
|
||||
} else if usize::from(mode) == 5 && self.written_gain.is_some() {
|
||||
9
|
||||
} else {
|
||||
usize::from(mode)
|
||||
};
|
||||
if let Some(gain) = self.written_gain {
|
||||
let gain = f64::from(gain);
|
||||
pass.set_blend_constant(wgpu::Color {
|
||||
r: gain,
|
||||
g: gain,
|
||||
b: gain,
|
||||
a: 1.0,
|
||||
});
|
||||
}
|
||||
pass.set_pipeline(&self.pipelines[index]);
|
||||
pass.set_bind_group(
|
||||
0,
|
||||
&self.groups[usize::from(image)],
|
||||
@@ -394,7 +469,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn the_shader_validates() {
|
||||
let module = naga::front::wgsl::parse_str(include_str!("custom_sky.wgsl")).unwrap();
|
||||
let module = naga::front::wgsl::parse_str(
|
||||
&(include_str!("../hdr_light.wgsl").to_owned() + include_str!("custom_sky.wgsl")),
|
||||
)
|
||||
.unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
@@ -413,4 +491,243 @@ mod tests {
|
||||
assert_eq!(v.position.iter().filter(|c| c.abs() == 1.0).count(), 3);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn written_custom_sky_modes_keep_factors_and_scale_only_screen_source() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let (device, queue) =
|
||||
pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let source = include_str!("../hdr_light.wgsl").to_owned()
|
||||
+ include_str!("custom_sky.wgsl")
|
||||
+ r#"
|
||||
@vertex fn test_vertex(@builtin(vertex_index) i: u32) -> Out {
|
||||
let p = vec2(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
return Out(vec4(p * 2.0 - 1.0, 0.0, 1.0), vec2(0.5), vec4(0.0, 0.0, 1.0, 1.0));
|
||||
}
|
||||
"#;
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Wgsl(source.into()),
|
||||
});
|
||||
let sky = CustomSky::new(&device, wgpu::TextureFormat::Rgba16Float);
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: None,
|
||||
bind_group_layouts: &[Some(&sky.layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let image = device.create_texture_with_data(
|
||||
&queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
&[204, 102, 51, 128],
|
||||
);
|
||||
let image = image.create_view(&Default::default());
|
||||
let sampler = device.create_sampler(&Default::default());
|
||||
let target = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba16Float,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = target.create_view(&Default::default());
|
||||
// Independent scalar expectation: a sky mark of 206/255 gives this gamma gain.
|
||||
let gain = (1.0f32 + 3.0 * (1.0 - 206.0 / 255.0) / 0.24).powf(1.0 / 2.2);
|
||||
for (mode_index, mode, debug) in MODES
|
||||
.iter()
|
||||
.copied()
|
||||
.enumerate()
|
||||
.map(|(i, m)| (i, m, false))
|
||||
.chain([(5, Blend::Screen, true)])
|
||||
{
|
||||
let uniform = Uniform {
|
||||
clip_from_sky: Mat4::IDENTITY.to_cols_array_2d(),
|
||||
brightness: 0.7,
|
||||
mode: mode_index as u32,
|
||||
pad: [0; 2],
|
||||
hdr_settings: [5.0, 4.0, 1.0, f32::from(u8::from(debug))],
|
||||
hdr_shares: [0.2, 0.5, 0.6, 1.0],
|
||||
hdr_light: [0.8, 0.8, 1.0, 1.0],
|
||||
};
|
||||
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytemuck::bytes_of(&uniform),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let color = if debug {
|
||||
blend(Blend::Add)
|
||||
} else if mode == Blend::Screen {
|
||||
wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::Constant,
|
||||
..blend(mode)
|
||||
}
|
||||
} else {
|
||||
blend(mode)
|
||||
};
|
||||
let pipeline_for = |entry, color| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: None,
|
||||
layout: Some(&layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("test_vertex"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some(entry),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &[("HDR_WRITE_LIGHT", 1.0)],
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: wgpu::TextureFormat::Rgba16Float,
|
||||
blend: Some(wgpu::BlendState {
|
||||
color,
|
||||
alpha: wgpu::BlendComponent::REPLACE,
|
||||
}),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let pipeline = pipeline_for(
|
||||
if debug {
|
||||
"fs_custom_sky_screen_light"
|
||||
} else {
|
||||
"fs_custom_sky"
|
||||
},
|
||||
color,
|
||||
);
|
||||
let attenuate = debug.then(|| pipeline_for("fs_custom_sky", blend(Blend::Burn)));
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &sky.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: buffer.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(&image),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::Sampler(&sampler),
|
||||
},
|
||||
],
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color {
|
||||
r: 2.0,
|
||||
g: 2.0,
|
||||
b: 2.0,
|
||||
a: 1.0,
|
||||
}),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
let g = f64::from(gain);
|
||||
pass.set_blend_constant(wgpu::Color {
|
||||
r: g,
|
||||
g,
|
||||
b: g,
|
||||
a: 1.0,
|
||||
});
|
||||
pass.set_bind_group(0, &group, &[0]);
|
||||
if let Some(attenuate) = &attenuate {
|
||||
pass.set_pipeline(attenuate);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
let readback = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 256,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
target.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &readback,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
target.size(),
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
readback.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let data = readback.get_mapped_range(..).unwrap();
|
||||
let got = half::f16::from_le_bytes([data[0], data[1]]).to_f32();
|
||||
let (d, t, k) = (2.0, 0.8, 128.0 / 255.0 * 0.7);
|
||||
let expected = if debug {
|
||||
let grey = (0.2126f32 * 0.8f32.powf(2.2)
|
||||
+ 0.7152 * 0.4f32.powf(2.2)
|
||||
+ 0.0722 * 0.2f32.powf(2.2))
|
||||
.powf(1.0 / 2.2);
|
||||
gain * grey * k + d * (1.0 - t * k)
|
||||
} else {
|
||||
match mode {
|
||||
Blend::Add => d + gain * t * k,
|
||||
Blend::Subtract => d - gain * t * k,
|
||||
Blend::Multiply => d * (1.0 + (t - 1.0) * k),
|
||||
Blend::Dodge => d + d * t * k,
|
||||
Blend::Burn => d * (1.0 - t * k),
|
||||
Blend::Screen => gain * t * k + d * (1.0 - t * k),
|
||||
Blend::Replace => gain * t,
|
||||
Blend::Overlay => 2.0 * d * (0.5 + (t - 0.5) * k),
|
||||
Blend::Alpha => gain * t * k + d * (1.0 - k),
|
||||
}
|
||||
};
|
||||
assert!(
|
||||
(got - expected).abs() < 0.004,
|
||||
"{mode:?} debug {debug}: {got} expected {expected}"
|
||||
);
|
||||
}
|
||||
eprintln!("HDR custom sky GPU ran all nine modes and debug Screen over above-white light");
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,10 @@ struct Layer {
|
||||
brightness: f32,
|
||||
// horizon_optifine::sky::Blend, in gfx/custom_sky.rs's MODES order.
|
||||
mode: u32,
|
||||
pad: vec2<u32>,
|
||||
hdr_settings: vec4<f32>,
|
||||
hdr_shares: vec4<f32>,
|
||||
hdr_light: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> layer: Layer;
|
||||
@group(0) @binding(1) var sky_texture: texture_2d<f32>;
|
||||
@@ -42,20 +46,34 @@ const REPLACE: u32 = 6u;
|
||||
const OVERLAY: u32 = 7u;
|
||||
const ALPHA: u32 = 8u;
|
||||
|
||||
@fragment fn fs_custom_sky(in: Out) -> @location(0) vec4<f32> {
|
||||
fn custom_sky_color(in: Out) -> vec4<f32> {
|
||||
let half = 0.5 / vec2<f32>(textureDimensions(sky_texture));
|
||||
let uv = clamp(in.uv, in.cell.xy + half, in.cell.zw - half);
|
||||
let t = textureSample(sky_texture, sky_sampler, uv);
|
||||
let k = t.a * layer.brightness;
|
||||
var color = t.rgb;
|
||||
if HDR_WRITE_LIGHT && layer.hdr_light.w != 0.0 &&
|
||||
(layer.mode == ADD || layer.mode == SUBTRACT || layer.mode == REPLACE || layer.mode == ALPHA) {
|
||||
color = hdr_written(color, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_shares);
|
||||
}
|
||||
switch layer.mode {
|
||||
// dst * src: white leaves the sky alone.
|
||||
case MULTIPLY: { return vec4(mix(vec3(1.0), t.rgb, k), 1.0); }
|
||||
// 2 * dst * src: grey leaves it alone.
|
||||
case OVERLAY: { return vec4(mix(vec3(0.5), t.rgb, k), 1.0); }
|
||||
case REPLACE: { return vec4(t.rgb, 1.0); }
|
||||
case REPLACE: { return vec4(color, 1.0); }
|
||||
// mix(dst, src, alpha).
|
||||
case ALPHA: { return vec4(t.rgb, k); }
|
||||
case ALPHA: { return vec4(color, k); }
|
||||
// add, subtract, dodge, burn, screen: the layer premultiplied, black leaves it alone.
|
||||
default: { return vec4(t.rgb * k, 1.0); }
|
||||
default: { return vec4(color * k, 1.0); }
|
||||
}
|
||||
}
|
||||
|
||||
@fragment fn fs_custom_sky(in: Out) -> @location(0) vec4<f32> {
|
||||
return custom_sky_color(in);
|
||||
}
|
||||
|
||||
@fragment fn fs_custom_sky_screen_light(in: Out) -> @location(0) vec4<f32> {
|
||||
let color = custom_sky_color(in);
|
||||
return vec4(hdr_written(color.rgb, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_shares), color.a);
|
||||
}
|
||||
@@ -106,6 +106,8 @@ pub struct Flat {
|
||||
phase2_calls: wgpu::Buffer,
|
||||
phase2_call_capacity: u64,
|
||||
phase2_call_data: Vec<[u32; 5]>,
|
||||
/// GPU culling's phase 2 uses indirect draws; flat draws without culling do not.
|
||||
indirect_execution: bool,
|
||||
/// The copy of phase 2's draws, and its ranges.
|
||||
phase2_group: Option<(wgpu::BindGroup, u32)>,
|
||||
/// The layout of a page's bind group 1 (shader packs' terrain pipelines share it).
|
||||
@@ -365,6 +367,7 @@ impl Flat {
|
||||
terrain_layout: &wgpu::BindGroupLayout,
|
||||
page_vertices: u64,
|
||||
min_sections: usize,
|
||||
indirect_execution: bool,
|
||||
pipeline: impl Fn(
|
||||
&wgpu::PipelineLayout,
|
||||
&str,
|
||||
@@ -489,10 +492,16 @@ impl Flat {
|
||||
device,
|
||||
"Flat section draw calls, phase 2",
|
||||
20,
|
||||
wgpu::BufferUsages::INDIRECT | storage_usage,
|
||||
storage_usage
|
||||
| if indirect_execution {
|
||||
wgpu::BufferUsages::INDIRECT
|
||||
} else {
|
||||
wgpu::BufferUsages::empty()
|
||||
},
|
||||
),
|
||||
phase2_call_capacity: 1,
|
||||
phase2_call_data: Vec::new(),
|
||||
indirect_execution,
|
||||
phase2_group: None,
|
||||
draw_layout,
|
||||
blocks_per_page: page_vertices.div_ceil(GRAIN),
|
||||
@@ -645,6 +654,7 @@ impl Flat {
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) -> bool {
|
||||
self.phase2_group = None;
|
||||
let phase2 = phase2.filter(|_| self.indirect_execution);
|
||||
let phase1_draws = if phase2.is_some() {
|
||||
phase1_draws
|
||||
} else {
|
||||
@@ -753,7 +763,12 @@ impl Flat {
|
||||
device,
|
||||
"Flat section draw calls, phase 2",
|
||||
self.phase2_call_capacity * 20,
|
||||
wgpu::BufferUsages::INDIRECT | wgpu::BufferUsages::STORAGE,
|
||||
wgpu::BufferUsages::STORAGE
|
||||
| if self.indirect_execution {
|
||||
wgpu::BufferUsages::INDIRECT
|
||||
} else {
|
||||
wgpu::BufferUsages::empty()
|
||||
},
|
||||
);
|
||||
}
|
||||
if phase2_calls > 0 {
|
||||
|
||||
@@ -72,9 +72,33 @@ fn named(name: Option<&str>) -> Option<Output> {
|
||||
struct Targets {
|
||||
size: [u32; 2],
|
||||
frame: wgpu::Texture,
|
||||
/// The world as `keep_world` kept it; on the layered frame, the frame as `resolve` (the
|
||||
/// world and the hand marked it) or `tonemap` found it.
|
||||
world: wgpu::Texture,
|
||||
coverage: Option<wgpu::Texture>,
|
||||
/// The layered frame's HUD target: what the HUD draws on.
|
||||
hud: Option<wgpu::Texture>,
|
||||
group: wgpu::BindGroup,
|
||||
/// The layered frame's passes' bindings: `resolve` (and `tonemap`), `keep_world` and
|
||||
/// `composite`.
|
||||
passes: Option<[wgpu::BindGroup; 3]>,
|
||||
}
|
||||
|
||||
/// The float frame with HUD coverage (`hud_coverage::supported`, docs/design/hdr-linear-light.md
|
||||
/// "The HUD and menus"): the default world draws write light directly; marked fallback frames
|
||||
/// turn their marks into light after the world and the hand (`resolve`); the tone curve eases it into the peak after the post effects (`tonemap`); the
|
||||
/// camera overlays draw on that light; the HUD draws on a target of its own,
|
||||
/// the world clipped to white (`keep_world`); and the last pass composites the HUD over the
|
||||
/// world's light by its coverage (`fs_hdr_layered`), as the menu blur does before it blurs
|
||||
/// (`composite`).
|
||||
struct Layered {
|
||||
resolve: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
|
||||
/// Reads what `resolve` reads.
|
||||
tonemap: wgpu::RenderPipeline,
|
||||
keep: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
|
||||
composite: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
|
||||
/// What `resolve` and `composite` read, written before they run (`begin`).
|
||||
params: wgpu::Buffer,
|
||||
}
|
||||
|
||||
/// How one tier of light sources (`mesher::hdr_tier`) shines: `share` of the headroom for
|
||||
@@ -156,8 +180,7 @@ pub struct Hdr {
|
||||
targets: Option<Targets>,
|
||||
/// `HORIZON_HDR_DEBUG=1`: grey what isn't expanded, tint what is magenta.
|
||||
debug: bool,
|
||||
coverage: bool,
|
||||
coverage_debug: bool,
|
||||
layered: Option<Layered>,
|
||||
/// Paper white and peak brightness, in nits.
|
||||
pub paper_white: f32,
|
||||
pub peak: f32,
|
||||
@@ -178,6 +201,10 @@ pub struct Hdr {
|
||||
pub calibration: Option<Calibration>,
|
||||
}
|
||||
|
||||
pub(super) fn shader() -> String {
|
||||
include_str!("../hdr_light.wgsl").to_owned() + include_str!("../hdr.wgsl")
|
||||
}
|
||||
|
||||
impl Hdr {
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
@@ -199,21 +226,23 @@ impl Hdr {
|
||||
debug: Option<&str>,
|
||||
) -> Self {
|
||||
let coverage_debug = coverage && debug == Some("coverage");
|
||||
let mut source = include_str!("../hdr.wgsl").to_owned();
|
||||
// The layered frame's last pass, else today's.
|
||||
let entry_point = if coverage { "fs_hdr_layered" } else { "fs_hdr" };
|
||||
let mut source = shader();
|
||||
if coverage_debug {
|
||||
// The debug view wraps the last pass as it is, so the picture under the tint is
|
||||
// exactly the one without it.
|
||||
let entry =
|
||||
"@fragment fn fs_hdr(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32>";
|
||||
assert!(
|
||||
source.contains(entry),
|
||||
"hdr.wgsl's fs_hdr changed its signature"
|
||||
let entry = format!(
|
||||
"@fragment fn {entry_point}(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32>"
|
||||
);
|
||||
source = source.replace(entry, "fn picture_hdr(pos: vec4<f32>) -> vec4<f32>");
|
||||
assert!(
|
||||
source.contains(&entry),
|
||||
"hdr.wgsl's {entry_point} changed its signature"
|
||||
);
|
||||
source = source.replace(&entry, "fn picture_hdr(pos: vec4<f32>) -> vec4<f32>");
|
||||
source.push_str(&entry);
|
||||
source.push_str(
|
||||
r#"
|
||||
@group(0) @binding(4) var coverage: texture_2d<f32>;
|
||||
@fragment fn fs_hdr(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
r#" {
|
||||
let picture = picture_hdr(pos);
|
||||
let c = textureLoad(coverage, vec2<i32>(pos.xy), 0).r;
|
||||
// Magenta at paper white, in the output's own encoding.
|
||||
@@ -228,46 +257,45 @@ impl Hdr {
|
||||
label: Some("Horizon HDR"),
|
||||
source: wgpu::ShaderSource::Wgsl(source.into()),
|
||||
});
|
||||
let texture = |binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
};
|
||||
let mut entries = vec![
|
||||
texture(0),
|
||||
texture(1),
|
||||
texture(3),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
// hdr.wgsl's bindings: the textures at `textures`, and its `Params` at 2 if `params`.
|
||||
let layout = |label, textures: &[u32], params: bool| {
|
||||
let texture = |binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
];
|
||||
if coverage_debug {
|
||||
entries.push(texture(4));
|
||||
}
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Horizon HDR bindings"),
|
||||
entries: &entries,
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Horizon HDR"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let pipeline_for = |format| {
|
||||
};
|
||||
let mut entries: Vec<_> = textures.iter().map(|&b| texture(b)).collect();
|
||||
if params {
|
||||
entries.push(wgpu::BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
});
|
||||
}
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some(label),
|
||||
entries: &entries,
|
||||
})
|
||||
};
|
||||
// One full-screen pass of hdr.wgsl's `entry` into `format`, reading `layout`.
|
||||
let pass = |label, layout: &wgpu::BindGroupLayout, entry, format| {
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some(label),
|
||||
bind_group_layouts: &[Some(layout)],
|
||||
..Default::default()
|
||||
});
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Horizon HDR"),
|
||||
label: Some(label),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
@@ -280,7 +308,7 @@ impl Hdr {
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_hdr"),
|
||||
entry_point: Some(entry),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
@@ -292,6 +320,32 @@ impl Hdr {
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let layout_bindings: &[u32] = if coverage { &[0, 3, 4, 5] } else { &[0, 1, 3] };
|
||||
let display = layout("Horizon HDR bindings", layout_bindings, true);
|
||||
let pipeline_for = |format| pass("Horizon HDR", &display, entry_point, format);
|
||||
let params = || {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon HDR parameters"),
|
||||
size: 64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
};
|
||||
let layered = coverage.then(|| {
|
||||
let with = |label, textures: &[u32], params, entry| {
|
||||
let layout = layout(label, textures, params);
|
||||
let pipeline = pass(label, &layout, entry, format);
|
||||
(layout, pipeline)
|
||||
};
|
||||
let resolve = with("Horizon HDR marks to light", &[1], true, "fs_resolve");
|
||||
Layered {
|
||||
tonemap: pass("Horizon HDR tone curve", &resolve.0, "fs_tonemap", format),
|
||||
resolve,
|
||||
keep: with("Horizon HDR HUD backdrop", &[0], false, "fs_keep"),
|
||||
composite: with("Horizon HDR composite", &[0, 4, 5], true, "fs_composite"),
|
||||
params: params(),
|
||||
}
|
||||
});
|
||||
let alpha_pass = |label, blend| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
@@ -334,29 +388,20 @@ impl Hdr {
|
||||
alpha: scale_alpha,
|
||||
}),
|
||||
);
|
||||
let params = || {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon HDR parameters"),
|
||||
size: 64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
};
|
||||
Self {
|
||||
logo: logo_mask(device, queue),
|
||||
layout,
|
||||
pipeline: pipeline_for(output.format()),
|
||||
capture_pipeline: pipeline_for(Output::Pq.format()),
|
||||
layout: display,
|
||||
clear_alpha,
|
||||
hand_mark,
|
||||
params: params(),
|
||||
capture_pipeline: pipeline_for(Output::Pq.format()),
|
||||
capture_params: params(),
|
||||
output,
|
||||
format,
|
||||
targets: None,
|
||||
debug: debug == Some("1"),
|
||||
coverage,
|
||||
coverage_debug,
|
||||
layered,
|
||||
paper_white: 200.0,
|
||||
peak: 1000.0,
|
||||
black: 0.0,
|
||||
@@ -398,25 +443,175 @@ impl Hdr {
|
||||
self.format,
|
||||
wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
);
|
||||
let coverage = self.coverage.then(|| {
|
||||
let layered = self.layered.is_some();
|
||||
let coverage = layered.then(|| {
|
||||
texture(
|
||||
"HUD coverage",
|
||||
super::hud_coverage::FORMAT,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
)
|
||||
});
|
||||
let group = self.group(device, &frame, &world, coverage.as_ref(), &self.params);
|
||||
// In the frame's format, so the HUD's pipelines draw on it as on the frame with HDR
|
||||
// off; the last pass clips it at white.
|
||||
let hud = layered.then(|| {
|
||||
texture(
|
||||
"HUD",
|
||||
self.format,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
)
|
||||
});
|
||||
let group = self.group(
|
||||
device,
|
||||
&frame,
|
||||
&world,
|
||||
coverage.as_ref(),
|
||||
hud.as_ref(),
|
||||
&self.params,
|
||||
);
|
||||
let passes = self
|
||||
.layered
|
||||
.as_ref()
|
||||
.zip(coverage.as_ref().zip(hud.as_ref()))
|
||||
.map(|(l, (coverage, hud))| {
|
||||
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
|
||||
let (frame, world, coverage, hud) =
|
||||
(view(&frame), view(&world), view(coverage), view(hud));
|
||||
let entry = |binding, view| wgpu::BindGroupEntry {
|
||||
binding,
|
||||
resource: wgpu::BindingResource::TextureView(view),
|
||||
};
|
||||
let params = wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: l.params.as_entire_binding(),
|
||||
};
|
||||
let group = |layout, entries: &[wgpu::BindGroupEntry]| {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Horizon HDR layered bindings"),
|
||||
layout,
|
||||
entries,
|
||||
})
|
||||
};
|
||||
[
|
||||
group(&l.resolve.0, &[entry(1, &world), params.clone()]),
|
||||
group(&l.keep.0, &[entry(0, &frame)]),
|
||||
group(
|
||||
&l.composite.0,
|
||||
&[
|
||||
entry(0, &frame),
|
||||
params,
|
||||
entry(4, &coverage),
|
||||
entry(5, &hud),
|
||||
],
|
||||
),
|
||||
]
|
||||
});
|
||||
self.targets = Some(Targets {
|
||||
size,
|
||||
frame,
|
||||
world,
|
||||
coverage,
|
||||
hud,
|
||||
group,
|
||||
passes,
|
||||
});
|
||||
}
|
||||
self.targets.as_ref().unwrap().frame.clone()
|
||||
}
|
||||
|
||||
/// The float frame with HUD coverage (`Layered`).
|
||||
pub fn layered(&self) -> bool {
|
||||
self.layered.is_some()
|
||||
}
|
||||
|
||||
/// The layered frame's HUD target, which the HUD and menus draw on instead of the frame
|
||||
/// once `keep_world` made it, and which an ordinary screenshot shows.
|
||||
pub fn hud(&self) -> Option<&wgpu::Texture> {
|
||||
self.targets.as_ref()?.hud.as_ref()
|
||||
}
|
||||
|
||||
/// On the layered frame, before `resolve` and `composite`: what they read, for a frame that
|
||||
/// drew a world (`world`) or not.
|
||||
pub fn begin(&self, queue: &wgpu::Queue, world: bool) {
|
||||
if let Some(l) = &self.layered {
|
||||
queue.write_buffer(
|
||||
&l.params,
|
||||
0,
|
||||
bytemuck::cast_slice(&self.params(world, false)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// On the layered frame, after the world and the hand drew and marked it: the marks
|
||||
/// become light in the frame (`fs_resolve`).
|
||||
pub fn resolve(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
self.redraw(encoder, |l| &l.resolve.1, "HDR marks to light", timing);
|
||||
}
|
||||
|
||||
/// On the layered frame, after the post effects and before the camera overlays: the tone
|
||||
/// curve eases the light into the peak (`fs_tonemap`).
|
||||
pub fn tonemap(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
self.redraw(encoder, |l| &l.tonemap, "HDR tone curve", timing);
|
||||
}
|
||||
|
||||
/// The layered frame drawn again by `pipeline`, which reads it from a copy in the world
|
||||
/// target.
|
||||
fn redraw(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
pipeline: impl Fn(&Layered) -> &wgpu::RenderPipeline,
|
||||
label: &'static str,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let (Some(l), Some(t)) = (&self.layered, &self.targets) else {
|
||||
return;
|
||||
};
|
||||
let Some([group, ..]) = &t.passes else {
|
||||
return;
|
||||
};
|
||||
encoder.copy_texture_to_texture(
|
||||
t.frame.as_image_copy(),
|
||||
t.world.as_image_copy(),
|
||||
t.frame.size(),
|
||||
);
|
||||
let view = t.frame.create_view(&Default::default());
|
||||
Self::draw(encoder, pipeline(l), group, &view, label, timing);
|
||||
}
|
||||
|
||||
/// On the layered frame: the picture so far (`fs_composite`), as the frame stores light,
|
||||
/// into `target`, for the menu blur to blur it and make it the world (`keep_world` after).
|
||||
pub fn composite(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
target: &wgpu::TextureView,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
if let (
|
||||
Some(l),
|
||||
Some(Targets {
|
||||
passes: Some([_, _, group]),
|
||||
..
|
||||
}),
|
||||
) = (&self.layered, &self.targets)
|
||||
{
|
||||
Self::draw(
|
||||
encoder,
|
||||
&l.composite.1,
|
||||
group,
|
||||
target,
|
||||
"HDR composite",
|
||||
timing,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn coverage_view(&self) -> Option<wgpu::TextureView> {
|
||||
Some(
|
||||
self.targets
|
||||
@@ -440,6 +635,9 @@ impl Hdr {
|
||||
/// Keeps the world as drawn so far, before the hand and HUD cover it, then zeroes the
|
||||
/// frame's alpha: the world's marks live on in the copy, and whatever draws from here
|
||||
/// on raises the alpha where it covers the world.
|
||||
///
|
||||
/// On the layered frame the frame keeps the world, the hand and the camera overlays, as
|
||||
/// light, and this makes the HUD's backdrop from it: clipped to white, alpha 0 (`fs_keep`).
|
||||
pub fn keep_world(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
@@ -448,6 +646,11 @@ impl Hdr {
|
||||
let Some(t) = &self.targets else {
|
||||
return;
|
||||
};
|
||||
if let (Some(l), Some(hud), Some([_, group, _])) = (&self.layered, &t.hud, &t.passes) {
|
||||
let view = hud.create_view(&Default::default());
|
||||
Self::draw(encoder, &l.keep.1, group, &view, "HDR HUD backdrop", timing);
|
||||
return;
|
||||
}
|
||||
encoder.copy_texture_to_texture(
|
||||
t.frame.as_image_copy(),
|
||||
t.world.as_image_copy(),
|
||||
@@ -475,13 +678,14 @@ impl Hdr {
|
||||
|
||||
/// After the hand: its pixels' alpha goes down to 1/255, below anything the HUD and
|
||||
/// menus leave, so HDR output tells the hand (the world's brightness, never lifted)
|
||||
/// from the UI (UI Brightness).
|
||||
/// from the UI (UI Brightness). The layered frame keeps the hand before `keep_world`,
|
||||
/// so it needs no mark there.
|
||||
pub fn mark_hand(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let Some(t) = &self.targets else {
|
||||
let Some(t) = self.targets.as_ref().filter(|_| self.layered.is_none()) else {
|
||||
return;
|
||||
};
|
||||
let view = t.frame.create_view(&Default::default());
|
||||
@@ -512,9 +716,10 @@ impl Hdr {
|
||||
}
|
||||
|
||||
/// The world as `keep_world` kept it: where the hand's marks go (its alpha, which HDR output
|
||||
/// reads for it too), and what tints the world rather than covering it.
|
||||
/// reads for it too), and what tints the world rather than covering it. None on the
|
||||
/// layered frame, where both draw on the frame before `keep_world`.
|
||||
pub fn hand_light_view(&self) -> Option<wgpu::TextureView> {
|
||||
let t = self.targets.as_ref()?;
|
||||
let t = self.targets.as_ref().filter(|_| self.layered.is_none())?;
|
||||
Some(t.world.create_view(&Default::default()))
|
||||
}
|
||||
|
||||
@@ -540,7 +745,15 @@ impl Hdr {
|
||||
0,
|
||||
bytemuck::cast_slice(&self.params(world, self.output == Output::Pq)),
|
||||
);
|
||||
Self::draw(encoder, &self.pipeline, &t.group, target, timing);
|
||||
let view = target.create_view(&Default::default());
|
||||
Self::draw(
|
||||
encoder,
|
||||
&self.pipeline,
|
||||
&t.group,
|
||||
&view,
|
||||
"HDR output",
|
||||
timing,
|
||||
);
|
||||
}
|
||||
|
||||
/// The frame `present` writes, as HDR10 (BT.2020, PQ, 10 bits a channel) whatever
|
||||
@@ -568,6 +781,7 @@ impl Hdr {
|
||||
&t.frame,
|
||||
&t.world,
|
||||
t.coverage.as_ref(),
|
||||
t.hud.as_ref(),
|
||||
&self.capture_params,
|
||||
);
|
||||
queue.write_buffer(
|
||||
@@ -575,7 +789,15 @@ impl Hdr {
|
||||
0,
|
||||
bytemuck::cast_slice(&self.params(world, true)),
|
||||
);
|
||||
Self::draw(encoder, &self.capture_pipeline, &group, &texture, None);
|
||||
let view = texture.create_view(&Default::default());
|
||||
Self::draw(
|
||||
encoder,
|
||||
&self.capture_pipeline,
|
||||
&group,
|
||||
&view,
|
||||
"HDR output",
|
||||
None,
|
||||
);
|
||||
Some(texture)
|
||||
}
|
||||
|
||||
@@ -585,26 +807,27 @@ impl Hdr {
|
||||
frame: &wgpu::Texture,
|
||||
world: &wgpu::Texture,
|
||||
coverage: Option<&wgpu::Texture>,
|
||||
hud: Option<&wgpu::Texture>,
|
||||
params: &wgpu::Buffer,
|
||||
) -> wgpu::BindGroup {
|
||||
let frame = frame.create_view(&Default::default());
|
||||
let world = world.create_view(&Default::default());
|
||||
let coverage = coverage.map(|t| t.create_view(&Default::default()));
|
||||
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
|
||||
let (frame, world) = (view(frame), view(world));
|
||||
let layered = coverage.zip(hud).map(|(c, h)| (view(c), view(h)));
|
||||
let texture = |binding, view| wgpu::BindGroupEntry {
|
||||
binding,
|
||||
resource: wgpu::BindingResource::TextureView(view),
|
||||
};
|
||||
let mut entries = vec![
|
||||
texture(0, &frame),
|
||||
texture(1, &world),
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: params.as_entire_binding(),
|
||||
},
|
||||
texture(3, &self.logo),
|
||||
];
|
||||
if self.coverage_debug {
|
||||
entries.push(texture(4, coverage.as_ref().unwrap()));
|
||||
match &layered {
|
||||
Some((coverage, hud)) => entries.extend([texture(4, coverage), texture(5, hud)]),
|
||||
None => entries.push(texture(1, &world)),
|
||||
}
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Horizon HDR bindings"),
|
||||
@@ -613,6 +836,11 @@ impl Hdr {
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn draw_params(&self, world: bool) -> ([f32; 4], [f32; 4]) {
|
||||
let p = self.params(world, false);
|
||||
([p[1], p[7], p[2], p[3]], p[8..12].try_into().unwrap())
|
||||
}
|
||||
|
||||
/// hdr.wgsl's `Params`.
|
||||
fn params(&self, world: bool, pq: bool) -> [f32; 16] {
|
||||
let paper = self.paper_white.max(1.0);
|
||||
@@ -641,16 +869,17 @@ impl Hdr {
|
||||
params
|
||||
}
|
||||
|
||||
/// One full-screen pass of `pipeline` into `view`.
|
||||
fn draw(
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
pipeline: &wgpu::RenderPipeline,
|
||||
group: &wgpu::BindGroup,
|
||||
target: &wgpu::Texture,
|
||||
view: &wgpu::TextureView,
|
||||
label: &'static str,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let view = target.create_view(&Default::default());
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
@@ -659,8 +888,8 @@ impl Hdr {
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon HDR"),
|
||||
timestamp_writes: timing.and_then(|t| t.writes("HDR output")),
|
||||
label: Some(label),
|
||||
timestamp_writes: timing.and_then(|t| t.writes(label)),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
@@ -723,7 +952,100 @@ mod tests {
|
||||
use wgpu::{SurfaceColorSpaces as S, TextureFormat as F};
|
||||
|
||||
#[test]
|
||||
fn coverage_target_resizes_resets_and_is_sampled_only_by_the_debug_view() {
|
||||
fn shared_light_matches_the_original_for_every_mark() {
|
||||
use wgpu::util::DeviceExt;
|
||||
let Some((device, queue)) = gpu() else {
|
||||
eprintln!("HDR equivalence GPU unavailable");
|
||||
return;
|
||||
};
|
||||
// All 8-bit marks, both easing policies, non-grey and over-white colours, look,
|
||||
// debug, headroom and tier shares; half-float neighbours straddle mark boundaries.
|
||||
let source = include_str!("../hdr_light.wgsl").to_owned()
|
||||
+ r#"
|
||||
struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: vec4<f32> }
|
||||
var<private> params: ReferenceParams;
|
||||
@group(0) @binding(0) var<storage, read_write> errors: array<u32>;
|
||||
"# + include_str!("hdr_light_reference.txt")
|
||||
+ r#"
|
||||
@compute @workgroup_size(64) fn check(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||
let i = id.x;
|
||||
let mark = f32(i % 256u) / 255.0;
|
||||
let mode = i / 256u;
|
||||
let eased = (mode & 1u) != 0u;
|
||||
let shows = (mode & 2u) != 0u;
|
||||
params = ReferenceParams(select(1.0, 1000.0 / 203.0, (mode & 4u) != 0u),
|
||||
select(1.0, 4.0, (mode & 8u) != 0u), select(0.0, 1.0, (mode & 16u) != 0u),
|
||||
select(0.0, 1.0, (mode & 32u) != 0u), vec4(0.2, 0.5, 0.6, 1.0));
|
||||
let color = select(vec3(0.9, 0.4, 0.02), vec3(1.4, 0.8, 0.2), (mode & 64u) != 0u);
|
||||
let expected = reference_lit(color, mark, shows, eased);
|
||||
let actual = hdr_mark_light(color, mark, shows, eased,
|
||||
vec4(params.peak, params.scene, params.world, params.debug), params.share);
|
||||
errors[i] = select(0u, 1u, any(abs(expected.light - actual.light) > vec3(1e-6)) || expected.lifted != actual.lifted);
|
||||
let boundary = (f32(i % 255u) + 0.5) / 255.0;
|
||||
for (var step = -1; step <= 1; step++) {
|
||||
let stored = unpack2x16float(pack2x16float(vec2(boundary + f32(step) / 65536.0))).x;
|
||||
let quantized = round(saturate(stored) * 255.0) / 255.0;
|
||||
let want = pow(reference_lit(color, quantized, params.world > 0.5 && params.peak > 1.0, false).light, vec3(1.0 / 2.2));
|
||||
let got = hdr_written(color, stored, vec4(params.peak, params.scene, params.world, params.debug), params.share);
|
||||
errors[i] |= select(0u, 2u, any(abs(want - got) > vec3(1e-6)));
|
||||
}
|
||||
}
|
||||
"#;
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("shared HDR equivalence"),
|
||||
source: wgpu::ShaderSource::Wgsl(source.into()),
|
||||
});
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
module: &shader,
|
||||
entry_point: Some("check"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let count = 256 * 128;
|
||||
let output = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: &vec![0; count * 4],
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
|
||||
});
|
||||
let readback = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: (count * 4) as u64,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: output.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_compute_pass(&Default::default());
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.dispatch_workgroups(count as u32 / 64, 1, 1);
|
||||
}
|
||||
encoder.copy_buffer_to_buffer(&output, 0, &readback, 0, (count * 4) as u64);
|
||||
queue.submit([encoder.finish()]);
|
||||
readback.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let data = readback.get_mapped_range(..).unwrap();
|
||||
for (i, bytes) in data.as_chunks::<4>().0.iter().enumerate() {
|
||||
assert_eq!(u32::from_le_bytes(*bytes), 0, "case {i}");
|
||||
}
|
||||
eprintln!(
|
||||
"HDR equivalence GPU ran {count} cases and {} half-float boundary cases",
|
||||
count * 3
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coverage_target_resizes_resets_and_shows_in_the_debug_view() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
@@ -808,14 +1130,278 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn gpu() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
/// Clears all of `texture` to `color`.
|
||||
pub(super) fn fill(
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
texture: &wgpu::Texture,
|
||||
color: [f64; 4],
|
||||
) {
|
||||
let view = texture.create_view(&Default::default());
|
||||
let mut attachment = super::super::hud_coverage::attachment(&view);
|
||||
let [r, g, b, a] = color;
|
||||
attachment.ops.load = wgpu::LoadOp::Clear(wgpu::Color { r, g, b, a });
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
color_attachments: &[Some(attachment)],
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
/// The first texel of `texture` once `encoder` ran: four half floats, or 10-bit PQ codes
|
||||
/// for HDR10.
|
||||
pub(super) fn texel(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
mut encoder: wgpu::CommandEncoder,
|
||||
texture: &wgpu::Texture,
|
||||
) -> [f32; 4] {
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 256,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
texture.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let bytes = buffer.get_mapped_range(..).unwrap();
|
||||
if texture.format() == Output::Pq.format() {
|
||||
let pixel = u32::from_le_bytes(bytes[..4].try_into().unwrap());
|
||||
return [0, 10, 20, 30].map(|shift| ((pixel >> shift) & 0x3ff) as f32);
|
||||
}
|
||||
std::array::from_fn(|i| half::f16::from_le_bytes([bytes[2 * i], bytes[2 * i + 1]]).to_f32())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_layered_frame_shows_the_world_as_the_last_pass_lifted_it() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
return;
|
||||
};
|
||||
// What the last pass shows of a world pixel nothing covers, from today's frame
|
||||
// (`layered` false) or the layered one, whose marks turn into light before it, then
|
||||
// pass the tone curve.
|
||||
let shown = |layered: bool, world: [f64; 4]| {
|
||||
let mut hdr =
|
||||
Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, layered, None);
|
||||
let frame = hdr.frame(&device, [1, 1]);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
fill(&mut encoder, &frame, world);
|
||||
if layered {
|
||||
hdr.begin(&queue, true);
|
||||
hdr.resolve(&mut encoder, None);
|
||||
hdr.tonemap(&mut encoder, None);
|
||||
hdr.keep_world(&mut encoder, None);
|
||||
super::super::hud_coverage::clear(&mut encoder, &hdr.coverage_view().unwrap());
|
||||
} else {
|
||||
hdr.keep_world(&mut encoder, None);
|
||||
}
|
||||
let capture = hdr.capture(&device, &queue, &mut encoder, true).unwrap();
|
||||
texel(&device, &queue, encoder, &capture)
|
||||
};
|
||||
// Unmarked, below white and above it; HDR lighting; light sources of each tier: all up to
|
||||
// the tone curve's knee, which the curve leaves as they are.
|
||||
for world in [
|
||||
[0.5, 0.4, 0.3, 1.0],
|
||||
[1.5, 1.2, 0.6, 1.0],
|
||||
[0.8, 0.7, 0.6, 0.88],
|
||||
[0.9, 0.8, 0.4, 0.05],
|
||||
[0.9, 0.8, 0.4, 0.2],
|
||||
[0.9, 0.8, 0.4, 0.4],
|
||||
[0.9, 0.8, 0.4, 0.6],
|
||||
] {
|
||||
let (today, layered) = (shown(false, world), shown(true, world));
|
||||
// Half floats round the light apart by a step at most.
|
||||
assert!(
|
||||
today.iter().zip(layered).all(|(a, b)| (a - b).abs() <= 1.0),
|
||||
"{world:?}: {today:?} {layered:?}"
|
||||
);
|
||||
if world[3] < 1.0 {
|
||||
assert!(layered[0] > shown(true, [world[0], world[1], world[2], 1.0])[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tone_curve_eases_light_into_the_peak() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
(hdr.paper_white, hdr.peak) = (203.0, 1000.0);
|
||||
let headroom = 1000.0 / 203.0;
|
||||
// The frame holding `color` (in gamma, as it stores light) after `passes` ran on it.
|
||||
let after =
|
||||
|hdr: &mut Hdr, color: [f64; 4], passes: &dyn Fn(&Hdr, &mut wgpu::CommandEncoder)| {
|
||||
let frame = hdr.frame(&device, [1, 1]);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
fill(&mut encoder, &frame, color);
|
||||
hdr.begin(&queue, true);
|
||||
passes(hdr, &mut encoder);
|
||||
texel(&device, &queue, encoder, &frame)
|
||||
};
|
||||
let tonemap = |hdr: &Hdr, encoder: &mut wgpu::CommandEncoder| hdr.tonemap(encoder, None);
|
||||
// Linear light in and out, in units of paper white.
|
||||
let eased = |hdr: &mut Hdr, light: [f32; 3]| {
|
||||
let [r, g, b] = light.map(|c| f64::from(c.powf(1.0 / 2.2)));
|
||||
let [r, g, b, a] = after(hdr, [r, g, b, 1.0], &tonemap);
|
||||
assert_eq!(a, 1.0);
|
||||
[r, g, b].map(|c| c.powf(2.2))
|
||||
};
|
||||
let near = |got: f32, want: f32| (got - want).abs() <= 0.005 * want.max(1.0);
|
||||
// Up to the knee (60% of the peak) the light stays exactly as stored.
|
||||
for light in [0.0f32, 0.5, 1.0, 2.0, 2.9] {
|
||||
let g = f64::from(light.powf(1.0 / 2.2));
|
||||
let stored = after(&mut hdr, [g, g * 0.5, 0.0, 1.0], &|_, _| {});
|
||||
assert_eq!(
|
||||
after(&mut hdr, [g, g * 0.5, 0.0, 1.0], &tonemap),
|
||||
stored,
|
||||
"{light}"
|
||||
);
|
||||
}
|
||||
// Past it, BT.2390's EETF in PQ at 203 and 1000 nits, with the source's peak at 2771
|
||||
// nits (13.7 times paper white), worked out apart from the shader.
|
||||
for (light, want) in [
|
||||
(3.5, 3.43791),
|
||||
(4.0, 3.78351),
|
||||
(headroom, 4.23556),
|
||||
(8.0, 4.82043),
|
||||
(12.0, 4.92462),
|
||||
(50.0, headroom),
|
||||
] {
|
||||
let [r, ..] = eased(&mut hdr, [light; 3]);
|
||||
assert!(near(r, want), "{light}: {r}, not {want}");
|
||||
}
|
||||
// It rises, stays under the peak, and the brightest channel scales the others: the
|
||||
// light keeps its hue.
|
||||
let mut last = 0.0;
|
||||
for i in 0..40 {
|
||||
let light = 2.5 * 1.08f32.powi(i);
|
||||
let [r, g, b] = eased(&mut hdr, [light, light / 4.0, 0.0]);
|
||||
assert!(r >= last - 0.001 && r <= headroom * 1.002, "{light}: {r}");
|
||||
assert!(near(g * 4.0, r) && b == 0.0, "{light}: {r} {g} {b}");
|
||||
last = r;
|
||||
}
|
||||
// No headroom (a peak below paper white): a clip at white, channel by channel.
|
||||
hdr.peak = 150.0;
|
||||
let [r, g, b] = eased(&mut hdr, [1.5, 0.5, 0.2]);
|
||||
assert!(r == 1.0 && near(g, 0.5) && near(b, 0.2), "{r} {g} {b}");
|
||||
// HDR lighting's gain stays past the knee until the curve: the marks become light
|
||||
// without easing.
|
||||
hdr.peak = 1000.0;
|
||||
let resolve = |hdr: &Hdr, encoder: &mut wgpu::CommandEncoder| hdr.resolve(encoder, None);
|
||||
let [r, ..] = after(&mut hdr, [1.0, 1.0, 1.0, 0.76], &resolve);
|
||||
let gain = 1.0 + 3.0 * (1.0 - 194.0 / 255.0) / 0.24;
|
||||
assert!(near(r.powf(2.2), gain), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hud_lays_over_the_worlds_light_by_its_coverage() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let target = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: F::Rgba16Float,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
// The world's light, the HUD's target over it (on the world clipped to white), its
|
||||
// coverage, UI Brightness, and the composite, all in gamma as the frame stores them.
|
||||
for (world, hud, coverage, ui, want) in [
|
||||
// Nothing over it: the world, light above white included.
|
||||
(3.0, 1.0, 0.0, 1.0, 3.0),
|
||||
// Half-transparent black: half of the world shows, as the HUD blends.
|
||||
(2.0, 0.5, 0.5, 1.0, 1.0),
|
||||
// Covered: the HUD as drawn, at UI Brightness.
|
||||
(2.0, 0.3, 1.0, 1.0, 0.3),
|
||||
(2.0, 0.3, 1.0, 0.5, 0.3 * 0.5f32.powf(1.0 / 2.2)),
|
||||
// Added over the world (the text highlight), which it doesn't hide.
|
||||
(0.6, 0.8, 0.0, 1.0, 0.8),
|
||||
// At or below white, at 100%: the HUD's picture as drawn.
|
||||
(0.7, 0.45, 0.5, 1.0, 0.45),
|
||||
] {
|
||||
hdr.ui = ui;
|
||||
let frame = hdr.frame(&device, [1, 1]);
|
||||
let t = hdr.targets.as_ref().unwrap();
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
fill(&mut encoder, &frame, [world, world, world, 1.0]);
|
||||
fill(&mut encoder, t.hud.as_ref().unwrap(), [hud, hud, hud, 0.0]);
|
||||
fill(&mut encoder, t.coverage.as_ref().unwrap(), [coverage; 4]);
|
||||
hdr.begin(&queue, true);
|
||||
hdr.composite(&mut encoder, &target.create_view(&Default::default()), None);
|
||||
let got = texel(&device, &queue, encoder, &target);
|
||||
assert!(
|
||||
(got[0] - want).abs() < 0.01 && got[3] == 1.0,
|
||||
"{world} {hud} {coverage} {ui}: {got:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_menu_blur_keeps_the_light_it_blurs() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let size = [8, 8];
|
||||
let frame = hdr.frame(&device, size);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
fill(&mut encoder, &frame, [4.0, 4.0, 4.0, 1.0]);
|
||||
hdr.keep_world(&mut encoder, None);
|
||||
super::super::hud_coverage::clear(&mut encoder, &hdr.coverage_view().unwrap());
|
||||
let mut blur = crate::menu_blur::MenuBlur::new(&device, F::Rgba16Float, false);
|
||||
let (_, source) = blur.source(&device, size);
|
||||
hdr.begin(&queue, true);
|
||||
hdr.composite(&mut encoder, &source, None);
|
||||
let view = frame.create_view(&Default::default());
|
||||
blur.blur(&device, &queue, &mut encoder, &view, size, 3.0, None);
|
||||
hdr.keep_world(&mut encoder, None);
|
||||
let hud = hdr.hud().unwrap().clone();
|
||||
// The blurred world keeps its light, and the HUD's backdrop starts again from it.
|
||||
let [r, ..] = texel(&device, &queue, encoder, &frame);
|
||||
assert!((r - 4.0).abs() < 0.01, "{r}");
|
||||
let encoder = device.create_command_encoder(&Default::default());
|
||||
assert_eq!(texel(&device, &queue, encoder, &hud), [1.0, 1.0, 1.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_shaders_validate() {
|
||||
let entity = crate::overlay::wgsl_constants() + include_str!("../entity.wgsl");
|
||||
let entity = super::super::entity_shader();
|
||||
for source in [
|
||||
include_str!("../hdr.wgsl"),
|
||||
include_str!("../terrain.wgsl"),
|
||||
&super::shader(),
|
||||
&super::super::terrain_shader(),
|
||||
include_str!("../hdr_stats.wgsl"),
|
||||
include_str!("stars.wgsl"),
|
||||
&(include_str!("../hdr_light.wgsl").to_owned() + include_str!("stars.wgsl")),
|
||||
&entity,
|
||||
] {
|
||||
let module = naga::front::wgsl::parse_str(source).unwrap();
|
||||
@@ -879,3 +1465,7 @@ mod lighting_tests {
|
||||
assert_eq!(scene_gain(200.0, 480.0), 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "hdr_written_tests.rs"]
|
||||
mod written_tests;
|
||||
@@ -0,0 +1,65 @@
|
||||
// The light of colour `f` under the world's mark `a` (the alpha the world left, hdr_mark) where
|
||||
// the world `shows`, else the colour clipped at white; and whether a light source lifted it.
|
||||
// `eased` for the last pass, which eases it into the peak itself; the layered frame leaves that
|
||||
// to the tone curve (fs_tonemap).
|
||||
struct ReferenceLit {
|
||||
light: vec3<f32>,
|
||||
lifted: bool,
|
||||
};
|
||||
|
||||
fn reference_lit(f: vec3<f32>, a: f32, shows: bool, eased: bool) -> ReferenceLit {
|
||||
var lifted = false;
|
||||
// Light over 1 (a float frame's) shows only where the world does and carries
|
||||
// no mark (alpha 1), up to the peak: light written as light. Marked pixels clip
|
||||
// at white before their lift, as on a unorm frame, so light sources, the sun and
|
||||
// HDR lighting look as they do there; so do the HUD, menus and HDR Lighting off.
|
||||
let c = select(min(f, vec3(1.0)), f, shows && a == 1.0);
|
||||
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
|
||||
var light = pow(c, vec3(2.2));
|
||||
// HDR lighting: alpha between 0.76 and 1 is how much of it the surface gets. Vanilla's
|
||||
// light is albedo times lightmap, so this is the lightmap going past 1. It eases into the
|
||||
// peak from 60% of it, so bright textures keep their detail. Unmarked light (alpha 1)
|
||||
// gets no gain and no knee: the clamp to the peak below is all it needs here.
|
||||
if shows && a >= 0.75 && a < 1.0 {
|
||||
let top = max(light.r, max(light.g, light.b));
|
||||
let gain = 1.0 + (params.scene - 1.0) * (1.0 - a) / 0.24;
|
||||
let v = top * gain;
|
||||
let knee = max(0.6 * params.peak, 1.0);
|
||||
var out = v;
|
||||
if eased && v > knee && params.peak > knee {
|
||||
out = knee + (params.peak - knee) * (1.0 - exp(-(v - knee) / (params.peak - knee)));
|
||||
}
|
||||
light *= select(gain, out / max(top, 1e-4), eased && v > knee);
|
||||
}
|
||||
// The world's alpha is below 0.75 on light sources, the sun and the moon
|
||||
// (terrain.wgsl, entity.wgsl): its band of 0.175 is the tier, the lower the
|
||||
// brighter, and where in the band how much (hdr_mark).
|
||||
if shows && a < 0.75 {
|
||||
let band = u32(clamp(floor((a + 0.005) / 0.175), 0.0, 3.0));
|
||||
let tier = 3u - band;
|
||||
let lift = 1.0 - clamp((a - 0.175 * f32(band)) / 0.165, 0.0, 1.0);
|
||||
if lift > 0.0 {
|
||||
let top = max(light.r, max(light.g, light.b));
|
||||
var gain = 1.0 + (params.peak - 1.0) * params.share[tier] * lift;
|
||||
if eased {
|
||||
gain = min(gain, params.peak / max(top, 1e-4));
|
||||
}
|
||||
light *= max(gain, 1.0);
|
||||
lifted = true;
|
||||
}
|
||||
}
|
||||
// HORIZON_HDR_DEBUG=1: what isn't lifted turns grey, what is keeps its
|
||||
// colour with a magenta tint, so a lift shows on any colour.
|
||||
if params.debug > 0.5 && params.world > 0.5 {
|
||||
if lifted {
|
||||
light *= vec3(1.0, 0.6, 1.0);
|
||||
} else {
|
||||
light = vec3(dot(light, vec3(0.2126, 0.7152, 0.0722)));
|
||||
}
|
||||
}
|
||||
if eased {
|
||||
light = min(light, vec3(params.peak));
|
||||
}
|
||||
return ReferenceLit(light, lifted);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,818 @@
|
||||
use super::{
|
||||
Hdr, Output,
|
||||
tests::{gpu, texel},
|
||||
};
|
||||
use wgpu::TextureFormat as F;
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
const TERRAIN_VERTEX: &str = r#"
|
||||
override TEST_TIER: u32 = 0u;
|
||||
override TEST_OVERLAY: u32 = 0u;
|
||||
@vertex fn test_vertex(@builtin(vertex_index) i: u32) -> TerrainWire {
|
||||
let p = vec2(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
var o: TerrainOut;
|
||||
o.clip = vec4(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
o.uv = vec2(0.5);
|
||||
o.local = vec3(p.x, 0.0, p.y);
|
||||
o.color = vec4(0.8, 0.9, 0.7, select(1.0, 0.5, globals.misc.z > 0.5));
|
||||
o.scene = vec2(15.0);
|
||||
o.spherical = globals.misc.x;
|
||||
o.cylindrical = globals.misc.x;
|
||||
o.visibility = globals.misc.y;
|
||||
o.emission = TEST_TIER;
|
||||
o.emissive_layer = select(0xffffffffu, 1u, TEST_OVERLAY != 0u);
|
||||
return to_wire(o);
|
||||
}
|
||||
"#;
|
||||
const ENTITY_VERTEX: &str = r#"
|
||||
override TEST_TIER: u32 = 0u;
|
||||
override TEST_OVERLAY: u32 = 0u;
|
||||
@vertex fn test_vertex(@builtin(vertex_index) i: u32) -> Out {
|
||||
let p = vec2(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
var o: Out;
|
||||
o.clip = vec4(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
o.uv = vec2(0.5);
|
||||
o.color = vec4(0.8, 0.9, 0.7, 1.0);
|
||||
o.back_color = o.color;
|
||||
o.light_color = vec4(0.7, 0.8, 0.9, 1.0);
|
||||
o.overlay_color = vec4(1.0);
|
||||
o.scene = 0.8;
|
||||
o.tier = TEST_TIER;
|
||||
o.overlay = TEST_OVERLAY;
|
||||
o.wrap = vec2(0.0, 1.0);
|
||||
o.proj = vec4(0.4, 0.6, 0.0, 1.0);
|
||||
o.fog_distance = vec2(globals.misc.x);
|
||||
return o;
|
||||
}
|
||||
"#;
|
||||
|
||||
struct Draw<'a> {
|
||||
device: &'a wgpu::Device,
|
||||
queue: &'a wgpu::Queue,
|
||||
terrain: bool,
|
||||
shader: wgpu::ShaderModule,
|
||||
layout: wgpu::PipelineLayout,
|
||||
bindings: wgpu::BindGroupLayout,
|
||||
group: wgpu::BindGroup,
|
||||
}
|
||||
impl<'a> Draw<'a> {
|
||||
fn render_oit(
|
||||
&self,
|
||||
hdr: &mut Hdr,
|
||||
kind: &str,
|
||||
tier: u32,
|
||||
overlay: u32,
|
||||
written: bool,
|
||||
) -> [f32; 4] {
|
||||
use super::super::oit::{Stage, stage_targets};
|
||||
let source = if self.terrain {
|
||||
super::super::terrain_shader()
|
||||
+ include_str!("../oit.wgsl")
|
||||
+ include_str!("../oit_world.wgsl")
|
||||
+ TERRAIN_VERTEX
|
||||
} else {
|
||||
super::super::entity_shader()
|
||||
+ include_str!("../oit.wgsl")
|
||||
+ include_str!("../oit_features.wgsl")
|
||||
+ include_str!("../oit_clouds.wgsl")
|
||||
+ ENTITY_VERTEX
|
||||
}
|
||||
.replace(
|
||||
"o.clip = vec4(p * 2.0 - 1.0, 0.0, 1.0)",
|
||||
"o.clip = vec4(p * 2.0 - 1.0, 0.5, 1.0)",
|
||||
);
|
||||
let shader = self
|
||||
.device
|
||||
.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("actual OIT fragments"),
|
||||
source: wgpu::ShaderSource::Wgsl(source.into()),
|
||||
});
|
||||
let screen_shader = self
|
||||
.device
|
||||
.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
(super::super::terrain_shader()
|
||||
+ include_str!("../oit.wgsl")
|
||||
+ include_str!("../oit_world.wgsl")
|
||||
+ TERRAIN_VERTEX)
|
||||
.into(),
|
||||
),
|
||||
});
|
||||
let make = |format| {
|
||||
self.device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default())
|
||||
};
|
||||
let bounds = make(F::Rgba32Float);
|
||||
let depth = make(F::Depth32Float);
|
||||
let coeff = [make(F::Rgba16Float), make(F::Rgba16Float)];
|
||||
let accumulated = make(F::Rgba16Float);
|
||||
let uniform = self
|
||||
.device
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytemuck::cast_slice(&[0.0_f32, 1.0, 0.0, 0.0]),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let frame = hdr.frame(self.device, [1, 1]);
|
||||
let view = frame.create_view(&Default::default());
|
||||
let mut encoder = self.device.create_command_encoder(&Default::default());
|
||||
super::tests::fill(&mut encoder, &frame, [2.5, 1.8, 0.9, 1.0]);
|
||||
let constants = [
|
||||
("HDR_WRITE_LIGHT", f64::from(u8::from(written))),
|
||||
("TEST_TIER", f64::from(tier)),
|
||||
("TEST_OVERLAY", f64::from(overlay)),
|
||||
];
|
||||
for stage in 0..4 {
|
||||
let textures: Vec<_> = match stage {
|
||||
0 => vec![],
|
||||
1 => vec![&bounds],
|
||||
2 => vec![&bounds, &coeff[0], &coeff[1]],
|
||||
_ => vec![&bounds, &coeff[0], &coeff[1], &accumulated],
|
||||
};
|
||||
let mut entries = vec![wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
}];
|
||||
entries.extend(
|
||||
textures
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, _)| wgpu::BindGroupLayoutEntry {
|
||||
binding: i as u32 + 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
}),
|
||||
);
|
||||
let bindings = self
|
||||
.device
|
||||
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: None,
|
||||
entries: &entries,
|
||||
});
|
||||
let mut resources = vec![wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: uniform.as_entire_binding(),
|
||||
}];
|
||||
resources.extend(
|
||||
textures
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, t)| wgpu::BindGroupEntry {
|
||||
binding: i as u32 + 1,
|
||||
resource: wgpu::BindingResource::TextureView(t),
|
||||
}),
|
||||
);
|
||||
let group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &bindings,
|
||||
entries: &resources,
|
||||
});
|
||||
let layout = self
|
||||
.device
|
||||
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: None,
|
||||
bind_group_layouts: &[Some(&self.bindings), None, Some(&bindings)],
|
||||
..Default::default()
|
||||
});
|
||||
let suffix = ["bounds", "transmittance", "accumulate", "composite"][stage];
|
||||
let entry = if stage == 3 {
|
||||
"fs_oit_composite".to_owned()
|
||||
} else if self.terrain {
|
||||
format!("fs_oit_{suffix}")
|
||||
} else {
|
||||
format!("fs_oit_{kind}_{suffix}")
|
||||
};
|
||||
let targets = if stage == 3 {
|
||||
vec![Some(wgpu::ColorTargetState {
|
||||
format: F::Rgba16Float,
|
||||
blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})]
|
||||
} else {
|
||||
stage_targets([Stage::DepthBounds, Stage::Transmittance, Stage::Accumulate][stage])
|
||||
};
|
||||
let shader = if stage == 3 { &screen_shader } else { &shader };
|
||||
let pipeline = self
|
||||
.device
|
||||
.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(&entry),
|
||||
layout: Some(&layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: shader,
|
||||
entry_point: Some("test_vertex"),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &constants,
|
||||
..Default::default()
|
||||
},
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: (stage == 3).then_some(wgpu::DepthStencilState {
|
||||
format: F::Depth32Float,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::Always),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: shader,
|
||||
entry_point: Some(&entry),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &constants,
|
||||
..Default::default()
|
||||
},
|
||||
targets: &targets,
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let outputs = match stage {
|
||||
0 => vec![&bounds],
|
||||
1 => vec![&coeff[0], &coeff[1]],
|
||||
2 => vec![&accumulated],
|
||||
_ => vec![&view],
|
||||
};
|
||||
let attachments: Vec<_> = outputs
|
||||
.iter()
|
||||
.map(|v| {
|
||||
Some(wgpu::RenderPassColorAttachment {
|
||||
view: v,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: if stage == 3 {
|
||||
wgpu::LoadOp::Load
|
||||
} else {
|
||||
wgpu::LoadOp::Clear(if stage == 0 {
|
||||
wgpu::Color {
|
||||
r: -(f32::MAX as f64),
|
||||
g: 0.0,
|
||||
b: 0.0,
|
||||
a: 0.0,
|
||||
}
|
||||
} else {
|
||||
wgpu::Color::TRANSPARENT
|
||||
})
|
||||
},
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
color_attachments: &attachments,
|
||||
depth_stencil_attachment: (stage == 3).then_some(
|
||||
wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(0.0),
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
},
|
||||
),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.set_bind_group(2, &group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
texel(self.device, self.queue, encoder, &frame)
|
||||
}
|
||||
fn new(
|
||||
device: &'a wgpu::Device,
|
||||
queue: &'a wgpu::Queue,
|
||||
terrain: bool,
|
||||
globals: &super::super::Globals,
|
||||
alpha: u8,
|
||||
) -> Self {
|
||||
let source = if terrain {
|
||||
super::super::terrain_shader() + TERRAIN_VERTEX
|
||||
} else {
|
||||
super::super::entity_shader() + ENTITY_VERTEX
|
||||
};
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("actual HDR draw shader"),
|
||||
source: wgpu::ShaderSource::Wgsl(source.into()),
|
||||
});
|
||||
let mut entries = vec![];
|
||||
for binding in 0..if terrain { 13 } else { 8 } {
|
||||
let sampler = if terrain {
|
||||
binding == 2 || binding == 4
|
||||
} else {
|
||||
binding == 2 || binding == 4 || binding == 6
|
||||
};
|
||||
let ty = if binding == 0 {
|
||||
wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
}
|
||||
} else if sampler {
|
||||
wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering)
|
||||
} else {
|
||||
wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
multisampled: false,
|
||||
view_dimension: if terrain && binding != 3 && binding != 12 {
|
||||
wgpu::TextureViewDimension::D2Array
|
||||
} else {
|
||||
wgpu::TextureViewDimension::D2
|
||||
},
|
||||
}
|
||||
};
|
||||
entries.push(wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty,
|
||||
count: None,
|
||||
});
|
||||
}
|
||||
let bindings = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: None,
|
||||
entries: &entries,
|
||||
});
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: None,
|
||||
bind_group_layouts: &[Some(&bindings)],
|
||||
..Default::default()
|
||||
});
|
||||
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytemuck::bytes_of(globals),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let texture = |layers, data: &[u8]| {
|
||||
device.create_texture_with_data(
|
||||
queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: layers,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: F::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
data,
|
||||
)
|
||||
};
|
||||
let array = texture(2, &[200, 100, 50, alpha, 240, 210, 80, 128]);
|
||||
let array = array.create_view(&wgpu::TextureViewDescriptor {
|
||||
dimension: Some(wgpu::TextureViewDimension::D2Array),
|
||||
..Default::default()
|
||||
});
|
||||
let image = texture(1, &[200, 100, 50, alpha]).create_view(&Default::default());
|
||||
let threshold = texture(1, &[0, 0, 0, 0]).create_view(&Default::default());
|
||||
let sampler = device.create_sampler(&Default::default());
|
||||
let resources: Vec<_> = entries
|
||||
.iter()
|
||||
.map(|entry| wgpu::BindGroupEntry {
|
||||
binding: entry.binding,
|
||||
resource: match entry.ty {
|
||||
wgpu::BindingType::Buffer { .. } => uniform.as_entire_binding(),
|
||||
wgpu::BindingType::Sampler(_) => wgpu::BindingResource::Sampler(&sampler),
|
||||
wgpu::BindingType::Texture {
|
||||
view_dimension: wgpu::TextureViewDimension::D2Array,
|
||||
..
|
||||
} => wgpu::BindingResource::TextureView(&array),
|
||||
_ => wgpu::BindingResource::TextureView(if entry.binding == 12 {
|
||||
&threshold
|
||||
} else {
|
||||
&image
|
||||
}),
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &bindings,
|
||||
entries: &resources,
|
||||
});
|
||||
Self {
|
||||
device,
|
||||
queue,
|
||||
terrain,
|
||||
shader,
|
||||
layout,
|
||||
bindings,
|
||||
group,
|
||||
}
|
||||
}
|
||||
fn render(
|
||||
&self,
|
||||
hdr: &mut Hdr,
|
||||
written: bool,
|
||||
entry: &str,
|
||||
tier: u32,
|
||||
overlay: u32,
|
||||
background: [f64; 4],
|
||||
) -> [f32; 4] {
|
||||
let constants = [
|
||||
("HDR_WRITE_LIGHT", f64::from(u8::from(written))),
|
||||
("TEST_TIER", f64::from(tier)),
|
||||
("TEST_OVERLAY", f64::from(overlay)),
|
||||
];
|
||||
let pipeline = |entry, mark| {
|
||||
self.device
|
||||
.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: None,
|
||||
layout: Some(&self.layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &self.shader,
|
||||
entry_point: Some("test_vertex"),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &constants,
|
||||
..Default::default()
|
||||
},
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &self.shader,
|
||||
entry_point: Some(entry),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &constants,
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: F::Rgba16Float,
|
||||
blend: Some(if mark || (self.terrain && entry != "fs_translucent") {
|
||||
wgpu::BlendState::REPLACE
|
||||
} else {
|
||||
wgpu::BlendState::ALPHA_BLENDING
|
||||
}),
|
||||
write_mask: if mark {
|
||||
wgpu::ColorWrites::ALPHA
|
||||
} else {
|
||||
wgpu::ColorWrites::ALL
|
||||
},
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let color = pipeline(entry, false);
|
||||
let mark = (!self.terrain && !written && entry == "fs_entity").then(|| {
|
||||
pipeline(
|
||||
if overlay == 4 {
|
||||
"fs_hdr_cloud_mark"
|
||||
} else {
|
||||
"fs_hdr_mark"
|
||||
},
|
||||
true,
|
||||
)
|
||||
});
|
||||
let frame = hdr.frame(self.device, [1, 1]);
|
||||
let view = frame.create_view(&Default::default());
|
||||
let mut encoder = self.device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let [r, g, b, a] = background;
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color { r, g, b, a }),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.set_pipeline(&color);
|
||||
pass.draw(0..3, 0..1);
|
||||
if let Some(mark) = &mark {
|
||||
pass.set_pipeline(mark);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
if !written {
|
||||
hdr.begin(self.queue, true);
|
||||
hdr.resolve(&mut encoder, None);
|
||||
}
|
||||
texel(self.device, self.queue, encoder, &frame)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_opaque_writers_match_marks_then_resolve() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
eprintln!("HDR draw GPU unavailable");
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
(hdr.paper_white, hdr.peak) = (203.0, 1000.0);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let mut count = 0;
|
||||
for fog in [0.0, 0.5, 1.0] {
|
||||
for visibility in [1.0, 0.4] {
|
||||
let globals = super::super::Globals {
|
||||
fog_color: [0.2, 0.3, 0.4, 1.0],
|
||||
fog: [0.0, 1.0, 0.0, 1.0],
|
||||
fog_ends: [100.0, 100.0, 0.0, 0.0],
|
||||
misc: [fog, visibility, 0.0, 0.0],
|
||||
filtering: [0.0, 1.0, 0.0, 0.0],
|
||||
hdr_windows: hdr.windows(),
|
||||
hdr_light: [0.8, 0.8, 1.0, 1.0],
|
||||
hdr_settings: settings,
|
||||
hdr_shares: shares,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
for terrain in [true, false] {
|
||||
let draw = Draw::new(&device, &queue, terrain, &globals, 255);
|
||||
for tier in 0..=4 {
|
||||
for overlay in if terrain {
|
||||
vec![0, 1]
|
||||
} else {
|
||||
vec![0, 6, 9, 10, 38]
|
||||
} {
|
||||
let entries = if terrain {
|
||||
vec!["fs_opaque", "fs_solid"]
|
||||
} else {
|
||||
vec!["fs_entity"]
|
||||
};
|
||||
for entry in entries {
|
||||
let marked =
|
||||
draw.render(&mut hdr, false, entry, tier, overlay, [0.0; 4]);
|
||||
let written =
|
||||
draw.render(&mut hdr, true, entry, tier, overlay, [0.0; 4]);
|
||||
// The marked path stores half floats before conversion; the written
|
||||
// one stores after it. Allow two half-float ULPs in gamma light.
|
||||
for i in 0..3 {
|
||||
assert!(
|
||||
(marked[i] - written[i]).abs() <= 0.002 * marked[i].max(1.0),
|
||||
"{terrain} {entry} tier {tier} overlay {overlay} fog {fog} visibility {visibility}: {marked:?} {written:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(written[3], 1.0);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("HDR actual draw GPU ran {count} opaque comparisons");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_translucent_writers_blend_their_own_light() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
eprintln!("HDR blend GPU unavailable");
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let globals = super::super::Globals {
|
||||
fog: [0.0, 100.0, 0.0, 100.0],
|
||||
fog_ends: [100.0, 100.0, 0.0, 0.0],
|
||||
misc: [0.0, 1.0, 0.0, 0.0],
|
||||
filtering: [0.0, 1.0, 0.0, 0.0],
|
||||
hdr_windows: hdr.windows(),
|
||||
hdr_light: [0.8, 0.8, 1.0, 1.0],
|
||||
hdr_settings: settings,
|
||||
hdr_shares: shares,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
// Texel alpha 100 is clear glass's kind of texel, below the 0.6 the marked frame's
|
||||
// `fs_translucent_mark` skips: written, it still gets its own light.
|
||||
for (terrain, entry, overlay, tier, tint_alpha, texel_alpha) in [
|
||||
(true, "fs_translucent", 0, 0, 1.0, 180),
|
||||
(true, "fs_translucent", 0, 0, 0.5, 180),
|
||||
(true, "fs_translucent", 0, 0, 1.0, 100),
|
||||
(false, "fs_entity", 0, 3, 1.0, 180),
|
||||
(false, "fs_entity", 4, 0, 1.0, 180),
|
||||
] {
|
||||
let mut globals = globals;
|
||||
globals.misc[2] = if tint_alpha < 1.0 { 1.0 } else { 0.0 };
|
||||
let draw = Draw::new(&device, &queue, terrain, &globals, texel_alpha);
|
||||
let src = draw.render(&mut hdr, true, entry, tier, overlay, [0.0; 4]);
|
||||
let dst = [2.5, 1.8, 0.9, 1.0];
|
||||
let got = draw.render(&mut hdr, true, entry, tier, overlay, dst);
|
||||
let alpha = f32::from(texel_alpha) / 255.0 * tint_alpha;
|
||||
if terrain {
|
||||
// Full block light leaves mark 194/255 whatever the texel's alpha, even when
|
||||
// vertex coverage is halved.
|
||||
let gain = (1.0 + (settings[1] - 1.0) * (1.0 - 194.0 / 255.0) / 0.24).powf(1.0 / 2.2);
|
||||
let expected = 200.0 / 255.0 * 0.8 * gain * alpha;
|
||||
assert!(
|
||||
(src[0] - expected).abs() < 0.002,
|
||||
"coverage {tint_alpha}: {src:?}, expected {expected}"
|
||||
);
|
||||
}
|
||||
for i in 0..3 {
|
||||
let want = src[i] + dst[i] as f32 * (1.0 - alpha);
|
||||
assert!(
|
||||
(got[i] - want).abs() < 0.003,
|
||||
"{entry} {overlay}: {got:?} expected {want}"
|
||||
);
|
||||
}
|
||||
assert!(got[0] > 1.0);
|
||||
}
|
||||
eprintln!("HDR actual blend GPU ran terrain, particle and cloud equations");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmarked_entity_users_keep_their_source_gain() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let globals = super::super::Globals {
|
||||
fog: [0.0, 100.0, 0.0, 100.0],
|
||||
fog_ends: [100.0, 100.0, 0.0, 0.0],
|
||||
hdr_windows: hdr.windows(),
|
||||
hdr_light: [1.0, 1.0, 1.0, 1.0],
|
||||
hdr_settings: settings,
|
||||
hdr_shares: shares,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
let draw = Draw::new(&device, &queue, false, &globals, 255);
|
||||
for (entry, overlay) in [
|
||||
("fs_entity_unmarked", 0),
|
||||
("fs_entity_shadow", 0),
|
||||
("fs_energy_swirl", 0),
|
||||
("fs_entity", 11),
|
||||
("fs_entity", 12),
|
||||
] {
|
||||
let raw = draw.render(&mut hdr, true, entry, 0, overlay, [0.0; 4]);
|
||||
let mut hdr_off = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
hdr_off.look = false;
|
||||
let legacy = draw.render(&mut hdr_off, false, entry, 0, overlay, [0.0; 4]);
|
||||
for i in 0..3 {
|
||||
assert!(
|
||||
(raw[i] - legacy[i]).abs() < 0.001,
|
||||
"{entry} {overlay}: {raw:?} {legacy:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn additive_texels_below_the_mark_cutoff_keep_their_gain() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let globals = super::super::Globals {
|
||||
fog: [0.0, 100.0, 0.0, 100.0],
|
||||
hdr_windows: hdr.windows(),
|
||||
hdr_light: [1.0, 1.0, 1.0, 1.0],
|
||||
hdr_settings: settings,
|
||||
hdr_shares: shares,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
let draw = Draw::new(&device, &queue, false, &globals, 16);
|
||||
let background = [0.0, 0.0, 0.0, 1.0];
|
||||
let marked = draw.render(&mut hdr, false, "fs_celestial", 4, 0, background);
|
||||
let written = draw.render(&mut hdr, true, "fs_celestial", 4, 0, background);
|
||||
// The old mark draw discards this texel; source alpha still contributes its colour.
|
||||
let red = (200.0 / 255.0) * 0.8 * 0.7 * (16.0 / 255.0);
|
||||
assert!(
|
||||
(written[0] - red).abs() < 0.0001,
|
||||
"{written:?}: expected {red}"
|
||||
);
|
||||
for i in 0..3 {
|
||||
assert!(
|
||||
(written[i] - marked[i]).abs() < 0.0001,
|
||||
"{written:?} {marked:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn check_oit_writer(terrain: bool, kind: &str, overlay: u32, tier: u32, alphas: &[u8]) {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
eprintln!("OIT GPU unavailable");
|
||||
return;
|
||||
};
|
||||
if !adapter
|
||||
.features()
|
||||
.contains(wgpu::Features::FLOAT32_BLENDABLE)
|
||||
{
|
||||
eprintln!("OIT unsupported on {:?}", adapter.get_info());
|
||||
return;
|
||||
}
|
||||
eprintln!(
|
||||
"OIT GPU {:?}: {kind}, alphas {alphas:?}",
|
||||
adapter.get_info()
|
||||
);
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
required_features: wgpu::Features::FLOAT32_BLENDABLE,
|
||||
..Default::default()
|
||||
}))
|
||||
.unwrap();
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let globals = super::super::Globals {
|
||||
fog: [0.0, 100.0, 0.0, 100.0],
|
||||
fog_ends: [100.0, 100.0, 0.0, 0.0],
|
||||
misc: [0.0, 1.0, 0.0, 0.0],
|
||||
filtering: [0.0, 1.0, 0.0, 0.0],
|
||||
hdr_windows: hdr.windows(),
|
||||
hdr_light: [0.8, 0.8, 1.0, 1.0],
|
||||
hdr_settings: settings,
|
||||
hdr_shares: shares,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
for &alpha in alphas {
|
||||
let draw = Draw::new(&device, &queue, terrain, &globals, alpha);
|
||||
let got = draw.render_oit(&mut hdr, kind, tier, overlay, true);
|
||||
let unconverted = draw.render_oit(&mut hdr, kind, tier, overlay, false);
|
||||
let entry = if terrain {
|
||||
"fs_translucent"
|
||||
} else if kind == "lightning" {
|
||||
"fs_celestial"
|
||||
} else {
|
||||
"fs_entity"
|
||||
};
|
||||
let mut expected = draw.render(
|
||||
&mut hdr,
|
||||
true,
|
||||
entry,
|
||||
tier,
|
||||
overlay,
|
||||
if kind == "lightning" {
|
||||
[0.0; 4]
|
||||
} else {
|
||||
[2.5, 1.8, 0.9, 1.0]
|
||||
},
|
||||
);
|
||||
if kind == "lightning" {
|
||||
for (c, background) in expected[..3].iter_mut().zip([2.5, 1.8, 0.9]) {
|
||||
*c += background;
|
||||
}
|
||||
}
|
||||
for i in 0..3 {
|
||||
assert!(
|
||||
(got[i] - expected[i]).abs() < 0.006,
|
||||
"{kind} alpha {alpha}: {got:?} expected {expected:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(got[3], 1.0);
|
||||
assert!(got[0] > 1.0, "OIT clipped light: {got:?}");
|
||||
assert!(
|
||||
(got[0] - unconverted[0]).abs() > 0.01,
|
||||
"test must distinguish missing conversion: {got:?} {unconverted:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_oit_terrain_writes_its_own_light_including_clear_glass() {
|
||||
check_oit_writer(true, "terrain", 0, 0, &[180, 100]);
|
||||
}
|
||||
#[test]
|
||||
fn actual_oit_particle_writes_its_own_light() {
|
||||
check_oit_writer(false, "entity", 0, 3, &[180]);
|
||||
}
|
||||
#[test]
|
||||
fn actual_oit_cloud_writes_its_own_light() {
|
||||
check_oit_writer(false, "cloud", 4, 0, &[180]);
|
||||
}
|
||||
#[test]
|
||||
fn actual_oit_additive_adds_converted_light_without_coverage() {
|
||||
check_oit_writer(false, "lightning", 0, 4, &[180]);
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
//! A held world (`WorldHold`, the native hook `native_world_hold`): the terrain goes on being
|
||||
//! drawn as it stands while what changes under it is meshed, and all of it takes its place on
|
||||
//! one frame. A section already on screen that is meshed again is uploaded to the arena as ever,
|
||||
//! over as many frames as its mesh takes to come, but waits beside the mesh drawn (`Hold`);
|
||||
//! letting go only swaps which of the two is drawn, with nothing left to upload. Sections not on
|
||||
//! screen yet (terrain streaming in) are not held.
|
||||
//!
|
||||
//! Both meshes of a waiting section are in memory, so a hold costs up to what the changed land's
|
||||
//! new meshes come to. It is bounded, and each bound lets go at once rather than losing anything:
|
||||
//! `MAX_BYTES` of waiting meshes, `MAX_HOLD` of time, and the arena running out
|
||||
//! (`Renderer::upload`). It never outlives what asked for it: the mods are asked every frame,
|
||||
//! and the sections' being cleared (the world left, another dimension) drops what waits. A hold
|
||||
//! that ended by itself (spent) stays ended until no mod has asked for a frame.
|
||||
use super::SectionKey;
|
||||
use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The longest a hold lasts.
|
||||
pub(super) const MAX_HOLD: Duration = Duration::from_secs(30);
|
||||
/// The most bytes of new meshes that wait.
|
||||
pub(super) const MAX_BYTES: u64 = 1 << 30;
|
||||
/// The most circles a hold is kept to (`WorldHold::hold_within`).
|
||||
pub const MAX_AREAS: usize = 64;
|
||||
|
||||
/// What the mods ask of the terrain's picture this frame (`native_world_hold`): nothing, all of
|
||||
/// it held, or only the sections inside some circles. Several mods add up: one holding all of it
|
||||
/// holds all of it, and circles join.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct WorldHold {
|
||||
all: bool,
|
||||
within: Vec<[f64; 3]>,
|
||||
}
|
||||
impl WorldHold {
|
||||
/// Hold the whole world's terrain as it is drawn, this frame.
|
||||
pub fn hold(&mut self) {
|
||||
self.all = true;
|
||||
}
|
||||
/// Hold the sections whose middle lies within `radius` blocks of (`x`, `z`), this frame. A
|
||||
/// circle that isn't finite or has a negative radius is left out, as are those past
|
||||
/// `MAX_AREAS`.
|
||||
pub fn hold_within(&mut self, x: f64, z: f64, radius: f64) {
|
||||
let good = x.is_finite() && z.is_finite() && radius.is_finite() && radius >= 0.0;
|
||||
if good && self.within.len() < MAX_AREAS {
|
||||
self.within.push([x, z, radius]);
|
||||
}
|
||||
}
|
||||
/// Whether anything is held.
|
||||
pub fn is_held(&self) -> bool {
|
||||
self.all || !self.within.is_empty()
|
||||
}
|
||||
/// Whether section `key` is in what is held: all of the world, or a circle its middle lies in.
|
||||
pub fn covers(&self, key: SectionKey) -> bool {
|
||||
if self.all {
|
||||
return true;
|
||||
}
|
||||
let (x, z) = (f64::from(key.0) * 16.0 + 8.0, f64::from(key.2) * 16.0 + 8.0);
|
||||
self.within
|
||||
.iter()
|
||||
.any(|&[cx, cz, r]| (x - cx).powi(2) + (z - cz).powi(2) <= r * r)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a frame's ask means for the sections waiting.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(super) enum Release {
|
||||
/// They go on waiting.
|
||||
None,
|
||||
/// Those the hold no longer covers take their place.
|
||||
Outside,
|
||||
/// All of them take their place.
|
||||
All,
|
||||
}
|
||||
|
||||
/// A section waiting behind the hold and the bytes its new mesh takes.
|
||||
struct Waiting<S> {
|
||||
section: S,
|
||||
bytes: u64,
|
||||
}
|
||||
|
||||
/// The renderer's side of a hold: whether it is on, what it covers, and the sections waiting.
|
||||
pub(super) struct Hold<S> {
|
||||
ask: WorldHold,
|
||||
since: Option<Instant>,
|
||||
spent: bool,
|
||||
waiting: FxHashMap<SectionKey, Waiting<S>>,
|
||||
bytes: u64,
|
||||
}
|
||||
impl<S> Default for Hold<S> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
ask: WorldHold::default(),
|
||||
since: None,
|
||||
spent: false,
|
||||
waiting: FxHashMap::default(),
|
||||
bytes: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<S> Hold<S> {
|
||||
/// Whether a new mesh of section `key` waits.
|
||||
pub fn holds(&self, key: SectionKey) -> bool {
|
||||
self.since.is_some() && !self.spent && self.ask.covers(key)
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.waiting.is_empty()
|
||||
}
|
||||
pub fn bytes(&self) -> u64 {
|
||||
self.bytes
|
||||
}
|
||||
/// A frame's ask, at `now`.
|
||||
pub fn ask(&mut self, ask: &WorldHold, now: Instant) -> Release {
|
||||
if !ask.is_held() {
|
||||
let was = self.since.is_some();
|
||||
(self.since, self.spent) = (None, false);
|
||||
self.ask = WorldHold::default();
|
||||
return if was && !self.waiting.is_empty() {
|
||||
Release::All
|
||||
} else {
|
||||
Release::None
|
||||
};
|
||||
}
|
||||
if self.spent {
|
||||
return Release::None;
|
||||
}
|
||||
let since = *self.since.get_or_insert(now);
|
||||
if now.saturating_duration_since(since) >= MAX_HOLD {
|
||||
log::warn!(
|
||||
"Horizon: a mod held the world for {} s: let go",
|
||||
MAX_HOLD.as_secs()
|
||||
);
|
||||
self.spend();
|
||||
return Release::All;
|
||||
}
|
||||
if *ask == self.ask {
|
||||
return Release::None;
|
||||
}
|
||||
// (fewer circles or smaller: some of what waits is let go; more: nothing waits that didn't)
|
||||
self.ask.clone_from(ask);
|
||||
if self.waiting.is_empty() {
|
||||
Release::None
|
||||
} else {
|
||||
Release::Outside
|
||||
}
|
||||
}
|
||||
/// Ended by itself: nothing waits from now on, until no mod has asked for a frame.
|
||||
/// What waited is the caller's to place (`take_all`) or drop.
|
||||
pub fn spend(&mut self) {
|
||||
self.spent = self.since.is_some();
|
||||
}
|
||||
/// Section `key`'s new mesh waits: the one that waited before for it comes back, to free.
|
||||
/// The bool is whether what waits is now too much (`MAX_BYTES`).
|
||||
pub fn wait(&mut self, key: SectionKey, section: S, bytes: u64) -> (Option<S>, bool) {
|
||||
let old = self.take(key);
|
||||
self.bytes += bytes;
|
||||
self.waiting.insert(key, Waiting { section, bytes });
|
||||
(old, self.bytes > MAX_BYTES)
|
||||
}
|
||||
/// Section `key`'s waiting mesh, no longer waiting.
|
||||
pub fn take(&mut self, key: SectionKey) -> Option<S> {
|
||||
if self.waiting.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let w = self.waiting.remove(&key)?;
|
||||
self.bytes -= w.bytes;
|
||||
Some(w.section)
|
||||
}
|
||||
/// Everything waiting, no longer waiting.
|
||||
pub fn take_all(&mut self) -> Vec<(SectionKey, S)> {
|
||||
self.bytes = 0;
|
||||
self.waiting.drain().map(|(k, w)| (k, w.section)).collect()
|
||||
}
|
||||
/// What waits that the hold no longer covers.
|
||||
pub fn take_outside(&mut self) -> Vec<(SectionKey, S)> {
|
||||
let ask = &self.ask;
|
||||
let all = self.since.is_none() || self.spent;
|
||||
let out: Vec<_> = self
|
||||
.waiting
|
||||
.extract_if(|&k, _| all || !ask.covers(k))
|
||||
.collect();
|
||||
self.bytes -= out.iter().map(|(_, w)| w.bytes).sum::<u64>();
|
||||
out.into_iter().map(|(k, w)| (k, w.section)).collect()
|
||||
}
|
||||
/// What waits in these columns, which are going.
|
||||
pub fn take_columns(&mut self, columns: &FxHashSet<(i32, i32)>) -> Vec<S> {
|
||||
if self.waiting.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let out: Vec<_> = self
|
||||
.waiting
|
||||
.extract_if(|k, _| columns.contains(&(k.0, k.2)))
|
||||
.collect();
|
||||
self.bytes -= out.iter().map(|(_, w)| w.bytes).sum::<u64>();
|
||||
out.into_iter().map(|(_, w)| w.section).collect()
|
||||
}
|
||||
/// The sections are all gone: what waits goes with them and the hold is spent.
|
||||
pub fn clear(&mut self) {
|
||||
self.waiting.clear();
|
||||
self.bytes = 0;
|
||||
self.spend();
|
||||
}
|
||||
pub fn sections(&self) -> impl Iterator<Item = &S> {
|
||||
self.waiting.values().map(|w| &w.section)
|
||||
}
|
||||
pub fn sections_mut(&mut self) -> impl Iterator<Item = &mut S> {
|
||||
self.waiting.values_mut().map(|w| &mut w.section)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn all() -> WorldHold {
|
||||
let mut h = WorldHold::default();
|
||||
h.hold();
|
||||
h
|
||||
}
|
||||
fn within(circles: &[[f64; 3]]) -> WorldHold {
|
||||
let mut h = WorldHold::default();
|
||||
for &[x, z, r] in circles {
|
||||
h.hold_within(x, z, r);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hold_covers_sections_by_their_middle() {
|
||||
assert!(all().covers((900, 0, -900)));
|
||||
assert!(!WorldHold::default().covers((0, 0, 0)));
|
||||
assert!(within(&[[8.0, 8.0, 1.0]]).covers((0, 3, 0)));
|
||||
assert!(!within(&[[0.0, 0.0, 11.0]]).covers((0, 3, 0)));
|
||||
assert!(within(&[[0.0, 0.0, 12.0]]).covers((0, 3, 0)));
|
||||
assert!(within(&[[1000.0, 0.0, 1.0], [24.0, 8.0, 0.5]]).covers((1, 0, 0)));
|
||||
assert!(within(&[[-8.0, -8.0, 0.0]]).covers((-1, 0, -1)));
|
||||
}
|
||||
#[test]
|
||||
fn bad_circles_are_left_out_and_there_are_at_most_max_areas() {
|
||||
let h = within(&[
|
||||
[f64::NAN, 0.0, 5.0],
|
||||
[0.0, f64::INFINITY, 5.0],
|
||||
[0.0, 0.0, -1.0],
|
||||
[0.0, 0.0, f64::INFINITY],
|
||||
]);
|
||||
assert!(!h.is_held());
|
||||
let mut many = WorldHold::default();
|
||||
for i in 0..MAX_AREAS + 10 {
|
||||
many.hold_within(i as f64 * 100.0, 0.0, 1.0);
|
||||
}
|
||||
assert_eq!(many.within.len(), MAX_AREAS);
|
||||
// (several mods: all of it from one holds all of it)
|
||||
many.hold();
|
||||
assert!(many.covers((-5000, 0, 5000)));
|
||||
}
|
||||
#[test]
|
||||
fn a_hold_starts_and_lets_go_when_no_one_asks() {
|
||||
let (mut g, t) = (Hold::<u32>::default(), Instant::now());
|
||||
assert_eq!(g.ask(&WorldHold::default(), t), Release::None);
|
||||
assert!(!g.holds((0, 0, 0)));
|
||||
assert_eq!(g.ask(&all(), t), Release::None);
|
||||
assert!(g.holds((0, 0, 0)));
|
||||
assert_eq!(g.ask(&all(), t + Duration::from_secs(5)), Release::None);
|
||||
// (nothing waited: nothing to place)
|
||||
assert_eq!(g.ask(&WorldHold::default(), t), Release::None);
|
||||
g.ask(&all(), t);
|
||||
g.wait((0, 0, 0), 7, 100);
|
||||
assert_eq!(g.ask(&WorldHold::default(), t), Release::All);
|
||||
assert_eq!(g.take_all(), vec![((0, 0, 0), 7)]);
|
||||
assert!(!g.holds((0, 0, 0)));
|
||||
}
|
||||
#[test]
|
||||
fn a_hold_ends_by_itself_and_stays_ended_until_let_go() {
|
||||
let (mut g, t) = (Hold::<u32>::default(), Instant::now());
|
||||
g.ask(&all(), t);
|
||||
assert_eq!(g.ask(&all(), t + MAX_HOLD), Release::All);
|
||||
assert!(!g.holds((0, 0, 0)));
|
||||
assert_eq!(g.ask(&all(), t + MAX_HOLD * 2), Release::None);
|
||||
assert!(!g.holds((0, 0, 0)));
|
||||
g.ask(&WorldHold::default(), t + MAX_HOLD * 2);
|
||||
g.ask(&all(), t + MAX_HOLD * 3);
|
||||
assert!(g.holds((0, 0, 0)));
|
||||
}
|
||||
#[test]
|
||||
fn smaller_circles_let_go_what_they_leave() {
|
||||
let (mut g, t) = (Hold::<u32>::default(), Instant::now());
|
||||
g.ask(&within(&[[0.0, 0.0, 100.0]]), t);
|
||||
assert!(g.holds((0, 0, 0)) && g.holds((5, 0, 0)) && !g.holds((7, 0, 0)));
|
||||
g.wait((0, 0, 0), 1, 10);
|
||||
g.wait((5, 0, 0), 2, 20);
|
||||
assert_eq!(g.ask(&within(&[[0.0, 0.0, 100.0]]), t), Release::None);
|
||||
assert_eq!(g.ask(&within(&[[0.0, 0.0, 40.0]]), t), Release::Outside);
|
||||
assert_eq!(g.take_outside(), vec![((5, 0, 0), 2)]);
|
||||
assert_eq!((g.sections().count(), g.bytes()), (1, 10));
|
||||
assert!(!g.holds((5, 0, 0)));
|
||||
// (to all of it again: nothing more is let go, and all of it waits again)
|
||||
assert_eq!(g.ask(&all(), t), Release::Outside);
|
||||
assert!(g.take_outside().is_empty());
|
||||
assert!(g.holds((5, 0, 0)));
|
||||
}
|
||||
#[test]
|
||||
fn what_waits_is_counted_and_capped() {
|
||||
let (mut g, t) = (Hold::<u32>::default(), Instant::now());
|
||||
g.ask(&all(), t);
|
||||
assert_eq!(g.wait((0, 0, 0), 1, MAX_BYTES - 10), (None, false));
|
||||
// (a newer mesh of the same section replaces it, and the old one comes back to free)
|
||||
assert_eq!(g.wait((0, 0, 0), 2, MAX_BYTES - 20), (Some(1), false));
|
||||
assert_eq!(g.bytes(), MAX_BYTES - 20);
|
||||
assert_eq!(g.wait((1, 0, 0), 3, 20), (None, false));
|
||||
assert_eq!(g.wait((2, 0, 0), 4, 1), (None, true));
|
||||
assert_eq!(g.take((1, 0, 0)), Some(3));
|
||||
assert_eq!(g.take((1, 0, 0)), None);
|
||||
assert_eq!(g.bytes(), MAX_BYTES - 19);
|
||||
}
|
||||
#[test]
|
||||
fn columns_going_and_the_sections_cleared_drop_what_waits() {
|
||||
let (mut g, t) = (Hold::<u32>::default(), Instant::now());
|
||||
g.ask(&all(), t);
|
||||
g.wait((0, 0, 0), 1, 10);
|
||||
g.wait((0, 5, 0), 2, 10);
|
||||
g.wait((3, 0, 0), 3, 10);
|
||||
let gone: FxHashSet<(i32, i32)> = [(0, 0)].into_iter().collect();
|
||||
let mut dropped = g.take_columns(&gone);
|
||||
dropped.sort_unstable();
|
||||
assert_eq!(dropped, vec![1, 2]);
|
||||
assert_eq!((g.sections().count(), g.bytes()), (1, 10));
|
||||
g.clear();
|
||||
assert!(g.is_empty() && g.bytes() == 0 && !g.holds((3, 0, 0)));
|
||||
// (cleared while held: spent until let go; with no hold, nothing to end)
|
||||
assert_eq!(g.ask(&all(), t), Release::None);
|
||||
assert!(!g.holds((3, 0, 0)));
|
||||
let mut none = Hold::<u32>::default();
|
||||
none.clear();
|
||||
none.ask(&all(), t);
|
||||
assert!(none.holds((3, 0, 0)));
|
||||
}
|
||||
}
|
||||
@@ -446,7 +446,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn coverage_wraps_the_debug_crosshair_without_changing_world_entry_points() {
|
||||
let mut module = naga::front::wgsl::parse_str(include_str!("../terrain.wgsl")).unwrap();
|
||||
let mut module = naga::front::wgsl::parse_str(&crate::gfx::terrain_shader()).unwrap();
|
||||
wrap(
|
||||
&mut module,
|
||||
"fs_debug_line",
|
||||
|
||||
@@ -98,8 +98,24 @@ fn interfaces(source: &str) -> Vec<Interface> {
|
||||
|
||||
/// The shaders as the renderer assembles them, by the `.wgsl` files each is made of.
|
||||
fn composed() -> Vec<(&'static str, Vec<&'static str>, String)> {
|
||||
let entity = crate::overlay::wgsl_constants() + include_str!("../entity.wgsl");
|
||||
let entity = super::entity_shader();
|
||||
vec![
|
||||
(
|
||||
"HDR",
|
||||
vec!["hdr.wgsl", "hdr_light.wgsl"],
|
||||
super::hdr::shader(),
|
||||
),
|
||||
("terrain", vec!["terrain.wgsl"], super::terrain_shader()),
|
||||
(
|
||||
"stars",
|
||||
vec!["stars.wgsl"],
|
||||
include_str!("../hdr_light.wgsl").to_owned() + include_str!("stars.wgsl"),
|
||||
),
|
||||
(
|
||||
"custom sky",
|
||||
vec!["custom_sky.wgsl"],
|
||||
include_str!("../hdr_light.wgsl").to_owned() + include_str!("custom_sky.wgsl"),
|
||||
),
|
||||
("entity", vec!["entity.wgsl"], entity.clone()),
|
||||
(
|
||||
"GPU culling occlusion test",
|
||||
@@ -124,14 +140,14 @@ fn composed() -> Vec<(&'static str, Vec<&'static str>, String)> {
|
||||
(
|
||||
"improved transparency world",
|
||||
vec!["terrain.wgsl", "oit.wgsl", "oit_world.wgsl"],
|
||||
include_str!("../terrain.wgsl").to_owned()
|
||||
super::terrain_shader()
|
||||
+ include_str!("../oit.wgsl")
|
||||
+ include_str!("../oit_world.wgsl"),
|
||||
),
|
||||
(
|
||||
"visibility buffer",
|
||||
vec!["terrain.wgsl", "visbuffer.wgsl"],
|
||||
include_str!("../terrain.wgsl").to_owned() + include_str!("../visbuffer.wgsl"),
|
||||
super::terrain_shader() + include_str!("../visbuffer.wgsl"),
|
||||
),
|
||||
(
|
||||
"improved transparency world border",
|
||||
|
||||
@@ -49,9 +49,23 @@ struct HorizonMaterialOut {
|
||||
@location(14) @interpolate(flat) normal: vec3<f32>,
|
||||
}
|
||||
fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
|
||||
light: vec2<u32>, origin: vec3<f32>) -> HorizonMaterialOut {
|
||||
let pos = horizon_material_displace(position + origin, uv, layer, color, light);
|
||||
return horizon_material_out(pos, uv, layer, color, vec2<f32>(light));
|
||||
light: vec2<u32>, origin: vec4<f32>) -> HorizonMaterialOut {
|
||||
let pos = horizon_material_displace(position + origin.xyz, uv, layer, color, light);
|
||||
var out = horizon_material_out(pos, uv, layer, color, vec2<f32>(light));
|
||||
// The section's fade-in (`RenderSection.getVisibility`), as how far it is from whole, in the
|
||||
// high 16 bits of `hdr_code`: no varying of its own, and 0 (whole) where nothing fades. It is
|
||||
// the same for the whole section, so the flat varying loses nothing.
|
||||
let instance = horizon_terrain_instance_visibility(origin.w);
|
||||
out.hdr_code |= u32(round((1.0 - instance) * 65535.0)) << 16u;
|
||||
return out;
|
||||
}
|
||||
// terrain.wgsl's fade-in (terrain.vsh's chunkVisibility): whole within 16 blocks, the section's
|
||||
// own visibility from 32 out, toward the fog colour, alpha kept. The distance blend runs here on
|
||||
// the interpolated distance, as vanilla's interpolated `visibility`, not once per triangle.
|
||||
fn horizon_material_faded(in: HorizonMaterialOut, color: vec4<f32>) -> vec4<f32> {
|
||||
let instance = 1.0 - f32(in.hdr_code >> 16u) / 65535.0;
|
||||
let visibility = mix(1.0, instance, clamp((in.spherical - 16.0) / 16.0, 0.0, 1.0));
|
||||
return vec4(mix(horizon_terrain_globals.fog_color.rgb, color.rgb, visibility), color.a);
|
||||
}
|
||||
// `light`: sky, block, 16 per level.
|
||||
fn horizon_material_out(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
|
||||
@@ -94,7 +108,7 @@ fn horizon_material_out(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f
|
||||
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>,
|
||||
@location(5) origin: vec4<f32>) -> HorizonMaterialOut {
|
||||
return horizon_material_vertex(position, horizon_terrain_terrain_uv(uv, origin.w), layer,
|
||||
color, light, origin.xyz);
|
||||
color, light, origin);
|
||||
}
|
||||
@vertex fn horizon_material_vs_flat(
|
||||
@builtin(vertex_index) vertex: u32,
|
||||
@@ -103,7 +117,7 @@ fn horizon_material_out(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f
|
||||
) -> HorizonMaterialOut {
|
||||
let origin = horizon_terrain_slot_origins[horizon_terrain_block_slots[vertex >> 8u]];
|
||||
return horizon_material_vertex(position, horizon_terrain_terrain_uv(uv, origin.w), layer,
|
||||
color, light, origin.xyz);
|
||||
color, light, origin);
|
||||
}
|
||||
// The prelude's `block_normal_map` and `block_specular_map`: the resource packs' LabPBR maps
|
||||
// in the pack's frame group (3), one array each, a blank 1x1 without them.
|
||||
@@ -181,9 +195,10 @@ struct HorizonMaterialSolid {
|
||||
// What `fs_solid` leaves in the frame's alpha for HDR output, from the sprite before the pack's
|
||||
// material: the pack decides the colour, Horizon still knows which pixels are light sources.
|
||||
fn horizon_material_mark(in: HorizonMaterialOut, texel: vec4<f32>) -> f32 {
|
||||
if in.hdr_code > 0u {
|
||||
let whole = in.hdr_code > 4u;
|
||||
let tier = select(in.hdr_code, in.hdr_code - 4u, whole);
|
||||
let code = in.hdr_code & 15u;
|
||||
if code > 0u {
|
||||
let whole = code > 4u;
|
||||
let tier = select(code, code - 4u, whole);
|
||||
let l = in.layer & 0x7fffu;
|
||||
let t = textureLoad(horizon_terrain_emission_thresholds,
|
||||
vec2(i32(l % horizon_terrain_SPRITE_SPLIT), i32(l / horizon_terrain_SPRITE_SPLIT)), 0);
|
||||
@@ -202,7 +217,7 @@ fn horizon_material_mark(in: HorizonMaterialOut, texel: vec4<f32>) -> f32 {
|
||||
return horizon_material_solid(in);
|
||||
}
|
||||
fn horizon_material_solid(in: HorizonMaterialOut) -> HorizonMaterialSolid {
|
||||
let color = horizon_material_color(in, false);
|
||||
let color = horizon_material_faded(in, horizon_material_color(in, false));
|
||||
let texel = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
|
||||
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
|
||||
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
|
||||
@@ -216,7 +231,7 @@ fn horizon_material_solid(in: HorizonMaterialOut) -> HorizonMaterialSolid {
|
||||
return out;
|
||||
}
|
||||
@fragment fn horizon_material_fs_translucent(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
|
||||
let color = horizon_material_color(in, true);
|
||||
let color = horizon_material_faded(in, horizon_material_color(in, true));
|
||||
if color.a < 0.01 { discard; }
|
||||
return horizon_material_fogged(color, in.spherical, in.cylindrical);
|
||||
}
|
||||
@@ -294,7 +309,8 @@ pub struct NativeMaterial {
|
||||
/// every name it declares prefixed.
|
||||
fn vanilla_terrain() -> String {
|
||||
prefixed(
|
||||
section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)"),
|
||||
&(include_str!("../hdr_light.wgsl").to_owned()
|
||||
+ section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)")),
|
||||
PREFIX,
|
||||
)
|
||||
}
|
||||
@@ -582,6 +598,25 @@ mod tests {
|
||||
report.stages.remove(0)
|
||||
}
|
||||
|
||||
/// The fade-in packed above the HDR code: a section a quarter visible (`section_instance`
|
||||
/// with step 1: mantissa `1 - visibility` = 0.75) keeps its visibility through the code, and
|
||||
/// a fragment 24 blocks away, half-way into the 16-block blend, is 1 - (1 - 0.25) / 2 = 0.625
|
||||
/// visible; the code's low nibble survives.
|
||||
#[test]
|
||||
fn the_fade_in_rides_the_hdr_code() {
|
||||
let w = f32::from_bits(1.0f32.to_bits() | (0.75 * 8_388_608.0) as u32);
|
||||
// `horizon_material_vertex`.
|
||||
let instance = 1.0 - (w.to_bits() & 0x7fffff) as f32 / 8_388_608.0;
|
||||
let code = 7u32 | (((1.0 - instance) * 65535.0).round() as u32) << 16;
|
||||
// `horizon_material_faded` and `horizon_material_mark`.
|
||||
let unpacked = 1.0 - (code >> 16) as f32 / 65535.0;
|
||||
assert!((unpacked - 0.25).abs() < 1.0 / 65535.0);
|
||||
let blend = ((24.0f32 - 16.0) / 16.0).clamp(0.0, 1.0);
|
||||
let visibility = 1.0 + (unpacked - 1.0) * blend;
|
||||
assert!((visibility - 0.625).abs() < 1.0 / 65535.0);
|
||||
assert_eq!(code & 15, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_terrain_takes_a_prefix_and_validates_with_a_pack() {
|
||||
let terrain = vanilla_terrain();
|
||||
|
||||
@@ -73,10 +73,15 @@ pub struct WorldDrawn<'a> {
|
||||
pub entities: &'a [Vertex],
|
||||
pub spans: &'a [EntitySpan],
|
||||
/// Which entity each run of `entities` is, recorded every frame (`Frame::entity_motion`):
|
||||
/// the run's first vertex and the entity, `(1 << 32) | id` for a mob or a player and
|
||||
/// `(2 << 32) | id` for a dropped item (their entity ids), 0 for anything else. A run lasts
|
||||
/// until the next. Unlike `spans`, whose ids name a type, these follow one entity from
|
||||
/// frame to frame (API 371).
|
||||
/// the run's first vertex and the entity, `(1 << 32) | id` for a mob or a player,
|
||||
/// `(2 << 32) | id` for a dropped item, `(3 << 32) | id` for another entity (boat,
|
||||
/// minecart, armor stand, item frame, painting, primed TNT, falling block) and
|
||||
/// `(4 << 32) | id` for an item frame's held item (their entity ids), `(5 << 32) | p` for a
|
||||
/// block entity (`p`: its position, x and z in 11 bits each, y in 10), `(6 << 32) | p`
|
||||
/// for a block a piston moves (`p`: the cell it moves to), `7 << 32` for the player's own
|
||||
/// model, 0 for anything else.
|
||||
/// A run lasts until the next. Unlike `spans`, whose ids name a type, these follow one
|
||||
/// entity from frame to frame (API 371).
|
||||
pub motion: &'a [(u32, u64)],
|
||||
/// The shared atlas entity UVs point into.
|
||||
pub atlas: Option<&'a wgpu::Texture>,
|
||||
|
||||
@@ -512,7 +512,8 @@ fn shader_source(
|
||||
format!(
|
||||
"{head}// Horizon's terrain (terrain.wgsl)\n{}{solid}{}{displace}{ENTRIES}",
|
||||
prefixed(
|
||||
section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)"),
|
||||
&(include_str!("../hdr_light.wgsl").to_owned()
|
||||
+ section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)")),
|
||||
PREFIX
|
||||
),
|
||||
super::native_post::line_helpers(api)
|
||||
|
||||
@@ -30,6 +30,7 @@ pub struct NativeSky {
|
||||
/// terrain.wgsl's sky with every name it declares (and `globals`) prefixed.
|
||||
fn vanilla_sky() -> String {
|
||||
let text = [
|
||||
include_str!("../hdr_light.wgsl"),
|
||||
section(TERRAIN, "struct Globals {", "@group(0) @binding(1)"),
|
||||
section(TERRAIN, "fn linear_fog(", "fn apply_fog("),
|
||||
section(TERRAIN, "struct SkyOut {", "// rendertype_lines"),
|
||||
|
||||
@@ -192,7 +192,7 @@ impl Oit {
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon improved transparency"),
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
(include_str!("../terrain.wgsl").to_owned()
|
||||
(super::terrain_shader()
|
||||
+ include_str!("../oit.wgsl")
|
||||
+ include_str!("../oit_world.wgsl"))
|
||||
.into(),
|
||||
@@ -253,7 +253,14 @@ impl Oit {
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
let constants = [("SPRITE_ARRAYS", sprites[0]), ("SPRITE_SPLIT", sprites[1])];
|
||||
let constants = [
|
||||
("SPRITE_ARRAYS", sprites[0]),
|
||||
("SPRITE_SPLIT", sprites[1]),
|
||||
(
|
||||
"HDR_WRITE_LIGHT",
|
||||
f64::from(u8::from(format == super::LINEAR_FRAME)),
|
||||
),
|
||||
];
|
||||
let add = wgpu::BlendOperation::Add;
|
||||
let depth = |write| {
|
||||
Some(wgpu::DepthStencilState {
|
||||
@@ -464,7 +471,10 @@ impl Oit {
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &cloud_shader,
|
||||
entry_point: Some(fragment),
|
||||
compilation_options: Default::default(),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &constants[2..],
|
||||
..Default::default()
|
||||
},
|
||||
targets: &stage_targets(stage),
|
||||
}),
|
||||
multiview_mask: None,
|
||||
@@ -488,7 +498,14 @@ impl Oit {
|
||||
Stage::Accumulate,
|
||||
),
|
||||
];
|
||||
let features = Features::new(device, &layouts, features[0], features[1], features[2]);
|
||||
let features = Features::new(
|
||||
device,
|
||||
&layouts,
|
||||
features[0],
|
||||
features[1],
|
||||
features[2],
|
||||
format,
|
||||
);
|
||||
Self {
|
||||
uniform,
|
||||
layouts,
|
||||
@@ -912,7 +929,7 @@ mod tests {
|
||||
#[test]
|
||||
fn the_terrain_shader_validates() {
|
||||
validate(
|
||||
&(include_str!("../terrain.wgsl").to_owned()
|
||||
&(crate::gfx::terrain_shader()
|
||||
+ include_str!("../oit.wgsl")
|
||||
+ include_str!("../oit_world.wgsl")),
|
||||
&[
|
||||
|
||||
@@ -55,6 +55,7 @@ impl Features {
|
||||
entity: &wgpu::BindGroupLayout,
|
||||
weather: &wgpu::BindGroupLayout,
|
||||
border: &wgpu::BindGroupLayout,
|
||||
format: wgpu::TextureFormat,
|
||||
) -> Self {
|
||||
let entity_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon improved transparency features"),
|
||||
@@ -113,6 +114,15 @@ impl Features {
|
||||
bind_group_layouts: &[groups[0], groups[1], Some(&layouts[stage as usize])],
|
||||
..Default::default()
|
||||
});
|
||||
let fragment = if kind.label == "text" && stage == Stage::Accumulate {
|
||||
"fs_oit_text_accumulate".to_owned()
|
||||
} else {
|
||||
format!("fs_oit_{}_{suffix}", kind.name)
|
||||
};
|
||||
let constants = [(
|
||||
"HDR_WRITE_LIGHT",
|
||||
f64::from(u8::from(format == super::LINEAR_FRAME)),
|
||||
)];
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(&format!("OIT {} {suffix}", kind.label)),
|
||||
layout: Some(&layout),
|
||||
@@ -136,8 +146,15 @@ impl Features {
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module,
|
||||
entry_point: Some(&format!("fs_oit_{}_{suffix}", kind.name)),
|
||||
compilation_options: Default::default(),
|
||||
entry_point: Some(&fragment),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: if kind.name == "border" {
|
||||
&[]
|
||||
} else {
|
||||
&constants
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
targets: &stage_targets(stage),
|
||||
}),
|
||||
multiview_mask: None,
|
||||
|
||||
@@ -728,6 +728,13 @@ impl PackAtlas {
|
||||
),
|
||||
("mc_Entity", entity),
|
||||
("at_midBlock", mid_block),
|
||||
// Horizon's own: the face's HDR tier (`mesher::emitting`'s code in the colour's low
|
||||
// alpha nibble): 0 for none, 1 to 4 for low to very bright, plus 4 when the whole
|
||||
// face shines (`mesher::WHOLE`).
|
||||
(
|
||||
"horizonTier",
|
||||
"float(15u - (uint(round(hz_v_color.a * 255.0)) & 15u))",
|
||||
),
|
||||
(
|
||||
"mc_midTexCoord",
|
||||
"vec4((hz_cell + hz_mid) * hz_scale, 0.0, 1.0)",
|
||||
@@ -750,6 +757,7 @@ impl PackAtlas {
|
||||
alpha_test: None,
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +157,7 @@ fn terrain_fade_reaches_pack_pixels_through_real_instances() {
|
||||
flat,
|
||||
geometry,
|
||||
origin,
|
||||
0x80ffffff,
|
||||
);
|
||||
let fade = if requested { expected } else { 1.0 };
|
||||
let expected = [
|
||||
@@ -182,6 +183,96 @@ fn terrain_fade_reaches_pack_pixels_through_real_instances() {
|
||||
assert_eq!(count, 98);
|
||||
}
|
||||
|
||||
/// `horizonTier`: the HDR tier the mesher marks a light source's faces with (`mesher::emitting`),
|
||||
/// read by a pack's terrain program, flat or instanced.
|
||||
#[test]
|
||||
fn terrain_tier_reaches_pack_pixels() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = match pollster::block_on(instance.request_adapter(&Default::default())) {
|
||||
Ok(adapter) => adapter,
|
||||
Err(error) => {
|
||||
eprintln!("SKIP terrain tier GPU control: no adapter: {error}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let array = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 2,
|
||||
height: 2,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let atlas = PackAtlas::new(&device, &mut encoder, &[array], 1, 1, 2).unwrap();
|
||||
queue.submit([encoder.finish()]);
|
||||
let folder = ProbePack(
|
||||
std::env::temp_dir().join(format!("horizon-terrain-tier-{}", std::process::id())),
|
||||
);
|
||||
std::fs::create_dir_all(folder.0.join("shaders")).unwrap();
|
||||
std::fs::write(
|
||||
folder.0.join("shaders/gbuffers_terrain.vsh"),
|
||||
"#version 330\nin float horizonTier;\nout vec4 observation;\n\
|
||||
void main() {\nobservation = vec4(horizonTier, 0.0, 0.0, 0.0);\n\
|
||||
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);\n}",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
folder.0.join("shaders/gbuffers_terrain.fsh"),
|
||||
"#version 330\nin vec4 observation;\nlayout(location = 0) out uvec4 pixel;\n\
|
||||
void main() { pixel = floatBitsToUint(observation); }",
|
||||
)
|
||||
.unwrap();
|
||||
let pack = horizon_shaders::Pack::open(&folder.0).unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
use crate::mesher::{BRIGHT, LOW, VERY_BRIGHT, WHOLE, emitting};
|
||||
// No light, low, very bright, and bright all over (4 more).
|
||||
let cases = [
|
||||
(0, 0.0),
|
||||
(LOW, 1.0),
|
||||
(VERY_BRIGHT, 4.0),
|
||||
(BRIGHT | WHOLE, 7.0),
|
||||
];
|
||||
for flat in [false, true] {
|
||||
let post = horizon_shaders::load_post(
|
||||
&pack,
|
||||
"",
|
||||
&properties,
|
||||
¯os,
|
||||
&horizon_shaders::GameInputs {
|
||||
terrain: Some(atlas.terrain_inputs(flat)),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let program = post
|
||||
.terrain
|
||||
.as_ref()
|
||||
.unwrap_or_else(|| panic!("{:?}", post.notes));
|
||||
for (tier, expected) in cases {
|
||||
let origin = section_instance([0.0; 3], 0, 1.0);
|
||||
let pixel = sample(
|
||||
&device,
|
||||
&queue,
|
||||
&atlas,
|
||||
program.program.as_ref().unwrap(),
|
||||
flat,
|
||||
false,
|
||||
origin,
|
||||
emitting(0xffff_ffff, tier),
|
||||
);
|
||||
assert_eq!(pixel[0], expected, "tier {tier} flat={flat}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sample(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
@@ -190,6 +281,7 @@ fn sample(
|
||||
flat: bool,
|
||||
geometry: bool,
|
||||
origin: [f32; 4],
|
||||
color: u32,
|
||||
) -> [f32; 4] {
|
||||
let buffer = |bytes: &[u8], usage| {
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
@@ -209,7 +301,7 @@ fn sample(
|
||||
let slots = buffer(bytemuck::bytes_of(&0u32), wgpu::BufferUsages::STORAGE);
|
||||
let vertices: Vec<_> = [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.]]
|
||||
.into_iter()
|
||||
.map(|pos| TerrainVertex::new(pos, [0.25, 0.75], 0, 0x80ffffff, 0))
|
||||
.map(|pos| TerrainVertex::new(pos, [0.25, 0.75], 0, color, 0))
|
||||
.collect();
|
||||
let data = buffer(
|
||||
bytemuck::cast_slice(&vertices),
|
||||
|
||||
@@ -107,6 +107,10 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
"at_velocity",
|
||||
"hz_velocity(gbufferModelView, gbufferPreviousModelView)",
|
||||
),
|
||||
// Horizon's own, as for terrain: the HDR tier of a light-giving item, particle or glowing
|
||||
// text (`mesher::hdr_tier`, the overlay's high bits), 0 for none, 1 to 4 for low to very
|
||||
// bright.
|
||||
("horizonTier", "float((hz_v_overlay >> 16u) & 255u)"),
|
||||
];
|
||||
horizon_shaders::GbuffersInputs {
|
||||
declarations,
|
||||
@@ -123,6 +127,8 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
}),
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
// Entities, items and block entities read the shared atlas: its own maps (paged_maps.rs).
|
||||
paged_maps: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,6 +192,7 @@ pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
|
||||
alpha_test: None,
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1628,3 +1635,44 @@ mod velocity_translate_tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tier_translate_tests {
|
||||
/// A pack's entity and hand programs may read `horizonTier`, the overlay's HDR tier.
|
||||
#[test]
|
||||
fn a_pack_reading_horizon_tier_translates_for_entities_and_the_hand() {
|
||||
let dir = std::env::temp_dir().join(format!("hz-entity-tier-{}", std::process::id()));
|
||||
let shaders = dir.join("shaders");
|
||||
std::fs::create_dir_all(&shaders).unwrap();
|
||||
std::fs::write(
|
||||
shaders.join("gbuffers_entities.vsh"),
|
||||
"#version 120\nattribute float horizonTier;\nvarying float t;\n\
|
||||
void main() {\n gl_Position = ftransform();\n t = horizonTier;\n}\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
shaders.join("gbuffers_entities.fsh"),
|
||||
"#version 120\nvarying float t;\nvoid main() {\n gl_FragData[0] = vec4(t);\n}\n",
|
||||
)
|
||||
.unwrap();
|
||||
let pack = horizon_shaders::Pack::open(&dir).unwrap();
|
||||
let m = horizon_shaders::Macros::standard();
|
||||
let vsh = horizon_shaders::preprocess(&pack, "gbuffers_entities.vsh", &m);
|
||||
let fsh = horizon_shaders::preprocess(&pack, "gbuffers_entities.fsh", &m);
|
||||
for (who, inputs) in [
|
||||
("entities", super::inputs()),
|
||||
("hand", super::super::pack_hand::inputs()),
|
||||
] {
|
||||
let attribute = inputs.attributes.iter().find(|(n, _)| n == "horizonTier");
|
||||
assert!(attribute.is_some(), "{who}");
|
||||
horizon_shaders::translate_gbuffers(
|
||||
&vsh,
|
||||
&fsh,
|
||||
horizon_shaders::Target::Buffers,
|
||||
&inputs,
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("{who}: {e:?}"));
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,9 @@ pub(super) fn format(f: Format, packed_float: bool) -> wgpu::TextureFormat {
|
||||
_ => rgba,
|
||||
};
|
||||
match (f.kind, f.bits) {
|
||||
(Kind::Unorm, 10) => T::Rgb10a2Unorm,
|
||||
(Kind::Uint, 10) => T::Rgb10a2Uint,
|
||||
(Kind::Snorm, 16) => pick(T::R16Snorm, T::Rg16Snorm, T::Rgba16Snorm),
|
||||
(Kind::Int, 8) => pick(T::R8Sint, T::Rg8Sint, T::Rgba8Sint),
|
||||
(Kind::Int, 16) => pick(T::R16Sint, T::Rg16Sint, T::Rgba16Sint),
|
||||
(Kind::Int, _) => pick(T::R32Sint, T::Rg32Sint, T::Rgba32Sint),
|
||||
|
||||
@@ -52,6 +52,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
alpha_test: None,
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
//! Paged maps (`paged_maps.rs`) through the translator's `hz_pagedTexture` and the pass's
|
||||
//! binding: a pack reads the map's texels at the colour's coordinates.
|
||||
use super::*;
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
/// The atlas the fixture's coordinates are in, and the one mapped texture's place.
|
||||
const ATLAS: (u32, u32) = (64, 32);
|
||||
const SPRITE: [u32; 4] = [20, 4, 20, 20];
|
||||
|
||||
/// The fixture's normal map texel at texel (x, y) of its texture.
|
||||
fn texel(x: u32, y: u32) -> [u8; 4] {
|
||||
[x as u8 * 10, y as u8 * 10, 7, 255]
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a host GPU; missing adapter is a failure"]
|
||||
fn a_pack_reads_paged_maps_at_the_colours_coordinates() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
required_limits: adapter.limits(),
|
||||
..Default::default()
|
||||
}))
|
||||
.unwrap();
|
||||
let root = std::env::temp_dir().join(format!("horizon-paged-pack-{}", std::process::id()));
|
||||
std::fs::create_dir_all(root.join("shaders")).unwrap();
|
||||
std::fs::write(
|
||||
root.join("shaders/gbuffers_terrain.vsh"),
|
||||
"#version 120\nvoid main(){gl_Position=gl_Vertex;}",
|
||||
)
|
||||
.unwrap();
|
||||
// Each pixel reads one atlas texel's centre: in the texture (on four pages), beside it,
|
||||
// on a page without maps, and the specular map, which no texture has.
|
||||
std::fs::write(
|
||||
root.join("shaders/gbuffers_terrain.fsh"),
|
||||
r#"#version 120
|
||||
uniform sampler2D normals;
|
||||
uniform sampler2D specular;
|
||||
vec2 at(float x, float y) { return (vec2(x, y) + 0.5) / vec2(64.0, 32.0); }
|
||||
void main() {
|
||||
int x = int(floor(gl_FragCoord.x));
|
||||
if (x == 0) gl_FragData[0] = texture2D(normals, at(20.0, 4.0));
|
||||
else if (x == 1) gl_FragData[0] = texture2D(normals, at(39.0, 23.0));
|
||||
else if (x == 2) gl_FragData[0] = texture2D(normals, at(31.0, 15.0), 2.0);
|
||||
else if (x == 3) gl_FragData[0] = texture2DLod(normals, at(32.0, 16.0), 3.0);
|
||||
else if (x == 4) gl_FragData[0] = texture2D(normals, at(19.0, 4.0));
|
||||
else if (x == 5) gl_FragData[0] = texture2D(normals, at(50.0, 30.0));
|
||||
else if (x == 6) gl_FragData[0] = texture2D(specular, at(25.0, 10.0));
|
||||
else gl_FragData[0] = texture2D(normals, at(33.0, 5.0));
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let [x0, y0, w, h] = SPRITE;
|
||||
let normal: Vec<u8> = (0..w * h).flat_map(|i| texel(i % w, i / w)).collect();
|
||||
let [normals, specular] = crate::paged_maps::build(
|
||||
ATLAS,
|
||||
&[crate::paged_maps::SpriteMaps {
|
||||
rect: SPRITE,
|
||||
maps: [Some(normal), None],
|
||||
}],
|
||||
16384,
|
||||
);
|
||||
assert!(specular.is_none());
|
||||
let normals = normals.unwrap();
|
||||
let map = device
|
||||
.create_texture_with_data(
|
||||
&queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: Some("Fixture paged normal map"),
|
||||
size: wgpu::Extent3d {
|
||||
width: normals.width,
|
||||
height: normals.height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
&normals.pixels,
|
||||
)
|
||||
.create_view(&Default::default());
|
||||
let pack = horizon_shaders::Pack::open(&root).unwrap();
|
||||
let macros = horizon_shaders::Macros::standard();
|
||||
let properties = horizon_shaders::Properties::parse("alphaTest.gbuffers_terrain=off", ¯os);
|
||||
let inputs = horizon_shaders::GameInputs {
|
||||
terrain: Some(horizon_shaders::GbuffersInputs {
|
||||
body: "vec2 p = vec2(float((gl_VertexIndex << 1) & 2), float(gl_VertexIndex & 2)); hz_Vertex=vec4(p*2.0-1.0,0.0,1.0);".into(),
|
||||
paged_maps: true,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let post = horizon_shaders::load_post(&pack, "", &properties, ¯os, &inputs);
|
||||
assert!(post.terrain.is_some(), "{:?}", post.notes);
|
||||
let world = World {
|
||||
sprite_rects: &[],
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap: map.clone(),
|
||||
terrain: Some(Geometry {
|
||||
buffers: &[],
|
||||
table: None,
|
||||
depth_write: true,
|
||||
atlas: None,
|
||||
material_maps: false,
|
||||
ids: None,
|
||||
cull: false,
|
||||
}),
|
||||
block_ids: &[],
|
||||
celestial: None,
|
||||
gui_size: [1, 1],
|
||||
clouds: None,
|
||||
weather: false,
|
||||
hand: false,
|
||||
};
|
||||
let mut post = PackPost::new(
|
||||
&device,
|
||||
&queue,
|
||||
&adapter,
|
||||
post,
|
||||
wgpu::TextureFormat::Rgba8Unorm,
|
||||
world,
|
||||
&Default::default(),
|
||||
)
|
||||
.expect("the pack runs");
|
||||
let mut pass = post
|
||||
.terrain
|
||||
.take()
|
||||
.unwrap_or_else(|| panic!("terrain pipeline must compile: {:?}", post.notes));
|
||||
// As the entity passes are (`paged`), with the maps the renderer hands over.
|
||||
pass.paged = true;
|
||||
post.set_paged_maps([Some(map), None]);
|
||||
post.copied |= 1;
|
||||
post.resize(&device, [8, 1]);
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 256,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let _clear = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Initialize fixture depth"),
|
||||
color_attachments: &[],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &post.targets.as_ref().unwrap().depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(1.0),
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
let group = post.bind(&device, &queue, &mut encoder, &mut pass, [8, 1]);
|
||||
{
|
||||
let mut render = post.begin(&mut encoder, &pass);
|
||||
render.set_pipeline(pass.render());
|
||||
render.set_bind_group(0, &group, &[]);
|
||||
if pass.draw {
|
||||
post.draws.bind(&mut render, "TERRAIN_SOLID");
|
||||
}
|
||||
render.draw(0..3, 0..1);
|
||||
}
|
||||
let output = post.targets.as_ref().unwrap().color[0].as_ref().unwrap()[0].texture();
|
||||
encoder.copy_texture_to_buffer(
|
||||
output.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &read,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
bytes_per_row: Some(256),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: 8,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let data = read.slice(..).get_mapped_range().unwrap();
|
||||
let local = |x: u32, y: u32| texel(x - x0, y - y0);
|
||||
let expected = [
|
||||
local(20, 4),
|
||||
local(39, 23),
|
||||
local(31, 15),
|
||||
local(32, 16),
|
||||
crate::sprites::FLAT_NORMAL,
|
||||
crate::sprites::FLAT_NORMAL,
|
||||
crate::sprites::NO_SPECULAR,
|
||||
local(33, 5),
|
||||
];
|
||||
for (i, e) in expected.iter().enumerate() {
|
||||
assert_eq!(&data[i * 4..i * 4 + 4], e, "pixel {i}");
|
||||
}
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
@@ -61,6 +61,9 @@ mod shadow_faces_tests;
|
||||
#[cfg(test)]
|
||||
mod r11f_image_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod work_group_fold_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod forced_flip_tests;
|
||||
|
||||
@@ -430,6 +433,9 @@ struct Pass {
|
||||
/// What `texture` reads instead of the block atlas.
|
||||
atlas: Option<wgpu::TextureView>,
|
||||
material_maps: bool,
|
||||
/// Draws the shared atlas's geometry: `normals` and `specular` are its paged maps
|
||||
/// (`PackPost::set_paged_maps`), as its programs were translated to read them.
|
||||
paged: bool,
|
||||
/// Reads `mc_Entity`'s table (`PackPost::block_ids`).
|
||||
ids: bool,
|
||||
/// Bind groups by the sides read (bits 0–15), the buffers whose mip chain is current (bits
|
||||
@@ -674,6 +680,8 @@ pub struct PackPost {
|
||||
custom: horizon_shaders::Custom,
|
||||
atlas: Option<wgpu::TextureView>,
|
||||
material_maps: Option<[wgpu::TextureView; 2]>,
|
||||
/// The shared atlas's normal and specular paged maps (`set_paged_maps`).
|
||||
paged_maps: [Option<wgpu::TextureView>; 2],
|
||||
lightmap: wgpu::TextureView,
|
||||
deferred_pre: u16,
|
||||
composite_pre: u16,
|
||||
@@ -2179,6 +2187,7 @@ fn build_pass(
|
||||
draw,
|
||||
atlas: geometry.and_then(|g| g.atlas.clone()),
|
||||
material_maps: geometry.is_some_and(|g| g.material_maps),
|
||||
paged: false,
|
||||
ids: geometry.is_some_and(|g| g.ids.is_some()),
|
||||
groups: HashMap::new(),
|
||||
portal_groups: HashMap::new(),
|
||||
@@ -2319,6 +2328,7 @@ fn build_compute(
|
||||
draw: false,
|
||||
atlas: None,
|
||||
material_maps: false,
|
||||
paged: false,
|
||||
ids: false,
|
||||
groups: HashMap::new(),
|
||||
portal_groups: HashMap::new(),
|
||||
@@ -2357,6 +2367,13 @@ impl Refused {
|
||||
}
|
||||
|
||||
impl PackPost {
|
||||
/// The shared atlas's normal and specular paged maps (`paged_maps.rs`), which the programs
|
||||
/// drawing its geometry read as `normals` and `specular`; set before the first frame (a pass's
|
||||
/// bind groups are kept once made). A missing one reads as the dummy (flat, no specular).
|
||||
pub fn set_paged_maps(&mut self, maps: [Option<wgpu::TextureView>; 2]) {
|
||||
self.paged_maps = maps;
|
||||
}
|
||||
|
||||
/// Builds the pipelines of `post` for a screen of `screen` format. An error when the pack
|
||||
/// can't show anything on the screen: the game turns it off, as a pack that fails to load.
|
||||
///
|
||||
@@ -2780,6 +2797,13 @@ impl PackPost {
|
||||
ids: None,
|
||||
cull: false,
|
||||
});
|
||||
// Programs drawing the shared atlas's geometry read its paged maps (`pack_entities::inputs`).
|
||||
let paged = |pass: Option<Pass>| {
|
||||
pass.map(|mut pass| {
|
||||
pass.paged = true;
|
||||
pass
|
||||
})
|
||||
};
|
||||
let entity_passes = programs.map(|p| {
|
||||
p.zip(entity_geometry.as_ref()).and_then(|(p, g)| {
|
||||
if p.name == "gbuffers_damagedblock" {
|
||||
@@ -2792,15 +2816,15 @@ impl PackPost {
|
||||
ids: g.ids,
|
||||
cull: false,
|
||||
};
|
||||
build(p, Some(&decal))
|
||||
paged(build(p, Some(&decal)))
|
||||
} else {
|
||||
build(p, Some(g))
|
||||
paged(build(p, Some(g)))
|
||||
}
|
||||
})
|
||||
});
|
||||
let shadow_entities = shadow_programs.map(|p| {
|
||||
p.zip(entity_geometry.as_ref())
|
||||
.and_then(|(p, g)| build(p, Some(g)))
|
||||
.and_then(|(p, g)| paged(build(p, Some(g))))
|
||||
});
|
||||
let entities = entity_vertices
|
||||
.filter(|_| (entity_passes.iter().chain(&shadow_entities)).any(Option::is_some))
|
||||
@@ -2843,7 +2867,7 @@ impl PackPost {
|
||||
ids: None,
|
||||
cull: false,
|
||||
};
|
||||
build(p, Some(&g)).map(|pass| (pass, geometry))
|
||||
paged(build(p, Some(&g))).map(|pass| (pass, geometry))
|
||||
});
|
||||
// Translucent held items, drawn from the hand's own buffer.
|
||||
let hand_water = post.hand_water.and_then(|p| {
|
||||
@@ -2857,7 +2881,7 @@ impl PackPost {
|
||||
ids: None,
|
||||
cull: false,
|
||||
};
|
||||
build(p, Some(&g))
|
||||
paged(build(p, Some(&g)))
|
||||
});
|
||||
let weather = post
|
||||
.weather
|
||||
@@ -3296,6 +3320,7 @@ impl PackPost {
|
||||
custom,
|
||||
atlas: world.atlas.as_ref().map(|a| a.0.clone()),
|
||||
material_maps: world.material_maps,
|
||||
paged_maps: [None, None],
|
||||
lightmap: world.lightmap,
|
||||
deferred_pre: mask(&post.deferred_pre),
|
||||
composite_pre: mask(&post.composite_pre),
|
||||
@@ -3656,6 +3681,12 @@ impl PackPost {
|
||||
Source::Dummy if s.dimension == wgpu::TextureViewDimension::D1 => {
|
||||
&self.dummy_1d
|
||||
}
|
||||
Source::Normals if pass.paged => {
|
||||
self.paged_maps[0].as_ref().unwrap_or(&self.dummy_normal)
|
||||
}
|
||||
Source::Specular if pass.paged => {
|
||||
self.paged_maps[1].as_ref().unwrap_or(&self.dummy_black)
|
||||
}
|
||||
Source::Normals => self
|
||||
.material_maps
|
||||
.as_ref()
|
||||
@@ -3756,6 +3787,16 @@ impl PackPost {
|
||||
[n(0), n(1), 1]
|
||||
});
|
||||
let (pipeline, groups) = (pipeline.clone(), groups);
|
||||
// wgpu panics on a dispatch over its limit. The translator reshapes a large
|
||||
// `workGroups`; a `workGroupsRender` that large is not run.
|
||||
let most = device.limits().max_compute_workgroups_per_dimension;
|
||||
if groups.iter().any(|&n| n > most) {
|
||||
log::warn!(
|
||||
"shader pack: {}: {groups:?} work groups is over {most} per dimension; not run",
|
||||
pass.name
|
||||
);
|
||||
return;
|
||||
}
|
||||
for image in (0..self.images.len()).filter(|k| pass.snapshots & 1 << k != 0) {
|
||||
pack_images::snapshot(encoder, &self.images[image]);
|
||||
}
|
||||
@@ -6256,6 +6297,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_is_made_in_its_layout_qualifiers_format() {
|
||||
// `image.x = … RGB10_A2 …` read through `layout(rgb10_a2) image2D x`.
|
||||
for q in [
|
||||
"rgba32f",
|
||||
"rgba16f",
|
||||
"rg16f",
|
||||
"r11f_g11f_b10f",
|
||||
"r32f",
|
||||
"rgba16",
|
||||
"rgb10_a2",
|
||||
"rgba8",
|
||||
"rg8",
|
||||
"r16",
|
||||
"rgba16_snorm",
|
||||
"rgba8_snorm",
|
||||
"r8_snorm",
|
||||
"rgba32i",
|
||||
"r16i",
|
||||
"rgb10_a2ui",
|
||||
"rgba8ui",
|
||||
"r32ui",
|
||||
] {
|
||||
let def = Format::parse(q).unwrap();
|
||||
assert_eq!(Some(pack_images::format(def, true)), image_format(q), "{q}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn samplers_read_custom_textures_of_their_type() {
|
||||
use wgpu::TextureSampleType as T;
|
||||
@@ -6301,6 +6370,10 @@ mod destination_tests;
|
||||
#[path = "pack_material_tests.rs"]
|
||||
mod material_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "pack_paged_maps_tests.rs"]
|
||||
mod paged_maps_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod rgb_alpha_tests;
|
||||
|
||||
|
||||
@@ -322,7 +322,7 @@ fn check(sampler: &str, stage: Stage, scene: Scene, expected: [u8; 4]) {
|
||||
let hand = matches!(scene, Scene::Terrain { hand: true, .. });
|
||||
let water = matches!(scene, Scene::Terrain { water: true, .. } | Scene::WaterOnly);
|
||||
let inputs=horizon_shaders::GameInputs {
|
||||
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None,item_id_declared:false,far_depth:false }),
|
||||
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None,item_id_declared:false,far_depth:false,paged_maps:false }),
|
||||
hand:hand.then(crate::gfx::pack_hand::inputs),..Default::default()
|
||||
};
|
||||
let mut post = gpu.load(
|
||||
@@ -719,6 +719,7 @@ fn shadow_depth_map_reads(
|
||||
alpha_test: None,
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -46,6 +46,7 @@ fn pack() -> Post {
|
||||
terrain: Some(horizon_shaders::GbuffersInputs {
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
declarations: "layout(location=0)in vec3 fixture_position;\n".into(),
|
||||
body: "hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);\
|
||||
hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);\
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
//! A `workGroups` over wgpu's 65535 per dimension runs, and its programs read the work group
|
||||
//! built-ins of the dispatch they declared. The programs are original.
|
||||
use super::destination_tests::Fixture;
|
||||
use super::*;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
fn pack() -> Post {
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut add = |name: &str, source: &str| {
|
||||
zip.start_file(
|
||||
format!("shaders/{name}"),
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
};
|
||||
// 70000 x 1 x 2 groups; the last 64 of the second layer each paint one pixel, green when
|
||||
// the built-ins agree with that dispatch.
|
||||
add(
|
||||
"composite.csh",
|
||||
"#version 430\nlayout(local_size_x=1) in;\nconst ivec3 workGroups=ivec3(70000,1,2);\n\
|
||||
layout(rgba8) writeonly uniform image2D colorimg0;\n\
|
||||
void main() {\n uint first = 70000u - 64u;\n\
|
||||
if (gl_WorkGroupID.z != 1u || gl_WorkGroupID.x < first) return;\n\
|
||||
uint k = gl_WorkGroupID.x - first;\n\
|
||||
bool ok = gl_WorkGroupID.y == 0u && gl_NumWorkGroups == uvec3(70000u, 1u, 2u)\n\
|
||||
&& gl_GlobalInvocationID == gl_WorkGroupID;\n\
|
||||
imageStore(colorimg0, ivec2(k % 8u, k / 8u), ok ? vec4(0, 1, 0, 1) : vec4(1, 0, 0, 1));\n}\n",
|
||||
);
|
||||
add(
|
||||
"final.vsh",
|
||||
"#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n",
|
||||
);
|
||||
add(
|
||||
"final.fsh",
|
||||
"#version 330 compatibility\nuniform sampler2D colortex0;\nin vec2 uv;\n\
|
||||
void main() { gl_FragColor = texelFetch(colortex0, ivec2(gl_FragCoord.xy), 0); }\n",
|
||||
);
|
||||
let pack =
|
||||
horizon_shaders::Pack::from_zip_bytes("original-fold", zip.finish().unwrap().into_inner())
|
||||
.unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
horizon_shaders::load_post(&pack, "", &properties, ¯os, &Default::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dispatch_over_the_limit_reads_its_declared_work_groups() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
let post = pack();
|
||||
assert!(post.notes.is_empty(), "{:?}", post.notes);
|
||||
let mut post = gpu.build(post);
|
||||
let pixels = gpu.render(&mut post, [8, 8]);
|
||||
for (k, got) in pixels.iter().enumerate() {
|
||||
assert_eq!(*got, [0, 255, 0, 255], "pixel {k}; notes {:?}", post.notes);
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
alpha_test: None,
|
||||
item_id_declared: false,
|
||||
far_depth: true,
|
||||
paged_maps: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +132,7 @@ pub fn celestial_inputs() -> horizon_shaders::GbuffersInputs {
|
||||
alpha_test: None,
|
||||
item_id_declared: false,
|
||||
far_depth: true,
|
||||
paged_maps: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -355,6 +355,25 @@ pub const NAMES: &[&str] = &[
|
||||
"hasSkylight",
|
||||
"hasCeiling",
|
||||
"cloudTime",
|
||||
// Horizon's own: Horizon Extras' dynamic lights (`dynamic_light.rs`), nearest first: how
|
||||
// many, and each one's camera-relative position and light level (zeros past the count).
|
||||
"horizonDynamicLightCount",
|
||||
"horizonDynamicLight0",
|
||||
"horizonDynamicLight1",
|
||||
"horizonDynamicLight2",
|
||||
"horizonDynamicLight3",
|
||||
"horizonDynamicLight4",
|
||||
"horizonDynamicLight5",
|
||||
"horizonDynamicLight6",
|
||||
"horizonDynamicLight7",
|
||||
"horizonDynamicLight8",
|
||||
"horizonDynamicLight9",
|
||||
"horizonDynamicLight10",
|
||||
"horizonDynamicLight11",
|
||||
"horizonDynamicLight12",
|
||||
"horizonDynamicLight13",
|
||||
"horizonDynamicLight14",
|
||||
"horizonDynamicLight15",
|
||||
"isElytraFlying",
|
||||
"cloudHeight",
|
||||
];
|
||||
@@ -929,6 +948,12 @@ impl FrameUniforms {
|
||||
"gbufferProjectionInverse" => m4(projection_inverse),
|
||||
"gbufferPreviousModelView" => m4(previous_view),
|
||||
"gbufferPreviousProjection" => m4(previous_projection),
|
||||
"horizonDynamicLightCount" => Value::i(&[frame.dynamic_lights.len() as i32]),
|
||||
n if dynamic_light_index(n).is_some() => Value::f(
|
||||
&(dynamic_light_index(n).and_then(|k| frame.dynamic_lights.get(k)))
|
||||
.copied()
|
||||
.unwrap_or([0.0; 4]),
|
||||
),
|
||||
"heldItemId" => Value::i(&[inputs.held_items[0]]),
|
||||
"heldItemId2" => Value::i(&[inputs.held_items[1]]),
|
||||
"heldBlockLightValue" => Value::i(&[inputs.held_light[0]]),
|
||||
@@ -1785,6 +1810,29 @@ pub(super) mod initial_history_tests {
|
||||
assert_eq!(crate::clouds::drift(256 * 400 + 3, 0.25, 256), 3.25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_dynamic_lights_reach_the_pack_nearest_first() {
|
||||
let hud = HudBatch::new([800, 450]);
|
||||
let mut f = frame(&hud);
|
||||
let lights = [[1.0, 2.0, 3.0, 14.0], [-4.0, 0.5, 6.0, 7.0]];
|
||||
f.dynamic_lights = &lights;
|
||||
let mut u = FrameUniforms::default();
|
||||
u.update(&f, &PackInputs::default(), [800, 450]);
|
||||
let count = index("horizonDynamicLightCount").unwrap();
|
||||
assert_eq!(int(&u, count), 2);
|
||||
assert_eq!(float(&u, "horizonDynamicLight0")[..4], lights[0]);
|
||||
assert_eq!(float(&u, "horizonDynamicLight1")[..4], lights[1]);
|
||||
// Past the count, and the last of the sixteen: zeros.
|
||||
assert_eq!(float(&u, "horizonDynamicLight2")[..4], [0.0; 4]);
|
||||
assert_eq!(float(&u, "horizonDynamicLight15")[..4], [0.0; 4]);
|
||||
assert_eq!(index("horizonDynamicLight16"), None);
|
||||
// Off (no lights): none.
|
||||
f.dynamic_lights = &[];
|
||||
u.update(&f, &PackInputs::default(), [800, 450]);
|
||||
assert_eq!(int(&u, count), 0);
|
||||
assert_eq!(float(&u, "horizonDynamicLight0")[..4], [0.0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environmental_smoothing_starts_at_the_first_inputs_without_resetting_each_frame() {
|
||||
let hud = HudBatch::new([800, 450]);
|
||||
@@ -2534,6 +2582,12 @@ pub(super) mod initial_history_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `horizonDynamicLight<k>`'s `k`, for the lights `Frame::dynamic_lights` holds.
|
||||
fn dynamic_light_index(name: &str) -> Option<usize> {
|
||||
let k: usize = name.strip_prefix("horizonDynamicLight")?.parse().ok()?;
|
||||
(k < crate::dynamic_light::MAX).then_some(k)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "pack_uniforms/ftransform_test.rs"]
|
||||
mod ftransform_test;
|
||||
@@ -46,6 +46,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
}),
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -124,6 +124,10 @@ impl<T> Resident<T> {
|
||||
pub fn get_mut(&mut self, slot: u32) -> &mut T {
|
||||
&mut self.items[slot as usize].1
|
||||
}
|
||||
/// Padded mesh bounds of the section in `slot`, relative to its origin.
|
||||
pub fn bounds(&self, slot: u32) -> Option<[Vec3; 2]> {
|
||||
self.bounds[slot as usize]
|
||||
}
|
||||
/// Sections from now on count from `min_y` (a level of another column).
|
||||
pub fn set_min_y(&mut self, min_y: i32) {
|
||||
self.min_y = min_y;
|
||||
|
||||
@@ -46,6 +46,9 @@ pub fn star_triangles() -> Vec<[f32; 3]> {
|
||||
struct Uniform {
|
||||
clip_from_sky: [[f32; 4]; 4],
|
||||
color: [f32; 4],
|
||||
hdr_settings: [f32; 4],
|
||||
hdr_shares: [f32; 4],
|
||||
hdr_light: [f32; 4],
|
||||
}
|
||||
|
||||
pub(super) struct Stars {
|
||||
@@ -93,7 +96,9 @@ impl Stars {
|
||||
});
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Stars shader"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("stars.wgsl").into()),
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
(include_str!("../hdr_light.wgsl").to_owned() + include_str!("stars.wgsl")).into(),
|
||||
),
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Stars layout"),
|
||||
@@ -129,7 +134,13 @@ impl Stars {
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_stars"),
|
||||
compilation_options: Default::default(),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &[(
|
||||
"HDR_WRITE_LIGHT",
|
||||
f64::from(u8::from(format == super::LINEAR_FRAME)),
|
||||
)],
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
// RenderPipelines.STARS: the celestial blend (src alpha, one), with the
|
||||
@@ -165,11 +176,15 @@ impl Stars {
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
clip_from_sky: Mat4,
|
||||
brightness: f32,
|
||||
globals: &super::Globals,
|
||||
) {
|
||||
let b = brightness.clamp(0.0, 1.0);
|
||||
let uniform = Uniform {
|
||||
clip_from_sky: clip_from_sky.to_cols_array_2d(),
|
||||
color: [b; 4],
|
||||
hdr_settings: globals.hdr_settings,
|
||||
hdr_shares: globals.hdr_shares,
|
||||
hdr_light: globals.hdr_light,
|
||||
};
|
||||
uploads.buffer(encoder, &self.uniform, 0, bytemuck::bytes_of(&uniform));
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ struct Stars {
|
||||
clip_from_sky: mat4x4<f32>,
|
||||
// starBrightness in every channel (RenderSystem.setShaderColor).
|
||||
color: vec4<f32>,
|
||||
hdr_settings: vec4<f32>,
|
||||
hdr_shares: vec4<f32>,
|
||||
hdr_light: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> stars: Stars;
|
||||
|
||||
@@ -16,5 +19,8 @@ struct Stars {
|
||||
// fully lifted wherever they show at all. Nothing else reads the frame's alpha.
|
||||
@fragment fn fs_stars() -> @location(0) vec4<f32> {
|
||||
let b = stars.color.a;
|
||||
if HDR_WRITE_LIGHT && stars.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_shares), b);
|
||||
}
|
||||
return vec4(stars.color.rgb * b, 0.175);
|
||||
}
|
||||
@@ -8,6 +8,10 @@ pub(super) fn pipelines(
|
||||
) -> [wgpu::RenderPipeline; 3] {
|
||||
std::array::from_fn(|i| {
|
||||
let mut constants = sprite_constants.to_vec();
|
||||
constants.push((
|
||||
"HDR_WRITE_LIGHT",
|
||||
f64::from(u8::from(format == super::LINEAR_FRAME)),
|
||||
));
|
||||
constants.push(("NATIVE_MODEL_ALPHA_CUTOFF", [0.0, 0.1, 0.5][i]));
|
||||
// The ordinary Vertex ABI is unchanged. This material's private payload uses
|
||||
// normal's four bytes for a float z, and glint UV for float normal x/y.
|
||||
|
||||
@@ -65,13 +65,18 @@ impl Visbuffer {
|
||||
let resolve_constants: Vec<(&str, f64)> = constants
|
||||
.iter()
|
||||
.copied()
|
||||
.chain([("VIS_BLOCKS_PER_PAGE", blocks_per_page as f64)])
|
||||
.chain([
|
||||
("VIS_BLOCKS_PER_PAGE", blocks_per_page as f64),
|
||||
(
|
||||
"HDR_WRITE_LIGHT",
|
||||
f64::from(u8::from(color == super::LINEAR_FRAME)),
|
||||
),
|
||||
])
|
||||
.collect();
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon visibility buffer"),
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
(include_str!("../terrain.wgsl").to_owned() + include_str!("../visbuffer.wgsl"))
|
||||
.into(),
|
||||
(super::terrain_shader() + include_str!("../visbuffer.wgsl")).into(),
|
||||
),
|
||||
});
|
||||
let fragment = wgpu::ShaderStages::FRAGMENT;
|
||||
|
||||
@@ -79,6 +79,7 @@ pub struct Post {
|
||||
/// The last TAA frame's unjittered `view_proj` and camera, `None` once a frame ran without it.
|
||||
taa_previous: Option<(DMat4, DVec3)>,
|
||||
taa_this_frame: TaaUniform,
|
||||
light_key: Option<([f32; 4], [f32; 4], [[f32; 4]; 2])>,
|
||||
}
|
||||
|
||||
impl Post {
|
||||
@@ -258,6 +259,19 @@ impl Post {
|
||||
taa_frame: 0,
|
||||
taa_previous: None,
|
||||
taa_this_frame: bytemuck::Zeroable::zeroed(),
|
||||
light_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
// History stores the baked gain as well as the picture. Reject it before jitter records
|
||||
// the next frame whenever that gain or the frame's representation changes.
|
||||
pub(super) fn light_representation(
|
||||
&mut self,
|
||||
key: Option<([f32; 4], [f32; 4], [[f32; 4]; 2])>,
|
||||
) {
|
||||
if self.light_key != key {
|
||||
self.taa_previous = None;
|
||||
self.light_key = key;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -730,6 +744,42 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn written_light_changes_reject_history_before_the_next_jitter() {
|
||||
let Some((device, _)) = test_device() else {
|
||||
return;
|
||||
};
|
||||
let mut post = Post::new(&device, wgpu::TextureFormat::Rgba16Float);
|
||||
post.taa = true;
|
||||
if !post.supports_taa() {
|
||||
return;
|
||||
}
|
||||
let key = (
|
||||
[5.0, 4.0, 1.0, 0.0],
|
||||
[0.2, 0.5, 0.6, 1.0],
|
||||
[[0.1; 4], [0.4; 4]],
|
||||
);
|
||||
for key in [Some(key), None, Some(key)] {
|
||||
post.light_representation(key);
|
||||
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
|
||||
assert_eq!(post.taa_this_frame.jitter[2], 1.0);
|
||||
post.light_representation(key);
|
||||
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
|
||||
assert_eq!(post.taa_this_frame.jitter[2], 0.0);
|
||||
}
|
||||
for field in 0..16 {
|
||||
let mut changed = key;
|
||||
match field {
|
||||
0..=3 => changed.0[field] += 0.01,
|
||||
4..=7 => changed.1[field - 4] += 0.01,
|
||||
_ => changed.2[(field - 8) / 4][(field - 8) % 4] += 0.01,
|
||||
}
|
||||
post.light_representation(Some(changed));
|
||||
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
|
||||
assert_eq!(post.taa_this_frame.jitter[2], 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn test_device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
|
||||
@@ -33,7 +33,7 @@ pub(super) fn pipeline(
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: shader,
|
||||
entry_point: Some("fs_entity"),
|
||||
entry_point: Some("fs_entity_unmarked"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
@@ -67,7 +67,7 @@ struct Out { @builtin(position) pos:vec4<f32>, @location(0) color:vec4<f32> }
|
||||
@vertex fn vs_entity(@location(0) pos:vec3<f32>, @location(3) color:vec4<f32>)->Out {
|
||||
return Out(vec4(pos,1.0),color);
|
||||
}
|
||||
@fragment fn fs_entity(v:Out)->@location(0) vec4<f32> { return v.color; }
|
||||
@fragment fn fs_entity_unmarked(v:Out)->@location(0) vec4<f32> { return v.color; }
|
||||
"#
|
||||
.into(),
|
||||
),
|
||||
|
||||
@@ -532,6 +532,54 @@ pub fn smooth_gradients_offered() -> bool {
|
||||
SMOOTH_GRADIENTS_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// GPU Culling's setting: off, on where it is offered, or Auto, which has it on only where the
|
||||
/// CPU limits the frame and it makes the frame faster (`autotune::GpuCullingAuto`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum GpuCullingMode {
|
||||
#[default]
|
||||
Off,
|
||||
On,
|
||||
Auto,
|
||||
}
|
||||
impl GpuCullingMode {
|
||||
pub const ALL: [GpuCullingMode; 3] = [
|
||||
GpuCullingMode::Off,
|
||||
GpuCullingMode::On,
|
||||
GpuCullingMode::Auto,
|
||||
];
|
||||
/// The value in `horizon-graphics.txt`: `true` and `false` as the switch wrote them.
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
Self::Off => "false",
|
||||
Self::On => "true",
|
||||
Self::Auto => "auto",
|
||||
}
|
||||
}
|
||||
/// The word `horizon-ctl gpu-culling` takes.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Off => "off",
|
||||
Self::On => "on",
|
||||
Self::Auto => "auto",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Off => "Off",
|
||||
Self::On => "On",
|
||||
Self::Auto => "Auto",
|
||||
}
|
||||
}
|
||||
fn parse(s: &str, default: Self) -> Self {
|
||||
match s {
|
||||
"true" | "on" => Self::On,
|
||||
"false" | "off" => Self::Off,
|
||||
"auto" => Self::Auto,
|
||||
_ => default,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether GPU Culling (`Graphics::gpu_culling`) can be switched on here, and why not: set when a
|
||||
/// renderer starts, and again when its pass can't be set up or its buffers stop fitting in video
|
||||
/// memory. Where it isn't offered the settings grey its switch out with the reason, and the saved
|
||||
@@ -672,8 +720,9 @@ pub struct Graphics {
|
||||
/// Near solid terrain culled, and its draws listed, on the GPU (`gfx::gpu_culling`), where it
|
||||
/// is offered (`gpu_culling_offer`). Off by default (GPU Culling, Performance graphics); it
|
||||
/// applies at once, and no preset sets it. Every save writes it, so a default turned on later
|
||||
/// needs a new key, as `batchTranslucent` did.
|
||||
pub gpu_culling: bool,
|
||||
/// needs a new key, as `batchTranslucent` did. Auto turns it on only where the CPU limits the
|
||||
/// frame and it makes the frame faster (`autotune`).
|
||||
pub gpu_culling: GpuCullingMode,
|
||||
/// Skip drawing entities hidden behind opaque terrain, synchronously or asynchronously.
|
||||
pub entity_culling: EntityCullingMode,
|
||||
/// Minimum entity population for coarse AABB culling; zero disables it.
|
||||
@@ -684,6 +733,8 @@ pub struct Graphics {
|
||||
/// vanilla's fancy leaves. Ignored with a resource pack that may replace
|
||||
/// the vanilla leaf models ([`Graphics::leaf_culling_effective`]).
|
||||
pub leaf_culling: LeafCullingMode,
|
||||
/// Draw regular ice opaque in the solid pass, without its see-through texture.
|
||||
pub opaque_ice: bool,
|
||||
/// Cache settled shulker-box models.
|
||||
pub cache_shulker_boxes: bool,
|
||||
/// Cache sign-text geometry.
|
||||
@@ -786,11 +837,12 @@ impl Default for Graphics {
|
||||
far_detail: FarDetail::Full,
|
||||
blue_map: crate::bluemap::Settings::default(),
|
||||
cave_culling: true,
|
||||
gpu_culling: false,
|
||||
gpu_culling: GpuCullingMode::Off,
|
||||
entity_culling: EntityCullingMode::Off,
|
||||
entity_culling_precheck: 64,
|
||||
particle_culling: ParticleCullingMode::Off,
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
opaque_ice: false,
|
||||
cache_shulker_boxes: false,
|
||||
// Sign text has used its renderer cache independently of the new model caches.
|
||||
cache_signs: true,
|
||||
@@ -885,13 +937,14 @@ impl Graphics {
|
||||
}
|
||||
"blueMapAutoDetect" => g.blue_map.auto_detect = flag(g.blue_map.auto_detect),
|
||||
"caveCulling" => g.cave_culling = flag(g.cave_culling),
|
||||
"gpuCulling" => g.gpu_culling = flag(g.gpu_culling),
|
||||
"gpuCulling" => g.gpu_culling = GpuCullingMode::parse(value, g.gpu_culling),
|
||||
"entityCulling" => g.entity_culling = EntityCullingMode::parse(value),
|
||||
"entityCullingPrecheck" => {
|
||||
g.entity_culling_precheck = number(g.entity_culling_precheck).min(512)
|
||||
}
|
||||
"particleCulling" => g.particle_culling = ParticleCullingMode::parse(value),
|
||||
"leafCulling" => g.leaf_culling = LeafCullingMode::parse(value),
|
||||
"opaqueIce" => g.opaque_ice = flag(g.opaque_ice),
|
||||
"blockEntityCache" => {
|
||||
let enabled = flag(false);
|
||||
g.cache_shulker_boxes = enabled;
|
||||
@@ -986,7 +1039,7 @@ impl Graphics {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
let mut text = format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nentityCullingPrecheck:{}\nparticleCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nupscalingSharpness:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrLighting:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\ngpuCulling:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nentityCullingPrecheck:{}\nparticleCulling:{}\nleafCulling:{}\nopaqueIce:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nupscalingSharpness:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrLighting:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\ngpuCulling:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
@@ -995,6 +1048,7 @@ impl Graphics {
|
||||
self.entity_culling_precheck,
|
||||
self.particle_culling.name(),
|
||||
self.leaf_culling.name(),
|
||||
bool_text(self.opaque_ice),
|
||||
bool_text(self.cache_shulker_boxes),
|
||||
bool_text(self.cache_signs),
|
||||
bool_text(self.cache_decorated_pots),
|
||||
@@ -1041,7 +1095,7 @@ impl Graphics {
|
||||
self.shader_pack,
|
||||
bool_text(self.translucent_batching),
|
||||
bool_text(self.hdr_smooth_gradients),
|
||||
bool_text(self.gpu_culling),
|
||||
self.gpu_culling.key(),
|
||||
);
|
||||
text += &self.blue_map.serialize();
|
||||
text
|
||||
@@ -1181,11 +1235,12 @@ mod tests {
|
||||
servers: Vec::new(),
|
||||
},
|
||||
cave_culling: false,
|
||||
gpu_culling: true,
|
||||
gpu_culling: GpuCullingMode::On,
|
||||
entity_culling: EntityCullingMode::Synchronous,
|
||||
entity_culling_precheck: 128,
|
||||
particle_culling: ParticleCullingMode::Asynchronous,
|
||||
leaf_culling: LeafCullingMode::SolidAggressive,
|
||||
opaque_ice: true,
|
||||
cache_shulker_boxes: true,
|
||||
cache_signs: true,
|
||||
cache_decorated_pots: true,
|
||||
@@ -1271,8 +1326,32 @@ mod tests {
|
||||
assert!(!Graphics::parse("hdr:true\nhdrSmoothGradients:false\n").wide_frame());
|
||||
assert!(Graphics::parse("translucentBatching:false\n").translucent_batching);
|
||||
// GPU Culling is opt-in: only the file or its switch turns it on.
|
||||
assert!(!Graphics::default().gpu_culling);
|
||||
assert!(Graphics::parse("gpuCulling:true\n").gpu_culling);
|
||||
assert_eq!(Graphics::default().gpu_culling, GpuCullingMode::Off);
|
||||
assert_eq!(
|
||||
Graphics::parse("gpuCulling:true\n").gpu_culling,
|
||||
GpuCullingMode::On
|
||||
);
|
||||
// Auto is a third value of the same key. A file from before it reads as before, and an
|
||||
// older Horizon reading `auto` finds a value it doesn't know and keeps Off.
|
||||
assert_eq!(
|
||||
Graphics::parse("gpuCulling:auto\n").gpu_culling,
|
||||
GpuCullingMode::Auto
|
||||
);
|
||||
assert_eq!(
|
||||
Graphics::parse("gpuCulling:false\n").gpu_culling,
|
||||
GpuCullingMode::Off
|
||||
);
|
||||
assert_eq!(
|
||||
Graphics::parse("gpuCulling:maybe\n").gpu_culling,
|
||||
GpuCullingMode::Off
|
||||
);
|
||||
for mode in GpuCullingMode::ALL {
|
||||
let g = Graphics {
|
||||
gpu_culling: mode,
|
||||
..Graphics::default()
|
||||
};
|
||||
assert_eq!(Graphics::parse(&g.serialize()).gpu_culling, mode);
|
||||
}
|
||||
// 0 is Auto; anything else is a peak the slider reaches.
|
||||
assert_eq!(Graphics::parse("hdrPeak:0\n").hdr_peak, 0);
|
||||
assert_eq!(Graphics::parse("hdrPeak:50\n").hdr_peak, 200);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -33,6 +33,11 @@ struct Params {
|
||||
@group(0) @binding(2) var<uniform> params: Params;
|
||||
// Horizon's logo, 1 inside, 0 outside, for calibration's test pictures.
|
||||
@group(0) @binding(3) var logo: texture_2d<f32>;
|
||||
// The float frame with HUD coverage (Hdr::layered): the frame keeps the world as light
|
||||
// (fs_resolve), the HUD draws on a picture of its own (`hud`, fs_keep), and `coverage` says how
|
||||
// much of the world it hides there (gfx/hud_coverage.rs).
|
||||
@group(0) @binding(4) var coverage: texture_2d<f32>;
|
||||
@group(0) @binding(5) var hud: texture_2d<f32>;
|
||||
|
||||
// Light sources are lifted evenly rather than by a highlight curve: vanilla's
|
||||
// textures were drawn for SDR, so a curve on their brightest pixels blows out
|
||||
@@ -149,80 +154,28 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
|
||||
return vec4(0.0);
|
||||
}
|
||||
|
||||
@fragment fn fs_hdr(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let at = vec2<i32>(pos.xy);
|
||||
// Calibration: above its controls the test picture, straight in nits, past
|
||||
// paper white, the lift and the black level.
|
||||
fn lit(f: vec3<f32>, a: f32, shows: bool, eased: bool) -> Lit {
|
||||
return hdr_mark_light(f, a, shows, eased, vec4(params.peak, params.scene, params.world, params.debug), params.share);
|
||||
}
|
||||
|
||||
// Calibration: above its controls the test picture, straight in nits, past
|
||||
// paper white, the lift and the black level.
|
||||
fn calibrating(pos: vec4<f32>) -> bool {
|
||||
let size = vec2<f32>(textureDimensions(frame));
|
||||
if params.calibration.x > 0.5 && pos.y < size.y * params.calibration.z {
|
||||
let nits = vec3(calibration_nits(pos.xy, size));
|
||||
if params.pq > 0.5 {
|
||||
return vec4(pq(nits), 1.0);
|
||||
}
|
||||
return vec4(nits / 80.0, 1.0);
|
||||
return params.calibration.x > 0.5 && pos.y < size.y * params.calibration.z;
|
||||
}
|
||||
|
||||
fn calibration_picture(pos: vec4<f32>) -> vec4<f32> {
|
||||
let nits = vec3(calibration_nits(pos.xy, vec2<f32>(textureDimensions(frame))));
|
||||
if params.pq > 0.5 {
|
||||
return vec4(pq(nits), 1.0);
|
||||
}
|
||||
let f = frame_texel(textureLoad(frame, at, 0));
|
||||
// What covers the world here (Hdr::keep_world): nothing at 0, the hand at
|
||||
// 1/255 (Hdr::mark_hand: drawn at the world's brightness), else the HUD or a
|
||||
// menu, by how much.
|
||||
let covered = f.a > 0.0;
|
||||
let hand = covered && f.a < 1.5 / 255.0;
|
||||
let ui = select(f.a, 0.0, hand);
|
||||
let w = frame_texel(textureLoad(world, at, 0));
|
||||
// The world's alpha is below 0.75 on light sources, the sun and the moon
|
||||
// (terrain.wgsl, entity.wgsl): only those shine, and only where nothing was
|
||||
// drawn over them since: grey text over the white sun still looks like the sun
|
||||
// darkened, but it raised the frame's alpha. Its band of 0.175 is the tier, the
|
||||
// lower the brighter, and where in the band how much (hdr_mark). The hand
|
||||
// writes its own alpha there (Hdr::hand_light_view): 1, or a light source it holds.
|
||||
var lifted = false;
|
||||
let shows = params.world > 0.5 && params.peak > 1.0
|
||||
&& (hand || (!covered && shows_world(f.rgb, w.rgb)));
|
||||
// Light over 1 (a float frame's) shows only where the world does and carries
|
||||
// no mark (alpha 1), up to the peak: light written as light. Marked pixels clip
|
||||
// at white before their lift, as on a unorm frame, so light sources, the sun and
|
||||
// HDR lighting look as they do there; so do the HUD, menus and HDR Lighting off.
|
||||
let c = select(min(f.rgb, vec3(1.0)), f.rgb, shows && w.a == 1.0);
|
||||
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
|
||||
var light = pow(c, vec3(2.2));
|
||||
// HDR lighting: alpha between 0.76 and 1 is how much of it the surface gets. Vanilla's
|
||||
// light is albedo times lightmap, so this is the lightmap going past 1. It eases into the
|
||||
// peak from 60% of it, so bright textures keep their detail. Unmarked light (alpha 1)
|
||||
// gets no gain and no knee: the clamp to the peak below is all it needs here.
|
||||
if shows && w.a >= 0.75 && w.a < 1.0 {
|
||||
let top = max(light.r, max(light.g, light.b));
|
||||
let gain = 1.0 + (params.scene - 1.0) * (1.0 - w.a) / 0.24;
|
||||
let v = top * gain;
|
||||
let knee = max(0.6 * params.peak, 1.0);
|
||||
var out = v;
|
||||
if v > knee && params.peak > knee {
|
||||
out = knee + (params.peak - knee) * (1.0 - exp(-(v - knee) / (params.peak - knee)));
|
||||
}
|
||||
light *= select(gain, out / max(top, 1e-4), v > knee);
|
||||
}
|
||||
if shows && w.a < 0.75 {
|
||||
let band = u32(clamp(floor((w.a + 0.005) / 0.175), 0.0, 3.0));
|
||||
let tier = 3u - band;
|
||||
let lift = 1.0 - clamp((w.a - 0.175 * f32(band)) / 0.165, 0.0, 1.0);
|
||||
if lift > 0.0 {
|
||||
let top = max(light.r, max(light.g, light.b));
|
||||
let gain = min(1.0 + (params.peak - 1.0) * params.share[tier] * lift, params.peak / max(top, 1e-4));
|
||||
light *= max(gain, 1.0);
|
||||
lifted = true;
|
||||
}
|
||||
}
|
||||
// HORIZON_HDR_DEBUG=1: what isn't lifted turns grey, what is keeps its
|
||||
// colour with a magenta tint, so a lift shows on any colour.
|
||||
if params.debug > 0.5 && params.world > 0.5 {
|
||||
if lifted {
|
||||
light *= vec3(1.0, 0.6, 1.0);
|
||||
} else {
|
||||
light = vec3(dot(light, vec3(0.2126, 0.7152, 0.0722)));
|
||||
}
|
||||
}
|
||||
light = min(light, vec3(params.peak));
|
||||
// The HUD and menus at their own brightness, by how much they cover.
|
||||
light *= mix(1.0, params.ui, ui);
|
||||
return vec4(nits / 80.0, 1.0);
|
||||
}
|
||||
|
||||
// Light in units of paper white, as the swapchain takes it: HDR10 or scRGB, the black level
|
||||
// lifted.
|
||||
fn display(light: vec3<f32>) -> vec4<f32> {
|
||||
let scrgb = light * params.paper;
|
||||
if params.pq > 0.5 {
|
||||
let e = pq(BT709_TO_BT2020 * scrgb * 80.0);
|
||||
@@ -233,3 +186,117 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
|
||||
}
|
||||
return vec4(scrgb, 1.0);
|
||||
}
|
||||
|
||||
@fragment fn fs_hdr(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
if calibrating(pos) {
|
||||
return calibration_picture(pos);
|
||||
}
|
||||
let at = vec2<i32>(pos.xy);
|
||||
let f = frame_texel(textureLoad(frame, at, 0));
|
||||
// What covers the world here (Hdr::keep_world): nothing at 0, the hand at
|
||||
// 1/255 (Hdr::mark_hand: drawn at the world's brightness), else the HUD or a
|
||||
// menu, by how much.
|
||||
let covered = f.a > 0.0;
|
||||
let hand = covered && f.a < 1.5 / 255.0;
|
||||
let ui = select(f.a, 0.0, hand);
|
||||
let w = frame_texel(textureLoad(world, at, 0));
|
||||
// The world's marks lift only where nothing was drawn over them since: grey text over
|
||||
// the white sun still looks like the sun darkened, but it raised the frame's alpha. The
|
||||
// hand writes its own alpha there (Hdr::hand_light_view): 1, or a light source it holds.
|
||||
let shows = params.world > 0.5 && params.peak > 1.0
|
||||
&& (hand || (!covered && shows_world(f.rgb, w.rgb)));
|
||||
var light = lit(f.rgb, w.a, shows, true).light;
|
||||
// The HUD and menus at their own brightness, by how much they cover.
|
||||
light *= mix(1.0, params.ui, ui);
|
||||
return display(light);
|
||||
}
|
||||
|
||||
// Hdr::resolve, after the world and the hand: the marks they left (`world` holds the frame as
|
||||
// they drew it) become light in the frame, in gamma as the frame stores it, alpha 1, past the
|
||||
// peak if they lift it there (the tone curve eases it in). What draws after it (camera overlays,
|
||||
// the menu blur) draws on light, and nothing is lifted later.
|
||||
@fragment fn fs_resolve(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let w = frame_texel(textureLoad(world, vec2<i32>(pos.xy), 0));
|
||||
let shows = params.world > 0.5 && params.peak > 1.0;
|
||||
return vec4(pow(lit(w.rgb, w.a, shows, false).light, vec3(1.0 / 2.2)), 1.0);
|
||||
}
|
||||
|
||||
// The tone curve's knee: up to it light stays as it is. Where HDR lighting eased into the peak
|
||||
// before the curve (`lit`), 60% of it, so bright textures keep their detail; at least paper white.
|
||||
fn tone_knee(headroom: f32) -> f32 {
|
||||
return max(0.6 * headroom, 1.0);
|
||||
}
|
||||
|
||||
// The tone curve on light whose brightest channel is `top`, in units of paper white `paper`
|
||||
// nits: what to scale the light by, so it ends at most `headroom` (above 1). ITU-R BT.2390's
|
||||
// EETF in PQ, without the black level lift (`display` does that): the same up to its knee, then
|
||||
// a Hermite spline that reaches the peak level, at the source's peak. That peak is where the knee
|
||||
// lands at `tone_knee`, PQ's 10 000 nits at most; light past it shows at the peak. The brightest
|
||||
// channel sets the scale for all three, so light keeps its hue as it eases (lava stays orange)
|
||||
// and no channel passes the peak.
|
||||
fn tone_scale(top: f32, headroom: f32, paper: f32) -> f32 {
|
||||
if top <= tone_knee(headroom) {
|
||||
return 1.0;
|
||||
}
|
||||
let peak = pq(vec3(headroom * paper)).x;
|
||||
let source = min(3.0 * peak - 2.0 * pq(vec3(tone_knee(headroom) * paper)).x, 1.0);
|
||||
let e = pq(vec3(top * paper)).x / source;
|
||||
let max_lum = peak / source;
|
||||
let ks = 1.5 * max_lum - 0.5;
|
||||
if e >= 1.0 {
|
||||
return headroom / top;
|
||||
}
|
||||
if e <= ks {
|
||||
return 1.0;
|
||||
}
|
||||
let t = (e - ks) / (1.0 - ks);
|
||||
let t2 = t * t;
|
||||
let t3 = t2 * t;
|
||||
let p = (2.0 * t3 - 3.0 * t2 + 1.0) * ks + (t3 - 2.0 * t2 + t) * (1.0 - ks) + (3.0 * t2 - 2.0 * t3) * max_lum;
|
||||
return pq_nits(vec3(p * source)).x / paper / top;
|
||||
}
|
||||
|
||||
// Hdr::tonemap, after the post effects and before the camera overlays (docs/design/
|
||||
// hdr-linear-light.md, `[tonemap]`): the light (`world` holds the frame) eased into the peak, in
|
||||
// gamma, where scaling the light by s scales the stored colour by s^(1/2.2). Light up to the
|
||||
// knee stays exactly as stored. With no headroom, a clip at white.
|
||||
@fragment fn fs_tonemap(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let g = textureLoad(world, vec2<i32>(pos.xy), 0).rgb;
|
||||
if params.peak <= 1.0 {
|
||||
return vec4(min(g, vec3(1.0)), 1.0);
|
||||
}
|
||||
let top = pow(max(max(g.r, max(g.g, g.b)), 0.0), 2.2);
|
||||
let s = tone_scale(top, params.peak, params.paper * 80.0);
|
||||
return vec4(select(g * pow(s, 1.0 / 2.2), g, s == 1.0), 1.0);
|
||||
}
|
||||
|
||||
// Hdr::keep_world: the HUD's backdrop, the world clipped to white with alpha 0, the frame as
|
||||
// the HUD found it before.
|
||||
@fragment fn fs_keep(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let w = textureLoad(frame, vec2<i32>(pos.xy), 0).rgb;
|
||||
return vec4(clamp(w, vec3(0.0), vec3(1.0)), 0.0);
|
||||
}
|
||||
|
||||
// The HUD over the world in gamma, the space the HUD blends in: its own part, what its backdrop
|
||||
// B doesn't explain (H = P - (1 - c) B, exact for the layering blends), at UI Brightness, over
|
||||
// (1 - c) of the world, light above white included. Where the world is at or below white, at
|
||||
// 100%, that is the HUD's picture as drawn.
|
||||
fn layered(at: vec2<i32>) -> vec3<f32> {
|
||||
let w = max(textureLoad(frame, at, 0).rgb, vec3(0.0));
|
||||
let p = clamp(textureLoad(hud, at, 0).rgb, vec3(0.0), vec3(1.0));
|
||||
let c = textureLoad(coverage, at, 0).r;
|
||||
let h = max(p - (1.0 - c) * min(w, vec3(1.0)), vec3(0.0));
|
||||
return h * pow(params.ui, 1.0 / 2.2) + (1.0 - c) * w;
|
||||
}
|
||||
|
||||
// Hdr::composite: the picture so far, stored as the frame stores light, for the menu blur.
|
||||
@fragment fn fs_composite(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
return vec4(layered(vec2<i32>(pos.xy)), 1.0);
|
||||
}
|
||||
|
||||
@fragment fn fs_hdr_layered(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
if calibrating(pos) {
|
||||
return calibration_picture(pos);
|
||||
}
|
||||
return display(min(pow(layered(vec2<i32>(pos.xy)), vec3(2.2)), vec3(params.peak)));
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
override HDR_WRITE_LIGHT: bool = false;
|
||||
|
||||
// The light of colour `f` under the world's mark `a` (the alpha the world left, hdr_mark) where
|
||||
// the world `shows`, else the colour clipped at white; and whether a light source lifted it.
|
||||
// `eased` for the last pass, which eases it into the peak itself; the layered frame leaves that
|
||||
// to the tone curve (fs_tonemap).
|
||||
struct Lit {
|
||||
light: vec3<f32>,
|
||||
lifted: bool,
|
||||
};
|
||||
|
||||
fn hdr_mark_light(f: vec3<f32>, a: f32, shows: bool, eased: bool, settings: vec4<f32>, shares: vec4<f32>) -> Lit {
|
||||
var lifted = false;
|
||||
// Light over 1 (a float frame's) shows only where the world does and carries
|
||||
// no mark (alpha 1), up to the peak: light written as light. Marked pixels clip
|
||||
// at white before their lift, as on a unorm frame, so light sources, the sun and
|
||||
// HDR lighting look as they do there; so do the HUD, menus and HDR Lighting off.
|
||||
let c = select(min(f, vec3(1.0)), f, shows && a == 1.0);
|
||||
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
|
||||
var light = pow(c, vec3(2.2));
|
||||
// HDR lighting: alpha between 0.76 and 1 is how much of it the surface gets. Vanilla's
|
||||
// light is albedo times lightmap, so this is the lightmap going past 1. It eases into the
|
||||
// peak from 60% of it, so bright textures keep their detail. Unmarked light (alpha 1)
|
||||
// gets no gain and no knee: the clamp to the peak below is all it needs here.
|
||||
if shows && a >= 0.75 && a < 1.0 {
|
||||
let top = max(light.r, max(light.g, light.b));
|
||||
let gain = 1.0 + (settings.y - 1.0) * (1.0 - a) / 0.24;
|
||||
let v = top * gain;
|
||||
let knee = max(0.6 * settings.x, 1.0);
|
||||
var out = v;
|
||||
if eased && v > knee && settings.x > knee {
|
||||
out = knee + (settings.x - knee) * (1.0 - exp(-(v - knee) / (settings.x - knee)));
|
||||
}
|
||||
light *= select(gain, out / max(top, 1e-4), eased && v > knee);
|
||||
}
|
||||
// The world's alpha is below 0.75 on light sources, the sun and the moon
|
||||
// (terrain.wgsl, entity.wgsl): its band of 0.175 is the tier, the lower the
|
||||
// brighter, and where in the band how much (hdr_mark).
|
||||
if shows && a < 0.75 {
|
||||
let band = u32(clamp(floor((a + 0.005) / 0.175), 0.0, 3.0));
|
||||
let tier = 3u - band;
|
||||
let lift = 1.0 - clamp((a - 0.175 * f32(band)) / 0.165, 0.0, 1.0);
|
||||
if lift > 0.0 {
|
||||
let top = max(light.r, max(light.g, light.b));
|
||||
var gain = 1.0 + (settings.x - 1.0) * shares[tier] * lift;
|
||||
if eased {
|
||||
gain = min(gain, settings.x / max(top, 1e-4));
|
||||
}
|
||||
light *= max(gain, 1.0);
|
||||
lifted = true;
|
||||
}
|
||||
}
|
||||
// HORIZON_HDR_DEBUG=1: what isn't lifted turns grey, what is keeps its
|
||||
// colour with a magenta tint, so a lift shows on any colour.
|
||||
if settings.w > 0.5 && settings.z > 0.5 {
|
||||
if lifted {
|
||||
light *= vec3(1.0, 0.6, 1.0);
|
||||
} else {
|
||||
light = vec3(dot(light, vec3(0.2126, 0.7152, 0.0722)));
|
||||
}
|
||||
}
|
||||
if eased {
|
||||
light = min(light, vec3(settings.x));
|
||||
}
|
||||
return Lit(light, lifted);
|
||||
}
|
||||
|
||||
// Marks are decoded at the precision of the original 8-bit frame, even in a float target.
|
||||
fn hdr_quantized_mark(mark: f32) -> f32 {
|
||||
return round(saturate(mark) * 255.0) / 255.0;
|
||||
}
|
||||
|
||||
fn hdr_written(color: vec3<f32>, mark: f32, settings: vec4<f32>, shares: vec4<f32>) -> vec3<f32> {
|
||||
let shows = settings.z > 0.5 && settings.x > 1.0;
|
||||
return pow(hdr_mark_light(max(color, vec3(0.0)), hdr_quantized_mark(mark), shows, false, settings, shares).light, vec3(1.0 / 2.2));
|
||||
}
|
||||
@@ -100,12 +100,18 @@ pub struct HudAtlas {
|
||||
/// A resource pack's own `core/rendertype_lines` shader for the block outline, translated;
|
||||
/// none draws the outline natively.
|
||||
pub lines_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
|
||||
/// The packs' `core/sky` and `core/stars`, when a pack replaces them.
|
||||
pub sky_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
|
||||
pub stars_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
|
||||
/// The packs' OptiFine random entity textures, whose variants this atlas packs.
|
||||
pub random_textures: horizon_optifine::random_textures::RandomTextures,
|
||||
/// Their emissive overlays (`_e`), packed here too.
|
||||
pub emissive: std::sync::Arc<horizon_optifine::emissive::Emissive>,
|
||||
/// ETF's blinking frames (`_blink`), packed here too.
|
||||
pub blinks: horizon_optifine::blink::Blinks,
|
||||
/// Of the shared atlas's textures, their LabPBR normal and specular maps as paged maps
|
||||
/// (`paged_maps.rs`), read while loading for shader packs (`sprites::labpbr_maps`).
|
||||
pub material_maps: std::sync::Arc<[Option<crate::paged_maps::PagedMap>; 2]>,
|
||||
white: Rect,
|
||||
}
|
||||
/// Atlas rows reserved for glyphs rasterized at run time.
|
||||
@@ -888,6 +894,10 @@ impl HudAtlas {
|
||||
&[],
|
||||
&crate::gfx::core_lines::GLUE,
|
||||
);
|
||||
let sky =
|
||||
|name| pack_text::load_core(root, defaults, name, &[], &crate::gfx::core_sky::GLUE);
|
||||
atlas.sky_shader = sky("sky");
|
||||
atlas.stars_shader = sky("stars");
|
||||
Ok(atlas)
|
||||
}
|
||||
/// Empty slots written at run time: map textures (game/maps.rs), the
|
||||
@@ -967,6 +977,7 @@ impl HudAtlas {
|
||||
revision: Self::next_revision(),
|
||||
pixels: Vec::new(),
|
||||
packed: None,
|
||||
material_maps: Default::default(),
|
||||
galactic_widths: [0; 128],
|
||||
width: 1,
|
||||
height: 1,
|
||||
@@ -981,6 +992,8 @@ impl HudAtlas {
|
||||
glint_armor: (Vec::new(), 1, 1),
|
||||
text_shader: None,
|
||||
lines_shader: None,
|
||||
sky_shader: None,
|
||||
stars_shader: None,
|
||||
random_textures: Default::default(),
|
||||
emissive: Arc::default(),
|
||||
blinks: Default::default(),
|
||||
@@ -1029,6 +1042,7 @@ impl HudAtlas {
|
||||
(glyph_top, height - glyph_top),
|
||||
white,
|
||||
),
|
||||
material_maps: Default::default(),
|
||||
galactic_widths: galactic_widths(&sprites, &pixels, width, height),
|
||||
pixels,
|
||||
packed: None,
|
||||
@@ -1045,6 +1059,8 @@ impl HudAtlas {
|
||||
white,
|
||||
text_shader: None,
|
||||
lines_shader: None,
|
||||
sky_shader: None,
|
||||
stars_shader: None,
|
||||
random_textures: Default::default(),
|
||||
emissive: Default::default(),
|
||||
blinks: Default::default(),
|
||||
@@ -1192,9 +1208,21 @@ impl HudAtlas {
|
||||
}
|
||||
}
|
||||
}
|
||||
// The title panorama and the New World preset pictures load on their own. The
|
||||
// Realms world-slot pictures are drawn from this atlas (vanilla draws their
|
||||
// textures full size): the three faces and `presets/isles.png` stay, costing
|
||||
// one 1024-tall shelf.
|
||||
files.retain(|p| {
|
||||
let s = p.to_string_lossy();
|
||||
!s.contains("title/background") && !s.contains("presets")
|
||||
let s = p.to_string_lossy().replace('\\', "/");
|
||||
let slot_picture = [
|
||||
"textures/gui/title/background/panorama_0.png",
|
||||
"textures/gui/title/background/panorama_2.png",
|
||||
"textures/gui/title/background/panorama_3.png",
|
||||
"textures/gui/presets/isles.png",
|
||||
]
|
||||
.iter()
|
||||
.any(|kept| s.ends_with(kept));
|
||||
slot_picture || (!s.contains("title/background") && !s.contains("presets"))
|
||||
});
|
||||
// OptiFine random entity variants, under the names their scan gives them (those
|
||||
// outside `textures/` are `<ns>:optifine/random/...`).
|
||||
@@ -1269,61 +1297,87 @@ impl HudAtlas {
|
||||
extra.sort();
|
||||
files.extend(extra);
|
||||
}
|
||||
type Decoded = (PackedImage, Option<TextureFlags>, Option<(u32, u32)>, bool);
|
||||
let decode_one = |path: &PathBuf| -> Option<Decoded> {
|
||||
let name = if let Some(name) = named.get(path) {
|
||||
name.clone()
|
||||
} else {
|
||||
match path.strip_prefix(&textures) {
|
||||
Ok(rel) => rel.with_extension("").to_string_lossy().replace('\\', "/"),
|
||||
Err(_) => {
|
||||
let rel = path.strip_prefix(&assets).ok()?.with_extension("");
|
||||
let rel = rel.to_string_lossy().replace('\\', "/");
|
||||
let (namespace, rest) = rel.split_once("/textures/")?;
|
||||
format!("{namespace}:{rest}")
|
||||
// A texture's LabPBR maps, as decoded (width, height, RGBA8), for shader packs.
|
||||
type Maps = [Option<(u32, u32, Vec<u8>)>; 2];
|
||||
let read_maps = full && crate::sprites::labpbr_maps();
|
||||
type Decoded = (
|
||||
PackedImage,
|
||||
Option<TextureFlags>,
|
||||
Option<(u32, u32)>,
|
||||
bool,
|
||||
Option<Box<Maps>>,
|
||||
);
|
||||
let decode_one =
|
||||
|path: &PathBuf| -> Option<Decoded> {
|
||||
let name = if let Some(name) = named.get(path) {
|
||||
name.clone()
|
||||
} else {
|
||||
match path.strip_prefix(&textures) {
|
||||
Ok(rel) => rel.with_extension("").to_string_lossy().replace('\\', "/"),
|
||||
Err(_) => {
|
||||
let rel = path.strip_prefix(&assets).ok()?.with_extension("");
|
||||
let rel = rel.to_string_lossy().replace('\\', "/");
|
||||
let (namespace, rest) = rel.split_once("/textures/")?;
|
||||
format!("{namespace}:{rest}")
|
||||
}
|
||||
}
|
||||
};
|
||||
// LabPBR's maps beside a texture are the world's (sprites.rs), not images to draw.
|
||||
if let Some(base) = name.strip_suffix("_n").or_else(|| name.strip_suffix("_s"))
|
||||
&& textures.join(format!("{base}.png")).is_file()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let image = decode(path)
|
||||
.map_err(|e| log::warn!("skipped GUI texture {}: {e}", path.display()))
|
||||
.ok()?;
|
||||
let mcmeta = horizon_core::prepared_files::read_to_string(
|
||||
&path.with_extension("png.mcmeta"),
|
||||
)
|
||||
.ok();
|
||||
let animation = animation_of(mcmeta.as_deref());
|
||||
// An animated strip keeps its first frame.
|
||||
let frames =
|
||||
animation.is_some() || name.starts_with("block/") || name.starts_with("item/");
|
||||
let (w, h, pixels) = if frames {
|
||||
first_frame(image, animation.as_ref())
|
||||
} else {
|
||||
image
|
||||
};
|
||||
// The default texture's size, when a pack's differs from it.
|
||||
let gui_size = default_textures
|
||||
.as_ref()
|
||||
.and_then(|d| png_size(&d.join(format!("{name}.png"))))
|
||||
.map(|(dw, dh)| {
|
||||
let side = dw.min(dh);
|
||||
if frames { (side, side) } else { (dw, dh) }
|
||||
})
|
||||
.filter(|&size| size != (w, h));
|
||||
let flags = mcmeta.as_deref().and_then(TextureFlags::from_mcmeta);
|
||||
let animated = frames && animation.is_some();
|
||||
let (gw, gh) = gui_size.unwrap_or((w, h));
|
||||
let maps = read_maps
|
||||
.then(|| {
|
||||
let stem = path.file_stem()?.to_str()?;
|
||||
let maps: Maps = ["_n", "_s"].map(|suffix| {
|
||||
decode(&path.with_file_name(format!("{stem}{suffix}.png"))).ok()
|
||||
});
|
||||
maps.iter().any(Option::is_some).then(|| Box::new(maps))
|
||||
})
|
||||
.flatten();
|
||||
// The Realms world-slot pictures are 1024-tall GUI textures drawn as
|
||||
// they are; the cap that keeps other GUI textures small does not
|
||||
// apply to them.
|
||||
let tall_picture = matches!(
|
||||
name.as_str(),
|
||||
"gui/title/background/panorama_0"
|
||||
| "gui/title/background/panorama_2"
|
||||
| "gui/title/background/panorama_3"
|
||||
| "gui/presets/isles"
|
||||
);
|
||||
(gw <= 1024 && (gh <= 512 || tall_picture || name.starts_with("font/")))
|
||||
.then_some(((name, w, h, pixels), flags, gui_size, animated, maps))
|
||||
};
|
||||
// LabPBR's maps beside a texture are the world's (sprites.rs), not images to draw.
|
||||
if let Some(base) = name.strip_suffix("_n").or_else(|| name.strip_suffix("_s"))
|
||||
&& textures.join(format!("{base}.png")).is_file()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let image = decode(path)
|
||||
.map_err(|e| log::warn!("skipped GUI texture {}: {e}", path.display()))
|
||||
.ok()?;
|
||||
let mcmeta =
|
||||
horizon_core::prepared_files::read_to_string(&path.with_extension("png.mcmeta"))
|
||||
.ok();
|
||||
let animation = animation_of(mcmeta.as_deref());
|
||||
// An animated strip keeps its first frame.
|
||||
let frames =
|
||||
animation.is_some() || name.starts_with("block/") || name.starts_with("item/");
|
||||
let (w, h, pixels) = if frames {
|
||||
first_frame(image, animation.as_ref())
|
||||
} else {
|
||||
image
|
||||
};
|
||||
// The default texture's size, when a pack's differs from it.
|
||||
let gui_size = default_textures
|
||||
.as_ref()
|
||||
.and_then(|d| png_size(&d.join(format!("{name}.png"))))
|
||||
.map(|(dw, dh)| {
|
||||
let side = dw.min(dh);
|
||||
if frames { (side, side) } else { (dw, dh) }
|
||||
})
|
||||
.filter(|&size| size != (w, h));
|
||||
let flags = mcmeta.as_deref().and_then(TextureFlags::from_mcmeta);
|
||||
let animated = frames && animation.is_some();
|
||||
let (gw, gh) = gui_size.unwrap_or((w, h));
|
||||
(gw <= 1024 && (gh <= 512 || name.starts_with("font/"))).then_some((
|
||||
(name, w, h, pixels),
|
||||
flags,
|
||||
gui_size,
|
||||
animated,
|
||||
))
|
||||
};
|
||||
let threads = std::thread::available_parallelism().map_or(2, |n| n.get());
|
||||
let chunk = files.len().div_ceil(threads).max(1);
|
||||
let decoded: Vec<_> = std::thread::scope(|scope| {
|
||||
@@ -1342,7 +1396,11 @@ impl HudAtlas {
|
||||
let mut gui_sizes = HashMap::new();
|
||||
let mut animated: Vec<String> = Vec::new();
|
||||
let mut images: Vec<PackedImage> = Vec::with_capacity(decoded.len());
|
||||
for (image, image_flags, gui_size, is_animated) in decoded {
|
||||
let mut maps: HashMap<String, Box<Maps>> = HashMap::new();
|
||||
for (image, image_flags, gui_size, is_animated, image_maps) in decoded {
|
||||
if let Some(m) = image_maps {
|
||||
maps.insert(image.0.clone(), m);
|
||||
}
|
||||
if let Some(f) = image_flags {
|
||||
flags.insert(image.0.clone(), f);
|
||||
}
|
||||
@@ -1517,6 +1575,28 @@ impl HudAtlas {
|
||||
}
|
||||
sprite_animations.push((rect, animation));
|
||||
}
|
||||
// Each mapped texture's maps at its size in the atlas, paged.
|
||||
let material_maps = if maps.is_empty() {
|
||||
Default::default()
|
||||
} else {
|
||||
let mapped: Vec<crate::paged_maps::SpriteMaps> = maps
|
||||
.iter()
|
||||
.filter_map(|(name, m)| {
|
||||
let rect = sprites.get(name)?;
|
||||
let (tw, th) = rect.texels((width, height));
|
||||
let x = (rect.u0 * width as f32).round() as u32;
|
||||
let y = (rect.v0 * height as f32).round() as u32;
|
||||
Some(crate::paged_maps::SpriteMaps {
|
||||
rect: [x, y, tw, th],
|
||||
maps: m.each_ref().map(|map| {
|
||||
let (mw, mh, pixels) = map.as_ref()?;
|
||||
crate::paged_maps::fit(pixels, (*mw, *mh), (tw, th))
|
||||
}),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
crate::paged_maps::build((width, height), &mapped, MAX_ATLAS_SIZE)
|
||||
};
|
||||
let white = sprites["horizon/white"];
|
||||
let glint = Self::glint_texture(root, defaults, "enchanted_glint_item");
|
||||
let glint_armor = Self::glint_texture(root, defaults, "enchanted_glint_armor");
|
||||
@@ -1531,6 +1611,7 @@ impl HudAtlas {
|
||||
);
|
||||
Ok(Self {
|
||||
revision: Self::next_revision(),
|
||||
material_maps: std::sync::Arc::new(material_maps),
|
||||
galactic_widths: galactic_widths(&sprites, &pixels, width, height),
|
||||
pixels,
|
||||
packed: None,
|
||||
@@ -1546,6 +1627,8 @@ impl HudAtlas {
|
||||
glint_armor,
|
||||
text_shader: None,
|
||||
lines_shader: None,
|
||||
sky_shader: None,
|
||||
stars_shader: None,
|
||||
random_textures,
|
||||
emissive,
|
||||
blinks,
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod assets;
|
||||
pub mod atlas_sources;
|
||||
pub mod audio;
|
||||
pub mod auth;
|
||||
pub mod autotune;
|
||||
pub mod ban_notice;
|
||||
pub mod bidi;
|
||||
pub mod biome;
|
||||
@@ -84,6 +85,7 @@ pub mod pack_screen;
|
||||
pub mod packs;
|
||||
pub mod pad_cursor;
|
||||
pub mod pad_glyph;
|
||||
pub mod paged_maps;
|
||||
pub mod par;
|
||||
pub mod particles;
|
||||
pub mod poi_sections;
|
||||
@@ -126,6 +128,7 @@ pub mod test_block;
|
||||
pub mod test_instance_block;
|
||||
pub mod testgen;
|
||||
pub mod title_edition;
|
||||
pub mod touch;
|
||||
pub mod trims;
|
||||
pub mod ui;
|
||||
pub mod vault;
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::{
|
||||
};
|
||||
pub mod cache;
|
||||
mod mesh;
|
||||
pub mod packed;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
@@ -559,7 +560,8 @@ pub(crate) fn state_kinds(facts: &BlockFacts) -> Vec<u8> {
|
||||
}
|
||||
/// Blocks that fill their cell but neither draw solid nor collide as a full
|
||||
/// block: far terrain draws them as blocks, or patches of powder snow leave
|
||||
/// holes in mountains (and paths and fields dips).
|
||||
/// holes in mountains (and paths and fields dips). Slabs use the same
|
||||
/// full-cell approximation as stairs, so distant slab builds remain visible.
|
||||
fn nearly_full(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
@@ -568,6 +570,7 @@ fn nearly_full(name: &str) -> bool {
|
||||
| "minecraft:farmland"
|
||||
| "minecraft:honey_block"
|
||||
) || name.ends_with("_stairs")
|
||||
|| name.ends_with("_slab")
|
||||
|| name.contains("fence")
|
||||
|| name.contains("_wall")
|
||||
|| name.contains("bars")
|
||||
@@ -1414,7 +1417,13 @@ impl Lod {
|
||||
&edges,
|
||||
&input.finer,
|
||||
);
|
||||
// Sorts the quads by facing side, and clears
|
||||
// the compact layout: a far tile has no tints
|
||||
// for `mesher::packed`, so its own records
|
||||
// (`lod::packed`) are laid out here, from the
|
||||
// quads in the order the sort left them.
|
||||
mesh.sort_solid();
|
||||
crate::lod::packed::lay_out(&mut mesh);
|
||||
let [ox, _, oz] = job.key.origin();
|
||||
let size = job.key.size();
|
||||
mesh.keep = keep_finer
|
||||
@@ -3977,6 +3986,73 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "requires prepared models/textures in HORIZON_TEST_RESOURCES"]
|
||||
fn slab_materials_survive_chunk_summaries_and_draw_far_faces() -> crate::Result<()> {
|
||||
use crate::{assets::Assets, biome::Biomes, light::LightTables, mesher::MeshTables};
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES"),
|
||||
);
|
||||
let assets = Arc::new(Assets::load(&root)?);
|
||||
let render = Arc::new(assets.reload(&root)?);
|
||||
let facts = BlockFacts::load(&root, assets.registry)?;
|
||||
let context = WorkerContext {
|
||||
light: Arc::new(LightTables::load(&root, assets.registry, &facts)?),
|
||||
tables: Arc::new(MeshTables::new(
|
||||
&assets,
|
||||
&render,
|
||||
&facts,
|
||||
crate::models::load_visibility(&root, assets.registry)?,
|
||||
)?),
|
||||
assets: assets.clone(),
|
||||
render,
|
||||
biomes: Arc::new(Biomes::load(&root)?),
|
||||
};
|
||||
let kinds = state_kinds(&facts);
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
let mut mesher = mesh::Mesher::default();
|
||||
let mut materials = FxHashSet::default();
|
||||
// Real top, bottom, double and waterlogged states: a waterlogged slab
|
||||
// must keep its material rather than become a column of plain water.
|
||||
for (i, f) in facts.states.iter().enumerate() {
|
||||
if !f.name.ends_with("_slab") {
|
||||
continue;
|
||||
}
|
||||
materials.insert(f.name.as_ref());
|
||||
let state = BlockStateId(i as u32);
|
||||
chunk.set(3, 90, 3, state.0);
|
||||
let columns = summarize(&chunk, &kinds);
|
||||
let column = &columns[3 * 16 + 3];
|
||||
assert_eq!(column.spans.len(), 1, "{} state {i}", f.name);
|
||||
assert_eq!(column.spans[0].state, state, "{} state {i}", f.name);
|
||||
assert_eq!((column.spans[0].min, column.spans[0].max), (90, 91));
|
||||
assert_eq!(Column::reduced(&columns, 0, 0, 4).spans, column.spans);
|
||||
let mesh = mesher.mesh(
|
||||
TileKey {
|
||||
x: 0,
|
||||
z: 0,
|
||||
level: 0,
|
||||
},
|
||||
&columns.iter().collect::<Vec<_>>(),
|
||||
&FxHashSet::default(),
|
||||
None,
|
||||
&context,
|
||||
&std::array::from_fn(|_| Vec::new()),
|
||||
&[],
|
||||
);
|
||||
// Like stairs, far slabs use the existing full-cell approximation.
|
||||
assert_eq!(mesh.solid.len(), 6 * 4, "{} state {i}", f.name);
|
||||
assert!(mesh.translucent.is_empty(), "{} state {i}", f.name);
|
||||
}
|
||||
for name in [
|
||||
"minecraft:smooth_stone_slab",
|
||||
"minecraft:oak_slab",
|
||||
"minecraft:cut_copper_slab",
|
||||
] {
|
||||
assert!(materials.contains(name), "missing fixture {name}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "requires prepared models/textures in HORIZON_TEST_RESOURCES"]
|
||||
fn real_mesh_clips_near_chunks_and_preserves_water_and_edited_surfaces() -> crate::Result<()> {
|
||||
use crate::{
|
||||
assets::Assets, biome::Biomes, light::LightTables, mesher::MeshTables,
|
||||
|
||||
@@ -1386,6 +1386,52 @@ mod tests {
|
||||
let p = |i: usize| glam::Vec3::from(q[i].pos);
|
||||
(p(1) - p(0)).cross(p(2) - p(0))
|
||||
}
|
||||
/// Every solid quad the mesher emits packs into a far record, and the
|
||||
/// record gives the quad back bit for bit (`lod::packed`).
|
||||
///
|
||||
/// `lod::packed`'s own tests check its records against its own copy of what
|
||||
/// `emit_quad` does, which would pass just as well if the two had drifted
|
||||
/// apart. This checks them against the real mesher, over terrain that makes
|
||||
/// faces of every axis and winding: a hill of varied heights, a material
|
||||
/// boundary, a light boundary and water.
|
||||
#[test]
|
||||
fn every_quad_the_mesher_emits_packs_and_comes_back() {
|
||||
let mut columns = columns();
|
||||
for (i, column) in columns.iter_mut().enumerate() {
|
||||
let (x, z) = (i % CELLS, i / CELLS);
|
||||
let top = 4 + (x as i16 % 5) * 2 + (z as i16 % 3);
|
||||
let state = if (x + z) % 7 == 0 { 3 } else { 1 };
|
||||
let light = if x % 4 == 0 { 16 } else { 240 };
|
||||
column.spans = vec![
|
||||
Span {
|
||||
light,
|
||||
..span(0, top, state)
|
||||
},
|
||||
// Water over the lower ground, for the fluid faces too.
|
||||
span(top, 12.max(top + 1), 2),
|
||||
]
|
||||
.into();
|
||||
}
|
||||
let mesh = mesh(&columns);
|
||||
let quads = mesh.solid.as_chunks::<4>().0;
|
||||
assert!(
|
||||
quads.len() > 50,
|
||||
"{} quads is too few to say much",
|
||||
quads.len()
|
||||
);
|
||||
for (i, quad) in quads.iter().enumerate() {
|
||||
let words = crate::lod::packed::pack(quad)
|
||||
.unwrap_or_else(|| panic!("quad {i} of {} did not pack", quads.len()));
|
||||
let back = crate::lod::packed::corners(words)
|
||||
.unwrap_or_else(|| panic!("quad {i}'s record names no face"));
|
||||
assert_eq!(
|
||||
bytemuck::cast::<_, [[u32; 6]; 4]>(back),
|
||||
bytemuck::cast::<_, [[u32; 6]; 4]>(*quad),
|
||||
"quad {i} came back changed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_solid_cube_merges_to_six_faces_with_block_scaled_uvs() {
|
||||
let mut columns = columns();
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
//! The compact layout of a far tile's solid quads in the arena: one 24-byte
|
||||
//! record each instead of four 24-byte vertices.
|
||||
//!
|
||||
//! A far tile's quads are not blocks' faces -- they are merged rectangles of a
|
||||
//! voxel column's exposed side, hundreds of blocks wide -- so none of them fits
|
||||
//! `mesher::packed`'s record, whose coordinates are a block grid of 16. They
|
||||
//! are far more regular than a chunk's quads instead: a rectangle in an
|
||||
//! axis-aligned plane, with one colour, one light and one sprite layer across
|
||||
//! all four corners, and UVs that `lod::mesh::emit_quad` derives from the
|
||||
//! positions. So a record holds what that function was given, and
|
||||
//! terrain.wgsl's `far_corner` does what it did:
|
||||
//!
|
||||
//! - word 0: the colour (RGBA8), the same for all four corners;
|
||||
//! - word 1: the sprite layer, and the light in the high half;
|
||||
//! - word 2: the plane, the coordinate all four corners share, as an `i32`;
|
||||
//! - word 3: the rectangle's low edge along the face's first axis, as an `i32`;
|
||||
//! - word 4: its low edge along the second, as an `i32`;
|
||||
//! - word 5: the plane's axis, whether the corners are reversed, the
|
||||
//! rectangle's width and height, and `FULL` clear.
|
||||
//!
|
||||
//! Nothing here reasons about which record is right for a quad: `pack` builds
|
||||
//! one, rebuilds the four vertices from it with the same code the shader
|
||||
//! mirrors, and keeps it only if they come back bit for bit. A quad no record
|
||||
//! reproduces -- a fractional coordinate, a rectangle too wide, a face whose
|
||||
//! UVs were scaled down to fit (`lod::mesh::MAX_PIECES`) -- is kept as its four
|
||||
//! vertices, exactly as `mesher::packed` keeps one.
|
||||
|
||||
use crate::mesher::{TerrainVertex, packed};
|
||||
|
||||
/// Word 5's bit of a quad kept as four vertices; word 0 is then the slots from
|
||||
/// the record to its first vertex. The same bit `mesher::packed` uses, so the
|
||||
/// two layouts place their records and spilled vertices alike.
|
||||
pub use crate::mesher::packed::FULL;
|
||||
|
||||
/// The draws' base vertex of a compact far mesh, above `packed::PACKED_BASE`:
|
||||
/// `FAR_BASE + 4 * slot + corner` is a corner of the quad whose record is at
|
||||
/// `slot`. terrain.wgsl's `section_corner` reads the layout off the index, so
|
||||
/// a far tile and a chunk section can share one arena and one pipeline.
|
||||
pub const FAR_BASE: u32 = 1 << 29;
|
||||
|
||||
/// Whether far tiles meshed from now on get this layout: `HORIZON_FAR_PACK=0`
|
||||
/// stops them and leaves chunk sections' own layout alone, so the two can be
|
||||
/// told apart in one build. `HORIZON_PACKED_QUADS=0` stops both.
|
||||
pub fn far_packing() -> bool {
|
||||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ON.get_or_init(|| std::env::var("HORIZON_FAR_PACK").as_deref() != Ok("0"))
|
||||
}
|
||||
|
||||
/// The widest rectangle a record's 11-bit width and height hold. `lod::mesh`
|
||||
/// splits a face at `MAX_FACE` = 1024 blocks unless it scales its UVs instead,
|
||||
/// and a scaled face does not pack, so this is never the binding limit.
|
||||
const MAX_EXTENT: i64 = (1 << 11) - 1;
|
||||
|
||||
/// Which axis a face's plane lies on, and what `lod::mesh::emit_quad` makes of
|
||||
/// the other two. The names are that function's: `u` is the rectangle's first
|
||||
/// axis and `v` its second, and the UV of a corner is `[u, -v]` of it.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum Axis {
|
||||
/// Up and Down: the plane is y, u is x and v is z.
|
||||
Y,
|
||||
/// North and South: the plane is z, u is x and v is y.
|
||||
Z,
|
||||
/// West and East: the plane is x, u is z and v is y.
|
||||
X,
|
||||
}
|
||||
|
||||
impl Axis {
|
||||
const ALL: [Self; 3] = [Self::Y, Self::Z, Self::X];
|
||||
|
||||
fn bits(self) -> u32 {
|
||||
match self {
|
||||
Self::Y => 0,
|
||||
Self::Z => 1,
|
||||
Self::X => 2,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn from_bits(bits: u32) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|a| a.bits() == bits)
|
||||
}
|
||||
}
|
||||
|
||||
/// A face as `lod::mesh::emit_quad` was given it, which is all a record holds.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Face {
|
||||
axis: Axis,
|
||||
/// Whether the corners go round the other way, as they do for Down, South
|
||||
/// and East (that function's `points.reverse()`).
|
||||
reversed: bool,
|
||||
plane: i32,
|
||||
u0: i32,
|
||||
v0: i32,
|
||||
du: u32,
|
||||
dv: u32,
|
||||
layer: u16,
|
||||
light: u16,
|
||||
color: u32,
|
||||
}
|
||||
|
||||
impl Face {
|
||||
/// The four vertices of it, as `lod::mesh::emit_quad` writes them. Kept
|
||||
/// here and not shared with that function because it is what the shader
|
||||
/// mirrors: the two have to agree, and `a_record_rebuilds_what_the_mesher_
|
||||
/// emitted` is what holds them to it.
|
||||
fn vertices(self) -> [TerrainVertex; 4] {
|
||||
let (p, u0, v0) = (self.plane as f32, self.u0 as f32, self.v0 as f32);
|
||||
let u1 = u0 + self.du as f32;
|
||||
let v1 = v0 + self.dv as f32;
|
||||
let mut points = match self.axis {
|
||||
Axis::Y => [[u0, p, v1], [u1, p, v1], [u1, p, v0], [u0, p, v0]],
|
||||
Axis::Z => [[u0, v1, p], [u1, v1, p], [u1, v0, p], [u0, v0, p]],
|
||||
Axis::X => [[p, v1, u1], [p, v1, u0], [p, v0, u0], [p, v0, u1]],
|
||||
};
|
||||
if self.reversed {
|
||||
points.reverse();
|
||||
}
|
||||
let uvs = points.map(|pos| match self.axis {
|
||||
Axis::Y => [pos[0], -pos[2]],
|
||||
Axis::Z => [pos[0], -pos[1]],
|
||||
Axis::X => [pos[2], -pos[1]],
|
||||
});
|
||||
let corner = [0, 1].map(|i| {
|
||||
uvs.iter()
|
||||
.map(|uv| uv[i])
|
||||
.fold(f32::INFINITY, f32::min)
|
||||
.floor()
|
||||
});
|
||||
let mut out = [TerrainVertex::tiled([0.0; 3], [0.0; 2], 0, 0, 0); 4];
|
||||
for ((to, pos), uv) in out.iter_mut().zip(points).zip(uvs) {
|
||||
*to = TerrainVertex::tiled(
|
||||
pos,
|
||||
[uv[0] - corner[0], uv[1] - corner[1]],
|
||||
self.layer,
|
||||
self.color,
|
||||
self.light,
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn words(self) -> [u32; 6] {
|
||||
[
|
||||
self.color,
|
||||
u32::from(self.layer) | u32::from(self.light) << 16,
|
||||
self.plane as u32,
|
||||
self.u0 as u32,
|
||||
self.v0 as u32,
|
||||
self.axis.bits() | u32::from(self.reversed) << 2 | self.du << 3 | self.dv << 14,
|
||||
]
|
||||
}
|
||||
|
||||
/// The face a record names, or `None` for a record that spills its quad's
|
||||
/// vertices (`FULL`) or that does not name an axis.
|
||||
#[cfg(test)]
|
||||
fn from_words(w: [u32; 6]) -> Option<Self> {
|
||||
if w[5] & FULL != 0 {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
axis: Axis::from_bits(w[5] & 3)?,
|
||||
reversed: (w[5] >> 2) & 1 != 0,
|
||||
plane: w[2] as i32,
|
||||
u0: w[3] as i32,
|
||||
v0: w[4] as i32,
|
||||
du: (w[5] >> 3) & 0x7ff,
|
||||
dv: (w[5] >> 14) & 0x7ff,
|
||||
layer: w[1] as u16,
|
||||
light: (w[1] >> 16) as u16,
|
||||
color: w[0],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A whole number in `i32`, or `None`.
|
||||
fn whole(v: f32) -> Option<i32> {
|
||||
(v.fract() == 0.0 && (f32::from(i16::MIN) * 16.0..=f32::from(i16::MAX) * 16.0).contains(&v))
|
||||
.then_some(v as i32)
|
||||
}
|
||||
|
||||
/// `quad`'s record, if one gives back its four vertices exactly.
|
||||
pub fn pack(quad: &[TerrainVertex; 4]) -> Option<[u32; 6]> {
|
||||
let first = quad[0];
|
||||
// One colour, one light and one layer across the four corners, which is
|
||||
// what lets a record hold them once.
|
||||
if quad
|
||||
.iter()
|
||||
.any(|v| (v.color, v.light, v.layer) != (first.color, first.light, first.layer))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let lo = [0, 1, 2].map(|k| quad.iter().map(|v| v.pos[k]).fold(f32::INFINITY, f32::min));
|
||||
let hi = [0, 1, 2].map(|k| {
|
||||
quad.iter()
|
||||
.map(|v| v.pos[k])
|
||||
.fold(f32::NEG_INFINITY, f32::max)
|
||||
});
|
||||
// Every axis and winding the mesher can have emitted; the round trip below
|
||||
// says which one it was, so nothing here has to work it out.
|
||||
for axis in Axis::ALL {
|
||||
let (plane, u, v) = match axis {
|
||||
Axis::Y => (1, 0, 2),
|
||||
Axis::Z => (2, 0, 1),
|
||||
Axis::X => (0, 2, 1),
|
||||
};
|
||||
if lo[plane] != hi[plane] {
|
||||
continue;
|
||||
}
|
||||
let (Some(plane), Some(u0), Some(v0)) = (whole(lo[plane]), whole(lo[u]), whole(lo[v]))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let (Some(u1), Some(v1)) = (whole(hi[u]), whole(hi[v])) else {
|
||||
continue;
|
||||
};
|
||||
let (du, dv) = (i64::from(u1 - u0), i64::from(v1 - v0));
|
||||
if du > MAX_EXTENT || dv > MAX_EXTENT {
|
||||
continue;
|
||||
}
|
||||
for reversed in [false, true] {
|
||||
let face = Face {
|
||||
axis,
|
||||
reversed,
|
||||
plane,
|
||||
u0,
|
||||
v0,
|
||||
du: du as u32,
|
||||
dv: dv as u32,
|
||||
layer: first.layer,
|
||||
light: first.light,
|
||||
color: first.color,
|
||||
};
|
||||
let words = face.words();
|
||||
// The record is kept only where it gives the quad back exactly, so
|
||||
// a quad this layout cannot hold is spilled rather than altered.
|
||||
if bytemuck::cast::<_, [[u32; 6]; 4]>(face.vertices())
|
||||
== bytemuck::cast::<_, [[u32; 6]; 4]>(*quad)
|
||||
{
|
||||
return Some(words);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The four corners a record names, as the shader rebuilds them; `None` for a
|
||||
/// record that spills its quad's vertices. For the tests that hold this layout
|
||||
/// and `lod::mesh` to each other.
|
||||
#[cfg(test)]
|
||||
pub fn corners(words: [u32; 6]) -> Option<[TerrainVertex; 4]> {
|
||||
Face::from_words(words).map(Face::vertices)
|
||||
}
|
||||
|
||||
/// Lays a far tile's solid quads out compactly in `mesh.packed`, where the
|
||||
/// renderer reads that layout. Call it after `SectionMesh::sort_solid`, which
|
||||
/// reorders the quads and clears whatever layout was there.
|
||||
pub fn lay_out(mesh: &mut crate::mesher::SectionMesh) {
|
||||
if !packed::packing() || !far_packing() {
|
||||
return;
|
||||
}
|
||||
mesh.packed = layout(&mesh.solid);
|
||||
mesh.packed_far = !mesh.packed.is_empty();
|
||||
}
|
||||
|
||||
/// A far tile's solid quads laid out compactly: a record each in mesh order,
|
||||
/// then the vertices of the quads no record fits. Empty where that would not
|
||||
/// be smaller than the vertices.
|
||||
pub fn layout(solid: &[TerrainVertex]) -> Vec<[u32; 6]> {
|
||||
let quads = solid.as_chunks::<4>().0;
|
||||
let records: Vec<Option<[u32; 6]>> = quads.iter().map(pack).collect();
|
||||
packed::place(&records, quads)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The four corners as bits: `TerrainVertex` has no `PartialEq`, and bit
|
||||
/// identity is the guarantee the layout makes in any case.
|
||||
fn words_of(quad: [TerrainVertex; 4]) -> [[u32; 6]; 4] {
|
||||
bytemuck::cast(quad)
|
||||
}
|
||||
|
||||
fn face() -> Face {
|
||||
Face {
|
||||
axis: Axis::Y,
|
||||
reversed: false,
|
||||
plane: 71,
|
||||
u0: -48,
|
||||
v0: 112,
|
||||
du: 16,
|
||||
dv: 7,
|
||||
layer: 3,
|
||||
light: 0xF0F0,
|
||||
color: 0xFF_80_40_20,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every axis and winding survives the words, so `from_words` and the
|
||||
/// shader's `far_corner` read what `words` wrote.
|
||||
#[test]
|
||||
fn a_face_survives_its_record() {
|
||||
for axis in Axis::ALL {
|
||||
for reversed in [false, true] {
|
||||
let want = Face {
|
||||
axis,
|
||||
reversed,
|
||||
..face()
|
||||
};
|
||||
let got = Face::from_words(want.words()).expect("a record names a face");
|
||||
assert_eq!(
|
||||
words_of(got.vertices()),
|
||||
words_of(want.vertices()),
|
||||
"{axis:?} {reversed}"
|
||||
);
|
||||
assert_eq!((got.axis, got.reversed), (axis, reversed));
|
||||
assert_eq!((got.plane, got.u0, got.v0), (want.plane, want.u0, want.v0));
|
||||
assert_eq!((got.du, got.dv), (want.du, want.dv));
|
||||
assert_eq!(
|
||||
(got.layer, got.light, got.color),
|
||||
(want.layer, want.light, want.color)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The widest and the most distant rectangle a record claims to hold comes
|
||||
/// back exactly, so the bit fields are wide enough for what they say.
|
||||
#[test]
|
||||
fn the_extremes_a_record_claims_come_back() {
|
||||
let want = Face {
|
||||
du: MAX_EXTENT as u32,
|
||||
dv: MAX_EXTENT as u32,
|
||||
plane: i32::from(i16::MAX),
|
||||
u0: -8_000_000,
|
||||
v0: 8_000_000,
|
||||
layer: u16::MAX,
|
||||
light: u16::MAX,
|
||||
color: u32::MAX,
|
||||
..face()
|
||||
};
|
||||
let got = Face::from_words(want.words()).expect("a record names a face");
|
||||
assert_eq!((got.du, got.dv), (want.du, want.dv));
|
||||
assert_eq!((got.plane, got.u0, got.v0), (want.plane, want.u0, want.v0));
|
||||
assert_eq!(
|
||||
(got.layer, got.light, got.color),
|
||||
(want.layer, want.light, want.color)
|
||||
);
|
||||
}
|
||||
|
||||
/// Any quad the mesher could have emitted packs, and unpacks to the very
|
||||
/// vertices it emitted: this is the whole guarantee the layout makes.
|
||||
#[test]
|
||||
fn a_record_rebuilds_what_the_mesher_emitted() {
|
||||
let mut packed = 0;
|
||||
for axis in Axis::ALL {
|
||||
for reversed in [false, true] {
|
||||
for (du, dv) in [(1, 1), (16, 1), (1, 16), (37, 91), (1024, 1024)] {
|
||||
let want = Face {
|
||||
axis,
|
||||
reversed,
|
||||
du,
|
||||
dv,
|
||||
..face()
|
||||
};
|
||||
let quad = want.vertices();
|
||||
let words = pack(&quad).expect("the mesher's own quad packs");
|
||||
let back = Face::from_words(words).expect("a record names a face");
|
||||
assert_eq!(
|
||||
words_of(back.vertices()),
|
||||
words_of(quad),
|
||||
"{axis:?} {reversed} {du}x{dv}"
|
||||
);
|
||||
packed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(packed, 30, "every case packed");
|
||||
}
|
||||
|
||||
/// A quad this layout cannot hold is refused rather than altered: the
|
||||
/// corners disagreeing on a colour, a fractional coordinate, and a
|
||||
/// rectangle wider than the width field.
|
||||
#[test]
|
||||
fn a_quad_no_record_fits_is_refused() {
|
||||
let mut uneven = face().vertices();
|
||||
uneven[2].color ^= 1;
|
||||
assert!(pack(&uneven).is_none(), "corners differ in colour");
|
||||
|
||||
let mut fraction = face().vertices();
|
||||
fraction[0].pos[0] += 0.5;
|
||||
assert!(pack(&fraction).is_none(), "a fractional coordinate");
|
||||
|
||||
let wide = Face {
|
||||
du: MAX_EXTENT as u32 + 1,
|
||||
..face()
|
||||
};
|
||||
assert!(pack(&wide.vertices()).is_none(), "wider than the field");
|
||||
}
|
||||
|
||||
/// A tile of quads that all pack takes a quarter of the slots its vertices
|
||||
/// would, which is the point of the layout.
|
||||
#[test]
|
||||
fn a_tile_that_packs_takes_a_quarter_of_the_slots() {
|
||||
let mut solid = Vec::new();
|
||||
for n in 0..64 {
|
||||
solid.extend(
|
||||
Face {
|
||||
u0: n * 4,
|
||||
..face()
|
||||
}
|
||||
.vertices(),
|
||||
);
|
||||
}
|
||||
let out = layout(&solid);
|
||||
assert_eq!(out.len(), solid.len() / 4, "a slot a quad, none spilled");
|
||||
for (quad, record) in solid.as_chunks::<4>().0.iter().zip(&out) {
|
||||
let face = Face::from_words(*record).expect("a record names a face");
|
||||
assert_eq!(words_of(face.vertices()), words_of(*quad),);
|
||||
}
|
||||
}
|
||||
|
||||
/// A tile whose quads mostly spill keeps the vertices and gives up on the
|
||||
/// layout rather than growing: `place` is what decides, and this holds it
|
||||
/// to the same rule `mesher::packed` follows.
|
||||
#[test]
|
||||
fn a_tile_that_mostly_spills_keeps_its_vertices() {
|
||||
let mut solid = Vec::new();
|
||||
for n in 0..64 {
|
||||
let mut quad = Face {
|
||||
u0: n * 4,
|
||||
..face()
|
||||
}
|
||||
.vertices();
|
||||
quad[1].color ^= 1;
|
||||
solid.extend(quad);
|
||||
}
|
||||
assert!(layout(&solid).is_empty(), "no layout smaller than vertices");
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,8 @@
|
||||
//! keeps 7 archives a day, the newest with the highest index.
|
||||
//!
|
||||
//! Messages go through the `log` facade. Horizon's own still print their plain text on stderr,
|
||||
//! as they did before the log existed; other crates' errors only reach the file (their
|
||||
//! warnings are mostly the Vulkan loader's startup chatter).
|
||||
//! as they did before the log existed. Other crates, including native mods and their helper
|
||||
//! crates, reach the file through INFO too, with their target prefixed to identify the source.
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{self, Read, Seek, SeekFrom, Write};
|
||||
@@ -424,14 +424,18 @@ fn ours(target: &str) -> bool {
|
||||
impl log::Log for Logger {
|
||||
fn enabled(&self, meta: &log::Metadata) -> bool {
|
||||
meta.level() <= log::Level::Info
|
||||
&& (ours(meta.target()) || meta.level() <= log::Level::Error)
|
||||
}
|
||||
|
||||
fn log(&self, record: &log::Record) {
|
||||
if !self.enabled(record.metadata()) {
|
||||
return;
|
||||
}
|
||||
let message = record.args().to_string();
|
||||
let horizon = ours(record.target());
|
||||
let message = if horizon {
|
||||
record.args().to_string()
|
||||
} else {
|
||||
format!("[{}] {}", record.target(), record.args())
|
||||
};
|
||||
let current = std::thread::current();
|
||||
let thread = match current.name() {
|
||||
Some("main") => self.main_thread,
|
||||
@@ -460,7 +464,7 @@ impl log::Log for Logger {
|
||||
),
|
||||
_ => {}
|
||||
}
|
||||
let stderr = ours(record.target()).then(|| message.clone());
|
||||
let stderr = horizon.then(|| message.clone());
|
||||
self.send(thread, level, &message, stderr);
|
||||
if gate == Gate::Last {
|
||||
self.write(
|
||||
@@ -652,6 +656,91 @@ pub fn newest_crash_report(home: &Path) -> Option<PathBuf> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn native_mod_records_reach_the_file_with_their_targets() {
|
||||
use log::Log;
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("horizon-native-log-test-{}", std::process::id()));
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let (out, receive) = mpsc::channel();
|
||||
let writer = Writer {
|
||||
file: Some(open_latest(&dir).unwrap().unwrap()),
|
||||
dir: dir.clone(),
|
||||
date: Local::now().date_naive(),
|
||||
};
|
||||
let handle = std::thread::spawn(move || writer.run(receive));
|
||||
let logger = Logger {
|
||||
out,
|
||||
recent: Mutex::new(VecDeque::new()),
|
||||
main_thread: "Render thread",
|
||||
floods: Mutex::new(Floods::default()),
|
||||
};
|
||||
for (target, level, message) in [
|
||||
("meridian_horizon::save", log::Level::Warn, "save failed"),
|
||||
("meridian_core::map", log::Level::Info, "map opened"),
|
||||
("wgpu_core::device", log::Level::Error, "device lost"),
|
||||
("meridian_core::map", log::Level::Debug, "debug detail"),
|
||||
("meridian_core::map", log::Level::Trace, "trace detail"),
|
||||
] {
|
||||
logger.log(
|
||||
&log::Record::builder()
|
||||
.args(format_args!("{message}"))
|
||||
.level(level)
|
||||
.target(target)
|
||||
.build(),
|
||||
);
|
||||
}
|
||||
logger.flush();
|
||||
drop(logger);
|
||||
handle.join().unwrap();
|
||||
let text = fs::read_to_string(dir.join("latest.log")).unwrap();
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
assert!(
|
||||
text.contains("[meridian_horizon::save] save failed"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(text.contains("[meridian_core::map] map opened"), "{text}");
|
||||
assert!(text.contains("[wgpu_core::device] device lost"), "{text}");
|
||||
assert!(!text.contains("debug detail"));
|
||||
assert!(!text.contains("trace detail"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizon_stderr_and_external_file_records_stay_separate() {
|
||||
use log::Log;
|
||||
let (out, receive) = mpsc::channel();
|
||||
let logger = Logger {
|
||||
out,
|
||||
recent: Mutex::new(VecDeque::new()),
|
||||
main_thread: "Render thread",
|
||||
floods: Mutex::new(Floods::default()),
|
||||
};
|
||||
for target in ["horizon_client::game", "meridian_core", "wgpu_hal::vulkan"] {
|
||||
logger.log(
|
||||
&log::Record::builder()
|
||||
.args(format_args!("diagnostic"))
|
||||
.level(log::Level::Info)
|
||||
.target(target)
|
||||
.build(),
|
||||
);
|
||||
let Out::Write {
|
||||
stderr,
|
||||
record: (_, _, message),
|
||||
..
|
||||
} = receive.try_recv().unwrap()
|
||||
else {
|
||||
panic!("a diagnostic must write a record")
|
||||
};
|
||||
if ours(target) {
|
||||
assert_eq!(stderr.as_deref(), Some("diagnostic"));
|
||||
assert_eq!(message, "diagnostic");
|
||||
} else {
|
||||
assert!(stderr.is_none());
|
||||
assert_eq!(message, format!("[{target}] diagnostic"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_player_dir_keeps_the_shared_files_in_the_game_folder() {
|
||||
let (game, player) = (Path::new("/games/shared"), Path::new("/games/players/alex"));
|
||||
|
||||
@@ -202,20 +202,44 @@ impl MenuBlur {
|
||||
target: &wgpu::TextureView,
|
||||
size: [u32; 2],
|
||||
radius: f32,
|
||||
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
|
||||
timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
|
||||
) {
|
||||
self.ensure(device, size);
|
||||
let work = self.work_size(size);
|
||||
let copy = if self.half { 2 } else { 0 };
|
||||
let copy = self.source(device, size).0;
|
||||
encoder.copy_texture_to_texture(
|
||||
frame.as_image_copy(),
|
||||
self.targets[copy].0.as_image_copy(),
|
||||
copy.as_image_copy(),
|
||||
wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
self.blur(device, queue, encoder, target, size, radius, timing);
|
||||
}
|
||||
/// The blur's copy of a frame of `size`, for a picture drawn into it rather than copied
|
||||
/// (HDR output's composite, `Hdr::composite`), then `blur`.
|
||||
pub fn source(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
size: [u32; 2],
|
||||
) -> (wgpu::Texture, wgpu::TextureView) {
|
||||
self.ensure(device, size);
|
||||
self.targets[if self.half { 2 } else { 0 }].clone()
|
||||
}
|
||||
/// Blur what `source` holds into `target`, as `apply` blurs its copy of the frame.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn blur(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
target: &wgpu::TextureView,
|
||||
size: [u32; 2],
|
||||
radius: f32,
|
||||
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
|
||||
) {
|
||||
self.ensure(device, size);
|
||||
let work = self.work_size(size);
|
||||
let radius = if self.half { radius / 2.0 } else { radius };
|
||||
if self.half {
|
||||
// A radius of 0 samples each half-size pixel's 2x2 block at its shared
|
||||
|
||||
@@ -347,6 +347,10 @@ pub struct SectionMesh {
|
||||
/// model order before.
|
||||
pub sides: Sides,
|
||||
pub translucent: Vec<TerrainVertex>,
|
||||
/// Whether `packed` holds far tile records (`lod::packed`) rather than
|
||||
/// section records (`mesher::packed`): the two are read apart, by the base
|
||||
/// vertex their draws use.
|
||||
pub packed_far: bool,
|
||||
/// `solid` in the compact layout (`packed`), when the renderer reads it and it is
|
||||
/// smaller; empty otherwise. Made by `sort_solid`.
|
||||
pub packed: Vec<[u32; 6]>,
|
||||
@@ -379,6 +383,7 @@ impl Default for SectionMesh {
|
||||
sides: Sides::default(),
|
||||
translucent: Vec::new(),
|
||||
packed: Vec::new(),
|
||||
packed_far: false,
|
||||
solid_tints: Vec::new(),
|
||||
visibility: ALL_VISIBLE,
|
||||
block_entities: Vec::new(),
|
||||
@@ -413,6 +418,7 @@ impl SectionMesh {
|
||||
self.solid.clear();
|
||||
self.translucent.clear();
|
||||
self.packed.clear();
|
||||
self.packed_far = false;
|
||||
self.solid_tints.clear();
|
||||
self.block_entities.clear();
|
||||
self.lights.clear();
|
||||
@@ -442,6 +448,7 @@ impl SectionMesh {
|
||||
self.sides = sides::sort_tinted(&mut self.solid, &mut self.solid_tints);
|
||||
self.bounds = self.compute_bounds();
|
||||
self.packed.clear();
|
||||
self.packed_far = false;
|
||||
if packed::packing() && quads > 0 && self.solid_tints.len() == quads {
|
||||
self.packed = packed::layout(&self.solid, &self.solid_tints);
|
||||
}
|
||||
@@ -652,7 +659,11 @@ struct Info {
|
||||
/// and a full collision shape. The only block leaf culling treats as solid.
|
||||
opaque_cube: bool,
|
||||
blocks_flow: bool,
|
||||
/// `instanceof IceBlock` (frosted ice too): fluids read it.
|
||||
ice: bool,
|
||||
/// Regular ice, the block the Opaque Ice option draws opaque; frosted ice
|
||||
/// keeps its see-through, cracking look.
|
||||
opaque_ice: bool,
|
||||
sturdy: u8,
|
||||
/// Occlusion face per direction: 0 empty, 1 the full-block singleton, 2 partial.
|
||||
face: [u8; 6],
|
||||
@@ -844,6 +855,7 @@ impl MeshTables {
|
||||
),
|
||||
blocks_flow: assets.blocks_fluid_flow.contains(&*s.name),
|
||||
ice: s.has_class("IceBlock"),
|
||||
opaque_ice: s.has_class("IceBlock") && !s.has_class("FrostedIceBlock"),
|
||||
sturdy: s.sturdy_full,
|
||||
face,
|
||||
cover,
|
||||
@@ -1251,6 +1263,8 @@ fn row_masks_match_cell_definition() {
|
||||
#[cfg(test)]
|
||||
mod leaf_culling_tests;
|
||||
#[cfg(test)]
|
||||
mod opaque_ice_tests;
|
||||
#[cfg(test)]
|
||||
mod reuse_tests;
|
||||
#[cfg(test)]
|
||||
mod tint_tests;
|
||||
@@ -1580,6 +1594,8 @@ pub struct Mesher<'a> {
|
||||
pub smooth_lighting: bool,
|
||||
/// The `cutoutLeaves` option (fancy graphics); off makes leaves opaque.
|
||||
pub cutout_leaves: bool,
|
||||
/// The `opaqueIce` option: regular ice draws opaque in the solid pass.
|
||||
pub opaque_ice: bool,
|
||||
/// Sprite layers a shader pack moves into the translucent pass, by layer.
|
||||
pub translucent_layers: &'a [bool],
|
||||
/// What the worker does with leaf faces inside a canopy.
|
||||
@@ -1705,6 +1721,9 @@ impl Mesher<'_> {
|
||||
let other = &info[neighbor as usize];
|
||||
// LeavesBlock.skipRendering with opaque leaves.
|
||||
let opaque_leaves = !self.cutout_leaves && state.leaves && other.leaves;
|
||||
// Opaque Ice: faces between two ice blocks are hidden, as they are
|
||||
// between two opaque blocks.
|
||||
let opaque_ice = self.opaque_ice && state.opaque_ice && other.opaque_ice;
|
||||
let culled_leaf = match self.leaf_culling {
|
||||
LeafCullingMode::Hollow | LeafCullingMode::Hidden => {
|
||||
state.leaves
|
||||
@@ -1722,6 +1741,7 @@ impl Mesher<'_> {
|
||||
_ => false,
|
||||
};
|
||||
visible[d as usize] = !opaque_leaves
|
||||
&& !opaque_ice
|
||||
&& !culled_leaf
|
||||
&& self
|
||||
.visibility
|
||||
@@ -1950,9 +1970,11 @@ impl Mesher<'_> {
|
||||
// The face's own UVs, already packed: no per-vertex rounding.
|
||||
std::array::from_fn(|i| (vertex(tint, i, face.uv_fixed[i]), q.uv[i]))
|
||||
};
|
||||
// Opaque leaves (forceOpaque) and leaves a leaf-culling mode proved internal draw in the
|
||||
// solid layer whatever their sprite.
|
||||
let solid_leaves = !self.cutout_leaves && state.leaves || interior_leaf;
|
||||
// Opaque leaves (forceOpaque), leaves a leaf-culling mode proved internal and Opaque
|
||||
// Ice draw in the solid layer whatever their sprite.
|
||||
let force_solid = !self.cutout_leaves && state.leaves
|
||||
|| interior_leaf
|
||||
|| self.opaque_ice && state.opaque_ice;
|
||||
let own = TileLayer::Layer {
|
||||
layer: face.layer as u16,
|
||||
translucent: face.translucent,
|
||||
@@ -1972,8 +1994,8 @@ impl Mesher<'_> {
|
||||
TileLayer::Skip | TileLayer::Default => own,
|
||||
};
|
||||
match pick {
|
||||
Pick::Keep => self.quad(out, solid_leaves, own, corners.map(|c| c.0), tint),
|
||||
Pick::Tile(t) => self.quad(out, solid_leaves, tile(t), corners.map(|c| c.0), tint),
|
||||
Pick::Keep => self.quad(out, force_solid, own, corners.map(|c| c.0), tint),
|
||||
Pick::Tile(t) => self.quad(out, force_solid, tile(t), corners.map(|c| c.0), tint),
|
||||
Pick::Quarters(tiles) => {
|
||||
let cuts = cuts(&corners);
|
||||
for s in cuts.windows(2) {
|
||||
@@ -1982,7 +2004,7 @@ impl Mesher<'_> {
|
||||
.map(|(s, t)| bilinear(&corners, s, t));
|
||||
let quarter = quarter_of(&piece);
|
||||
let piece = piece.map(|c| c.0);
|
||||
self.quad(out, solid_leaves, tile(tiles[quarter]), piece, tint);
|
||||
self.quad(out, force_solid, tile(tiles[quarter]), piece, tint);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2031,17 +2053,17 @@ impl Mesher<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// One terrain quad with sprite `tile`, in the pass its alpha needs (`solid_leaves`: no
|
||||
/// One terrain quad with sprite `tile`, in the pass its alpha needs (`force_solid`: no
|
||||
/// alpha cutout whatever the sprite).
|
||||
fn quad(
|
||||
&self,
|
||||
out: &mut SectionMesh,
|
||||
solid_leaves: bool,
|
||||
force_solid: bool,
|
||||
tile: TileLayer,
|
||||
v: [TerrainVertex; 4],
|
||||
tint: Option<u32>,
|
||||
) {
|
||||
self.quad_as(out, solid_leaves, tile, v, true, tint);
|
||||
self.quad_as(out, force_solid, tile, v, true, tint);
|
||||
}
|
||||
/// Whether [`Mesher::quad`] draws `tile` in the translucent pass.
|
||||
fn translucent(&self, tile: TileLayer) -> bool {
|
||||
@@ -2065,7 +2087,7 @@ impl Mesher<'_> {
|
||||
fn quad_as(
|
||||
&self,
|
||||
out: &mut SectionMesh,
|
||||
solid_leaves: bool,
|
||||
force_solid: bool,
|
||||
tile: TileLayer,
|
||||
mut v: [TerrainVertex; 4],
|
||||
by_sprite: bool,
|
||||
@@ -2082,14 +2104,16 @@ impl Mesher<'_> {
|
||||
if self.tables.is_animated(layer) {
|
||||
out.note_sprite_layer(layer);
|
||||
}
|
||||
let translucent = if by_sprite {
|
||||
let translucent = if force_solid {
|
||||
false
|
||||
} else if by_sprite {
|
||||
self.translucent(tile)
|
||||
} else {
|
||||
translucent
|
||||
};
|
||||
// Quads over opaque texels draw in the solid layer: no alpha cutout (a dirt path's side
|
||||
// skips its clear top row). All four vertices share the layer.
|
||||
let layer = if opaque && !translucent || solid_leaves {
|
||||
let layer = if opaque && !translucent || force_solid {
|
||||
layer | OPAQUE_LAYER
|
||||
} else {
|
||||
layer
|
||||
|
||||
@@ -343,6 +343,7 @@ mod tests {
|
||||
opaque_cube: opaque,
|
||||
blocks_flow: false,
|
||||
ice: false,
|
||||
opaque_ice: false,
|
||||
sturdy: 0,
|
||||
face: [0; 6],
|
||||
cover: [0.0; 6],
|
||||
|
||||
@@ -83,6 +83,7 @@ fn mesh(
|
||||
blend_radius: 0,
|
||||
smooth_lighting: false,
|
||||
cutout_leaves: true,
|
||||
opaque_ice: false,
|
||||
translucent_layers: &[],
|
||||
leaf_culling: mode,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
//! Opaque Ice through the real mesher: regular ice moves from the translucent
|
||||
//! pass to the solid pass without its texture alpha, keeps its faces toward
|
||||
//! air, loses the faces between two ice blocks, and leaves other translucent
|
||||
//! blocks alone. Needs prepared resources, like the other mesher geometry tests.
|
||||
use super::*;
|
||||
use crate::{
|
||||
assets::{Assets, RenderAssets},
|
||||
biome::Biomes,
|
||||
blocks::BlockFacts,
|
||||
chunk::Chunk,
|
||||
models,
|
||||
};
|
||||
|
||||
struct Fixture {
|
||||
assets: Assets,
|
||||
biomes: Biomes,
|
||||
render: RenderAssets,
|
||||
visibility: horizon_world::block_render::FaceVisibility,
|
||||
tables: MeshTables,
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
let root = std::env::var_os("HORIZON_MESH_TEST_RESOURCES")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3")
|
||||
});
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let biomes = Biomes::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let visibility = models::load_visibility(&root, assets.registry).unwrap();
|
||||
let tables = MeshTables::new(&assets, &render, &facts, visibility.clone()).unwrap();
|
||||
Fixture {
|
||||
assets,
|
||||
biomes,
|
||||
render,
|
||||
visibility,
|
||||
tables,
|
||||
}
|
||||
}
|
||||
|
||||
fn state(f: &Fixture, name: &str) -> u32 {
|
||||
f.assets.registry.default_state(name).unwrap().0
|
||||
}
|
||||
|
||||
fn mesh(f: &Fixture, chunk: &Chunk, opaque_ice: bool) -> SectionMesh {
|
||||
let region = [[Some(chunk); 3]; 3];
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: false,
|
||||
shader_pack_lighting: false,
|
||||
separate_ao: false,
|
||||
shader_pack: false,
|
||||
assets: &f.assets,
|
||||
render: &f.render,
|
||||
tables: &f.tables,
|
||||
biomes: &f.biomes,
|
||||
visibility: f.visibility.clone(),
|
||||
zoom: BiomeZoom::new(0),
|
||||
blend_radius: 0,
|
||||
smooth_lighting: false,
|
||||
cutout_leaves: true,
|
||||
opaque_ice,
|
||||
translucent_layers: &[],
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
};
|
||||
mesher.mesh(®ion, 0)
|
||||
}
|
||||
|
||||
const BASE: i32 = MIN_Y + 4;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn off_keeps_regular_ice_in_the_translucent_pass() {
|
||||
let f = fixture();
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
chunk.set(8, BASE, 8, state(&f, "minecraft:ice"));
|
||||
let mesh = mesh(&f, &chunk, false);
|
||||
assert!(mesh.solid.is_empty());
|
||||
assert_eq!(mesh.translucent.len(), 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn on_draws_regular_ice_opaque_in_the_solid_pass() {
|
||||
let f = fixture();
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
chunk.set(8, BASE, 8, state(&f, "minecraft:ice"));
|
||||
let mesh = mesh(&f, &chunk, true);
|
||||
assert!(mesh.translucent.is_empty());
|
||||
let quads = mesh.solid.as_chunks::<4>().0;
|
||||
assert_eq!(quads.len(), 6);
|
||||
for quad in quads {
|
||||
assert!(
|
||||
quad.iter().all(|v| v.layer & OPAQUE_LAYER != 0),
|
||||
"an opaque ice quad kept its texture alpha"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn on_culls_the_face_between_two_ice_blocks() {
|
||||
let f = fixture();
|
||||
let ice = state(&f, "minecraft:ice");
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
chunk.set(7, BASE, 8, ice);
|
||||
chunk.set(8, BASE, 8, ice);
|
||||
let mesh = mesh(&f, &chunk, true);
|
||||
assert!(mesh.translucent.is_empty());
|
||||
// Two cubes, six faces each, minus the face they share.
|
||||
assert_eq!(mesh.solid.len(), 10 * 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn on_leaves_other_translucent_blocks_alone() {
|
||||
let f = fixture();
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
chunk.set(4, BASE, 8, state(&f, "minecraft:ice"));
|
||||
chunk.set(12, BASE, 8, state(&f, "minecraft:slime_block"));
|
||||
let mesh = mesh(&f, &chunk, true);
|
||||
assert_eq!(mesh.solid.len(), 24, "only the ice cube is solid");
|
||||
assert!(
|
||||
mesh.translucent.len() >= 24,
|
||||
"the slime cube is still translucent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn on_leaves_frosted_ice_translucent() {
|
||||
let f = fixture();
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
chunk.set(8, BASE, 8, state(&f, "minecraft:frosted_ice"));
|
||||
let mesh = mesh(&f, &chunk, true);
|
||||
assert!(mesh.solid.is_empty());
|
||||
assert_eq!(mesh.translucent.len(), 24);
|
||||
}
|
||||
@@ -208,6 +208,18 @@ pub fn layout(solid: &[TerrainVertex], tints: &[u32]) -> Vec<[u32; 6]> {
|
||||
.zip(tints)
|
||||
.map(|(q, &t)| pack_quad(q, t))
|
||||
.collect();
|
||||
place(&records, quads)
|
||||
}
|
||||
|
||||
/// A mesh's solid range from a record per quad, `None` where no record fits:
|
||||
/// the records in mesh order, padded to a multiple of 4 slots, then the four
|
||||
/// vertices of each quad without one, whose record holds the distance to them
|
||||
/// (`FULL`). Empty where that would not be smaller than the vertices, which is
|
||||
/// what makes the layout never a loss.
|
||||
///
|
||||
/// Shared with `lod::packed`, whose records hold a far tile's quads instead but
|
||||
/// spill and pad the same way.
|
||||
pub fn place(records: &[Option<[u32; 6]>], quads: &[[TerrainVertex; 4]]) -> Vec<[u32; 6]> {
|
||||
let n = quads.len();
|
||||
let packed = records.iter().filter(|r| r.is_some()).count();
|
||||
let full = n - packed;
|
||||
@@ -229,7 +241,7 @@ pub fn layout(solid: &[TerrainVertex], tints: &[u32]) -> Vec<[u32; 6]> {
|
||||
}));
|
||||
}
|
||||
out.resize(n + pad, [0; 6]);
|
||||
for (q, record) in quads.iter().zip(&records) {
|
||||
for (q, record) in quads.iter().zip(records) {
|
||||
if record.is_none() {
|
||||
out.extend(
|
||||
q.iter()
|
||||
|
||||
@@ -181,6 +181,7 @@ fn a_shader_pack_lights_the_faces_itself() {
|
||||
blend_radius: 0,
|
||||
smooth_lighting: false,
|
||||
cutout_leaves: true,
|
||||
opaque_ice: false,
|
||||
translucent_layers: &[],
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
};
|
||||
@@ -237,6 +238,7 @@ fn a_shader_pack_lights_every_nether_face_like_an_up_face() {
|
||||
blend_radius: 0,
|
||||
smooth_lighting: false,
|
||||
cutout_leaves: true,
|
||||
opaque_ice: false,
|
||||
translucent_layers: &[],
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
};
|
||||
@@ -289,6 +291,7 @@ fn separate_ao_passes_ao_in_alpha_without_darkening_rgb() {
|
||||
blend_radius: 0,
|
||||
smooth_lighting: true,
|
||||
cutout_leaves: true,
|
||||
opaque_ice: false,
|
||||
translucent_layers: &[],
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
};
|
||||
@@ -364,6 +367,7 @@ fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
|
||||
blend_radius: 2,
|
||||
smooth_lighting: reload == 0,
|
||||
cutout_leaves: reload == 0,
|
||||
opaque_ice: false,
|
||||
translucent_layers: &[],
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
};
|
||||
@@ -513,6 +517,7 @@ fn a_shader_pack_s_still_water_top_starts_at_its_south_west_corner() {
|
||||
blend_radius: 0,
|
||||
smooth_lighting: true,
|
||||
cutout_leaves: true,
|
||||
opaque_ice: false,
|
||||
translucent_layers: &[],
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
};
|
||||
|
||||
@@ -92,6 +92,7 @@ fn info(solid: bool) -> Info {
|
||||
opaque_cube: solid,
|
||||
blocks_flow: false,
|
||||
ice: false,
|
||||
opaque_ice: false,
|
||||
sturdy: 0,
|
||||
face: [0; 6],
|
||||
cover: [0.0; 6],
|
||||
|
||||
@@ -21,7 +21,11 @@ fn oit_cloud_color(in: Out, front: bool, fade: bool) -> vec4<f32> {
|
||||
return oit_add_transmittance(1.0 - color.a, in.clip.z, in.clip.xy);
|
||||
}
|
||||
@fragment fn fs_oit_cloud_accumulate(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
let color = oit_cloud_color(in, front, true);
|
||||
var color = oit_cloud_color(in, front, true);
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
|
||||
color = vec4(hdr_written(color.rgb, hdr_scene_alpha(globals.hdr_light.y),
|
||||
globals.hdr_settings, globals.hdr_shares), color.a);
|
||||
}
|
||||
let seen = oit_sample_transmittance(vec2<i32>(in.clip.xy),
|
||||
oit_normalize_depth(in.clip.z, in.clip.xy), 1.0 - color.a);
|
||||
return vec4(color.rgb * color.a, color.a) * seen;
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
return oit_transmittance_fragment(entity_color(in, front).a, in.clip);
|
||||
}
|
||||
@fragment fn fs_oit_entity_accumulate(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
return oit_accumulate_fragment(entity_written_color(in, front), in.clip);
|
||||
}
|
||||
// Text keeps its source gain, as fs_entity_unmarked does in the forward pass.
|
||||
@fragment fn fs_oit_text_accumulate(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
return oit_accumulate_fragment(entity_color(in, front), in.clip);
|
||||
}
|
||||
// `OIT_ENTITY_SHADOW`.
|
||||
@@ -56,6 +60,9 @@
|
||||
return OitCoefficients(vec4(0.0), vec4(0.0));
|
||||
}
|
||||
@fragment fn fs_oit_lightning_accumulate(in: Out) -> @location(0) vec4<f32> {
|
||||
let color = celestial_color(in);
|
||||
var color = celestial_color(in);
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
|
||||
color = vec4(hdr_written(color.rgb, hdr_entity_mark(in), globals.hdr_settings, globals.hdr_shares), color.a);
|
||||
}
|
||||
return oit_accumulate_additive(vec4(color.rgb, min(0.99, color.a)), 0.0, in.clip);
|
||||
}
|
||||
@@ -9,19 +9,25 @@ fn oit_terrain_color(in: TerrainOut) -> vec4<f32> {
|
||||
}
|
||||
// Stage 1: (-nearest, furthest, nearest device depth, nearest opaque device
|
||||
// depth), combined by max blending.
|
||||
@fragment fn fs_oit_bounds(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
@fragment fn fs_oit_bounds(wire: TerrainWire) -> @location(0) vec4<f32> {
|
||||
let in = from_wire(wire);
|
||||
let color = oit_terrain_color(in);
|
||||
let linear = oit_linear_depth(in.clip.z);
|
||||
let opaque = select(0.0, in.clip.z, color.a > OIT_FULLY_OPAQUE_ALPHA);
|
||||
return vec4(-linear, linear, in.clip.z, opaque);
|
||||
}
|
||||
// Stage 2.
|
||||
@fragment fn fs_oit_transmittance(in: TerrainOut) -> OitCoefficients {
|
||||
@fragment fn fs_oit_transmittance(wire: TerrainWire) -> OitCoefficients {
|
||||
let in = from_wire(wire);
|
||||
let color = oit_terrain_color(in);
|
||||
return oit_add_transmittance(1.0 - color.a, in.clip.z, in.clip.xy);
|
||||
}
|
||||
// Stage 3: premultiplied colour, weighted by the transmittance in front.
|
||||
@fragment fn fs_oit_accumulate(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
@fragment fn fs_oit_accumulate(wire: TerrainWire) -> @location(0) vec4<f32> {
|
||||
let in = from_wire(wire);
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
|
||||
hdr_normal = normalize(cross(dpdy(in.local), dpdx(in.local)));
|
||||
}
|
||||
let color = oit_terrain_color(in);
|
||||
let seen = oit_sample_transmittance(vec2<i32>(in.clip.xy),
|
||||
oit_normalize_depth(in.clip.z, in.clip.xy), 1.0 - color.a);
|
||||
@@ -30,6 +36,13 @@ fn oit_terrain_color(in: TerrainOut) -> vec4<f32> {
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
|
||||
// The fog colour is premultiplied by the same weight.
|
||||
let rgb = mix(weighted.rgb, globals.fog_color.rgb * weighted.a, f * globals.fog_color.a);
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
|
||||
let mark = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()),
|
||||
globals.hdr_light.y, f * globals.fog_color.a));
|
||||
// Convert the surface before weighting; its mark never belongs to the light behind it.
|
||||
let fogged = mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a);
|
||||
return vec4(hdr_written(fogged, mark, globals.hdr_settings, globals.hdr_shares) * weighted.a, weighted.a);
|
||||
}
|
||||
return vec4(rgb, weighted.a);
|
||||
}
|
||||
|
||||
@@ -42,7 +55,12 @@ struct OitFragment {
|
||||
@fragment fn fs_oit_cull(@builtin(position) pos: vec4<f32>) -> OitFragment {
|
||||
let b = textureLoad(oit_bounds, vec2<i32>(pos.xy), 0);
|
||||
let opaque = oit_linear_depth(b.a);
|
||||
return OitFragment(vec4(b.r, min(b.g, opaque), b.b, b.a), b.a);
|
||||
// The later stages test GreaterEqual against this depth, so the opaque fragment itself must still
|
||||
// pass: Metal's `position.z` can differ from the rasterizer's depth by an ulp, which dropped
|
||||
// random pixels of an opaque body. A few ulps farther (a positive float's bits minus one is
|
||||
// the next smaller value) keeps it, and culls anything behind it by a visible distance.
|
||||
let culled_depth = select(0.0, bitcast<f32>(bitcast<u32>(b.a) - 4u), b.a > 0.0);
|
||||
return OitFragment(vec4(b.r, min(b.g, opaque), b.b, b.a), culled_depth);
|
||||
}
|
||||
// The sum over the world, renormalized to the coverage the transform gives.
|
||||
@fragment fn fs_oit_composite(@builtin(position) pos: vec4<f32>) -> OitFragment {
|
||||
|
||||
@@ -312,11 +312,13 @@ impl Opt {
|
||||
Opt::PadRepeatInterval => "horizon.options.repeat_interval",
|
||||
Opt::PadInvertX => "horizon.options.invert_x",
|
||||
Opt::PadInvertY => "horizon.options.invert_y",
|
||||
Opt::PadCraftOnServers => "horizon.options.craft_on_servers",
|
||||
Opt::PadToggleSneak => "horizon.options.sneak_button",
|
||||
Opt::PadMoveDeadzone => "horizon.options.move_deadzone",
|
||||
Opt::PadLookDeadzone => "horizon.options.look_deadzone",
|
||||
Opt::PadTriggerThreshold => "horizon.options.trigger_threshold",
|
||||
Opt::PadGlyphs => "horizon.options.button_glyphs",
|
||||
Opt::PadNintendoButtons => "horizon.options.nintendo_buttons",
|
||||
Opt::PadKeyboard => "horizon.options.on_screen_keyboard",
|
||||
Opt::PadGameplayGuide => "horizon.options.gameplay_guide",
|
||||
Opt::ModelPart(i) => [
|
||||
@@ -848,6 +850,7 @@ impl Opt {
|
||||
}),
|
||||
Opt::PadInvertX => on_off(s.controller.invert_x),
|
||||
Opt::PadInvertY => on_off(s.controller.invert_y),
|
||||
Opt::PadCraftOnServers => on_off(s.controller.craft_on_servers),
|
||||
Opt::PadToggleSneak => tr(if s.controller.toggle_sneak {
|
||||
"options.key.toggle"
|
||||
} else {
|
||||
@@ -864,6 +867,11 @@ impl Opt {
|
||||
GlyphStyle::PlayStation => "horizon.options.glyphs.playstation",
|
||||
GlyphStyle::Nintendo => "horizon.options.glyphs.nintendo",
|
||||
}),
|
||||
Opt::PadNintendoButtons => tr(if s.controller.nintendo_letters {
|
||||
"horizon.options.nintendo_buttons.letter"
|
||||
} else {
|
||||
"horizon.options.nintendo_buttons.position"
|
||||
}),
|
||||
Opt::PadKeyboard => tr(match s.controller.keyboard {
|
||||
gamepad::TextKeyboard::Auto => "horizon.options.keyboard.auto",
|
||||
gamepad::TextKeyboard::Horizon => "horizon.options.keyboard.horizon",
|
||||
@@ -1150,7 +1158,13 @@ impl Opt {
|
||||
}
|
||||
Opt::PadInvertX => s.controller.invert_x = !s.controller.invert_x,
|
||||
Opt::PadInvertY => s.controller.invert_y = !s.controller.invert_y,
|
||||
Opt::PadCraftOnServers => {
|
||||
s.controller.craft_on_servers = !s.controller.craft_on_servers
|
||||
}
|
||||
Opt::PadToggleSneak => s.controller.toggle_sneak = !s.controller.toggle_sneak,
|
||||
Opt::PadNintendoButtons => {
|
||||
s.controller.nintendo_letters = !s.controller.nintendo_letters
|
||||
}
|
||||
Opt::PadLightbar => {
|
||||
let all = gamepad::Lightbar::ALL;
|
||||
let i = all
|
||||
@@ -1645,7 +1659,7 @@ pub struct OptionsUi {
|
||||
pub minecart_improvements: bool,
|
||||
/// Scroll the selected language into the middle on the next layout.
|
||||
center_language: bool,
|
||||
pub graphics: crate::graphics_ui::GraphicsUi,
|
||||
graphics: shell::Shell<crate::graphics_ui::GraphicsScreen>,
|
||||
pub controller: crate::controller_ui::ControllerUi,
|
||||
/// Horizon Extras, on the settings shell.
|
||||
extras: shell::Shell<crate::extras_ui::ExtrasScreen>,
|
||||
@@ -2086,9 +2100,6 @@ impl OptionsUi {
|
||||
.pack_screen
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.search_focused()),
|
||||
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr => {
|
||||
self.graphics.typing()
|
||||
}
|
||||
Page::Controller | Page::PadBinds => self.controller.typing(),
|
||||
_ if Self::shader_page(self.page()).is_some() => self.shader.typing(),
|
||||
_ => match self.shell() {
|
||||
@@ -2201,9 +2212,6 @@ impl OptionsUi {
|
||||
if page == Page::Packs {
|
||||
self.pack_screen = None;
|
||||
}
|
||||
if let Some(at) = Self::graphics_page(page) {
|
||||
self.graphics.open(at);
|
||||
}
|
||||
if let Some(at) = Self::controller_page(page) {
|
||||
self.controller.open(at);
|
||||
}
|
||||
@@ -2213,6 +2221,9 @@ impl OptionsUi {
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
shell.open();
|
||||
}
|
||||
if let Some(at) = Self::graphics_page(page) {
|
||||
self.graphics.show(at);
|
||||
}
|
||||
if page == Page::SplitSetup {
|
||||
self.split_setup.reset();
|
||||
}
|
||||
@@ -2229,15 +2240,27 @@ impl OptionsUi {
|
||||
fn shell(&self) -> Option<&dyn shell::Hosted> {
|
||||
match self.page() {
|
||||
Page::Extras => Some(&self.extras),
|
||||
page if Self::graphics_page(page).is_some() => Some(&self.graphics),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn shell_mut(&mut self) -> Option<&mut dyn shell::Hosted> {
|
||||
match self.page() {
|
||||
Page::Extras => Some(&mut self.extras),
|
||||
page if Self::graphics_page(page).is_some() => Some(&mut self.graphics),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// A controller's or the keyboard's focus landed on `rect`: a row of a shell screen's list
|
||||
/// that is partly hidden scrolls all into view. How far it moved down, when it moved.
|
||||
pub fn reveal(&mut self, s: &Settings, size: (f32, f32), rect: [f32; 4]) -> Option<f32> {
|
||||
self.shell_mut()?.reveal(s, size, rect)
|
||||
}
|
||||
/// The window's size, for the graphics screen's vanilla rows that step through its
|
||||
/// resolutions: before each input that can press one.
|
||||
fn graphics_framebuffer(&mut self) {
|
||||
self.graphics.screen.framebuffer = self.framebuffer;
|
||||
}
|
||||
/// What a shell screen's outputs do to these options.
|
||||
fn shell_events(&mut self, s: &mut Settings, outputs: Vec<shell::Output>) -> Vec<Event> {
|
||||
let mut events = Vec::new();
|
||||
@@ -2245,43 +2268,17 @@ impl OptionsUi {
|
||||
match output {
|
||||
shell::Output::Event(event) => events.push(event),
|
||||
shell::Output::BindKey(name) => self.selected_key = Some(name),
|
||||
shell::Output::Push(page) => self.push(page),
|
||||
shell::Output::Push(page) => {
|
||||
if page == Page::HdrCalibration {
|
||||
self.calibration = Some(crate::hdr_calibration::Calibration::new(s));
|
||||
}
|
||||
self.push(page);
|
||||
}
|
||||
shell::Output::Done => events.extend(self.back(s)),
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
/// What the graphics screen's actions do to these options.
|
||||
fn graphics_events(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
actions: Vec<crate::graphics_ui::Action>,
|
||||
) -> Vec<Event> {
|
||||
use crate::graphics_ui::Action;
|
||||
let mut events = Vec::new();
|
||||
for action in actions {
|
||||
match action {
|
||||
Action::ButtonClick => events.push(Event::ButtonClick),
|
||||
Action::Changed => events.push(Event::Changed),
|
||||
Action::SliderDrag => events.push(Event::SliderDrag),
|
||||
Action::BindKey(name) => self.selected_key = Some(name),
|
||||
Action::Calibrate => {
|
||||
self.calibration = Some(crate::hdr_calibration::Calibration::new(s));
|
||||
self.push(Page::HdrCalibration);
|
||||
}
|
||||
// The Shaders page's Shader Packs...: the list of either kind, as the page
|
||||
// opens it from anywhere.
|
||||
Action::ShaderPacks => {
|
||||
events.push(Event::ListShaderPacks);
|
||||
s.shader_options = None;
|
||||
events.push(Event::OpenShaderOptions);
|
||||
self.push(Page::ShaderPacks);
|
||||
}
|
||||
Action::Done => events.extend(self.back(s)),
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
/// What the controller screen's actions do to these options.
|
||||
fn controller_events(
|
||||
&mut self,
|
||||
@@ -3690,11 +3687,6 @@ impl OptionsUi {
|
||||
c.draw(batch, atlas, cursor);
|
||||
return;
|
||||
}
|
||||
if Self::graphics_page(self.page()).is_some() {
|
||||
self.graphics.binding = self.selected_key;
|
||||
self.graphics.draw(s, batch, atlas, cursor, self.in_world);
|
||||
return;
|
||||
}
|
||||
if Self::controller_page(self.page()).is_some() {
|
||||
self.controller.binding = self.selected_pad_action;
|
||||
self.controller.draw(s, batch, atlas, cursor, self.in_world);
|
||||
@@ -3708,6 +3700,8 @@ impl OptionsUi {
|
||||
let in_world = self.in_world;
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
shell.draw(s, batch, atlas, cursor, in_world, waiting);
|
||||
// The keys lay the screen out at the size it was drawn at.
|
||||
self.size = (batch.width, batch.height);
|
||||
return;
|
||||
}
|
||||
if self.page() == Page::SplitSetup {
|
||||
@@ -3797,22 +3791,16 @@ impl OptionsUi {
|
||||
}
|
||||
match &wd.kind {
|
||||
WKind::Button { label, active, act } => {
|
||||
if matches!(act, Act::Done) {
|
||||
ui::action_button(
|
||||
batch,
|
||||
atlas,
|
||||
crate::widgets::Action::Done,
|
||||
wd.x,
|
||||
wd.y,
|
||||
wd.w,
|
||||
wd.h,
|
||||
label,
|
||||
*active,
|
||||
hover,
|
||||
);
|
||||
} else {
|
||||
ui::button(batch, atlas, wd.x, wd.y, wd.w, wd.h, label, *active, hover);
|
||||
}
|
||||
ui::focused_button(
|
||||
batch,
|
||||
atlas,
|
||||
[wd.x, wd.y, wd.w, wd.h],
|
||||
label,
|
||||
*active,
|
||||
pointer_over,
|
||||
is_focused,
|
||||
matches!(act, Act::Done).then_some(crate::widgets::Action::Done),
|
||||
);
|
||||
crate::widgets::last_narratable(|n| {
|
||||
narrate_widget(n, s, wd, hover, list_key, framebuffer);
|
||||
});
|
||||
@@ -4061,21 +4049,6 @@ impl OptionsUi {
|
||||
let outcome = c.click((x, y), size, drag);
|
||||
return self.calibration_outcome(s, outcome);
|
||||
}
|
||||
if Self::graphics_page(self.page()).is_some() {
|
||||
// Waiting on a key: a mouse button binds, as on Key Binds.
|
||||
if self.selected_key.is_some() && !drag {
|
||||
let button = if right {
|
||||
winit::event::MouseButton::Right
|
||||
} else {
|
||||
winit::event::MouseButton::Left
|
||||
};
|
||||
return self.bind_mouse(s, button);
|
||||
}
|
||||
let actions =
|
||||
self.graphics
|
||||
.click(s, (x, y), size, framebuffer, drag, right, self.shift);
|
||||
return self.graphics_events(s, actions);
|
||||
}
|
||||
if self.shell().is_some() {
|
||||
// Waiting on a key: a mouse button binds, as on Key Binds.
|
||||
if self.selected_key.is_some() && !drag {
|
||||
@@ -4087,6 +4060,8 @@ impl OptionsUi {
|
||||
return self.bind_mouse(s, button);
|
||||
}
|
||||
let shift = self.shift;
|
||||
self.framebuffer = framebuffer;
|
||||
self.graphics_framebuffer();
|
||||
let outputs = self.shell_mut().map_or_else(Vec::new, |shell| {
|
||||
shell.click(s, (x, y), size, drag, right, shift)
|
||||
});
|
||||
@@ -4228,6 +4203,11 @@ impl OptionsUi {
|
||||
if wd.list && (y < top || y >= bottom) {
|
||||
continue;
|
||||
}
|
||||
// AbstractWidget.mouseClicked rejects inactive buttons before the
|
||||
// container can focus them.
|
||||
if matches!(wd.kind, WKind::Button { active: false, .. }) {
|
||||
return events;
|
||||
}
|
||||
// A click focuses the widget for its highlight only: vanilla's wheel
|
||||
// steps a keyboard-focused widget from anywhere, not a clicked one.
|
||||
self.focus = Some(focus_id(wd_idx, &wd));
|
||||
@@ -4369,8 +4349,7 @@ impl OptionsUi {
|
||||
let slider = self.dragging.take().is_some();
|
||||
let (shader_slider, shader_pack) = self.shader.release();
|
||||
let shell_slider = self.shell_mut().is_some_and(|shell| shell.release());
|
||||
let graphics_slider =
|
||||
self.graphics.release() | self.controller.release() | shader_slider | shell_slider;
|
||||
let graphics_slider = self.controller.release() | shader_slider | shell_slider;
|
||||
let calibration_slider = self.calibration.as_mut().is_some_and(|c| c.release());
|
||||
let split_drop = self.split_setup.release();
|
||||
if slider || graphics_slider || calibration_slider {
|
||||
@@ -4437,7 +4416,6 @@ impl OptionsUi {
|
||||
/// wheel wherever the cursor is. Focus left by a click does not count
|
||||
/// (`focus_from_keyboard`): honouring it toggled the clicked option on every
|
||||
/// later wheel event, from anywhere on the screen, while the list never scrolled.
|
||||
/// The graphics screen has its own wheel (`GraphicsUi::wheel`).
|
||||
pub fn wheel(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
@@ -4451,14 +4429,11 @@ impl OptionsUi {
|
||||
if self.resource_error.is_some() {
|
||||
return Vec::new();
|
||||
}
|
||||
self.graphics_framebuffer();
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
let outputs = shell.wheel(s, size, cursor, amount);
|
||||
return self.shell_events(s, outputs);
|
||||
}
|
||||
if Self::graphics_page(self.page()).is_some() {
|
||||
let actions = self.graphics.wheel(s, size, cursor, framebuffer, amount);
|
||||
return self.graphics_events(s, actions);
|
||||
}
|
||||
if self.page() == Page::Studio {
|
||||
self.studio.wheel(amount);
|
||||
return Vec::new();
|
||||
@@ -4550,10 +4525,6 @@ impl OptionsUi {
|
||||
shell.scroll(s, size, amount);
|
||||
return;
|
||||
}
|
||||
if Self::graphics_page(self.page()).is_some() {
|
||||
self.graphics.scroll(s, size, amount);
|
||||
return;
|
||||
}
|
||||
if self.page() == Page::Studio {
|
||||
self.studio.wheel(amount);
|
||||
return;
|
||||
@@ -4750,8 +4721,6 @@ impl OptionsUi {
|
||||
if shell.typing() {
|
||||
shell.type_text(text);
|
||||
}
|
||||
} else if Self::graphics_page(self.page()).is_some() && self.graphics.typing() {
|
||||
self.graphics.type_text(text);
|
||||
} else if Self::controller_page(self.page()).is_some() && self.controller.typing() {
|
||||
self.controller.type_text(text);
|
||||
} else if Self::shader_page(self.page()).is_some() && self.shader.typing() {
|
||||
@@ -4790,9 +4759,6 @@ impl OptionsUi {
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
return shell.shortcut(shortcut, clipboard);
|
||||
}
|
||||
if Self::graphics_page(self.page()).is_some() {
|
||||
return self.graphics.shortcut(shortcut, clipboard);
|
||||
}
|
||||
if Self::controller_page(self.page()).is_some() {
|
||||
return self.controller.shortcut(shortcut, clipboard);
|
||||
}
|
||||
@@ -4909,24 +4875,13 @@ impl OptionsUi {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
if Self::graphics_page(self.page()).is_some() {
|
||||
let size = (self.size.0.max(480.0), self.size.1.max(240.0));
|
||||
let fb = [self.framebuffer[0].max(480), self.framebuffer[1].max(240)];
|
||||
let (actions, handled) = self.graphics.key_modifiers(s, key, text, shift, size, fb);
|
||||
let mut events = self.graphics_events(s, actions);
|
||||
if key == K::Escape {
|
||||
events.extend(self.back(s));
|
||||
return events;
|
||||
}
|
||||
if handled {
|
||||
return events;
|
||||
}
|
||||
return events;
|
||||
}
|
||||
let gui = (
|
||||
self.size.0.max(MIN_KEY_GUI.0),
|
||||
self.size.1.max(MIN_KEY_GUI.1),
|
||||
);
|
||||
// The size it was drawn at; before the first draw, one its keys can lay out at.
|
||||
let gui = if self.size.0 > 0.0 && self.size.1 > 0.0 {
|
||||
self.size
|
||||
} else {
|
||||
MIN_KEY_GUI
|
||||
};
|
||||
self.graphics_framebuffer();
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
let outputs = shell.key(s, key, text, shift, gui);
|
||||
return self.shell_events(s, outputs);
|
||||
@@ -6101,6 +6056,104 @@ mod tests {
|
||||
assert_eq!(s.file.fullscreen_resolution, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pointer_cursor_leaves_a_clicked_option_while_its_focus_stays() {
|
||||
use crate::widgets::{Cursor, take_cursor};
|
||||
let atlas = atlas();
|
||||
let mut s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::Video);
|
||||
let size = (480.0, 480.0);
|
||||
let (widgets, top, bottom, _) = ui.layout(&s, size.0, size.1);
|
||||
let button = widgets
|
||||
.iter()
|
||||
.find(|wd| {
|
||||
matches!(
|
||||
wd.kind,
|
||||
WKind::Button {
|
||||
active: true,
|
||||
act: Act::Cycle(_),
|
||||
..
|
||||
}
|
||||
) && (!wd.list || wd.y >= top && wd.y + wd.h <= bottom)
|
||||
})
|
||||
.expect("visible cycle button");
|
||||
let over = (button.x + 1.0, button.y + 1.0);
|
||||
ui.click(
|
||||
&mut s,
|
||||
&atlas,
|
||||
over.0,
|
||||
over.1,
|
||||
size,
|
||||
[480, 480],
|
||||
false,
|
||||
false,
|
||||
);
|
||||
ui.release();
|
||||
assert!(
|
||||
ui.focus.is_some(),
|
||||
"the accepted click keeps vanilla's focus"
|
||||
);
|
||||
assert!(!ui.focus_from_keyboard);
|
||||
let _ = take_cursor();
|
||||
ui.draw(&s, &mut HudBatch::new([480, 480]), &atlas, over);
|
||||
assert_eq!(take_cursor(), Cursor::PointingHand);
|
||||
ui.draw(&s, &mut HudBatch::new([480, 480]), &atlas, (-10.0, -10.0));
|
||||
assert_eq!(take_cursor(), Cursor::Arrow, "focus is not mouse hover");
|
||||
assert!(
|
||||
ui.focus.is_some(),
|
||||
"moving the pointer does not clear focus"
|
||||
);
|
||||
ui.focus_from_keyboard = true;
|
||||
ui.draw(&s, &mut HudBatch::new([480, 480]), &atlas, (-10.0, -10.0));
|
||||
assert_eq!(
|
||||
take_cursor(),
|
||||
Cursor::Arrow,
|
||||
"keyboard focus is not mouse hover either"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pointer_cursor_leaves_a_disabled_option_without_focusing_it() {
|
||||
use crate::widgets::{Cursor, take_cursor};
|
||||
let atlas = atlas();
|
||||
let mut s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::KeyBinds);
|
||||
let size = (480.0, 480.0);
|
||||
let (widgets, top, bottom, _) = ui.layout(&s, size.0, size.1);
|
||||
let button = widgets
|
||||
.iter()
|
||||
.find(|wd| {
|
||||
matches!(wd.kind, WKind::Button { active: false, .. })
|
||||
&& (!wd.list || wd.y >= top && wd.y + wd.h <= bottom)
|
||||
})
|
||||
.expect("visible disabled button");
|
||||
let over = (button.x + 1.0, button.y + 1.0);
|
||||
let _ = take_cursor();
|
||||
ui.draw(&s, &mut HudBatch::new([480, 480]), &atlas, over);
|
||||
assert_eq!(take_cursor(), Cursor::NotAllowed);
|
||||
assert!(
|
||||
ui.click(
|
||||
&mut s,
|
||||
&atlas,
|
||||
over.0,
|
||||
over.1,
|
||||
size,
|
||||
[480, 480],
|
||||
false,
|
||||
false
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
ui.focus.is_none(),
|
||||
"an inactive widget rejects the click before focus"
|
||||
);
|
||||
ui.draw(&s, &mut HudBatch::new([480, 480]), &atlas, (-10.0, -10.0));
|
||||
assert_eq!(take_cursor(), Cursor::Arrow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clicked_extras_tooltip_leaves_with_the_pointer() {
|
||||
let atlas = atlas();
|
||||
@@ -7616,7 +7669,7 @@ mod tests {
|
||||
/// Packs... opens the pack list, listed afresh, and Done comes back to that page.
|
||||
#[test]
|
||||
fn the_graphics_shaders_page_opens_the_shader_pack_list() {
|
||||
use crate::graphics_ui::{H, Item, Target};
|
||||
use crate::graphics_ui::{H, Item};
|
||||
use winit::keyboard::KeyCode;
|
||||
let atlas = atlas();
|
||||
let mut s = Settings::default();
|
||||
@@ -7625,15 +7678,15 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
ui.show(Page::Graphics);
|
||||
ui.graphics.open(crate::graphics_ui::Page::Shaders);
|
||||
let packs = Target::Item(Item::Horizon(H::ShaderPacks));
|
||||
ui.graphics.show(crate::graphics_ui::Page::Shaders);
|
||||
let packs = Item::Horizon(H::ShaderPacks);
|
||||
assert!(
|
||||
(0..40).any(|_| {
|
||||
ui.key(&mut s, &atlas, KeyCode::Tab, None);
|
||||
ui.graphics.focus == Some(packs)
|
||||
ui.graphics.focused_row() == Some(packs)
|
||||
}),
|
||||
"{:?}",
|
||||
ui.graphics.focus
|
||||
ui.graphics.focused()
|
||||
);
|
||||
let events = ui.key(&mut s, &atlas, KeyCode::Enter, None);
|
||||
assert_eq!(ui.page(), Page::ShaderPacks);
|
||||
@@ -7642,7 +7695,10 @@ mod tests {
|
||||
assert!(events.contains(&Event::OpenShaderOptions), "{events:?}");
|
||||
ui.back(&mut s);
|
||||
assert_eq!(ui.page(), Page::Graphics);
|
||||
assert_eq!(ui.graphics.page(), crate::graphics_ui::Page::Shaders);
|
||||
assert_eq!(
|
||||
ui.graphics.open_page(),
|
||||
Some(crate::graphics_ui::Page::Shaders)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -7865,14 +7921,20 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn the_graphics_screens_wheel_scrolls_even_over_a_slider_being_changed() {
|
||||
use crate::graphics_ui::{H, Item, Target};
|
||||
use crate::graphics_ui::{H, Item};
|
||||
use winit::keyboard::KeyCode as K;
|
||||
let mut s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::Graphics);
|
||||
ui.graphics.show(crate::graphics_ui::Page::Performance);
|
||||
let size = (640.0, 360.0);
|
||||
s.horizon.chunk_updates = 16;
|
||||
ui.graphics.focus = Some(Target::Item(Item::Horizon(H::ChunkUpdates)));
|
||||
ui.graphics.can_change_value = true;
|
||||
let chunk_updates = Item::Horizon(H::ChunkUpdates);
|
||||
// Tab onto it: the arrows change it.
|
||||
while ui.graphics.focused_row() != Some(chunk_updates) {
|
||||
shell::Hosted::key(&mut ui.graphics, &mut s, K::Tab, None, false, size);
|
||||
}
|
||||
assert!(ui.graphics.changes_value());
|
||||
let before = ui.graphics.scroll_amount();
|
||||
ui.wheel(&mut s, size, (320.0, 150.0), [1920, 1080], -1.0);
|
||||
assert_eq!(s.horizon.chunk_updates, 16);
|
||||
@@ -7902,29 +7964,21 @@ mod tests {
|
||||
// 2. Horizon Graphics page through OptionsUi:
|
||||
// Tab onto ChunkUpdates slider, ArrowRight adjusts value, Space toggles can_change_value
|
||||
ui.show(Page::Graphics);
|
||||
ui.graphics.open(crate::graphics_ui::Page::Performance);
|
||||
ui.graphics.show(crate::graphics_ui::Page::Performance);
|
||||
s.horizon.chunk_updates = 10;
|
||||
let chunk_updates = crate::graphics_ui::Item::Horizon(crate::graphics_ui::H::ChunkUpdates);
|
||||
ui.size = size;
|
||||
ui.framebuffer = [854, 480];
|
||||
|
||||
// Tab into controls
|
||||
for _ in 0..80 {
|
||||
ui.key(&mut s, &atlas, K::Tab, None);
|
||||
if ui.graphics.focus
|
||||
== Some(crate::graphics_ui::Target::Item(
|
||||
crate::graphics_ui::Item::Horizon(crate::graphics_ui::H::ChunkUpdates),
|
||||
))
|
||||
{
|
||||
if ui.graphics.focused_row() == Some(chunk_updates) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
ui.graphics.focus,
|
||||
Some(crate::graphics_ui::Target::Item(
|
||||
crate::graphics_ui::Item::Horizon(crate::graphics_ui::H::ChunkUpdates)
|
||||
))
|
||||
);
|
||||
assert!(ui.graphics.can_change_value);
|
||||
assert_eq!(ui.graphics.focused_row(), Some(chunk_updates));
|
||||
assert!(ui.graphics.changes_value());
|
||||
|
||||
// Right arrow steps ChunkUpdates to 11
|
||||
let events = ui.key(&mut s, &atlas, K::ArrowRight, None);
|
||||
@@ -7933,7 +7987,7 @@ mod tests {
|
||||
|
||||
// Space toggles can_change_value to false
|
||||
ui.key(&mut s, &atlas, K::Space, None);
|
||||
assert!(!ui.graphics.can_change_value);
|
||||
assert!(!ui.graphics.changes_value());
|
||||
|
||||
// ArrowRight does not change ChunkUpdates when can_change_value is false
|
||||
ui.key(&mut s, &atlas, K::ArrowRight, None);
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
//! The shared atlas's LabPBR maps (`_n`, `_s` beside an item's, entity's or block's texture),
|
||||
//! for shader packs: a map the size of the atlas would be mostly empty (only some textures have
|
||||
//! maps) and as large as the atlas itself, so each is a paged map instead. The atlas is cut into
|
||||
//! 16x16 pages; only pages holding a mapped texture get one in the map's pool, found through a
|
||||
//! page table at the map's start. Packs read the maps with the colour's coordinates, as for
|
||||
//! terrain: the translator turns their reads into `hz_pagedTexture` (horizon-shaders,
|
||||
//! `PAGED_READ`, which documents the layout this writes).
|
||||
|
||||
/// A page's side, in texels.
|
||||
pub const PAGE: u32 = 16;
|
||||
/// Texels before the page table: the atlas's size, and the map's value where it has none.
|
||||
const HEADER: u32 = 2;
|
||||
|
||||
/// One paged map, RGBA8.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PagedMap {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub pixels: Vec<u8>,
|
||||
}
|
||||
|
||||
/// A texture's maps: its place in the atlas (x, y, width, height, in texels) and its normal and
|
||||
/// specular images at that size (RGBA8), each where it has one.
|
||||
pub struct SpriteMaps {
|
||||
pub rect: [u32; 4],
|
||||
pub maps: [Option<Vec<u8>>; 2],
|
||||
}
|
||||
|
||||
/// The normal and specular paged maps of an atlas of `size` texels, each `None` where no texture
|
||||
/// has that map; `max_side` is the GPU's largest texture side.
|
||||
pub fn build(size: (u32, u32), sprites: &[SpriteMaps], max_side: u32) -> [Option<PagedMap>; 2] {
|
||||
[
|
||||
(0, crate::sprites::FLAT_NORMAL),
|
||||
(1, crate::sprites::NO_SPECULAR),
|
||||
]
|
||||
.map(|(kind, default)| {
|
||||
let mapped = sprites
|
||||
.iter()
|
||||
.filter_map(|s| s.maps[kind].as_deref().map(|m| (s.rect, m)));
|
||||
build_one(size, mapped, default, max_side)
|
||||
})
|
||||
}
|
||||
|
||||
fn build_one<'a>(
|
||||
(width, height): (u32, u32),
|
||||
sprites: impl Iterator<Item = ([u32; 4], &'a [u8])>,
|
||||
default: [u8; 4],
|
||||
max_side: u32,
|
||||
) -> Option<PagedMap> {
|
||||
let (pages_x, pages_y) = (width.div_ceil(PAGE), height.div_ceil(PAGE));
|
||||
// Each atlas page's place in the pool, plus one (0: none).
|
||||
let mut table = vec![0u32; (pages_x * pages_y) as usize];
|
||||
let mut pool: Vec<Vec<u8>> = Vec::new();
|
||||
let blank: Vec<u8> = default.repeat((PAGE * PAGE) as usize);
|
||||
for ([x, y, w, h], map) in sprites {
|
||||
if w == 0 || h == 0 || map.len() < (w * h * 4) as usize || x + w > width || y + h > height {
|
||||
continue;
|
||||
}
|
||||
for py in y / PAGE..=(y + h - 1) / PAGE {
|
||||
for px in x / PAGE..=(x + w - 1) / PAGE {
|
||||
let entry = &mut table[(py * pages_x + px) as usize];
|
||||
if *entry == 0 {
|
||||
pool.push(blank.clone());
|
||||
*entry = pool.len() as u32;
|
||||
}
|
||||
let page = &mut pool[*entry as usize - 1];
|
||||
// The sprite's texels on this page.
|
||||
let (x0, x1) = (x.max(px * PAGE), (x + w).min((px + 1) * PAGE));
|
||||
let (y0, y1) = (y.max(py * PAGE), (y + h).min((py + 1) * PAGE));
|
||||
for ty in y0..y1 {
|
||||
let src = (((ty - y) * w + (x0 - x)) * 4) as usize;
|
||||
let dst = (((ty - py * PAGE) * PAGE + (x0 - px * PAGE)) * 4) as usize;
|
||||
let len = ((x1 - x0) * 4) as usize;
|
||||
page[dst..dst + len].copy_from_slice(&map[src..src + len]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if pool.is_empty() || pool.len() >= 1 << 24 {
|
||||
return None;
|
||||
}
|
||||
let entries = HEADER + pages_x * pages_y;
|
||||
// The narrowest map width (whole pages) that keeps it within the GPU's side.
|
||||
let (side, rows, map_height) = [1024, 2048, 4096, 8192, 16384]
|
||||
.into_iter()
|
||||
.filter(|&s| s <= max_side)
|
||||
.map(|s| {
|
||||
let rows = entries.div_ceil(s).next_multiple_of(PAGE);
|
||||
let per_row = s / PAGE;
|
||||
(s, rows, rows + (pool.len() as u32).div_ceil(per_row) * PAGE)
|
||||
})
|
||||
.find(|&(_, _, h)| h <= max_side)?;
|
||||
let mut pixels = vec![0u8; (side * map_height * 4) as usize];
|
||||
let header = [
|
||||
width as u8,
|
||||
(width >> 8) as u8,
|
||||
height as u8,
|
||||
(height >> 8) as u8,
|
||||
];
|
||||
pixels[..4].copy_from_slice(&header);
|
||||
pixels[4..8].copy_from_slice(&default);
|
||||
for (i, &k) in table.iter().enumerate() {
|
||||
let at = (HEADER as usize + i) * 4;
|
||||
pixels[at..at + 4].copy_from_slice(&[k as u8, (k >> 8) as u8, (k >> 16) as u8, 255]);
|
||||
}
|
||||
let per_row = side / PAGE;
|
||||
for (k, page) in pool.iter().enumerate() {
|
||||
let k = k as u32;
|
||||
let (ox, oy) = (k % per_row * PAGE, rows + k / per_row * PAGE);
|
||||
for row in 0..PAGE {
|
||||
let dst = (((oy + row) * side + ox) * 4) as usize;
|
||||
let src = (row * PAGE * 4) as usize;
|
||||
pixels[dst..dst + (PAGE * 4) as usize]
|
||||
.copy_from_slice(&page[src..src + (PAGE * 4) as usize]);
|
||||
}
|
||||
}
|
||||
Some(PagedMap {
|
||||
width: side,
|
||||
height: map_height,
|
||||
pixels,
|
||||
})
|
||||
}
|
||||
|
||||
/// `map` (RGBA8, `mw` by `mh`) at `w` by `h`, nearest: a map drawn for a strip of animation
|
||||
/// frames keeps its first, the colour's shape from its top.
|
||||
pub fn fit(map: &[u8], (mw, mh): (u32, u32), (w, h): (u32, u32)) -> Option<Vec<u8>> {
|
||||
if mw == 0 || mh == 0 || w == 0 || h == 0 || map.len() < (mw * mh * 4) as usize {
|
||||
return None;
|
||||
}
|
||||
// The first frame of a strip as tall as the colour's frame is wide, scaled.
|
||||
let frame_h = (u64::from(mw) * u64::from(h) / u64::from(w)).clamp(1, u64::from(mh)) as u32;
|
||||
let mut out = Vec::with_capacity((w * h * 4) as usize);
|
||||
for y in 0..h {
|
||||
let sy = (u64::from(y) * u64::from(frame_h) / u64::from(h)) as u32;
|
||||
for x in 0..w {
|
||||
let sx = (u64::from(x) * u64::from(mw) / u64::from(w)) as u32;
|
||||
let at = ((sy * mw + sx) * 4) as usize;
|
||||
out.extend_from_slice(&map[at..at + 4]);
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// What `PAGED_READ` returns at texel (x, y) of the atlas.
|
||||
fn read(map: &PagedMap, (width, height): (u32, u32), (x, y): (u32, u32)) -> [u8; 4] {
|
||||
let texel = |at: u32| {
|
||||
let at = (at * 4) as usize;
|
||||
[0, 1, 2, 3].map(|c| map.pixels[at + c])
|
||||
};
|
||||
let at = |x: u32, y: u32| texel(y * map.width + x);
|
||||
let h = texel(0);
|
||||
assert_eq!(
|
||||
(
|
||||
u32::from(h[0]) | u32::from(h[1]) << 8,
|
||||
u32::from(h[2]) | u32::from(h[3]) << 8
|
||||
),
|
||||
(width, height)
|
||||
);
|
||||
let pages = (width.div_ceil(PAGE), height.div_ceil(PAGE));
|
||||
let e = texel(2 + (y / PAGE) * pages.0 + x / PAGE);
|
||||
let k = u32::from(e[0]) | u32::from(e[1]) << 8 | u32::from(e[2]) << 16;
|
||||
if k == 0 {
|
||||
return texel(1);
|
||||
}
|
||||
let rows = (2 + pages.0 * pages.1)
|
||||
.div_ceil(map.width)
|
||||
.next_multiple_of(16);
|
||||
let per_row = map.width / 16;
|
||||
at(
|
||||
(k - 1) % per_row * 16 + x % 16,
|
||||
rows + (k - 1) / per_row * 16 + y % 16,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapped_textures_read_back_and_the_rest_reads_the_default() {
|
||||
let size = (4096, 2048);
|
||||
// A 20x20 texture across four pages, and one without a specular map.
|
||||
let normal: Vec<u8> = (0..20 * 20)
|
||||
.flat_map(|i: u32| [i as u8, (i >> 8) as u8, 7, 255])
|
||||
.collect();
|
||||
let sprites = [
|
||||
SpriteMaps {
|
||||
rect: [30, 40, 20, 20],
|
||||
maps: [Some(normal.clone()), Some(vec![9; 20 * 20 * 4])],
|
||||
},
|
||||
SpriteMaps {
|
||||
rect: [1000, 1000, 16, 16],
|
||||
maps: [Some(vec![3; 16 * 16 * 4]), None],
|
||||
},
|
||||
];
|
||||
let [normals, specular] = build(size, &sprites, 16384);
|
||||
let (normals, specular) = (normals.unwrap(), specular.unwrap());
|
||||
for y in 0..20 {
|
||||
for x in 0..20 {
|
||||
let i = ((y * 20 + x) * 4) as usize;
|
||||
assert_eq!(read(&normals, size, (30 + x, 40 + y)), normal[i..i + 4]);
|
||||
assert_eq!(read(&specular, size, (30 + x, 40 + y)), [9; 4]);
|
||||
}
|
||||
}
|
||||
// The texels beside it on its pages, and pages without a map.
|
||||
assert_eq!(read(&normals, size, (29, 40)), crate::sprites::FLAT_NORMAL);
|
||||
assert_eq!(read(&normals, size, (2000, 5)), crate::sprites::FLAT_NORMAL);
|
||||
assert_eq!(
|
||||
read(&specular, size, (1005, 1005)),
|
||||
crate::sprites::NO_SPECULAR
|
||||
);
|
||||
assert_eq!(read(&normals, size, (1005, 1005)), [3; 4]);
|
||||
// Five pages: a table of 2 + 256 x 128 entries, 33 rows at 1024 wide (48, whole pages),
|
||||
// then a row of pages.
|
||||
assert_eq!((normals.width, normals.height), (1024, 64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_mapped_makes_no_map() {
|
||||
let sprites = [SpriteMaps {
|
||||
rect: [0, 0, 16, 16],
|
||||
maps: [None, None],
|
||||
}];
|
||||
assert_eq!(build((1024, 1024), &sprites, 16384), [None, None]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_map_fits_its_texture() {
|
||||
// A 2x map is sampled down; a strip of frames keeps its first.
|
||||
let map: Vec<u8> = (0..4 * 8).flat_map(|i: u8| [i, 0, 0, 255]).collect();
|
||||
let fitted = fit(&map, (4, 8), (2, 2)).unwrap();
|
||||
assert_eq!(
|
||||
fitted.chunks(4).map(|p| p[0]).collect::<Vec<_>>(),
|
||||
[0, 2, 8, 10]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,10 @@ pub struct ParticleOptions {
|
||||
pub mod_particle: Option<String>,
|
||||
/// Pre-1.20.5 entity-effect providers use spawn velocity as RGB; true is the ambient provider.
|
||||
pub legacy_entity_effect: Option<bool>,
|
||||
/// A pre-1.21.4 server's trail, which had no duration: TrailParticle.Provider chose
|
||||
/// `nextInt(40) + 10` after constructing the particle. Set by the client for an older
|
||||
/// server only, never read from the wire.
|
||||
pub legacy_trail_lifetime: bool,
|
||||
}
|
||||
impl ParticleOptions {
|
||||
pub fn simple(ty: ParticleType) -> Self {
|
||||
@@ -75,6 +79,7 @@ impl ParticleOptions {
|
||||
vibration: None,
|
||||
mod_particle: None,
|
||||
legacy_entity_effect: None,
|
||||
legacy_trail_lifetime: false,
|
||||
}
|
||||
}
|
||||
/// A native mod's `SimpleParticleType`: the sprite set of
|
||||
@@ -3485,9 +3490,7 @@ impl ParticleEngine {
|
||||
scaled(o.color[2], jb),
|
||||
);
|
||||
q.size = 0.26;
|
||||
// The older-server adapter marks pre-1.21.4 TargetColorParticleOption with -1.
|
||||
// Its TrailParticle.Provider chose nextInt(40) + 10 after constructing the particle.
|
||||
q.lifetime = if duration == -1 {
|
||||
q.lifetime = if o.legacy_trail_lifetime {
|
||||
self.level_random.next_int_bound(40).unwrap_or(0) + 10
|
||||
} else {
|
||||
duration
|
||||
@@ -5764,12 +5767,12 @@ mod tests {
|
||||
fn legacy_trails_choose_a_lifetime_for_each_particle_from_the_level_random() {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
engine.seed_randoms(1, 42, 2);
|
||||
let legacy = ParticleOptions {
|
||||
legacy_trail_lifetime: true,
|
||||
..ParticleOptions::trail([1.0, 2.0, 3.0], 0x7f3f1f, -1)
|
||||
};
|
||||
for _ in 0..5 {
|
||||
engine.create(
|
||||
&ParticleOptions::trail([1.0, 2.0, 3.0], 0x7f3f1f, -1),
|
||||
[0.0; 3],
|
||||
[0.0; 3],
|
||||
);
|
||||
engine.create(&legacy, [0.0; 3], [0.0; 3]);
|
||||
}
|
||||
// java.util.Random(42).nextInt(40) + 10, as vanilla 1.21.3 TrailParticle.Provider does.
|
||||
assert_eq!(
|
||||
@@ -5786,6 +5789,13 @@ mod tests {
|
||||
[0.0; 3],
|
||||
);
|
||||
assert_eq!(engine.pending.last().unwrap().lifetime, 37);
|
||||
// A current server's -1 is a lifetime like any other, not the old marker.
|
||||
engine.create(
|
||||
&ParticleOptions::trail([1.0, 2.0, 3.0], 0x7f3f1f, -1),
|
||||
[0.0; 3],
|
||||
[0.0; 3],
|
||||
);
|
||||
assert_eq!(engine.pending.last().unwrap().lifetime, -1);
|
||||
}
|
||||
#[test]
|
||||
fn sizes_follow_vanilla_curves() {
|
||||
|
||||
@@ -18,15 +18,33 @@ pub fn entity() -> LegacyRandom {
|
||||
create()
|
||||
}
|
||||
|
||||
/// `RandomSupport.SEED_UNIQUIFIER`'s start and step.
|
||||
const UNIQUIFIER_START: i64 = 8_682_522_807_148_012;
|
||||
const UNIQUIFIER_STEP: i64 = 1_181_783_497_276_652_981;
|
||||
|
||||
thread_local! {
|
||||
/// This thread's uniquifier under `HORIZON_TEST_SEEDS`.
|
||||
static THREAD_UNIQUIFIER: std::cell::Cell<i64> = const { std::cell::Cell::new(UNIQUIFIER_START) };
|
||||
}
|
||||
|
||||
/// `RandomSupport.generateUniqueSeed`. Under `HORIZON_STILL` (screenshots that
|
||||
/// come out the same every run) the clock is left out, so the seeds repeat run
|
||||
/// to run, in the order they are taken.
|
||||
/// to run, in the order they are taken. Under `HORIZON_TEST_SEEDS` the clock is
|
||||
/// left out too and each thread steps a uniquifier of its own, as the parity
|
||||
/// oracles' agent makes vanilla do (docs/parity-workflow.md, "Randoms that
|
||||
/// repeat"): the client's draws then don't shift the server thread's.
|
||||
pub fn unique_seed() -> i64 {
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
static UNIQUIFIER: AtomicI64 = AtomicI64::new(8_682_522_807_148_012);
|
||||
static UNIQUIFIER: AtomicI64 = AtomicI64::new(UNIQUIFIER_START);
|
||||
if test_seeds() {
|
||||
return THREAD_UNIQUIFIER.with(|u| {
|
||||
u.set(u.get().wrapping_mul(UNIQUIFIER_STEP));
|
||||
u.get()
|
||||
});
|
||||
}
|
||||
let mut current = UNIQUIFIER.load(Ordering::Relaxed);
|
||||
let next = loop {
|
||||
let next = current.wrapping_mul(1_181_783_497_276_652_981);
|
||||
let next = current.wrapping_mul(UNIQUIFIER_STEP);
|
||||
match UNIQUIFIER.compare_exchange(current, next, Ordering::Relaxed, Ordering::Relaxed) {
|
||||
Ok(_) => break next,
|
||||
Err(v) => current = v,
|
||||
@@ -47,6 +65,30 @@ fn still() -> bool {
|
||||
*STILL.get_or_init(|| std::env::var("HORIZON_STILL").is_ok_and(|v| v == "1"))
|
||||
}
|
||||
|
||||
/// `HORIZON_TEST_SEEDS=1`, a dev switch for parity harnesses, read once.
|
||||
fn test_seeds() -> bool {
|
||||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ON.get_or_init(|| {
|
||||
let on = std::env::var("HORIZON_TEST_SEEDS").is_ok_and(|v| v == "1");
|
||||
if on {
|
||||
log::warn!(
|
||||
"HORIZON_TEST_SEEDS=1: unseeded randoms repeat run to run (parity harnesses only)"
|
||||
);
|
||||
}
|
||||
on
|
||||
})
|
||||
}
|
||||
|
||||
/// A harness's sync step: the calling thread's uniquifier from now on. Only
|
||||
/// under `HORIZON_TEST_SEEDS`, where the clock is left out.
|
||||
pub fn set_thread_uniquifier(value: i64) -> Result<(), &'static str> {
|
||||
if !test_seeds() {
|
||||
return Err("setting the seed uniquifier needs HORIZON_TEST_SEEDS=1");
|
||||
}
|
||||
THREAD_UNIQUIFIER.with(|u| u.set(value));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -840,11 +840,14 @@ pub enum Opt {
|
||||
PadRepeatInterval,
|
||||
PadInvertX,
|
||||
PadInvertY,
|
||||
PadCraftOnServers,
|
||||
PadToggleSneak,
|
||||
PadMoveDeadzone,
|
||||
PadLookDeadzone,
|
||||
PadTriggerThreshold,
|
||||
PadGlyphs,
|
||||
/// A Nintendo pad's face buttons by place or by letter (`PadSettings::nintendo_letters`).
|
||||
PadNintendoButtons,
|
||||
PadKeyboard,
|
||||
PadGameplayGuide,
|
||||
/// `options.modelPart.*`: one skin layer (index into `options_file::MODEL_PARTS`).
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
//!
|
||||
//! A new screen implements [`Screen`], describing its rows from the settings, and [`Shell`] does
|
||||
//! everything else: drawing, clicks, one scroll path, a controller's focus and its declared
|
||||
//! buttons, keys and each row's description (`extras_ui` is the smallest). `graphics_ui`,
|
||||
//! `controller_ui` and `shader_ui` still drive the parts below by hand.
|
||||
//! buttons, keys and each row's description (`extras_ui` is the smallest; `graphics_ui` has
|
||||
//! pages, sliders, costs and Undo). `controller_ui` and `shader_ui` still drive the parts below
|
||||
//! by hand.
|
||||
use crate::{
|
||||
graphics_style as style,
|
||||
hud::{HudAtlas, HudBatch},
|
||||
@@ -14,6 +15,7 @@ use crate::{
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
use std::rc::Rc;
|
||||
|
||||
/// The screen's parts in GUI pixels. Down the left: the mark, the search
|
||||
/// field, the page list, and Undo and Done at the foot. On the right: the
|
||||
@@ -37,6 +39,15 @@ pub(crate) struct Columns {
|
||||
const NOTE_LINES: usize = 2;
|
||||
/// The height of a line of description.
|
||||
const NOTE_LINE_H: f32 = 10.0;
|
||||
/// The strip's text from its left edge, and its first line (the name) from its top.
|
||||
const DOCK_INSET: f32 = 12.0;
|
||||
const DOCK_NAME_Y: f32 = 8.0;
|
||||
/// A cost's square before the name: its side, how far down it sits, and the name after it.
|
||||
const COST_SQUARE: f32 = 4.0;
|
||||
const COST_SQUARE_Y: f32 = 10.0;
|
||||
const COST_NAME_X: f32 = 8.0;
|
||||
/// What the strip keeps clear at its right of a name with its cost.
|
||||
const COST_LINE_SLACK: f32 = 32.0;
|
||||
/// A taller strip leaves the list at least this high.
|
||||
const MIN_LIST_H: f32 = 60.0;
|
||||
impl Columns {
|
||||
@@ -744,7 +755,6 @@ pub(crate) enum Value {
|
||||
Choice(String),
|
||||
/// A slider: left and right step it (`steps`: its stops, else a pixel of the track), a press
|
||||
/// on the track sets it (a controller's focus does the same).
|
||||
#[allow(dead_code)] // No screen on the shell has one yet; `controller_ui` will (#11487).
|
||||
Slider {
|
||||
position: f32,
|
||||
steps: Option<u32>,
|
||||
@@ -771,6 +781,10 @@ pub(crate) struct Row<R> {
|
||||
pub(crate) chosen: Option<String>,
|
||||
/// Can be used now (a setting of a switch that is off can't).
|
||||
pub(crate) live: bool,
|
||||
/// Why it can't be used now: shown in the strip, greyed, instead of what it does.
|
||||
pub(crate) why: Option<String>,
|
||||
/// What it costs, beside its name in the strip.
|
||||
pub(crate) cost: Option<style::Cost>,
|
||||
}
|
||||
impl<R> Row<R> {
|
||||
pub(crate) fn new(id: R, name: String, value: Value) -> Self {
|
||||
@@ -781,6 +795,8 @@ impl<R> Row<R> {
|
||||
about: String::new(),
|
||||
chosen: None,
|
||||
live: true,
|
||||
why: None,
|
||||
cost: None,
|
||||
}
|
||||
}
|
||||
pub(crate) fn about(mut self, about: String) -> Self {
|
||||
@@ -791,6 +807,16 @@ impl<R> Row<R> {
|
||||
self.live = live;
|
||||
self
|
||||
}
|
||||
/// Greyed, saying why, while there is a reason it can't be used.
|
||||
pub(crate) fn blocked(mut self, why: Option<String>) -> Self {
|
||||
self.live = why.is_none();
|
||||
self.why = why;
|
||||
self
|
||||
}
|
||||
pub(crate) fn cost(mut self, cost: style::Cost) -> Self {
|
||||
self.cost = Some(cost);
|
||||
self
|
||||
}
|
||||
/// Its whole caption ("Name: value"), for the narrator and the widget tree.
|
||||
fn label(&self, value: Option<&str>) -> String {
|
||||
match value {
|
||||
@@ -865,6 +891,11 @@ pub(crate) trait Screen {
|
||||
NOTE_LINES
|
||||
}
|
||||
fn sheets(&self, s: &Settings) -> Vec<Sheet<Self::Page, Self::Row>>;
|
||||
/// A fingerprint ([`stamp_of`]) of what `sheets` reads that can change while the screen is
|
||||
/// open, besides through `press`, `slide` and `wheel_step` (the shell builds again after
|
||||
/// those): the rows are built again only when it changes, or the search, the page, the size or
|
||||
/// the language does.
|
||||
fn stamp(&self, s: &Settings) -> u64;
|
||||
/// A row was pressed (`back`: Shift held); the shell has played the click.
|
||||
fn press(&mut self, s: &mut Settings, row: Self::Row, back: bool) -> Vec<Output>;
|
||||
/// A slider row moved to `position`.
|
||||
@@ -879,6 +910,45 @@ pub(crate) trait Screen {
|
||||
) -> Option<Vec<Output>> {
|
||||
None
|
||||
}
|
||||
/// Whether the screen has Undo, which puts back what it changed (`undo`) since it opened.
|
||||
fn undoes(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// Undo: put back, from the settings as the screen opened (`before`), what it changes.
|
||||
fn undo(&mut self, _s: &mut Settings, _before: &Settings) {}
|
||||
/// Whether what a row did (its outputs) is a change Undo puts back.
|
||||
fn changes(&self, outputs: &[Output]) -> bool {
|
||||
changes_settings(outputs)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `outputs` say a setting changed, or a key is being bound to one.
|
||||
pub(crate) fn changes_settings(outputs: &[Output]) -> bool {
|
||||
use crate::options_ui::Event;
|
||||
let changed = |output: &Output| {
|
||||
matches!(
|
||||
output,
|
||||
Output::BindKey(_) | Output::Event(Event::Changed | Event::SliderDrag)
|
||||
)
|
||||
};
|
||||
outputs.iter().any(changed)
|
||||
}
|
||||
|
||||
/// A fingerprint of `parts` for [`Screen::stamp`]: their `Debug` text, hashed as it is written,
|
||||
/// without keeping it.
|
||||
pub(crate) fn stamp_of(parts: &impl std::fmt::Debug) -> u64 {
|
||||
use std::{fmt::Write, hash::Hasher};
|
||||
struct Hashing(std::collections::hash_map::DefaultHasher);
|
||||
impl Write for Hashing {
|
||||
fn write_str(&mut self, text: &str) -> std::fmt::Result {
|
||||
self.0.write(text.as_bytes());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
let mut hashing = Hashing(Default::default());
|
||||
// Writing into a hasher can't fail.
|
||||
let _ = write!(hashing, "{parts:?}");
|
||||
hashing.0.finish()
|
||||
}
|
||||
|
||||
/// A [`Shell`] of any screen, as `OptionsUi` drives it.
|
||||
@@ -907,6 +977,10 @@ pub(crate) trait Hosted {
|
||||
) -> Vec<Output>;
|
||||
/// Lets go of a slider (true: it plays its click) or the scrollbar.
|
||||
fn release(&mut self) -> bool;
|
||||
/// A controller's or the keyboard's focus is on the row at `rect`: if the list hides part of
|
||||
/// it, it scrolls all into view (`AbstractSelectionList.setSelected`). How far the row moved
|
||||
/// down, when it moved.
|
||||
fn reveal(&mut self, s: &Settings, size: (f32, f32), rect: [f32; 4]) -> Option<f32>;
|
||||
/// The mouse wheel at `at`: it scrolls, unless the row under it takes the step (`wheel_step`).
|
||||
fn wheel(
|
||||
&mut self,
|
||||
@@ -931,6 +1005,9 @@ pub(crate) trait Hosted {
|
||||
fn typing(&self) -> bool;
|
||||
/// A key or the pad moved the focus away: the search field stops taking the keys.
|
||||
fn blur_search(&mut self);
|
||||
/// Nothing has the focus: a touch moved off it, say.
|
||||
#[allow(dead_code)] // For #11846's `OptionsUi::touch_start`, which cleared Graphics' focus.
|
||||
fn clear_focus(&mut self);
|
||||
/// Text typed into the search field (the keyboard, a controller's on-screen keyboard).
|
||||
fn type_text(&mut self, text: &str);
|
||||
/// Ctrl+A/C/V/X in the search field.
|
||||
@@ -943,10 +1020,11 @@ pub(crate) trait Hosted {
|
||||
|
||||
/// What the keyboard or a controller can focus.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Target<P, R> {
|
||||
pub(crate) enum Target<P, R> {
|
||||
Row(R),
|
||||
Page(P),
|
||||
Search,
|
||||
Undo,
|
||||
Done,
|
||||
}
|
||||
|
||||
@@ -961,6 +1039,19 @@ struct View<P, R> {
|
||||
content: f32,
|
||||
}
|
||||
|
||||
/// What a [`View`] was built for: when any of it changes, the rows are built again.
|
||||
#[derive(PartialEq)]
|
||||
struct Built<P, R> {
|
||||
stamp: u64,
|
||||
/// Which language the rows' text is in (`lang::generation`).
|
||||
language: u64,
|
||||
size: (f32, f32),
|
||||
query: String,
|
||||
page: Option<P>,
|
||||
/// While searching, the rows the focus and a drag keep in the list.
|
||||
kept: (Option<R>, Option<R>),
|
||||
}
|
||||
|
||||
/// A screen on the shell, with all that isn't the screen's: where the list is, what has the focus,
|
||||
/// what is held and what is searched.
|
||||
pub(crate) struct Shell<S: Screen> {
|
||||
@@ -980,6 +1071,12 @@ pub(crate) struct Shell<S: Screen> {
|
||||
/// While searching, the page last picked in the page list: current until the list scrolls.
|
||||
picked: Option<S::Page>,
|
||||
waiting: Option<&'static str>,
|
||||
/// The rows as last built, and what for.
|
||||
built: Option<(Built<S::Page, S::Row>, Rc<View<S::Page, S::Row>>)>,
|
||||
/// The settings as the screen found them, for Undo (`Screen::undoes`), and whether it
|
||||
/// changed them since.
|
||||
before: Option<Box<Settings>>,
|
||||
changed: bool,
|
||||
}
|
||||
impl<S: Screen + Default> Default for Shell<S> {
|
||||
fn default() -> Self {
|
||||
@@ -998,6 +1095,9 @@ impl<S: Screen + Default> Default for Shell<S> {
|
||||
page: None,
|
||||
picked: None,
|
||||
waiting: None,
|
||||
built: None,
|
||||
before: None,
|
||||
changed: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1008,10 +1108,71 @@ impl<S: Screen> Shell<S> {
|
||||
fn shows_focus(&self, target: Target<S::Page, S::Row>) -> bool {
|
||||
self.keyed && self.focus == Some(target)
|
||||
}
|
||||
/// The screen's rows now: the open page's or, while searching, every page's whose name or
|
||||
/// The screen's rows now, built again only when what they were built for changed
|
||||
/// ([`Built`]). The scroll is kept within them.
|
||||
fn view(&mut self, s: &Settings, size: (f32, f32)) -> Rc<View<S::Page, S::Row>> {
|
||||
let query = self.query();
|
||||
let searching = !query.is_empty();
|
||||
let focused = match self.focus {
|
||||
Some(Target::Row(row)) if searching => Some(row),
|
||||
_ => None,
|
||||
};
|
||||
let key = Built {
|
||||
stamp: self.screen.stamp(s),
|
||||
language: lang::generation(),
|
||||
size,
|
||||
query,
|
||||
page: self.page,
|
||||
kept: (focused, self.dragging.filter(|_| searching)),
|
||||
};
|
||||
let view = match &self.built {
|
||||
Some((built, view)) if *built == key => view.clone(),
|
||||
_ => {
|
||||
let view = Rc::new(self.build(s, size));
|
||||
self.built = Some((key, view.clone()));
|
||||
view
|
||||
}
|
||||
};
|
||||
self.scroll
|
||||
.clamp(view.cols.top, view.cols.bottom, view.content);
|
||||
view
|
||||
}
|
||||
/// A row may read otherwise now: the screen changed something.
|
||||
fn rebuild(&mut self) {
|
||||
self.built = None;
|
||||
}
|
||||
/// A row did `outputs`: it reads anew, and Undo has something to put back if they changed it.
|
||||
fn did(&mut self, outputs: &[Output]) {
|
||||
self.rebuild();
|
||||
// Only a screen with Undo has something for it to put back.
|
||||
if self.screen.undoes() && self.screen.changes(outputs) {
|
||||
self.changed = true;
|
||||
}
|
||||
}
|
||||
/// Remember the settings before the first change, for Undo.
|
||||
fn keep(&mut self, s: &Settings) {
|
||||
if self.screen.undoes() && self.before.is_none() {
|
||||
self.before = Some(Box::new(s.clone()));
|
||||
}
|
||||
}
|
||||
/// Undo, gone once it has put the settings back.
|
||||
fn undo(&mut self, s: &mut Settings) -> Vec<Output> {
|
||||
if let Some(before) = self.before.take() {
|
||||
self.screen.undo(s, &before);
|
||||
self.before = Some(before);
|
||||
}
|
||||
self.changed = false;
|
||||
self.rebuild();
|
||||
vec![CLICK, Output::Event(crate::options_ui::Event::Changed)]
|
||||
}
|
||||
/// Open at `page`.
|
||||
pub(crate) fn show(&mut self, page: S::Page) {
|
||||
self.page = Some(page);
|
||||
}
|
||||
/// The screen's rows: the open page's or, while searching, every page's whose name or
|
||||
/// description has the search in it, under their pages' names. The row being changed stays
|
||||
/// whatever it reads now. The scroll is kept within it.
|
||||
fn view(&mut self, s: &Settings, (w, h): (f32, f32)) -> View<S::Page, S::Row> {
|
||||
/// whatever it reads now.
|
||||
fn build(&self, s: &Settings, (w, h): (f32, f32)) -> View<S::Page, S::Row> {
|
||||
let cols = Columns::of(w, h).with_note_lines(self.screen.note_lines());
|
||||
let query = self.query();
|
||||
let sheets = self.screen.sheets(s);
|
||||
@@ -1048,7 +1209,6 @@ impl<S: Screen> Shell<S> {
|
||||
}
|
||||
}
|
||||
let (lines, content) = lines.finish();
|
||||
self.scroll.clamp(cols.top, cols.bottom, content);
|
||||
View {
|
||||
cols,
|
||||
pages,
|
||||
@@ -1136,12 +1296,14 @@ impl<S: Screen> Shell<S> {
|
||||
};
|
||||
self.dragging = Some(row.id);
|
||||
self.screen.slide(s, row.id, position);
|
||||
self.did(&[DRAGGED]);
|
||||
vec![DRAGGED]
|
||||
}
|
||||
_ => {
|
||||
self.can_change_value = false;
|
||||
let mut outputs = vec![CLICK];
|
||||
outputs.extend(self.screen.press(s, row.id, back));
|
||||
self.did(&outputs);
|
||||
outputs
|
||||
}
|
||||
}
|
||||
@@ -1168,9 +1330,28 @@ impl<S: Screen> Shell<S> {
|
||||
out.push((Target::Row(id), cols.row(y)));
|
||||
}
|
||||
}
|
||||
if self.changed {
|
||||
out.push((Target::Undo, cols.button(1)));
|
||||
}
|
||||
out.push((Target::Done, cols.button(0)));
|
||||
out
|
||||
}
|
||||
/// Where Down from the search field goes on a screen with no page list, whose foot is under the
|
||||
/// field instead: the first row to use that is all in view.
|
||||
fn first_row(
|
||||
&self,
|
||||
view: &View<S::Page, S::Row>,
|
||||
) -> Option<(Target<S::Page, S::Row>, [f32; 4])> {
|
||||
if !self.tabs(view).is_empty() {
|
||||
return None;
|
||||
}
|
||||
let cols = &view.cols;
|
||||
let mut placed = self.placed(view);
|
||||
let first = placed.find(|&(row, y)| {
|
||||
row.live && row.value != Value::Note && y >= cols.top && y + style::ROW <= cols.bottom
|
||||
});
|
||||
first.map(|(row, y)| (Target::Row(row.id), cols.row(y)))
|
||||
}
|
||||
/// Focus `target`, scrolling its row into the list's view.
|
||||
fn focus_on(
|
||||
&mut self,
|
||||
@@ -1247,10 +1428,14 @@ impl<S: Screen> Shell<S> {
|
||||
current: Option<S::Page>,
|
||||
) {
|
||||
let cols = &view.cols;
|
||||
let [x, y, ..] = cols.dock;
|
||||
let [x, y, w, _] = cols.dock;
|
||||
let left = x + DOCK_INSET;
|
||||
let top = y + DOCK_NAME_Y;
|
||||
style::panel(batch, atlas, cols.dock);
|
||||
let (name, about) = match described.map(|i| &view.rows[i]) {
|
||||
Some(row) => (row.name.as_str(), row.described()),
|
||||
let row = described.map(|i| &view.rows[i]);
|
||||
let why = row.and_then(|row| row.why.as_deref().filter(|_| !row.live));
|
||||
let (name, about) = match row {
|
||||
Some(row) => (row.name.as_str(), why.unwrap_or(row.described())),
|
||||
None => {
|
||||
let Some(page) = view.pages.iter().find(|p| Some(p.0) == current) else {
|
||||
return;
|
||||
@@ -1258,11 +1443,30 @@ impl<S: Screen> Shell<S> {
|
||||
(page.1.as_str(), page.2.as_str())
|
||||
}
|
||||
};
|
||||
let name = Text::literal(name.to_owned());
|
||||
batch.text_styled(atlas, &name, x + 12.0, y + 8.0, style::WHITE, true);
|
||||
match row.and_then(|row| row.cost) {
|
||||
// Its cost in its colour, after a square of it.
|
||||
Some(cost) => {
|
||||
let line = format!("{name} · {}", lang::get(cost.label()));
|
||||
let line = style::fit(atlas, &line, w - COST_LINE_SLACK);
|
||||
let line = Text::literal(line).with_color(cost.rgb());
|
||||
let square = [left, y + COST_SQUARE_Y, COST_SQUARE, COST_SQUARE];
|
||||
style::fill(batch, atlas, square, style::cost_color(cost));
|
||||
let after = left + COST_NAME_X;
|
||||
batch.text_styled(atlas, &line, after, top, style::WHITE, true);
|
||||
}
|
||||
None => {
|
||||
let name = Text::literal(name.to_owned());
|
||||
batch.text_styled(atlas, &name, left, top, style::WHITE, true);
|
||||
}
|
||||
}
|
||||
// Labelled for the widget tree: what the strip says is what a test or an agent reads.
|
||||
crate::widgets::record(about, cols.dock, false);
|
||||
dock_note(batch, atlas, cols, about.to_owned(), style::GREY);
|
||||
let color = if why.is_some() {
|
||||
style::MUTED
|
||||
} else {
|
||||
style::GREY
|
||||
};
|
||||
dock_note(batch, atlas, cols, about.to_owned(), color);
|
||||
}
|
||||
}
|
||||
impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
@@ -1279,6 +1483,7 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
waiting: Option<&'static str>,
|
||||
) {
|
||||
self.waiting = waiting;
|
||||
self.keep(s);
|
||||
let view = self.view(s, (batch.width, batch.height));
|
||||
let cols = view.cols;
|
||||
backdrop(batch, atlas, in_world);
|
||||
@@ -1325,7 +1530,18 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
},
|
||||
);
|
||||
scroller(batch, atlas, &cols, view.content, self.scroll.amount);
|
||||
if let Some((_, row)) = self.first_row(&view) {
|
||||
crate::widgets::focus_link(cols.search(), true, row);
|
||||
}
|
||||
self.draw_dock(batch, atlas, &view, described, current);
|
||||
// Undo, over Done, only once there is something to undo.
|
||||
if self.changed {
|
||||
let undo = cols.button(1);
|
||||
let label = Text::literal(lang::get("Undo"));
|
||||
let flags = (self.shows_focus(Target::Undo), false);
|
||||
crate::widgets::declare_action(crate::widgets::Action::Apply, undo, true);
|
||||
foot_button(batch, atlas, undo, &label, cursor, flags);
|
||||
}
|
||||
let done = cols.button(0);
|
||||
let label = Text::literal(lang::get("Done"));
|
||||
let flags = (self.shows_focus(Target::Done), true);
|
||||
@@ -1341,6 +1557,7 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
right: bool,
|
||||
shift: bool,
|
||||
) -> Vec<Output> {
|
||||
self.keep(s);
|
||||
let view = self.view(s, size);
|
||||
let cols = view.cols;
|
||||
let at = |[rx, ry, rw, rh]: [f32; 4]| ui::hovered((x, y), rx, ry, rw, rh);
|
||||
@@ -1358,6 +1575,7 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
}
|
||||
if let Some(row) = self.dragging {
|
||||
self.screen.slide(s, row, cols.slider_at(x));
|
||||
self.did(&[DRAGGED]);
|
||||
return vec![DRAGGED];
|
||||
}
|
||||
return Vec::new();
|
||||
@@ -1390,6 +1608,11 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
self.focus = Some(Target::Done);
|
||||
return vec![CLICK, Output::Done];
|
||||
}
|
||||
if self.changed && at(cols.button(1)) {
|
||||
// Undo goes away with the change it undid.
|
||||
self.focus = None;
|
||||
return self.undo(s);
|
||||
}
|
||||
for (i, page) in self.tabs(&view) {
|
||||
if at(cols.page(i)) && self.has(page, &view) {
|
||||
self.focus = Some(Target::Page(page));
|
||||
@@ -1409,6 +1632,22 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
self.scroll.release();
|
||||
self.dragging.take().is_some()
|
||||
}
|
||||
fn reveal(&mut self, s: &Settings, size: (f32, f32), [x, y, w, h]: [f32; 4]) -> Option<f32> {
|
||||
let view = self.view(s, size);
|
||||
let cols = &view.cols;
|
||||
let middle = (x + w / 2.0, y + h / 2.0);
|
||||
let (_, row_y) = self.placed(&view).find(|&(_, ry)| {
|
||||
let [rx, ry, rw, rh] = cols.row(ry);
|
||||
ui::hovered(middle, rx, ry, rw, rh)
|
||||
})?;
|
||||
let before = self.scroll.amount;
|
||||
scroll_into_view(&mut self.scroll, cols.row(row_y), cols, view.content);
|
||||
let moved = before - self.scroll.amount;
|
||||
(moved != 0.0).then(|| {
|
||||
self.picked = None;
|
||||
moved
|
||||
})
|
||||
}
|
||||
fn wheel(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
@@ -1421,6 +1660,7 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
if let Some(row) = self.row_at(&view, at).filter(|row| row.live)
|
||||
&& let Some(outputs) = self.screen.wheel_step(s, row.id, amount > 0.0)
|
||||
{
|
||||
self.did(&outputs);
|
||||
return outputs;
|
||||
}
|
||||
}
|
||||
@@ -1443,6 +1683,7 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
size: (f32, f32),
|
||||
) -> Vec<Output> {
|
||||
use winit::keyboard::KeyCode as K;
|
||||
self.keep(s);
|
||||
if key == K::Escape {
|
||||
self.can_change_value = false;
|
||||
return vec![Output::Done];
|
||||
@@ -1471,6 +1712,14 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
self.focus_on(focusables[next], &view);
|
||||
return Vec::new();
|
||||
}
|
||||
if key == K::ArrowDown
|
||||
&& self.focus == Some(Target::Search)
|
||||
&& let Some(first) = self.first_row(&view)
|
||||
{
|
||||
self.keyed = true;
|
||||
self.focus_on(first, &view);
|
||||
return Vec::new();
|
||||
}
|
||||
let focused = view
|
||||
.rows
|
||||
.iter()
|
||||
@@ -1486,6 +1735,7 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
let width = view.cols.track().1 + 8.0;
|
||||
let next = slider_step(position, steps, width, key == K::ArrowRight);
|
||||
self.screen.slide(s, row.id, next);
|
||||
self.did(&[DRAGGED]);
|
||||
return vec![DRAGGED];
|
||||
}
|
||||
if matches!(
|
||||
@@ -1521,6 +1771,11 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
Some(row) => self.activate(s, row, shift, None),
|
||||
None => Vec::new(),
|
||||
},
|
||||
Some(Target::Undo) => {
|
||||
// Undo goes away with the change it undid: Done has the focus.
|
||||
self.focus = Some(Target::Done);
|
||||
self.undo(s)
|
||||
}
|
||||
Some(Target::Done) => vec![CLICK, Output::Done],
|
||||
Some(Target::Search) | None => Vec::new(),
|
||||
}
|
||||
@@ -1533,6 +1788,10 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
self.focus = None;
|
||||
}
|
||||
}
|
||||
fn clear_focus(&mut self) {
|
||||
self.focus = None;
|
||||
self.can_change_value = false;
|
||||
}
|
||||
fn type_text(&mut self, text: &str) {
|
||||
if !text.is_empty() {
|
||||
self.search.insert_text(text);
|
||||
@@ -1576,6 +1835,78 @@ impl<S: Screen> Shell<S> {
|
||||
pub(crate) fn scroll_amount(&self) -> f32 {
|
||||
self.scroll.amount
|
||||
}
|
||||
/// What the shell's own focus is on.
|
||||
pub(crate) fn focused(&self) -> Option<Target<S::Page, S::Row>> {
|
||||
self.focus
|
||||
}
|
||||
/// Put the focus there, as a key would.
|
||||
pub(crate) fn set_focus(&mut self, focus: Option<Target<S::Page, S::Row>>) {
|
||||
self.focus = focus;
|
||||
self.keyed = true;
|
||||
}
|
||||
/// Left and Right move the focused slider.
|
||||
pub(crate) fn changes_value(&self) -> bool {
|
||||
self.can_change_value
|
||||
}
|
||||
/// Undo shows: the screen changed something since it opened.
|
||||
pub(crate) fn changed(&self) -> bool {
|
||||
self.changed
|
||||
}
|
||||
/// The scrollbar is held.
|
||||
pub(crate) fn scroller_held(&self) -> bool {
|
||||
self.scroll.held()
|
||||
}
|
||||
/// What the search field holds now, set as typing would.
|
||||
pub(crate) fn set_search(&mut self, text: &str) {
|
||||
self.search.set_value(text);
|
||||
self.searched();
|
||||
}
|
||||
/// The page shown while nothing is searched, as opened or picked.
|
||||
pub(crate) fn open_page(&self) -> Option<S::Page> {
|
||||
self.page
|
||||
}
|
||||
/// The list's lines, in order, and its content's height.
|
||||
pub(crate) fn lines_at(
|
||||
&mut self,
|
||||
s: &Settings,
|
||||
size: (f32, f32),
|
||||
) -> (Vec<Line<S::Page, usize>>, f32) {
|
||||
let view = self.view(s, size);
|
||||
(view.lines.clone(), view.content)
|
||||
}
|
||||
/// Each row of the list, in order, with its page.
|
||||
pub(crate) fn listed(&mut self, s: &Settings, size: (f32, f32)) -> Vec<(S::Page, S::Row)> {
|
||||
let view = self.view(s, size);
|
||||
let lines = view.lines.iter();
|
||||
lines
|
||||
.filter_map(|l| Some((l.page, view.rows[l.setting?].id)))
|
||||
.collect()
|
||||
}
|
||||
/// The page the plate is headed with.
|
||||
pub(crate) fn current_page(&mut self, s: &Settings, size: (f32, f32)) -> Option<S::Page> {
|
||||
let view = self.view(s, size);
|
||||
self.current(&view)
|
||||
}
|
||||
/// Whether `row` is all in the list's view now.
|
||||
pub(crate) fn in_view(&mut self, s: &Settings, size: (f32, f32), row: S::Row) -> bool {
|
||||
let view = self.view(s, size);
|
||||
let cols = view.cols;
|
||||
let mut placed = self.placed(&view);
|
||||
placed.any(|(r, y)| r.id == row && y >= cols.top && y + style::ROW <= cols.bottom)
|
||||
}
|
||||
/// Whether Tab reaches `row` now.
|
||||
pub(crate) fn tab_reaches(&mut self, s: &Settings, size: (f32, f32), row: S::Row) -> bool {
|
||||
let view = self.view(s, size);
|
||||
let focusables = self.focusables(&view, false);
|
||||
focusables.iter().any(|(t, _)| *t == Target::Row(row))
|
||||
}
|
||||
/// The row the shell's own focus is on.
|
||||
pub(crate) fn focused_row(&self) -> Option<S::Row> {
|
||||
match self.focus {
|
||||
Some(Target::Row(row)) => Some(row),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The captions of the rows the list has now, as the widget tree reads them.
|
||||
pub(crate) fn row_labels(&mut self, s: &Settings) -> Vec<String> {
|
||||
let view = self.view(s, (854.0, 480.0));
|
||||
@@ -1601,6 +1932,8 @@ mod tests {
|
||||
slider: f32,
|
||||
choice: usize,
|
||||
pressed: Vec<(u32, bool)>,
|
||||
/// How many times its rows were built.
|
||||
built: std::cell::Cell<usize>,
|
||||
}
|
||||
const CHOICES: [(&str, &str); 2] = [("Red", "Paints it red."), ("Blue", "Paints it blue.")];
|
||||
const SLIDER: u32 = 0;
|
||||
@@ -1608,13 +1941,19 @@ mod tests {
|
||||
const FIRST_FILLER: u32 = 10;
|
||||
const FILLERS: u32 = 40;
|
||||
const GREY: u32 = 100;
|
||||
const WHY: &str = "Not here.";
|
||||
impl Screen for Probe {
|
||||
type Page = u8;
|
||||
type Row = u32;
|
||||
fn title(&self) -> &'static str {
|
||||
"Probe"
|
||||
}
|
||||
fn sheets(&self, _: &Settings) -> Vec<Sheet<u8, u32>> {
|
||||
/// Its rows read the field of view, besides its own state.
|
||||
fn stamp(&self, s: &Settings) -> u64 {
|
||||
stamp_of(&s.fov)
|
||||
}
|
||||
fn sheets(&self, s: &Settings) -> Vec<Sheet<u8, u32>> {
|
||||
self.built.set(self.built.get() + 1);
|
||||
let (value, chosen) = CHOICES[self.choice];
|
||||
let mut rows = vec![
|
||||
Row::new(
|
||||
@@ -1623,7 +1962,7 @@ mod tests {
|
||||
Value::Slider {
|
||||
position: self.slider,
|
||||
steps: Some(10),
|
||||
text: "x".to_owned(),
|
||||
text: s.fov.to_string(),
|
||||
},
|
||||
),
|
||||
Row {
|
||||
@@ -1631,7 +1970,9 @@ mod tests {
|
||||
..Row::new(CHOICE, "Paint".to_owned(), Value::Choice(value.to_owned()))
|
||||
.about("Picks a colour.".to_owned())
|
||||
},
|
||||
Row::new(GREY, "Greyed".to_owned(), Value::Press).live(false),
|
||||
Row::new(GREY, "Greyed".to_owned(), Value::Press)
|
||||
.about("Does a thing.".to_owned())
|
||||
.blocked(Some(WHY.to_owned())),
|
||||
];
|
||||
rows.extend((0..FILLERS).map(|i| {
|
||||
Row::new(FIRST_FILLER + i, format!("Filler {i}"), Value::Press)
|
||||
@@ -1655,13 +1996,21 @@ mod tests {
|
||||
},
|
||||
]
|
||||
}
|
||||
fn press(&mut self, _: &mut Settings, row: u32, back: bool) -> Vec<Output> {
|
||||
fn press(&mut self, s: &mut Settings, row: u32, back: bool) -> Vec<Output> {
|
||||
self.pressed.push((row, back));
|
||||
if row == CHOICE {
|
||||
self.choice = (self.choice + 1) % CHOICES.len();
|
||||
match row {
|
||||
CHOICE => self.choice = (self.choice + 1) % CHOICES.len(),
|
||||
FIRST_FILLER => s.fov += 1.0,
|
||||
_ => {}
|
||||
}
|
||||
vec![CHANGED]
|
||||
}
|
||||
fn undoes(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn undo(&mut self, s: &mut Settings, before: &Settings) {
|
||||
s.fov = before.fov;
|
||||
}
|
||||
fn slide(&mut self, _: &mut Settings, _: u32, position: f32) {
|
||||
self.slider = position;
|
||||
}
|
||||
@@ -1677,6 +2026,169 @@ mod tests {
|
||||
shell.point_at(&Settings::default(), size, row)
|
||||
}
|
||||
|
||||
fn atlas() -> HudAtlas {
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
|
||||
}
|
||||
|
||||
/// What one frame asks of a screen: a draw, then the stick's scroll, an arrow key and the wheel
|
||||
/// away from any row, at the size it was drawn at.
|
||||
fn frame(shell: &mut Shell<Probe>, s: &mut Settings, atlas: &HudAtlas) -> (f32, f32) {
|
||||
let mut batch = HudBatch::new([1708, 960]);
|
||||
shell.draw(s, &mut batch, atlas, (0.0, 0.0), false, None);
|
||||
let size = (batch.width, batch.height);
|
||||
shell.scroll(s, size, -1.0);
|
||||
shell.key(s, K::ArrowDown, None, false, size);
|
||||
shell.wheel(s, size, (0.0, 0.0), 1.0);
|
||||
size
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_are_built_once_and_again_only_when_what_they_read_changes() {
|
||||
let atlas = atlas();
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let built = |shell: &Shell<Probe>| shell.screen.built.get();
|
||||
let reads = |shell: &mut Shell<Probe>, s: &Settings, size, end: &str| {
|
||||
let view = shell.view(s, size);
|
||||
let mut labels = view.rows.iter().map(|r| r.label(r.value_text().as_deref()));
|
||||
labels.any(|label| label.ends_with(end))
|
||||
};
|
||||
// Each of the four built them before: now the first frame builds them once, the next none.
|
||||
let size = frame(&mut shell, &mut s, &atlas);
|
||||
assert_eq!(built(&shell), 1);
|
||||
frame(&mut shell, &mut s, &atlas);
|
||||
assert_eq!(built(&shell), 1);
|
||||
// A press changes the screen's own state: its row reads the new value.
|
||||
let at = middle_of(&mut shell, CHOICE, size);
|
||||
shell.click(&mut s, at, size, false, false, false);
|
||||
assert!(reads(&mut shell, &s, size, "Blue"));
|
||||
// A setting changed elsewhere (a key bound, a command) shows on the next frame.
|
||||
s.fov = 90.0;
|
||||
frame(&mut shell, &mut s, &atlas);
|
||||
assert!(reads(&mut shell, &s, size, "90"));
|
||||
let before = built(&shell);
|
||||
frame(&mut shell, &mut s, &atlas);
|
||||
assert_eq!(built(&shell), before);
|
||||
// Searching, and another size, build them again.
|
||||
shell.type_text("filler");
|
||||
shell.view(&s, size);
|
||||
assert_eq!(built(&shell), before + 1);
|
||||
shell.view(&s, SHORT);
|
||||
assert_eq!(built(&shell), before + 2);
|
||||
// So does another language, and coming back from it.
|
||||
lang::scoped(lang::Language::load(&[], "de_de"), || shell.view(&s, SHORT));
|
||||
assert_eq!(built(&shell), before + 3);
|
||||
shell.view(&s, SHORT);
|
||||
assert_eq!(built(&shell), before + 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_the_list_hides_part_of_scrolls_into_view_when_focused_and_then_takes_a_press() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let cols = Columns::of(SHORT.0, SHORT.1);
|
||||
// A filler far down, scrolled to just over the bottom: its middle is under the list's edge.
|
||||
let cut = FIRST_FILLER + 20;
|
||||
let view = shell.view(&s, SHORT);
|
||||
let (_, y) = shell.placed(&view).find(|(r, _)| r.id == cut).unwrap();
|
||||
shell.scroll.amount = y - cols.bottom + style::ROW / 2.0 - 1.0;
|
||||
let rect = cols.row(y - shell.scroll.amount);
|
||||
assert!(
|
||||
rect[1] + rect[3] / 2.0 >= cols.bottom,
|
||||
"the row's middle is hidden"
|
||||
);
|
||||
let moved = Hosted::reveal(&mut shell, &s, SHORT, rect).unwrap();
|
||||
let shown = [rect[0], rect[1] + moved, rect[2], rect[3]];
|
||||
assert!(moved < 0.0 && shown[1] + shown[3] <= cols.bottom);
|
||||
// All in view, it stays put.
|
||||
assert_eq!(Hosted::reveal(&mut shell, &s, SHORT, shown), None);
|
||||
let middle = (shown[0] + 4.0, shown[1] + shown[3] / 2.0);
|
||||
assert_eq!(
|
||||
shell.click(&mut s, middle, SHORT, false, false, false),
|
||||
vec![CLICK, CHANGED]
|
||||
);
|
||||
assert_eq!(shell.screen.pressed, [(cut, false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_appears_after_a_change_and_puts_back_what_the_screen_opened_with() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let undo = Columns::of(SIZE.0, SIZE.1).button(1);
|
||||
let on_undo = (undo[0] + 2.0, undo[1] + 2.0);
|
||||
// Nothing changed: there is no Undo to press.
|
||||
assert!(
|
||||
shell
|
||||
.click(&mut s, on_undo, SIZE, false, false, false)
|
||||
.is_empty()
|
||||
);
|
||||
let at = middle_of(&mut shell, FIRST_FILLER, SIZE);
|
||||
shell.click(&mut s, at, SIZE, false, false, false);
|
||||
assert_eq!(s.fov, 71.0);
|
||||
// Another option changed meanwhile isn't the screen's to put back.
|
||||
s.chat_opacity = 0.25;
|
||||
assert_eq!(
|
||||
shell.click(&mut s, on_undo, SIZE, false, false, false),
|
||||
vec![CLICK, CHANGED]
|
||||
);
|
||||
assert_eq!((s.fov, s.chat_opacity), (70.0, 0.25));
|
||||
assert!(
|
||||
shell
|
||||
.click(&mut s, on_undo, SIZE, false, false, false)
|
||||
.is_empty()
|
||||
);
|
||||
// From the keyboard: Tab reaches it, Enter undoes and leaves the focus on Done.
|
||||
shell.click(&mut s, at, SIZE, false, false, false);
|
||||
let reached = (0..100).any(|_| {
|
||||
shell.key(&mut s, K::Tab, None, false, SIZE);
|
||||
shell.focus == Some(Target::Undo)
|
||||
});
|
||||
assert!(reached);
|
||||
assert_eq!(
|
||||
shell.key(&mut s, K::Enter, None, false, SIZE),
|
||||
vec![CLICK, CHANGED]
|
||||
);
|
||||
assert_eq!(s.fov, 70.0);
|
||||
assert_eq!(shell.focus, Some(Target::Done));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_strip_says_why_a_greyed_row_cant_be_used() {
|
||||
let atlas = atlas();
|
||||
let s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let mut batch = HudBatch::new([1708, 960]);
|
||||
let size = (batch.width, batch.height);
|
||||
let strip = |batch: &mut HudBatch, shell: &mut Shell<Probe>, at| {
|
||||
shell.draw(&s, batch, &atlas, at, false, None);
|
||||
crate::widgets::end_frame(crate::widgets::generation() + 1);
|
||||
let dock = Columns::of(size.0, size.1).dock;
|
||||
let labelled = crate::widgets::last_labelled();
|
||||
let found = labelled.iter().find(|w| [w.x, w.y, w.w, w.h] == dock);
|
||||
found.map(|w| w.label.clone())
|
||||
};
|
||||
let grey = middle_of(&mut shell, GREY, size);
|
||||
assert_eq!(strip(&mut batch, &mut shell, grey).as_deref(), Some(WHY));
|
||||
let choice = middle_of(&mut shell, CHOICE, size);
|
||||
assert_eq!(
|
||||
strip(&mut batch, &mut shell, choice).as_deref(),
|
||||
Some(CHOICES[0].1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn growing_the_window_keeps_the_scroll_within_the_list() {
|
||||
let s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
shell.scroll(&s, SIZE, -1000.0);
|
||||
assert!(shell.scroll.amount > 0.0);
|
||||
let tall = (SIZE.0, 4000.0);
|
||||
let view = shell.view(&s, tall);
|
||||
assert_eq!(shell.scroll.amount, 0.0);
|
||||
let first = shell.placed(&view).next().map(|(_, y)| y).unwrap();
|
||||
assert!(first >= view.cols.top);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_scroll_path_moves_the_list_for_wheel_and_stick_alike() {
|
||||
let mut s = Settings::default();
|
||||
|
||||
@@ -20,6 +20,7 @@ use std::{
|
||||
pub const MASTER: usize = 0;
|
||||
pub const MUSIC: usize = 1;
|
||||
pub const RECORDS: usize = 2;
|
||||
pub const WEATHER: usize = 3;
|
||||
pub const NEUTRAL: usize = 6;
|
||||
pub const PLAYERS: usize = 7;
|
||||
pub const AMBIENT: usize = 8;
|
||||
@@ -77,7 +78,7 @@ impl SoundEntity {
|
||||
}
|
||||
|
||||
/// The tickable instance kinds Horizon plays (`TickableSoundInstance`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Ticking {
|
||||
/// Not tickable.
|
||||
No,
|
||||
@@ -112,6 +113,24 @@ pub enum Ticking {
|
||||
/// A native mod's music layer (`MusicLayers`), whose volume its owner
|
||||
/// sets each tick.
|
||||
MusicLayer,
|
||||
/// `DirectionalSoundInstance`: ten blocks from the camera toward
|
||||
/// `Vec3.directionFromRotation(x_angle, y_angle)`.
|
||||
Directional { x_angle: f32, y_angle: f32 },
|
||||
}
|
||||
/// `DirectionalSoundInstance.setPosition`: the camera plus
|
||||
/// `Vec3.directionFromRotation(xAngle, yAngle).scale(10.0)`.
|
||||
fn directional_position(camera: [f64; 3], x_angle: f32, y_angle: f32) -> [f64; 3] {
|
||||
use horizon_core::geometry::trig;
|
||||
let to_rad = std::f32::consts::PI / 180.0;
|
||||
let y = -y_angle * to_rad - std::f32::consts::PI;
|
||||
let (y_cos, y_sin) = (trig::cos(f64::from(y)), trig::sin(f64::from(y)));
|
||||
let x = -x_angle * to_rad;
|
||||
let (x_cos, x_sin) = (-trig::cos(f64::from(x)), trig::sin(f64::from(x)));
|
||||
[
|
||||
camera[0] + f64::from(y_sin * x_cos) * 10.0,
|
||||
camera[1] + f64::from(x_sin) * 10.0,
|
||||
camera[2] + f64::from(y_cos * x_cos) * 10.0,
|
||||
]
|
||||
}
|
||||
/// What a minecart's sound instances read about it.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -170,6 +189,8 @@ pub struct TickContext {
|
||||
pub player_fall_flying: bool,
|
||||
/// The mob the player rides, when it is not a minecart.
|
||||
pub ridden: Option<RiddenSound>,
|
||||
/// `mainCamera().position()`.
|
||||
pub camera_position: [f64; 3],
|
||||
}
|
||||
/// `AbstractSoundInstance` and its subclasses' fields.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -588,6 +609,31 @@ impl SoundInstance {
|
||||
s.ticking = Ticking::Elytra { time: 0 };
|
||||
s
|
||||
}
|
||||
/// `new DirectionalSoundInstance(event, source, random, camera, xAngle,
|
||||
/// yAngle)`: volume and pitch 1, heard everywhere alike.
|
||||
pub fn directional(
|
||||
id: &str,
|
||||
source: usize,
|
||||
random: LegacyRandom,
|
||||
camera: [f64; 3],
|
||||
x_angle: f32,
|
||||
y_angle: f32,
|
||||
) -> Self {
|
||||
let mut s = Self::new(
|
||||
id,
|
||||
source,
|
||||
1.0,
|
||||
1.0,
|
||||
random,
|
||||
false,
|
||||
0,
|
||||
Attenuation::None,
|
||||
directional_position(camera, x_angle, y_angle),
|
||||
false,
|
||||
);
|
||||
s.ticking = Ticking::Directional { x_angle, y_angle };
|
||||
s
|
||||
}
|
||||
pub fn tickable(&self) -> bool {
|
||||
self.ticking != Ticking::No
|
||||
}
|
||||
@@ -762,6 +808,12 @@ impl SoundInstance {
|
||||
self.volume = 1.0;
|
||||
self.pitch = 1.0;
|
||||
}
|
||||
Ticking::Directional { x_angle, y_angle } => {
|
||||
// DirectionalSoundInstance.tick: setPosition.
|
||||
let (x_angle, y_angle) = (*x_angle, *y_angle);
|
||||
self.position = directional_position(ctx.camera_position, x_angle, y_angle);
|
||||
self.attenuation = Attenuation::None;
|
||||
}
|
||||
Ticking::GuardianAttack { guardian } => {
|
||||
let id = *guardian;
|
||||
// The client's guardian never has a target of its own, so
|
||||
@@ -861,6 +913,9 @@ pub trait Backend {
|
||||
fn unpause(&mut self, channel: ChannelId);
|
||||
/// `Channel.stopped`.
|
||||
fn stopped(&mut self, channel: ChannelId) -> bool;
|
||||
/// Whether the channel plays clearly through a mod's muffle (`Audio::muffle`): its sound
|
||||
/// event is one the mod exempted. A backend without a muffle ignores it.
|
||||
fn set_unmuffled(&mut self, _channel: ChannelId, _unmuffled: bool) {}
|
||||
}
|
||||
/// A `SoundEventListener.onPlaySound` call.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -937,6 +992,9 @@ pub struct SoundEngine {
|
||||
/// listener: 0 as vanilla, `HEARD_RESERVE` with the `uiSoundReserve` extra.
|
||||
pub listener_reserve: usize,
|
||||
pub notifications: Vec<Notification>,
|
||||
/// The sound events a mod's muffle leaves clear (`Audio::muffle`), normalized: their channels
|
||||
/// are marked as they start, and those playing when the list changes are marked again.
|
||||
unmuffled: Vec<String>,
|
||||
}
|
||||
fn handle_channel(h: &Handle) -> Option<ChannelId> {
|
||||
h.channel
|
||||
@@ -966,6 +1024,21 @@ impl SoundEngine {
|
||||
listening: false,
|
||||
listener_reserve: 0,
|
||||
notifications: Vec::new(),
|
||||
unmuffled: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// The sound events that play clearly through a mod's muffle from now on, normalized
|
||||
/// (`minecraft:ui.button.click`); the channels playing now follow the new list too.
|
||||
pub fn set_unmuffled(&mut self, events: Vec<String>, backend: &mut impl Backend) {
|
||||
self.unmuffled = events;
|
||||
for (id, handle) in &self.instance_to_channel {
|
||||
let (Some(instance), Some(channel)) = (
|
||||
self.instances.get(id),
|
||||
self.handles.get(handle).and_then(handle_channel),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
backend.set_unmuffled(channel, self.unmuffled.contains(&instance.identifier));
|
||||
}
|
||||
}
|
||||
/// Isolate a fixture's dispatch from process-wide native installation.
|
||||
@@ -1315,6 +1388,7 @@ impl SoundEngine {
|
||||
}
|
||||
let position = instance.position;
|
||||
let looping = instance.looping && instance.delay <= 0;
|
||||
let unmuffled = self.unmuffled.contains(&instance.identifier);
|
||||
let streaming = sound.stream;
|
||||
let pool = usize::from(streaming);
|
||||
// With the `uiSoundReserve` extra, sounds out in the world leave a few static channels free, so a flood of
|
||||
@@ -1367,6 +1441,7 @@ impl SoundEngine {
|
||||
backend.set_looping(channel, looping && !streaming);
|
||||
backend.set_position(channel, position);
|
||||
backend.set_relative(channel, relative);
|
||||
backend.set_unmuffled(channel, unmuffled);
|
||||
backend.start(channel, &sound, looping);
|
||||
if tickable {
|
||||
self.ticking.push(id);
|
||||
|
||||
@@ -274,6 +274,7 @@ struct MeshSettings {
|
||||
blend_radius: i32,
|
||||
smooth_lighting: bool,
|
||||
cutout_leaves: bool,
|
||||
opaque_ice: bool,
|
||||
translucent_layers: Arc<[bool]>,
|
||||
leaf_culling: LeafCullingMode,
|
||||
}
|
||||
@@ -380,6 +381,33 @@ impl MeshPool {
|
||||
}
|
||||
}
|
||||
|
||||
/// The chunks `wanted` in the order they decorate: by rank, lowest first. No
|
||||
/// two share a rank, so only the chunks without one yet need their distance:
|
||||
/// those no ticket reached (forced and extra areas) are ranked after every
|
||||
/// other, nearest first. The pass that pops them stops once the workers are
|
||||
/// busy, usually long before the last, so a heap orders only what it takes. A
|
||||
/// server with players spread over the map holds tens of thousands of proto
|
||||
/// chunks; measuring and sorting them all every tick took a fifth of its tick.
|
||||
fn decoration_queue(
|
||||
order: &mut DecorationOrder,
|
||||
wanted: impl Iterator<Item = (i32, i32)>,
|
||||
distance: impl Fn(i32, i32) -> i64,
|
||||
) -> std::collections::BinaryHeap<std::cmp::Reverse<(u64, i32, i32)>> {
|
||||
let mut ranked = Vec::new();
|
||||
let mut fresh = Vec::new();
|
||||
for (x, z) in wanted {
|
||||
match order.rank(x, z) {
|
||||
Some(rank) => ranked.push(std::cmp::Reverse((rank, x, z))),
|
||||
None => fresh.push((distance(x, z), x, z)),
|
||||
}
|
||||
}
|
||||
fresh.sort_unstable();
|
||||
for (_, x, z) in fresh {
|
||||
ranked.push(std::cmp::Reverse((order.rank_or_append(x, z), x, z)));
|
||||
}
|
||||
std::collections::BinaryHeap::from(ranked)
|
||||
}
|
||||
|
||||
/// Finished jobs, from the workers to the game thread, in the order they
|
||||
/// finished. Not an `mpsc` channel: its receiver waits for a sender it finds
|
||||
/// half way through a send, and a worker descheduled there on a busy machine
|
||||
@@ -533,6 +561,8 @@ pub struct Streaming {
|
||||
/// The `ao` and `cutoutLeaves` options.
|
||||
pub smooth_lighting: bool,
|
||||
pub cutout_leaves: bool,
|
||||
/// Draw regular ice opaque in the solid pass (`Mesher::opaque_ice`).
|
||||
pub opaque_ice: bool,
|
||||
/// Sprite layers a shader pack draws in the translucent pass (`[[materials]]
|
||||
/// translucent`), by layer; empty without one.
|
||||
pub translucent_layers: Arc<[bool]>,
|
||||
@@ -563,6 +593,9 @@ pub struct Streaming {
|
||||
pub extra: Vec<((i32, i32), i32)>,
|
||||
/// `extra`'s squares as a grid, made again when `extra` changes.
|
||||
cover: Option<ExtraCover>,
|
||||
/// Set while `update` runs, after it refreshed `cover`: nothing else can
|
||||
/// change `extra` meanwhile, so `cover` answers without comparing them.
|
||||
cover_current: bool,
|
||||
/// Mesh the sections for drawing (false on a server thread).
|
||||
pub mesh: bool,
|
||||
/// The pregeneration band (`set_band`).
|
||||
@@ -997,6 +1030,7 @@ impl<G: ChunkGenerator> Jobs<G> {
|
||||
blend_radius: settings.blend_radius,
|
||||
smooth_lighting: settings.smooth_lighting,
|
||||
cutout_leaves: settings.cutout_leaves,
|
||||
opaque_ice: settings.opaque_ice,
|
||||
translucent_layers: &settings.translucent_layers,
|
||||
leaf_culling: settings.leaf_culling,
|
||||
assets: &context.assets,
|
||||
@@ -1111,6 +1145,7 @@ impl Streaming {
|
||||
biome_blend: 2,
|
||||
smooth_lighting: true,
|
||||
cutout_leaves: true,
|
||||
opaque_ice: false,
|
||||
translucent_layers: Arc::from([]),
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
light_tables,
|
||||
@@ -1123,6 +1158,7 @@ impl Streaming {
|
||||
relight_resent: false,
|
||||
extra: Vec::new(),
|
||||
cover: None,
|
||||
cover_current: false,
|
||||
mesh: true,
|
||||
band: None,
|
||||
band_ranked: None,
|
||||
@@ -1263,6 +1299,7 @@ impl Streaming {
|
||||
self.meshed.remove(&(x, z));
|
||||
self.lighting.remove(&(x, z));
|
||||
world.dirty_sections.take_column(x, z);
|
||||
world.entities_pending.remove(&(x, z));
|
||||
chunks.extend(world.chunks.remove(&(x, z)));
|
||||
}
|
||||
chunks
|
||||
@@ -1403,6 +1440,7 @@ impl Streaming {
|
||||
.block_names
|
||||
.extend(std::mem::take(&mut chunk.block_names));
|
||||
let entities = std::mem::take(&mut chunk.entities);
|
||||
world.entities_pending.insert((chunk.x, chunk.z));
|
||||
if !entities.is_empty() {
|
||||
world.loaded_entities.push(((chunk.x, chunk.z), entities));
|
||||
}
|
||||
@@ -1626,6 +1664,7 @@ impl Streaming {
|
||||
return (uploads, Vec::new());
|
||||
}
|
||||
ExtraCover::refresh(&mut self.cover, &self.extra);
|
||||
self.cover_current = true;
|
||||
// The sweeps below walk every loaded column and proto chunk. Run them
|
||||
// at once when the view moves and every eighth tick otherwise: a
|
||||
// column can stay a little past the edge without anyone noticing.
|
||||
@@ -1734,6 +1773,7 @@ impl Streaming {
|
||||
if let Some(chunk) = world.chunks.remove(key) {
|
||||
removed.push(chunk);
|
||||
}
|
||||
world.entities_pending.remove(key);
|
||||
self.lit.remove(key);
|
||||
self.meshed.remove(key);
|
||||
self.lighting.remove(key);
|
||||
@@ -1834,7 +1874,7 @@ impl Streaming {
|
||||
extra: &self.extra,
|
||||
forced: &self.forced,
|
||||
mod_forced: &self.mod_forced,
|
||||
cover: ExtraCover::matching(&self.cover, &self.extra),
|
||||
cover: ExtraCover::current(&self.cover, &self.extra, self.cover_current),
|
||||
band: self.band.as_ref(),
|
||||
};
|
||||
let (protos, generating) = (&self.protos, &self.generating);
|
||||
@@ -1866,29 +1906,24 @@ impl Streaming {
|
||||
})
|
||||
})
|
||||
};
|
||||
let keys: Vec<(i32, i32)> = self
|
||||
.protos
|
||||
.iter()
|
||||
.filter(|(k, p)| p.is_some() && wants(self, k.0, k.1))
|
||||
.map(|(k, _)| *k)
|
||||
.collect();
|
||||
let mut wanted: Vec<(u64, i64, i32, i32)> = keys
|
||||
.into_iter()
|
||||
.map(|(x, z)| (0, self.distance(x, z), x, z))
|
||||
.collect();
|
||||
// Chunks no ticket reached (forced and extra areas) go last, nearest first.
|
||||
wanted.sort_unstable_by_key(|w| w.1);
|
||||
for w in &mut wanted {
|
||||
w.0 = self.decoration_order.rank_or_append(w.2, w.3);
|
||||
}
|
||||
wanted.sort_unstable();
|
||||
// Out for the call: ranking reads the streamer's ranges.
|
||||
let mut order = std::mem::take(&mut self.decoration_order);
|
||||
let mut wanted = decoration_queue(
|
||||
&mut order,
|
||||
self.protos
|
||||
.iter()
|
||||
.filter(|&(&(x, z), proto)| proto.is_some() && wants(self, x, z))
|
||||
.map(|(&k, _)| k),
|
||||
|x, z| self.distance(x, z),
|
||||
);
|
||||
self.decoration_order = order;
|
||||
// Most wanted chunks are blocked, and the same neighbours are
|
||||
// asked about for each of them: what `waits_for` says is kept for
|
||||
// the pass (nothing it reads changes in it but `decorated`, which
|
||||
// updates the entry). Without it, scheduling at render distance 32
|
||||
// took most of the thread that runs it while the world loaded.
|
||||
let waiting: std::cell::RefCell<FxHashMap<(i32, i32), bool>> = Default::default();
|
||||
for (_, _, x, z) in wanted {
|
||||
while let Some(std::cmp::Reverse((_, x, z))) = wanted.pop() {
|
||||
if self.decorating >= workers * 2 {
|
||||
break;
|
||||
}
|
||||
@@ -1993,6 +2028,7 @@ impl Streaming {
|
||||
// A server draws nothing: its sections are never meshed.
|
||||
if !self.mesh {
|
||||
world.dirty_sections.clear();
|
||||
self.cover_current = false;
|
||||
return (uploads, removed);
|
||||
}
|
||||
// Meshing once the column and its neighbours are lit, and the column
|
||||
@@ -2013,7 +2049,7 @@ impl Streaming {
|
||||
extra: &self.extra,
|
||||
forced: &self.forced,
|
||||
mod_forced: &self.mod_forced,
|
||||
cover: ExtraCover::matching(&self.cover, &self.extra),
|
||||
cover: ExtraCover::current(&self.cover, &self.extra, self.cover_current),
|
||||
band: self.band.as_ref(),
|
||||
};
|
||||
self.waiting.retain(|&(x, sy, z)| {
|
||||
@@ -2093,6 +2129,7 @@ impl Streaming {
|
||||
blend_radius: self.biome_blend,
|
||||
smooth_lighting: self.smooth_lighting,
|
||||
cutout_leaves: self.cutout_leaves,
|
||||
opaque_ice: self.opaque_ice,
|
||||
translucent_layers: self.translucent_layers.clone(),
|
||||
leaf_culling: self.leaf_culling,
|
||||
},
|
||||
@@ -2100,6 +2137,7 @@ impl Streaming {
|
||||
);
|
||||
}
|
||||
}
|
||||
self.cover_current = false;
|
||||
(uploads, removed)
|
||||
}
|
||||
fn schedule_generation(&mut self, world: &World, workers: usize) {
|
||||
@@ -2110,7 +2148,7 @@ impl Streaming {
|
||||
extra: &self.extra,
|
||||
forced: &self.forced,
|
||||
mod_forced: &self.mod_forced,
|
||||
cover: ExtraCover::matching(&self.cover, &self.extra),
|
||||
cover: ExtraCover::current(&self.cover, &self.extra, self.cover_current),
|
||||
band: self.band.as_ref(),
|
||||
},
|
||||
world,
|
||||
@@ -2244,7 +2282,7 @@ impl Streaming {
|
||||
extra: &self.extra,
|
||||
forced: &self.forced,
|
||||
mod_forced: &self.mod_forced,
|
||||
cover: ExtraCover::matching(&self.cover, &self.extra),
|
||||
cover: ExtraCover::current(&self.cover, &self.extra, self.cover_current),
|
||||
band: self.band.as_ref(),
|
||||
}
|
||||
}
|
||||
@@ -2615,6 +2653,20 @@ impl ExtraCover {
|
||||
) -> Option<&'a ExtraCover> {
|
||||
cover.as_ref().filter(|c| c.extra == extra)
|
||||
}
|
||||
/// [`Self::matching`], told whether `cover` is known to match: the
|
||||
/// comparison reads every area, and `update` asks for each column it
|
||||
/// looks at (a server holds an area per player).
|
||||
fn current<'a>(
|
||||
cover: &'a Option<ExtraCover>,
|
||||
extra: &[((i32, i32), i32)],
|
||||
known: bool,
|
||||
) -> Option<&'a ExtraCover> {
|
||||
if known {
|
||||
debug_assert!(cover.as_ref().is_none_or(|c| c.extra == extra));
|
||||
return cover.as_ref();
|
||||
}
|
||||
Self::matching(cover, extra)
|
||||
}
|
||||
/// Rebuild the coverage index if `extra` changed; none for one area or none
|
||||
/// (a scan is as quick).
|
||||
fn refresh(cover: &mut Option<ExtraCover>, extra: &[((i32, i32), i32)]) {
|
||||
@@ -3173,6 +3225,64 @@ static NO_CHUNKS: std::sync::LazyLock<HashSet<(i32, i32)>> = std::sync::LazyLock
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The queue hands out the wanted chunks in the order the full sort did
|
||||
/// (every chunk measured, sorted by distance, the new ones ranked in that
|
||||
/// order, then all sorted by rank), and ranks the new ones the same way,
|
||||
/// whatever order the chunks are listed in.
|
||||
#[test]
|
||||
fn decoration_queue_pops_in_the_order_a_full_sort_gives() {
|
||||
// Distinct for every chunk, so the full sort has one answer.
|
||||
let distance = |x: i32, z: i32| i64::from(x) * 100_000 + i64::from(z);
|
||||
let full_sort = |order: &mut DecorationOrder, wanted: &[(i32, i32)]| {
|
||||
let mut w: Vec<(u64, i64, i32, i32)> = wanted
|
||||
.iter()
|
||||
.map(|&(x, z)| (0, distance(x, z), x, z))
|
||||
.collect();
|
||||
w.sort_unstable_by_key(|w| w.1);
|
||||
for w in &mut w {
|
||||
w.0 = order.rank_or_append(w.2, w.3);
|
||||
}
|
||||
w.sort_unstable();
|
||||
w.into_iter().map(|w| (w.2, w.3)).collect::<Vec<_>>()
|
||||
};
|
||||
let ranked = |order: &mut DecorationOrder| {
|
||||
order.reassign((0, 0), 6, |x, z| (x + z).rem_euclid(5) == 0);
|
||||
};
|
||||
let (mut fast, mut full) = (DecorationOrder::default(), DecorationOrder::default());
|
||||
ranked(&mut fast);
|
||||
ranked(&mut full);
|
||||
let mut seed = 0x9E37_79B9_7F4A_7C15u64;
|
||||
let mut shuffled = |mut keys: Vec<(i32, i32)>| {
|
||||
for i in (1..keys.len()).rev() {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
keys.swap(i, (seed % (i as u64 + 1)) as usize);
|
||||
}
|
||||
keys
|
||||
};
|
||||
// Some of the player's ranked chunks, and an extra area's that no
|
||||
// ticket reached; the second time, more of the area, the first part
|
||||
// ranked by then.
|
||||
let mut wanted: Vec<(i32, i32)> = (-6i32..=6)
|
||||
.flat_map(|x| (-6..=6).map(move |z| (x, z)))
|
||||
.filter(|&(x, z)| (x * 7 + z).rem_euclid(3) != 0)
|
||||
.collect();
|
||||
wanted.extend((20..=24).flat_map(|x| (-3..=3).map(move |z| (x, z))));
|
||||
for round in 0..2 {
|
||||
if round == 1 {
|
||||
wanted.extend((25..=27).flat_map(|x| (-3..=3).map(move |z| (x, z))));
|
||||
}
|
||||
let wanted = shuffled(wanted.clone());
|
||||
let mut queue = decoration_queue(&mut fast, wanted.iter().copied(), distance);
|
||||
let popped: Vec<(i32, i32)> = std::iter::from_fn(|| queue.pop())
|
||||
.map(|std::cmp::Reverse((_, x, z))| (x, z))
|
||||
.collect();
|
||||
assert_eq!(popped, full_sort(&mut full, &wanted), "round {round}");
|
||||
assert_eq!(fast.ordered(), full.ordered(), "round {round}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resent_column_keeps_its_sections_but_takes_new_biomes() {
|
||||
let mut old = Chunk::empty(2, -3);
|
||||
|
||||
@@ -40,7 +40,10 @@ struct Globals {
|
||||
hdr_windows: array<vec4<f32>, 2>,
|
||||
// HDR lighting: x how strongly the sky lights the world now (`Sky::sky_light_factor`),
|
||||
// y what the sky and fog get: the same, but none under water; z 1 with HDR output on.
|
||||
// w: this entire frame writes light, so alpha is coverage rather than a mark.
|
||||
hdr_light: vec4<f32>,
|
||||
hdr_settings: vec4<f32>,
|
||||
hdr_shares: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||
@group(0) @binding(1) var sprites: texture_2d_array<f32>;
|
||||
@@ -199,26 +202,73 @@ fn sprite_at(tiled: vec2<f32>, layer: u32, du: vec2<f32>, dv: vec2<f32>) -> vec4
|
||||
return sample_nearest(uv, layer, pixel, du, dv, texel_screen, tiled_face);
|
||||
}
|
||||
|
||||
// The terrain stages' values, as the code reads them. What crosses from the vertex stage to the
|
||||
// fragment stage is `TerrainWire`: the same values in fewer interpolant locations.
|
||||
struct TerrainOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@location(1) @interpolate(flat) layer: u32,
|
||||
@location(2) color: vec4<f32>,
|
||||
@location(3) spherical: f32,
|
||||
@location(4) cylindrical: f32,
|
||||
clip: vec4<f32>,
|
||||
uv: vec2<f32>,
|
||||
layer: u32,
|
||||
color: vec4<f32>,
|
||||
spherical: f32,
|
||||
cylindrical: f32,
|
||||
// How the face shines in HDR output (mesher.rs's `EMISSION_BITS`): 0 not
|
||||
// at all, 1 to 4 the tier with its bright parts, 5 to 8 the tier all over.
|
||||
@location(5) @interpolate(flat) emission: u32,
|
||||
emission: u32,
|
||||
// HDR lighting: block and sky light, as levels 0 to 15.
|
||||
@location(11) scene: vec2<f32>,
|
||||
@location(6) @interpolate(flat) fade: u32,
|
||||
scene: vec2<f32>,
|
||||
fade: u32,
|
||||
// A distant tile's tile-relative position, for its fade (`faded`).
|
||||
@location(7) local: vec3<f32>,
|
||||
local: vec3<f32>,
|
||||
// terrain.vsh's chunkVisibility: the section's fade-in, from 16 blocks out.
|
||||
@location(8) visibility: f32,
|
||||
visibility: f32,
|
||||
// The camera-relative height, for the water mirror's cut at the plane.
|
||||
@location(9) height: f32,
|
||||
@location(10) @interpolate(flat) emissive_layer: u32,
|
||||
height: f32,
|
||||
emissive_layer: u32,
|
||||
}
|
||||
// `TerrainOut` in 5 interpolant locations instead of 12. A GPU's vertex stage pays for each
|
||||
// location it exports (AMD's parameter cache gives one a whole slot, however few of its four
|
||||
// components are used): on an RX 580 the 12 cost 3.2 ns per quad and these 5 cost 1.6. Every
|
||||
// component is still interpolated on its own (and the integers stay flat), so each pixel gets
|
||||
// the very values the separate locations gave it.
|
||||
struct TerrainWire {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
// The colour.
|
||||
@location(0) a: vec4<f32>,
|
||||
// `local` and `visibility`.
|
||||
@location(1) b: vec4<f32>,
|
||||
// `uv` and `scene`.
|
||||
@location(2) c: vec4<f32>,
|
||||
// `spherical`, `cylindrical` and `height`.
|
||||
@location(3) d: vec4<f32>,
|
||||
// `layer`, `emission`, `fade` and `emissive_layer`.
|
||||
@location(4) @interpolate(flat) e: vec4<u32>,
|
||||
}
|
||||
fn to_wire(o: TerrainOut) -> TerrainWire {
|
||||
var w: TerrainWire;
|
||||
w.clip = o.clip;
|
||||
w.a = o.color;
|
||||
w.b = vec4(o.local, o.visibility);
|
||||
w.c = vec4(o.uv, o.scene);
|
||||
w.d = vec4(o.spherical, o.cylindrical, o.height, 0.0);
|
||||
w.e = vec4(o.layer, o.emission, o.fade, o.emissive_layer);
|
||||
return w;
|
||||
}
|
||||
fn from_wire(w: TerrainWire) -> TerrainOut {
|
||||
var o: TerrainOut;
|
||||
o.clip = w.clip;
|
||||
o.color = w.a;
|
||||
o.local = w.b.xyz;
|
||||
o.visibility = w.b.w;
|
||||
o.uv = w.c.xy;
|
||||
o.scene = w.c.zw;
|
||||
o.spherical = w.d.x;
|
||||
o.cylindrical = w.d.y;
|
||||
o.height = w.d.z;
|
||||
o.layer = w.e.x;
|
||||
o.emission = w.e.y;
|
||||
o.fade = w.e.z;
|
||||
o.emissive_layer = w.e.w;
|
||||
return o;
|
||||
}
|
||||
// Whether a fading tile or section leaves this pixel out (gfx/lod.rs's
|
||||
// `fade_code`, `fade_out_code`). It fades in, or out, chunk by chunk: each
|
||||
@@ -333,9 +383,9 @@ fn fade_code(w: f32) -> u32 {
|
||||
@vertex fn vs_terrain(
|
||||
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
|
||||
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>,
|
||||
@location(5) origin: vec4<f32>) -> TerrainOut {
|
||||
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
|
||||
origin.w);
|
||||
@location(5) origin: vec4<f32>) -> TerrainWire {
|
||||
return to_wire(terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
|
||||
origin.w));
|
||||
}
|
||||
// Flat section draws (gfx/flat.rs): one draw per arena page over the frame's
|
||||
// index list. A section's vertices fill whole 256-vertex blocks of its page:
|
||||
@@ -346,10 +396,10 @@ fn fade_code(w: f32) -> u32 {
|
||||
@builtin(vertex_index) vertex: u32,
|
||||
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
|
||||
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>
|
||||
) -> TerrainOut {
|
||||
) -> TerrainWire {
|
||||
let origin = slot_origins[block_slots[vertex >> 8u]];
|
||||
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
|
||||
origin.w);
|
||||
return to_wire(terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
|
||||
origin.w));
|
||||
}
|
||||
// Solid terrain draws in two passes, each over its own part of every mesh's quads
|
||||
// (`mesher::Part`): first the quads with no cutout that are not fading, through
|
||||
@@ -370,38 +420,38 @@ fn left_out() -> TerrainOut {
|
||||
@vertex fn vs_terrain_opaque(
|
||||
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
|
||||
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>,
|
||||
@location(5) origin: vec4<f32>) -> TerrainOut {
|
||||
if !opaque_quad(layer, origin.w) { return left_out(); }
|
||||
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
|
||||
origin.w);
|
||||
@location(5) origin: vec4<f32>) -> TerrainWire {
|
||||
if !opaque_quad(layer, origin.w) { return to_wire(left_out()); }
|
||||
return to_wire(terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
|
||||
origin.w));
|
||||
}
|
||||
@vertex fn vs_terrain_cutout(
|
||||
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
|
||||
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>,
|
||||
@location(5) origin: vec4<f32>) -> TerrainOut {
|
||||
if opaque_quad(layer, origin.w) { return left_out(); }
|
||||
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
|
||||
origin.w);
|
||||
@location(5) origin: vec4<f32>) -> TerrainWire {
|
||||
if opaque_quad(layer, origin.w) { return to_wire(left_out()); }
|
||||
return to_wire(terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
|
||||
origin.w));
|
||||
}
|
||||
@vertex fn vs_terrain_flat_opaque(
|
||||
@builtin(vertex_index) vertex: u32,
|
||||
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
|
||||
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>
|
||||
) -> TerrainOut {
|
||||
) -> TerrainWire {
|
||||
let origin = slot_origins[block_slots[vertex >> 8u]];
|
||||
if !opaque_quad(layer, origin.w) { return left_out(); }
|
||||
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
|
||||
origin.w);
|
||||
if !opaque_quad(layer, origin.w) { return to_wire(left_out()); }
|
||||
return to_wire(terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
|
||||
origin.w));
|
||||
}
|
||||
@vertex fn vs_terrain_flat_cutout(
|
||||
@builtin(vertex_index) vertex: u32,
|
||||
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
|
||||
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>
|
||||
) -> TerrainOut {
|
||||
) -> TerrainWire {
|
||||
let origin = slot_origins[block_slots[vertex >> 8u]];
|
||||
if opaque_quad(layer, origin.w) { return left_out(); }
|
||||
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
|
||||
origin.w);
|
||||
if opaque_quad(layer, origin.w) { return to_wire(left_out()); }
|
||||
return to_wire(terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
|
||||
origin.w));
|
||||
}
|
||||
// Solid section draws that read their vertices from the arena page (gfx.rs's `pulled`
|
||||
// pipelines; `mesher/packed.rs`): a draw's vertex indices below PACKED_BASE name vertices
|
||||
@@ -411,6 +461,12 @@ fn left_out() -> TerrainOut {
|
||||
// Binding 19: visbuffer.wgsl, in the same module, takes 2 to 18 of its own group 1.
|
||||
@group(1) @binding(19) var<storage, read> arena_page: array<vec2<u32>>;
|
||||
const PACKED_BASE: u32 = 0x10000000u;
|
||||
// Far tiles' own compact layout (`lod/packed.rs`), read apart from a section's by the base
|
||||
// its draws use: from FAR_BASE on, a corner of the quad whose far record is at that slot.
|
||||
const FAR_BASE: u32 = 0x20000000u;
|
||||
// `TerrainVertex::tiled`: a far tile's UVs are in blocks from the face's corner, this many
|
||||
// fixed-point steps each (`mesher::TILED_UV_STEPS`).
|
||||
const TILED_UV_STEPS: f32 = 32.0;
|
||||
// A vertex's words: position bits, then UV, layer with light, colour.
|
||||
struct ArenaCorner {
|
||||
p: vec3<u32>,
|
||||
@@ -471,6 +527,39 @@ fn packed_corner(lo: vec3<u32>, hi: vec3<u32>, corner: u32) -> ArenaCorner {
|
||||
return ArenaCorner(bitcast<vec3<u32>>(vec3<f32>(pos)),
|
||||
vec3((u << 10u) | (v << 26u), (hi.x & 0xffffu) | (packed_light << 16u), color));
|
||||
}
|
||||
// `lod::packed::Face::vertices`: corner `corner` of the far record whose words are lo (0 to
|
||||
// 2) and hi (3 to 5). A far quad is a rectangle in an axis-aligned plane with one colour,
|
||||
// one light and one layer, so the record holds the rectangle and this rebuilds the four
|
||||
// corners and the UVs `lod::mesh::emit_quad` derived from them. The two have to agree, and
|
||||
// `lod::mesh`'s `every_quad_the_mesher_emits_packs_and_comes_back` is what holds them to it.
|
||||
fn far_corner(lo: vec3<u32>, hi: vec3<u32>, corner: u32) -> ArenaCorner {
|
||||
let axis = hi.z & 3u;
|
||||
let reversed = (hi.z >> 2u) & 1u;
|
||||
let plane = f32(bitcast<i32>(lo.z));
|
||||
let u0 = f32(bitcast<i32>(hi.x));
|
||||
let v0 = f32(bitcast<i32>(hi.y));
|
||||
let u1 = u0 + f32((hi.z >> 3u) & 0x7ffu);
|
||||
let v1 = v0 + f32((hi.z >> 14u) & 0x7ffu);
|
||||
// `points.reverse()`, which is what the mesher does for Down, South and East.
|
||||
let c = select(corner, 3u - corner, reversed == 1u);
|
||||
let far_v = select(v0, v1, c <= 1u);
|
||||
// Axis X runs its u the other way round, as that function's `points` do.
|
||||
let first = c == 0u || c == 3u;
|
||||
let far_u = select(select(u1, u0, first), select(u0, u1, first), axis == 2u);
|
||||
// 0: the plane is y, u is x and v is z. 1: the plane is z, u is x, v is y.
|
||||
// 2: the plane is x, u is z and v is y.
|
||||
var pos = vec3(far_u, plane, far_v);
|
||||
if axis == 1u {
|
||||
pos = vec3(far_u, far_v, plane);
|
||||
} else if axis == 2u {
|
||||
pos = vec3(plane, far_v, far_u);
|
||||
}
|
||||
// The UV is `[u, -v]` of the face counted from its corner, which is `[u0, -v1]`: every
|
||||
// coordinate is whole, so the mesher's `floor` of the least corner is the corner itself.
|
||||
let uv = vec2<u32>(vec2(far_u - u0, v1 - far_v) * TILED_UV_STEPS);
|
||||
// Word 1 is the layer with the light, word 0 the colour, as a vertex holds them.
|
||||
return ArenaCorner(bitcast<vec3<u32>>(pos), vec3(uv.x | (uv.y << 16u), lo.y, lo.x));
|
||||
}
|
||||
// A solid draw's vertex `vertex`, and the slot its block is found by (`block_slots`, 256
|
||||
// slots a block): the vertex's own, or its quad's record's. `packed`: it has a record.
|
||||
struct SectionCorner {
|
||||
@@ -482,6 +571,18 @@ fn section_corner(vertex: u32) -> SectionCorner {
|
||||
if vertex < PACKED_BASE {
|
||||
return SectionCorner(arena_vertex(vertex), vertex, false);
|
||||
}
|
||||
if vertex >= FAR_BASE {
|
||||
let far_slot = (vertex - FAR_BASE) >> 2u;
|
||||
let a = arena_page[far_slot * 3u];
|
||||
let b = arena_page[far_slot * 3u + 1u];
|
||||
let c = arena_page[far_slot * 3u + 2u];
|
||||
// `FULL`: the quad's four vertices, word 0 slots on, as a section's spill.
|
||||
if (c.y & 0x80000000u) != 0u {
|
||||
return SectionCorner(arena_vertex(far_slot + a.x + (vertex & 3u)), far_slot, true);
|
||||
}
|
||||
return SectionCorner(far_corner(vec3(a.x, a.y, b.x), vec3(b.y, c.x, c.y), vertex & 3u),
|
||||
far_slot, true);
|
||||
}
|
||||
let slot = (vertex - PACKED_BASE) >> 2u;
|
||||
let corner = vertex & 3u;
|
||||
let a = arena_page[slot * 3u];
|
||||
@@ -511,37 +612,37 @@ fn corner_vertex(c: ArenaCorner, origin: vec4<f32>) -> TerrainOut {
|
||||
unorm8x4(c.r.z), light, origin.xyz, origin.w);
|
||||
}
|
||||
@vertex fn vs_section(@builtin(vertex_index) vertex: u32,
|
||||
@location(5) origin: vec4<f32>) -> TerrainOut {
|
||||
return corner_vertex(section_corner(vertex).c, origin);
|
||||
@location(5) origin: vec4<f32>) -> TerrainWire {
|
||||
return to_wire(corner_vertex(section_corner(vertex).c, origin));
|
||||
}
|
||||
@vertex fn vs_section_opaque(@builtin(vertex_index) vertex: u32,
|
||||
@location(5) origin: vec4<f32>) -> TerrainOut {
|
||||
@location(5) origin: vec4<f32>) -> TerrainWire {
|
||||
let s = section_corner(vertex);
|
||||
if !opaque_quad(corner_layer(s.c), origin.w) { return left_out(); }
|
||||
return corner_vertex(s.c, origin);
|
||||
if !opaque_quad(corner_layer(s.c), origin.w) { return to_wire(left_out()); }
|
||||
return to_wire(corner_vertex(s.c, origin));
|
||||
}
|
||||
@vertex fn vs_section_cutout(@builtin(vertex_index) vertex: u32,
|
||||
@location(5) origin: vec4<f32>) -> TerrainOut {
|
||||
@location(5) origin: vec4<f32>) -> TerrainWire {
|
||||
let s = section_corner(vertex);
|
||||
if opaque_quad(corner_layer(s.c), origin.w) { return left_out(); }
|
||||
return corner_vertex(s.c, origin);
|
||||
if opaque_quad(corner_layer(s.c), origin.w) { return to_wire(left_out()); }
|
||||
return to_wire(corner_vertex(s.c, origin));
|
||||
}
|
||||
@vertex fn vs_section_flat(@builtin(vertex_index) vertex: u32) -> TerrainOut {
|
||||
@vertex fn vs_section_flat(@builtin(vertex_index) vertex: u32) -> TerrainWire {
|
||||
let s = section_corner(vertex);
|
||||
let origin = slot_origins[block_slots[s.slot >> 8u]];
|
||||
return corner_vertex(s.c, origin);
|
||||
return to_wire(corner_vertex(s.c, origin));
|
||||
}
|
||||
@vertex fn vs_section_flat_opaque(@builtin(vertex_index) vertex: u32) -> TerrainOut {
|
||||
@vertex fn vs_section_flat_opaque(@builtin(vertex_index) vertex: u32) -> TerrainWire {
|
||||
let s = section_corner(vertex);
|
||||
let origin = slot_origins[block_slots[s.slot >> 8u]];
|
||||
if !opaque_quad(corner_layer(s.c), origin.w) { return left_out(); }
|
||||
return corner_vertex(s.c, origin);
|
||||
if !opaque_quad(corner_layer(s.c), origin.w) { return to_wire(left_out()); }
|
||||
return to_wire(corner_vertex(s.c, origin));
|
||||
}
|
||||
@vertex fn vs_section_flat_cutout(@builtin(vertex_index) vertex: u32) -> TerrainOut {
|
||||
@vertex fn vs_section_flat_cutout(@builtin(vertex_index) vertex: u32) -> TerrainWire {
|
||||
let s = section_corner(vertex);
|
||||
let origin = slot_origins[block_slots[s.slot >> 8u]];
|
||||
if opaque_quad(corner_layer(s.c), origin.w) { return left_out(); }
|
||||
return corner_vertex(s.c, origin);
|
||||
if opaque_quad(corner_layer(s.c), origin.w) { return to_wire(left_out()); }
|
||||
return to_wire(corner_vertex(s.c, origin));
|
||||
}
|
||||
// HDR lighting: how far a surface's light carries it above paper white, 0 to 1. Only the
|
||||
// strongest light does, so dark stays as dark as in SDR; sky light by how the face meets the sun.
|
||||
@@ -620,11 +721,13 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
|
||||
let lift = smoothstep(globals.hdr_windows[0][t], globals.hdr_windows[1][t], y);
|
||||
return 0.175 * f32(3u - t) + 0.165 * (1.0 - lift);
|
||||
}
|
||||
@fragment fn fs_solid(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
@fragment fn fs_solid(wire: TerrainWire) -> @location(0) vec4<f32> {
|
||||
let in = from_wire(wire);
|
||||
return solid_color(in);
|
||||
}
|
||||
// `fs_solid` for the opaque pass (`opaque_quad`): no fade, no cutout, no discard.
|
||||
@fragment fn fs_opaque(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
@fragment fn fs_opaque(wire: TerrainWire) -> @location(0) vec4<f32> {
|
||||
let in = from_wire(wire);
|
||||
if HDR_LIGHTING && globals.hdr_light.z != 0.0 {
|
||||
hdr_normal = normalize(cross(dpdy(in.local), dpdx(in.local)));
|
||||
}
|
||||
@@ -635,7 +738,8 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
|
||||
}
|
||||
// The water mirror (gfx.rs's reflection pass): what lies below the water plane
|
||||
// (`fog_ends.w`, camera-relative) would show above it in the mirror, so it is cut away.
|
||||
@fragment fn fs_solid_mirror(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
@fragment fn fs_solid_mirror(wire: TerrainWire) -> @location(0) vec4<f32> {
|
||||
let in = from_wire(wire);
|
||||
if in.height < globals.fog_ends.w { discard; }
|
||||
return solid_color(in);
|
||||
}
|
||||
@@ -656,7 +760,15 @@ fn solid_color(in: TerrainOut) -> vec4<f32> {
|
||||
}
|
||||
// The rest of a solid pixel's colour once it is kept: emission and fog.
|
||||
fn solid_finish(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>) -> vec4<f32> {
|
||||
return solid_finish_at(in, texel, color, dpdx(in.uv), dpdy(in.uv));
|
||||
return solid_light(solid_finish_at(in, texel, color, dpdx(in.uv), dpdy(in.uv)));
|
||||
}
|
||||
// A finished solid pixel as the frame keeps it: its mark in alpha, or on the written lane its
|
||||
// light, alpha 1.
|
||||
fn solid_light(finished: vec4<f32>) -> vec4<f32> {
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(finished.rgb, finished.a, globals.hdr_settings, globals.hdr_shares), 1.0);
|
||||
}
|
||||
return finished;
|
||||
}
|
||||
// `solid_finish` with the UV's screen gradients given (`sprite_at`).
|
||||
fn solid_finish_at(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>, du: vec2<f32>,
|
||||
@@ -703,7 +815,8 @@ fn solid_finish_at(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>, du: vec2<
|
||||
// HDR lighting on what's mostly opaque among translucent faces (water), drawn again after
|
||||
// them with only alpha written: `fs_translucent` blends its alpha as coverage, so a lake's
|
||||
// value would be diluted. Clear glass leaves what's behind it, light sources included, alone.
|
||||
@fragment fn fs_translucent_mark(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
@fragment fn fs_translucent_mark(wire: TerrainWire) -> @location(0) vec4<f32> {
|
||||
let in = from_wire(wire);
|
||||
let dx = dpdx(in.local);
|
||||
let dy = dpdy(in.local);
|
||||
hdr_normal = normalize(cross(dy, dx));
|
||||
@@ -716,11 +829,26 @@ fn solid_finish_at(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>, du: vec2<
|
||||
// Water, glass and other see-through faces draw whole as they fade in, the
|
||||
// layer drawn first in front (gfx.rs's water tiers): dithered, the faded-out
|
||||
// pixels showed the layer behind through the one in front, twice the water.
|
||||
@fragment fn fs_translucent(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
var color = sprite(in.uv, in.layer & 0x7fffu) * in.color;
|
||||
@fragment fn fs_translucent(wire: TerrainWire) -> @location(0) vec4<f32> {
|
||||
let in = from_wire(wire);
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
|
||||
hdr_normal = normalize(cross(dpdy(in.local), dpdx(in.local)));
|
||||
}
|
||||
let texel = sprite(in.uv, in.layer & 0x7fffu);
|
||||
var color = texel * in.color;
|
||||
color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, color.a), color, in.visibility);
|
||||
if color.a < 0.01 { discard; }
|
||||
return apply_fog(color, in.spherical, in.cylindrical);
|
||||
let finished = apply_fog(color, in.spherical, in.cylindrical);
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
|
||||
// The surface's own light, whatever its texel's alpha: what is behind it is light already.
|
||||
// (The marked frame keeps one mark a pixel, so there `fs_translucent_mark` skips clear
|
||||
// texels to leave what is behind them its own.)
|
||||
let fogged = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
|
||||
let mark = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()), globals.hdr_light.y, fogged));
|
||||
return vec4(hdr_written(finished.rgb, mark, globals.hdr_settings, globals.hdr_shares), finished.a);
|
||||
}
|
||||
return finished;
|
||||
}
|
||||
|
||||
// Native still-fluid boxes use block sprites and lightmap without terrain AO/emission
|
||||
@@ -786,7 +914,8 @@ struct NativeModelOut {
|
||||
+ max(0.0, dot(light1, in.normal))) * 0.6 + 0.4);
|
||||
color = vec4(color.rgb * shade, color.a);
|
||||
}
|
||||
return apply_fog(color, in.spherical, in.cylindrical);
|
||||
let finished = apply_fog(color, in.spherical, in.cylindrical);
|
||||
return vec4(finished.rgb, select(finished.a, 1.0, HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0));
|
||||
}
|
||||
|
||||
// Sky (SkyRenderer): the 10-vertex sky disc fan at y = 16 (radius 512) in
|
||||
@@ -919,12 +1048,19 @@ fn sky_along_ray_base(d: vec3<f32>) -> vec3<f32> {
|
||||
}
|
||||
return color;
|
||||
}
|
||||
fn sky_finished(color: vec3<f32>) -> vec4<f32> {
|
||||
let mark = 1.0 - 0.24 * globals.hdr_light.y;
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(color, mark, globals.hdr_settings, globals.hdr_shares), 1.0);
|
||||
}
|
||||
return vec4(color, mark);
|
||||
}
|
||||
@fragment fn fs_sky(in: SkyOut) -> @location(0) vec4<f32> {
|
||||
// `hdr_scene_alpha`, spelled out: this part of the file also goes into shader packs' skies.
|
||||
return vec4(sky_along_ray(view_ray(in.ndc)), 1.0 - 0.24 * globals.hdr_light.y);
|
||||
return sky_finished(sky_along_ray(view_ray(in.ndc)));
|
||||
}
|
||||
@fragment fn fs_sky_base(in: SkyOut) -> @location(0) vec4<f32> {
|
||||
return vec4(sky_along_ray_base(view_ray(in.ndc)), 1.0 - 0.24 * globals.hdr_light.y);
|
||||
return sky_finished(sky_along_ray_base(view_ray(in.ndc)));
|
||||
}
|
||||
// The sunrise/sunset fan as geometry: the clip position is projected by the
|
||||
// CPU with the sky's unjittered view, and the apex weight (1 at the apex, 0 at
|
||||
@@ -937,7 +1073,7 @@ struct SunriseOut {
|
||||
return SunriseOut(clip, weight);
|
||||
}
|
||||
@fragment fn fs_sunrise(in: SunriseOut) -> @location(0) vec4<f32> {
|
||||
return vec4(globals.sunrise.rgb, in.weight * globals.sunrise.a);
|
||||
return vec4(sky_finished(globals.sunrise.rgb).rgb, in.weight * globals.sunrise.a);
|
||||
}
|
||||
// The first-person hand's depth clear inside the world pass: `vs_sky`'s triangle lies at the
|
||||
// far plane, written over the whole depth target with no colour.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
//! On-screen touch controls for touch-screen devices (Android first). Nothing
|
||||
//! here runs unless a touch surface is in use.
|
||||
pub mod layout;
|
||||
pub use layout::{Control, Mode, Placement, Settings};
|
||||
@@ -0,0 +1,429 @@
|
||||
// Adapted from Starling (https://github.com/starling-client/starling, `android` branch at
|
||||
// 150a414b09a1, `crates/horizon-client-render/src/touch/layout.rs`), written by Vasysik and the
|
||||
// Starling contributors. Copyright (c) 2026 Ludovic Marie and the Horizon contributors, under the
|
||||
// same MIT license as Horizon (LICENSE at the repository root).
|
||||
|
||||
//! The touch layout: which on-screen controls exist, where each one sits for
|
||||
//! each control scheme, and the player's saved choices. Coordinates are
|
||||
//! normalized to the surface; sizes are fractions of its height, so a button
|
||||
//! stays square on wide phones and on tablets. Nothing here reads input or
|
||||
//! draws: this is the file the touch controls and their editor share.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
/// How the player moves and aims.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum Mode {
|
||||
/// A joystick moves; attack and use act on the crosshair.
|
||||
#[default]
|
||||
Crosshair,
|
||||
/// A joystick moves; tapping the world attacks or uses what was tapped.
|
||||
Tap,
|
||||
/// A D-pad moves; tapping the world acts on it (or the crosshair, split).
|
||||
Dpad,
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
pub const ALL: [Self; 3] = [Self::Crosshair, Self::Tap, Self::Dpad];
|
||||
/// The four schemes a settings screen offers: mode, `split_controls`, label.
|
||||
pub const SCHEMES: [(Self, bool, &'static str); 4] = [
|
||||
(Self::Crosshair, false, "Joystick & aim crosshair"),
|
||||
(Self::Tap, false, "Joystick & tap to interact"),
|
||||
(Self::Dpad, false, "D-Pad & tap to interact"),
|
||||
(Self::Dpad, true, "D-Pad & aim crosshair"),
|
||||
];
|
||||
|
||||
/// This mode's row in [`Settings::layouts`].
|
||||
pub fn index(self) -> usize {
|
||||
self as usize
|
||||
}
|
||||
}
|
||||
|
||||
/// The built-in controls. The order is the index into a layout row and into
|
||||
/// `button_visibility`, so new controls go at the end.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Control {
|
||||
Move,
|
||||
Sprint,
|
||||
Jump,
|
||||
Sneak,
|
||||
Attack,
|
||||
Use,
|
||||
Chat,
|
||||
Pause,
|
||||
Settings,
|
||||
Inventory,
|
||||
Debug,
|
||||
Perspective,
|
||||
Offhand,
|
||||
Shield,
|
||||
}
|
||||
|
||||
/// How many built-in controls a layout row holds.
|
||||
pub const CONTROLS: usize = Control::ALL.len();
|
||||
|
||||
impl Control {
|
||||
pub const ALL: [Self; 14] = [
|
||||
Self::Move,
|
||||
Self::Sprint,
|
||||
Self::Jump,
|
||||
Self::Sneak,
|
||||
Self::Attack,
|
||||
Self::Use,
|
||||
Self::Chat,
|
||||
Self::Pause,
|
||||
Self::Settings,
|
||||
Self::Inventory,
|
||||
Self::Debug,
|
||||
Self::Perspective,
|
||||
Self::Offhand,
|
||||
Self::Shield,
|
||||
];
|
||||
|
||||
/// The name the layout editor shows.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Move => "Movement",
|
||||
Self::Sprint => "Sprint",
|
||||
Self::Jump => "Jump / fly up",
|
||||
Self::Sneak => "Sneak / fly down",
|
||||
Self::Attack => "Attack / mine",
|
||||
Self::Use => "Use / place",
|
||||
Self::Chat => "Chat",
|
||||
Self::Pause => "Pause",
|
||||
Self::Settings => "Touch settings",
|
||||
Self::Inventory => "Inventory",
|
||||
Self::Debug => "Debug overlay (F3)",
|
||||
Self::Perspective => "Camera perspective (F5)",
|
||||
Self::Offhand => "Swap hands",
|
||||
Self::Shield => "Raise shield",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where one control sits: its centre (0..1 of the surface), its size (a
|
||||
/// fraction of the surface height) and its opacity.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Placement {
|
||||
pub center: [f32; 2],
|
||||
pub size: f32,
|
||||
pub opacity: f32,
|
||||
}
|
||||
|
||||
impl Placement {
|
||||
pub const fn new(x: f32, y: f32, size: f32) -> Self {
|
||||
Self {
|
||||
center: [x, y],
|
||||
size,
|
||||
opacity: 0.65,
|
||||
}
|
||||
}
|
||||
|
||||
/// The square control as `[x, y, w, h]` in normalized surface coordinates,
|
||||
/// for a surface `aspect` (width / height) wide.
|
||||
pub fn rect(self, aspect: f32) -> [f32; 4] {
|
||||
let w = self.size / aspect.max(0.1);
|
||||
[
|
||||
self.center[0] - w / 2.0,
|
||||
self.center[1] - self.size / 2.0,
|
||||
w,
|
||||
self.size,
|
||||
]
|
||||
}
|
||||
|
||||
/// Brings a value read from disk back on screen and into range.
|
||||
pub fn sanitize(&mut self) {
|
||||
self.size = finite_or(self.size, 0.15).clamp(0.045, 0.45);
|
||||
self.opacity = finite_or(self.opacity, 0.65).clamp(0.0, 1.0);
|
||||
for c in &mut self.center {
|
||||
*c = finite_or(*c, 0.5).clamp(0.025, 0.975);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `button_visibility` values: follow the scheme, always shown, always hidden.
|
||||
pub const FOLLOW_SCHEME: u8 = 0;
|
||||
pub const SHOWN: u8 = 1;
|
||||
pub const HIDDEN: u8 = 2;
|
||||
|
||||
/// The player's touch layout, saved as JSON. Missing fields take their
|
||||
/// defaults, so later options can be added without resetting a saved file.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Settings {
|
||||
pub mode: Mode,
|
||||
/// With [`Mode::Dpad`]: attack and use act on the crosshair.
|
||||
pub split_controls: bool,
|
||||
/// Mirrors every control left to right.
|
||||
pub left_handed: bool,
|
||||
/// With [`Mode::Dpad`]: the jump and sneak buttons trade places.
|
||||
pub swap_jump_sneak: bool,
|
||||
pub sprint_button: bool,
|
||||
/// Sprint by pushing the joystick past its rim instead of with a button.
|
||||
pub sprint_with_joystick: bool,
|
||||
pub offhand_button: bool,
|
||||
pub shield_button: bool,
|
||||
/// Per control: [`FOLLOW_SCHEME`], [`SHOWN`] or [`HIDDEN`].
|
||||
pub button_visibility: [u8; CONTROLS],
|
||||
/// One row of placements per [`Mode`], indexed by [`Control`].
|
||||
pub layouts: [[Placement; CONTROLS]; 3],
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
let row = [
|
||||
Placement::new(0.13, 0.76, 0.32), // Move
|
||||
Placement::new(0.72, 0.78, 0.105), // Sprint
|
||||
Placement::new(0.92, 0.78, 0.16), // Jump
|
||||
Placement::new(0.81, 0.86, 0.115), // Sneak
|
||||
Placement::new(0.80, 0.64, 0.16), // Attack
|
||||
Placement::new(0.92, 0.52, 0.16), // Use
|
||||
Placement::new(0.46, 0.055, 0.075), // Chat
|
||||
Placement::new(0.50, 0.055, 0.075), // Pause
|
||||
Placement::new(0.54, 0.055, 0.075), // Settings
|
||||
Placement::new(0.73, 0.95, 0.075), // Inventory
|
||||
Placement::new(0.42, 0.055, 0.075), // Debug
|
||||
Placement::new(0.58, 0.055, 0.075), // Perspective
|
||||
Placement::new(0.80, 0.96, 0.075), // Offhand
|
||||
Placement::new(0.92, 0.36, 0.12), // Shield
|
||||
];
|
||||
let mut layouts = [row; 3];
|
||||
// The D-pad sits where the joystick does; sneak becomes its centre.
|
||||
layouts[Mode::Dpad.index()][Control::Sneak as usize] = Placement::new(0.13, 0.76, 0.10);
|
||||
layouts[Mode::Dpad.index()][Control::Jump as usize] = Placement::new(0.90, 0.78, 0.14);
|
||||
Self {
|
||||
mode: Mode::Crosshair,
|
||||
split_controls: false,
|
||||
left_handed: false,
|
||||
swap_jump_sneak: false,
|
||||
sprint_button: true,
|
||||
sprint_with_joystick: false,
|
||||
offhand_button: false,
|
||||
shield_button: true,
|
||||
button_visibility: [FOLLOW_SCHEME; CONTROLS],
|
||||
layouts,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// Reads `path`; a missing or unreadable file gives the defaults.
|
||||
pub fn load(path: &Path) -> Self {
|
||||
let mut settings: Self = std::fs::read(path)
|
||||
.ok()
|
||||
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
|
||||
.unwrap_or_default();
|
||||
settings.sanitize();
|
||||
settings
|
||||
}
|
||||
|
||||
/// Writes `path` through a temporary file, so a crash never leaves half a layout.
|
||||
pub fn save(&self, path: &Path) -> std::io::Result<()> {
|
||||
let temp = path.with_extension("json.tmp");
|
||||
std::fs::write(&temp, serde_json::to_vec_pretty(self)?)?;
|
||||
std::fs::rename(temp, path)
|
||||
}
|
||||
|
||||
pub fn sanitize(&mut self) {
|
||||
// Only the D-pad has a split variant (see `Mode::SCHEMES`).
|
||||
if self.mode != Mode::Dpad {
|
||||
self.split_controls = false;
|
||||
}
|
||||
for v in &mut self.button_visibility {
|
||||
if *v > HIDDEN {
|
||||
*v = FOLLOW_SCHEME;
|
||||
}
|
||||
}
|
||||
for p in self.layouts.iter_mut().flatten() {
|
||||
p.sanitize();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether attack and use act on the crosshair rather than on a tap.
|
||||
pub fn crosshair(&self) -> bool {
|
||||
self.mode == Mode::Crosshair || self.split_controls
|
||||
}
|
||||
|
||||
pub fn visible(&self, c: Control) -> bool {
|
||||
match self.button_visibility[c as usize] {
|
||||
SHOWN => return true,
|
||||
HIDDEN => return false,
|
||||
_ => {}
|
||||
}
|
||||
match c {
|
||||
Control::Offhand => self.offhand_button,
|
||||
Control::Shield => self.shield_button && !self.visible(Control::Use),
|
||||
Control::Attack | Control::Use => self.crosshair(),
|
||||
Control::Sprint => {
|
||||
self.sprint_button && self.mode != Mode::Dpad && !self.sprint_with_joystick
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The layout slot a control is drawn in (jump and sneak may trade places).
|
||||
pub fn slot(&self, c: Control) -> usize {
|
||||
let swap = self.mode == Mode::Dpad && self.swap_jump_sneak;
|
||||
match c {
|
||||
Control::Jump if swap => Control::Sneak as usize,
|
||||
Control::Sneak if swap => Control::Jump as usize,
|
||||
_ => c as usize,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a control is in the current scheme, mirrored for left hands.
|
||||
pub fn placement(&self, c: Control) -> Placement {
|
||||
let mut p = self.layouts[self.mode.index()][self.slot(c)];
|
||||
if self.left_handed {
|
||||
p.center[0] = 1.0 - p.center[0];
|
||||
}
|
||||
p
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `p` lies in the rectangle `[x, y, w, h]` (edges included).
|
||||
pub fn contains([x, y, w, h]: [f32; 4], p: [f32; 2]) -> bool {
|
||||
(x..=x + w).contains(&p[0]) && (y..=y + h).contains(&p[1])
|
||||
}
|
||||
|
||||
fn finite_or(value: f32, fallback: f32) -> f32 {
|
||||
if value.is_finite() { value } else { fallback }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp_file(name: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!("horizon-touch-{name}-{}.json", std::process::id()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_indices_match_layout_rows_and_schemes() {
|
||||
for (i, mode) in Mode::ALL.into_iter().enumerate() {
|
||||
assert_eq!(mode.index(), i);
|
||||
}
|
||||
for (mode, split, _) in Mode::SCHEMES {
|
||||
assert!(!split || mode == Mode::Dpad);
|
||||
}
|
||||
for (i, c) in Control::ALL.into_iter().enumerate() {
|
||||
assert_eq!(c as usize, i);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scheme_decides_attack_use_sprint_and_shield() {
|
||||
let mut s = Settings::default();
|
||||
assert!(s.visible(Control::Attack) && s.visible(Control::Use));
|
||||
assert!(s.visible(Control::Sprint));
|
||||
// The shield button only stands in when Use is hidden.
|
||||
assert!(!s.visible(Control::Shield));
|
||||
s.mode = Mode::Tap;
|
||||
assert!(!s.visible(Control::Attack) && !s.visible(Control::Use));
|
||||
assert!(s.visible(Control::Shield));
|
||||
s.mode = Mode::Dpad;
|
||||
assert!(!s.visible(Control::Sprint));
|
||||
s.split_controls = true;
|
||||
assert!(s.crosshair() && s.visible(Control::Attack));
|
||||
s.mode = Mode::Crosshair;
|
||||
s.sprint_with_joystick = true;
|
||||
assert!(!s.visible(Control::Sprint));
|
||||
assert!(!s.visible(Control::Offhand));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_visibility_overrides_the_scheme_and_survives_a_save() {
|
||||
let mut s = Settings {
|
||||
mode: Mode::Tap,
|
||||
..Settings::default()
|
||||
};
|
||||
s.button_visibility[Control::Attack as usize] = SHOWN;
|
||||
s.button_visibility[Control::Chat as usize] = HIDDEN;
|
||||
assert!(s.visible(Control::Attack));
|
||||
assert!(!s.visible(Control::Chat));
|
||||
let file = temp_file("visibility");
|
||||
s.save(&file).unwrap();
|
||||
let read = Settings::load(&file);
|
||||
std::fs::remove_file(&file).unwrap();
|
||||
assert_eq!(read, s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_bad_or_partial_files_fall_back_to_defaults() {
|
||||
assert_eq!(Settings::load(&temp_file("absent")), Settings::default());
|
||||
let file = temp_file("bad");
|
||||
std::fs::write(&file, b"{ not json").unwrap();
|
||||
assert_eq!(Settings::load(&file), Settings::default());
|
||||
std::fs::write(&file, br#"{"mode":"dpad","left_handed":true}"#).unwrap();
|
||||
let read = Settings::load(&file);
|
||||
std::fs::remove_file(&file).unwrap();
|
||||
assert_eq!(read.mode, Mode::Dpad);
|
||||
assert!(read.left_handed);
|
||||
assert_eq!(read.layouts, Settings::default().layouts);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_brings_controls_back_on_screen() {
|
||||
let mut s = Settings {
|
||||
mode: Mode::Tap,
|
||||
split_controls: true,
|
||||
..Settings::default()
|
||||
};
|
||||
s.button_visibility[0] = 9;
|
||||
s.layouts[0][0] = Placement {
|
||||
center: [f32::NAN, 7.0],
|
||||
size: 0.0,
|
||||
opacity: f32::INFINITY,
|
||||
};
|
||||
s.sanitize();
|
||||
assert!(!s.split_controls);
|
||||
assert_eq!(s.button_visibility[0], FOLLOW_SCHEME);
|
||||
let p = s.layouts[0][0];
|
||||
assert_eq!(p.center, [0.5, 0.975]);
|
||||
assert_eq!(p.size, 0.045);
|
||||
assert_eq!(p.opacity, 0.65);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_hands_mirror_and_dpad_can_swap_jump_and_sneak() {
|
||||
let mut s = Settings::default();
|
||||
let right = s.placement(Control::Jump).center;
|
||||
s.left_handed = true;
|
||||
assert_eq!(
|
||||
s.placement(Control::Jump).center,
|
||||
[1.0 - right[0], right[1]]
|
||||
);
|
||||
s.left_handed = false;
|
||||
s.mode = Mode::Dpad;
|
||||
s.swap_jump_sneak = true;
|
||||
let row = s.layouts[Mode::Dpad.index()];
|
||||
assert_eq!(s.placement(Control::Jump), row[Control::Sneak as usize]);
|
||||
assert_eq!(s.placement(Control::Sneak), row[Control::Jump as usize]);
|
||||
// Only the D-pad swaps.
|
||||
s.mode = Mode::Crosshair;
|
||||
assert_eq!(s.slot(Control::Jump), Control::Jump as usize);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn controls_stay_square_on_wide_surfaces() {
|
||||
let p = Placement::new(0.5, 0.5, 0.2);
|
||||
let [x, y, w, h] = p.rect(2.0);
|
||||
assert_eq!([w, h], [0.1, 0.2]);
|
||||
assert!(contains([x, y, w, h], [0.5, 0.5]));
|
||||
assert!(contains([x, y, w, h], [x + w, y + h]));
|
||||
assert!(!contains([x, y, w, h], [0.56, 0.5]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_controls_are_on_screen_in_every_mode() {
|
||||
let s = Settings::default();
|
||||
for row in s.layouts {
|
||||
for p in row {
|
||||
let mut clamped = p;
|
||||
clamped.sanitize();
|
||||
assert_eq!(clamped, p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -217,6 +217,25 @@ pub fn blit_sprite(
|
||||
draw_sprite(batch, *r, nine_slice_metadata(sprite), [x, y, w, h], color);
|
||||
}
|
||||
}
|
||||
/// `GuiGraphicsExtractor.blit(location, x, y, u, v, width, height, textureWidth,
|
||||
/// textureHeight)`: a `src` region of a sprite — in its own texture's units, as the
|
||||
/// u/v/width/height arguments are — stretched over `w` × `h`.
|
||||
pub fn blit_sprite_region(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
sprite: &str,
|
||||
src: [f32; 4],
|
||||
x: f32,
|
||||
y: f32,
|
||||
w: f32,
|
||||
h: f32,
|
||||
color: u32,
|
||||
) {
|
||||
if let Some(r) = atlas.sprites.get(sprite) {
|
||||
let size = [r.w as f32, r.h as f32];
|
||||
batch.rect_uv(x, y, w, h, sub(*r, size, src), color);
|
||||
}
|
||||
}
|
||||
fn draw_sprite(
|
||||
batch: &mut HudBatch,
|
||||
r: Rect,
|
||||
@@ -575,6 +594,7 @@ pub fn action_button(
|
||||
label,
|
||||
active,
|
||||
hover,
|
||||
false,
|
||||
room,
|
||||
|rect| crate::widgets::Narratable::button(label.plain(), rect, active),
|
||||
);
|
||||
@@ -602,10 +622,46 @@ pub fn button_as(
|
||||
label,
|
||||
active,
|
||||
hover,
|
||||
false,
|
||||
0.0,
|
||||
narration,
|
||||
);
|
||||
}
|
||||
/// An options button whose focus highlights it without changing the mouse cursor.
|
||||
/// `AbstractWidget.handleCursor` tests hover, not `isHoveredOrFocused`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn focused_button(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
rect: [f32; 4],
|
||||
label: &Text,
|
||||
active: bool,
|
||||
hover: bool,
|
||||
focused: bool,
|
||||
action: Option<crate::widgets::Action>,
|
||||
) {
|
||||
let room = if let Some(action) = action {
|
||||
crate::widgets::declare_action(action, rect, active);
|
||||
if active {
|
||||
crate::widgets::action_glyph_room()
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
button_with_room(
|
||||
batch,
|
||||
atlas,
|
||||
rect,
|
||||
label,
|
||||
active,
|
||||
hover,
|
||||
focused,
|
||||
room,
|
||||
|rect| crate::widgets::Narratable::button(label.plain(), rect, active),
|
||||
);
|
||||
}
|
||||
/// A button whose label keeps `room` free at its left.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn button_with_room(
|
||||
@@ -615,6 +671,7 @@ fn button_with_room(
|
||||
label: &Text,
|
||||
active: bool,
|
||||
hover: bool,
|
||||
focused: bool,
|
||||
room: f32,
|
||||
narration: impl FnOnce([f32; 4]) -> crate::widgets::Narratable,
|
||||
) {
|
||||
@@ -624,11 +681,11 @@ fn button_with_room(
|
||||
if active && plain.trim().is_empty() {
|
||||
crate::widgets::target([x, y, w, h]);
|
||||
}
|
||||
crate::widgets::narratable(|| narration([x, y, w, h]).hovered(hover));
|
||||
crate::widgets::narratable(|| narration([x, y, w, h]).hovered(hover || focused));
|
||||
crate::widgets::widget_cursor(hover, active);
|
||||
let sprite = if !active {
|
||||
"gui/sprites/widget/button_disabled"
|
||||
} else if hover {
|
||||
} else if hover || focused {
|
||||
"gui/sprites/widget/button_highlighted"
|
||||
} else {
|
||||
"gui/sprites/widget/button"
|
||||
@@ -1258,6 +1315,29 @@ impl Caret {
|
||||
rect: [f32; 4],
|
||||
focused: bool,
|
||||
focused_at: Instant,
|
||||
) {
|
||||
self.draw_color(
|
||||
batch,
|
||||
atlas,
|
||||
(id, value, max),
|
||||
rect,
|
||||
focused,
|
||||
focused_at,
|
||||
rgba(224, 224, 224, 255),
|
||||
);
|
||||
}
|
||||
/// [`Self::draw`] with the text in `color` (`EditBox.setTextColor`): the
|
||||
/// settings tab turns an invalid Realm name red.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn draw_color(
|
||||
&mut self,
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
(id, value, max): (crate::clipboard::FieldId, &str, usize),
|
||||
rect: [f32; 4],
|
||||
focused: bool,
|
||||
focused_at: Instant,
|
||||
color: u32,
|
||||
) {
|
||||
let mut idle;
|
||||
let edit = if focused {
|
||||
@@ -1270,6 +1350,7 @@ impl Caret {
|
||||
};
|
||||
[edit.x, edit.y, edit.width, edit.height] = rect;
|
||||
edit.focused = focused;
|
||||
edit.text_color = color;
|
||||
if edit.focused_at < focused_at {
|
||||
edit.focused_at = focused_at;
|
||||
}
|
||||
|
||||
@@ -423,5 +423,5 @@ fn vis_corner(position: vec3<u32>, rest: vec3<u32>, origin: vec4<f32>) -> Terrai
|
||||
let texel = vis_sprite(t.uv, t.layer & 0x7fffu, du, dv);
|
||||
var color = texel * t.color;
|
||||
color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, color.a), color, t.visibility);
|
||||
return solid_finish_at(t, texel, color, du, dv);
|
||||
return solid_light(solid_finish_at(t, texel, color, du, dv));
|
||||
}
|
||||
@@ -105,6 +105,8 @@ thread_local! {
|
||||
static ACTION_SCREENS: Cell<(u64, u64)> = const { Cell::new((0, 0)) };
|
||||
/// The lists drawn with a scrollbar since `end_frame`, and the last frame's.
|
||||
static SCROLL_AREAS: RefCell<(Vec<ScrollArea>, Vec<ScrollArea>)> = const { RefCell::new((Vec::new(), Vec::new())) };
|
||||
/// The steps screens declared since `end_frame` ([`focus_link`]), and the last frame's.
|
||||
static FOCUS_LINKS: RefCell<(Vec<FocusLink>, Vec<FocusLink>)> = const { RefCell::new((Vec::new(), Vec::new())) };
|
||||
}
|
||||
|
||||
/// Splitscreen: one player's buttons and focus targets, kept aside while another player's game
|
||||
@@ -117,6 +119,7 @@ pub struct Saved(
|
||||
bool,
|
||||
(u64, u64),
|
||||
(Vec<ScrollArea>, Vec<ScrollArea>),
|
||||
(Vec<FocusLink>, Vec<FocusLink>),
|
||||
);
|
||||
|
||||
/// This thread's buttons and targets, leaving none.
|
||||
@@ -128,6 +131,7 @@ pub fn take() -> Saved {
|
||||
ACTION_GLYPHS.take(),
|
||||
ACTION_SCREENS.take(),
|
||||
SCROLL_AREAS.take(),
|
||||
FOCUS_LINKS.take(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -136,6 +140,7 @@ pub fn put(saved: Saved) {
|
||||
WIDGETS.set(saved.0);
|
||||
TARGETS.set(saved.1);
|
||||
SCROLL_AREAS.set(saved.5);
|
||||
FOCUS_LINKS.set(saved.6);
|
||||
ACTIONS.set(saved.2);
|
||||
ACTION_GLYPHS.set(saved.3);
|
||||
ACTION_SCREENS.set(saved.4);
|
||||
@@ -282,6 +287,7 @@ pub fn cover_frame() {
|
||||
TARGETS.with_borrow_mut(|(current, _, _)| current.clear());
|
||||
ACTIONS.with_borrow_mut(|(current, _)| current.clear());
|
||||
SCROLL_AREAS.with_borrow_mut(|(current, _)| current.clear());
|
||||
FOCUS_LINKS.with_borrow_mut(|(current, _)| current.clear());
|
||||
}
|
||||
|
||||
/// This frame drew a list with something to scroll, in `rect`, scrolled `amount`
|
||||
@@ -315,6 +321,38 @@ pub fn over_scroll_area(at: (f32, f32)) -> (f32, f32) {
|
||||
})
|
||||
}
|
||||
|
||||
/// A step the arrows and the D-pad take that the rectangles don't show: `down` (else up) from
|
||||
/// a focus whose middle is in `from`, to the target at `to`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct FocusLink {
|
||||
pub from: [f32; 4],
|
||||
pub down: bool,
|
||||
pub to: [f32; 4],
|
||||
}
|
||||
|
||||
/// Down (or up) from what is in `from` goes to `to`: a screen whose layout puts the next control
|
||||
/// off to one side, where vanilla's step would pass it by (the settings shell's search field over
|
||||
/// its list, on a screen with no page list).
|
||||
pub fn focus_link(from: [f32; 4], down: bool, to: [f32; 4]) {
|
||||
FOCUS_LINKS.with_borrow_mut(|(current, _)| current.push(FocusLink { from, down, to }));
|
||||
}
|
||||
|
||||
/// Where the last drawn frame said a step `down` (else up) from `focus` goes, if it said.
|
||||
pub fn linked_step(focus: [f32; 4], down: bool) -> Option<[f32; 4]> {
|
||||
let [x, y, w, h] = focus;
|
||||
let middle = (x + w / 2.0, y + h / 2.0);
|
||||
FOCUS_LINKS.with_borrow(|(_, last)| {
|
||||
last.iter()
|
||||
.find(|l| {
|
||||
let [lx, ly, lw, lh] = l.from;
|
||||
l.down == down
|
||||
&& (lx..lx + lw).contains(&middle.0)
|
||||
&& (ly..ly + lh).contains(&middle.1)
|
||||
})
|
||||
.map(|l| l.to)
|
||||
})
|
||||
}
|
||||
|
||||
/// The focus targets of the last drawn frame.
|
||||
pub fn last_targets() -> Vec<Target> {
|
||||
TARGETS.with_borrow(|(_, last, _)| last.clone())
|
||||
@@ -392,6 +430,9 @@ pub fn end_frame(frames: u64) {
|
||||
SCROLL_AREAS.with_borrow_mut(|(current, last)| {
|
||||
*last = std::mem::take(current);
|
||||
});
|
||||
FOCUS_LINKS.with_borrow_mut(|(current, last)| {
|
||||
*last = std::mem::take(current);
|
||||
});
|
||||
NARRATION.with_borrow_mut(|(current, last)| {
|
||||
*last = std::mem::replace(current, Narration::EMPTY);
|
||||
});
|
||||
|
||||
@@ -75,6 +75,10 @@ pub struct World {
|
||||
pub light_edits: Vec<((i32, i32, i32), u32, u32)>,
|
||||
/// Saved entities of chunks that just loaded, spawned by the game.
|
||||
pub loaded_entities: Vec<((i32, i32), Vec<horizon_world::nbt::Tag>)>,
|
||||
/// Chunks loaded since the level's last pass, their entity sections not
|
||||
/// ready yet (`PersistentEntitySectionManager`'s PENDING), empty ones too:
|
||||
/// the pass ends by readying them (`processPendingLoads`).
|
||||
pub entities_pending: FxHashSet<(i32, i32)>,
|
||||
/// Chunks that just came into this level from a worker, and whether each
|
||||
/// was generated rather than read from the save: the game tells native mods.
|
||||
pub loaded_chunks: Vec<(i32, i32, bool)>,
|
||||
@@ -86,7 +90,7 @@ pub struct World {
|
||||
/// interaction range.
|
||||
pub chest_openers: FxHashMap<(i32, i32, i32), (i32, f64)>,
|
||||
/// `BaseContainerBlockEntity.name`: custom names of container block entities.
|
||||
pub block_names: FxHashMap<(i32, i32, i32), String>,
|
||||
pub block_names: FxHashMap<(i32, i32, i32), horizon_world::nbt::Tag>,
|
||||
/// States (by id) whose writes are recorded in `watched_writes` (sculk
|
||||
/// blocks, which need a block entity).
|
||||
pub watched: Vec<bool>,
|
||||
@@ -214,6 +218,7 @@ impl World {
|
||||
dirty_light: Default::default(),
|
||||
light_edits: Vec::new(),
|
||||
loaded_entities: Vec::new(),
|
||||
entities_pending: FxHashSet::default(),
|
||||
loaded_chunks: Vec::new(),
|
||||
modified: FxHashSet::default(),
|
||||
attachments: Default::default(),
|
||||
|
||||
@@ -147,9 +147,10 @@ fn sunrise_sectors_match_reference_on_real_gpu() {
|
||||
#[test]
|
||||
fn native_pack_sky_keeps_sunrise_helpers_private() {
|
||||
use wgsl_prefix::{TERRAIN, prefixed, section};
|
||||
// Use the same complete terrain sections and prefixer as native_sky.rs.
|
||||
// Use the same shared helper, complete terrain sections and prefixer as native_sky.rs.
|
||||
let sky = prefixed(
|
||||
&[
|
||||
include_str!("../src/hdr_light.wgsl"),
|
||||
section(TERRAIN, "struct Globals {", "@group(0) @binding(1)"),
|
||||
section(TERRAIN, "fn linear_fog(", "fn apply_fog("),
|
||||
section(TERRAIN, "struct SkyOut {", "// rendertype_lines"),
|
||||
@@ -179,7 +180,10 @@ fn native_pack_sky_keeps_sunrise_helpers_private() {
|
||||
|
||||
#[test]
|
||||
fn complete_terrain_shader_still_validates_for_fallback_sky() {
|
||||
let module = naga::front::wgsl::parse_str(wgsl_prefix::TERRAIN).unwrap();
|
||||
let module = naga::front::wgsl::parse_str(
|
||||
&(include_str!("../src/hdr_light.wgsl").to_owned() + wgsl_prefix::TERRAIN),
|
||||
)
|
||||
.unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
|
||||
Loaded 100 of 694 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user