mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-12 18:35:16 -10:00
Compare commits
1206
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36eb98b6f6 | ||
|
|
a884525213 | ||
|
|
c35f0ba323 | ||
|
|
7e6b2db114 | ||
|
|
768f0ddb83 | ||
|
|
7c1a43138c | ||
|
|
1a62ef7eb8 | ||
|
|
fd448c5dc3 | ||
|
|
e0d0e34ba3 | ||
|
|
4e459bf8e7 | ||
|
|
15f090e126 | ||
|
|
f21b83c8b2 | ||
|
|
b929bea501 | ||
|
|
ed2d455bf1 | ||
|
|
6dd6357962 | ||
|
|
6416121902 | ||
|
|
eb8b298f9e | ||
|
|
c5127a8777 | ||
|
|
911bbf054b | ||
|
|
f646074830 | ||
|
|
5e3b0f03b6 | ||
|
|
bb64b215fe | ||
|
|
779ea29839 | ||
|
|
74fd49ced0 | ||
|
|
8cf76b5ced | ||
|
|
53759e036a | ||
|
|
950b9fed63 | ||
|
|
01f8706d10 | ||
|
|
ba4e4cf377 | ||
|
|
c442809fa7 | ||
|
|
af682746e0 | ||
|
|
f34daa1a75 | ||
|
|
130d3ce7b6 | ||
|
|
4acdda54f2 | ||
|
|
a1c4de50b1 | ||
|
|
3b48fdf7c4 | ||
|
|
02b94e5b12 | ||
|
|
51a4fe79e6 | ||
|
|
cd0f850c12 | ||
|
|
97fc687ceb | ||
|
|
f7fce57158 | ||
|
|
fb50a7ad88 | ||
|
|
db82ec5ba0 | ||
|
|
0f93711e13 | ||
|
|
2524bc9ea2 | ||
|
|
fcfa91e598 | ||
|
|
deb3b4fec3 | ||
|
|
3fd1593ca6 | ||
|
|
95d99935ea | ||
|
|
31fc9ce21d | ||
|
|
72f92a5ef7 | ||
|
|
914d5e664d | ||
|
|
23b4c9c411 | ||
|
|
95e346c9ad | ||
|
|
6f478a6cad | ||
|
|
737f81c32d | ||
|
|
408c08e029 | ||
|
|
44c7007125 | ||
|
|
551cb3a741 | ||
|
|
683603fe21 | ||
|
|
5b29177859 | ||
|
|
3d6224deb4 | ||
|
|
d1613f705f | ||
|
|
e78022f152 | ||
|
|
5c4cdb41f6 | ||
|
|
3cac36d0f1 | ||
|
|
50d2c4f3e8 | ||
|
|
5e89bcee59 | ||
|
|
97aa3cf252 | ||
|
|
0ae3420fd5 | ||
|
|
20b1949dbd | ||
|
|
5db98af340 | ||
|
|
00b3fa3db7 | ||
|
|
9b2f425f42 | ||
|
|
a0a2a53279 | ||
|
|
13d6ba09ad | ||
|
|
1352566ab3 | ||
|
|
ca80643fda | ||
|
|
e9faef471e | ||
|
|
7beafea5e1 | ||
|
|
9e840cecfd | ||
|
|
78f6ac5536 | ||
|
|
5ca5e36230 | ||
|
|
3e5a89b088 | ||
|
|
0609305694 | ||
|
|
b723023664 | ||
|
|
a5f6b3ba0e | ||
|
|
66129b4902 | ||
|
|
ac2603ed41 | ||
|
|
58d42c41ed | ||
|
|
dffce9c48d | ||
|
|
42e897cc73 | ||
|
|
ebd4205ea6 | ||
|
|
14859c67b7 | ||
|
|
542c7c239e | ||
|
|
93d98006a8 | ||
|
|
12b96b27c3 | ||
|
|
5b912979d4 | ||
|
|
e518cb868d | ||
|
|
3c1027d1e1 | ||
|
|
ae8e375feb | ||
|
|
7e8805bb61 | ||
|
|
fb089da76c | ||
|
|
d162b9f5e5 | ||
|
|
748c6ba3d5 | ||
|
|
3665978732 | ||
|
|
5364cb4ebc | ||
|
|
855e215c43 | ||
|
|
7c8f5e38a6 | ||
|
|
108bf1dd93 | ||
|
|
3fc31df485 | ||
|
|
633d28079b | ||
|
|
e5efe9dc8d | ||
|
|
2b8ce785e1 | ||
|
|
d52edf0341 | ||
|
|
852dcc5416 | ||
|
|
62d8085479 | ||
|
|
72627408bc | ||
|
|
91c0bf1f42 | ||
|
|
c26187a6f0 | ||
|
|
5a135748c8 | ||
|
|
5208a5385b | ||
|
|
d3c30ae2b0 | ||
|
|
eb44631ea7 | ||
|
|
53209c8800 | ||
|
|
5700ead75f | ||
|
|
e3b03d4adc | ||
|
|
387fc29cb8 | ||
|
|
16448c5ff1 | ||
|
|
38f23d16e4 | ||
|
|
5b4d583209 | ||
|
|
ebf8069c57 | ||
|
|
1751960d36 | ||
|
|
35e7459102 | ||
|
|
b942e89cd6 | ||
|
|
f117ec604a | ||
|
|
34dcda27f0 | ||
|
|
ee8b7b891a | ||
|
|
f24ee1accf | ||
|
|
9a314a0b5a | ||
|
|
68ac8880d3 | ||
|
|
717b8d192f | ||
|
|
7cd92e310d | ||
|
|
541ef4e5fa | ||
|
|
baa43ddf05 | ||
|
|
337b8a751c | ||
|
|
f31921a9a3 | ||
|
|
1aa472967f | ||
|
|
327daa3899 | ||
|
|
cc1fea2714 | ||
|
|
ba91f75cb2 | ||
|
|
40ca777682 | ||
|
|
036dc77592 | ||
|
|
2a3b142012 | ||
|
|
5164ada2a6 | ||
|
|
a21f8a3947 | ||
|
|
6d3692c673 | ||
|
|
383b68b576 | ||
|
|
69fdcefa2a | ||
|
|
59493f4837 | ||
|
|
0a80c3d42f | ||
|
|
f0811d3363 | ||
|
|
c543cb8689 | ||
|
|
4b65ec8a0d | ||
|
|
bb8a15db1e | ||
|
|
555b885429 | ||
|
|
722e63500a | ||
|
|
f1708966d5 | ||
|
|
7b45eb9614 | ||
|
|
805f69c8a9 | ||
|
|
911aa6ddc6 | ||
|
|
5816e84e56 | ||
|
|
1cd6c70899 | ||
|
|
d78f0c49c9 | ||
|
|
eb084c5152 | ||
|
|
aa3483bf5a | ||
|
|
23678db4f7 | ||
|
|
a97725d63c | ||
|
|
0ce3cf8624 | ||
|
|
aa548b0a41 | ||
|
|
cd4e73e270 | ||
|
|
d946b838fe | ||
|
|
b2d30693b8 | ||
|
|
8b13f108ef | ||
|
|
a15a3ea50c | ||
|
|
5ddbaaab62 | ||
|
|
6c34352b0f | ||
|
|
52540d4ff2 | ||
|
|
f671c935c2 | ||
|
|
d0c091ae1d | ||
|
|
11e85c0411 | ||
|
|
2dabcba7dc | ||
|
|
c94cdb0c76 | ||
|
|
9303b88c79 | ||
|
|
ecc1570388 | ||
|
|
8182a33000 | ||
|
|
dd90805814 | ||
|
|
050e90b150 | ||
|
|
87be63d40a | ||
|
|
278d0355e9 | ||
|
|
0c1a839a02 | ||
|
|
00f230d640 | ||
|
|
535211eb7e | ||
|
|
83e0929684 | ||
|
|
e3e866eb41 | ||
|
|
d4c59f8c7a | ||
|
|
a4a529a565 | ||
|
|
82f042de20 | ||
|
|
c351efbab6 | ||
|
|
3054963dec | ||
|
|
25a8fab320 | ||
|
|
194c5c2c6c | ||
|
|
bab03f04db | ||
|
|
9a71098242 | ||
|
|
6d76e89de2 | ||
|
|
609981c352 | ||
|
|
3aeb206813 | ||
|
|
9bd670d3d3 | ||
|
|
63bd6c914d | ||
|
|
ea787a658c | ||
|
|
14e27946ca | ||
|
|
9639526d6b | ||
|
|
336b647f63 | ||
|
|
f181472b63 | ||
|
|
875b98b71f | ||
|
|
5e177afe1d | ||
|
|
59972a243d | ||
|
|
d9b9996a4d | ||
|
|
a71f9d3bd3 | ||
|
|
91e8f91fee | ||
|
|
92eb621473 | ||
|
|
c6544a2311 | ||
|
|
63ad77a1da | ||
|
|
440a7f0144 | ||
|
|
96bb424a7f | ||
|
|
5cdcbf9812 | ||
|
|
3cc15c8ba0 | ||
|
|
061ba23407 | ||
|
|
e983ba70a5 | ||
|
|
80eca9b7e2 | ||
|
|
1a5bea076d | ||
|
|
672b1cae9a | ||
|
|
af9e445fbd | ||
|
|
745274bee2 | ||
|
|
f85db126cc | ||
|
|
9a2135d7b2 | ||
|
|
beb77a48e0 | ||
|
|
8286150240 | ||
|
|
2abe71ca92 | ||
|
|
d7a25b8aec | ||
|
|
62eb25aa1d | ||
|
|
c6683ed9a2 | ||
|
|
0a484606c9 | ||
|
|
c497ba5279 | ||
|
|
98961ccd14 | ||
|
|
f787306fce | ||
|
|
d8bf54d1e6 | ||
|
|
f165e7fa29 | ||
|
|
9d73f6a26a | ||
|
|
c2bead90c4 | ||
|
|
e470dac100 | ||
|
|
22bfbbd417 | ||
|
|
4a60f12112 | ||
|
|
c950a3dafe | ||
|
|
f92b723265 | ||
|
|
784eb9f185 | ||
|
|
aa71621cae | ||
|
|
e829142877 | ||
|
|
192be7194f | ||
|
|
4ae80fa5ea | ||
|
|
5f3caf9be4 | ||
|
|
571ef142c5 | ||
|
|
21845f7949 | ||
|
|
27a4dc65c9 | ||
|
|
deaab60ba9 | ||
|
|
4484d84eed | ||
|
|
c2c3cea8e3 | ||
|
|
a5dbd2c5c1 | ||
|
|
4c4de96fa5 | ||
|
|
c19aaf98e5 | ||
|
|
7798130a18 | ||
|
|
30a7b86bc8 | ||
|
|
0e0f95efa6 | ||
|
|
483497ef1c | ||
|
|
f7ba023dc6 | ||
|
|
cad46c5ff5 | ||
|
|
654bed3a7f | ||
|
|
6154ce2282 | ||
|
|
5e331b277a | ||
|
|
96712da3cb | ||
|
|
755ba0b9a5 | ||
|
|
29223492fc | ||
|
|
d5f9e06dac | ||
|
|
26d4f3f273 | ||
|
|
fc71cbfeed | ||
|
|
fcf7c70c83 | ||
|
|
be34bdfd3c | ||
|
|
09d748f19c | ||
|
|
0cc70624e2 | ||
|
|
0d55f3075b | ||
|
|
e72b69caca | ||
|
|
166e09f55e | ||
|
|
f48bf07992 | ||
|
|
24b778c3b4 | ||
|
|
e1895229b8 | ||
|
|
cf79b811ac | ||
|
|
2b86defaa7 | ||
|
|
0da40f990f | ||
|
|
d63ae80019 | ||
|
|
cd5c829460 | ||
|
|
14ff11b96b | ||
|
|
12bbd34fe5 | ||
|
|
4a015bfb7c | ||
|
|
aaad7c57c1 | ||
|
|
61c43e5109 | ||
|
|
10721b288e | ||
|
|
19fa5d003a | ||
|
|
be9a3c000f | ||
|
|
82a17c59ae | ||
|
|
646722c466 | ||
|
|
02f9ee4629 | ||
|
|
bad917dea7 | ||
|
|
902c147f8e | ||
|
|
92166e6236 | ||
|
|
eb6536a832 | ||
|
|
cf26b92d45 | ||
|
|
bb9d4c2467 | ||
|
|
d9d89df9d8 | ||
|
|
cd1c3ad784 | ||
|
|
5a5d9f780c | ||
|
|
687caa60c4 | ||
|
|
9503016e93 | ||
|
|
344bcdec9a | ||
|
|
cce82d001c | ||
|
|
f9d2eddb83 | ||
|
|
d66d48d9a3 | ||
|
|
18415b2fbd | ||
|
|
9d9edcc5c7 | ||
|
|
aab654d4e8 | ||
|
|
b7eeb79a33 | ||
|
|
db7b03ffda | ||
|
|
e27a62210c | ||
|
|
474e9eb4c8 | ||
|
|
06e9cfe6da | ||
|
|
b6941b78f2 | ||
|
|
d64883cef3 | ||
|
|
7ab3a23f95 | ||
|
|
9fd3efbf81 | ||
|
|
07050a34bd | ||
|
|
9054341b05 | ||
|
|
30db6aa7fd | ||
|
|
663e7457ab | ||
|
|
2b375bf38c | ||
|
|
172cec105a | ||
|
|
45f36d84e1 | ||
|
|
5b28083ef3 | ||
|
|
3072b90817 | ||
|
|
8368def6fa | ||
|
|
4c29ce57c7 | ||
|
|
291b2c2b03 | ||
|
|
10004fcd77 | ||
|
|
9fa96cab6f | ||
|
|
892c64cc69 | ||
|
|
f8e59c3e3b | ||
|
|
2106fb21d8 | ||
|
|
6fad4fb984 | ||
|
|
4752a44392 | ||
|
|
32ed49b6d8 | ||
|
|
35992267b9 | ||
|
|
95d025f4c2 | ||
|
|
40b9d160c9 | ||
|
|
984872a0dd | ||
|
|
4d00052e33 | ||
|
|
94077f9e66 | ||
|
|
869df6be8e | ||
|
|
10ea380a42 | ||
|
|
da19583b0e | ||
|
|
a4bf39fccf | ||
|
|
a856c936d6 | ||
|
|
1dcb48e1ec | ||
|
|
79b225758e | ||
|
|
80570541d0 | ||
|
|
475ea2f6a7 | ||
|
|
f26c009854 | ||
|
|
783c5d4bd5 | ||
|
|
8c2241221b | ||
|
|
fb003e9b48 | ||
|
|
e243b417ec | ||
|
|
cc15269fb6 | ||
|
|
1177119180 | ||
|
|
b0918fb06c | ||
|
|
702b9e5a6e | ||
|
|
0bdbffccf7 | ||
|
|
e3d11137f6 | ||
|
|
2bfb755693 | ||
|
|
783c053601 | ||
|
|
1416eb94b9 | ||
|
|
76a0ded489 | ||
|
|
0e06fcffe8 | ||
|
|
44699b4c51 | ||
|
|
22b2093962 | ||
|
|
7a67cce201 | ||
|
|
11bda3ecc3 | ||
|
|
c187167717 | ||
|
|
c26677ed47 | ||
|
|
7a725b44e8 | ||
|
|
09449b8b5a | ||
|
|
c9fbcbc9c0 | ||
|
|
3f287372f4 | ||
|
|
3a44c61b5c | ||
|
|
d3e0272348 | ||
|
|
e3d38ae5bd | ||
|
|
adf711981d | ||
|
|
58cdf1b551 | ||
|
|
818e0a229e | ||
|
|
b140490175 | ||
|
|
09fa6403c5 | ||
|
|
7244070cdf | ||
|
|
cad3d55f76 | ||
|
|
0c03fe844b | ||
|
|
05ed4ff36f | ||
|
|
0d85f3f65f | ||
|
|
ede16abf28 | ||
|
|
39046b18e8 | ||
|
|
765595fc00 | ||
|
|
02b5729038 | ||
|
|
071685a3cf | ||
|
|
370854a657 | ||
|
|
46d26691cf | ||
|
|
713fe9176d | ||
|
|
2e23fd1dd1 | ||
|
|
66319c12e6 | ||
|
|
31b0e68219 | ||
|
|
8bf7dd6dfe | ||
|
|
5814b1cb5f | ||
|
|
59cd7697be | ||
|
|
063632d5f5 | ||
|
|
8424af6894 | ||
|
|
a0d2e7d935 | ||
|
|
90827741ae | ||
|
|
7e779ceb8b | ||
|
|
5481445402 | ||
|
|
2c62ea5aaa | ||
|
|
0bd5635d74 | ||
|
|
53ef669745 | ||
|
|
1aef29fada | ||
|
|
44d13ad5d4 | ||
|
|
f66e1a8d91 | ||
|
|
83703c28b2 | ||
|
|
634df8744a | ||
|
|
e8b23d06ed | ||
|
|
80ef919e40 | ||
|
|
c1894da34b | ||
|
|
1b016ba22e | ||
|
|
0482839c1b | ||
|
|
023cef202f | ||
|
|
fdc97df507 | ||
|
|
09fdaa392a | ||
|
|
6e5270105b | ||
|
|
7c9db3e706 | ||
|
|
e8b01b7282 | ||
|
|
f22a814607 | ||
|
|
fd7c423476 | ||
|
|
731699144c | ||
|
|
b14027d065 | ||
|
|
5133e20195 | ||
|
|
1fc67d4d10 | ||
|
|
59f63a251a | ||
|
|
e745db772e | ||
|
|
ad8e395f3c | ||
|
|
7573ae6a3e | ||
|
|
3e75afe983 | ||
|
|
29df2938b1 | ||
|
|
f071ffe84b | ||
|
|
525c1c9834 | ||
|
|
627864a666 | ||
|
|
47b40ecd35 | ||
|
|
efaac3cdc0 | ||
|
|
0808d1f189 | ||
|
|
014462436f | ||
|
|
7dffe8983b | ||
|
|
5a4d8c11e6 | ||
|
|
789be0131d | ||
|
|
54ea398de4 | ||
|
|
219d6b6b4e | ||
|
|
cccf79f63a | ||
|
|
6c090eb39d | ||
|
|
4e44044828 | ||
|
|
59319a8e2d | ||
|
|
3086bc8910 | ||
|
|
0c07b18c76 | ||
|
|
71d150b028 | ||
|
|
f8cf08849e | ||
|
|
d78f353156 | ||
|
|
251c800717 | ||
|
|
632b455a88 | ||
|
|
6c58b77fed | ||
|
|
02b3da3eec | ||
|
|
0bd65042f2 | ||
|
|
4a33f95d69 | ||
|
|
245d97a312 | ||
|
|
eb1fbb151d | ||
|
|
4676650694 | ||
|
|
9aae4a6671 | ||
|
|
660288e50b | ||
|
|
2497c21f48 | ||
|
|
5cbded2bd9 | ||
|
|
53ffc05840 | ||
|
|
5b275cd324 | ||
|
|
05c0a3942f | ||
|
|
fa34419e4b | ||
|
|
a640a5a91a | ||
|
|
7475a27b7b | ||
|
|
721a16e17b | ||
|
|
20c3b24dab | ||
|
|
8d60a65c6c | ||
|
|
72d6a5b383 | ||
|
|
4347efb700 | ||
|
|
b1104143ef | ||
|
|
1c98b67b44 | ||
|
|
1299432e7a | ||
|
|
271bace8e1 | ||
|
|
d07604cd8e | ||
|
|
195dceeb94 | ||
|
|
dec1f7bd51 | ||
|
|
3943570b53 | ||
|
|
a5b58a242b | ||
|
|
8dc176f4e6 | ||
|
|
6494dc92e0 | ||
|
|
13fe0905ba | ||
|
|
7cae9ff3c4 | ||
|
|
d9d0c7ad13 | ||
|
|
d60a13d0d3 | ||
|
|
38e2c2522a | ||
|
|
8c46ceea46 | ||
|
|
7d7fa98a44 | ||
|
|
fd2f881a32 | ||
|
|
0f209ac875 | ||
|
|
de7ceb78d7 | ||
|
|
c3325b01f3 | ||
|
|
77b12a46b1 | ||
|
|
73e29da806 | ||
|
|
59dbb4703d | ||
|
|
fe78b81e88 | ||
|
|
eb0605533b | ||
|
|
28f0c9802e | ||
|
|
e1a97b5783 | ||
|
|
4e86da4a7a | ||
|
|
98df3549d2 | ||
|
|
7d256e1766 | ||
|
|
1ec82f9a9f | ||
|
|
1db662da23 | ||
|
|
d62d4787ab | ||
|
|
012f70508a | ||
|
|
40a5a99ca8 | ||
|
|
ece1d80195 | ||
|
|
c0cfcda90e | ||
|
|
4a90452d73 | ||
|
|
858de11740 | ||
|
|
68d5cd7114 | ||
|
|
3528b8ce41 | ||
|
|
b1fb8a4048 | ||
|
|
37d6df3219 | ||
|
|
59b2aed66c | ||
|
|
b9abf293aa | ||
|
|
cb8761a18c | ||
|
|
2525ab8ff8 | ||
|
|
f5dbdcadba | ||
|
|
d361eaf394 | ||
|
|
fe4d3a2e94 | ||
|
|
1b5be38573 | ||
|
|
54909bf033 | ||
|
|
2106e9b21e | ||
|
|
f5a3990fbf | ||
|
|
b1cdc91bf4 | ||
|
|
e7cdaa120a | ||
|
|
58a9cbddca | ||
|
|
3da49d7a02 | ||
|
|
eae9be3b4f | ||
|
|
f21daf7263 | ||
|
|
21a8b3bff1 | ||
|
|
1979dc6dc2 | ||
|
|
b2df8cee34 | ||
|
|
ec1fb9a182 | ||
|
|
cec503f33e | ||
|
|
8a205c4d53 | ||
|
|
56d067048f | ||
|
|
6980c84dfc | ||
|
|
6d72da27f6 | ||
|
|
18e01a3ba2 | ||
|
|
68b16245aa | ||
|
|
5ccb6cd15b | ||
|
|
c994a813ec | ||
|
|
ee18d85a3d | ||
|
|
1c7ff0f82d | ||
|
|
32a43ad647 | ||
|
|
8be0fd72d2 | ||
|
|
b5c00e8040 | ||
|
|
23ae7d42c2 | ||
|
|
46179fd42e | ||
|
|
91491459d5 | ||
|
|
4cce8f8484 | ||
|
|
6e6c114701 | ||
|
|
1aa3ad9441 | ||
|
|
fc67c07589 | ||
|
|
c7227eb3f7 | ||
|
|
66493ad0a4 | ||
|
|
c3f130ec76 | ||
|
|
190aa00a63 | ||
|
|
a4266531f2 | ||
|
|
03ed9279f2 | ||
|
|
ceb22dff50 | ||
|
|
d025af8ef1 | ||
|
|
bb6b34d530 | ||
|
|
b2258c35e7 | ||
|
|
90fed51ab1 | ||
|
|
5de4e1ee74 | ||
|
|
be2f80a4cf | ||
|
|
682d70268a | ||
|
|
12e1d88d71 | ||
|
|
6b1f808b13 | ||
|
|
81336ce7a9 | ||
|
|
47e22facbb | ||
|
|
f2a97a7329 | ||
|
|
1d2b14b01d | ||
|
|
1a9bf76f69 | ||
|
|
fc67e09538 | ||
|
|
9ccd03e15c | ||
|
|
715402eb80 | ||
|
|
18e26a3e4d | ||
|
|
95756af921 | ||
|
|
72e4d61b57 | ||
|
|
a45820d34e | ||
|
|
4c2306d61d | ||
|
|
ff08319e96 | ||
|
|
6604778b58 | ||
|
|
194ddaa041 | ||
|
|
289674508b | ||
|
|
ce25b504c8 | ||
|
|
5a281cec36 | ||
|
|
f2ea985b8d | ||
|
|
33a70e3fa4 | ||
|
|
f5ea4bde3a | ||
|
|
251976843a | ||
|
|
8e17b81335 | ||
|
|
1bcc5ec3f9 | ||
|
|
9ac80b72b7 | ||
|
|
83583196d4 | ||
|
|
1c5f7c2d3c | ||
|
|
a8d3ffaf2e | ||
|
|
e7922700f0 | ||
|
|
b11f2ea604 | ||
|
|
7ad2c3b0bc | ||
|
|
c52976236b | ||
|
|
8ec11feda8 | ||
|
|
f2b07381ab | ||
|
|
983bd56fb5 | ||
|
|
bee546c085 | ||
|
|
3e3ed99fde | ||
|
|
80392718df | ||
|
|
122a7650ad | ||
|
|
c4c5649ce9 | ||
|
|
da7c9d05b3 | ||
|
|
b61cff5625 | ||
|
|
938c04c32e | ||
|
|
37f6d169d7 | ||
|
|
ebcd7beedb | ||
|
|
1e45ee6ed3 | ||
|
|
8bf3aa3982 | ||
|
|
7b98f70c85 | ||
|
|
7c723354eb | ||
|
|
c9675c1ba5 | ||
|
|
56b3fbab8c | ||
|
|
debbbf6585 | ||
|
|
ac4204f395 | ||
|
|
7714b1d37a | ||
|
|
dfcfa1a0db | ||
|
|
b045dddfa1 | ||
|
|
9e701221fd | ||
|
|
5a76ab5f51 | ||
|
|
ef218f6ced | ||
|
|
8e4d76ef04 | ||
|
|
a8f543b1cc | ||
|
|
5f61926401 | ||
|
|
ad8dd2f57e | ||
|
|
98b40e8e5e | ||
|
|
018ca6a76e | ||
|
|
242fba5b5a | ||
|
|
1759cc1565 | ||
|
|
c93c23248a | ||
|
|
fbfa055eff | ||
|
|
fb85eb77e0 | ||
|
|
a3efbe017d | ||
|
|
b610bc7618 | ||
|
|
b082e400c6 | ||
|
|
9a267cf4bf | ||
|
|
b7414749eb | ||
|
|
239b96621a | ||
|
|
ffb455858e | ||
|
|
f19afb61ad | ||
|
|
d9eab08f7f | ||
|
|
c6f61bbcc4 | ||
|
|
08b55819b1 | ||
|
|
d27e19390d | ||
|
|
ad491915a5 | ||
|
|
f356ceda41 | ||
|
|
70865d77e8 | ||
|
|
09b8b5b94b | ||
|
|
d526f569a0 | ||
|
|
957ba89eac | ||
|
|
c4146a25af | ||
|
|
dc064e4bb1 | ||
|
|
c59deaa19b | ||
|
|
c741fd4d39 | ||
|
|
fb6b8c54e2 | ||
|
|
d1a243289c | ||
|
|
eb18b83652 | ||
|
|
ca1c65d957 | ||
|
|
c4889805f3 | ||
|
|
64fc971ebc | ||
|
|
d580bddaec | ||
|
|
b52d83f71e | ||
|
|
c6e83d5f09 | ||
|
|
72c066552e | ||
|
|
3f254035ec | ||
|
|
545b8f9e8d | ||
|
|
8c893c55f0 | ||
|
|
c2e172a8a3 | ||
|
|
9147b0893b | ||
|
|
004628230c | ||
|
|
75cfb4cc28 | ||
|
|
dd60b729ee | ||
|
|
651abefe2b | ||
|
|
8858d0c7e0 | ||
|
|
df589fa039 | ||
|
|
2c8c04518d | ||
|
|
08e01dc3da | ||
|
|
c289d208e6 | ||
|
|
84d6e3d97a | ||
|
|
226d535e77 | ||
|
|
7f0f9337f3 | ||
|
|
7d011d64aa | ||
|
|
4ea481cb2f | ||
|
|
2aeaeb2495 | ||
|
|
782331b499 | ||
|
|
2962f1172d | ||
|
|
30e4e488b2 | ||
|
|
b2095cc1ca | ||
|
|
e2ee8ad7d9 | ||
|
|
c59a4d6ffd | ||
|
|
4256dec408 | ||
|
|
959c3b6180 | ||
|
|
e00864ba59 | ||
|
|
866057db34 | ||
|
|
ee8d4c4a72 | ||
|
|
fb05c1acfc | ||
|
|
8e4e46fdd6 | ||
|
|
9351cd381f | ||
|
|
72e3ec1924 | ||
|
|
a2cdad2a10 | ||
|
|
53e8715d83 | ||
|
|
278dee9871 | ||
|
|
7a8364a0f7 | ||
|
|
c96f20ac6c | ||
|
|
fb6707ec92 | ||
|
|
dfc7fdd786 | ||
|
|
306dca0a21 | ||
|
|
541bc4b519 | ||
|
|
ac68d5ae04 | ||
|
|
76a8f27085 | ||
|
|
0ba2a84306 | ||
|
|
40b2f16229 | ||
|
|
023a57e557 | ||
|
|
6352b7468c | ||
|
|
26d32147ea | ||
|
|
b5c4b2f84b | ||
|
|
0b2ed50a68 | ||
|
|
75ef2801f5 | ||
|
|
a15fb9d167 | ||
|
|
b1499e6662 | ||
|
|
699cf1101f | ||
|
|
386d0560a1 | ||
|
|
f90e164b4b | ||
|
|
49873c3371 | ||
|
|
359388eea7 | ||
|
|
07af125581 | ||
|
|
cf0566a27d | ||
|
|
bdbf33939c | ||
|
|
4d6923514e | ||
|
|
5e5258f410 | ||
|
|
d0314e8f25 | ||
|
|
03c750245c | ||
|
|
d9672a3587 | ||
|
|
add317d016 | ||
|
|
b944c970c3 | ||
|
|
3030aa3652 | ||
|
|
f65b2574e1 | ||
|
|
f80e0cfbf0 | ||
|
|
15e02ce312 | ||
|
|
1c15680e9a | ||
|
|
7cdd21f24d | ||
|
|
27b18c07f0 | ||
|
|
61d8dea2a7 | ||
|
|
c9544070f8 | ||
|
|
361923c262 | ||
|
|
adbf42ffb9 | ||
|
|
a06a1853e2 | ||
|
|
0afcabe0ec | ||
|
|
67e0a30ea8 | ||
|
|
02d449ced2 | ||
|
|
b97a7d0dec | ||
|
|
b6832dc940 | ||
|
|
2e8cc55201 | ||
|
|
7942b6d4c3 | ||
|
|
c7a26889ff | ||
|
|
46b588e72a | ||
|
|
2843049551 | ||
|
|
9e7925e2e9 | ||
|
|
81b07fb2d6 | ||
|
|
d13e35bb8f | ||
|
|
e24554895d | ||
|
|
bd94713fdd | ||
|
|
a86ab9d4bc | ||
|
|
ccf6b2c913 | ||
|
|
c4ec5dfd57 | ||
|
|
fadace66a0 | ||
|
|
e301d92133 | ||
|
|
7c384cd6ae | ||
|
|
e61bf50d60 | ||
|
|
d1190a4dc7 | ||
|
|
7d37a291c2 | ||
|
|
bcb0d01d9e | ||
|
|
6ece267eb9 | ||
|
|
d2bc41cd4e | ||
|
|
61ffa23382 | ||
|
|
fca27e4c63 | ||
|
|
c44aa01ebb | ||
|
|
eb06abefe2 | ||
|
|
f61130508b | ||
|
|
5fcd41ad3c | ||
|
|
c1b56cea13 | ||
|
|
7ab5bfd238 | ||
|
|
e347c6f99f | ||
|
|
f03a2ffe21 | ||
|
|
935faca944 | ||
|
|
fac0918973 | ||
|
|
cfa85f016f | ||
|
|
70ee24e9ef | ||
|
|
615735e12c | ||
|
|
c46fd6f944 | ||
|
|
18bb5b3b1d | ||
|
|
dc3fe491e3 | ||
|
|
d1619f89b3 | ||
|
|
267c3838b6 | ||
|
|
4068a09887 | ||
|
|
dfbdf94740 | ||
|
|
afb34764ec | ||
|
|
fc9eb64042 | ||
|
|
fc9acc4a74 | ||
|
|
79b1df4051 | ||
|
|
31ae839a81 | ||
|
|
226fb118f8 | ||
|
|
4390baae27 | ||
|
|
aa3df3b4f7 | ||
|
|
51fa349fe4 | ||
|
|
479276f66d | ||
|
|
17cdf49055 | ||
|
|
b49f625199 | ||
|
|
034ffdbfef | ||
|
|
c1fad2fb18 | ||
|
|
9b087a4560 | ||
|
|
28fa20f13d | ||
|
|
ac784c6832 | ||
|
|
4d3d579464 | ||
|
|
d46bea7851 | ||
|
|
a25182804a | ||
|
|
dd178e638f | ||
|
|
9cddcfb071 | ||
|
|
09cfb0197c | ||
|
|
4039f0eeb6 | ||
|
|
01731db683 | ||
|
|
7b9fca2a49 | ||
|
|
e8578c2e47 | ||
|
|
8c9c801232 | ||
|
|
1fd78941bd | ||
|
|
28584b59bf | ||
|
|
4b8f61ed0e | ||
|
|
1f7d2adcbe | ||
|
|
1258ec339a | ||
|
|
7cbe3d49b0 | ||
|
|
83122825e5 | ||
|
|
c3af4fadeb | ||
|
|
2f8540be63 | ||
|
|
fbf4ddd296 | ||
|
|
a8c16eb5db | ||
|
|
ce66fd40bd | ||
|
|
22e92450e4 | ||
|
|
ee1dfabaa6 | ||
|
|
5e1eab0e05 | ||
|
|
fc83f106f2 | ||
|
|
7f8e095514 | ||
|
|
91e62194e8 | ||
|
|
d1d35697a1 | ||
|
|
6a9154801f | ||
|
|
9094309407 | ||
|
|
72c06622be | ||
|
|
4888b73428 | ||
|
|
d6fb844de4 | ||
|
|
793565a33f | ||
|
|
4bed23dc4c | ||
|
|
b820331574 | ||
|
|
ca8cc5a43b | ||
|
|
81f336b814 | ||
|
|
18c2782391 | ||
|
|
ac2a95ab1d | ||
|
|
faef0581d1 | ||
|
|
4df6ddc3f3 | ||
|
|
eb150b0297 | ||
|
|
88cc456f65 | ||
|
|
b2d4f65e77 | ||
|
|
d42af058e8 | ||
|
|
b4b9d03ed5 | ||
|
|
9f1fb2c77c | ||
|
|
46fe2c7f3d | ||
|
|
467d045271 | ||
|
|
f23a03f6c7 | ||
|
|
644d598685 | ||
|
|
afd4df1cd9 | ||
|
|
fa7424ca31 | ||
|
|
37249efd34 | ||
|
|
98498da9aa | ||
|
|
a2b63b3e3b | ||
|
|
1f3f7cebae | ||
|
|
316bef4484 | ||
|
|
4cea8c2ee6 | ||
|
|
45eb484b85 | ||
|
|
0694db7dcb | ||
|
|
f3fdefa0ec | ||
|
|
b21058f361 | ||
|
|
0dace8ff46 | ||
|
|
58f80819d9 | ||
|
|
f606efbf36 | ||
|
|
5054ffc2ab | ||
|
|
96a7044c15 | ||
|
|
e8397cd8eb | ||
|
|
182994edc8 | ||
|
|
dd2d560901 | ||
|
|
7837d91790 | ||
|
|
53b7a6768d | ||
|
|
9d025d7e3c | ||
|
|
0a48f1eade | ||
|
|
9f51db9b40 | ||
|
|
301fc5fc05 | ||
|
|
74db568335 | ||
|
|
a03902b64d | ||
|
|
30637c0820 | ||
|
|
b63d7491d2 | ||
|
|
e64e477126 | ||
|
|
dc94adc898 | ||
|
|
f914bcd4a9 | ||
|
|
0a6d0dd257 | ||
|
|
c719e4ac0e | ||
|
|
7049d0e811 | ||
|
|
78e094e614 | ||
|
|
f81f6de46b | ||
|
|
f64da572af | ||
|
|
fe0a4d80bf | ||
|
|
ff42a99d98 | ||
|
|
aced4bb6b9 | ||
|
|
97a323b499 | ||
|
|
227333eacd | ||
|
|
9367ed7710 | ||
|
|
e15589bbbb | ||
|
|
84fe5b4f12 | ||
|
|
7ee566f6b7 | ||
|
|
3774ca6edd | ||
|
|
c73321876c | ||
|
|
19d083fa26 | ||
|
|
8d35e7fc0a | ||
|
|
46f0546de6 | ||
|
|
7c173c30b1 | ||
|
|
715a969071 | ||
|
|
5097c53509 | ||
|
|
a89477a4b9 | ||
|
|
430b6dc4c5 | ||
|
|
e591b36a8d | ||
|
|
701eaa6f9c | ||
|
|
018a51c27f | ||
|
|
4db980e5de | ||
|
|
d40ab7df1a | ||
|
|
54578041bc | ||
|
|
adc454cfc9 | ||
|
|
2cdecbf069 | ||
|
|
945c667281 | ||
|
|
bdec76846e | ||
|
|
93ebb80b92 | ||
|
|
8d7267f7f7 | ||
|
|
345041401e | ||
|
|
72dff2d5b7 | ||
|
|
194a86eff3 | ||
|
|
0eb2196f99 | ||
|
|
18c03d23d8 | ||
|
|
dceee849cb | ||
|
|
10b318cd6b | ||
|
|
79cfecbdb9 | ||
|
|
298d3f12e9 | ||
|
|
73a745339e | ||
|
|
70287510ab | ||
|
|
26afbb999c | ||
|
|
d906242b0e | ||
|
|
3089267e7c | ||
|
|
dbf99fe456 | ||
|
|
7842fca4da | ||
|
|
895726d381 | ||
|
|
ef01959fb1 | ||
|
|
ec2cd929ab | ||
|
|
6f79c55c2a | ||
|
|
529cd743a0 | ||
|
|
3877ae52d8 | ||
|
|
8e46f3971d | ||
|
|
1f96d244f9 | ||
|
|
ebe1a5d52b | ||
|
|
27979c816b | ||
|
|
61a0362859 | ||
|
|
166073d4bc | ||
|
|
92742bef1b | ||
|
|
bb29b70b32 | ||
|
|
3ad8a18a7b | ||
|
|
e632dda2b7 | ||
|
|
d22f313e9e | ||
|
|
16237bb2b7 | ||
|
|
0a11f464a3 | ||
|
|
71e2401edc | ||
|
|
e957a4bd85 | ||
|
|
6c5f62af35 | ||
|
|
d029364bd4 | ||
|
|
89f90f0b65 | ||
|
|
f965ae85f3 | ||
|
|
983d9a3235 | ||
|
|
87c473666a | ||
|
|
1c9f8a4359 | ||
|
|
d0850614ba | ||
|
|
f4d10e7099 | ||
|
|
1b029a08f6 | ||
|
|
8a5211f260 | ||
|
|
0831968204 | ||
|
|
1e8745416d | ||
|
|
9fc9722501 | ||
|
|
adfd8e43c8 | ||
|
|
f7267efa1b | ||
|
|
aac293db9b | ||
|
|
a328e9c5db | ||
|
|
4272268cbc | ||
|
|
d288db6b6e | ||
|
|
3ef0ebf52f | ||
|
|
35fc116bd3 | ||
|
|
ede2ada183 | ||
|
|
cc100b4470 | ||
|
|
2a95475e8f | ||
|
|
47894e831f | ||
|
|
5e735bd030 | ||
|
|
da4591bc88 | ||
|
|
dbcbe4fe87 | ||
|
|
8423eabc57 | ||
|
|
fd46aa03c5 | ||
|
|
87f2c1314e | ||
|
|
4c2a2c42d2 | ||
|
|
3d11f0b6c6 | ||
|
|
db571535c2 | ||
|
|
7d9d35c729 | ||
|
|
6354608451 | ||
|
|
1fc4ccd439 | ||
|
|
ce9fdee6d4 | ||
|
|
8d1b4c8cca | ||
|
|
fee41659bd | ||
|
|
4c246e24f2 | ||
|
|
b0154fc77d | ||
|
|
2dbff9aefe | ||
|
|
dee8aecd7a | ||
|
|
3a0a7390bf | ||
|
|
e434d37311 | ||
|
|
544600c10a | ||
|
|
75e0f7387b | ||
|
|
285846c5ee | ||
|
|
9df9c5bde2 | ||
|
|
28561fd634 | ||
|
|
f3613a539d | ||
|
|
04a9f56511 | ||
|
|
5ea2d3c0fb | ||
|
|
be3db9da27 | ||
|
|
0d6d1f1f66 | ||
|
|
bc4559b024 | ||
|
|
b4733ffec0 | ||
|
|
1b8e661146 | ||
|
|
95b6b28fcb | ||
|
|
993525d333 | ||
|
|
3bd70dbc06 | ||
|
|
0b4c261d0c | ||
|
|
caac772100 | ||
|
|
1d7e15c969 | ||
|
|
68685775d9 | ||
|
|
e87894a81d | ||
|
|
35d8ee793f | ||
|
|
78caa5b352 | ||
|
|
3597945405 | ||
|
|
87623d9714 | ||
|
|
71a09a89c0 | ||
|
|
18c344ff3c | ||
|
|
1b28cc6feb | ||
|
|
af837708aa | ||
|
|
ba04e6991d | ||
|
|
56cf9c3301 | ||
|
|
05925da3cd | ||
|
|
a072c2275f | ||
|
|
4e27118c3b | ||
|
|
415797f30a | ||
|
|
c283f0ce85 | ||
|
|
5bc42b54cf | ||
|
|
980844073d | ||
|
|
780a965ae2 | ||
|
|
7b7afe9a8f | ||
|
|
dc191e58df | ||
|
|
9f08382773 | ||
|
|
b787473097 | ||
|
|
89feab9f8f | ||
|
|
dd5fd7e80d | ||
|
|
e715b5b743 | ||
|
|
4dbc406616 | ||
|
|
c5a342f764 | ||
|
|
6efe76a432 | ||
|
|
958af813dd | ||
|
|
da474503d8 | ||
|
|
50386e55df | ||
|
|
fc1972ffe7 | ||
|
|
05b2a7afee | ||
|
|
4e71e99f98 | ||
|
|
8fc4c27855 | ||
|
|
3ff75a8ecb | ||
|
|
2f07a64084 | ||
|
|
35ddec5e9e | ||
|
|
9102edfb56 | ||
|
|
be4be0ce6b | ||
|
|
d7b9e267e0 | ||
|
|
769cdcd232 | ||
|
|
3d9ef21f68 | ||
|
|
6634e4ba98 | ||
|
|
ad01e692c9 | ||
|
|
1547313299 | ||
|
|
9874954fd0 | ||
|
|
2e68167379 | ||
|
|
14f833917a | ||
|
|
9ca66bdb02 | ||
|
|
0da566c2ed | ||
|
|
8883d77504 | ||
|
|
838d140cbc | ||
|
|
d2825d34f8 | ||
|
|
e6f80a4ee4 | ||
|
|
1d4d4feaf5 | ||
|
|
ff458a358d | ||
|
|
c61ca75ca4 | ||
|
|
cc52c95887 | ||
|
|
e0f6c4f5e8 | ||
|
|
91813c5ae0 | ||
|
|
a8a0d2cc25 | ||
|
|
8e88e778a6 | ||
|
|
7b5cf13614 | ||
|
|
1514d3d16a | ||
|
|
3e093c2c6f | ||
|
|
e9db26a19a | ||
|
|
267d39c511 | ||
|
|
3b3de54fa3 | ||
|
|
a1cf28d100 | ||
|
|
517546d1af | ||
|
|
cb7a5c204e | ||
|
|
7833ed6c05 | ||
|
|
8b7cc25b66 | ||
|
|
0689bc6505 | ||
|
|
e9e7050f32 | ||
|
|
afdac528fc | ||
|
|
875c7d7a2b | ||
|
|
9a2931de68 | ||
|
|
52128e9f79 | ||
|
|
e635a9b0b4 | ||
|
|
0d21d07827 | ||
|
|
b91b5516f3 | ||
|
|
be1de92536 | ||
|
|
b3be633edf | ||
|
|
ae1555ab94 | ||
|
|
ee3a790501 | ||
|
|
6ca76d9e9c | ||
|
|
c58a3eef6f | ||
|
|
ca57097ae8 | ||
|
|
bb6e18b94d | ||
|
|
b28069d08e | ||
|
|
2f4a142e8b | ||
|
|
83df38c895 | ||
|
|
507006a08d | ||
|
|
b98cb1a611 | ||
|
|
d70f2fdb20 | ||
|
|
b7eb967776 | ||
|
|
c74851a64b | ||
|
|
6b6a9577c7 | ||
|
|
316f9680d5 | ||
|
|
d8a86b2454 | ||
|
|
d4cfdeab5c | ||
|
|
5a60e2252b |
No files matched your search
@@ -0,0 +1,4 @@
|
||||
# `cargo xtask …` runs the repository's task crate, so the commands in docs/perf-platform.md and
|
||||
# web/perf/README.md work as written. `cargo run -p xtask -- …` stays equivalent.
|
||||
[alias]
|
||||
xtask = "run --package xtask --"
|
||||
@@ -0,0 +1,56 @@
|
||||
name: perf-wasm
|
||||
# The site consumes this reader bundle from horizon-rust/horizon-site.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'crates/horizon-perf/**'
|
||||
- 'crates/horizon-perf-format/**'
|
||||
- 'crates/horizon-bench/**'
|
||||
- 'xtask/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- '.github/workflows/perf-wasm.yml'
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- 'crates/horizon-perf/**'
|
||||
- 'crates/horizon-perf-format/**'
|
||||
- 'crates/horizon-bench/**'
|
||||
- 'xtask/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- '.github/workflows/perf-wasm.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: perf-wasm-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
HORIZON_TELEMETRY_URL: "off"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: perf-wasm
|
||||
# The agreement test: the C ABI, called natively, must print the bytes the CLI prints.
|
||||
- name: The wasm ABI agrees with the CLI
|
||||
run: cargo test -p horizon-perf --features wasm --test wasm
|
||||
- name: Build the wasm the page loads
|
||||
run: cargo run -p xtask -- perf-wasm
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: perf-wasm
|
||||
path: target/perf-wasm
|
||||
if-no-files-found: error
|
||||
@@ -102,6 +102,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `horizon-shader-api` | Horizon's own WGSL shader packs ([docs/api/shaders.md](docs/api/shaders.md)): `horizon-shaders.toml`, the `.hshader` package; shared by the game, the launcher and the registry |
|
||||
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
|
||||
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
|
||||
| `horizon-perf-format` | The `.hzperf` capture contract ([docs/perf-platform.md](docs/perf-platform.md) §3): `proto/horizon/perf/v1/capture.proto`, its generated types, the gzip writer and its thread. No Horizon dependencies; nothing in the game reads a capture |
|
||||
| `horizon-telemetry`, `horizon-telemetry-server`, `horizon-bench` | Opt-in data collection, its ingest server, the benchmark report format |
|
||||
| `horizon-system` | The machine the game runs on (OS, CPU, memory, drive, power, GPU maker and memory), for the crash report, the renderer's GPU info and telemetry |
|
||||
| `horizon-platform` | The platform's public API: what the Horizon layer may use of the engine ([docs/layers.md](docs/layers.md)); re-exports `horizon-system` today |
|
||||
@@ -213,7 +214,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
| 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)) |
|
||||
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
|
||||
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
|
||||
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs` + `extras/` (each extra's glue: `mouse_tweaks.rs`, `sorting.rs`, `pvp.rs`, `reconnect.rs`), `options.rs` |
|
||||
|
||||
## How `Game` is organised
|
||||
|
||||
|
||||
Generated
+343
-4
@@ -18,6 +18,15 @@ version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
|
||||
|
||||
[[package]]
|
||||
name = "addr2line"
|
||||
version = "0.26.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a"
|
||||
dependencies = [
|
||||
"gimli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
@@ -129,6 +138,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "arboard"
|
||||
version = "3.7.0"
|
||||
@@ -264,6 +279,12 @@ version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "beef"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.10.0"
|
||||
@@ -559,7 +580,7 @@ checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"termcolor",
|
||||
"unicode-width",
|
||||
"unicode-width 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1011,6 +1032,12 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
@@ -1109,6 +1136,18 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "framehop"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f54fe4785e899d4d6f43793b151c63c5647240fc630b005509d2614a939f693"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"cfg-if",
|
||||
"fallible-iterator",
|
||||
"gimli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs4"
|
||||
version = "1.1.0"
|
||||
@@ -1246,6 +1285,15 @@ dependencies = [
|
||||
"windows 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gimli"
|
||||
version = "0.33.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gl_generator"
|
||||
version = "0.14.0"
|
||||
@@ -1426,7 +1474,7 @@ dependencies = [
|
||||
name = "horizon-bench"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-telemetry",
|
||||
"horizon-system",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
@@ -1482,10 +1530,13 @@ dependencies = [
|
||||
"horizon-mods",
|
||||
"horizon-narrator",
|
||||
"horizon-optifine",
|
||||
"horizon-perf",
|
||||
"horizon-perf-format",
|
||||
"horizon-protocol",
|
||||
"horizon-server-files",
|
||||
"horizon-shader-api",
|
||||
"horizon-shaders",
|
||||
"horizon-system",
|
||||
"horizon-telemetry",
|
||||
"horizon-theme",
|
||||
"horizon-threads",
|
||||
@@ -1560,6 +1611,8 @@ dependencies = [
|
||||
"horizon-mods",
|
||||
"horizon-narrator",
|
||||
"horizon-optifine",
|
||||
"horizon-perf",
|
||||
"horizon-perf-format",
|
||||
"horizon-protocol",
|
||||
"horizon-server-files",
|
||||
"horizon-shader-api",
|
||||
@@ -1614,6 +1667,8 @@ dependencies = [
|
||||
"horizon-items",
|
||||
"horizon-narrator",
|
||||
"horizon-optifine",
|
||||
"horizon-perf",
|
||||
"horizon-perf-format",
|
||||
"horizon-polytone",
|
||||
"horizon-protocol",
|
||||
"horizon-shader-api",
|
||||
@@ -1831,6 +1886,7 @@ dependencies = [
|
||||
"horizon-game",
|
||||
"horizon-heartbeat",
|
||||
"horizon-official-servers",
|
||||
"horizon-perf-share",
|
||||
"horizon-platform",
|
||||
"log",
|
||||
"winresource",
|
||||
@@ -1849,18 +1905,60 @@ dependencies = [
|
||||
name = "horizon-optifine"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-bench",
|
||||
"horizon-core",
|
||||
"horizon-system",
|
||||
"log",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-perf"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"horizon-bench",
|
||||
"horizon-perf-format",
|
||||
"horizon-perf-share",
|
||||
"prost",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-perf-format"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"prost",
|
||||
"prost-build",
|
||||
"prost-types",
|
||||
"protox",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-perf-share"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"horizon-addresses",
|
||||
"horizon-platform",
|
||||
"log",
|
||||
"open",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"ureq",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-platform"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-bench",
|
||||
"horizon-core",
|
||||
"horizon-hooks",
|
||||
"horizon-perf-format",
|
||||
"horizon-prepare",
|
||||
"horizon-protocol",
|
||||
"horizon-server-files",
|
||||
@@ -1945,7 +2043,6 @@ dependencies = [
|
||||
"chrono",
|
||||
"getrandom 0.3.4",
|
||||
"horizon-anticheat",
|
||||
"horizon-bench",
|
||||
"horizon-chat-filter",
|
||||
"horizon-mcp",
|
||||
"horizon-platform",
|
||||
@@ -2029,9 +2126,14 @@ version = "0.1.0"
|
||||
name = "horizon-threads"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"addr2line",
|
||||
"framehop",
|
||||
"libc",
|
||||
"libmimalloc-sys",
|
||||
"memmap2",
|
||||
"mimalloc",
|
||||
"object",
|
||||
"rustc-demangle",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -2041,6 +2143,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"horizon-core",
|
||||
"horizon-threads",
|
||||
"libm",
|
||||
"log",
|
||||
"lz4_flex",
|
||||
@@ -2282,6 +2385,15 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
@@ -2508,6 +2620,72 @@ version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "logos"
|
||||
version = "0.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff472f899b4ec2d99161c51f60ff7075eeb3097069a36050d8037a6325eb8154"
|
||||
dependencies = [
|
||||
"logos-derive 0.15.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "logos"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb2c55a318a87600ea870ff8c2012148b44bf18b74fad48d0f835c38c7d07c5f"
|
||||
dependencies = [
|
||||
"logos-derive 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "logos-codegen"
|
||||
version = "0.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "192a3a2b90b0c05b27a0b2c43eecdb7c415e29243acc3f89cc8247a5b693045c"
|
||||
dependencies = [
|
||||
"beef",
|
||||
"fnv",
|
||||
"lazy_static",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex-syntax",
|
||||
"rustc_version",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "logos-codegen"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "58b3ffaa284e1350d017a57d04ada118c4583cf260c8fb01e0fe28a2e9cf8970"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "logos-derive"
|
||||
version = "0.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "605d9697bcd5ef3a42d38efc51541aa3d6a4a25f7ab6d1ed0da5ac632a26b470"
|
||||
dependencies = [
|
||||
"logos-codegen 0.15.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "logos-derive"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52d3a9855747c17eaf4383823f135220716ab49bea5fbea7dd42cc9a92f8aa31"
|
||||
dependencies = [
|
||||
"logos-codegen 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.18.5"
|
||||
@@ -2550,6 +2728,28 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miette"
|
||||
version = "7.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"miette-derive",
|
||||
"unicode-width 0.1.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miette-derive"
|
||||
version = "7.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mimalloc"
|
||||
version = "0.1.52"
|
||||
@@ -2603,6 +2803,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multimap"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
|
||||
|
||||
[[package]]
|
||||
name = "multiversion_no_op"
|
||||
version = "1.0.0"
|
||||
@@ -3183,6 +3389,15 @@ dependencies = [
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "object"
|
||||
version = "0.39.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oid-registry"
|
||||
version = "0.8.1"
|
||||
@@ -3514,6 +3729,16 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa"
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "3.5.0"
|
||||
@@ -3538,6 +3763,96 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
|
||||
|
||||
[[package]]
|
||||
name = "prost"
|
||||
version = "0.14.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"prost-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost-build"
|
||||
version = "0.14.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"itertools",
|
||||
"log",
|
||||
"multimap",
|
||||
"petgraph",
|
||||
"prettyplease",
|
||||
"prost",
|
||||
"prost-types",
|
||||
"regex",
|
||||
"syn 2.0.119",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost-derive"
|
||||
version = "0.14.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost-reflect"
|
||||
version = "0.16.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01b80ea363c31af2de2b92e3c07ed1156628f7838c4afb4df75ee78a37fedbd1"
|
||||
dependencies = [
|
||||
"logos 0.16.1",
|
||||
"miette",
|
||||
"prost",
|
||||
"prost-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost-types"
|
||||
version = "0.14.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a"
|
||||
dependencies = [
|
||||
"prost",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "protox"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f25a07a73c6717f0b9bbbd685918f5df9815f7efba450b83d9c9dea41f0e3a1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"miette",
|
||||
"prost",
|
||||
"prost-reflect",
|
||||
"prost-types",
|
||||
"protox-parse",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "protox-parse"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "072eee358134396a4643dff81cfff1c255c9fbd3fb296be14bdb6a26f9156366"
|
||||
dependencies = [
|
||||
"logos 0.15.1",
|
||||
"miette",
|
||||
"prost-types",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
@@ -3837,6 +4152,12 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-demangle"
|
||||
version = "0.1.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
@@ -4395,6 +4716,18 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termcolor"
|
||||
version = "1.4.1"
|
||||
@@ -4664,6 +4997,12 @@ version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.2"
|
||||
|
||||
@@ -37,6 +37,9 @@ members = [
|
||||
"crates/horizon-discord",
|
||||
"crates/horizon-official-servers",
|
||||
"crates/horizon-bench",
|
||||
"crates/horizon-perf-format",
|
||||
"crates/horizon-perf",
|
||||
"crates/horizon-perf-share",
|
||||
"crates/horizon-friends",
|
||||
"crates/horizon-relay",
|
||||
"crates/horizon-mods",
|
||||
|
||||
@@ -62,6 +62,8 @@ The GLSL frontend also resets the common type for each comma-separated declarato
|
||||
variable's array specifier does not change later variables.
|
||||
It also reads a struct member outside a constant expression through a pointer to the member,
|
||||
not out of a loaded copy of the whole struct, so a member an atomic function uses can be read.
|
||||
The shared interpolation defaults also compile with `glsl-in` alone, without needing the
|
||||
SPIR-V or WGSL frontend to enable them.
|
||||
|
||||
## DirectX Shader Compiler
|
||||
|
||||
|
||||
@@ -35,6 +35,12 @@ pub const COSMETICS_API: &str = "https://cosmetics.playhorizon.dev";
|
||||
/// "I'm playing" ping, benchmark uploads (`HORIZON_TELEMETRY_URL` overrides it).
|
||||
pub const TELEMETRY: &str = "https://telemetry.playhorizon.dev";
|
||||
|
||||
/// The performance viewer page a saved capture opens in (`HORIZON_PERF_VIEWER` overrides it).
|
||||
pub const PERF_VIEWER: &str = "https://playhorizon.dev/perf";
|
||||
|
||||
/// The capture upload API behind Share (`HORIZON_PERF_CAPTURES` overrides it).
|
||||
pub const PERF_CAPTURES: &str = "https://playhorizon.dev/api/perf/captures";
|
||||
|
||||
/// Public STUN servers for Play with Friends' NAT discovery: two independent operators, so one
|
||||
/// outage doesn't break discovery (`HORIZON_STUN_SERVERS` overrides them).
|
||||
pub const STUN_SERVERS: &[&str] = &["stun.cloudflare.com:3478", "stun.l.google.com:19302"];
|
||||
|
||||
@@ -206,7 +206,7 @@ impl Config {
|
||||
Ok(file.anticheat)
|
||||
}
|
||||
|
||||
/// Thresholds can't be negative.
|
||||
/// Thresholds can't be negative, and neither can the timer's milliseconds.
|
||||
pub fn check(&self) -> Result<(), String> {
|
||||
for &c in Check::ALL {
|
||||
let a = self.actions(c);
|
||||
@@ -220,6 +220,18 @@ impl Config {
|
||||
));
|
||||
}
|
||||
}
|
||||
// A negative tolerance flags a client on time every tick, and so does a cap below minus the
|
||||
// tolerance (the balance rests at the cap); NaN never flags, or never caps the balance. No
|
||||
// negative number of milliseconds means anything, so all are refused.
|
||||
if [self.timer.tolerance_ms, self.timer.max_lag_ms]
|
||||
.iter()
|
||||
.any(|v| !v.is_finite() || *v < 0.0)
|
||||
{
|
||||
return Err(
|
||||
"anticheat.timer: tolerance_ms and max_lag_ms are numbers of milliseconds from 0 up"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let tolerance = self.prediction.tolerance;
|
||||
if !tolerance.is_finite() || tolerance <= 0.0 {
|
||||
return Err("anticheat.prediction: tolerance is a number of blocks above 0".into());
|
||||
@@ -361,6 +373,26 @@ mod tests {
|
||||
assert!(Config::from_server_toml("[anticheat.timer]\nkick = -1\n").is_err());
|
||||
}
|
||||
|
||||
/// A negative `tolerance_ms` flags a client on time every tick, and so does a `max_lag_ms` below
|
||||
/// `-tolerance_ms` (the balance rests at the cap); a NaN turns the check (or its cap) off without a
|
||||
/// word. Every negative or non-finite value is refused, as a negative threshold is.
|
||||
#[test]
|
||||
fn refuses_timer_settings_that_are_not_milliseconds_from_0_up() {
|
||||
for bad in [
|
||||
"tolerance_ms = -1",
|
||||
"tolerance_ms = nan",
|
||||
"tolerance_ms = inf",
|
||||
"max_lag_ms = -1",
|
||||
"max_lag_ms = nan",
|
||||
] {
|
||||
let text = format!("[anticheat.timer]\n{bad}\n");
|
||||
let e = Config::from_server_toml(&text).expect_err(bad);
|
||||
assert!(e.starts_with("anticheat.timer:"), "{bad}: {e}");
|
||||
}
|
||||
let edge = "[anticheat.timer]\ntolerance_ms = 0\nmax_lag_ms = 0\n";
|
||||
assert!(Config::from_server_toml(edge).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_rise_decay_and_act_in_order() {
|
||||
let mut c = on();
|
||||
|
||||
@@ -45,6 +45,11 @@ mod tests {
|
||||
|
||||
/// `seconds` of server ticks of `server_ms` each, with `per_tick` client ticks handled in each.
|
||||
fn run(b: &mut Balance, server_ms: f64, per_tick: f64, seconds: f64) -> usize {
|
||||
run_at(b, 20.0, server_ms, per_tick, seconds)
|
||||
}
|
||||
|
||||
/// [`run`] at the tick rate `rate` (`/tick rate`), which the client's ticks follow.
|
||||
fn run_at(b: &mut Balance, rate: f32, server_ms: f64, per_tick: f64, seconds: f64) -> usize {
|
||||
let c = TimerConfig::default();
|
||||
let mut owed = 0.0;
|
||||
let mut flags = 0;
|
||||
@@ -53,12 +58,31 @@ mod tests {
|
||||
owed += per_tick;
|
||||
while owed >= 1.0 {
|
||||
owed -= 1.0;
|
||||
flags += usize::from(b.tick_end(20.0, &c).is_some());
|
||||
flags += usize::from(b.tick_end(rate, &c).is_some());
|
||||
}
|
||||
}
|
||||
flags
|
||||
}
|
||||
|
||||
/// At another tick rate a vanilla client ticks at the rate the server sent it, and each of its
|
||||
/// ticks is worth `1000 / rate` ms: never flagged on time, flagged again and again 1.5 times as fast.
|
||||
#[test]
|
||||
fn the_balance_follows_the_tick_rate() {
|
||||
for rate in [10.0_f32, 60.0] {
|
||||
let tick_ms = 1000.0 / f64::from(rate);
|
||||
assert_eq!(
|
||||
run_at(&mut Balance::default(), rate, tick_ms, 1.0, 600.0),
|
||||
0,
|
||||
"on time at {rate}"
|
||||
);
|
||||
// Half a tick ahead every server tick: 75 flags in a minute at 10 a second, 94 at 60.
|
||||
let flags = run_at(&mut Balance::default(), rate, tick_ms, 1.5, 60.0);
|
||||
assert!(flags >= 50, "1.5 times as fast at {rate}: {flags} flags");
|
||||
}
|
||||
// The rate counts: at 10 a second, a client sending 20 ticks a second is twice as fast.
|
||||
assert!(run_at(&mut Balance::default(), 10.0, 100.0, 2.0, 60.0) >= 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vanilla_client_is_never_flagged() {
|
||||
let mut b = Balance::default();
|
||||
|
||||
@@ -1764,14 +1764,13 @@ fn stack_from_template(template: &Value) -> Option<StackView> {
|
||||
|
||||
/// `RangeSelectItemModel.lastIndexLessOrEqual`.
|
||||
fn last_index_less_or_equal(entries: &[(f32, ItemModelNode)], needle: f32) -> Option<usize> {
|
||||
let haystack: Vec<f32> = entries.iter().map(|e| e.0).collect();
|
||||
let found = if haystack.len() < 16 {
|
||||
haystack
|
||||
let found = if entries.len() < 16 {
|
||||
entries
|
||||
.iter()
|
||||
.position(|h| *h > needle)
|
||||
.map_or(haystack.len() as i64 - 1, |i| i as i64 - 1)
|
||||
.position(|e| e.0 > needle)
|
||||
.map_or(entries.len() as i64 - 1, |i| i as i64 - 1)
|
||||
} else {
|
||||
match binary_search(&haystack, needle) {
|
||||
match binary_search(entries, needle) {
|
||||
Ok(index) => index as i64,
|
||||
Err(insertion) => insertion as i64 - 1,
|
||||
}
|
||||
@@ -1780,8 +1779,8 @@ fn last_index_less_or_equal(entries: &[(f32, ItemModelNode)], needle: f32) -> Op
|
||||
}
|
||||
/// `Arrays.binarySearch(float[], float)`, including its handling of equal
|
||||
/// values (-0.0 below 0.0, NaN above everything).
|
||||
fn binary_search(a: &[f32], key: f32) -> std::result::Result<usize, usize> {
|
||||
let (mut low, mut high) = (0i64, a.len() as i64 - 1);
|
||||
fn binary_search(entries: &[(f32, ItemModelNode)], key: f32) -> std::result::Result<usize, usize> {
|
||||
let (mut low, mut high) = (0i64, entries.len() as i64 - 1);
|
||||
let bits = |v: f32| -> i32 {
|
||||
if v.is_nan() {
|
||||
0x7fc0_0000
|
||||
@@ -1791,7 +1790,7 @@ fn binary_search(a: &[f32], key: f32) -> std::result::Result<usize, usize> {
|
||||
};
|
||||
while low <= high {
|
||||
let mid = (low + high) >> 1;
|
||||
let value = a[mid as usize];
|
||||
let value = entries[mid as usize].0;
|
||||
if value < key {
|
||||
low = mid + 1;
|
||||
} else if value > key {
|
||||
|
||||
@@ -158,3 +158,61 @@ fn a_patched_stack_reads_its_patch_over_its_items_defaults() {
|
||||
}
|
||||
assert_eq!(view.to_map(), *merged);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_index_less_or_equal_linear_and_binary() {
|
||||
use super::last_index_less_or_equal;
|
||||
let make = |thresholds: &[f32]| -> Vec<(f32, ItemModelNode)> {
|
||||
thresholds
|
||||
.iter()
|
||||
.map(|&t| (t, ItemModelNode::Empty))
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Empty
|
||||
assert_eq!(last_index_less_or_equal(&[], 0.0), None);
|
||||
|
||||
// Short (< 16): linear search
|
||||
let short = make(&[-1.0, 0.0, 1.0, 2.5, 5.0]);
|
||||
assert_eq!(last_index_less_or_equal(&short, -2.0), None);
|
||||
assert_eq!(last_index_less_or_equal(&short, -1.0), Some(0));
|
||||
assert_eq!(last_index_less_or_equal(&short, -0.5), Some(0));
|
||||
assert_eq!(last_index_less_or_equal(&short, 0.0), Some(1));
|
||||
assert_eq!(last_index_less_or_equal(&short, 2.5), Some(3));
|
||||
assert_eq!(last_index_less_or_equal(&short, 3.0), Some(3));
|
||||
assert_eq!(last_index_less_or_equal(&short, 5.0), Some(4));
|
||||
assert_eq!(last_index_less_or_equal(&short, 10.0), Some(4));
|
||||
|
||||
// Long (>= 16): binary search
|
||||
let thresholds: Vec<f32> = (0..32).map(|i| i as f32 * 10.0).collect();
|
||||
let long = make(&thresholds);
|
||||
assert_eq!(last_index_less_or_equal(&long, -5.0), None);
|
||||
assert_eq!(last_index_less_or_equal(&long, 0.0), Some(0));
|
||||
assert_eq!(last_index_less_or_equal(&long, 5.0), Some(0));
|
||||
assert_eq!(last_index_less_or_equal(&long, 10.0), Some(1));
|
||||
assert_eq!(last_index_less_or_equal(&long, 150.0), Some(15));
|
||||
assert_eq!(last_index_less_or_equal(&long, 155.0), Some(15));
|
||||
assert_eq!(last_index_less_or_equal(&long, 310.0), Some(31));
|
||||
assert_eq!(last_index_less_or_equal(&long, 1000.0), Some(31));
|
||||
|
||||
// Short lists use upper-bound tie behavior, including signed zero.
|
||||
let duplicates = make(&[1.0, 1.0, 2.0]);
|
||||
assert_eq!(last_index_less_or_equal(&duplicates, 1.0), Some(1));
|
||||
|
||||
let zeroes = make(&[-0.0, 0.0]);
|
||||
assert_eq!(last_index_less_or_equal(&zeroes, -0.0), Some(1));
|
||||
assert_eq!(last_index_less_or_equal(&zeroes, 0.0), Some(1));
|
||||
|
||||
// At 16 entries, vanilla returns Arrays.binarySearch's hit, not the last tie.
|
||||
// Expected indices were checked on the JVM.
|
||||
let duplicates = make(&[
|
||||
0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 7.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0,
|
||||
]);
|
||||
assert_eq!(last_index_less_or_equal(&duplicates, 7.0), Some(7));
|
||||
|
||||
let zeroes = make(&[
|
||||
-8.0, -7.0, -6.0, -5.0, -4.0, -3.0, -2.0, -0.0, -0.0, 0.0, 0.0, 1.0, 2.0, 3.0, 4.0, 5.0,
|
||||
]);
|
||||
assert_eq!(last_index_less_or_equal(&zeroes, -0.0), Some(7));
|
||||
assert_eq!(last_index_less_or_equal(&zeroes, 0.0), Some(9));
|
||||
}
|
||||
@@ -9,5 +9,7 @@ description = "The benchmark report format (docs/benchmark-report.md), shared by
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# The hardware probes shared with telemetry (cores, clocks, drive, VRAM, power, form).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
|
||||
# The hardware probes (cores, clocks, drive, VRAM, power, form); a reader of reports (the wasm
|
||||
# build of horizon-perf) needs the types below, never the probes.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
horizon-system = { path = "../horizon-system" }
|
||||
@@ -0,0 +1,434 @@
|
||||
//! Paired A/B comparison of benchmark blocks (docs/benchmark-report.md, "A/B runs").
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{round, Frames};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Verdict {
|
||||
TooFewPairs,
|
||||
WithinNoise,
|
||||
Negligible,
|
||||
Slower,
|
||||
Faster,
|
||||
}
|
||||
|
||||
/// B against A over paired blocks; lower milliseconds are better.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Paired {
|
||||
pub pairs: usize,
|
||||
pub a: f64,
|
||||
pub b: f64,
|
||||
/// Hodges-Lehmann estimate of b - a.
|
||||
pub diff: f64,
|
||||
/// Absent when `a` is zero.
|
||||
pub diff_pct: Option<f64>,
|
||||
/// Exact Wilcoxon signed-rank interval; absent below 6 pairs.
|
||||
pub ci95: Option<[f64; 2]>,
|
||||
pub confidence: Option<f64>,
|
||||
/// Median distance between neighbouring blocks of the same arm.
|
||||
pub noise: Option<f64>,
|
||||
pub verdict: Verdict,
|
||||
}
|
||||
|
||||
type Getter = fn(&Frames) -> Option<f64>;
|
||||
|
||||
fn median(values: &[f64]) -> f64 {
|
||||
let mut v = values.to_vec();
|
||||
v.sort_by(f64::total_cmp);
|
||||
match v.len() {
|
||||
0 => 0.0,
|
||||
n if n % 2 == 1 => v[n / 2],
|
||||
n => (v[n / 2 - 1] + v[n / 2]) / 2.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn round4(v: f64) -> f64 {
|
||||
(v * 10_000.0).round() / 10_000.0
|
||||
}
|
||||
|
||||
/// `(a, b)` per pair, `noise` left for the caller.
|
||||
pub fn paired(pairs: &[(f64, f64)]) -> Paired {
|
||||
let n = pairs.len();
|
||||
let a = median(&pairs.iter().map(|p| p.0).collect::<Vec<_>>());
|
||||
let b = median(&pairs.iter().map(|p| p.1).collect::<Vec<_>>());
|
||||
let d: Vec<f64> = pairs.iter().map(|p| p.1 - p.0).collect();
|
||||
let mut walsh = Vec::with_capacity(n * (n + 1) / 2);
|
||||
for i in 0..n {
|
||||
for j in i..n {
|
||||
walsh.push((d[i] + d[j]) / 2.0);
|
||||
}
|
||||
}
|
||||
walsh.sort_by(f64::total_cmp);
|
||||
let diff = median(&walsh);
|
||||
let interval = interval(n, &walsh);
|
||||
let verdict = match interval {
|
||||
None => Verdict::TooFewPairs,
|
||||
Some(([lo, hi], _)) if lo <= 0.0 && 0.0 <= hi => Verdict::WithinNoise,
|
||||
Some(_) if diff.abs() < 0.05_f64.max(0.02 * a) => Verdict::Negligible,
|
||||
Some(([lo, _], _)) if lo > 0.0 => Verdict::Slower,
|
||||
Some(_) => Verdict::Faster,
|
||||
};
|
||||
Paired {
|
||||
pairs: n,
|
||||
a: round(a) + 0.0,
|
||||
b: round(b) + 0.0,
|
||||
diff: round(diff) + 0.0,
|
||||
diff_pct: (a != 0.0).then(|| round(100.0 * diff / a) + 0.0),
|
||||
ci95: interval.map(|([lo, hi], _)| [round(lo) + 0.0, round(hi) + 0.0]),
|
||||
confidence: interval.map(|(_, c)| round4(c)),
|
||||
noise: None,
|
||||
verdict,
|
||||
}
|
||||
}
|
||||
|
||||
/// The widest symmetric interval of the sorted Walsh averages whose tails hold at most 2.5% each, and its confidence.
|
||||
fn interval(n: usize, walsh: &[f64]) -> Option<([f64; 2], f64)> {
|
||||
let m = walsh.len();
|
||||
// Null probabilities of T+ (halving is exact in binary, so these are the counts over 2^n).
|
||||
let mut p = vec![0.0f64; m + 1];
|
||||
p[0] = 1.0;
|
||||
for r in 1..=n {
|
||||
for t in (0..=m).rev() {
|
||||
p[t] = (p[t] + if t >= r { p[t - r] } else { 0.0 }) / 2.0;
|
||||
}
|
||||
}
|
||||
let (mut k, mut tail, mut best) = (0, 0.0, 0.0);
|
||||
while k < m {
|
||||
tail += p[k];
|
||||
if tail > 0.025 {
|
||||
break;
|
||||
}
|
||||
k += 1;
|
||||
best = tail;
|
||||
}
|
||||
(k >= 1).then(|| ([walsh[k - 1], walsh[m - k]], 1.0 - 2.0 * best))
|
||||
}
|
||||
|
||||
/// The result of an A/B run: pairs of blocks in B A A B order, recomputable from the report's `frames`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Ab {
|
||||
pub switch: String,
|
||||
pub a: String,
|
||||
pub b: String,
|
||||
pub pairs: u32,
|
||||
pub block_s: f64,
|
||||
pub warmup_s: f64,
|
||||
/// Block 0 (measured before any switch) lies outside the later B blocks.
|
||||
pub first_block_outside_range: bool,
|
||||
/// By `gpu_p50_ms`, `cpu_p50_ms`, `frame_p50_ms`; a metric missing from any block is left out.
|
||||
pub result: BTreeMap<String, Paired>,
|
||||
}
|
||||
|
||||
impl Ab {
|
||||
/// Pairs the `ab.a`/`ab.b` entries of `frames` by `repeat` (0, 1, 2...): pair k is blocks 2k and 2k+1.
|
||||
pub fn from_frames(
|
||||
switch: &str,
|
||||
a: &str,
|
||||
b: &str,
|
||||
block_s: f64,
|
||||
warmup_s: f64,
|
||||
frames: &[Frames],
|
||||
) -> Result<Ab, String> {
|
||||
let mut blocks: Vec<&Frames> = frames
|
||||
.iter()
|
||||
.filter(|f| f.phase == "ab.a" || f.phase == "ab.b")
|
||||
.collect();
|
||||
blocks.sort_by_key(|f| f.repeat);
|
||||
if blocks.is_empty()
|
||||
|| !blocks.len().is_multiple_of(2)
|
||||
|| blocks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(i, f)| f.repeat as usize != i)
|
||||
{
|
||||
return Err("A/B blocks must be numbered 0..N with N even".to_owned());
|
||||
}
|
||||
if blocks.chunks(2).any(|p| p[0].phase == p[1].phase) {
|
||||
return Err("each pair needs one ab.a and one ab.b block".to_owned());
|
||||
}
|
||||
let pairs = blocks.len() / 2;
|
||||
let metrics: [(&str, Getter); 3] = [
|
||||
("gpu_p50_ms", |f| f.gpu_p50_ms),
|
||||
("cpu_p50_ms", |f| f.cpu_p50_ms),
|
||||
("frame_p50_ms", |f| Some(f.p50_ms)),
|
||||
];
|
||||
let mut result = BTreeMap::new();
|
||||
let mut series: BTreeMap<&str, Vec<f64>> = BTreeMap::new();
|
||||
for (name, get) in metrics {
|
||||
let Some(values) = blocks.iter().map(|f| get(f)).collect::<Option<Vec<f64>>>() else {
|
||||
continue;
|
||||
};
|
||||
let by_arm: Vec<(f64, f64)> = blocks
|
||||
.chunks(2)
|
||||
.zip(values.chunks(2))
|
||||
.map(|(p, v)| {
|
||||
if p[0].phase == "ab.a" {
|
||||
(v[0], v[1])
|
||||
} else {
|
||||
(v[1], v[0])
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut stat = paired(&by_arm);
|
||||
let gaps: Vec<f64> = (1..values.len().saturating_sub(1))
|
||||
.step_by(2)
|
||||
.filter(|&i| blocks[i].phase == blocks[i + 1].phase)
|
||||
.map(|i| (values[i] - values[i + 1]).abs())
|
||||
.collect();
|
||||
stat.noise = (!gaps.is_empty()).then(|| round(median(&gaps)) + 0.0);
|
||||
result.insert(name.to_owned(), stat);
|
||||
series.insert(name, values);
|
||||
}
|
||||
let first_block_outside_range = series
|
||||
.get("gpu_p50_ms")
|
||||
.or_else(|| series.get("frame_p50_ms"))
|
||||
.is_some_and(|values| {
|
||||
let later: Vec<f64> = (1..values.len())
|
||||
.filter(|&i| blocks[i].phase == "ab.b")
|
||||
.map(|i| values[i])
|
||||
.collect();
|
||||
blocks[0].phase == "ab.b"
|
||||
&& !later.is_empty()
|
||||
&& (values[0] < later.iter().copied().fold(f64::INFINITY, f64::min)
|
||||
|| values[0] > later.iter().copied().fold(f64::NEG_INFINITY, f64::max))
|
||||
});
|
||||
Ok(Ab {
|
||||
switch: switch.to_owned(),
|
||||
a: a.to_owned(),
|
||||
b: b.to_owned(),
|
||||
pairs: pairs as u32,
|
||||
block_s,
|
||||
warmup_s,
|
||||
first_block_outside_range,
|
||||
result,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{App, Kind, Report};
|
||||
|
||||
// Expected values: exact-rational Walsh averages and a brute-force sign enumeration, run once outside this code.
|
||||
fn check(
|
||||
a: &[f64],
|
||||
b: &[f64],
|
||||
diff: f64,
|
||||
ci: Option<[f64; 2]>,
|
||||
conf: Option<f64>,
|
||||
verdict: Verdict,
|
||||
) {
|
||||
let pairs: Vec<(f64, f64)> = a.iter().copied().zip(b.iter().copied()).collect();
|
||||
let p = paired(&pairs);
|
||||
assert_eq!(
|
||||
(p.pairs, p.diff, p.ci95, p.confidence, p.verdict),
|
||||
(a.len(), diff, ci, conf, verdict),
|
||||
"{b:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_decided_vectors() {
|
||||
use Verdict::*;
|
||||
let z = |n: usize| vec![0.0; n];
|
||||
let up = |n: usize| (1..=n).map(|v| v as f64).collect::<Vec<_>>();
|
||||
check(&z(8), &up(8), 4.5, Some([2.0, 7.0]), Some(0.9609), Slower);
|
||||
let wobble = [-0.3, 0.2, -0.1, 0.4, -0.2, 0.1, 0.3, -0.4];
|
||||
check(
|
||||
&z(8),
|
||||
&wobble,
|
||||
0.0,
|
||||
Some([-0.3, 0.3]),
|
||||
Some(0.9609),
|
||||
WithinNoise,
|
||||
);
|
||||
check(&z(6), &up(6), 3.5, Some([1.0, 6.0]), Some(0.9688), Slower);
|
||||
check(&z(5), &up(5), 3.0, None, None, TooFewPairs);
|
||||
let a = [3.65, 3.70, 3.62, 3.68, 3.66, 3.71, 3.63, 3.69];
|
||||
let b = [5.63, 5.60, 5.70, 5.58, 5.65, 5.62, 5.66, 5.61];
|
||||
check(&a, &b, 1.96, Some([1.905, 2.03]), Some(0.9609), Slower);
|
||||
let tiny: Vec<f64> = (1..=8).map(|v| 10.0 + v as f64 / 100.0).collect();
|
||||
check(
|
||||
&[10.0; 8],
|
||||
&tiny,
|
||||
0.045,
|
||||
Some([0.02, 0.07]),
|
||||
Some(0.9609),
|
||||
Negligible,
|
||||
);
|
||||
let down: Vec<f64> = up(8).iter().map(|_| 0.0).collect();
|
||||
check(
|
||||
&up(8),
|
||||
&down,
|
||||
-4.5,
|
||||
Some([-7.0, -2.0]),
|
||||
Some(0.9609),
|
||||
Faster,
|
||||
);
|
||||
let mixed = [0.5, -0.2, 0.0, 0.3, 0.9, -0.1, 0.4];
|
||||
check(
|
||||
&z(7),
|
||||
&mixed,
|
||||
0.225,
|
||||
Some([-0.1, 0.65]),
|
||||
Some(0.9531),
|
||||
WithinNoise,
|
||||
);
|
||||
let ten = [0.3, -0.1, 0.8, 0.2, -0.5, 0.6, 0.1, 0.4, 0.7, 0.05];
|
||||
check(
|
||||
&z(10),
|
||||
&ten,
|
||||
0.25,
|
||||
Some([-0.025, 0.55]),
|
||||
Some(0.9512),
|
||||
WithinNoise,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn medians_percent_and_empty_input() {
|
||||
let a = [3.65, 3.70, 3.62, 3.68, 3.66, 3.71, 3.63, 3.69];
|
||||
let b = [5.63, 5.60, 5.70, 5.58, 5.65, 5.62, 5.66, 5.61];
|
||||
let p = paired(&a.iter().copied().zip(b).collect::<Vec<_>>());
|
||||
assert_eq!(
|
||||
(p.a, p.b, p.diff_pct, p.noise),
|
||||
(3.67, 5.625, Some(53.406), None)
|
||||
);
|
||||
assert_eq!(
|
||||
paired(&[(0.0, 1.0); 8]).diff_pct,
|
||||
None,
|
||||
"no percent of zero"
|
||||
);
|
||||
assert_eq!(paired(&[]).verdict, Verdict::TooFewPairs);
|
||||
}
|
||||
|
||||
fn block(phase: &str, repeat: u32, frame: f64, cpu: Option<f64>, gpu: Option<f64>) -> Frames {
|
||||
Frames {
|
||||
phase: phase.into(),
|
||||
repeat,
|
||||
frames: 100,
|
||||
seconds: 4.0,
|
||||
avg_fps: 250.0,
|
||||
low_1pct_fps: 100.0,
|
||||
low_01pct_fps: 90.0,
|
||||
p50_ms: frame,
|
||||
p95_ms: frame + 1.0,
|
||||
p99_ms: frame + 2.0,
|
||||
p999_ms: frame + 3.0,
|
||||
max_ms: frame + 4.0,
|
||||
cpu_p50_ms: cpu,
|
||||
gpu_p50_ms: gpu,
|
||||
histogram: vec![0; crate::HISTOGRAM_EDGES_MS.len()],
|
||||
}
|
||||
}
|
||||
|
||||
// B A A B B A A B B A A B, as gpu milliseconds; frame time is gpu + 1.
|
||||
fn twelve(first_b: f64) -> Vec<Frames> {
|
||||
let gpu = [
|
||||
first_b, 3.0, 3.2, 5.0, 5.2, 3.1, 3.1, 5.1, 5.4, 3.4, 3.3, 5.3,
|
||||
];
|
||||
gpu.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &g)| {
|
||||
block(
|
||||
if matches!(i % 4, 0 | 3) {
|
||||
"ab.b"
|
||||
} else {
|
||||
"ab.a"
|
||||
},
|
||||
i as u32,
|
||||
g + 1.0,
|
||||
Some(g / 2.0),
|
||||
Some(g),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn from(frames: &[Frames]) -> Result<Ab, String> {
|
||||
Ab::from_frames("gpu-culling", "off", "on", 4.0, 2.0, frames)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noise_and_the_first_block_flag() {
|
||||
let ab = from(&twelve(6.4)).unwrap();
|
||||
assert!(
|
||||
ab.first_block_outside_range,
|
||||
"6.4 is 1 ms above the other B blocks"
|
||||
);
|
||||
let gpu = &ab.result["gpu_p50_ms"];
|
||||
// Neighbours (1,2) (3,4) (5,6) (7,8) (9,10): 0.2 0.2 0 0.3 0.1.
|
||||
assert_eq!(
|
||||
(gpu.pairs, gpu.a, gpu.b, gpu.noise),
|
||||
(6, 3.15, 5.25, Some(0.2))
|
||||
);
|
||||
assert_eq!(
|
||||
(gpu.ci95, gpu.verdict),
|
||||
(Some([1.8, 3.4]), Verdict::Slower),
|
||||
"every d is positive: min and max Walsh averages"
|
||||
);
|
||||
assert_eq!(ab.result["cpu_p50_ms"].noise, Some(0.1));
|
||||
assert_eq!(
|
||||
(ab.pairs, ab.switch.as_str(), ab.a.as_str(), ab.b.as_str()),
|
||||
(6, "gpu-culling", "off", "on")
|
||||
);
|
||||
assert!(!from(&twelve(5.2)).unwrap().first_block_outside_range);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_metric_missing_from_one_block_is_left_out() {
|
||||
let mut frames = twelve(5.2);
|
||||
frames[7].gpu_p50_ms = None;
|
||||
let ab = from(&frames).unwrap();
|
||||
assert_eq!(
|
||||
ab.result.keys().collect::<Vec<_>>(),
|
||||
["cpu_p50_ms", "frame_p50_ms"]
|
||||
);
|
||||
let mut frames = twelve(6.4);
|
||||
frames[0].gpu_p50_ms = None;
|
||||
frames[0].p50_ms = 6.0;
|
||||
assert!(
|
||||
!from(&frames).unwrap().first_block_outside_range,
|
||||
"frame time 6.0 is inside 6.0..6.4 of the later B blocks"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_that_do_not_pair_are_refused() {
|
||||
assert!(from(&[]).is_err());
|
||||
assert!(from(&twelve(5.2)[..11]).is_err(), "odd number of blocks");
|
||||
let mut frames = twelve(5.2);
|
||||
frames[1].phase = "ab.b".into();
|
||||
assert!(from(&frames).is_err(), "a pair of two B blocks");
|
||||
let mut frames = twelve(5.2);
|
||||
frames[3].repeat = 9;
|
||||
assert!(from(&frames).is_err(), "a gap in the numbering");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_report_with_ab_round_trips_and_old_ones_still_parse() {
|
||||
let mut r = Report::new(Kind::Game, App::default());
|
||||
let old = r.to_json();
|
||||
assert!(!old.contains("\"ab\""));
|
||||
assert_eq!(Report::parse(&old).unwrap().ab, None);
|
||||
r.frames = twelve(5.2);
|
||||
r.ab = Some(from(&r.frames).unwrap());
|
||||
let json = r.to_json();
|
||||
assert_eq!(Report::parse(&json).unwrap(), r);
|
||||
assert!(
|
||||
json.contains("\"first_block_outside_range\": false")
|
||||
&& json.contains("\"verdict\": \"slower\"")
|
||||
);
|
||||
assert_eq!(r.for_upload().ab, r.ab);
|
||||
let mut few = paired(&[(1.0, 2.0); 3]);
|
||||
few.noise = None;
|
||||
assert!(serde_json::to_string(&few)
|
||||
.unwrap()
|
||||
.contains("\"ci95\":null,\"confidence\":null"));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
//! The machine a report was measured on. CPU, RAM and OS are detected here; the GPU and the display
|
||||
//! come from the program that rendered (the game knows its adapter, the launcher its window).
|
||||
|
||||
use horizon_telemetry::system::command_output as output;
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
use horizon_system::command_output as output;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
@@ -61,6 +62,7 @@ pub struct Display {
|
||||
pub refresh_hz: Option<u32>,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl Hardware {
|
||||
pub fn detect() -> Hardware {
|
||||
let probes = probes();
|
||||
@@ -68,12 +70,12 @@ impl Hardware {
|
||||
os: std::env::consts::OS.to_owned(),
|
||||
os_version: os_version(),
|
||||
arch: std::env::consts::ARCH.to_owned(),
|
||||
cpu: cpu(),
|
||||
cpu: horizon_system::cpu(),
|
||||
cpu_threads: std::thread::available_parallelism().ok().map(|n| n.get()),
|
||||
cpu_cores: probes.cores,
|
||||
cpu_base_mhz: probes.base,
|
||||
cpu_boost_mhz: probes.boost,
|
||||
ram_mb: ram_bytes().map(|b| b >> 20),
|
||||
ram_mb: horizon_system::ram_bytes().map(|b| b >> 20),
|
||||
storage: None,
|
||||
form: probes.form,
|
||||
gpu: None,
|
||||
@@ -84,13 +86,14 @@ impl Hardware {
|
||||
/// [`Hardware::detect`] with the kind of drive `game_dir` is on.
|
||||
pub fn detect_in(game_dir: &std::path::Path) -> Hardware {
|
||||
Hardware {
|
||||
storage: horizon_telemetry::system::storage(game_dir).map(str::to_owned),
|
||||
storage: horizon_system::storage(game_dir).map(str::to_owned),
|
||||
..Hardware::detect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the telemetry probes read (`horizon_telemetry::system`).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
/// What `horizon_system::Machine` reads.
|
||||
struct Probes {
|
||||
cores: Option<u16>,
|
||||
base: Option<u32>,
|
||||
@@ -98,95 +101,31 @@ struct Probes {
|
||||
form: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn probes() -> Probes {
|
||||
let s = horizon_telemetry::payload::System::detect();
|
||||
let s = horizon_system::Machine::detect();
|
||||
let some = |n: u32| (n > 0).then_some(n);
|
||||
Probes {
|
||||
cores: (s.cores > 0).then_some(s.cores),
|
||||
base: some(s.cpu_base_mhz),
|
||||
boost: some(s.cpu_boost_mhz),
|
||||
form: (!s.form.is_empty()).then_some(s.form),
|
||||
}
|
||||
}
|
||||
|
||||
/// The one-minute load average: above about the number of threads the machine was busy.
|
||||
pub fn load_average() -> Option<f64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
// "{ 1.23 1.10 1.00 }"
|
||||
output("sysctl", &["-n", "vm.loadavg"])?
|
||||
.trim_start_matches('{')
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.parse()
|
||||
.ok()
|
||||
} else {
|
||||
std::fs::read_to_string("/proc/loadavg")
|
||||
.ok()?
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.parse()
|
||||
.ok()
|
||||
form: (!s.form.is_empty()).then(|| s.form.to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
/// `ac` or `battery`; `None` where it can't be read (and on machines without a battery on Linux).
|
||||
pub fn power_source() -> Option<&'static str> {
|
||||
horizon_telemetry::system::power()
|
||||
}
|
||||
|
||||
/// Resident memory of a process, in bytes (macOS, Linux).
|
||||
pub fn rss_bytes(pid: u32) -> Option<u64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
return output("ps", &["-o", "rss=", "-p", &pid.to_string()])?
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.map(|kb| kb * 1024);
|
||||
}
|
||||
let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
|
||||
let line = status.lines().find(|l| l.starts_with("VmRSS:"))?;
|
||||
line.split_whitespace()
|
||||
.nth(1)?
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.map(|kb| kb * 1024)
|
||||
}
|
||||
|
||||
/// CPU time a process has used, user and system, in seconds (macOS, Linux).
|
||||
pub fn cpu_seconds(pid: u32) -> Option<f64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
// "[[dd-]hh:]mm:ss.cc"
|
||||
let time = output("ps", &["-o", "time=", "-p", &pid.to_string()])?;
|
||||
let (rest, days) = match time.split_once('-') {
|
||||
Some((d, rest)) => (rest.to_owned(), d.parse::<f64>().ok()?),
|
||||
None => (time, 0.0),
|
||||
};
|
||||
let seconds = rest.split(':').try_fold(0.0, |acc, part| {
|
||||
part.parse::<f64>().ok().map(|v| acc * 60.0 + v)
|
||||
})?;
|
||||
return Some(days * 86_400.0 + seconds);
|
||||
}
|
||||
// utime and stime, fields 14 and 15, in clock ticks (100 per second on Linux).
|
||||
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
||||
let fields: Vec<&str> = stat.rsplit_once(')')?.1.split_whitespace().collect();
|
||||
let ticks: f64 = fields.get(11)?.parse::<f64>().ok()? + fields.get(12)?.parse::<f64>().ok()?;
|
||||
Some(ticks / 100.0)
|
||||
horizon_system::power()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(target_os = "macos")]
|
||||
fn os_version() -> Option<String> {
|
||||
output("sw_vers", &["-productVersion"])
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn cpu() -> Option<String> {
|
||||
output("sysctl", &["-n", "machdep.cpu.brand_string"])
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
output("sysctl", &["-n", "hw.memsize"])?.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn os_version() -> Option<String> {
|
||||
let release = std::fs::read_to_string("/etc/os-release").ok()?;
|
||||
@@ -199,22 +138,7 @@ fn os_version() -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn cpu() -> Option<String> {
|
||||
let info = std::fs::read_to_string("/proc/cpuinfo").ok()?;
|
||||
// x86 names the CPU per core; arm64 only gives the core's implementer and part.
|
||||
horizon_telemetry::system::linux_cpu_name(&info)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
let info = std::fs::read_to_string("/proc/meminfo").ok()?;
|
||||
let kb = key_value(&info, "MemTotal", ':')?
|
||||
.trim_end_matches("kB")
|
||||
.trim();
|
||||
kb.parse::<u64>().ok().map(|kb| kb * 1024)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn key_value<'a>(text: &'a str, key: &str, sep: char) -> Option<&'a str> {
|
||||
text.lines().find_map(|line| {
|
||||
@@ -223,45 +147,19 @@ fn key_value<'a>(text: &'a str, key: &str, sep: char) -> Option<&'a str> {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(target_os = "windows")]
|
||||
fn os_version() -> Option<String> {
|
||||
horizon_telemetry::system::windows_version()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn cpu() -> Option<String> {
|
||||
horizon_telemetry::system::windows_cpu()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
let out = output(
|
||||
"powershell",
|
||||
&[
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory",
|
||||
],
|
||||
)?;
|
||||
out.parse().ok()
|
||||
horizon_system::windows_version()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn os_version() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn cpu() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -274,9 +172,6 @@ mod tests {
|
||||
assert!(hw.cpu.is_some(), "{hw:?}");
|
||||
assert!(hw.ram_mb.unwrap() > 256, "{hw:?}");
|
||||
assert!(hw.os_version.is_some(), "{hw:?}");
|
||||
assert!(load_average().is_some());
|
||||
assert!(rss_bytes(std::process::id()).unwrap() > 1 << 20);
|
||||
assert!(cpu_seconds(std::process::id()).unwrap() >= 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,9 +11,15 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod compare;
|
||||
mod hardware;
|
||||
|
||||
pub use hardware::{cpu_seconds, load_average, power_source, rss_bytes, Display, Gpu, Hardware};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use hardware::power_source;
|
||||
pub use hardware::{Display, Gpu, Hardware};
|
||||
/// The process probes, from the platform (`horizon-system`).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use horizon_system::{cpu_seconds, load_average, rss_bytes};
|
||||
|
||||
pub const FORMAT: &str = "horizon-benchmark-report";
|
||||
pub const VERSION: u32 = 1;
|
||||
@@ -234,6 +240,9 @@ pub struct Report {
|
||||
pub metrics: BTreeMap<String, Summary>,
|
||||
#[serde(default)]
|
||||
pub frames: Vec<Frames>,
|
||||
/// Present in A/B runs only.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ab: Option<compare::Ab>,
|
||||
/// Local only: a name the person running it chose.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub label: Option<String>,
|
||||
@@ -244,6 +253,7 @@ pub struct Report {
|
||||
|
||||
impl Report {
|
||||
/// An empty report for this machine, created now.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn new(kind: Kind, app: App) -> Report {
|
||||
Self::with_hardware(kind, app, Hardware::detect())
|
||||
}
|
||||
@@ -261,6 +271,7 @@ impl Report {
|
||||
conditions: Conditions::default(),
|
||||
metrics: BTreeMap::new(),
|
||||
frames: Vec::new(),
|
||||
ab: None,
|
||||
label: None,
|
||||
local: Vec::new(),
|
||||
}
|
||||
|
||||
@@ -61,6 +61,8 @@ pub enum ClickEvent {
|
||||
/// A local file to open with the system handler.
|
||||
/// Only allow this in locally constructed messages, never server/LAN text.
|
||||
OpenFile(String),
|
||||
/// A `/profiler` line the client's own buttons run; never decoded from a server's text, so only they reach the local profiler.
|
||||
Profiler(String),
|
||||
}
|
||||
|
||||
/// A non-character `minecraft:object` embedded in a text component.
|
||||
@@ -202,7 +204,12 @@ impl Text {
|
||||
}
|
||||
}
|
||||
pub fn styled(s: impl Into<String>, style: Style) -> Self {
|
||||
Self::literal(s).with_style(&style)
|
||||
let mut text = Self::literal(s).with_style(&style);
|
||||
// `inherit` never passes an object on, but a styled span's own style carries it.
|
||||
for span in &mut text.spans {
|
||||
span.style.inline_object.clone_from(&style.inline_object);
|
||||
}
|
||||
text
|
||||
}
|
||||
pub fn colored(s: impl Into<String>, color: u32) -> Self {
|
||||
Self::styled(s, Style::color(color))
|
||||
@@ -619,6 +626,9 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
|
||||
event
|
||||
}
|
||||
ClickEvent::OpenFile(path) => json!({ "action": "open_file", "path": path }),
|
||||
ClickEvent::Profiler(command) => {
|
||||
json!({ "action": "run_command", "command": command })
|
||||
}
|
||||
};
|
||||
out.push(("click_event".to_owned(), event));
|
||||
}
|
||||
@@ -772,6 +782,27 @@ pub fn format_translation(template: &str, args: &[Text]) -> Text {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn a_styled_object_span_keeps_its_object_through_append_and_slice() {
|
||||
let object = InlineObject::Atlas {
|
||||
atlas: "minecraft:gui".into(),
|
||||
sprite: "horizon:gui/mark".into(),
|
||||
fallback: "H".into(),
|
||||
};
|
||||
let sprite = Text::styled(
|
||||
"\u{fffc}",
|
||||
Style {
|
||||
inline_object: Some(object.clone()),
|
||||
..Style::color(WHITE)
|
||||
},
|
||||
);
|
||||
let line = Text::colored("[", DARK_GRAY)
|
||||
.append(sprite)
|
||||
.append(Text::colored("] ", DARK_GRAY));
|
||||
let line = line.slice(0, 3);
|
||||
assert_eq!(line.spans[1].style.inline_object, Some(object));
|
||||
assert_eq!(line.plain(), "[H]");
|
||||
}
|
||||
#[test]
|
||||
fn appending_builds_the_same_tree_in_linear_time() {
|
||||
// A styled text is wrapped once, so its style doesn't reach what follows; a plain one
|
||||
// takes the siblings itself; a copy taken before an append keeps its own tree.
|
||||
|
||||
@@ -27,6 +27,8 @@ horizon-mcp = { path = "../horizon-mcp" }
|
||||
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
# The .hzperf capture format and writer (docs/perf-platform.md).
|
||||
horizon-perf-format = { path = "../horizon-perf-format" }
|
||||
# One pool of helper threads for the server tick's parallel phases (`parallel.rs`): threads made
|
||||
# once, not for every phase of every tick.
|
||||
rayon = "1.10"
|
||||
@@ -130,6 +132,8 @@ workspace = true
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
horizon-client-render = { path = "../horizon-client-render", features = ["test-support"] }
|
||||
horizon-items = { path = "../horizon-items", features = ["test-support"] }
|
||||
# Reads back the captures the server_perf tests write.
|
||||
horizon-perf = { path = "../horizon-perf" }
|
||||
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ horizon-narrator = { path = "../horizon-narrator" }
|
||||
# OptiFine-format pack files: random entity textures (hud.rs), connected textures
|
||||
# (connected.rs) and custom item texture rules (cit.rs).
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
horizon-perf-format = { path = "../horizon-perf-format" }
|
||||
horizon-polytone = { path = "../horizon-polytone" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
@@ -74,6 +75,9 @@ wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
|
||||
audio = ["dep:cpal"]
|
||||
# Helpers horizon-client's tests build their fixtures with.
|
||||
test-support = []
|
||||
# The `scene_replay` example's allocator counts allocations (horizon-threads' `alloc-count`).
|
||||
alloc-count = ["horizon-threads/alloc-count"]
|
||||
|
||||
# horizon-client's `horizon-extras`: asking the device for hardware ray tracing on request
|
||||
# (`gfx/ray_query.rs`, `HORIZON_RAY_TRACING=1`). Without it the device is requested as without
|
||||
# the variable.
|
||||
@@ -83,6 +87,7 @@ horizon-extras = []
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
horizon-perf = { path = "../horizon-perf" }
|
||||
# Tests: a TrueType font to load, and the design system the menus' motion curves match.
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
horizon-items = { path = "../horizon-items", features = ["test-support"] }
|
||||
|
||||
@@ -6,13 +6,14 @@
|
||||
//! [--mipmaps N] [--lod-distance CHUNKS] [--render-distance CHUNKS] [--size WxH]
|
||||
//! [--section-draws flat|indirect] [--section-cull on|off] [--section-sides on|off]
|
||||
//! [--gpu-culling on|off] [--root RESOURCES] [--screenshot PNG]
|
||||
//! [--world SAVE --leaf-culling MODE] [--memory on]`
|
||||
//! [--world SAVE --leaf-culling MODE] [--memory on] [--gpu-detail on|off]`
|
||||
//! `--screenshot` saves the first view's last frame, after the timed frames.
|
||||
//! `--world` meshes the captured sections again from that save's region files
|
||||
//! (blocks, biomes and saved light; `ao`, `biomeBlendRadius` and `cutoutLeaves`
|
||||
//! from `./options.txt`) with `--leaf-culling` (default `off`), so a mesher
|
||||
//! change can be measured on the captured view. A section whose 3x3 columns are
|
||||
//! not all saved keeps its captured mesh.
|
||||
//! `--gpu-detail on` adds GPU culling's dispatch times and, on Vulkan, the world passes' counters.
|
||||
//! `--memory on` adds the renderer's largest GPU resources and, on macOS, the process's
|
||||
//! physical footprint by category (`footprint`), taken after the timed frames.
|
||||
use horizon_client_render::{
|
||||
@@ -24,6 +25,7 @@ use horizon_client_render::{
|
||||
gfx::{
|
||||
PreparedTextures, Renderer, ScreenshotRequest,
|
||||
capture::{self, AtlasCheck, Scene},
|
||||
pack_timing,
|
||||
panorama::Faces,
|
||||
},
|
||||
graphics::LeafCullingMode,
|
||||
@@ -61,7 +63,10 @@ use winit::{
|
||||
// The game's allocator, so that `--memory`'s footprint is the game's: the system heap kept
|
||||
// gigabytes of the meshes `--world` frees dirty.
|
||||
#[global_allocator]
|
||||
static ALLOCATOR: horizon_threads::Allocator = horizon_threads::ALLOCATOR;
|
||||
static ALLOCATOR: horizon_threads::GameAllocator = horizon_threads::GAME_ALLOCATOR;
|
||||
|
||||
/// Frames drawn with detail on, enough for a few timed frames of each kind.
|
||||
const DETAIL_FRAMES: usize = 60;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Options {
|
||||
@@ -84,6 +89,7 @@ struct Options {
|
||||
world: Option<PathBuf>,
|
||||
leaf_culling: LeafCullingMode,
|
||||
memory: bool,
|
||||
gpu_detail: bool,
|
||||
}
|
||||
|
||||
fn on_off(value: &str) -> Result<bool, String> {
|
||||
@@ -136,6 +142,7 @@ fn options() -> Result<Options, String> {
|
||||
"--screenshot" => o.screenshot = Some(value.into()),
|
||||
"--world" => o.world = Some(value.into()),
|
||||
"--memory" => o.memory = on_off(&value)?,
|
||||
"--gpu-detail" => o.gpu_detail = on_off(&value)?,
|
||||
"--leaf-culling" => {
|
||||
o.leaf_culling = LeafCullingMode::ALL
|
||||
.into_iter()
|
||||
@@ -400,6 +407,23 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
None => notes.push("screenshot: not saved".into()),
|
||||
}
|
||||
}
|
||||
let mut detail = serde_json::Map::new();
|
||||
if o.gpu_detail {
|
||||
renderer.set_pass_detail(true);
|
||||
for _ in 0..DETAIL_FRAMES {
|
||||
draw(&mut renderer)?;
|
||||
}
|
||||
detail.insert(
|
||||
"gpu_pass_parts_median_ms".into(),
|
||||
pack_timing::parts_json(&renderer.own_pass_parts()),
|
||||
);
|
||||
let unit = renderer.pass_detail_support().and_then(|s| s.fragment_unit);
|
||||
detail.insert(
|
||||
"gpu_counters_median".into(),
|
||||
pack_timing::counters_by_pass_json(&renderer.own_pass_counters(), unit),
|
||||
);
|
||||
renderer.set_pass_detail(false);
|
||||
}
|
||||
views.push(json!({
|
||||
"yaw_offset": offset,
|
||||
"gpu_ms": summary(&gpu),
|
||||
@@ -417,6 +441,9 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// indices hidden.
|
||||
"culled": renderer.culled_sections(),
|
||||
}));
|
||||
if let Some(Value::Object(view)) = views.last_mut() {
|
||||
view.extend(detail);
|
||||
}
|
||||
}
|
||||
let section_draws = if renderer.flat_draws() {
|
||||
"flat"
|
||||
@@ -424,6 +451,11 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
"indirect"
|
||||
};
|
||||
let memory = o.memory.then(|| memory_report(&renderer));
|
||||
let gpu_detail = o
|
||||
.gpu_detail
|
||||
.then(|| renderer.pass_detail_support())
|
||||
.flatten()
|
||||
.map(pack_timing::support_json);
|
||||
let report = json!({
|
||||
"capture": o.dir.display().to_string(),
|
||||
"adapter": renderer.info.name,
|
||||
@@ -454,6 +486,7 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
"notes": notes,
|
||||
"views": views,
|
||||
"memory": memory,
|
||||
"gpu_detail": gpu_detail,
|
||||
});
|
||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||
Ok(())
|
||||
|
||||
@@ -1442,6 +1442,8 @@ impl Audio {
|
||||
} else {
|
||||
Err("no output on a dedicated server".into())
|
||||
};
|
||||
// Without `audio` a stream can't exist (`Infallible`), so the `Ok` arm never runs.
|
||||
#[cfg_attr(not(feature = "audio"), allow(unreachable_code))]
|
||||
let (stream, live) = match opened {
|
||||
Ok(v) => (Some(v), true),
|
||||
Err(e) => {
|
||||
|
||||
@@ -568,6 +568,8 @@ impl Chat {
|
||||
pub fn set_qol(&mut self, atlas: &HudAtlas, qol: ChatQol) {
|
||||
if self.qol != qol {
|
||||
self.qol = qol;
|
||||
self.scroll = 0;
|
||||
self.new_since_scroll = false;
|
||||
self.rewrap(&atlas.font);
|
||||
}
|
||||
}
|
||||
@@ -593,20 +595,25 @@ impl Chat {
|
||||
.filter(|q| !q.trim().is_empty());
|
||||
if self.search != query {
|
||||
self.search = query;
|
||||
self.scroll = 0;
|
||||
self.new_since_scroll = false;
|
||||
self.rewrap(font);
|
||||
}
|
||||
}
|
||||
pub fn searching(&self) -> bool {
|
||||
self.search.is_some()
|
||||
}
|
||||
/// Re-wrap after a width or scale change (ChatComponent.rescaleChat),
|
||||
/// keeping only the messages the Chat option shows.
|
||||
/// Re-wrap after a width or scale change (`ChatComponent.rescaleChat`):
|
||||
/// `resetChatScroll`, then the messages the Chat option shows.
|
||||
pub fn rescale(&mut self, atlas: &HudAtlas) {
|
||||
self.rewrap(&atlas.font);
|
||||
}
|
||||
fn rewrap(&mut self, font: &crate::font::Font) {
|
||||
self.scroll = 0;
|
||||
self.new_since_scroll = false;
|
||||
self.rewrap(&atlas.font);
|
||||
}
|
||||
/// `ChatComponent.refreshTrimmedMessages`: the lines are laid out again
|
||||
/// where the reader was (`chatScrollbarPos` survives a filter change);
|
||||
/// `rescale` resets the scroll itself, as `rescaleChat` does.
|
||||
fn rewrap(&mut self, font: &crate::font::Font) {
|
||||
self.lines.clear();
|
||||
let mut messages = std::mem::take(&mut self.messages);
|
||||
for m in messages.iter_mut().rev() {
|
||||
|
||||
@@ -1067,6 +1067,34 @@ fn a_server_filter_mask_hides_or_masks_the_message() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `ChatComponent.refreshTrimmedMessages`: a filter change lays the lines out
|
||||
/// again but keeps `chatScrollbarPos` and `newMessageSinceScroll`; only
|
||||
/// `rescaleChat` resets them, after `resetChatScroll`.
|
||||
#[test]
|
||||
fn a_chat_option_change_keeps_the_readers_place_but_a_rescale_does_not() {
|
||||
let font = font();
|
||||
let mut chat = Chat::default();
|
||||
for i in 0..60 {
|
||||
chat.add_message(
|
||||
&font,
|
||||
Text::literal(format!("line {i}")),
|
||||
None,
|
||||
ChatSource::Server,
|
||||
0,
|
||||
false,
|
||||
);
|
||||
}
|
||||
chat.scroll = 3;
|
||||
chat.new_since_scroll = true;
|
||||
chat.set_abilities(&font, ChatAbilities::new(ChatVisibility::System));
|
||||
assert_eq!(chat.scroll, 3);
|
||||
assert!(chat.new_since_scroll);
|
||||
let atlas = crate::hud::HudAtlas::build(Path::new("/nonexistent/horizon-test-assets")).unwrap();
|
||||
chat.rescale(&atlas);
|
||||
assert_eq!(chat.scroll, 0);
|
||||
assert!(!chat.new_since_scroll);
|
||||
}
|
||||
|
||||
/// `SuggestionsList.extractRenderState`: the list's rectangle asks for the
|
||||
/// pointing hand; outside it the cursor stays the arrow.
|
||||
#[test]
|
||||
|
||||
@@ -196,7 +196,7 @@ impl ColumnLayout {
|
||||
}
|
||||
/// The index of the section holding block `y`, if the column holds it.
|
||||
pub fn section_index(self, y: i32) -> Option<usize> {
|
||||
usize::try_from((y - self.min_y) >> 4)
|
||||
usize::try_from((i64::from(y) - i64::from(self.min_y)) >> 4)
|
||||
.ok()
|
||||
.filter(|&s| s < self.sections)
|
||||
}
|
||||
@@ -700,7 +700,7 @@ impl Chunk {
|
||||
/// The index of the section holding block `y`, if the column holds it.
|
||||
#[inline]
|
||||
pub fn section_index(&self, y: i32) -> Option<usize> {
|
||||
usize::try_from((y - self.min_y) >> 4)
|
||||
usize::try_from((i64::from(y) - i64::from(self.min_y)) >> 4)
|
||||
.ok()
|
||||
.filter(|&s| s < self.sections.len())
|
||||
}
|
||||
@@ -984,6 +984,24 @@ pub struct ChunkExtras {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn extreme_y_queries_are_air_and_writes_leave_the_column_unchanged() {
|
||||
for layout in [ColumnLayout::VANILLA, ColumnLayout::new(0, 256).unwrap()] {
|
||||
let mut chunk = Chunk::with_layout(0, 0, layout);
|
||||
chunk.set(0, layout.min_y(), 0, 7);
|
||||
for y in [i32::MIN, i32::MIN + 63, -2_146_769_275, i32::MAX] {
|
||||
assert_eq!(layout.section_index(y), None);
|
||||
assert_eq!(chunk.section_index(y), None);
|
||||
assert_eq!(chunk.get(0, y, 0), 0);
|
||||
assert_eq!(
|
||||
chunk.light(0, y, 0),
|
||||
if y < layout.min_y() { 0 } else { 0xf0 }
|
||||
);
|
||||
chunk.set(0, y, 0, 9);
|
||||
}
|
||||
assert_eq!(chunk.get(0, layout.min_y(), 0), 7);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn each_light_form_keeps_every_value_and_run() {
|
||||
// Sky varies alone, block varies alone, both, neither.
|
||||
let cases: [(fn(usize) -> u8, &str); 4] = [
|
||||
|
||||
@@ -465,6 +465,9 @@ pub struct Jukebox {
|
||||
/// reference exists. Keep this separate from legacy playback adoption.
|
||||
pub song_clock_present: bool,
|
||||
pub playing: bool,
|
||||
/// A clock a save brought back, not playing yet: `setSongWithoutPlaying`
|
||||
/// keeps the song and its clock without the sound's level event 1010.
|
||||
pub loaded_clock: bool,
|
||||
}
|
||||
impl Jukebox {
|
||||
/// `JukeboxBlockEntity.getTheItem`: the disc it holds.
|
||||
|
||||
@@ -419,8 +419,10 @@ impl SceneCollisions<'_, '_> {
|
||||
let hi = BlockPos::containing(Vec3::new(max.x + 1.0e-7, max.y + 1.0e-7, max.z + 1.0e-7))
|
||||
.offset(1, 1, 1);
|
||||
let size = |a: i32, b: i32| i64::from(b) - i64::from(a) + 1;
|
||||
let cells = size(lo.x, hi.x) * size(lo.y, hi.y) * size(lo.z, hi.z);
|
||||
if lo.x > hi.x || lo.y > hi.y || lo.z > hi.z || cells > 4096 {
|
||||
let cells = [size(lo.x, hi.x), size(lo.y, hi.y), size(lo.z, hi.z)]
|
||||
.into_iter()
|
||||
.try_fold(1i64, |n, size| n.checked_mul(size));
|
||||
if lo.x > hi.x || lo.y > hi.y || lo.z > hi.z || cells.is_none_or(|n| n > 4096) {
|
||||
return false;
|
||||
}
|
||||
// A column and section at a time. Most boxes lie in a few sections whose palette says that no
|
||||
@@ -1111,6 +1113,13 @@ mod section_tests {
|
||||
binding: &binding,
|
||||
subject: None,
|
||||
};
|
||||
// The fast path must decline an enormous volume without overflowing
|
||||
// its size calculation or beginning to walk it.
|
||||
let enormous = Aabb::new(
|
||||
Vec3::new(-1.0e9, -1.0e9, -1.0e9),
|
||||
Vec3::new(1.0e9, 1.0e9, 1.0e9),
|
||||
);
|
||||
assert!(!scene.cells_without_collision(enormous));
|
||||
// Under the fence: its section's outer cells may hold a shape reaching in.
|
||||
let under = Aabb::new(Vec3::new(5.4, 31.6, 5.4), Vec3::new(5.6, 31.8, 5.6));
|
||||
assert!(!scene.cells_without_collision(under.expand_towards(Vec3::new(0.0, 0.1, 0.0))));
|
||||
|
||||
@@ -56,6 +56,8 @@ pub struct Overlay {
|
||||
struct LoadedRule {
|
||||
rule: Rule,
|
||||
tiles: Vec<TileLayer>,
|
||||
/// By tile: the rules whose `matchTiles` name it (a range of `lists`), which chain on it.
|
||||
chain: Vec<(u32, u32)>,
|
||||
overlay: OverlayJoin,
|
||||
}
|
||||
|
||||
@@ -188,6 +190,7 @@ impl ConnectedTextures {
|
||||
Ok(tiles) => Some(LoadedRule {
|
||||
rule,
|
||||
tiles,
|
||||
chain: Vec::new(),
|
||||
overlay: OverlayJoin::default(),
|
||||
}),
|
||||
Err(e) => {
|
||||
@@ -292,10 +295,23 @@ impl ConnectedTextures {
|
||||
let mut by_layer: Vec<Vec<u16>> = Vec::new();
|
||||
// Block rules by the block each entry names, so a state tests only its own.
|
||||
let mut by_block: HashMap<&str, Vec<u16>> = HashMap::new();
|
||||
// Tile rules by the sprite or tile file each names, for chaining on a picked tile.
|
||||
let mut by_name: HashMap<String, Vec<u16>> = HashMap::new();
|
||||
for (i, r) in out.rules.iter().enumerate() {
|
||||
let i = i as u16;
|
||||
for tile in &r.rule.match_tiles {
|
||||
if let Some(l) = layer(&sprite_name(tile)) {
|
||||
let name = sprite_name(tile);
|
||||
let list = by_name.entry(name.clone()).or_default();
|
||||
if list.last() != Some(&i) {
|
||||
list.push(i);
|
||||
}
|
||||
// A tile file (`./1.png`) is no model's sprite: only chaining reaches it, by
|
||||
// name. By layer it would match every face whose sprite has the same pixels
|
||||
// (identical stills share a layer: Conquest's blank placeholders, dozens).
|
||||
if !name.contains(':') {
|
||||
continue;
|
||||
}
|
||||
if let Some(l) = layer(&name) {
|
||||
let l = l as usize;
|
||||
if by_layer.len() <= l {
|
||||
by_layer.resize(l + 1, Vec::new());
|
||||
@@ -316,6 +332,26 @@ impl ConnectedTextures {
|
||||
let range = push_list(&mut out.lists, &list);
|
||||
out.by_layer.push(range);
|
||||
}
|
||||
let by_name: HashMap<String, (u32, u32)> = by_name
|
||||
.into_iter()
|
||||
.map(|(name, list)| (name, push_list(&mut out.lists, &list)))
|
||||
.collect();
|
||||
// Set at the end: `by_block` borrows the rules' names.
|
||||
let chains: Vec<Vec<(u32, u32)>> = out
|
||||
.rules
|
||||
.iter()
|
||||
.map(|r| {
|
||||
r.rule
|
||||
.tiles
|
||||
.iter()
|
||||
.map(|t| match t {
|
||||
Tile::Texture(path) => by_name.get(&sprite_name(path)).copied(),
|
||||
_ => None,
|
||||
})
|
||||
.map(Option::unwrap_or_default)
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
let mut list = Vec::new();
|
||||
for state in states {
|
||||
let Some((name, props)) = state else {
|
||||
@@ -355,6 +391,9 @@ impl ConnectedTextures {
|
||||
let range = push_list(&mut out.lists, &list);
|
||||
out.by_state.push(range);
|
||||
}
|
||||
for (r, chain) in out.rules.iter_mut().zip(chains) {
|
||||
r.chain = chain;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -392,9 +431,11 @@ impl ConnectedTextures {
|
||||
}
|
||||
|
||||
fn list(&self, ranges: &[(u32, u32)], i: usize) -> &[u16] {
|
||||
ranges.get(i).map_or(&[], |&(start, len)| {
|
||||
&self.lists[start as usize..(start + len) as usize]
|
||||
})
|
||||
ranges.get(i).map_or(&[], |&range| self.range(range))
|
||||
}
|
||||
|
||||
fn range(&self, (start, len): (u32, u32)) -> &[u16] {
|
||||
&self.lists[start as usize..(start + len) as usize]
|
||||
}
|
||||
|
||||
/// What the `face` of the block `state` at `pos` draws, for a quad with sprite `layer`.
|
||||
@@ -441,7 +482,7 @@ impl ConnectedTextures {
|
||||
let r = &self.rules[usize::from(i)];
|
||||
if (block || r.rule.match_blocks.is_empty())
|
||||
&& (tile || r.rule.match_tiles.is_empty())
|
||||
&& let Some(pick) = self.apply(
|
||||
&& let Some((pick, index)) = self.apply(
|
||||
r,
|
||||
state,
|
||||
layer,
|
||||
@@ -454,7 +495,7 @@ impl ConnectedTextures {
|
||||
)
|
||||
{
|
||||
// "Tiles output by CTM rules can also be matched by another rule": once, by the
|
||||
// tile rules naming the picked tile (and this block, if they name blocks).
|
||||
// tile rules naming the picked tile by name (and this block, if they name blocks).
|
||||
// A tile on the face's own sprite (`tiles=stone`) is no new tile.
|
||||
let Pick::Tile(TileLayer::Layer { layer: picked, .. }) = pick else {
|
||||
return pick;
|
||||
@@ -467,7 +508,7 @@ impl ConnectedTextures {
|
||||
// again: an overlay would draw twice.
|
||||
let first = self.list(&self.by_layer, layer as usize);
|
||||
let again = self
|
||||
.list(&self.by_layer, picked as usize)
|
||||
.range(r.chain.get(index).copied().unwrap_or_default())
|
||||
.iter()
|
||||
.map(|&j| (j, &self.rules[usize::from(j)]))
|
||||
.filter(|&(j, r)| {
|
||||
@@ -488,8 +529,8 @@ impl ConnectedTextures {
|
||||
&texture,
|
||||
&mut overlay,
|
||||
) {
|
||||
Some(Pick::Keep) => return pick,
|
||||
Some(chained) => return chained,
|
||||
Some((Pick::Keep, _)) => return pick,
|
||||
Some((chained, _)) => return chained,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
@@ -512,7 +553,7 @@ impl ConnectedTextures {
|
||||
biome: &impl Fn() -> Option<&'b str>,
|
||||
texture: &impl Fn() -> Option<Axes>,
|
||||
overlay: &mut impl FnMut(Overlay),
|
||||
) -> Option<Pick> {
|
||||
) -> Option<(Pick, usize)> {
|
||||
let rule = &r.rule;
|
||||
// `Rule::applies_at`, with the biome looked up only for rules that test one. Rules with
|
||||
// a custom name are not loaded.
|
||||
@@ -602,7 +643,7 @@ impl ConnectedTextures {
|
||||
return if quarters.contains(&TileLayer::Skip) {
|
||||
None
|
||||
} else {
|
||||
Some(Pick::Quarters(quarters))
|
||||
Some((Pick::Quarters(quarters), 0))
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -617,7 +658,7 @@ impl ConnectedTextures {
|
||||
return None;
|
||||
}
|
||||
if !joins([0, 1, 0]) {
|
||||
return Some(Pick::Keep);
|
||||
return Some((Pick::Keep, 0));
|
||||
}
|
||||
0
|
||||
}
|
||||
@@ -644,8 +685,8 @@ impl ConnectedTextures {
|
||||
}
|
||||
match tile(index) {
|
||||
TileLayer::Skip => None,
|
||||
TileLayer::Default => Some(Pick::Keep),
|
||||
t => Some(Pick::Tile(t)),
|
||||
TileLayer::Default => Some((Pick::Keep, index)),
|
||||
t => Some((Pick::Tile(t), index)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -675,6 +716,7 @@ mod tests {
|
||||
.enumerate()
|
||||
.map(|(n, rule)| LoadedRule {
|
||||
overlay: OverlayJoin::default(),
|
||||
chain: Vec::new(),
|
||||
tiles: rule
|
||||
.tiles
|
||||
.iter()
|
||||
@@ -722,6 +764,10 @@ mod tests {
|
||||
"assets/minecraft/optifine/ctm/b0.png" => Some(2000),
|
||||
// And `a0` of the first rule.
|
||||
"assets/minecraft/optifine/ctm/a0.png" => Some(1000),
|
||||
// Two blank placeholders with the same pixels, so one layer: the tile `grass/1`
|
||||
// of the third rule (`chaining_follows_the_tile_not_its_pixels`).
|
||||
"assets/minecraft/optifine/ctm/grass/1.png"
|
||||
| "assets/minecraft/optifine/ctm/aaa/1.png" => Some(3000),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
@@ -1176,6 +1222,31 @@ mod tests {
|
||||
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chaining_follows_the_tile_not_its_pixels() {
|
||||
// Conquest's grass top: a placeholder tile that a rule beside it replaces. Another
|
||||
// folder's placeholder has the same pixels, hence the same layer, and its rule sorts
|
||||
// first; it must not draw on the grass.
|
||||
let ctm = world(&[
|
||||
(
|
||||
"grass/top.properties",
|
||||
"matchTiles=glass\nmethod=fixed\ntiles=1",
|
||||
),
|
||||
(
|
||||
"grass/random_1.properties",
|
||||
"matchTiles=./1.png\nmethod=fixed\ntiles=17\nfaces=top",
|
||||
),
|
||||
(
|
||||
"aaa/random_1.properties",
|
||||
"matchTiles=./1.png\nmethod=fixed\ntiles=5\nfaces=top",
|
||||
),
|
||||
]);
|
||||
// Rules in path order: aaa/random_1 (1000), grass/random_1 (2000), grass/top (3000).
|
||||
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
|
||||
// A face whose own sprite has a placeholder's pixels is no placeholder.
|
||||
assert_eq!(pick_in(&ctm, 2, 3000, Face::Up, &[]), Pick::Keep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orient_texture_follows_the_quads_uvs() {
|
||||
let rule = |orient: &str| {
|
||||
|
||||
@@ -56,6 +56,15 @@ pub struct Glue<'a> {
|
||||
pub struct CoreProgram {
|
||||
pub vertex: naga::Module,
|
||||
pub fragment: naga::Module,
|
||||
/// The blocks and samplers that are none of vanilla's, in binding order: the `i`th is at
|
||||
/// [`other_binding`]`(i)` (a sampler's object one after its texture; a block leaves that
|
||||
/// binding unused).
|
||||
pub other: Vec<String>,
|
||||
}
|
||||
|
||||
/// The binding of the `index`th block or sampler of [`CoreProgram::other`].
|
||||
pub fn other_binding(index: usize) -> u32 {
|
||||
OTHER_BINDINGS + 2 * index as u32
|
||||
}
|
||||
|
||||
/// The shaders of one pipeline (`text.vsh`, `text.fsh`) with vanilla's `defines` for it.
|
||||
@@ -66,6 +75,20 @@ pub fn translate_core(
|
||||
defines: &[&str],
|
||||
include: &dyn Fn(&str) -> Option<String>,
|
||||
glue: &Glue,
|
||||
) -> Result<CoreProgram, String> {
|
||||
translate_core_with(vertex, fragment, defines, include, glue, &|_| false)
|
||||
}
|
||||
|
||||
/// [`translate_core`] for a pipeline that also gives the blocks and samplers `provided` says
|
||||
/// yes to, at their [`CoreProgram::other`] bindings; the others are still an error. `provided`
|
||||
/// answers a block's name and a sampler's alike: the caller knows which kind each one is.
|
||||
pub fn translate_core_with(
|
||||
vertex: &str,
|
||||
fragment: &str,
|
||||
defines: &[&str],
|
||||
include: &dyn Fn(&str) -> Option<String>,
|
||||
glue: &Glue,
|
||||
provided: &dyn Fn(&str) -> bool,
|
||||
) -> Result<CoreProgram, String> {
|
||||
const CORE: &str = "minecraft:shaders/core/";
|
||||
let vertex_text = glsl_fixups::fixups(&strip_comments(&expand(vertex, CORE, include)?));
|
||||
@@ -75,10 +98,16 @@ pub fn translate_core(
|
||||
.map_err(|e| format!("vertex shader: {e}"))?;
|
||||
let fragment_code = rewrite(&fragment_text, ShaderStage::Fragment, glue, &mut names)
|
||||
.map_err(|e| format!("fragment shader: {e}"))?;
|
||||
let vertex = compile(ShaderStage::Vertex, &vertex_code, defines)
|
||||
let vertex = compile(ShaderStage::Vertex, &vertex_code, defines, &names, provided)
|
||||
.map_err(|e| format!("vertex shader: {e}"))?;
|
||||
let fragment = compile(ShaderStage::Fragment, &fragment_code, defines)
|
||||
.map_err(|e| format!("fragment shader: {e}"))?;
|
||||
let fragment = compile(
|
||||
ShaderStage::Fragment,
|
||||
&fragment_code,
|
||||
defines,
|
||||
&names,
|
||||
provided,
|
||||
)
|
||||
.map_err(|e| format!("fragment shader: {e}"))?;
|
||||
let given = locations(&vertex, false);
|
||||
if let Some(missing) = locations(&fragment, true)
|
||||
.into_iter()
|
||||
@@ -88,7 +117,11 @@ pub fn translate_core(
|
||||
"the fragment shader reads location {missing}, which the vertex shader doesn't write"
|
||||
));
|
||||
}
|
||||
Ok(CoreProgram { vertex, fragment })
|
||||
Ok(CoreProgram {
|
||||
vertex,
|
||||
fragment,
|
||||
other: names.other,
|
||||
})
|
||||
}
|
||||
|
||||
/// A bound on a pack's includes, which are pasted every time.
|
||||
@@ -309,7 +342,9 @@ fn block_name(line: &str) -> Option<&str> {
|
||||
let rest = line[at + "uniform".len()..].trim();
|
||||
let name_end = rest.find(|c: char| !is_ident(c)).unwrap_or(rest.len());
|
||||
let (name, tail) = rest.split_at(name_end);
|
||||
(!name.is_empty() && matches!(tail.trim(), "" | "{")).then_some(name)
|
||||
// `{` alone, or the whole block on the line (`uniform Day { float daylight; };`).
|
||||
(!name.is_empty() && (tail.trim().is_empty() || tail.trim_start().starts_with('{')))
|
||||
.then_some(name)
|
||||
}
|
||||
|
||||
/// What the two stages share: the vertex stage's outputs and their locations, and the names of
|
||||
@@ -337,7 +372,7 @@ impl Names {
|
||||
self.other.len() - 1
|
||||
}
|
||||
};
|
||||
OTHER_BINDINGS + 2 * index as u32
|
||||
other_binding(index)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,7 +586,13 @@ fn rename_main(text: &str) -> String {
|
||||
}
|
||||
|
||||
/// Parses and validates one stage; errors name the line of the rewritten code.
|
||||
fn compile(stage: ShaderStage, code: &str, defines: &[&str]) -> Result<naga::Module, String> {
|
||||
fn compile(
|
||||
stage: ShaderStage,
|
||||
code: &str,
|
||||
defines: &[&str],
|
||||
names: &Names,
|
||||
provided: &dyn Fn(&str) -> bool,
|
||||
) -> Result<naga::Module, String> {
|
||||
let mut options = Options::from(stage);
|
||||
for define in defines {
|
||||
let (name, value) = define.split_once(' ').unwrap_or((define, ""));
|
||||
@@ -583,16 +624,16 @@ fn compile(stage: ShaderStage, code: &str, defines: &[&str]) -> Result<naga::Mod
|
||||
})?;
|
||||
let entry = info.get_entry_point(0);
|
||||
for (handle, global) in module.global_variables.iter() {
|
||||
let other = global
|
||||
.binding
|
||||
.as_ref()
|
||||
.is_some_and(|b| b.binding >= OTHER_BINDINGS);
|
||||
if other && !entry[handle].is_empty() {
|
||||
let name = global.name.as_deref().unwrap_or("?");
|
||||
let name = name
|
||||
.strip_prefix("hz_tex_")
|
||||
.or_else(|| name.strip_prefix("hz_smp_"))
|
||||
.unwrap_or(name);
|
||||
let Some(binding) = global.binding.as_ref().map(|b| b.binding) else {
|
||||
continue;
|
||||
};
|
||||
if binding >= OTHER_BINDINGS && !entry[handle].is_empty() {
|
||||
// The name it was declared with (a block's instance may have none).
|
||||
let index = ((binding - OTHER_BINDINGS) / 2) as usize;
|
||||
let name = names.other.get(index).map_or("?", String::as_str);
|
||||
if provided(name) {
|
||||
continue;
|
||||
}
|
||||
return Err(format!("{name} is not a block or sampler of this pipeline"));
|
||||
}
|
||||
}
|
||||
@@ -868,6 +909,63 @@ mod tests {
|
||||
translate_core(vsh, fsh, &[], &include, &GLUE).unwrap();
|
||||
}
|
||||
|
||||
/// A block declared on one line, as packs write a one-float uniform, is bound like one
|
||||
/// over several lines; the blocks and samplers the caller provides are accepted, in binding
|
||||
/// order, and one it doesn't provide is not.
|
||||
#[test]
|
||||
fn a_block_on_one_line_is_bound_and_provided_blocks_are_accepted() {
|
||||
let vsh = "#version 330\nlayout(std140) uniform Day { float daylight; };\nout vec4 vertexColor;\n\
|
||||
void main() { vertexColor = vec4(daylight); gl_Position = vec4(0.0); }\n";
|
||||
let fsh = "#version 330\nuniform sampler2D Bounds;\nin vec4 vertexColor;\nout vec4 fragColor;\n\
|
||||
void main() { fragColor = vertexColor * texture(Bounds, vec2(0.5)); }\n";
|
||||
let both = |name: &str| name == "Day" || name == "Bounds";
|
||||
let program = translate_core_with(vsh, fsh, &[], &include, &GLUE, &both).unwrap();
|
||||
assert_eq!(program.other, ["Day", "Bounds"]);
|
||||
// The bindings the caller gives them at.
|
||||
let bindings = |module: &naga::Module| -> Vec<(Option<String>, u32)> {
|
||||
module
|
||||
.global_variables
|
||||
.iter()
|
||||
.filter_map(|(_, g)| Some((g.name.clone(), g.binding.as_ref()?.binding)))
|
||||
.collect()
|
||||
};
|
||||
assert!(
|
||||
bindings(&program.vertex)
|
||||
.iter()
|
||||
.any(|(_, b)| *b == other_binding(0)),
|
||||
"{:?}",
|
||||
bindings(&program.vertex)
|
||||
);
|
||||
let fragment = bindings(&program.fragment);
|
||||
assert!(
|
||||
fragment.contains(&(Some("hz_tex_Bounds".into()), other_binding(1))),
|
||||
"{fragment:?}"
|
||||
);
|
||||
assert!(
|
||||
fragment.contains(&(Some("hz_smp_Bounds".into()), other_binding(1) + 1)),
|
||||
"{fragment:?}"
|
||||
);
|
||||
let error = translate_core(vsh, fsh, &[], &include, &GLUE).unwrap_err();
|
||||
assert!(error.contains("Day is not a block or sampler"), "{error}");
|
||||
let only_day = |name: &str| name == "Day";
|
||||
let error = translate_core_with(vsh, fsh, &[], &include, &GLUE, &only_day).unwrap_err();
|
||||
assert!(
|
||||
error.contains("Bounds is not a block or sampler"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Only a uniform block's declaration names a block, written over several lines or on one.
|
||||
#[test]
|
||||
fn block_names_are_read_from_block_declarations_only() {
|
||||
assert_eq!(block_name("layout(std140) uniform Day {"), Some("Day"));
|
||||
assert_eq!(block_name("uniform Day { float daylight; };"), Some("Day"));
|
||||
assert_eq!(block_name("uniform Foo{"), Some("Foo"));
|
||||
assert_eq!(block_name("uniform Globals"), Some("Globals"));
|
||||
assert_eq!(block_name("uniform float x;"), None);
|
||||
assert_eq!(block_name("uniform sampler2D Sampler0;"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_horizon_does_not_provide_is_an_error() {
|
||||
let fsh = "#version 330\nin vec4 vertexColor;\nout vec4 fragColor;\nvoid main() { fragColor = vertexColor; }\n";
|
||||
|
||||
@@ -36,7 +36,7 @@ struct Globals {
|
||||
// w: apply the no-pack HDR gain; HDR_WRITE_LIGHT independently keeps float alpha as coverage.
|
||||
hdr_light: vec4<f32>,
|
||||
hdr_settings: vec4<f32>,
|
||||
hdr_shares: vec4<f32>,
|
||||
hdr_gains: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||
@group(0) @binding(1) var atlas: texture_2d<f32>;
|
||||
@@ -292,7 +292,7 @@ fn entity_written_color(in: Out, color: vec4<f32>) -> vec4<f32> {
|
||||
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 vec4(hdr_written(color.rgb, mark, globals.hdr_settings, globals.hdr_gains), color.a);
|
||||
}
|
||||
if FRAME_LINEAR && (in.overlay == 11u || in.overlay == 12u) {
|
||||
// The cracks scale what is under them by twice their colour (CRUMBLING_BLEND). Vanilla's
|
||||
@@ -451,7 +451,7 @@ fn celestial_color(in: Out) -> vec4<f32> {
|
||||
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
|
||||
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 vec4(hdr_written(color.rgb, hdr_entity_mark(in), globals.hdr_settings, globals.hdr_gains), color.a);
|
||||
}
|
||||
return frame_stored(color);
|
||||
}
|
||||
@@ -472,7 +472,7 @@ fn celestial_color(in: Out) -> vec4<f32> {
|
||||
// or it would come out darker than the sky around it.
|
||||
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);
|
||||
return vec4(hdr_written(added, select(1.0, hdr_mark(tier, y), full), globals.hdr_settings, globals.hdr_gains), color.a);
|
||||
}
|
||||
// Premultiplied after the decode: the light the texel adds, at its coverage.
|
||||
if HDR_WRITE_LIGHT { return vec4(frame_stored(color).rgb * color.a, color.a); }
|
||||
|
||||
@@ -192,6 +192,7 @@ fn packed_wanted() -> bool {
|
||||
pub(crate) mod animate;
|
||||
mod arena;
|
||||
pub mod capture;
|
||||
mod census;
|
||||
pub mod compositor;
|
||||
pub(crate) mod core_lines;
|
||||
pub(crate) mod core_sky;
|
||||
@@ -209,6 +210,7 @@ mod hdr_icc;
|
||||
mod hdr_start;
|
||||
mod hdr_stats;
|
||||
pub(crate) mod hud_coverage;
|
||||
mod in_flight;
|
||||
pub use hdr::{Calibration as HdrCalibration, Output as HdrOutput};
|
||||
mod hold;
|
||||
pub use hold::WorldHold;
|
||||
@@ -246,9 +248,11 @@ mod pack_post;
|
||||
mod pack_shadow;
|
||||
mod pack_sky;
|
||||
mod pack_textures;
|
||||
pub(crate) mod pack_timing;
|
||||
pub mod pack_timing;
|
||||
pub mod pack_uniforms;
|
||||
mod pack_weather;
|
||||
mod pass_timing_api;
|
||||
use pass_timing_api::{after_culling_pass, pixel_count, world_pass_after_culling};
|
||||
mod page_runs;
|
||||
pub mod panorama;
|
||||
mod readback;
|
||||
@@ -337,42 +341,6 @@ fn surface_pass<'e>(
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
fn world_pass_after_culling<'e>(
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
view: &wgpu::TextureView,
|
||||
depth: &wgpu::TextureView,
|
||||
keep_depth: bool,
|
||||
timing: Option<&mut pack_timing::PassTiming>,
|
||||
name: &str,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon world, after culling"),
|
||||
timestamp_writes: timing.and_then(|t| t.writes(name)),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
// A shader pack reads it as depthtex0.
|
||||
store: if keep_depth {
|
||||
wgpu::StoreOp::Store
|
||||
} else {
|
||||
wgpu::StoreOp::Discard
|
||||
},
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
/// The point a `shaders/` pack's terrain is drawn relative to: the camera with the fraction of
|
||||
/// each coordinate rounded to the nearest 1/65536 block. Read back with a probe pack of ours,
|
||||
/// Iris 1.11.7's terrain `gl_Vertex` is the block's position minus this, for cameras from 13
|
||||
@@ -427,10 +395,10 @@ struct Globals {
|
||||
/// full (`hdr::Hdr::windows`); zero without HDR.
|
||||
hdr_windows: [[f32; 4]; 2],
|
||||
hdr_light: [f32; 4],
|
||||
/// Headroom, effective scene gain, world/look and debug, as HDR output's pass has them
|
||||
/// The scene's peak, HDR lighting's gain, world/look and debug; then the tiers' gains
|
||||
/// (`Hdr::draw_params`).
|
||||
hdr_settings: [f32; 4],
|
||||
hdr_shares: [f32; 4],
|
||||
hdr_gains: [f32; 4],
|
||||
}
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
@@ -1473,10 +1441,14 @@ pub struct Renderer {
|
||||
pack_loading: Option<PackLoading>,
|
||||
/// The post passes of one of Horizon's own shader packs; never beside `pack_post`'s.
|
||||
native: Option<native_pack::NativePack>,
|
||||
/// Time the shader pack's passes on the GPU (F3, or `HORIZON_SHADER_PACK_TIMING`).
|
||||
/// Time the shader pack's passes on the GPU (F3, or a capture made with `--gpu`).
|
||||
pass_timing: bool,
|
||||
/// Times the parts of passes too (`set_pass_detail`).
|
||||
pass_detail: bool,
|
||||
/// Least time between timed frames (`set_pass_timing_gap`).
|
||||
pass_timing_gap: std::time::Duration,
|
||||
/// Whether the timers keep every timed frame for `take_timed_frames`.
|
||||
keep_timed_frames: bool,
|
||||
/// Times Horizon's own passes on the GPU, as `pass_timing` does (`own_pass_times`).
|
||||
own_timing: Option<pack_timing::PassTiming>,
|
||||
/// The entity vertices Horizon draws itself while a shader pack draws the others.
|
||||
@@ -2547,160 +2519,6 @@ enum FirstFrame {
|
||||
fn on_off(on: bool) -> &'static str {
|
||||
if on { "on" } else { "off" }
|
||||
}
|
||||
impl Renderer {
|
||||
/// Sections with GPU buffers, for soak tests watching residency.
|
||||
pub fn resident_sections(&self) -> usize {
|
||||
self.sections.len()
|
||||
}
|
||||
/// Quads of the far terrain tiles with GPU buffers.
|
||||
pub fn lod_quads(&self) -> u64 {
|
||||
self.lods
|
||||
.values()
|
||||
.map(|tile| u64::from(tile.mesh.solid + tile.mesh.translucent))
|
||||
.sum()
|
||||
}
|
||||
/// Tiles and quads of the far terrain tiles with GPU buffers, by level.
|
||||
pub fn lod_levels(&self) -> Vec<[u64; 2]> {
|
||||
let mut levels = Vec::new();
|
||||
for (key, tile) in &self.lods {
|
||||
let level = usize::from(key.level);
|
||||
if levels.len() <= level {
|
||||
levels.resize(level + 1, [0, 0]);
|
||||
}
|
||||
levels[level][0] += 1;
|
||||
levels[level][1] += u64::from(tile.mesh.solid + tile.mesh.translucent);
|
||||
}
|
||||
levels
|
||||
}
|
||||
/// Whether solid quads go to the arena in the compact layout
|
||||
/// (`packed_quads`), and whether far tiles' own records are on
|
||||
/// (`lod::packed::far_packing`).
|
||||
pub fn packed_layout(&self) -> [bool; 2] {
|
||||
[self.packed_quads, crate::lod::packed::far_packing()]
|
||||
}
|
||||
/// Bytes of the arena the resident chunk sections hold, and of the one far
|
||||
/// tiles hold: the same split `gpu_census` reports, for `status`, so the
|
||||
/// compact layouts can be seen working.
|
||||
pub fn vertex_bytes_split(&self) -> [u64; 2] {
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
let used = |s: &SectionGpu| (s.solid_slots + u64::from(s.translucent) * 4) * stride;
|
||||
[
|
||||
self.sections.values().map(used).sum(),
|
||||
self.lods.values().map(|tile| used(&tile.mesh)).sum(),
|
||||
]
|
||||
}
|
||||
/// Bytes of the section vertex arenas' buffers, and of the vertices
|
||||
/// resident sections hold in them.
|
||||
pub fn section_vertex_bytes(&self) -> [u64; 2] {
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
let vertices: u64 = self
|
||||
.sections
|
||||
.values()
|
||||
.chain(self.lods.values().map(|tile| &*tile.mesh))
|
||||
.map(|s| s.solid_slots + u64::from(s.translucent) * 4)
|
||||
.sum();
|
||||
let translucent = self.translucent_arena.as_ref();
|
||||
let arenas = self.arena.bytes() + translucent.map_or(0, arena::Arena::bytes);
|
||||
[arenas, vertices * stride]
|
||||
}
|
||||
/// Bytes of the renderer's largest GPU resources, by name (`scene_replay --memory`):
|
||||
/// the terrain arenas (their pages, and what resident sections and far tiles use of
|
||||
/// them), the quad indices, the block sprites with their mips, the LabPBR maps, flat
|
||||
/// draws' buffers and the visibility buffer's.
|
||||
pub fn gpu_census(&self) -> Vec<(&'static str, u64)> {
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
let used = |s: &SectionGpu| (s.solid_slots + u64::from(s.translucent) * 4) * stride;
|
||||
let textures = |t: &[wgpu::Texture]| t.iter().map(texture_bytes).sum::<u64>();
|
||||
let translucent = self.translucent_arena.as_ref();
|
||||
vec![
|
||||
("section_arena_pages", self.arena.bytes()),
|
||||
(
|
||||
"translucent_arena_pages",
|
||||
translucent.map_or(0, arena::Arena::bytes),
|
||||
),
|
||||
("sorted_index_pages", self.sorted_indices.bytes()),
|
||||
(
|
||||
"near_section_vertices",
|
||||
self.sections.values().map(used).sum(),
|
||||
),
|
||||
(
|
||||
"held_section_vertices",
|
||||
self.hold
|
||||
.sections()
|
||||
.filter_map(|held| held.gpu.as_ref())
|
||||
.map(used)
|
||||
.sum(),
|
||||
),
|
||||
(
|
||||
"far_tile_vertices",
|
||||
self.lods.values().map(|tile| used(&tile.mesh)).sum(),
|
||||
),
|
||||
("quad_indices", self.indices.size()),
|
||||
("block_sprites", textures(&self.sprite_textures)),
|
||||
(
|
||||
"pbr_maps",
|
||||
self.pbr_maps
|
||||
.as_ref()
|
||||
.map_or(0, |maps| maps.iter().flatten().map(texture_bytes).sum()),
|
||||
),
|
||||
(
|
||||
"flat_draws",
|
||||
self.flat.as_ref().map_or(0, flat::Flat::bytes),
|
||||
),
|
||||
(
|
||||
"visibility_buffer",
|
||||
self.visbuf.as_ref().map_or(0, visbuffer::Visbuffer::bytes),
|
||||
),
|
||||
]
|
||||
}
|
||||
/// Device memory the backend's allocator holds, by the label of what holds
|
||||
/// it, largest first, with its allocated and reserved totals (Vulkan and
|
||||
/// DirectX 12; none on Metal and OpenGL) (`horizon-ctl memory`).
|
||||
pub fn gpu_memory_by_label(&self) -> Option<(u64, u64, Vec<(String, u64)>)> {
|
||||
let report = self.device.generate_allocator_report()?;
|
||||
let mut by_label: HashMap<String, u64> = HashMap::new();
|
||||
for a in report.allocations {
|
||||
*by_label.entry(a.name).or_default() += a.size;
|
||||
}
|
||||
let mut labels: Vec<_> = by_label.into_iter().collect();
|
||||
labels.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
|
||||
Some((
|
||||
report.total_allocated_bytes,
|
||||
report.total_reserved_bytes,
|
||||
labels,
|
||||
))
|
||||
}
|
||||
/// Heap bytes of the renderer's per-section bookkeeping (`horizon-ctl memory`):
|
||||
/// resident sections, far tiles, block entities and light sources by section.
|
||||
pub fn cpu_census(&self) -> [(&'static str, usize); 4] {
|
||||
use std::mem::size_of;
|
||||
let per_section = |map_len: usize, lists: usize| {
|
||||
map_len * (size_of::<SectionKey>() + size_of::<Vec<([i32; 3], u32)>>() + 1)
|
||||
+ lists * size_of::<([i32; 3], u32)>()
|
||||
};
|
||||
let lists = |map: &crate::fast_hash::FxHashMap<SectionKey, Vec<([i32; 3], u32)>>| {
|
||||
per_section(
|
||||
map.capacity(),
|
||||
map.values().map(Vec::capacity).sum::<usize>(),
|
||||
)
|
||||
};
|
||||
let animated: usize = self
|
||||
.sections
|
||||
.values()
|
||||
.map(|s| s.animated_layers.len() * size_of::<u16>())
|
||||
.sum();
|
||||
[
|
||||
("resident_sections", self.sections.heap_bytes() + animated),
|
||||
(
|
||||
"far_tiles",
|
||||
self.lods.capacity()
|
||||
* (size_of::<crate::lod::TileKey>() + size_of::<lod::Tile>() + 1),
|
||||
),
|
||||
("block_entities", lists(&self.block_entities)),
|
||||
("light_sources", lists(&self.light_blocks)),
|
||||
]
|
||||
}
|
||||
}
|
||||
impl Renderer {
|
||||
pub async fn new(
|
||||
window: Arc<Window>,
|
||||
@@ -5239,8 +5057,10 @@ impl Renderer {
|
||||
pack_post: None,
|
||||
pack_loading: None,
|
||||
native: None,
|
||||
pass_timing: pack_timing::PassTiming::logging(),
|
||||
pass_timing: false,
|
||||
pass_detail: false,
|
||||
pass_timing_gap: std::time::Duration::ZERO,
|
||||
keep_timed_frames: false,
|
||||
own_timing: None,
|
||||
pack_entity_rest: Vec::new(),
|
||||
cloud_bindings,
|
||||
@@ -7602,51 +7422,6 @@ impl Renderer {
|
||||
.terrain_inputs(self.pack_terrain_flat),
|
||||
)
|
||||
}
|
||||
/// Times the shader pack's passes on the GPU while `on` (the F3 overlay is open), or always
|
||||
/// with `HORIZON_SHADER_PACK_TIMING`.
|
||||
pub fn set_pass_timing(&mut self, on: bool) {
|
||||
self.pass_timing = on || pack_timing::PassTiming::logging();
|
||||
}
|
||||
/// Times a frame at most once per `gap` while passes are timed (the F3 overlay needs far fewer
|
||||
/// samples than a measurement). `HORIZON_SHADER_PACK_TIMING` keeps every sample.
|
||||
pub fn set_pass_timing_gap(&mut self, gap: std::time::Duration) {
|
||||
self.pass_timing_gap = if pack_timing::PassTiming::logging() {
|
||||
std::time::Duration::ZERO
|
||||
} else {
|
||||
gap
|
||||
};
|
||||
}
|
||||
/// The running shader pack's timer, of either kind.
|
||||
fn pass_timer<'a>(
|
||||
pack: &'a mut Option<(i32, Option<pack_post::PackPost>)>,
|
||||
native: &'a mut Option<native_pack::NativePack>,
|
||||
) -> Option<&'a mut Option<pack_timing::PassTiming>> {
|
||||
match (pack, native) {
|
||||
(Some((_, Some(post))), _) => Some(&mut post.timing),
|
||||
(_, Some(native)) => Some(&mut native.timing),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Each pass of the running shader pack with its median GPU time in milliseconds, while
|
||||
/// they are timed (`set_pass_timing`).
|
||||
pub fn shader_pass_times(&self) -> Vec<(String, f32)> {
|
||||
let timer = match (&self.pack_post, &self.native) {
|
||||
(Some((_, Some(post))), _) => post.timing.as_ref(),
|
||||
(_, Some(native)) => native.timing.as_ref(),
|
||||
_ => None,
|
||||
};
|
||||
timer
|
||||
.map(pack_timing::PassTiming::medians)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
/// Each of Horizon's own passes with its median GPU time in milliseconds, while they are
|
||||
/// timed (`set_pass_timing`).
|
||||
pub fn own_pass_times(&self) -> Vec<(String, f32)> {
|
||||
self.own_timing
|
||||
.as_ref()
|
||||
.map(pack_timing::PassTiming::medians)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
/// The macros this renderer gives shader packs: the GL extensions this device has
|
||||
/// (subgroups, when it has them), and the vendor, renderer and mipmap levels
|
||||
/// (`Macros::define_renderer`).
|
||||
@@ -8255,6 +8030,8 @@ impl Renderer {
|
||||
}
|
||||
if let Some(t) = &mut self.own_timing {
|
||||
t.gap = self.pass_timing_gap;
|
||||
t.keep_frames(self.keep_timed_frames);
|
||||
t.set_detail(self.pass_detail);
|
||||
t.begin(&self.device, &self.queue);
|
||||
}
|
||||
if self.animated_textures {
|
||||
@@ -8291,14 +8068,14 @@ impl Renderer {
|
||||
self.shader_pack_kind().is_some(),
|
||||
!frame.title,
|
||||
);
|
||||
let (hdr_settings, hdr_shares) = self
|
||||
let (hdr_settings, hdr_gains) = self
|
||||
.hdr
|
||||
.as_ref()
|
||||
.map_or(([0.0; 4], [0.0; 4]), |h| h.draw_params(!frame.title));
|
||||
self.world_post.baked_light(
|
||||
hdr_gain,
|
||||
hdr_settings,
|
||||
hdr_shares,
|
||||
hdr_gains,
|
||||
self.hdr.as_ref().map_or([[0.0; 4]; 2], hdr::Hdr::windows),
|
||||
);
|
||||
let jitter = self.world_post.jitter(
|
||||
@@ -8458,7 +8235,7 @@ impl Renderer {
|
||||
f32::from(u8::from(hdr_gain)),
|
||||
],
|
||||
hdr_settings,
|
||||
hdr_shares,
|
||||
hdr_gains,
|
||||
};
|
||||
self.uploads
|
||||
.buffer(&mut encoder, &self.globals, 0, bytemuck::bytes_of(&globals));
|
||||
@@ -8674,6 +8451,7 @@ impl Renderer {
|
||||
&mut self.uploads,
|
||||
&mut encoder,
|
||||
&input,
|
||||
self.own_timing.as_mut(),
|
||||
)
|
||||
});
|
||||
// A frame whose planes leave the test's integers (the camera millions of blocks
|
||||
@@ -10022,6 +9800,7 @@ impl Renderer {
|
||||
}
|
||||
if let Some(Some(t)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
|
||||
t.gap = self.pass_timing_gap;
|
||||
t.keep_frames(self.keep_timed_frames);
|
||||
}
|
||||
let pack_reads_world = pack_post || native_post;
|
||||
laps.lap("r_uploads");
|
||||
@@ -10546,7 +10325,8 @@ impl Renderer {
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
let world_pixels = pixel_count(world_depth);
|
||||
let first_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon world"),
|
||||
timestamp_writes: self.own_timing.as_mut().and_then(|t| t.writes("World")),
|
||||
color_attachments: &attachments,
|
||||
@@ -10566,9 +10346,16 @@ impl Renderer {
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
let mut pass = pack_timing::counted(
|
||||
self.own_timing.as_mut(),
|
||||
first_pass,
|
||||
world_pixels,
|
||||
"before translucent",
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pack_frame(&mut pass);
|
||||
if frame.title || (sky_visible && (frame.skybox || frame.end_sky)) {
|
||||
pass.stage(self.own_timing.as_mut(), "sky");
|
||||
if !frame.title && frame.skybox && !pack_draws_sky && !native_sky_pass {
|
||||
match self
|
||||
.native
|
||||
@@ -10655,16 +10442,15 @@ impl Renderer {
|
||||
.forget_lifetime();
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pack_frame(&mut pass);
|
||||
pass
|
||||
pack_timing::CountedPass::plain(pass)
|
||||
};
|
||||
// The occlusion test's pipeline draws into the world's colour alone.
|
||||
let plain_pass = |encoder: &mut wgpu::CommandEncoder| {
|
||||
let mut pass =
|
||||
world_pass_after_culling(encoder, world_view, world_depth, true, None, "")
|
||||
.forget_lifetime();
|
||||
let mut pass = after_culling_pass(encoder, world_view, world_depth, true, None)
|
||||
.forget_lifetime();
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pack_frame(&mut pass);
|
||||
pass
|
||||
pack_timing::CountedPass::plain(pass)
|
||||
};
|
||||
if let Some(surface) = &surface {
|
||||
drop(pass);
|
||||
@@ -10718,6 +10504,7 @@ impl Renderer {
|
||||
keep_depth,
|
||||
self.own_timing.as_mut(),
|
||||
"World after visibility buffer",
|
||||
world_pixels,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pack_frame(&mut pass);
|
||||
@@ -10750,6 +10537,7 @@ impl Renderer {
|
||||
flat.draw(pass, &flat.calls[calls.clone()], vertices);
|
||||
}
|
||||
};
|
||||
pass.stage(self.own_timing.as_mut(), "solid terrain");
|
||||
match cull {
|
||||
// As below: phase 1, the test, then phase 2's draws
|
||||
// that the test saw, copied after it.
|
||||
@@ -10759,6 +10547,8 @@ impl Renderer {
|
||||
drop(pass);
|
||||
pass = plain_pass(&mut encoder);
|
||||
}
|
||||
pass.split(self.own_timing.as_mut(), "occlusion boxes");
|
||||
pass.stage(self.own_timing.as_mut(), "occlusion boxes");
|
||||
cull.test(&mut pass);
|
||||
drop(pass);
|
||||
cull.test_pass(&mut encoder, self.own_timing.as_mut());
|
||||
@@ -10772,10 +10562,12 @@ impl Renderer {
|
||||
keep_depth,
|
||||
self.own_timing.as_mut(),
|
||||
"World after culling",
|
||||
world_pixels,
|
||||
),
|
||||
};
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pack_frame(&mut pass);
|
||||
pass.stage(self.own_timing.as_mut(), "solid terrain phase 2");
|
||||
for (pipeline, _, calls) in passes {
|
||||
pass.set_pipeline(pipeline);
|
||||
flat.draw_phase2(&mut pass, calls.clone(), vertices);
|
||||
@@ -10813,6 +10605,7 @@ impl Renderer {
|
||||
let pulled = material.is_none() && self.flat.is_some();
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.set_vertex_buffer(1, self.instances.slice(..));
|
||||
pass.stage(self.own_timing.as_mut(), "solid terrain");
|
||||
if let Some(group) = warp_group {
|
||||
pass.set_bind_group(2, group, &[]);
|
||||
}
|
||||
@@ -10837,6 +10630,8 @@ impl Renderer {
|
||||
drop(pass);
|
||||
pass = plain_pass(&mut encoder);
|
||||
}
|
||||
pass.split(self.own_timing.as_mut(), "occlusion boxes");
|
||||
pass.stage(self.own_timing.as_mut(), "occlusion boxes");
|
||||
cull.test(&mut pass);
|
||||
drop(pass);
|
||||
cull.test_pass(&mut encoder, self.own_timing.as_mut());
|
||||
@@ -10849,6 +10644,7 @@ impl Renderer {
|
||||
keep_depth,
|
||||
self.own_timing.as_mut(),
|
||||
"World after culling",
|
||||
world_pixels,
|
||||
),
|
||||
};
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
@@ -10858,6 +10654,7 @@ impl Renderer {
|
||||
wgpu::IndexFormat::Uint32,
|
||||
);
|
||||
pass.set_vertex_buffer(1, self.instances.slice(..));
|
||||
pass.stage(self.own_timing.as_mut(), "solid terrain phase 2");
|
||||
for (pipeline, _, draws) in passes {
|
||||
pass.set_pipeline(pipeline);
|
||||
self.draw_sections_from(
|
||||
@@ -10905,6 +10702,8 @@ impl Renderer {
|
||||
drop(pass);
|
||||
pass = plain_pass(&mut encoder);
|
||||
}
|
||||
pass.split(self.own_timing.as_mut(), "occlusion boxes");
|
||||
pass.stage(self.own_timing.as_mut(), "occlusion boxes");
|
||||
gpu.test(&mut pass);
|
||||
drop(pass);
|
||||
gpu.compact(&mut encoder, self.own_timing.as_mut());
|
||||
@@ -10917,6 +10716,7 @@ impl Renderer {
|
||||
keep_depth,
|
||||
self.own_timing.as_mut(),
|
||||
"World after culling",
|
||||
world_pixels,
|
||||
),
|
||||
};
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
@@ -10925,6 +10725,7 @@ impl Renderer {
|
||||
self.indices.slice(..),
|
||||
wgpu::IndexFormat::Uint32,
|
||||
);
|
||||
pass.stage(self.own_timing.as_mut(), "solid terrain phase 2");
|
||||
for (pipeline, draws, _) in passes {
|
||||
pass.set_pipeline(pipeline);
|
||||
gpu.draw(&mut pass, &self.arena, page_group, 1);
|
||||
@@ -10951,6 +10752,7 @@ impl Renderer {
|
||||
&& let Some(d) = &n.displacement
|
||||
&& let Some(pipeline) = n.material.as_ref().and_then(|m| m.displaced.as_ref())
|
||||
{
|
||||
pass.stage(self.own_timing.as_mut(), "displacement");
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pack_frame(&mut pass);
|
||||
d.draw(&mut pass, &d.view, pipeline);
|
||||
@@ -10967,6 +10769,7 @@ impl Renderer {
|
||||
let hdr_entity_mark =
|
||||
warped.map_or(&self.hdr_entity_mark_pipeline, |w| &w.hdr_entity_mark);
|
||||
if static_entity_quads > 0 {
|
||||
pass.stage(self.own_timing.as_mut(), "static block entities");
|
||||
pass.set_pipeline(
|
||||
warped.map_or(&self.static_entity_pipeline, |w| &w.static_entities),
|
||||
);
|
||||
@@ -10992,6 +10795,7 @@ impl Renderer {
|
||||
}
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
if entity_quads > 0 {
|
||||
pass.stage(self.own_timing.as_mut(), "entities");
|
||||
pass.set_pipeline(warped.map_or(&self.entity_pipeline, |w| &w.entities));
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
@@ -11003,6 +10807,7 @@ impl Renderer {
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if text_quads > 0 && !oit_on {
|
||||
pass.stage(self.own_timing.as_mut(), "entity text");
|
||||
pass.set_pipeline(warped.map_or(&self.text_polygon_offset_pipeline, |w| &w.text));
|
||||
self.bind_world_text(&mut pass, warped.is_none(), false);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
@@ -11012,6 +10817,7 @@ impl Renderer {
|
||||
// FeatureRenderDispatcher.PreparedFrame.executeTranslucent: the shadows
|
||||
// come before the translucent items, which then cover them (#7026).
|
||||
if shadow_quads > 0 && !oit_on {
|
||||
pass.stage(self.own_timing.as_mut(), "shadows");
|
||||
pass.set_pipeline(warped.map_or(&self.shadow_blob_pipeline, |w| &w.entity_shadows));
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
@@ -11027,6 +10833,7 @@ impl Renderer {
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if translucent_item_quads > 0 && !oit_on {
|
||||
pass.stage(self.own_timing.as_mut(), "translucent items");
|
||||
pass.set_pipeline(
|
||||
warped.map_or(&self.translucent_item_pipeline, |w| &w.translucent_items),
|
||||
);
|
||||
@@ -11062,6 +10869,7 @@ impl Renderer {
|
||||
.enumerate()
|
||||
{
|
||||
if count > 0 {
|
||||
pass.stage(self.own_timing.as_mut(), "terrain models");
|
||||
pass.set_pipeline(&self.terrain_model_pipelines[i]);
|
||||
pass.set_bind_group(
|
||||
0,
|
||||
@@ -11082,6 +10890,7 @@ impl Renderer {
|
||||
// translucent material, before terrain transparency. This material
|
||||
// always writes depth and never joins the improved-transparency queues.
|
||||
if fluid_quads > 0 {
|
||||
pass.stage(self.own_timing.as_mut(), "fluid block entities");
|
||||
pass.set_pipeline(&self.fluid_pipeline);
|
||||
pass.set_bind_group(0, &self.fluid_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
@@ -11094,6 +10903,7 @@ impl Renderer {
|
||||
}
|
||||
// Cracks multiply the existing light; scalar OIT coverage cannot carry that factor.
|
||||
if crumbling_quads > 0 && (!oit_on || hdr_gain) {
|
||||
pass.stage(self.own_timing.as_mut(), "block breaking");
|
||||
pass.set_pipeline(&self.crumbling_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
@@ -11107,6 +10917,7 @@ impl Renderer {
|
||||
// BoatRenderer's water patch (RenderTypes.waterMask): depth only,
|
||||
// before translucent terrain, so no water shows inside the boat.
|
||||
if water_mask_quads > 0 && !oit_on {
|
||||
pass.stage(self.own_timing.as_mut(), "boat water mask");
|
||||
pass.set_pipeline(&self.water_mask_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
@@ -11119,6 +10930,7 @@ impl Renderer {
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if !frame.lightning.bolts.is_empty() && !oit_on {
|
||||
pass.stage(self.own_timing.as_mut(), "lightning");
|
||||
pass.set_pipeline(&self.lightning_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
self.lightning.draw(&mut pass, &frame.lightning);
|
||||
@@ -11129,6 +10941,7 @@ impl Renderer {
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if swirl_quads > 0 && !oit_on {
|
||||
pass.stage(self.own_timing.as_mut(), "energy swirl");
|
||||
pass.set_pipeline(&self.energy_swirl_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
@@ -11145,6 +10958,7 @@ impl Renderer {
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if additive_quads > 0 && !oit_on {
|
||||
pass.stage(self.own_timing.as_mut(), "additive entities");
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
let mut first = 0;
|
||||
@@ -11170,20 +10984,24 @@ impl Renderer {
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
if outline_edges > 0 && !frame.outline_after_terrain && !pack_draws_outline {
|
||||
pass.stage(self.own_timing.as_mut(), "block outline");
|
||||
self.draw_outline(&mut pass, outline_edges as u32, frame.outline_high_contrast);
|
||||
}
|
||||
// DrawableGizmoPrimitives' opaque group writes depth, the translucent one does not.
|
||||
if debug_opaque > 0 {
|
||||
pass.stage(self.own_timing.as_mut(), "debug lines");
|
||||
pass.set_pipeline(&self.debug_lines_opaque);
|
||||
pass.set_vertex_buffer(0, self.debug_line_buffer.slice(..));
|
||||
pass.draw(0..4, 0..debug_opaque);
|
||||
}
|
||||
if debug_depth > debug_opaque {
|
||||
pass.stage(self.own_timing.as_mut(), "debug lines");
|
||||
pass.set_pipeline(&self.debug_lines);
|
||||
pass.set_vertex_buffer(0, self.debug_line_buffer.slice(..));
|
||||
pass.draw(0..4, debug_opaque..debug_depth);
|
||||
}
|
||||
if debug_top > 0 {
|
||||
pass.stage(self.own_timing.as_mut(), "debug lines");
|
||||
pass.set_pipeline(&self.debug_lines_on_top);
|
||||
pass.set_vertex_buffer(0, self.debug_line_buffer.slice(..));
|
||||
pass.draw(0..4, debug_depth..debug_depth + debug_top);
|
||||
@@ -11211,6 +11029,7 @@ impl Renderer {
|
||||
keep_depth,
|
||||
self.own_timing.as_mut(),
|
||||
"World after copy",
|
||||
world_pixels,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pack_frame(&mut pass);
|
||||
@@ -11384,9 +11203,11 @@ impl Renderer {
|
||||
true,
|
||||
self.own_timing.as_mut(),
|
||||
"World after OIT",
|
||||
world_pixels,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.stage(self.own_timing.as_mut(), "OIT marks");
|
||||
if cloud_quads > 0 && !pack_draws_clouds {
|
||||
self.mark_clouds(&mut pass, cloud_quads);
|
||||
}
|
||||
@@ -11419,6 +11240,8 @@ impl Renderer {
|
||||
self.mark_translucent_terrain(&mut pass, flat, solid_draws, translucent_draws);
|
||||
}
|
||||
Some(flat) => {
|
||||
pass.split(self.own_timing.as_mut(), "translucent terrain");
|
||||
pass.stage(self.own_timing.as_mut(), "translucent terrain");
|
||||
pass.set_pipeline(pack_flat.map_or(&flat.translucent, |p| &p.1));
|
||||
flat.draw(
|
||||
&mut pass,
|
||||
@@ -11433,8 +11256,11 @@ impl Renderer {
|
||||
solid_draws,
|
||||
translucent_draws,
|
||||
);
|
||||
pass.split(self.own_timing.as_mut(), "after translucent");
|
||||
}
|
||||
None => {
|
||||
pass.split(self.own_timing.as_mut(), "translucent terrain");
|
||||
pass.stage(self.own_timing.as_mut(), "translucent terrain");
|
||||
pass.set_pipeline(translucent_pipeline);
|
||||
if let Some(group) = warp_group {
|
||||
pass.set_bind_group(2, group, &[]);
|
||||
@@ -11457,6 +11283,7 @@ impl Renderer {
|
||||
translucent_draws,
|
||||
);
|
||||
}
|
||||
pass.split(self.own_timing.as_mut(), "after translucent");
|
||||
}
|
||||
}
|
||||
// LevelRenderer.executeTranslucentAfterTerrain: vanilla's
|
||||
@@ -11464,6 +11291,7 @@ impl Renderer {
|
||||
// the quads write depth, so before it they would hide the glass
|
||||
// or water behind them.
|
||||
if translucent_particle_quads > 0 && !oit_on {
|
||||
pass.stage(self.own_timing.as_mut(), "translucent particles");
|
||||
pass.set_pipeline(warped.map_or(&self.entity_pipeline, |w| &w.entities));
|
||||
if let Some(group) = warp_group {
|
||||
pass.set_bind_group(2, group, &[]);
|
||||
@@ -11492,6 +11320,7 @@ impl Renderer {
|
||||
// particles (also after the OIT composite) and before the hand.
|
||||
// Its own geometry is independent of block-entity lifetime.
|
||||
if world_text_quads + world_text_offset_quads > 0 {
|
||||
pass.stage(self.own_timing.as_mut(), "world text");
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
@@ -11514,9 +11343,11 @@ impl Renderer {
|
||||
// LevelRenderer.executeClassicTransparency: translucent targets'
|
||||
// outlines follow terrain, so its blending cannot wash them out.
|
||||
if outline_edges > 0 && frame.outline_after_terrain && !pack_draws_outline {
|
||||
pass.stage(self.own_timing.as_mut(), "block outline");
|
||||
self.draw_outline(&mut pass, outline_edges as u32, frame.outline_high_contrast);
|
||||
}
|
||||
if translucent_crumbling_quads > 0 && (!oit_on || hdr_gain) {
|
||||
pass.stage(self.own_timing.as_mut(), "block breaking");
|
||||
pass.set_pipeline(&self.crumbling_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
@@ -11527,6 +11358,7 @@ impl Renderer {
|
||||
);
|
||||
}
|
||||
if cloud_quads > 0 && !pack_draws_clouds && !oit_on {
|
||||
pass.stage(self.own_timing.as_mut(), "clouds");
|
||||
pass.set_pipeline(&self.cloud_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_bind_group(1, &self.cloud_group, &[]);
|
||||
@@ -11538,6 +11370,7 @@ impl Renderer {
|
||||
// terrain and clouds, so the water behind the rain is drawn first.
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
if !pack_draws_weather && !oit_on {
|
||||
pass.stage(self.own_timing.as_mut(), "weather");
|
||||
let native_weather = self
|
||||
.native
|
||||
.as_ref()
|
||||
@@ -11549,6 +11382,7 @@ impl Renderer {
|
||||
}
|
||||
// LevelRenderer: the world border after translucent terrain and weather.
|
||||
if let Some(b) = frame.border.as_ref().filter(|_| !frame.title && !oit_on) {
|
||||
pass.stage(self.own_timing.as_mut(), "world border");
|
||||
pass.set_pipeline(&self.border_pipeline);
|
||||
pass.set_bind_group(0, &self.border_group, &[]);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
@@ -11561,6 +11395,7 @@ impl Renderer {
|
||||
}
|
||||
// LevelRenderer.executeSeeThrough, after the rest of the world.
|
||||
if see_through_quads > 0 {
|
||||
pass.stage(self.own_timing.as_mut(), "see-through");
|
||||
pass.set_pipeline(&self.see_through_pipeline);
|
||||
self.bind_world_text(&mut pass, true, true);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
@@ -11573,9 +11408,11 @@ impl Renderer {
|
||||
// Native mods' primitives: after the entities and the rest of the world, into its
|
||||
// targets (its resolution, reversed depth and TAA), before the hand.
|
||||
if world_primitives {
|
||||
pass.stage(self.own_timing.as_mut(), "mod primitives");
|
||||
self.world_primitives.draw(&mut pass);
|
||||
}
|
||||
if hand_in_world {
|
||||
pass.stage(self.own_timing.as_mut(), "hand");
|
||||
pass.set_pipeline(&self.depth_reset);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
@@ -12515,130 +12352,6 @@ pub(crate) fn copy_frame(
|
||||
);
|
||||
buffer
|
||||
}
|
||||
impl Renderer {
|
||||
/// The frame's readbacks to start right after its submission (on the submit thread when
|
||||
/// the frame goes there): occlusion culling's results (`cull`) and GPU culling's
|
||||
/// statistics. GPU frames leave occlusion culling off, so a frame has one at most.
|
||||
fn after_submit(&mut self) -> Option<submit::AfterSubmit> {
|
||||
let cull = self.cull.as_mut().and_then(|c| c.submitted());
|
||||
let gpu = self.gpu_culling.as_mut().and_then(|g| g.submitted());
|
||||
match (cull, gpu) {
|
||||
(Some(c), Some(g)) => Some(Box::new(move || {
|
||||
c();
|
||||
g();
|
||||
})),
|
||||
(Some(c), None) => Some(Box::new(c)),
|
||||
(None, Some(g)) => Some(Box::new(g)),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
/// Frame `frame`'s statistics shown, unless a newer frame's are: a CPU frame's as it is
|
||||
/// drawn, a GPU frame's when its counts come back (`GpuCulling::collect`).
|
||||
fn show_stats(&mut self, frame: u64, [sections, draws, calls]: [usize; 3]) {
|
||||
if frame > self.stats_frame {
|
||||
self.stats_frame = frame;
|
||||
(
|
||||
self.drawn_sections,
|
||||
self.section_draws,
|
||||
self.section_draw_calls,
|
||||
) = (sections, draws, calls);
|
||||
}
|
||||
}
|
||||
/// Frames between the last one drawn and the one whose statistics are shown: 0 after a
|
||||
/// CPU frame, one to three in GPU frames, while their counts are on their way back.
|
||||
pub fn stats_lag(&self) -> u64 {
|
||||
self.frame_serial - self.stats_frame
|
||||
}
|
||||
/// Take in the frame the submit thread submitted: start its readbacks and, offscreen,
|
||||
/// keep at most two frames ahead of the GPU, as a frame submitted in place does.
|
||||
fn land_frame(&mut self) {
|
||||
let Some(offscreen) = self.deferred.take() else {
|
||||
return;
|
||||
};
|
||||
let submission = self.queue.landed();
|
||||
if let Some(timer) = &mut self.gpu_timer {
|
||||
timer.submitted(&self.queue);
|
||||
timer.collect(&self.device, &mut self.gpu_times);
|
||||
}
|
||||
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
|
||||
timer.submitted(&self.queue);
|
||||
}
|
||||
if offscreen && let Some(submission) = submission {
|
||||
self.in_flight.push_back(submission);
|
||||
if !self.paced_elsewhere {
|
||||
self.wait_frames();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// No swapchain paces offscreen frames: keep two on the GPU at most.
|
||||
pub fn wait_in_flight(&mut self) {
|
||||
self.land_frame();
|
||||
self.wait_frames();
|
||||
}
|
||||
/// The frame on the submit thread submitted: a surface reconfigure waits for the
|
||||
/// device and breaks if a frame is submitted while it waits, and the compositor
|
||||
/// copies views after them. (The end of a frame already lands the frames of the
|
||||
/// turn before; this is the turn's own.)
|
||||
pub fn wait_submissions(&mut self) {
|
||||
self.queue.wait();
|
||||
}
|
||||
fn wait_frames(&mut self) {
|
||||
while self.in_flight.len() > 2 {
|
||||
let index = self.in_flight.pop_front();
|
||||
let _ = self.device.poll(wgpu::PollType::Wait {
|
||||
submission_index: index,
|
||||
timeout: Some(std::time::Duration::from_secs(5)),
|
||||
});
|
||||
}
|
||||
}
|
||||
/// Take a screenshot request: queue the frame's copy, or report why the
|
||||
/// frame cannot be read back.
|
||||
fn take_readback(
|
||||
&mut self,
|
||||
request: ScreenshotRequest,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
texture: &wgpu::Texture,
|
||||
) -> Option<(ScreenshotRequest, wgpu::Buffer)> {
|
||||
// The copy and PNG writer both assume 8-bit pixels; a float surface would make
|
||||
// the row stride too small for the copy and abort in validation.
|
||||
if self.config.format.block_copy_size(None) == Some(4) {
|
||||
let size = [self.config.width, self.config.height];
|
||||
let format = self.config.format.remove_srgb_suffix();
|
||||
// The layered float frame maps the world to SDR, then adds the HUD as drawn.
|
||||
// Other 16-bit frames keep their clipped 8-bit copy.
|
||||
let copy = self
|
||||
.hdr
|
||||
.as_mut()
|
||||
.and_then(|hdr| {
|
||||
hdr.sdr_screenshot(&self.device, encoder, format, request.size.is_some())
|
||||
})
|
||||
.or_else(|| {
|
||||
(texture.format() != format && texture.format() == self.output.frame.format)
|
||||
.then(|| {
|
||||
self.readback
|
||||
.unorm_copy(&self.device, encoder, texture, format)
|
||||
})
|
||||
});
|
||||
let texture = copy.as_ref().unwrap_or(texture);
|
||||
Some((request, copy_frame(&self.device, encoder, texture, size)))
|
||||
} else {
|
||||
let error = format!(
|
||||
"screenshots need a 32-bit surface format, not {:?}",
|
||||
self.config.format
|
||||
);
|
||||
if request.background {
|
||||
self.saved_screenshots.push(screenshot_save::Saved {
|
||||
path: request.path,
|
||||
result: Err(error),
|
||||
hdr: None,
|
||||
});
|
||||
} else {
|
||||
self.screenshot_result = Some(Err(error));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `screenshots/2026-10-28_12.00.00.png` -> `screenshots/2026-10-28_12.00.00-hdr.png`.
|
||||
fn hdr_screenshot_path(path: &std::path::Path) -> PathBuf {
|
||||
let stem = path.file_stem().unwrap_or_default().to_string_lossy();
|
||||
@@ -12744,56 +12457,8 @@ fn block_entities_within_reach<'a>(
|
||||
})
|
||||
.flatten()
|
||||
}
|
||||
/// Camera-relative clip planes (`Frustum`).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Frustum {
|
||||
planes: [Vec4; 6],
|
||||
}
|
||||
impl Frustum {
|
||||
/// Whether the camera-relative box `min..max` is at least partly inside.
|
||||
pub fn intersects(&self, min: Vec3, max: Vec3) -> bool {
|
||||
!self.planes.iter().any(|p| {
|
||||
let sign = p.truncate().cmpge(Vec3::ZERO);
|
||||
let v = Vec3::select(sign, max, min);
|
||||
p.truncate().dot(v) + p.w < 0.0
|
||||
})
|
||||
}
|
||||
/// Whether a camera-relative sphere is at least partly inside. Frustum
|
||||
/// planes are normalized, so each test needs only one dot product and a
|
||||
/// radius comparison.
|
||||
pub fn intersects_sphere(&self, center: Vec3, radius: f32) -> bool {
|
||||
if !center.is_finite() || !radius.is_finite() || radius < 0.0 {
|
||||
return true;
|
||||
}
|
||||
!self
|
||||
.planes
|
||||
.iter()
|
||||
.any(|plane| plane.truncate().dot(center) + plane.w < -radius)
|
||||
}
|
||||
/// Depth clip planes alone: X/Y projection jitter cannot change this test.
|
||||
/// Retaining side-of-screen geometry makes this safe before TAA selects a phase.
|
||||
pub fn intersects_depth(&self, min: Vec3, max: Vec3) -> bool {
|
||||
if !min.is_finite() || !max.is_finite() || !min.cmple(max).all() {
|
||||
return true;
|
||||
}
|
||||
!self.planes[4..].iter().any(|p| {
|
||||
let sign = p.truncate().cmpge(Vec3::ZERO);
|
||||
let v = Vec3::select(sign, max, min);
|
||||
p.truncate().dot(v) + p.w < 0.0
|
||||
})
|
||||
}
|
||||
/// Whether the camera-relative box `min..max` is entirely inside. Then
|
||||
/// `intersects` accepts every box within it: each plane's distance only
|
||||
/// grows from this box's nearest corner to the inner box's farthest, and
|
||||
/// rounding keeps that order.
|
||||
pub fn contains(&self, min: Vec3, max: Vec3) -> bool {
|
||||
self.planes.iter().all(|p| {
|
||||
let sign = p.truncate().cmpge(Vec3::ZERO);
|
||||
let v = Vec3::select(sign, min, max);
|
||||
p.truncate().dot(v) + p.w >= 0.0
|
||||
})
|
||||
}
|
||||
}
|
||||
mod view_frustum;
|
||||
pub use view_frustum::Frustum;
|
||||
/// Vanilla's `Projection`: reversed depth (near maps to 1, far to 0, tests
|
||||
/// are GREATER_THAN_OR_EQUAL), which keeps far faces a hair apart, like
|
||||
/// fire_side's 0.01 pixel in front of its block, from z-fighting.
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
//! What the renderer holds, for status, soak tests and memory reports (`horizon-ctl memory`,
|
||||
//! `scene_replay --memory`): resident sections and far tiles, and the bytes of its largest
|
||||
//! GPU resources and of its per-section bookkeeping.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::{
|
||||
Renderer, SectionGpu, SectionKey, TerrainVertex, arena, flat, lod, texture_bytes, visbuffer,
|
||||
};
|
||||
|
||||
impl Renderer {
|
||||
/// Sections with GPU buffers, for soak tests watching residency.
|
||||
pub fn resident_sections(&self) -> usize {
|
||||
self.sections.len()
|
||||
}
|
||||
/// Quads of the far terrain tiles with GPU buffers.
|
||||
pub fn lod_quads(&self) -> u64 {
|
||||
self.lods
|
||||
.values()
|
||||
.map(|tile| u64::from(tile.mesh.solid + tile.mesh.translucent))
|
||||
.sum()
|
||||
}
|
||||
/// Tiles and quads of the far terrain tiles with GPU buffers, by level.
|
||||
pub fn lod_levels(&self) -> Vec<[u64; 2]> {
|
||||
let mut levels = Vec::new();
|
||||
for (key, tile) in &self.lods {
|
||||
let level = usize::from(key.level);
|
||||
if levels.len() <= level {
|
||||
levels.resize(level + 1, [0, 0]);
|
||||
}
|
||||
levels[level][0] += 1;
|
||||
levels[level][1] += u64::from(tile.mesh.solid + tile.mesh.translucent);
|
||||
}
|
||||
levels
|
||||
}
|
||||
/// Whether solid quads go to the arena in the compact layout
|
||||
/// (`packed_quads`), and whether far tiles' own records are on
|
||||
/// (`lod::packed::far_packing`).
|
||||
pub fn packed_layout(&self) -> [bool; 2] {
|
||||
[self.packed_quads, crate::lod::packed::far_packing()]
|
||||
}
|
||||
/// Bytes of the arena the resident chunk sections hold, and of the one far
|
||||
/// tiles hold: the same split `gpu_census` reports, for `status`, so the
|
||||
/// compact layouts can be seen working.
|
||||
pub fn vertex_bytes_split(&self) -> [u64; 2] {
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
let used = |s: &SectionGpu| (s.solid_slots + u64::from(s.translucent) * 4) * stride;
|
||||
[
|
||||
self.sections.values().map(used).sum(),
|
||||
self.lods.values().map(|tile| used(&tile.mesh)).sum(),
|
||||
]
|
||||
}
|
||||
/// Bytes of the section vertex arenas' buffers, and of the vertices
|
||||
/// resident sections hold in them.
|
||||
pub fn section_vertex_bytes(&self) -> [u64; 2] {
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
let vertices: u64 = self
|
||||
.sections
|
||||
.values()
|
||||
.chain(self.lods.values().map(|tile| &*tile.mesh))
|
||||
.map(|s| s.solid_slots + u64::from(s.translucent) * 4)
|
||||
.sum();
|
||||
let translucent = self.translucent_arena.as_ref();
|
||||
let arenas = self.arena.bytes() + translucent.map_or(0, arena::Arena::bytes);
|
||||
[arenas, vertices * stride]
|
||||
}
|
||||
/// Bytes of the renderer's largest GPU resources, by name (`scene_replay --memory`):
|
||||
/// the terrain arenas (their pages, and what resident sections and far tiles use of
|
||||
/// them), the quad indices, the block sprites with their mips, the LabPBR maps, flat
|
||||
/// draws' buffers and the visibility buffer's.
|
||||
pub fn gpu_census(&self) -> Vec<(&'static str, u64)> {
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
let used = |s: &SectionGpu| (s.solid_slots + u64::from(s.translucent) * 4) * stride;
|
||||
let textures = |t: &[wgpu::Texture]| t.iter().map(texture_bytes).sum::<u64>();
|
||||
let translucent = self.translucent_arena.as_ref();
|
||||
vec![
|
||||
("section_arena_pages", self.arena.bytes()),
|
||||
(
|
||||
"translucent_arena_pages",
|
||||
translucent.map_or(0, arena::Arena::bytes),
|
||||
),
|
||||
("sorted_index_pages", self.sorted_indices.bytes()),
|
||||
(
|
||||
"near_section_vertices",
|
||||
self.sections.values().map(used).sum(),
|
||||
),
|
||||
(
|
||||
"held_section_vertices",
|
||||
self.hold
|
||||
.sections()
|
||||
.filter_map(|held| held.gpu.as_ref())
|
||||
.map(used)
|
||||
.sum(),
|
||||
),
|
||||
(
|
||||
"far_tile_vertices",
|
||||
self.lods.values().map(|tile| used(&tile.mesh)).sum(),
|
||||
),
|
||||
("quad_indices", self.indices.size()),
|
||||
("block_sprites", textures(&self.sprite_textures)),
|
||||
(
|
||||
"pbr_maps",
|
||||
self.pbr_maps
|
||||
.as_ref()
|
||||
.map_or(0, |maps| maps.iter().flatten().map(texture_bytes).sum()),
|
||||
),
|
||||
(
|
||||
"flat_draws",
|
||||
self.flat.as_ref().map_or(0, flat::Flat::bytes),
|
||||
),
|
||||
(
|
||||
"visibility_buffer",
|
||||
self.visbuf.as_ref().map_or(0, visbuffer::Visbuffer::bytes),
|
||||
),
|
||||
]
|
||||
}
|
||||
/// Device memory the backend's allocator holds, by the label of what holds
|
||||
/// it, largest first, with its allocated and reserved totals (Vulkan and
|
||||
/// DirectX 12; none on Metal and OpenGL) (`horizon-ctl memory`).
|
||||
pub fn gpu_memory_by_label(&self) -> Option<(u64, u64, Vec<(String, u64)>)> {
|
||||
let report = self.device.generate_allocator_report()?;
|
||||
let mut by_label: HashMap<String, u64> = HashMap::new();
|
||||
for a in report.allocations {
|
||||
*by_label.entry(a.name).or_default() += a.size;
|
||||
}
|
||||
let mut labels: Vec<_> = by_label.into_iter().collect();
|
||||
labels.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
|
||||
Some((
|
||||
report.total_allocated_bytes,
|
||||
report.total_reserved_bytes,
|
||||
labels,
|
||||
))
|
||||
}
|
||||
/// Heap bytes of the renderer's per-section bookkeeping (`horizon-ctl memory`):
|
||||
/// resident sections, far tiles, block entities and light sources by section.
|
||||
pub fn cpu_census(&self) -> [(&'static str, usize); 4] {
|
||||
use std::mem::size_of;
|
||||
let per_section = |map_len: usize, lists: usize| {
|
||||
map_len * (size_of::<SectionKey>() + size_of::<Vec<([i32; 3], u32)>>() + 1)
|
||||
+ lists * size_of::<([i32; 3], u32)>()
|
||||
};
|
||||
let lists = |map: &crate::fast_hash::FxHashMap<SectionKey, Vec<([i32; 3], u32)>>| {
|
||||
per_section(
|
||||
map.capacity(),
|
||||
map.values().map(Vec::capacity).sum::<usize>(),
|
||||
)
|
||||
};
|
||||
let animated: usize = self
|
||||
.sections
|
||||
.values()
|
||||
.map(|s| s.animated_layers.len() * size_of::<u16>())
|
||||
.sum();
|
||||
[
|
||||
("resident_sections", self.sections.heap_bytes() + animated),
|
||||
(
|
||||
"far_tiles",
|
||||
self.lods.capacity()
|
||||
* (size_of::<crate::lod::TileKey>() + size_of::<lod::Tile>() + 1),
|
||||
),
|
||||
("block_entities", lists(&self.block_entities)),
|
||||
("light_sources", lists(&self.light_blocks)),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ struct Uniform {
|
||||
mode: u32,
|
||||
pad: [u32; 2],
|
||||
hdr_settings: [f32; 4],
|
||||
hdr_shares: [f32; 4],
|
||||
hdr_gains: [f32; 4],
|
||||
hdr_light: [f32; 4],
|
||||
}
|
||||
|
||||
@@ -407,7 +407,7 @@ impl CustomSky {
|
||||
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 {
|
||||
if globals.hdr_settings[2] > 0.5 {
|
||||
(1.0 + (globals.hdr_settings[1] - 1.0) * (1.0 - mark) / 0.24).powf(1.0 / 2.2)
|
||||
} else {
|
||||
1.0
|
||||
@@ -428,7 +428,7 @@ impl CustomSky {
|
||||
mode: mode as u32,
|
||||
pad: [0; 2],
|
||||
hdr_settings: globals.hdr_settings,
|
||||
hdr_shares: globals.hdr_shares,
|
||||
hdr_gains: globals.hdr_gains,
|
||||
hdr_light: globals.hdr_light,
|
||||
};
|
||||
let offset = STRIDE * self.frame.len() as u64;
|
||||
@@ -702,7 +702,7 @@ mod tests {
|
||||
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_gains: [1.6, 2.4, 2.8, 4.0],
|
||||
hdr_light: [0.8, 0.8, 1.0, f32::from(u8::from(written))],
|
||||
};
|
||||
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
|
||||
@@ -10,7 +10,7 @@ struct Layer {
|
||||
mode: u32,
|
||||
pad: vec2<u32>,
|
||||
hdr_settings: vec4<f32>,
|
||||
hdr_shares: vec4<f32>,
|
||||
hdr_gains: vec4<f32>,
|
||||
hdr_light: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> layer: Layer;
|
||||
@@ -54,7 +54,7 @@ fn custom_sky_color(in: Out) -> vec4<f32> {
|
||||
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);
|
||||
color = hdr_written(color, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_gains);
|
||||
}
|
||||
// On a frame of linear light (`FRAME_LINEAR`) the layer's colour is decoded as it is stored.
|
||||
switch layer.mode {
|
||||
@@ -84,5 +84,5 @@ fn custom_sky_color(in: Out) -> vec4<f32> {
|
||||
|
||||
@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);
|
||||
return vec4(hdr_written(color.rgb, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_gains), color.a);
|
||||
}
|
||||
@@ -71,7 +71,7 @@ use super::{
|
||||
Frustum, SectionKey,
|
||||
arena::{Arena, try_create},
|
||||
occlusion::Reach,
|
||||
pack_timing::PassTiming,
|
||||
pack_timing::{PassTiming, compute_parts},
|
||||
resident,
|
||||
submit::Queue,
|
||||
upload::Uploads,
|
||||
@@ -374,6 +374,21 @@ pub(super) enum Prepared {
|
||||
OutOfMemory,
|
||||
}
|
||||
|
||||
/// A part of a compute pass: `pipeline` over `[x, y]` workgroups, nothing when `x` is 0.
|
||||
fn step<'a>(
|
||||
group: &'a wgpu::BindGroup,
|
||||
pipeline: &'a wgpu::ComputePipeline,
|
||||
[x, y]: [u32; 2],
|
||||
) -> impl FnMut(&mut wgpu::ComputePass<'_>) + 'a {
|
||||
move |pass| {
|
||||
if x > 0 {
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.dispatch_workgroups(x, y, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame's `tables` before the regions, into `words`, and the parameters
|
||||
/// the frustum test and facing read with them; `None` when the CPU draws the
|
||||
/// frame (`Prepared::Cpu`): a sum of a plane's terms could leave i32
|
||||
@@ -1190,12 +1205,13 @@ impl GpuCulling {
|
||||
uploads: &mut Uploads,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
input: &FrameInputs<'_>,
|
||||
timing: Option<&mut PassTiming>,
|
||||
) -> Prepared {
|
||||
self.pages.clear();
|
||||
self.ids = 0;
|
||||
self.n_updates = 0;
|
||||
self.occluding = false;
|
||||
self.prepare_buffers(device, queue, uploads, encoder, input)
|
||||
self.prepare_buffers(device, queue, uploads, encoder, input, timing)
|
||||
.unwrap_or(Prepared::OutOfMemory)
|
||||
}
|
||||
|
||||
@@ -1206,6 +1222,7 @@ impl GpuCulling {
|
||||
uploads: &mut Uploads,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
input: &FrameInputs<'_>,
|
||||
mut timing: Option<&mut PassTiming>,
|
||||
) -> Option<Prepared> {
|
||||
// Every buffer grows before anything is written. One made anew drops
|
||||
// the bind group at once, and a new section table needs every record:
|
||||
@@ -1287,6 +1304,9 @@ impl GpuCulling {
|
||||
occl = OCCLUDE | if probe { PROBE } else { 0 };
|
||||
}
|
||||
}
|
||||
let uploading = timing
|
||||
.as_mut()
|
||||
.and_then(|t| t.encoder_begin(encoder, "GPU culling uploads"));
|
||||
if self.records.all_dirty {
|
||||
// The whole table, after it grew or more records changed than
|
||||
// updates take (GPU culling switched on in a loaded world, a
|
||||
@@ -1352,6 +1372,9 @@ impl GpuCulling {
|
||||
// are written before they are read.
|
||||
let cleared = if self.occluding { tests_at } else { phase1_end };
|
||||
encoder.clear_buffer(&self.counts.buffer, 0, Some(cleared * 4));
|
||||
if let Some(t) = timing {
|
||||
t.encoder_end(encoder, uploading);
|
||||
}
|
||||
if self.group.is_none() {
|
||||
self.group = Some(self.bind(device));
|
||||
if let Some(test) = &mut self.test {
|
||||
@@ -1403,23 +1426,24 @@ impl GpuCulling {
|
||||
let Some(group) = self.group.as_ref().filter(|_| self.ids > 0) else {
|
||||
return;
|
||||
};
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("GPU culling"),
|
||||
timestamp_writes: timing.and_then(|t| t.compute_writes("GPU culling")),
|
||||
});
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
let [apply, cull, scan, scatter] = &self.pipelines;
|
||||
if self.n_updates > 0 {
|
||||
pass.set_pipeline(apply);
|
||||
pass.dispatch_workgroups(self.n_updates.div_ceil(WORKGROUP), 1, 1);
|
||||
}
|
||||
let blocks = self.ids.div_ceil(WORKGROUP);
|
||||
pass.set_pipeline(cull);
|
||||
pass.dispatch_workgroups(blocks.min(ROW), blocks.div_ceil(ROW), 1);
|
||||
pass.set_pipeline(scan);
|
||||
pass.dispatch_workgroups(self.pass_pages, 1, 1);
|
||||
pass.set_pipeline(scatter);
|
||||
pass.dispatch_workgroups(blocks.min(ROW), blocks.div_ceil(ROW), 1);
|
||||
let rows = [blocks.min(ROW), blocks.div_ceil(ROW)];
|
||||
let updates = [self.n_updates.div_ceil(WORKGROUP), 1];
|
||||
let (mut apply, mut cull) = (step(group, apply, updates), step(group, cull, rows));
|
||||
let pages = [self.pass_pages, 1];
|
||||
let (mut scan, mut scatter) = (step(group, scan, pages), step(group, scatter, rows));
|
||||
compute_parts(
|
||||
encoder,
|
||||
timing,
|
||||
"GPU culling",
|
||||
&mut [
|
||||
("apply", &mut apply),
|
||||
("cull", &mut cull),
|
||||
("scan", &mut scan),
|
||||
("scatter", &mut scatter),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// The kept sections' solid draws, a count draw per page, with the quad
|
||||
@@ -1494,19 +1518,22 @@ impl GpuCulling {
|
||||
if !self.occluding {
|
||||
return;
|
||||
}
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("GPU culling phase 2"),
|
||||
timestamp_writes: timing.and_then(|t| t.compute_writes("GPU culling phase 2")),
|
||||
});
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
let [count2, scan2, scatter2] = &test.phase2;
|
||||
let blocks = self.ids.div_ceil(WORKGROUP);
|
||||
pass.set_pipeline(count2);
|
||||
pass.dispatch_workgroups(blocks.min(ROW), blocks.div_ceil(ROW), 1);
|
||||
pass.set_pipeline(scan2);
|
||||
pass.dispatch_workgroups(self.pass_pages, 1, 1);
|
||||
pass.set_pipeline(scatter2);
|
||||
pass.dispatch_workgroups(blocks.min(ROW), blocks.div_ceil(ROW), 1);
|
||||
let rows = [blocks.min(ROW), blocks.div_ceil(ROW)];
|
||||
let pages = [self.pass_pages, 1];
|
||||
let (mut count, mut scatter) = (step(group, count2, rows), step(group, scatter2, rows));
|
||||
let mut scan = step(group, scan2, pages);
|
||||
compute_parts(
|
||||
encoder,
|
||||
timing,
|
||||
"GPU culling phase 2",
|
||||
&mut [
|
||||
("count2", &mut count),
|
||||
("scan2", &mut scan),
|
||||
("scatter2", &mut scatter),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// After the frame's world passes (phase 2's counts too), in the same
|
||||
@@ -2720,6 +2747,11 @@ mod tests {
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
let needed =
|
||||
wgpu::Features::MULTI_DRAW_INDIRECT_COUNT | wgpu::Features::INDIRECT_FIRST_INSTANCE;
|
||||
let timing = adapter.features()
|
||||
& (wgpu::Features::TIMESTAMP_QUERY
|
||||
| wgpu::Features::TIMESTAMP_QUERY_INSIDE_PASSES
|
||||
| wgpu::Features::TIMESTAMP_QUERY_INSIDE_ENCODERS
|
||||
| wgpu::Features::SHADER_EARLY_DEPTH_TEST);
|
||||
if !adapter.features().contains(needed)
|
||||
|| super::super::flat::is_broken_intel_vulkan(&adapter.get_info())
|
||||
{
|
||||
@@ -2727,7 +2759,7 @@ mod tests {
|
||||
}
|
||||
eprintln!("GPU culling on {} ({name})", adapter.get_info().name);
|
||||
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
required_features: needed,
|
||||
required_features: needed | timing,
|
||||
..Default::default()
|
||||
}))
|
||||
.ok()
|
||||
@@ -2834,6 +2866,11 @@ mod tests {
|
||||
// was drawn.
|
||||
let (mut eyed, mut hidden, mut several) = (0, 0, 0);
|
||||
let (mut edge, mut edge_drawn) = (None, false);
|
||||
// Timed in detail as the renderer does: the commands, and so the draws, stay the same.
|
||||
let mut timing = PassTiming::new(device, queue);
|
||||
if let Some(t) = &mut timing {
|
||||
t.set_detail(true);
|
||||
}
|
||||
for case in 0..25 {
|
||||
let radius = [2, 5, 9][case % 3];
|
||||
let at_edge = case == 24;
|
||||
@@ -2981,6 +3018,9 @@ mod tests {
|
||||
edge = Some(id);
|
||||
}
|
||||
let whole = gpu.records.all_dirty;
|
||||
if let Some(t) = &mut timing {
|
||||
t.begin(device, queue);
|
||||
}
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let input = FrameInputs {
|
||||
camera,
|
||||
@@ -3000,11 +3040,18 @@ mod tests {
|
||||
camera: camera + DVec3::Y * 2.0e7,
|
||||
..input
|
||||
};
|
||||
let prepared = gpu.prepare(device, queue, &mut uploads, &mut encoder, &up);
|
||||
let prepared = gpu.prepare(device, queue, &mut uploads, &mut encoder, &up, None);
|
||||
assert_eq!(prepared, Prepared::Cpu, "case 7");
|
||||
assert!(gpu.records.all_dirty || !gpu.records.dirty.is_empty());
|
||||
}
|
||||
let prepared = gpu.prepare(device, queue, &mut uploads, &mut encoder, &input);
|
||||
let prepared = gpu.prepare(
|
||||
device,
|
||||
queue,
|
||||
&mut uploads,
|
||||
&mut encoder,
|
||||
&input,
|
||||
timing.as_mut(),
|
||||
);
|
||||
assert_eq!(prepared, Prepared::Gpu, "case {case}");
|
||||
match case {
|
||||
4 => assert!(
|
||||
@@ -3060,12 +3107,15 @@ mod tests {
|
||||
culled += 1;
|
||||
}
|
||||
}
|
||||
gpu.dispatch(&mut encoder, None);
|
||||
gpu.dispatch(&mut encoder, timing.as_mut());
|
||||
// Its statistics come back with a CPU part of the case's own.
|
||||
let cpu = [case * 7, case * 3, case];
|
||||
gpu.copy_stats(&mut encoder, case as u64, cpu);
|
||||
uploads.finish(&encoder);
|
||||
queue.submit([encoder.finish()]);
|
||||
if let Some(t) = &mut timing {
|
||||
t.submitted(queue);
|
||||
}
|
||||
gpu.submitted().expect("a readback free")();
|
||||
let pages = u64::from(gpu.pass_pages);
|
||||
let counts = read(device, queue, &gpu.counts.buffer, (STATS + pages) * 4);
|
||||
@@ -3124,6 +3174,18 @@ mod tests {
|
||||
"of {eyed} from an eye, {hidden} with a bucket left out, {several} in three runs"
|
||||
);
|
||||
assert!(edge_drawn, "the bucket at the section's edge");
|
||||
if let Some(t) = &timing {
|
||||
let parts = t.part_medians();
|
||||
let names = |pass: &str| {
|
||||
let parts = parts.iter().find(|p| p.0 == pass)?;
|
||||
Some(parts.1.iter().map(|p| p.0.as_str()).collect::<Vec<_>>())
|
||||
};
|
||||
assert_eq!(
|
||||
names("GPU culling"),
|
||||
Some(vec!["apply", "cull", "scan", "scatter"]),
|
||||
"{parts:?}"
|
||||
);
|
||||
}
|
||||
assert!(culled > 2_000, "{culled} within the distance left out");
|
||||
assert!(faded * 10 > compared, "{faded} fading of {compared} drawn");
|
||||
assert!(
|
||||
@@ -3136,6 +3198,113 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Detail times both phases and the uploads and leaves counts and draws as an untimed frame's.
|
||||
#[test]
|
||||
fn detail_times_both_phases_and_leaves_the_draws_alone() {
|
||||
on_each_device(|device, queue| {
|
||||
let mut rng = Rng(0x1F83_D9AB_FB41_BD6B);
|
||||
let (frustum, _) = view(&mut rng);
|
||||
let records: Vec<Record> = (0..400)
|
||||
.map(|i| {
|
||||
let key = (rng.range(-6, 6), rng.range(0, 23), rng.range(-6, 6));
|
||||
Record::new(
|
||||
key,
|
||||
i % 3,
|
||||
u64::from(i) * 1024,
|
||||
1 + rng.below(500),
|
||||
UNBOUNDED,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut timing = PassTiming::new(device, queue);
|
||||
let early = device
|
||||
.features()
|
||||
.contains(wgpu::Features::SHADER_EARLY_DEPTH_TEST);
|
||||
let Some(t) = timing.as_mut().filter(|_| early) else {
|
||||
eprintln!("no timestamps or early depth test: skipped");
|
||||
return;
|
||||
};
|
||||
t.set_detail(true);
|
||||
let run = |timing: &mut Option<PassTiming>| {
|
||||
let mut gpu = GpuCulling::new(device, Some(wgpu::TextureFormat::Rgba8Unorm))
|
||||
.expect("the pass");
|
||||
let mut uploads = Uploads::new(device);
|
||||
for r in &records {
|
||||
gpu.records.insert(*r);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for frame in 0..12 {
|
||||
if let Some(t) = timing {
|
||||
t.begin(device, queue);
|
||||
}
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let input = FrameInputs {
|
||||
camera: DVec3::new(0.5, 70.0, 0.5),
|
||||
min_y: -64,
|
||||
sections: 24,
|
||||
radius: 5,
|
||||
frustum: &frustum,
|
||||
reach: None,
|
||||
eye: None,
|
||||
view_proj: Mat4::IDENTITY,
|
||||
occlude: true,
|
||||
};
|
||||
let prepared = gpu.prepare(
|
||||
device,
|
||||
queue,
|
||||
&mut uploads,
|
||||
&mut encoder,
|
||||
&input,
|
||||
timing.as_mut(),
|
||||
);
|
||||
assert_eq!(prepared, Prepared::Gpu, "frame {frame}");
|
||||
assert!(gpu.occluding(), "frame {frame}: phase 2 runs");
|
||||
gpu.dispatch(&mut encoder, timing.as_mut());
|
||||
gpu.compact(&mut encoder, timing.as_mut());
|
||||
uploads.finish(&encoder);
|
||||
queue.submit([encoder.finish()]);
|
||||
if let Some(t) = timing {
|
||||
t.submitted(queue);
|
||||
}
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let pages = u64::from(gpu.pass_pages);
|
||||
out.push((
|
||||
read(device, queue, &gpu.counts.buffer, (STATS + 2 * pages) * 4),
|
||||
read(device, queue, &gpu.draws.buffer, gpu.draws.bytes),
|
||||
));
|
||||
}
|
||||
out
|
||||
};
|
||||
let timed = run(&mut timing);
|
||||
let plain = run(&mut None);
|
||||
assert_eq!(timed, plain, "detail changes what is drawn");
|
||||
let t = timing.as_ref().expect("the timer");
|
||||
let parts = t.part_medians();
|
||||
let names = |pass: &str| {
|
||||
let parts = parts.iter().find(|p| p.0 == pass)?;
|
||||
Some(parts.1.iter().map(|p| p.0.as_str()).collect::<Vec<_>>())
|
||||
};
|
||||
assert_eq!(
|
||||
names("GPU culling"),
|
||||
Some(vec!["apply", "cull", "scan", "scatter"]),
|
||||
"{parts:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
names("GPU culling phase 2"),
|
||||
Some(vec!["count2", "scan2", "scatter2"]),
|
||||
"{parts:?}"
|
||||
);
|
||||
// The uploads are a pass of their own, never a part of one.
|
||||
assert_eq!(names("GPU culling uploads"), None, "{parts:?}");
|
||||
let passes = t.medians();
|
||||
assert_eq!(
|
||||
passes.iter().any(|p| p.0 == "GPU culling uploads"),
|
||||
t.detail_support().encoder_stamps,
|
||||
"{passes:?}"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// The statistics come back with their frame, in place and from the submit
|
||||
/// thread, submitted as gfx.rs submits frames (`submit_later`, or in place
|
||||
/// with its `after` handed back): three frames on their way fill the ring, a
|
||||
@@ -3377,7 +3546,7 @@ struct Quad {
|
||||
view_proj: Mat4::IDENTITY,
|
||||
occlude: false,
|
||||
};
|
||||
let prepared = gpu.prepare(device, queue, &mut uploads, &mut encoder, &input);
|
||||
let prepared = gpu.prepare(device, queue, &mut uploads, &mut encoder, &input, None);
|
||||
assert_eq!(prepared, Prepared::Gpu, "frame {frame}");
|
||||
let mut params = tables(&input, &mut words).expect("the planes fit");
|
||||
params.pages = gpu.pass_pages;
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
//! or scRGB swapchain at the player's paper white, expanding the world's
|
||||
//! highlights towards their peak brightness. The world is copied aside before
|
||||
//! the hand and HUD so only it is expanded.
|
||||
//!
|
||||
//! On the float frame Horizon's world light is the same on every display (`TIERS`,
|
||||
//! `SUNLIT_GAIN`, up to `SCENE_PEAK`), and the tone curve alone fits it to the display's
|
||||
//! peak (docs/design/hdr-linear-light.md, step 6a).
|
||||
|
||||
/// The swapchain HDR output presents to.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
@@ -99,38 +103,53 @@ struct Layered {
|
||||
params: wgpu::Buffer,
|
||||
}
|
||||
|
||||
/// How one tier of light sources (`mesher::hdr_tier`) shines: `share` of the headroom for
|
||||
/// its brightest pixels, lifted from brightness `start` (none) to `full` (all of it), the
|
||||
/// texel's own brightness before lighting.
|
||||
/// How one tier of light sources (`mesher::hdr_tier`) shines: its brightest pixels are
|
||||
/// multiplied by `gain`, in multiples of paper white, lifted from brightness `start` (none) to
|
||||
/// `full` (all of it), the texel's own brightness before lighting.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Tier {
|
||||
pub share: f32,
|
||||
pub gain: f32,
|
||||
pub start: f32,
|
||||
pub full: f32,
|
||||
}
|
||||
/// Low, incandescent, bright and very bright.
|
||||
/// Low, incandescent, bright and very bright: faint glowers such as magma, flames and lava,
|
||||
/// glowstone and campfires, the sun.
|
||||
pub const TIERS: [Tier; 4] = [
|
||||
Tier {
|
||||
share: 0.2,
|
||||
gain: 1.6,
|
||||
start: 0.08,
|
||||
full: 0.09,
|
||||
},
|
||||
Tier {
|
||||
share: 0.5,
|
||||
gain: 2.4,
|
||||
start: 0.14,
|
||||
full: 0.38,
|
||||
},
|
||||
Tier {
|
||||
share: 0.6,
|
||||
gain: 2.8,
|
||||
start: 0.14,
|
||||
full: 0.38,
|
||||
},
|
||||
Tier {
|
||||
share: 1.0,
|
||||
gain: 4.0,
|
||||
start: 0.14,
|
||||
full: 0.38,
|
||||
},
|
||||
];
|
||||
/// The scene's peak, in multiples of paper white: just above the brightest light the world
|
||||
/// writes (the sun's disc, white at the very bright tier's 4), and what the tone curve maps
|
||||
/// onto the display's peak; light past it (where the sky adds onto the sun) is held at it. The
|
||||
/// world is mastered for a 1000-nit display at ITU-R BT.2408's 203-nit reference white, as
|
||||
/// HDR10 content usually is: there it shows as written, a dimmer display eases its highlights
|
||||
/// into its peak (BT.2390's EETF), and a brighter one shows it as at 1000.
|
||||
pub const SCENE_PEAK: f32 = 1000.0 / 203.0;
|
||||
/// HDR lighting: what the strongest light multiplies a surface by (`hdr_light.wgsl`).
|
||||
pub const SUNLIT_GAIN: f32 = 3.0;
|
||||
/// The compatibility frame's tiers (the 16-bit or 8-bit frame, until step 5 removes it): each
|
||||
/// one's share of the display's headroom, as before one peak, so the look HDR has by default
|
||||
/// changes once, when the float frame replaces it.
|
||||
const COMPAT_SHARES: [f32; 4] = [0.2, 0.5, 0.6, 1.0];
|
||||
|
||||
/// Which pixels of a light source's sprite may shine (`hdr_stats.wgsl`), as a fraction of
|
||||
/// the way from its darkest to its brightest opaque pixel: `parts` for one whose bright parts
|
||||
/// shine (a torch's flame, not its handle), `whole` for one that shines all over
|
||||
@@ -331,7 +350,7 @@ impl Hdr {
|
||||
let params = || {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon HDR parameters"),
|
||||
size: 64,
|
||||
size: 80,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
@@ -944,16 +963,19 @@ impl Hdr {
|
||||
})
|
||||
}
|
||||
|
||||
/// What the world's writers read (`hdr_written`): the scene's peak, HDR lighting's gain,
|
||||
/// world/look and debug, then the tiers' gains. No part of the display reaches them.
|
||||
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())
|
||||
let sunlit = if self.lighting { SUNLIT_GAIN } else { 1.0 };
|
||||
([SCENE_PEAK, sunlit, p[2], p[3]], self.tiers.map(|t| t.gain))
|
||||
}
|
||||
|
||||
/// hdr.wgsl's `Params`.
|
||||
fn params(&self, world: bool, pq: bool) -> [f32; 16] {
|
||||
fn params(&self, world: bool, pq: bool) -> [f32; 20] {
|
||||
let paper = self.paper_white.max(1.0);
|
||||
let c = self.calibration;
|
||||
let mut params = [0.0; 16];
|
||||
let mut params = [0.0; 20];
|
||||
params[..5].copy_from_slice(&[
|
||||
paper / 80.0,
|
||||
(self.peak / paper).max(1.0),
|
||||
@@ -968,12 +990,17 @@ impl Hdr {
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
for (i, t) in self.tiers.iter().enumerate() {
|
||||
params[8 + i] = t.share;
|
||||
}
|
||||
params[8..12].copy_from_slice(&COMPAT_SHARES);
|
||||
params[12] = c.map_or(0.0, |c| c.step as f32);
|
||||
params[13] = c.map_or(0.0, |c| c.nits);
|
||||
params[14] = c.map_or(0.0, |c| c.band);
|
||||
// Only Horizon's lifted world is known to end at the scene's peak; elsewhere (HDR
|
||||
// Lighting off, a pack's own light, the title) the curve's source follows the display.
|
||||
params[16] = if world && self.lighting {
|
||||
SCENE_PEAK
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
params
|
||||
}
|
||||
|
||||
@@ -1050,8 +1077,9 @@ fn logo_mask(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
|
||||
.create_view(&Default::default())
|
||||
}
|
||||
|
||||
/// What the strongest light multiplies a surface by, in HDR lighting: 4, which looked right
|
||||
/// across biomes; less only where there is little headroom to ease into (`hdr.wgsl`).
|
||||
/// The compatibility frame's HDR lighting (`fs_hdr`): what the strongest light multiplies a
|
||||
/// surface by, 4, which looked right across biomes; less only where there is little headroom
|
||||
/// to ease into. The float frame's is `SUNLIT_GAIN`.
|
||||
fn scene_gain(peak: f32, paper_white: f32) -> f32 {
|
||||
(peak / paper_white.max(1.0) * 1.75).clamp(1.0, 4.0)
|
||||
}
|
||||
@@ -1069,7 +1097,8 @@ mod tests {
|
||||
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.
|
||||
// debug, headroom and tier shares; half-float neighbours straddle mark boundaries. The
|
||||
// writers take the gain that a share gives.
|
||||
let source = include_str!("../hdr_light.wgsl").to_owned()
|
||||
+ r#"
|
||||
struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: vec4<f32> }
|
||||
@@ -1088,19 +1117,20 @@ var<private> params: ReferenceParams;
|
||||
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 gains = 1.0 + (params.peak - 1.0) * params.share;
|
||||
let actual = hdr_mark_light(color, mark, shows, eased,
|
||||
vec4(params.peak, params.scene, params.world, params.debug), params.share);
|
||||
vec4(params.peak, params.scene, params.world, params.debug), (params.peak - 1.0) * params.share);
|
||||
errors[i] = select(0u, 1u, any(abs(expected.light - actual.light) > vec3(1e-6)) || expected.lifted != actual.lifted);
|
||||
// Gain stays above the tone knee until the separate tone curve runs.
|
||||
let gain = 1.0 + 3.0 * (1.0 - 194.0 / 255.0) / 0.24;
|
||||
let white = hdr_written(vec3(1.0), 0.76, vec4(5.0, 4.0, 1.0, 0.0), params.share);
|
||||
let white = hdr_written(vec3(1.0), 0.76, vec4(5.0, 4.0, 1.0, 0.0), gains);
|
||||
errors[i] |= select(0u, 4u, any(abs(pow(white, vec3(2.2)) - vec3(gain)) > vec3(1e-5)));
|
||||
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);
|
||||
let want = pow(reference_lit(color, quantized, params.world > 0.5, false).light, vec3(1.0 / 2.2));
|
||||
let got = hdr_written(color, stored, vec4(params.peak, params.scene, params.world, params.debug), gains);
|
||||
errors[i] |= select(0u, 2u, any(abs(want - got) > vec3(1e-6)));
|
||||
}
|
||||
}
|
||||
@@ -1319,10 +1349,12 @@ var<private> params: ReferenceParams;
|
||||
|
||||
// Independent pre-refactor light arithmetic applied to a compatibility attachment.
|
||||
// Float targets in these tests only receive the resulting colour and coverage.
|
||||
// `lift`: the reference's peak, HDR lighting gain, world and debug, then the tiers' shares
|
||||
// of the headroom up to that peak (`writers_lift`, `display_lift`).
|
||||
pub(super) fn legacy_light(
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
hdr: &Hdr,
|
||||
lift: [[f32; 4]; 2],
|
||||
source: &wgpu::Texture,
|
||||
target: &wgpu::Texture,
|
||||
) {
|
||||
@@ -1345,7 +1377,7 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
@fragment fn fs() -> @location(0) vec4<f32> {
|
||||
let c = textureLoad(source, vec2(0), 0);
|
||||
let mark = round(saturate(c.a) * 255.0) / 255.0;
|
||||
let light = reference_lit(c.rgb, mark, params.world > 0.5 && params.peak > 1.0, false).light;
|
||||
let light = reference_lit(c.rgb, mark, params.world > 0.5, false).light;
|
||||
return vec4(pow(light, vec3(1.0 / 2.2)), 1.0);
|
||||
}
|
||||
"#)
|
||||
@@ -1373,10 +1405,9 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let params = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytemuck::cast_slice(&[settings, shares]),
|
||||
contents: bytemuck::cast_slice(&lift),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let view = source.create_view(&Default::default());
|
||||
@@ -1404,6 +1435,19 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
);
|
||||
}
|
||||
|
||||
/// The float frame's writers' light as `legacy_light` takes it: each tier's gain as a share
|
||||
/// of the headroom up to the scene's peak.
|
||||
pub(super) fn writers_lift(hdr: &Hdr) -> [[f32; 4]; 2] {
|
||||
let (settings, gains) = hdr.draw_params(true);
|
||||
[settings, gains.map(|g| (g - 1.0) / (settings[0] - 1.0))]
|
||||
}
|
||||
|
||||
/// The compatibility frame's display pass's light (`fs_hdr`) as `legacy_light` takes it.
|
||||
fn display_lift(hdr: &Hdr) -> [[f32; 4]; 2] {
|
||||
let p = hdr.params(true, false);
|
||||
[[p[1], p[7], p[2], p[3]], p[8..12].try_into().unwrap()]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_light_matches_the_legacy_non_float_display_below_the_tone_knee() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
@@ -1416,8 +1460,9 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
eprintln!("legacy display GPU skipped: 16-bit UNORM unavailable");
|
||||
return;
|
||||
}
|
||||
// Compare the compatibility display lift with direct colour under the layered HUD.
|
||||
// The frozen oracle supplies the writer's light from a non-float attachment.
|
||||
// Compare the compatibility display lift with direct colour under the layered HUD:
|
||||
// the frozen oracle turns the marks into the light that display gives them, and the
|
||||
// layered display, whose curve is the identity at 1000 nits, must show it the same.
|
||||
let shown = |layered: bool, world: [f64; 4]| {
|
||||
let mut hdr = Hdr::with_debug(
|
||||
&device,
|
||||
@@ -1441,7 +1486,14 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
if world[3] == 1.0 {
|
||||
fill(&mut encoder, &frame, world);
|
||||
} else {
|
||||
legacy_light(&device, &mut encoder, &hdr, &marked, &frame);
|
||||
// The light the compatibility frame shows: its own lift, not the writers'.
|
||||
legacy_light(
|
||||
&device,
|
||||
&mut encoder,
|
||||
display_lift(&legacy),
|
||||
&marked,
|
||||
&frame,
|
||||
);
|
||||
}
|
||||
hdr.begin(&queue, true);
|
||||
hdr.tonemap(&mut encoder, None);
|
||||
@@ -1480,78 +1532,206 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
assert!(above[0] > white[0] && above[1] > white[1]);
|
||||
}
|
||||
|
||||
/// The world's writers read nothing of the display: the same gains and the same scene peak
|
||||
/// at any paper white and peak (docs/design/hdr-linear-light.md, step 6a).
|
||||
#[test]
|
||||
fn the_tone_curve_eases_light_into_the_peak() {
|
||||
fn the_writers_light_is_the_same_on_every_display() {
|
||||
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);
|
||||
let written = hdr.draw_params(true);
|
||||
assert_eq!(
|
||||
written,
|
||||
(
|
||||
[super::SCENE_PEAK, super::SUNLIT_GAIN, 1.0, 0.0],
|
||||
[1.6, 2.4, 2.8, 4.0]
|
||||
)
|
||||
);
|
||||
for (paper, peak) in [
|
||||
(203.0, 300.0),
|
||||
(203.0, 600.0),
|
||||
(203.0, 1500.0),
|
||||
(400.0, 600.0),
|
||||
(480.0, 200.0),
|
||||
(80.0, 10000.0),
|
||||
] {
|
||||
(hdr.paper_white, hdr.peak) = (paper, peak);
|
||||
assert_eq!(hdr.draw_params(true), written, "{paper}/{peak}");
|
||||
assert_eq!(
|
||||
hdr.params(true, false)[16],
|
||||
super::SCENE_PEAK,
|
||||
"{paper}/{peak}"
|
||||
);
|
||||
}
|
||||
// The title, and frames Horizon doesn't light (HDR Lighting off, a pack drawing):
|
||||
// nothing says where their light ends, so the curve's source follows the display.
|
||||
assert_eq!(hdr.params(false, false)[16], 0.0);
|
||||
hdr.lighting = false;
|
||||
assert_eq!(hdr.draw_params(true).0[1], 1.0);
|
||||
assert_eq!(hdr.params(true, false)[16], 0.0);
|
||||
}
|
||||
|
||||
/// The tone curve maps the scene's peak S onto the display's peak T with ITU-R BT.2390's
|
||||
/// EETF, keeping hue, after holding light above S at S. Checkpoints are worked out apart
|
||||
/// from the shader, in f64 from SMPTE ST 2084 and BT.2390.
|
||||
#[test]
|
||||
fn the_tone_curve_maps_the_scene_peak_onto_the_display() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let scene_peak = super::SCENE_PEAK;
|
||||
// The frame holding `color` (in gamma, as it stores light), after the curve if `curve`.
|
||||
let through = |hdr: &mut Hdr, color: [f64; 4], curve: bool| {
|
||||
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);
|
||||
if curve {
|
||||
hdr.tonemap(&mut encoder, None);
|
||||
}
|
||||
texel(&device, &queue, encoder, &frame)
|
||||
};
|
||||
// `exact`, or within one half-float step: S's own gamma rounds up in half float, to
|
||||
// light just above S that the curve holds at S, and whether that comes back to the same
|
||||
// half float depends on the GPU's rounding (one step lower on an Apple M4 Pro).
|
||||
let unchanged = |hdr: &mut Hdr, color: [f64; 4], exact: bool| {
|
||||
let (got, stored) = (through(hdr, color, true), through(hdr, color, false));
|
||||
(0..4).all(|c| {
|
||||
got[c] == stored[c] || !exact && (got[c] - stored[c]).abs() <= stored[c] / 1024.0
|
||||
})
|
||||
};
|
||||
// Linear light in and out, in units of paper white.
|
||||
let eased = |hdr: &mut Hdr, light: [f32; 3]| {
|
||||
let mapped = |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);
|
||||
let [r, g, b, a] = through(hdr, [r, g, b, 1.0], true);
|
||||
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),
|
||||
// A display at or above the mastering one (T >= S): every stored value up to S comes
|
||||
// out exactly as it went in, grey or coloured, and light above S is held at S with its
|
||||
// hue, so 1500 and 10 000 nits show what 1000 does; so do the settings' extremes (Paper
|
||||
// White 10 to 480 nits, Peak up to 10 000) wherever T reaches S.
|
||||
for (paper, peak) in [
|
||||
(203.0, 1000.0),
|
||||
(203.0, 1500.0),
|
||||
(203.0, 10000.0),
|
||||
(480.0, 10000.0),
|
||||
(10.0, 200.0),
|
||||
] {
|
||||
let [r, ..] = eased(&mut hdr, [light; 3]);
|
||||
assert!(near(r, want), "{light}: {r}, not {want}");
|
||||
(hdr.paper_white, hdr.peak) = (paper, peak);
|
||||
for i in 0..=64 {
|
||||
let g = f64::from((scene_peak * i as f32 / 64.0).powf(1.0 / 2.2));
|
||||
let exact = i < 64;
|
||||
assert!(unchanged(&mut hdr, [g, g, g, 1.0], exact), "{peak}: {i}");
|
||||
assert!(
|
||||
unchanged(&mut hdr, [g, g * 0.6, g * 0.1, 1.0], exact),
|
||||
"{peak}: {i}"
|
||||
);
|
||||
}
|
||||
for light in [8.0f32, 50.0] {
|
||||
let [r, g, b] = mapped(&mut hdr, [light, light / 4.0, 0.0]);
|
||||
assert!(
|
||||
near(r, scene_peak) && near(g * 4.0, r) && b == 0.0,
|
||||
"{peak}: {light}: {r} {g} {b}"
|
||||
);
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
// Dimmer displays: unchanged up to the knee, then the EETF into T, T at S and past it.
|
||||
// At 300 nits the knee falls just below white (161 nits): white shows at 198 nits
|
||||
// where the gates' curve, whose source followed the display, kept it at 203; the
|
||||
// highlights above it keep more of their steps.
|
||||
for (paper, peak, knee, checkpoints) in [
|
||||
(
|
||||
203.0,
|
||||
600.0,
|
||||
2.0,
|
||||
&[
|
||||
(2.5, 2.47516),
|
||||
(3.0, 2.75699),
|
||||
(4.0, 2.9404),
|
||||
(scene_peak, 600.0 / 203.0),
|
||||
(8.0, 600.0 / 203.0),
|
||||
][..],
|
||||
),
|
||||
(
|
||||
203.0,
|
||||
300.0,
|
||||
0.75,
|
||||
&[
|
||||
(0.9, 0.89289),
|
||||
(1.0, 0.97365),
|
||||
(1.2, 1.10406),
|
||||
(1.4, 1.20134),
|
||||
(2.0, 1.36833),
|
||||
(4.0, 1.47641),
|
||||
(8.0, 300.0 / 203.0),
|
||||
],
|
||||
),
|
||||
(
|
||||
400.0,
|
||||
600.0,
|
||||
0.8,
|
||||
&[
|
||||
(1.0, 0.97973),
|
||||
(1.2, 1.11501),
|
||||
(1.5, 1.25607),
|
||||
(2.0, 1.38818),
|
||||
(scene_peak, 1.5),
|
||||
],
|
||||
),
|
||||
// No headroom (a peak under paper white): the scene is compressed into white.
|
||||
(
|
||||
203.0,
|
||||
150.0,
|
||||
0.4,
|
||||
&[
|
||||
(1.0, 0.78346),
|
||||
(1.5, 0.90244),
|
||||
(2.0, 0.95589),
|
||||
(4.0, 0.99944),
|
||||
(scene_peak, 1.0),
|
||||
],
|
||||
),
|
||||
] {
|
||||
(hdr.paper_white, hdr.peak) = (paper, peak);
|
||||
for light in [0.0f32, 0.3, knee] {
|
||||
let g = f64::from(light.powf(1.0 / 2.2));
|
||||
assert!(
|
||||
unchanged(&mut hdr, [g, g * 0.5, 0.0, 1.0], true),
|
||||
"{paper}/{peak}: {light}"
|
||||
);
|
||||
}
|
||||
for &(light, want) in checkpoints {
|
||||
let [r, ..] = mapped(&mut hdr, [light; 3]);
|
||||
assert!(near(r, want), "{paper}/{peak}: {light}: {r}, not {want}");
|
||||
}
|
||||
// It rises, stays under the peak, and the brightest channel scales the others; a
|
||||
// half float's step in gamma is 0.1% of the light near white.
|
||||
let headroom = (peak / paper).max(1.0);
|
||||
let mut last = 0.0;
|
||||
for i in 0..40 {
|
||||
let light = 0.5 * 1.08f32.powi(i);
|
||||
let [r, g, b] = mapped(&mut hdr, [light, light / 4.0, 0.0]);
|
||||
assert!(
|
||||
r >= last * 0.998 && r <= headroom * 1.002,
|
||||
"{peak}: {light}: {r}"
|
||||
);
|
||||
assert!(near(g * 4.0, r) && b == 0.0, "{peak}: {light}: {r} {g} {b}");
|
||||
last = r;
|
||||
}
|
||||
}
|
||||
// A display with little headroom, 300 nits at a paper white of 203: the knee sits at
|
||||
// white, so all that SDR shows stays as it is, and the source's peak is 645 nits.
|
||||
hdr.peak = 300.0;
|
||||
// Light Horizon doesn't lift (HDR Lighting off, a 0.2 pack's own) has no known peak:
|
||||
// the curve's source follows the display, the knee at 60% of its peak and at least
|
||||
// white, as before one peak. At 300 nits the source's peak is 645 nits.
|
||||
hdr.lighting = false;
|
||||
(hdr.paper_white, hdr.peak) = (203.0, 300.0);
|
||||
let headroom = 300.0 / 203.0;
|
||||
for light in [0.5f32, 0.9, 1.0] {
|
||||
for light in [0.0f32, 0.5, 0.9, 1.0] {
|
||||
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}"
|
||||
);
|
||||
assert!(unchanged(&mut hdr, [g, g * 0.5, 0.0, 1.0], true), "{light}");
|
||||
}
|
||||
for (light, want) in [
|
||||
(1.2, 1.16839),
|
||||
@@ -1560,12 +1740,12 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
(2.0, 1.44024),
|
||||
(4.0, headroom),
|
||||
] {
|
||||
let [r, ..] = eased(&mut hdr, [light; 3]);
|
||||
let [r, ..] = mapped(&mut hdr, [light; 3]);
|
||||
assert!(near(r, want), "{light}: {r}, not {want}");
|
||||
}
|
||||
// No headroom (a peak below paper white): a clip at white, channel by channel.
|
||||
// With no headroom there, a clip at white, channel by channel.
|
||||
hdr.peak = 150.0;
|
||||
let [r, g, b] = eased(&mut hdr, [1.5, 0.5, 0.2]);
|
||||
let [r, g, b] = mapped(&mut hdr, [1.5, 0.5, 0.2]);
|
||||
assert!(r == 1.0 && near(g, 0.5) && near(b, 0.2), "{r} {g} {b}");
|
||||
}
|
||||
|
||||
@@ -1582,7 +1762,7 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
(hdr.paper_white, hdr.peak, hdr.black) = (250.0, 800.0, 0.0);
|
||||
(hdr.ui, hdr.look, hdr.lighting) = (0.5, false, false);
|
||||
hdr.masks.parts = 0.4;
|
||||
hdr.tiers[1].share = 0.7;
|
||||
hdr.tiers[1].gain = 3.0;
|
||||
hdr.debug = true;
|
||||
hdr.calibration = Some(super::Calibration {
|
||||
step: 2,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{
|
||||
Hdr, Output,
|
||||
tests::{gpu, legacy_light, texel},
|
||||
tests::{gpu, legacy_light, texel, writers_lift},
|
||||
};
|
||||
use wgpu::TextureFormat as F;
|
||||
use wgpu::util::DeviceExt;
|
||||
@@ -648,7 +648,7 @@ impl<'a> Draw<'a> {
|
||||
}
|
||||
if !written && !self.raw {
|
||||
let light = hdr.frame(self.device, [1, 1]);
|
||||
legacy_light(self.device, &mut encoder, hdr, &frame, &light);
|
||||
legacy_light(self.device, &mut encoder, writers_lift(hdr), &frame, &light);
|
||||
return texel(self.device, self.queue, encoder, &light);
|
||||
}
|
||||
texel(self.device, self.queue, encoder, &frame)
|
||||
@@ -662,7 +662,7 @@ fn float_plain_fragments_keep_rgb_and_write_only_coverage() {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let (settings, gains) = hdr.draw_params(true);
|
||||
let mut cases = 0;
|
||||
for hdr_on in [false, true] {
|
||||
let globals = super::super::Globals {
|
||||
@@ -677,7 +677,7 @@ fn float_plain_fragments_keep_rgb_and_write_only_coverage() {
|
||||
hdr_windows: hdr.windows(),
|
||||
hdr_light: [0.8, 0.8, f32::from(u8::from(hdr_on)), 0.0],
|
||||
hdr_settings: settings,
|
||||
hdr_shares: shares,
|
||||
hdr_gains: gains,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
for terrain in [true, false] {
|
||||
@@ -739,7 +739,7 @@ fn float_stars_keep_the_original_rgb_with_brightness_as_coverage() {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let (settings, gains) = hdr.draw_params(true);
|
||||
// Frozen f030d9c80 fragment: the original written branch and its marked fallback.
|
||||
let source = include_str!("../hdr_light.wgsl").to_owned()
|
||||
+ include_str!("stars.wgsl")
|
||||
@@ -750,7 +750,7 @@ fn float_stars_keep_the_original_rgb_with_brightness_as_coverage() {
|
||||
@fragment fn reference() -> @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(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_gains), b);
|
||||
}
|
||||
return vec4(stars.color.rgb * b, 0.175);
|
||||
}
|
||||
@@ -763,7 +763,7 @@ fn float_stars_keep_the_original_rgb_with_brightness_as_coverage() {
|
||||
let mut data = [0.0_f32; 32];
|
||||
data[16..20].copy_from_slice(&[0.7, 0.8, 0.9, 0.35]);
|
||||
data[20..24].copy_from_slice(&settings);
|
||||
data[24..28].copy_from_slice(&shares);
|
||||
data[24..28].copy_from_slice(&gains);
|
||||
data[31] = gain;
|
||||
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
@@ -908,7 +908,7 @@ fn actual_opaque_writers_match_the_legacy_non_float_oracle() {
|
||||
}
|
||||
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 (settings, gains) = hdr.draw_params(true);
|
||||
let mut count = 0;
|
||||
for fog in [0.0, 0.5, 1.0] {
|
||||
for visibility in [1.0, 0.4] {
|
||||
@@ -921,7 +921,7 @@ fn actual_opaque_writers_match_the_legacy_non_float_oracle() {
|
||||
hdr_windows: hdr.windows(),
|
||||
hdr_light: [0.8, 0.8, 1.0, 1.0],
|
||||
hdr_settings: settings,
|
||||
hdr_shares: shares,
|
||||
hdr_gains: gains,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
for terrain in [true, false] {
|
||||
@@ -968,7 +968,7 @@ fn actual_translucent_writers_blend_their_own_light() {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let (settings, gains) = 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],
|
||||
@@ -977,7 +977,7 @@ fn actual_translucent_writers_blend_their_own_light() {
|
||||
hdr_windows: hdr.windows(),
|
||||
hdr_light: [0.8, 0.8, 1.0, 1.0],
|
||||
hdr_settings: settings,
|
||||
hdr_shares: shares,
|
||||
hdr_gains: gains,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
// Texel alpha 100 is clear glass's kind of texel, below the 0.6 the marked frame's
|
||||
@@ -1035,14 +1035,14 @@ fn unmarked_entity_users_keep_their_source_gain() {
|
||||
return;
|
||||
}
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let (settings, gains) = 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,
|
||||
hdr_gains: gains,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
let draw = Draw::new(&device, &queue, false, &globals, 255);
|
||||
@@ -1079,13 +1079,13 @@ fn additive_texels_below_the_mark_cutoff_keep_their_gain() {
|
||||
return;
|
||||
}
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let (settings, gains) = 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,
|
||||
hdr_gains: gains,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
let draw = Draw::new(&device, &queue, false, &globals, 16);
|
||||
@@ -1129,7 +1129,7 @@ fn check_oit_writer(terrain: bool, kind: &str, overlay: u32, tier: u32, alphas:
|
||||
}))
|
||||
.unwrap();
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let (settings, gains) = 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],
|
||||
@@ -1138,7 +1138,7 @@ fn check_oit_writer(terrain: bool, kind: &str, overlay: u32, tier: u32, alphas:
|
||||
hdr_windows: hdr.windows(),
|
||||
hdr_light: [0.8, 0.8, 1.0, 1.0],
|
||||
hdr_settings: settings,
|
||||
hdr_shares: shares,
|
||||
hdr_gains: gains,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
for &alpha in alphas {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Frames in flight: the readbacks a submission starts, landing frames the submit thread
|
||||
//! submitted, keeping offscreen frames at most two ahead of the GPU, and a screenshot's copy.
|
||||
|
||||
use super::{Renderer, ScreenshotRequest, copy_frame, screenshot_save, submit};
|
||||
|
||||
impl Renderer {
|
||||
/// The frame's readbacks to start right after its submission (on the submit thread when
|
||||
/// the frame goes there): occlusion culling's results (`cull`) and GPU culling's
|
||||
/// statistics. GPU frames leave occlusion culling off, so a frame has one at most.
|
||||
pub(super) fn after_submit(&mut self) -> Option<submit::AfterSubmit> {
|
||||
let cull = self.cull.as_mut().and_then(|c| c.submitted());
|
||||
let gpu = self.gpu_culling.as_mut().and_then(|g| g.submitted());
|
||||
match (cull, gpu) {
|
||||
(Some(c), Some(g)) => Some(Box::new(move || {
|
||||
c();
|
||||
g();
|
||||
})),
|
||||
(Some(c), None) => Some(Box::new(c)),
|
||||
(None, Some(g)) => Some(Box::new(g)),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
/// Frame `frame`'s statistics shown, unless a newer frame's are: a CPU frame's as it is
|
||||
/// drawn, a GPU frame's when its counts come back (`GpuCulling::collect`).
|
||||
pub(super) fn show_stats(&mut self, frame: u64, [sections, draws, calls]: [usize; 3]) {
|
||||
if frame > self.stats_frame {
|
||||
self.stats_frame = frame;
|
||||
(
|
||||
self.drawn_sections,
|
||||
self.section_draws,
|
||||
self.section_draw_calls,
|
||||
) = (sections, draws, calls);
|
||||
}
|
||||
}
|
||||
/// Frames between the last one drawn and the one whose statistics are shown: 0 after a
|
||||
/// CPU frame, one to three in GPU frames, while their counts are on their way back.
|
||||
pub fn stats_lag(&self) -> u64 {
|
||||
self.frame_serial - self.stats_frame
|
||||
}
|
||||
/// Take in the frame the submit thread submitted: start its readbacks and, offscreen,
|
||||
/// keep at most two frames ahead of the GPU, as a frame submitted in place does.
|
||||
pub(super) fn land_frame(&mut self) {
|
||||
let Some(offscreen) = self.deferred.take() else {
|
||||
return;
|
||||
};
|
||||
let submission = self.queue.landed();
|
||||
if let Some(timer) = &mut self.gpu_timer {
|
||||
timer.submitted(&self.queue);
|
||||
timer.collect(&self.device, &mut self.gpu_times);
|
||||
}
|
||||
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
|
||||
timer.submitted(&self.queue);
|
||||
}
|
||||
if let Some(timer) = &mut self.own_timing {
|
||||
timer.submitted(&self.queue);
|
||||
}
|
||||
if offscreen && let Some(submission) = submission {
|
||||
self.in_flight.push_back(submission);
|
||||
if !self.paced_elsewhere {
|
||||
self.wait_frames();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// No swapchain paces offscreen frames: keep two on the GPU at most.
|
||||
pub fn wait_in_flight(&mut self) {
|
||||
self.land_frame();
|
||||
self.wait_frames();
|
||||
}
|
||||
/// The frame on the submit thread submitted: a surface reconfigure waits for the
|
||||
/// device and breaks if a frame is submitted while it waits, and the compositor
|
||||
/// copies views after them. (The end of a frame already lands the frames of the
|
||||
/// turn before; this is the turn's own.)
|
||||
pub fn wait_submissions(&mut self) {
|
||||
self.queue.wait();
|
||||
}
|
||||
pub(super) fn wait_frames(&mut self) {
|
||||
while self.in_flight.len() > 2 {
|
||||
let index = self.in_flight.pop_front();
|
||||
let _ = self.device.poll(wgpu::PollType::Wait {
|
||||
submission_index: index,
|
||||
timeout: Some(std::time::Duration::from_secs(5)),
|
||||
});
|
||||
}
|
||||
}
|
||||
/// Take a screenshot request: queue the frame's copy, or report why the
|
||||
/// frame cannot be read back.
|
||||
pub(super) fn take_readback(
|
||||
&mut self,
|
||||
request: ScreenshotRequest,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
texture: &wgpu::Texture,
|
||||
) -> Option<(ScreenshotRequest, wgpu::Buffer)> {
|
||||
// The copy and PNG writer both assume 8-bit pixels; a float surface would make
|
||||
// the row stride too small for the copy and abort in validation.
|
||||
if self.config.format.block_copy_size(None) == Some(4) {
|
||||
let size = [self.config.width, self.config.height];
|
||||
let format = self.config.format.remove_srgb_suffix();
|
||||
// The layered float frame maps the world to SDR, then adds the HUD as drawn.
|
||||
// Other 16-bit frames keep their clipped 8-bit copy.
|
||||
let copy = self
|
||||
.hdr
|
||||
.as_mut()
|
||||
.and_then(|hdr| {
|
||||
hdr.sdr_screenshot(&self.device, encoder, format, request.size.is_some())
|
||||
})
|
||||
.or_else(|| {
|
||||
(texture.format() != format && texture.format() == self.output.frame.format)
|
||||
.then(|| {
|
||||
self.readback
|
||||
.unorm_copy(&self.device, encoder, texture, format)
|
||||
})
|
||||
});
|
||||
let texture = copy.as_ref().unwrap_or(texture);
|
||||
Some((request, copy_frame(&self.device, encoder, texture, size)))
|
||||
} else {
|
||||
let error = format!(
|
||||
"screenshots need a 32-bit surface format, not {:?}",
|
||||
self.config.format
|
||||
);
|
||||
if request.background {
|
||||
self.saved_screenshots.push(screenshot_save::Saved {
|
||||
path: request.path,
|
||||
result: Err(error),
|
||||
hdr: None,
|
||||
});
|
||||
} else {
|
||||
self.screenshot_result = Some(Err(error));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2770,6 +2770,8 @@ file = \"b.wgsl\"
|
||||
true,
|
||||
);
|
||||
(display.paper_white, display.peak) = (paper, peak);
|
||||
// As the renderer has it under a pack that draws: Horizon lights none of it.
|
||||
display.lighting = false;
|
||||
if switched {
|
||||
use super::super::hdr::Output::{Pq, ScRgb};
|
||||
display = display.retargeted(&device, &queue, ScRgb);
|
||||
|
||||
@@ -696,13 +696,16 @@ impl Oit {
|
||||
),
|
||||
] {
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
let pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(label),
|
||||
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes(label)),
|
||||
color_attachments: &first,
|
||||
depth_stencil_attachment: read_only(terrain_depth),
|
||||
..Default::default()
|
||||
});
|
||||
let pixels = super::pixel_count(terrain_depth);
|
||||
let mut pass =
|
||||
super::pack_timing::counted(timing.as_deref_mut(), pass, pixels, label);
|
||||
pass.set_bind_group(GROUP, group, &[]);
|
||||
draw(&mut pass, stage);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,169 @@
|
||||
//! The world pass after culling, and the renderer's GPU pass timing switches and readings.
|
||||
use super::{Renderer, native_pack, pack_post, pack_timing};
|
||||
|
||||
pub(super) fn world_pass_after_culling<'e>(
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
view: &wgpu::TextureView,
|
||||
depth: &wgpu::TextureView,
|
||||
keep_depth: bool,
|
||||
mut timing: Option<&mut pack_timing::PassTiming>,
|
||||
name: &str,
|
||||
pixels: u64,
|
||||
) -> pack_timing::CountedPass<'e> {
|
||||
let pass = after_culling_pass(
|
||||
encoder,
|
||||
view,
|
||||
depth,
|
||||
keep_depth,
|
||||
timing.as_deref_mut().and_then(|t| t.writes(name)),
|
||||
);
|
||||
pack_timing::counted(timing, pass, pixels, "before translucent")
|
||||
}
|
||||
/// The pixels of a depth attachment, for `fragments_per_pixel`.
|
||||
pub(super) fn pixel_count(depth: &wgpu::TextureView) -> u64 {
|
||||
let texture = depth.texture();
|
||||
u64::from(texture.width()) * u64::from(texture.height())
|
||||
}
|
||||
pub(super) fn after_culling_pass<'e>(
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
view: &wgpu::TextureView,
|
||||
depth: &wgpu::TextureView,
|
||||
keep_depth: bool,
|
||||
timestamp_writes: Option<wgpu::RenderPassTimestampWrites<'_>>,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon world, after culling"),
|
||||
timestamp_writes,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
// A shader pack reads it as depthtex0.
|
||||
store: if keep_depth {
|
||||
wgpu::StoreOp::Store
|
||||
} else {
|
||||
wgpu::StoreOp::Discard
|
||||
},
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
/// Times the shader pack's passes on the GPU while `on` (the F3 overlay is open or a capture records them).
|
||||
pub fn set_pass_timing(&mut self, on: bool) {
|
||||
self.pass_timing = on;
|
||||
}
|
||||
/// Times the parts of Horizon's own passes while `on`.
|
||||
pub fn set_pass_detail(&mut self, on: bool) {
|
||||
self.pass_detail = on;
|
||||
}
|
||||
/// Times a frame at most once per `gap` while passes are timed (the F3 overlay needs far fewer
|
||||
/// samples than a measurement).
|
||||
pub fn set_pass_timing_gap(&mut self, gap: std::time::Duration) {
|
||||
self.pass_timing_gap = gap;
|
||||
}
|
||||
/// Keeps each timed frame's pass times for `take_timed_frames` while `on` (a capture records them).
|
||||
pub fn set_keep_timed_frames(&mut self, on: bool) {
|
||||
self.keep_timed_frames = on;
|
||||
}
|
||||
/// The frames both timers timed since the last call, without waiting on the GPU.
|
||||
pub fn take_timed_frames(&mut self) -> pack_timing::TimedFrames {
|
||||
let own = self
|
||||
.own_timing
|
||||
.as_mut()
|
||||
.map(pack_timing::PassTiming::take_frames)
|
||||
.unwrap_or_default();
|
||||
let pack = match Self::pass_timer(&mut self.pack_post, &mut self.native) {
|
||||
Some(Some(t)) => t.take_frames(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
pack_timing::TimedFrames { own, pack }
|
||||
}
|
||||
/// The running shader pack's timer, of either kind.
|
||||
pub(super) fn pass_timer<'a>(
|
||||
pack: &'a mut Option<(i32, Option<pack_post::PackPost>)>,
|
||||
native: &'a mut Option<native_pack::NativePack>,
|
||||
) -> Option<&'a mut Option<pack_timing::PassTiming>> {
|
||||
match (pack, native) {
|
||||
(Some((_, Some(post))), _) => Some(&mut post.timing),
|
||||
(_, Some(native)) => Some(&mut native.timing),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Each pass of the running shader pack with its median GPU time in milliseconds, while
|
||||
/// they are timed (`set_pass_timing`).
|
||||
pub fn shader_pass_times(&self) -> Vec<(String, f32)> {
|
||||
let timer = match (&self.pack_post, &self.native) {
|
||||
(Some((_, Some(post))), _) => post.timing.as_ref(),
|
||||
(_, Some(native)) => native.timing.as_ref(),
|
||||
_ => None,
|
||||
};
|
||||
timer
|
||||
.map(pack_timing::PassTiming::medians)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
/// Each of Horizon's own passes with its median GPU time in milliseconds, while they are
|
||||
/// timed (`set_pass_timing`).
|
||||
pub fn own_pass_times(&self) -> Vec<(String, f32)> {
|
||||
self.own_timing
|
||||
.as_ref()
|
||||
.map(pack_timing::PassTiming::medians)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
/// Each of Horizon's own passes with its parts' median GPU times in milliseconds.
|
||||
pub fn own_pass_parts(&self) -> Vec<(String, Vec<(String, f32)>)> {
|
||||
self.own_timing
|
||||
.as_ref()
|
||||
.map(pack_timing::PassTiming::part_medians)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
/// Each counted pass's counters (medians), while detail is on and the device has them.
|
||||
pub fn own_pass_counters(&self) -> Vec<(String, pack_timing::PassCounters)> {
|
||||
self.own_timing
|
||||
.as_ref()
|
||||
.map(pack_timing::PassTiming::counter_medians)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
/// Both timers' readings and this adapter, for a capture; `None` when neither has timed a pass.
|
||||
pub fn gpu_readout(&self) -> Option<pack_timing::GpuReadout> {
|
||||
let own = self
|
||||
.own_timing
|
||||
.as_ref()
|
||||
.map(pack_timing::PassTiming::readings)
|
||||
.filter(|r| !r.is_empty());
|
||||
let pack = match (&self.pack_post, &self.native) {
|
||||
(Some((_, Some(post))), _) => post.timing.as_ref(),
|
||||
(_, Some(native)) => native.timing.as_ref(),
|
||||
_ => None,
|
||||
}
|
||||
.map(pack_timing::PassTiming::readings)
|
||||
.filter(|r| !r.is_empty());
|
||||
(own.is_some() || pack.is_some()).then(|| pack_timing::GpuReadout {
|
||||
adapter: self.info.clone(),
|
||||
own,
|
||||
pack,
|
||||
})
|
||||
}
|
||||
/// Whether dispatch parts and counters are being asked for (`set_pass_detail`).
|
||||
pub fn pass_detail(&self) -> bool {
|
||||
self.pass_detail
|
||||
}
|
||||
/// What detail timing can show on this device; `None` without pass timing.
|
||||
pub fn pass_detail_support(&self) -> Option<pack_timing::DetailSupport> {
|
||||
self.own_timing
|
||||
.as_ref()
|
||||
.map(pack_timing::PassTiming::detail_support)
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ struct Uniform {
|
||||
clip_from_sky: [[f32; 4]; 4],
|
||||
color: [f32; 4],
|
||||
hdr_settings: [f32; 4],
|
||||
hdr_shares: [f32; 4],
|
||||
hdr_gains: [f32; 4],
|
||||
hdr_light: [f32; 4],
|
||||
}
|
||||
|
||||
@@ -184,7 +184,7 @@ impl Stars {
|
||||
clip_from_sky: clip_from_sky.to_cols_array_2d(),
|
||||
color: [b; 4],
|
||||
hdr_settings: globals.hdr_settings,
|
||||
hdr_shares: globals.hdr_shares,
|
||||
hdr_gains: globals.hdr_gains,
|
||||
hdr_light: globals.hdr_light,
|
||||
};
|
||||
uploads.buffer(encoder, &self.uniform, 0, bytemuck::bytes_of(&uniform));
|
||||
|
||||
@@ -5,7 +5,7 @@ struct Stars {
|
||||
// starBrightness in every channel (RenderSystem.setShaderColor).
|
||||
color: vec4<f32>,
|
||||
hdr_settings: vec4<f32>,
|
||||
hdr_shares: vec4<f32>,
|
||||
hdr_gains: vec4<f32>,
|
||||
hdr_light: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> stars: Stars;
|
||||
@@ -20,7 +20,7 @@ struct Stars {
|
||||
@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(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_gains), b);
|
||||
}
|
||||
if HDR_WRITE_LIGHT { return vec4(frame_stored(stars.color).rgb * b, b); }
|
||||
return vec4(stars.color.rgb * b, 0.175);
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
//! an adapter that can't present to the window gives way to another of its backend
|
||||
//! (#13493).
|
||||
use super::{
|
||||
Renderer, check_layers, cull, describe_adapter, is_software, oit, startup_step, surface_format,
|
||||
timing,
|
||||
Renderer, check_layers, cull, describe_adapter, is_software, oit, pack_timing, startup_step,
|
||||
surface_format, timing,
|
||||
};
|
||||
// Ray tracing on request, which the device needs from its creation (gfx.rs may not grow).
|
||||
#[path = "ray_query.rs"]
|
||||
@@ -135,6 +135,7 @@ pub(super) async fn open_adapter(
|
||||
// atomics on images: their buffers and images. Filtering 32-bit float textures: shader
|
||||
// packs' RGBA32F buffers, which GL filters and mipmaps. Count draws: GPU culling's (`gpu_culling`).
|
||||
let features = timing::GpuTimer::features(adapter)
|
||||
| pack_timing::PassTiming::features(adapter)
|
||||
| cull::Cull::features(adapter)
|
||||
| oit::features(adapter)
|
||||
| ray_query::features(adapter)
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
//! The camera's view frustum, as camera-relative clip planes.
|
||||
|
||||
use glam::{Vec3, Vec4};
|
||||
|
||||
/// Camera-relative clip planes (`Frustum`).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Frustum {
|
||||
pub(super) planes: [Vec4; 6],
|
||||
}
|
||||
impl Frustum {
|
||||
/// Whether the camera-relative box `min..max` is at least partly inside.
|
||||
pub fn intersects(&self, min: Vec3, max: Vec3) -> bool {
|
||||
!self.planes.iter().any(|p| {
|
||||
let sign = p.truncate().cmpge(Vec3::ZERO);
|
||||
let v = Vec3::select(sign, max, min);
|
||||
p.truncate().dot(v) + p.w < 0.0
|
||||
})
|
||||
}
|
||||
/// Whether a camera-relative sphere is at least partly inside. Frustum
|
||||
/// planes are normalized, so each test needs only one dot product and a
|
||||
/// radius comparison.
|
||||
pub fn intersects_sphere(&self, center: Vec3, radius: f32) -> bool {
|
||||
if !center.is_finite() || !radius.is_finite() || radius < 0.0 {
|
||||
return true;
|
||||
}
|
||||
!self
|
||||
.planes
|
||||
.iter()
|
||||
.any(|plane| plane.truncate().dot(center) + plane.w < -radius)
|
||||
}
|
||||
/// Depth clip planes alone: X/Y projection jitter cannot change this test.
|
||||
/// Retaining side-of-screen geometry makes this safe before TAA selects a phase.
|
||||
pub fn intersects_depth(&self, min: Vec3, max: Vec3) -> bool {
|
||||
if !min.is_finite() || !max.is_finite() || !min.cmple(max).all() {
|
||||
return true;
|
||||
}
|
||||
!self.planes[4..].iter().any(|p| {
|
||||
let sign = p.truncate().cmpge(Vec3::ZERO);
|
||||
let v = Vec3::select(sign, max, min);
|
||||
p.truncate().dot(v) + p.w < 0.0
|
||||
})
|
||||
}
|
||||
/// Whether the camera-relative box `min..max` is entirely inside. Then
|
||||
/// `intersects` accepts every box within it: each plane's distance only
|
||||
/// grows from this box's nearest corner to the inner box's farthest, and
|
||||
/// rounding keeps that order.
|
||||
pub fn contains(&self, min: Vec3, max: Vec3) -> bool {
|
||||
self.planes.iter().all(|p| {
|
||||
let sign = p.truncate().cmpge(Vec3::ZERO);
|
||||
let v = Vec3::select(sign, min, max);
|
||||
p.truncate().dot(v) + p.w >= 0.0
|
||||
})
|
||||
}
|
||||
/// Whether the camera-relative `point` is on or inside every plane (vanilla's
|
||||
/// `Frustum.pointInFrustum`). A point that is not finite is kept: a test that cannot
|
||||
/// tell must not hide the particle.
|
||||
pub fn contains_point(&self, point: Vec3) -> bool {
|
||||
if !point.is_finite() {
|
||||
return true;
|
||||
}
|
||||
self.planes
|
||||
.iter()
|
||||
.all(|plane| plane.truncate().dot(point) + plane.w >= 0.0)
|
||||
}
|
||||
/// The same frustum seen from a camera moved `blocks` back along the view direction
|
||||
/// (vanilla's `Frustum.offset`): the sides open up near the camera and the near plane
|
||||
/// moves behind it, so what sits just behind or beside the camera stays inside.
|
||||
///
|
||||
/// The view direction is the normal of the depth planes (the two are opposite), signed
|
||||
/// towards where the side planes point. A frustum that has no direction to pull along
|
||||
/// comes back unchanged: a box with no depth planes (the shadow pass's), and a box whose
|
||||
/// sides are parallel to the view direction (an orthographic projection), where the side
|
||||
/// normals cancel and say nothing about which way is forward.
|
||||
pub fn pulled_back(&self, blocks: f32) -> Frustum {
|
||||
let sides: Vec3 = self.planes[..4].iter().map(|p| p.truncate()).sum();
|
||||
let depth = self.planes[4].truncate();
|
||||
let along = depth.dot(sides);
|
||||
// A perspective frustum's four side normals each lean forward, so this is well above 1.
|
||||
if along.abs() < 1.0e-3 {
|
||||
return *self;
|
||||
}
|
||||
let toward_sides = if along > 0.0 { depth } else { -depth };
|
||||
let axis = toward_sides.normalize_or_zero();
|
||||
let mut planes = self.planes;
|
||||
for plane in &mut planes {
|
||||
plane.w += blocks * plane.truncate().dot(axis);
|
||||
}
|
||||
Frustum { planes }
|
||||
}
|
||||
/// The frustum of a camera-relative `view_proj`, for tests outside this module.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn from_view_projection(view_proj: glam::Mat4) -> Frustum {
|
||||
Frustum {
|
||||
planes: super::frustum(view_proj),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Frustum, Vec3};
|
||||
use crate::gfx::world_projection;
|
||||
use glam::Mat4;
|
||||
|
||||
const FOV: f32 = 70.0;
|
||||
const ASPECT: f32 = 16.0 / 9.0;
|
||||
const FAR: f32 = 512.0;
|
||||
const EPS: f32 = 0.01;
|
||||
|
||||
fn looking_down_minus_z() -> Frustum {
|
||||
Frustum::from_view_projection(world_projection(FOV, ASPECT, FAR))
|
||||
}
|
||||
|
||||
fn tangents() -> (f32, f32) {
|
||||
let up = (FOV / 2.0).to_radians().tan();
|
||||
(up * ASPECT, up)
|
||||
}
|
||||
|
||||
/// Each of the six planes of the unmoved frustum, a point just inside and one just outside,
|
||||
/// from the field of view worked out by hand.
|
||||
#[test]
|
||||
fn a_point_is_inside_only_within_all_six_planes() {
|
||||
let view = looking_down_minus_z();
|
||||
let (tan_x, tan_y) = tangents();
|
||||
let depth = 10.0;
|
||||
let inside = [
|
||||
Vec3::new(0.0, 0.0, -depth),
|
||||
Vec3::new(depth * tan_x - EPS, 0.0, -depth),
|
||||
Vec3::new(-(depth * tan_x - EPS), 0.0, -depth),
|
||||
Vec3::new(0.0, depth * tan_y - EPS, -depth),
|
||||
Vec3::new(0.0, -(depth * tan_y - EPS), -depth),
|
||||
Vec3::new(0.0, 0.0, -0.06),
|
||||
Vec3::new(0.0, 0.0, -(FAR - 0.1)),
|
||||
];
|
||||
let outside = [
|
||||
Vec3::new(depth * tan_x + EPS, 0.0, -depth),
|
||||
Vec3::new(-(depth * tan_x + EPS), 0.0, -depth),
|
||||
Vec3::new(0.0, depth * tan_y + EPS, -depth),
|
||||
Vec3::new(0.0, -(depth * tan_y + EPS), -depth),
|
||||
Vec3::new(0.0, 0.0, -0.04),
|
||||
Vec3::new(0.0, 0.0, -(FAR + 0.1)),
|
||||
Vec3::new(0.0, 0.0, 1.0),
|
||||
Vec3::new(0.0, 0.0, 0.0),
|
||||
];
|
||||
for p in inside {
|
||||
assert!(view.contains_point(p), "{p} should be inside");
|
||||
}
|
||||
for p in outside {
|
||||
assert!(!view.contains_point(p), "{p} should be outside");
|
||||
}
|
||||
assert!(view.contains_point(Vec3::NAN), "an unknown point is kept");
|
||||
}
|
||||
|
||||
/// Camera 3 blocks back: its apex is at z = 3, so the sides open by 3 * tan, the near
|
||||
/// plane is at z = 3 - 0.05 and the far plane at z = 3 - FAR.
|
||||
#[test]
|
||||
fn pulling_the_camera_back_moves_every_plane() {
|
||||
let (tan_x, tan_y) = tangents();
|
||||
let back = looking_down_minus_z().pulled_back(3.0);
|
||||
let inside = [
|
||||
Vec3::new(0.0, 0.0, 2.9),
|
||||
Vec3::new(3.0 * tan_x - EPS, 0.0, 0.0),
|
||||
Vec3::new(-(3.0 * tan_x - EPS), 0.0, 0.0),
|
||||
Vec3::new(0.0, 3.0 * tan_y - EPS, 0.0),
|
||||
Vec3::new(0.0, -(3.0 * tan_y - EPS), 0.0),
|
||||
Vec3::new(0.0, 0.0, 3.0 - FAR + 0.1),
|
||||
];
|
||||
let outside = [
|
||||
Vec3::new(0.0, 0.0, 3.0),
|
||||
Vec3::new(3.0 * tan_x + EPS, 0.0, 0.0),
|
||||
Vec3::new(-(3.0 * tan_x + EPS), 0.0, 0.0),
|
||||
Vec3::new(0.0, 3.0 * tan_y + EPS, 0.0),
|
||||
Vec3::new(0.0, -(3.0 * tan_y + EPS), 0.0),
|
||||
Vec3::new(0.0, 0.0, 3.0 - FAR - 0.1),
|
||||
];
|
||||
for p in inside {
|
||||
assert!(back.contains_point(p), "{p} should be inside");
|
||||
}
|
||||
for p in outside {
|
||||
assert!(!back.contains_point(p), "{p} should be outside");
|
||||
}
|
||||
// The unmoved frustum does not have the point that only the offset one has.
|
||||
assert!(!looking_down_minus_z().contains_point(Vec3::new(0.0, 0.0, 2.9)));
|
||||
// An offset of nothing changes no verdict.
|
||||
let same = looking_down_minus_z();
|
||||
let unmoved = same.pulled_back(0.0);
|
||||
for p in [
|
||||
Vec3::new(1.0, 2.0, -3.0),
|
||||
Vec3::new(0.0, 0.0, 1.0),
|
||||
Vec3::new(40.0, 0.0, -5.0),
|
||||
] {
|
||||
assert_eq!(same.contains_point(p), unmoved.contains_point(p));
|
||||
}
|
||||
}
|
||||
|
||||
/// The offset follows the camera's own view direction, whatever way it looks.
|
||||
#[test]
|
||||
fn the_offset_is_along_the_view_direction() {
|
||||
for (yaw, pitch) in [(0.3_f32, 0.0_f32), (2.0, -0.5), (-1.2, 0.9), (3.0, 1.2)] {
|
||||
let pose = Mat4::from_rotation_x(pitch) * Mat4::from_rotation_y(yaw);
|
||||
let view = Frustum::from_view_projection(world_projection(FOV, ASPECT, FAR) * pose);
|
||||
let forward = pose.inverse().transform_vector3(-Vec3::Z);
|
||||
let back = view.pulled_back(3.0);
|
||||
assert!(
|
||||
back.contains_point(forward * -2.9),
|
||||
"{yaw} {pitch}: 2.9 behind"
|
||||
);
|
||||
assert!(
|
||||
!back.contains_point(forward * -3.0),
|
||||
"{yaw} {pitch}: 3 behind"
|
||||
);
|
||||
assert!(
|
||||
!view.contains_point(forward * -2.9),
|
||||
"{yaw} {pitch}: unmoved"
|
||||
);
|
||||
assert!(back.contains_point(forward * 20.0) && view.contains_point(forward * 20.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// An orthographic box has no apex to pull back from: its side normals cancel, so there is
|
||||
/// no direction, and the frustum must come back as it was.
|
||||
#[test]
|
||||
fn an_orthographic_frustum_stays_where_it_is() {
|
||||
let ortho =
|
||||
Frustum::from_view_projection(Mat4::orthographic_rh(-8.0, 8.0, -4.5, 4.5, 100.0, 0.05));
|
||||
let moved = ortho.pulled_back(3.0);
|
||||
for p in [
|
||||
Vec3::new(0.0, 0.0, 2.0),
|
||||
Vec3::new(0.0, 0.0, -9.0),
|
||||
Vec3::new(7.9, 4.4, -9.0),
|
||||
Vec3::new(8.1, 0.0, -9.0),
|
||||
Vec3::new(0.0, 0.0, -101.0),
|
||||
] {
|
||||
assert_eq!(ortho.contains_point(p), moved.contains_point(p), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The shadow pass's box has no depth planes, so no direction: it must not move.
|
||||
#[test]
|
||||
fn a_frustum_without_depth_planes_stays_where_it_is() {
|
||||
let mut flat = looking_down_minus_z();
|
||||
flat.planes[4] = glam::Vec4::W;
|
||||
flat.planes[5] = glam::Vec4::W;
|
||||
let moved = flat.pulled_back(3.0);
|
||||
for p in [
|
||||
Vec3::new(0.0, 0.0, 2.0),
|
||||
Vec3::new(0.0, 0.0, -9.0),
|
||||
Vec3::new(80.0, 0.0, -9.0),
|
||||
] {
|
||||
assert_eq!(flat.contains_point(p), moved.contains_point(p));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -239,17 +239,17 @@ impl Visbuffer {
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
depth: &wgpu::TextureView,
|
||||
clear: bool,
|
||||
timing: Option<&mut PassTiming>,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
mut timing: Option<&mut PassTiming>,
|
||||
) -> super::pack_timing::CountedPass<'e> {
|
||||
let (ids, _) = self.ids.as_ref().expect("fitted before the world pass");
|
||||
let name = if clear {
|
||||
"Visibility IDs"
|
||||
} else {
|
||||
"Visibility IDs after culling"
|
||||
};
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
let pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon visibility buffer"),
|
||||
timestamp_writes: timing.and_then(|t| t.writes(name)),
|
||||
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes(name)),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: ids,
|
||||
resolve_target: None,
|
||||
@@ -272,7 +272,8 @@ impl Visbuffer {
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
});
|
||||
super::pack_timing::counted(timing, pass, super::pixel_count(depth), name)
|
||||
}
|
||||
|
||||
/// Phase 1's solid calls into the IDs, as the normal passes draw them: the opaque
|
||||
|
||||
@@ -360,13 +360,13 @@ impl Post {
|
||||
&mut self,
|
||||
gain: bool,
|
||||
settings: [f32; 4],
|
||||
shares: [f32; 4],
|
||||
gains: [f32; 4],
|
||||
windows: [[f32; 4]; 2],
|
||||
) {
|
||||
// These uniforms do not change baked colour without gain. Keep compatibility
|
||||
// history stable when only its final display settings change.
|
||||
let key = Some(if gain {
|
||||
(true, settings, shares, windows)
|
||||
(true, settings, gains, windows)
|
||||
} else {
|
||||
(false, [0.0; 4], [0.0; 4], [[0.0; 4]; 2])
|
||||
});
|
||||
@@ -887,7 +887,7 @@ mod tests {
|
||||
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
|
||||
assert_eq!(post.taa_this_frame.jitter[2], 0.0);
|
||||
eprintln!(
|
||||
"TAA baked-light GPU checked gain, all settings/shares/windows and the first frame after a pack"
|
||||
"TAA baked-light GPU checked gain, all settings/gains/windows and the first frame after a pack"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ fn warp_fragment(in: TerrainOut, front: bool) -> Fragment {
|
||||
let fog = 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, fog));
|
||||
return vec4(hdr_written(finished.rgb, mark, globals.hdr_settings, globals.hdr_shares), finished.a);
|
||||
return vec4(hdr_written(finished.rgb, mark, globals.hdr_settings, globals.hdr_gains), finished.a);
|
||||
}
|
||||
return frame_stored(finished);
|
||||
}
|
||||
@@ -853,7 +853,7 @@ impl H {
|
||||
fn label(self, s: &Settings, out: &EffectiveOutput) -> Text {
|
||||
let g = &s.horizon;
|
||||
match self {
|
||||
H::Preset => labelled("Preset", g.preset.label()),
|
||||
H::Preset => labelled("Horizon Preset", g.preset.label()),
|
||||
H::LodDistance => match g.lod_distance {
|
||||
0 => labelled("LOD Distance", "Off"),
|
||||
n => labelled("LOD Distance", lang::tr("%s chunks", &[&n])),
|
||||
@@ -1753,6 +1753,14 @@ mod tests {
|
||||
ui.set_search("tearing");
|
||||
let found = ui.listed(&s, SIZE);
|
||||
assert!(found.iter().any(|(_, i)| *i == Item::Vanilla(Opt::Vsync)));
|
||||
// Both presets answer "preset"; Horizon's own, named apart from vanilla's, answers its name.
|
||||
ui.set_search("preset");
|
||||
let found = ui.listed(&s, SIZE);
|
||||
let general = |item| (Page::General, item);
|
||||
assert!(found.contains(&general(Item::Horizon(H::Preset))));
|
||||
assert!(found.contains(&general(Item::Vanilla(Opt::Preset))));
|
||||
ui.set_search("horizon preset");
|
||||
assert_eq!(ui.listed(&s, SIZE), [general(Item::Horizon(H::Preset))]);
|
||||
ui.set_search("hdr");
|
||||
let [x, y, w, h] = cols.page(Page::ALL.len() - 1);
|
||||
press(&mut ui, &mut s, (x + w / 2.0, y + h / 2.0));
|
||||
|
||||
@@ -20,15 +20,20 @@ struct Params {
|
||||
black: f32,
|
||||
// The HUD and menus' brightness as a share of paper white.
|
||||
ui: f32,
|
||||
// HDR lighting: what the strongest light multiplies a surface by, 1 for none.
|
||||
// The compatibility frame's lift (`lit`; the float frame's writers have theirs from
|
||||
// Hdr::draw_params). HDR lighting: what the strongest light multiplies a surface by, 1 for
|
||||
// none.
|
||||
scene: f32,
|
||||
// Per tier, low to very bright (hdr.rs `Tier`): the share of the headroom
|
||||
// Per tier, low to very bright (hdr.rs `COMPAT_SHARES`): the share of the headroom
|
||||
// its fully lifted pixels get.
|
||||
share: vec4<f32>,
|
||||
// HDR calibration (hdr_calibration.rs): x its step (0 none, 1 black level,
|
||||
// 2 peak, 3 paper white), y the slider in nits, z where its controls start,
|
||||
// a share of the height.
|
||||
calibration: vec4<f32>,
|
||||
// The scene's peak in units of paper white where Horizon lifts the world (hdr.rs
|
||||
// `SCENE_PEAK`), else 0: what the tone curve maps onto the display's peak (`tone_scale`).
|
||||
scene_peak: f32,
|
||||
};
|
||||
@group(0) @binding(2) var<uniform> params: Params;
|
||||
// Horizon's logo, 1 inside, 0 outside, for calibration's test pictures.
|
||||
@@ -155,7 +160,7 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
|
||||
}
|
||||
|
||||
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);
|
||||
return hdr_mark_light(f, a, shows, eased, vec4(params.peak, params.scene, params.world, params.debug), (params.peak - 1.0) * params.share);
|
||||
}
|
||||
|
||||
// Calibration: above its controls the test picture, straight in nits, past
|
||||
@@ -251,33 +256,52 @@ fn display(light: vec3<f32>, pos: vec4<f32>) -> vec4<f32> {
|
||||
return display(light, pos);
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Where the scene's peak is unknown (`tone_scale`), the 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;
|
||||
}
|
||||
// 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` (T). 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 reaching T at the source's peak, past which light shows at T. The brightest channel sets
|
||||
// the scale for all three, so light keeps its hue (lava stays orange) and no channel passes T.
|
||||
//
|
||||
// The source is the scene's peak S where Horizon lifts the world (`scene_peak`, hdr.rs
|
||||
// `SCENE_PEAK`): the world's light is the same on every display, light past S is held at S, and
|
||||
// where T reaches S that is all, so a display above S shows the world as one at S. Where the
|
||||
// scene's range is unknown (`scene_peak` 0: a 0.2 pack's own light, HDR Lighting off), the
|
||||
// source follows the display, as the 0.2 contract has it: the peak that puts the EETF's knee at
|
||||
// `tone_knee`, PQ's 10 000 nits at most.
|
||||
fn tone_scale(top: f32, scene_peak: f32, headroom: f32, paper: f32) -> f32 {
|
||||
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;
|
||||
var source: f32;
|
||||
var e: f32;
|
||||
var held = 1.0;
|
||||
if scene_peak > 0.0 {
|
||||
held = min(1.0, scene_peak / max(top, 1e-6));
|
||||
if scene_peak <= headroom {
|
||||
return held;
|
||||
}
|
||||
source = pq(vec3(scene_peak * paper)).x;
|
||||
e = pq(vec3(min(top, scene_peak) * paper)).x / source;
|
||||
} else {
|
||||
if top <= tone_knee(headroom) {
|
||||
return 1.0;
|
||||
}
|
||||
source = min(3.0 * peak - 2.0 * pq(vec3(tone_knee(headroom) * paper)).x, 1.0);
|
||||
e = pq(vec3(top * paper)).x / source;
|
||||
}
|
||||
let max_lum = peak / source;
|
||||
let ks = 1.5 * max_lum - 0.5;
|
||||
// At least 0: past 3 to 1 in PQ the knee would leave the source's range.
|
||||
let ks = max(1.5 * max_lum - 0.5, 0.0);
|
||||
if e >= 1.0 {
|
||||
return headroom / top;
|
||||
}
|
||||
if e <= ks {
|
||||
return 1.0;
|
||||
return held;
|
||||
}
|
||||
let t = (e - ks) / (1.0 - ks);
|
||||
let t2 = t * t;
|
||||
@@ -287,16 +311,16 @@ fn tone_scale(top: f32, headroom: f32, paper: f32) -> f32 {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// hdr-linear-light.md, `[tonemap]`): the light (`world` holds the frame) fitted to the display, in
|
||||
// gamma, where scaling the light by s scales the stored colour by s^(1/2.2). Light the curve
|
||||
// leaves alone stays exactly as stored. With no headroom and no scene peak, 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 {
|
||||
if params.scene_peak == 0.0 && 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);
|
||||
let s = tone_scale(top, params.scene_peak, params.peak, params.paper * 80.0);
|
||||
return vec4(select(g * pow(s, 1.0 / 2.2), g, s == 1.0), 1.0);
|
||||
}
|
||||
|
||||
@@ -331,7 +355,7 @@ fn layered(at: vec2<i32>) -> vec3<f32> {
|
||||
}
|
||||
|
||||
// Screenshots keep midtones at their drawn brightness, with a shoulder starting at 0.6
|
||||
// paper white in linear light. Unlike the display curve (whose knee is at least white),
|
||||
// paper white in linear light. Unlike the display curve (which ends at the display's peak),
|
||||
// this has an unbounded source range: k + (1-k)*(top-k)/(top+1-2*k) approaches SDR
|
||||
// white smoothly, with slope 1 at k. One scale keeps the world's linear RGB ratios.
|
||||
fn screenshot_world(w: vec3<f32>) -> vec3<f32> {
|
||||
|
||||
@@ -27,7 +27,8 @@ struct Lit {
|
||||
lifted: bool,
|
||||
};
|
||||
|
||||
fn hdr_mark_light(f: vec3<f32>, a: f32, shows: bool, eased: bool, settings: vec4<f32>, shares: vec4<f32>) -> Lit {
|
||||
// `lifts`, per tier: how much its fully lifted pixels gain over their own light (their gain - 1).
|
||||
fn hdr_mark_light(f: vec3<f32>, a: f32, shows: bool, eased: bool, settings: vec4<f32>, lifts: 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
|
||||
@@ -60,7 +61,7 @@ fn hdr_mark_light(f: vec3<f32>, a: f32, shows: bool, eased: bool, settings: vec4
|
||||
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;
|
||||
var gain = 1.0 + lifts[tier] * lift;
|
||||
if eased {
|
||||
gain = min(gain, settings.x / max(top, 1e-4));
|
||||
}
|
||||
@@ -88,7 +89,8 @@ 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));
|
||||
// The world's writers' light, from the tiers' `gains` (Hdr::draw_params): the same on every
|
||||
// display; the tone curve fits it to the display afterwards (fs_tonemap).
|
||||
fn hdr_written(color: vec3<f32>, mark: f32, settings: vec4<f32>, gains: vec4<f32>) -> vec3<f32> {
|
||||
return pow(hdr_mark_light(max(color, vec3(0.0)), hdr_quantized_mark(mark), settings.z > 0.5, false, settings, gains - vec4(1.0)).light, vec3(1.0 / 2.2));
|
||||
}
|
||||
@@ -55,7 +55,7 @@ pub struct TextureFlags {
|
||||
impl TextureFlags {
|
||||
/// The `texture` section of a `.png.mcmeta` file, if it sets either flag.
|
||||
pub fn from_mcmeta(json: &str) -> Option<Self> {
|
||||
let meta = crate::packs::first_json(json.as_bytes())?;
|
||||
let meta: serde_json::Value = crate::packs::first_json(json.as_bytes()).ok()?;
|
||||
let section = meta.get("texture")?;
|
||||
let flag = |key| section.get(key).and_then(serde_json::Value::as_bool) == Some(true);
|
||||
let flags = Self {
|
||||
@@ -388,7 +388,7 @@ fn strip_frame(pixels: &[u8], stride: u32, x: u32, y: u32, w: u32, h: u32) -> Ve
|
||||
}
|
||||
/// The `animation` section of a texture's `.mcmeta`.
|
||||
fn animation_of(mcmeta: Option<&str>) -> Option<AnimationMetadata> {
|
||||
let meta = crate::packs::first_json(mcmeta?.as_bytes())?;
|
||||
let meta: serde_json::Value = crate::packs::first_json(mcmeta?.as_bytes()).ok()?;
|
||||
AnimationMetadata::from_json(&meta.get("animation")?.to_string()).ok()
|
||||
}
|
||||
/// An animated strip's first frame: the region the mcmeta's frame size picks
|
||||
@@ -1401,9 +1401,9 @@ impl HudAtlas {
|
||||
edition.3 = pixels;
|
||||
}
|
||||
images.push(("horizon/white".into(), 4, 4, vec![255; 64]));
|
||||
// The controller's art, unless a pack brought its own (walked above, as a mod's GUI
|
||||
// textures are).
|
||||
for image in crate::pad_glyph::images() {
|
||||
// The controller's art and Horizon's mark, unless a pack brought its own (walked above, as a
|
||||
// mod's GUI textures are).
|
||||
for image in crate::pad_glyph::images().chain(crate::mark_sprite::image()) {
|
||||
if !images.iter().any(|i| i.0 == image.0) {
|
||||
images.push(image);
|
||||
}
|
||||
|
||||
@@ -396,6 +396,7 @@ fn coverage_uses_pack_texts_final_alpha_and_discard() {
|
||||
let program = crate::core_shader::CoreProgram {
|
||||
vertex: naga::front::wgsl::parse_str("@vertex fn main(@location(0) p: vec2<f32>) -> @builtin(position) vec4<f32> { return vec4(p.x / 2.0 - 1.0, 1.0 - p.y, 0.0, 1.0); }").unwrap(),
|
||||
fragment: naga::front::wgsl::parse_str("struct Result { @location(0) color: vec4<f32> } @fragment fn main(@builtin(position) p: vec4<f32>) -> Result { if p.x > 2.0 { discard; } return Result(vec4(0.4, 0.2, 0.1, 0.5)); }").unwrap(),
|
||||
other: Vec::new(),
|
||||
};
|
||||
gpu.hud.set_text_shader(&gpu.device, Some(&program));
|
||||
assert!(gpu.hud.pack_text.is_some());
|
||||
@@ -417,6 +418,7 @@ fn a_pack_text_shader_without_a_colour_output_is_dropped_not_wrapped() {
|
||||
let program = crate::core_shader::CoreProgram {
|
||||
vertex: naga::front::wgsl::parse_str("@vertex fn main(@location(0) p: vec2<f32>) -> @builtin(position) vec4<f32> { return vec4(p, 0.0, 1.0); }").unwrap(),
|
||||
fragment: naga::front::wgsl::parse_str("@fragment fn main() -> @location(1) vec4<f32> { return vec4(1.0); }").unwrap(),
|
||||
other: Vec::new(),
|
||||
};
|
||||
gpu.hud.set_text_shader(&gpu.device, Some(&program));
|
||||
assert!(gpu.hud.pack_text.is_none());
|
||||
|
||||
@@ -39,14 +39,13 @@ pub fn text_with_backdrop(
|
||||
background: u32,
|
||||
) {
|
||||
if background != 0 {
|
||||
// Font.PreparedTextBuilder.markBackground: one pixel above and left of
|
||||
// the glyphs, the glyph advance to the right and nine high: not a
|
||||
// fixed two-pixel frame.
|
||||
// GuiGraphicsExtractor.textWithBackdrop adds two pixels around the
|
||||
// text's advance and the font's nine-pixel line height.
|
||||
out.push(fill(
|
||||
x - 1,
|
||||
y - 1,
|
||||
x + w,
|
||||
y + 9,
|
||||
x - 2,
|
||||
y - 2,
|
||||
x + w + 2,
|
||||
y + 11,
|
||||
multiply(background, color),
|
||||
));
|
||||
}
|
||||
@@ -1491,10 +1490,10 @@ mod boss_bar_room_tests {
|
||||
mod backdrop_tests {
|
||||
use super::*;
|
||||
|
||||
/// `markBackground`: the backdrop reaches one pixel above and left of the
|
||||
/// glyphs, their advance to the right and nine down, not 2 on every side.
|
||||
/// `GuiGraphicsExtractor.textWithBackdrop`: two pixels around the advance
|
||||
/// and the nine-pixel font line, independently of glyph-background drawing.
|
||||
#[test]
|
||||
fn the_backdrop_frames_the_glyphs_by_one_pixel() {
|
||||
fn the_backdrop_frames_the_text_by_two_pixels() {
|
||||
let mut out = Vec::new();
|
||||
text_with_backdrop(
|
||||
&mut out,
|
||||
@@ -1508,6 +1507,6 @@ mod backdrop_tests {
|
||||
let Some(GuiDraw::Fill { x0, y0, x1, y1, .. }) = out.first() else {
|
||||
panic!("a backdrop is a fill first");
|
||||
};
|
||||
assert_eq!((*x0, *y0, *x1, *y1), (9, 19, 16, 29));
|
||||
assert_eq!((*x0, *y0, *x1, *y1), (8, 18, 18, 31));
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,7 @@ pub mod level_loading;
|
||||
pub mod light;
|
||||
pub mod lod;
|
||||
pub mod logging;
|
||||
pub mod mark_sprite;
|
||||
pub mod menu_blur;
|
||||
pub mod menu_style;
|
||||
pub mod mesher;
|
||||
@@ -102,6 +103,9 @@ pub mod pad_glyph;
|
||||
pub mod paged_maps;
|
||||
pub mod par;
|
||||
pub mod particles;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod perf_capture;
|
||||
pub mod perf_gpu;
|
||||
pub mod poi_sections;
|
||||
pub mod polytone_post;
|
||||
pub mod post_chain;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
//! Horizon's mark (`docs/media/brand/mark-dark.svg`) on the text grid, for an inline text sprite.
|
||||
|
||||
/// The mark's name in the HUD atlas.
|
||||
pub const SPRITE: &str = "horizon:gui/mark";
|
||||
const PNG: &[u8] = include_bytes!("../../horizon-client/assets/horizon/textures/gui/mark.png");
|
||||
|
||||
/// The mark as the HUD atlas takes an image.
|
||||
pub fn image() -> std::iter::Once<(String, u32, u32, Vec<u8>)> {
|
||||
let (width, height, rgba) = crate::hud::decode_bytes(PNG).expect("Horizon's mark is PNG");
|
||||
std::iter::once((SPRITE.to_owned(), width, height, rgba))
|
||||
}
|
||||
@@ -2259,22 +2259,14 @@ impl Mesher<'_> {
|
||||
translucent: face.translucent,
|
||||
opaque: face.opaque,
|
||||
};
|
||||
// A tile keeps the quad's forced translucency; `<skip>` and `<default>` are its own.
|
||||
let tile = |t: TileLayer| match t {
|
||||
TileLayer::Layer {
|
||||
layer,
|
||||
translucent,
|
||||
opaque,
|
||||
} => TileLayer::Layer {
|
||||
layer,
|
||||
translucent: translucent || q.render_type.is_none() && q.material.force_translucent,
|
||||
opaque,
|
||||
},
|
||||
TileLayer::Skip | TileLayer::Default => own,
|
||||
};
|
||||
let own_translucent = self.translucent(own);
|
||||
let tile = |t: TileLayer| in_face_pass(t, own, own_translucent);
|
||||
match pick {
|
||||
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::Tile(t) => {
|
||||
let v = corners.map(|c| c.0);
|
||||
self.quad_as(out, force_solid, tile(t), v, false, tint);
|
||||
}
|
||||
Pick::Quarters(tiles) => {
|
||||
let cuts = cuts(&corners);
|
||||
for s in cuts.windows(2) {
|
||||
@@ -2283,7 +2275,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, force_solid, tile(tiles[quarter]), piece, tint);
|
||||
self.quad_as(out, force_solid, tile(tiles[quarter]), piece, false, tint);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2296,12 +2288,7 @@ impl Mesher<'_> {
|
||||
if count == 0 {
|
||||
return;
|
||||
}
|
||||
let face_translucent = match pick {
|
||||
Pick::Keep => self.translucent(own),
|
||||
Pick::Tile(t) => self.translucent(tile(t)),
|
||||
Pick::Quarters(tiles) => tiles.into_iter().any(|t| self.translucent(tile(t))),
|
||||
Pick::Nothing => false,
|
||||
};
|
||||
let face_translucent = !matches!(pick, Pick::Nothing) && own_translucent;
|
||||
// Overlay tiles face the standard way too (Continuity: "overlay textures always have the
|
||||
// standard orientation"), whatever turns the model's own UVs (dirt's and sand's random
|
||||
// rotations).
|
||||
@@ -2450,6 +2437,21 @@ fn cuts(c: &[(TerrainVertex, [f32; 2]); 4]) -> Cuts {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Tile `t` as drawn over a face whose own sprite is `own`, in the face's pass
|
||||
/// (`own_translucent`): CTM picks a texture, not a render layer (OptiFine draws a tile in the
|
||||
/// block's). The tile's texels only drop the alpha test when all are opaque. A Conquest grass
|
||||
/// blade tile with 7 partly clear texels of 1024 blended, and wrote the depth of its clear
|
||||
/// rectangle, which Bliss fogs. `<skip>` and `<default>` are the face's own sprite.
|
||||
fn in_face_pass(t: TileLayer, own: TileLayer, own_translucent: bool) -> TileLayer {
|
||||
match t {
|
||||
TileLayer::Layer { layer, opaque, .. } => TileLayer::Layer {
|
||||
layer,
|
||||
translucent: own_translucent,
|
||||
opaque,
|
||||
},
|
||||
TileLayer::Skip | TileLayer::Default => own,
|
||||
}
|
||||
}
|
||||
/// The UV a vanilla cube's face gives the block-local point `p` (`BlockElement`'s default UVs),
|
||||
/// in sprite units: up is north on the top face, south on the bottom one, and up on the sides.
|
||||
fn standard_uv(facing: horizon_assets::model::Direction, [x, y, z]: [f32; 3]) -> [f32; 2] {
|
||||
@@ -2574,6 +2576,38 @@ mod quarter_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tile_draws_in_its_faces_pass() {
|
||||
// Conquest's grass blade tile: a few partly clear texels, over an opaque face.
|
||||
let blade = TileLayer::Layer {
|
||||
layer: 5,
|
||||
translucent: true,
|
||||
opaque: false,
|
||||
};
|
||||
let face = |translucent| TileLayer::Layer {
|
||||
layer: 1,
|
||||
translucent,
|
||||
opaque: !translucent,
|
||||
};
|
||||
let cut = TileLayer::Layer {
|
||||
layer: 5,
|
||||
translucent: false,
|
||||
opaque: false,
|
||||
};
|
||||
assert_eq!(in_face_pass(blade, face(false), false), cut);
|
||||
// Over stained glass the tile blends, as the face does.
|
||||
let blend = TileLayer::Layer {
|
||||
layer: 5,
|
||||
translucent: true,
|
||||
opaque: false,
|
||||
};
|
||||
assert_eq!(in_face_pass(blade, face(true), true), blend);
|
||||
assert_eq!(
|
||||
in_face_pass(TileLayer::Default, face(false), false),
|
||||
face(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlays_take_the_cubes_own_uvs() {
|
||||
use horizon_assets::model::Direction as D;
|
||||
|
||||
@@ -24,7 +24,7 @@ fn oit_cloud_color(in: Out, front: bool, fade: bool) -> vec4<f32> {
|
||||
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);
|
||||
globals.hdr_settings, globals.hdr_gains), 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);
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
@fragment fn fs_oit_lightning_accumulate(in: Out) -> @location(0) vec4<f32> {
|
||||
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);
|
||||
color = vec4(hdr_written(color.rgb, hdr_entity_mark(in), globals.hdr_settings, globals.hdr_gains), color.a);
|
||||
}
|
||||
return oit_accumulate_additive(vec4(color.rgb, min(0.99, color.a)), 0.0, in.clip);
|
||||
}
|
||||
@@ -41,7 +41,7 @@ fn oit_terrain_color(in: TerrainOut) -> vec4<f32> {
|
||||
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(hdr_written(fogged, mark, globals.hdr_settings, globals.hdr_gains) * weighted.a, weighted.a);
|
||||
}
|
||||
return vec4(rgb, weighted.a);
|
||||
}
|
||||
|
||||
@@ -1594,9 +1594,9 @@ pub struct OptionsUi {
|
||||
radial_pick: shell::Shell<crate::radial_editor::PickScreen>,
|
||||
/// Shaders: the shader packs and the picked one's options, on the settings shell.
|
||||
shader: shell::Shell<crate::shader_ui::ShaderScreen>,
|
||||
/// The Pack Options page, and the packs' Polytone config entries by pack id (the game
|
||||
/// fills them beside `pack_infos`).
|
||||
pub pack_options: crate::pack_options_ui::PackOptionsUi,
|
||||
/// Pack Options, on the settings shell, and the packs' Polytone config entries by pack id
|
||||
/// (the game fills them beside `pack_infos`).
|
||||
pack_options: shell::Shell<crate::pack_options_ui::PackOptionsScreen>,
|
||||
pub pack_configs: std::collections::BTreeMap<String, horizon_polytone::pack::Configs>,
|
||||
pub split_profiles: crate::split_profiles::SplitProfiles,
|
||||
/// HDR calibration while its page is open.
|
||||
@@ -3401,10 +3401,6 @@ impl OptionsUi {
|
||||
c.draw(batch, atlas, cursor);
|
||||
return;
|
||||
}
|
||||
if self.page() == Page::PackOptions {
|
||||
self.pack_options.draw(batch, atlas, cursor, self.in_world);
|
||||
return;
|
||||
}
|
||||
let waiting = self.waiting();
|
||||
let in_world = self.in_world;
|
||||
if self.draw_radial_name(batch, atlas, cursor, in_world) {
|
||||
@@ -3786,20 +3782,6 @@ impl OptionsUi {
|
||||
});
|
||||
return self.shell_events(s, outputs);
|
||||
}
|
||||
if self.page() == Page::PackOptions {
|
||||
let mut events = Vec::new();
|
||||
for action in self
|
||||
.pack_options
|
||||
.click((x, y), size, drag, right || self.shift)
|
||||
{
|
||||
match action {
|
||||
crate::pack_options_ui::Action::ButtonClick => events.push(Event::ButtonClick),
|
||||
crate::pack_options_ui::Action::SliderDrag => {}
|
||||
crate::pack_options_ui::Action::Done => events.extend(self.back(s)),
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
if self.page() == Page::Packs {
|
||||
let mut events = Vec::new();
|
||||
if drag {
|
||||
@@ -3829,14 +3811,7 @@ impl OptionsUi {
|
||||
crate::pack_screen::Action::BrowsePick(i) => {
|
||||
events.push(Event::DownloadPack(i));
|
||||
}
|
||||
crate::pack_screen::Action::Options(id) => {
|
||||
let mut configs = horizon_polytone::pack::Configs::default();
|
||||
if let Some(own) = self.pack_configs.get(&id) {
|
||||
configs.merge(own);
|
||||
}
|
||||
self.pack_options = crate::pack_options_ui::PackOptionsUi::open(configs);
|
||||
self.push(Page::PackOptions);
|
||||
}
|
||||
crate::pack_screen::Action::Options(id) => self.open_pack_options(&id),
|
||||
crate::pack_screen::Action::None => {}
|
||||
}
|
||||
return events;
|
||||
@@ -4074,7 +4049,6 @@ impl OptionsUi {
|
||||
// release, even if the pointer has left the widget or its value stayed.
|
||||
let slider = self.dragging.take().is_some();
|
||||
let shell_slider = self.shell_mut().and_then(|shell| shell.release());
|
||||
self.pack_options.release();
|
||||
let calibration_slider = self.calibration.as_mut().is_some_and(|c| c.release());
|
||||
let split_drop = self.split_setup.release();
|
||||
if slider || shell_slider.is_some() || calibration_slider {
|
||||
@@ -4168,10 +4142,6 @@ impl OptionsUi {
|
||||
self.studio.wheel(amount);
|
||||
return Vec::new();
|
||||
}
|
||||
if self.page() == Page::PackOptions {
|
||||
self.pack_options.scroll(size, amount);
|
||||
return Vec::new();
|
||||
}
|
||||
let plain = self.resource_error.is_none()
|
||||
&& !(self.pack_screen.is_some() && self.stack.last() == Some(&Page::Packs));
|
||||
if !plain {
|
||||
@@ -4255,10 +4225,6 @@ impl OptionsUi {
|
||||
self.studio.wheel(amount);
|
||||
return;
|
||||
}
|
||||
if self.page() == Page::PackOptions {
|
||||
self.pack_options.scroll(size, amount);
|
||||
return;
|
||||
}
|
||||
match &mut self.pack_screen {
|
||||
Some(p) if self.stack.last() == Some(&Page::Packs) => {
|
||||
p.scroll(cursor.0 as f64, cursor.1 as f64, amount as f64)
|
||||
@@ -4393,14 +4359,8 @@ impl OptionsUi {
|
||||
self.can_change_value = false;
|
||||
match page {
|
||||
Page::PackOptions => {
|
||||
let ui = std::mem::take(&mut self.pack_options);
|
||||
if ui.changed {
|
||||
let entries = ui.configs.entries().keys().cloned().collect();
|
||||
events.push(Event::PackOptions {
|
||||
entries,
|
||||
chosen: ui.configs.chosen(),
|
||||
});
|
||||
}
|
||||
let screen = std::mem::take(&mut self.pack_options.screen);
|
||||
events.extend(screen.finished());
|
||||
}
|
||||
Page::ShaderOptions | Page::ShaderPacks => {
|
||||
if s.shader_options.as_ref().is_some_and(|o| o.changed) {
|
||||
@@ -4960,6 +4920,34 @@ mod tests {
|
||||
/// A renderer whose destination offers nothing and whose display reports nothing.
|
||||
const NONE: EffectiveOutput = EffectiveOutput::NONE;
|
||||
|
||||
#[test]
|
||||
fn pack_options_leave_with_a_change_and_with_nothing_after_its_undo() {
|
||||
use crate::settings_shell::Screen as _;
|
||||
let mut s = Settings::default();
|
||||
let atlas = HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets"));
|
||||
let atlas = atlas.unwrap();
|
||||
let mut ui = OptionsUi::default();
|
||||
let mut configs = horizon_polytone::pack::Configs::default();
|
||||
let birds = serde_json::json!({"default_value": true});
|
||||
configs.add_entry("p:birds".into(), &birds);
|
||||
ui.pack_configs.insert("pack".into(), configs);
|
||||
for undone in [false, true] {
|
||||
ui.open_pack_options("pack");
|
||||
assert!(ui.shell().is_some(), "Pack Options is on the shell");
|
||||
let birds = crate::pack_options_ui::Item::Entry(0);
|
||||
ui.pack_options.screen.press(&mut s, birds, false);
|
||||
if undone {
|
||||
let before = s.clone();
|
||||
ui.pack_options.screen.undo(&mut s, &before);
|
||||
}
|
||||
let events = ui.key(&mut s, &atlas, winit::keyboard::KeyCode::Escape, None);
|
||||
let sent = events
|
||||
.iter()
|
||||
.any(|e| matches!(e, Event::PackOptions { .. }));
|
||||
assert_eq!(sent, !undone, "undone: {undone}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The window allows the input method only while `typing` says a search
|
||||
/// box has the focus (`Game::text_field_focused`), so a page without one
|
||||
/// must not claim the keys: the movement and menu keys keep working there.
|
||||
|
||||
@@ -23,6 +23,7 @@ impl OptionsUi {
|
||||
Page::ControllerRadialPick => Some(&self.radial_pick),
|
||||
page if Self::controller_page(page).is_some() => Some(&self.controller),
|
||||
page if Self::shader_page(page).is_some() => Some(&self.shader),
|
||||
Page::PackOptions => Some(&self.pack_options),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -35,9 +36,20 @@ impl OptionsUi {
|
||||
Page::ControllerRadialPick => Some(&mut self.radial_pick),
|
||||
page if Self::controller_page(page).is_some() => Some(&mut self.controller),
|
||||
page if Self::shader_page(page).is_some() => Some(&mut self.shader),
|
||||
Page::PackOptions => Some(&mut self.pack_options),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Pack Options for the pack `id`, from the pack screen: its Polytone config entries.
|
||||
pub(super) fn open_pack_options(&mut self, id: &str) {
|
||||
let mut configs = horizon_polytone::pack::Configs::default();
|
||||
if let Some(own) = self.pack_configs.get(id) {
|
||||
configs.merge(own);
|
||||
}
|
||||
// Opening a shell starts it afresh: the entries go in after.
|
||||
self.push(Page::PackOptions);
|
||||
self.pack_options.screen = crate::pack_options_ui::PackOptionsScreen::open(configs);
|
||||
}
|
||||
/// Where the open shell screen asked the focus to open (a radial slot's list, on the slot's
|
||||
/// action), for a controller's focus to land there.
|
||||
pub fn shell_opened_focus(&self) -> Option<[f32; 4]> {
|
||||
@@ -53,13 +65,14 @@ impl OptionsUi {
|
||||
return;
|
||||
}
|
||||
// Every shell: the click may have changed the page.
|
||||
let shells: [&mut dyn shell::Hosted; 6] = [
|
||||
let shells: [&mut dyn shell::Hosted; 7] = [
|
||||
&mut self.extras,
|
||||
&mut self.graphics,
|
||||
&mut self.controller,
|
||||
&mut self.radial,
|
||||
&mut self.radial_pick,
|
||||
&mut self.shader,
|
||||
&mut self.pack_options,
|
||||
];
|
||||
for shell in shells {
|
||||
shell.clicks_step(false);
|
||||
|
||||
@@ -1,45 +1,36 @@
|
||||
//! The Pack Options page (#11486): the config entries of the selected resource packs' Polytone
|
||||
//! `config_entries` (wiki "Polytone Configs"), in the frame of Horizon's settings screens
|
||||
//! (`settings_shell`). Numbers are sliders, texts cycle, booleans switch; the entries sit in
|
||||
//! their `section`s, named by the packs' own translations. Leaving with Done keeps what changed,
|
||||
//! and the packs load again with it.
|
||||
use crate::widgets::Action as Declared;
|
||||
//! The Pack Options screen (#11486), on the settings shell (`settings_shell`): the config entries
|
||||
//! of the selected resource packs' Polytone `config_entries` (wiki "Polytone Configs"). Each
|
||||
//! `section` is a page, named by the packs' own translations; numbers are sliders that stop at
|
||||
//! the entry's `step`, value lists are choices, booleans switch and free text reads. The first
|
||||
//! page starts with the pack-wide presets and Reset all. The shell gives it the keyboard, a
|
||||
//! controller's focus, the search, the strip's descriptions and Undo; leaving keeps what differs
|
||||
//! from how the screen opened, and the packs load again with it.
|
||||
use crate::{
|
||||
graphics_style as style,
|
||||
hud::{HudAtlas, HudBatch},
|
||||
lang,
|
||||
settings_shell::{self as shell, Columns, Line, Lines},
|
||||
text::Text,
|
||||
ui,
|
||||
settings::Settings,
|
||||
settings_shell::{self as shell, Group, Output, Row, Sheet, Value},
|
||||
};
|
||||
use horizon_polytone::pack::{ConfigKind, ConfigValue, Configs};
|
||||
|
||||
/// What a click asks of the options screen.
|
||||
/// A row: the pack-wide presets, Reset all, or the entry at an index of `ids`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
ButtonClick,
|
||||
SliderDrag,
|
||||
/// Done: leave, keeping what changed.
|
||||
Done,
|
||||
pub(crate) enum Item {
|
||||
Presets,
|
||||
ResetAll,
|
||||
Entry(u16),
|
||||
}
|
||||
|
||||
/// The page's state: the entries being edited, how they are listed, and the pointer's hold.
|
||||
#[derive(Default)]
|
||||
pub struct PackOptionsUi {
|
||||
/// The selected packs' entries and the values being picked.
|
||||
pub configs: Configs,
|
||||
/// Entry ids, by the row index the list uses.
|
||||
ids: Vec<String>,
|
||||
/// The sections in order: the `section` id (none for entries without one) and its rows.
|
||||
sections: Vec<(Option<String>, Vec<u16>)>,
|
||||
scroll: ui::Scroll,
|
||||
dragging: Option<u16>,
|
||||
/// A value changed since the page opened.
|
||||
pub changed: bool,
|
||||
}
|
||||
|
||||
/// The presets' row (and its section): past every entry's index.
|
||||
const PRESETS: u16 = u16::MAX;
|
||||
const TITLE: &str = "horizon.pack.options.title";
|
||||
/// The first page's group of rows for the whole pack, and every page's group of entries.
|
||||
const WHOLE_PACK: &str = "Whole pack";
|
||||
const PACK_OPTIONS: &str = "Pack settings";
|
||||
const RESET_ALL: &str = "Reset all";
|
||||
const RESET_ABOUT: &str = "Puts every option of this pack back to its default.";
|
||||
const AT_DEFAULTS: &str = "Every option is at its default already.";
|
||||
const PRESETS_ABOUT: &str = "Sets many options at once, as the pack groups them.";
|
||||
const PACK_SETTINGS: &str = "Settings made by the pack's author: they change only this pack.";
|
||||
/// A pack's tooltips, with the performance line after them, run longer than Horizon's own.
|
||||
const NOTE_LINES: usize = 3;
|
||||
|
||||
/// A translation, or `fallback` where no language has the key.
|
||||
fn translated(key: &str, fallback: impl FnOnce() -> String) -> String {
|
||||
@@ -65,9 +56,34 @@ fn readable(path: &str) -> String {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
impl PackOptionsUi {
|
||||
/// The page opens on `configs`, the selected packs' entries with the player's values.
|
||||
pub fn open(configs: Configs) -> Self {
|
||||
/// A number as its entry shows it: whole with a whole step, else up to two decimals.
|
||||
fn number_text(n: f64, step: f64) -> String {
|
||||
if step >= 1.0 && step.fract() == 0.0 {
|
||||
format!("{n:.0}")
|
||||
} else {
|
||||
let text = format!("{n:.2}");
|
||||
text.trim_end_matches('0').trim_end_matches('.').to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// The screen: the entries being edited, as they were when it opened (for Undo and for whether
|
||||
/// leaving changes anything), and how they are paged.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct PackOptionsScreen {
|
||||
/// The selected packs' entries and the values being picked.
|
||||
pub(crate) configs: Configs,
|
||||
opened: Configs,
|
||||
/// Entry ids, by `Item::Entry`'s index.
|
||||
ids: Vec<String>,
|
||||
/// The sections in order: the `section` id (none for entries without one) and its entries.
|
||||
sections: Vec<(Option<String>, Vec<u16>)>,
|
||||
/// Bumped on every change, for `stamp`: the values are floats, which don't hash.
|
||||
revision: u64,
|
||||
}
|
||||
|
||||
impl PackOptionsScreen {
|
||||
/// The screen opens on `configs`, the selected packs' entries with the player's values.
|
||||
pub(crate) fn open(configs: Configs) -> Self {
|
||||
let mut ids: Vec<(String, Option<String>, i64)> = configs
|
||||
.entries()
|
||||
.iter()
|
||||
@@ -96,25 +112,21 @@ impl PackOptionsUi {
|
||||
(None, None) => a.0.cmp(&b.0),
|
||||
});
|
||||
Self {
|
||||
opened: configs.clone(),
|
||||
configs,
|
||||
ids: ids.into_iter().map(|(id, ..)| id).collect(),
|
||||
sections: sections.into_iter().map(|(s, _, rows)| (s, rows)).collect(),
|
||||
..Self::default()
|
||||
revision: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn layout(&self, w: f32, h: f32) -> (Columns, Vec<Line<u16, u16>>, f32) {
|
||||
let cols = Columns::of(w, h);
|
||||
let mut lines = Lines::new();
|
||||
// The pack-wide presets first, when the entries have some.
|
||||
if !self.configs.presets().is_empty() {
|
||||
lines.titled(PRESETS, vec![PRESETS]);
|
||||
}
|
||||
for (i, (_, rows)) in self.sections.iter().enumerate() {
|
||||
lines.titled(i as u16, rows.clone());
|
||||
}
|
||||
let (lines, content) = lines.finish();
|
||||
(cols, lines, content)
|
||||
/// What leaving sends: the entries and the player's choices, if they differ from how the
|
||||
/// screen opened (an Undo, or a value stepped back, leaves nothing to send).
|
||||
pub(crate) fn finished(&self) -> Option<crate::options_ui::Event> {
|
||||
(self.configs != self.opened).then(|| crate::options_ui::Event::PackOptions {
|
||||
entries: self.configs.entries().keys().cloned().collect(),
|
||||
chosen: self.configs.chosen(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The namespace the presets' translations are under: of the first entry with presets.
|
||||
@@ -166,21 +178,14 @@ impl PackOptionsUi {
|
||||
None if back => n - 1,
|
||||
None => 0,
|
||||
};
|
||||
let before = self.configs.clone();
|
||||
self.configs.apply_preset(&presets[next]);
|
||||
if self.configs != before {
|
||||
self.changed = true;
|
||||
}
|
||||
self.revision += 1;
|
||||
}
|
||||
|
||||
/// A section's heading: `config.<namespace>.section.<id>`, by its first entry's namespace.
|
||||
fn section_name(&self, section: u16) -> String {
|
||||
if section == PRESETS {
|
||||
let key = format!("{}.presets", self.presets_namespace());
|
||||
return translated(&key, || lang::get("horizon.pack.options.presets"));
|
||||
}
|
||||
let Some((id, rows)) = self.sections.get(usize::from(section)) else {
|
||||
return String::new();
|
||||
/// A section's page name: `config.<namespace>.section.<id>`, by its first entry's namespace.
|
||||
fn section_name(&self, section: usize) -> String {
|
||||
let Some((id, rows)) = self.sections.get(section) else {
|
||||
return lang::get("horizon.pack.options.general");
|
||||
};
|
||||
let Some(id) = id else {
|
||||
return lang::get("horizon.pack.options.general");
|
||||
@@ -192,367 +197,218 @@ impl PackOptionsUi {
|
||||
translated(&format!("config.{namespace}.section.{id}"), || readable(id))
|
||||
}
|
||||
|
||||
/// An entry's name (`config.<namespace>.<name>`) and its value's label.
|
||||
fn parts(&self, row: u16) -> (String, Option<String>) {
|
||||
if row == PRESETS {
|
||||
let label = self.preset_label(self.current_preset().as_deref());
|
||||
return (self.section_name(PRESETS), Some(label));
|
||||
}
|
||||
let Some(id) = self.ids.get(usize::from(row)) else {
|
||||
return (String::new(), None);
|
||||
};
|
||||
let (namespace, path) = split(id);
|
||||
let name = translated(&format!("config.{namespace}.{path}"), || readable(path));
|
||||
let entry = &self.configs.entries()[id];
|
||||
let value = match self.configs.get(id) {
|
||||
Some(ConfigValue::Number(n)) => {
|
||||
let step = match entry.kind {
|
||||
ConfigKind::Number { step, .. } => step,
|
||||
_ => 1.0,
|
||||
};
|
||||
let text = if step >= 1.0 && step.fract() == 0.0 {
|
||||
format!("{n:.0}")
|
||||
} else {
|
||||
format!("{n:.2}")
|
||||
.trim_end_matches('0')
|
||||
.trim_end_matches('.')
|
||||
.to_owned()
|
||||
};
|
||||
Some(text)
|
||||
}
|
||||
Some(ConfigValue::Text(t)) => Some(t.clone()),
|
||||
Some(ConfigValue::Bool(_)) | None => None,
|
||||
};
|
||||
let value = value.map(|v| match &entry.value_translation {
|
||||
Some(key) if lang::has(key) => lang::tr(key, &[&v]),
|
||||
_ => v,
|
||||
});
|
||||
(name, value)
|
||||
}
|
||||
|
||||
/// The tooltip (`config.<namespace>.<name>.tooltip`) and the performance hint.
|
||||
fn description(&self, row: u16) -> String {
|
||||
let Some(id) = self.ids.get(usize::from(row)) else {
|
||||
return String::new();
|
||||
};
|
||||
/// The tooltip (`config.<namespace>.<name>.tooltip`) and the performance line after it.
|
||||
fn description(&self, id: &str) -> String {
|
||||
let (namespace, path) = split(id);
|
||||
let key = format!("config.{namespace}.{path}.tooltip");
|
||||
let mut text = if lang::has(&key) {
|
||||
let tooltip = if lang::has(&key) {
|
||||
lang::get(&key)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
if let Some(impact) = &self.configs.entries()[id].performance_impact {
|
||||
let level = lang::get(&format!("horizon.pack.options.impact.{impact}"));
|
||||
let line = lang::tr("horizon.pack.options.impact", &[&level]);
|
||||
text = if text.is_empty() {
|
||||
line
|
||||
} else {
|
||||
format!("{text} {line}")
|
||||
};
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// A slider's place (0 to 1) for a number entry.
|
||||
fn slider(&self, row: u16) -> Option<f32> {
|
||||
let id = self.ids.get(usize::from(row))?;
|
||||
let ConfigKind::Number { min, max, .. } = self.configs.entries()[id].kind else {
|
||||
return None;
|
||||
};
|
||||
let Some(ConfigValue::Number(n)) = self.configs.get(id) else {
|
||||
return None;
|
||||
};
|
||||
Some(if max > min {
|
||||
((n - min) / (max - min)) as f32
|
||||
} else {
|
||||
0.0
|
||||
})
|
||||
}
|
||||
|
||||
fn switch(&self, row: u16) -> Option<bool> {
|
||||
match self.configs.get(self.ids.get(usize::from(row))?)? {
|
||||
ConfigValue::Bool(b) => Some(*b),
|
||||
_ => None,
|
||||
let impact = self.configs.entries()[id]
|
||||
.performance_impact
|
||||
.as_ref()
|
||||
.map(|impact| {
|
||||
let level = lang::get(&format!("horizon.pack.options.impact.{impact}"));
|
||||
lang::tr("horizon.pack.options.impact", &[&level])
|
||||
});
|
||||
match impact {
|
||||
Some(line) if tooltip.is_empty() => line,
|
||||
Some(line) => format!("{tooltip} {line}"),
|
||||
None => tooltip,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw(
|
||||
&mut self,
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
in_world: bool,
|
||||
) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
let (cols, lines, content) = self.layout(w, h);
|
||||
self.scroll.clamp(cols.top, cols.bottom, content);
|
||||
shell::backdrop(batch, atlas, in_world);
|
||||
shell::mark(
|
||||
batch,
|
||||
atlas,
|
||||
&cols,
|
||||
&lang::get("horizon.pack.options.title"),
|
||||
);
|
||||
shell::plate(
|
||||
batch,
|
||||
atlas,
|
||||
&cols,
|
||||
&lang::get("horizon.pack.options.title"),
|
||||
);
|
||||
let over_list = cursor.1 >= cols.top && cursor.1 < cols.bottom;
|
||||
let mut described = None;
|
||||
shell::list(
|
||||
batch,
|
||||
atlas,
|
||||
&cols,
|
||||
&lines,
|
||||
self.scroll.amount,
|
||||
|section| self.section_name(section),
|
||||
|batch, row: u16, y| {
|
||||
let [rx, ry, rw, rh] = cols.row(y);
|
||||
let hover = over_list && ui::hovered(cursor, rx, ry, rw, rh);
|
||||
if hover || self.dragging == Some(row) {
|
||||
described = Some(row);
|
||||
/// Entry `i`'s row: a switch, a slider in its steps, a choice, or text to read.
|
||||
fn entry_row(&self, i: u16) -> Option<Row<Item>> {
|
||||
let id = self.ids.get(usize::from(i))?;
|
||||
let (namespace, path) = split(id);
|
||||
let name = translated(&format!("config.{namespace}.{path}"), || readable(path));
|
||||
let entry = &self.configs.entries()[id];
|
||||
let shown = |text: String| match &entry.value_translation {
|
||||
Some(key) if lang::has(key) => lang::tr(key, &[&text]),
|
||||
_ => text,
|
||||
};
|
||||
let value = match (&entry.kind, self.configs.get(id)?) {
|
||||
(_, ConfigValue::Bool(on)) => Value::Switch(*on),
|
||||
(&ConfigKind::Number { min, max, step }, &ConfigValue::Number(n)) => {
|
||||
let span = max - min;
|
||||
let stops = if step > 0.0 {
|
||||
(span / step).round()
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
Value::Slider {
|
||||
position: if span > 0.0 {
|
||||
((n - min) / span) as f32
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
steps: (stops >= 1.0).then_some(stops as u32),
|
||||
text: shown(number_text(n, step)),
|
||||
}
|
||||
let (name, value) = self.parts(row);
|
||||
let label = match &value {
|
||||
Some(v) => format!("{name}: {v}"),
|
||||
None => name.clone(),
|
||||
};
|
||||
let view = shell::RowView {
|
||||
label: &label,
|
||||
name: &name,
|
||||
value: value.as_deref(),
|
||||
switch: self.switch(row),
|
||||
slider: self.slider(row),
|
||||
live: true,
|
||||
hover,
|
||||
focused: false,
|
||||
held: self.dragging == Some(row),
|
||||
key: None,
|
||||
};
|
||||
shell::row(batch, atlas, &cols, y, &view);
|
||||
},
|
||||
);
|
||||
shell::scroller(batch, atlas, &cols, content, self.scroll.amount);
|
||||
// The strip: the pointed-at entry's tooltip.
|
||||
let [x, y, ..] = cols.dock;
|
||||
style::panel(batch, atlas, cols.dock);
|
||||
if let Some(row) = described {
|
||||
let name = Text::literal(self.parts(row).0);
|
||||
batch.text_styled(atlas, &name, x + 12.0, y + 8.0, style::WHITE, true);
|
||||
shell::dock_note(batch, atlas, &cols, self.description(row), style::GREY);
|
||||
}
|
||||
let mut foot = vec![(cols.button(0), "gui.done", Some(Declared::Done))];
|
||||
if self
|
||||
.configs
|
||||
.chosen()
|
||||
.iter()
|
||||
.any(|(id, _)| self.ids.contains(id))
|
||||
{
|
||||
foot.push((cols.button(1), "controls.reset", None));
|
||||
}
|
||||
for (rect, key, declared) in foot {
|
||||
if let Some(declared) = declared {
|
||||
crate::widgets::declare_action(declared, rect, true);
|
||||
}
|
||||
let label = Text::literal(lang::get(key));
|
||||
shell::foot_button(batch, atlas, rect, &label, cursor, (false, true));
|
||||
}
|
||||
(ConfigKind::Text { allowed }, ConfigValue::Text(t)) if !allowed.is_empty() => {
|
||||
Value::Choice(shown(t.clone()))
|
||||
}
|
||||
(_, value) => Value::Read(shown(match value {
|
||||
ConfigValue::Number(n) => number_text(*n, 1.0),
|
||||
ConfigValue::Text(t) => t.clone(),
|
||||
ConfigValue::Bool(b) => b.to_string(),
|
||||
})),
|
||||
};
|
||||
Some(Row::new(Item::Entry(i), name, value).about(self.description(id)))
|
||||
}
|
||||
|
||||
/// A press (or a drag with `drag`) at `(x, y)`; `back` turns a cycle backwards.
|
||||
pub fn click(
|
||||
&mut self,
|
||||
(x, y): (f32, f32),
|
||||
(w, h): (f32, f32),
|
||||
drag: bool,
|
||||
back: bool,
|
||||
) -> Vec<Action> {
|
||||
let (cols, lines, content) = self.layout(w, h);
|
||||
let at = |[rx, ry, rw, rh]: [f32; 4]| ui::hovered((x, y), rx, ry, rw, rh);
|
||||
if drag {
|
||||
if self.scroll.held() {
|
||||
self.scroll.drag(y, cols.top, cols.bottom, content);
|
||||
} else if let Some(row) = self.dragging {
|
||||
self.set_slider(row, cols.slider_at(x));
|
||||
return vec![Action::SliderDrag];
|
||||
}
|
||||
return Vec::new();
|
||||
/// The rows for the whole pack: its presets, when it has some, and Reset all.
|
||||
fn whole_pack(&self) -> Group<Item> {
|
||||
let mut rows = Vec::new();
|
||||
if !self.configs.presets().is_empty() {
|
||||
let key = format!("{}.presets", self.presets_namespace());
|
||||
let name = translated(&key, || lang::get("horizon.pack.options.presets"));
|
||||
let label = self.preset_label(self.current_preset().as_deref());
|
||||
rows.push(
|
||||
Row::new(Item::Presets, name, Value::Choice(label)).about(lang::get(PRESETS_ABOUT)),
|
||||
);
|
||||
}
|
||||
if cols.scrolls()
|
||||
&& self
|
||||
.scroll
|
||||
.press((x, y), cols.bar_x(), cols.top, cols.bottom, content)
|
||||
{
|
||||
return Vec::new();
|
||||
let at_defaults = self.configs.chosen().is_empty();
|
||||
let reset = Row::new(Item::ResetAll, lang::get(RESET_ALL), Value::Press)
|
||||
.about(lang::get(RESET_ABOUT))
|
||||
.blocked(at_defaults.then(|| lang::get(AT_DEFAULTS)));
|
||||
rows.push(reset);
|
||||
Group {
|
||||
name: WHOLE_PACK,
|
||||
rows,
|
||||
}
|
||||
if at(cols.button(0)) {
|
||||
return vec![Action::ButtonClick, Action::Done];
|
||||
}
|
||||
if at(cols.button(1)) {
|
||||
self.reset();
|
||||
return vec![Action::ButtonClick];
|
||||
}
|
||||
if y < cols.top || y >= cols.bottom {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(row) = lines.iter().find_map(|line| {
|
||||
let row = line.setting?;
|
||||
at(cols.row(cols.top + line.y - self.scroll.amount)).then_some(row)
|
||||
}) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if row == PRESETS {
|
||||
self.cycle_preset(back);
|
||||
return vec![Action::ButtonClick];
|
||||
}
|
||||
let Some(id) = self.ids.get(usize::from(row)).cloned() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let entry = self.configs.entries()[&id].clone();
|
||||
match (&entry.kind, self.configs.get(&id).cloned()) {
|
||||
(ConfigKind::Number { .. }, _) => {
|
||||
self.dragging = Some(row);
|
||||
self.set_slider(row, cols.slider_at(x));
|
||||
return vec![Action::SliderDrag];
|
||||
}
|
||||
(ConfigKind::Bool, Some(ConfigValue::Bool(b))) => {
|
||||
self.set(&id, ConfigValue::Bool(!b));
|
||||
}
|
||||
(ConfigKind::Text { allowed }, Some(ConfigValue::Text(t))) if !allowed.is_empty() => {
|
||||
let i = allowed.iter().position(|a| *a == t).unwrap_or(0);
|
||||
let n = allowed.len();
|
||||
let next = if back { (i + n - 1) % n } else { (i + 1) % n };
|
||||
self.set(&id, ConfigValue::Text(allowed[next].clone()));
|
||||
}
|
||||
_ => return Vec::new(),
|
||||
}
|
||||
vec![Action::ButtonClick]
|
||||
}
|
||||
|
||||
fn set(&mut self, id: &str, value: ConfigValue) {
|
||||
let before = self.configs.get(id).cloned();
|
||||
self.configs.set(id, value);
|
||||
if self.configs.get(id).cloned() != before {
|
||||
self.changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Number entry `row` at slider place `at` (0 to 1).
|
||||
fn set_slider(&mut self, row: u16, at: f32) {
|
||||
let Some(id) = self.ids.get(usize::from(row)).cloned() else {
|
||||
return;
|
||||
};
|
||||
if let ConfigKind::Number { min, max, .. } = self.configs.entries()[&id].kind {
|
||||
self.set(&id, ConfigValue::Number(min + f64::from(at) * (max - min)));
|
||||
}
|
||||
self.revision += 1;
|
||||
}
|
||||
|
||||
/// Every entry back to its default.
|
||||
fn reset(&mut self) {
|
||||
let defaults: Vec<_> = self
|
||||
.configs
|
||||
.entries()
|
||||
.iter()
|
||||
let defaults: Vec<_> = (self.configs.entries().iter())
|
||||
.map(|(id, e)| (id.clone(), e.default.clone()))
|
||||
.collect();
|
||||
for (id, value) in defaults {
|
||||
self.set(&id, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The pointer lets go.
|
||||
pub fn release(&mut self) {
|
||||
self.scroll.release();
|
||||
self.dragging = None;
|
||||
impl shell::Screen for PackOptionsScreen {
|
||||
type Page = u16;
|
||||
type Row = Item;
|
||||
fn title(&self) -> &'static str {
|
||||
TITLE
|
||||
}
|
||||
|
||||
/// The mouse wheel scrolls the list.
|
||||
pub fn scroll(&mut self, (w, h): (f32, f32), amount: f32) {
|
||||
let (cols, _, content) = self.layout(w, h);
|
||||
self.scroll
|
||||
.wheel(amount, 18.0, cols.top, cols.bottom, content);
|
||||
fn note_lines(&self) -> usize {
|
||||
NOTE_LINES
|
||||
}
|
||||
/// The rows read only the screen's own entries, which change through it alone.
|
||||
fn stamp(&self, _s: &Settings) -> u64 {
|
||||
self.revision
|
||||
}
|
||||
/// A page per section; the first starts with the rows for the whole pack.
|
||||
fn sheets(&self, _s: &Settings) -> Vec<Sheet<u16, Item>> {
|
||||
let about = lang::get(PACK_SETTINGS);
|
||||
let mut sheets = Vec::new();
|
||||
for (i, (_, entries)) in self.sections.iter().enumerate() {
|
||||
let rows = entries.iter().filter_map(|&e| self.entry_row(e)).collect();
|
||||
let mut groups = vec![Group {
|
||||
name: PACK_OPTIONS,
|
||||
rows,
|
||||
}];
|
||||
if i == 0 {
|
||||
groups.insert(0, self.whole_pack());
|
||||
}
|
||||
sheets.push(Sheet::new(
|
||||
i as u16,
|
||||
self.section_name(i),
|
||||
about.clone(),
|
||||
groups,
|
||||
));
|
||||
}
|
||||
if sheets.is_empty() {
|
||||
let groups = vec![self.whole_pack()];
|
||||
sheets.push(Sheet::new(0, self.section_name(0), about, groups));
|
||||
}
|
||||
sheets
|
||||
}
|
||||
/// A switch flips, a choice and the presets step (back with Shift, or Left), Reset all
|
||||
/// resets.
|
||||
fn press(&mut self, _s: &mut Settings, item: Item, back: bool) -> Vec<Output> {
|
||||
let i = match item {
|
||||
Item::Presets => {
|
||||
self.cycle_preset(back);
|
||||
return Vec::new();
|
||||
}
|
||||
Item::ResetAll => {
|
||||
self.reset();
|
||||
return Vec::new();
|
||||
}
|
||||
Item::Entry(i) => i,
|
||||
};
|
||||
let Some(id) = self.ids.get(usize::from(i)).cloned() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let kind = self.configs.entries()[&id].kind.clone();
|
||||
match (kind, self.configs.get(&id).cloned()) {
|
||||
(_, Some(ConfigValue::Bool(on))) => self.set(&id, ConfigValue::Bool(!on)),
|
||||
(ConfigKind::Text { allowed }, Some(ConfigValue::Text(t))) if !allowed.is_empty() => {
|
||||
let at = allowed.iter().position(|a| *a == t).unwrap_or(0);
|
||||
let n = allowed.len();
|
||||
let next = if back { (at + n - 1) % n } else { (at + 1) % n };
|
||||
self.set(&id, ConfigValue::Text(allowed[next].clone()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
/// A slider moves its entry to the step nearest it (`Configs::set` snaps it).
|
||||
fn slide(&mut self, _s: &mut Settings, item: Item, position: f32) {
|
||||
let Item::Entry(i) = item else { return };
|
||||
let Some(id) = self.ids.get(usize::from(i)).cloned() else {
|
||||
return;
|
||||
};
|
||||
if let ConfigKind::Number { min, max, .. } = self.configs.entries()[&id].kind {
|
||||
let value = min + f64::from(position.clamp(0.0, 1.0)) * (max - min);
|
||||
self.set(&id, ConfigValue::Number(value));
|
||||
}
|
||||
}
|
||||
/// With the "Scroll wheel changes settings" extra, the wheel steps a choice and the presets.
|
||||
fn wheel_step(&mut self, s: &mut Settings, item: Item, back: bool) -> Option<Vec<Output>> {
|
||||
let steps = match item {
|
||||
Item::Presets => true,
|
||||
Item::Entry(i) => self
|
||||
.entry_row(i)
|
||||
.is_some_and(|row| matches!(row.value, Value::Choice(_))),
|
||||
Item::ResetAll => false,
|
||||
};
|
||||
if !steps {
|
||||
return None;
|
||||
}
|
||||
let mut outputs = vec![Output::Event(crate::options_ui::Event::ButtonClick)];
|
||||
outputs.extend(self.press(s, item, back));
|
||||
Some(outputs)
|
||||
}
|
||||
fn undoes(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// The entries as the screen opened with them.
|
||||
fn undo(&mut self, _s: &mut Settings, _before: &Settings) -> Vec<Output> {
|
||||
self.configs = self.opened.clone();
|
||||
self.revision += 1;
|
||||
Vec::new()
|
||||
}
|
||||
/// Undo shows exactly while an entry differs from how the screen opened.
|
||||
fn differs(&self, _s: &Settings, _before: &Settings) -> Option<bool> {
|
||||
Some(self.configs != self.opened)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn configs() -> Configs {
|
||||
let mut c = Configs::default();
|
||||
c.add_entry(
|
||||
"p:fog".into(),
|
||||
&serde_json::json!({"default_value": 0.5, "section": "sky", "section_order": 2}),
|
||||
);
|
||||
c.add_entry("p:rain".into(), &serde_json::json!({"default_value": "drops", "allowed_values": ["drops", "streaks"], "section": "weather", "section_order": 1}));
|
||||
c.add_entry(
|
||||
"p:birds".into(),
|
||||
&serde_json::json!({"default_value": true}),
|
||||
);
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sections_sort_by_their_order_and_the_unsectioned_last() {
|
||||
let ui = PackOptionsUi::open(configs());
|
||||
let order: Vec<_> = ui.sections.iter().map(|(s, _)| s.as_deref()).collect();
|
||||
assert_eq!(order, [Some("weather"), Some("sky"), None]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_switch_cycle_and_slide() {
|
||||
let mut ui = PackOptionsUi::open(configs());
|
||||
let size = (480.0, 270.0);
|
||||
let (cols, lines, _) = ui.layout(size.0, size.1);
|
||||
let ids = ui.ids.clone();
|
||||
let point = |id: &str| {
|
||||
let row = ids.iter().position(|i| i == id).unwrap() as u16;
|
||||
let line = lines.iter().find(|l| l.setting == Some(row)).unwrap();
|
||||
let [x, y, w, h] = cols.row(cols.top + line.y);
|
||||
(x + w - 4.0, y + h / 2.0)
|
||||
};
|
||||
let (birds, rain) = (point("p:birds"), point("p:rain"));
|
||||
ui.click(birds, size, false, false);
|
||||
assert_eq!(ui.configs.get("p:birds"), Some(&ConfigValue::Bool(false)));
|
||||
ui.click(rain, size, false, false);
|
||||
assert_eq!(
|
||||
ui.configs.get("p:rain"),
|
||||
Some(&ConfigValue::Text("streaks".into()))
|
||||
);
|
||||
// The slider's far right end is its maximum.
|
||||
let fog = point("p:fog");
|
||||
let (tx, tw) = cols.track();
|
||||
ui.click((tx + tw, fog.1), size, false, false);
|
||||
ui.release();
|
||||
assert_eq!(ui.configs.get("p:fog"), Some(&ConfigValue::Number(1.0)));
|
||||
assert!(ui.changed);
|
||||
assert_eq!(ui.configs.chosen().len(), 3);
|
||||
ui.reset();
|
||||
assert!(ui.configs.chosen().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_presets_row_cycles_the_pack_wide_presets_and_shows_custom_otherwise() {
|
||||
let mut c = Configs::default();
|
||||
let entry = |default: f64, fast: f64, pretty: f64| serde_json::json!({"default_value": default, "presets": {"fast": fast, "pretty": pretty}});
|
||||
c.add_entry("p:fog".into(), &entry(0.5, 0.0, 1.0));
|
||||
c.add_entry("p:dust".into(), &entry(0.5, 0.0, 0.8));
|
||||
let mut ui = PackOptionsUi::open(c);
|
||||
// The defaults are no preset.
|
||||
assert_eq!(ui.current_preset(), None);
|
||||
ui.cycle_preset(false);
|
||||
assert_eq!(ui.current_preset().as_deref(), Some("fast"));
|
||||
assert_eq!(ui.configs.get("p:fog"), Some(&ConfigValue::Number(0.0)));
|
||||
ui.cycle_preset(false);
|
||||
assert_eq!(ui.current_preset().as_deref(), Some("pretty"));
|
||||
ui.cycle_preset(true);
|
||||
assert_eq!(ui.current_preset().as_deref(), Some("fast"));
|
||||
// One value off the preset: custom again.
|
||||
ui.set("p:dust", ConfigValue::Number(0.3));
|
||||
assert_eq!(ui.current_preset(), None);
|
||||
assert!(ui.changed);
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
@@ -0,0 +1,281 @@
|
||||
use super::*;
|
||||
use crate::options_ui::Event;
|
||||
use crate::settings_shell::{Columns, Hosted, Screen as _, Shell, Target};
|
||||
use winit::keyboard::KeyCode as K;
|
||||
|
||||
const SIZE: (f32, f32) = (854.0, 480.0);
|
||||
|
||||
/// Fog (a number) under Sky, rain (a list of values) under Weather, birds (a boolean) in no
|
||||
/// section.
|
||||
fn configs() -> Configs {
|
||||
let mut c = Configs::default();
|
||||
c.add_entry(
|
||||
"p:fog".into(),
|
||||
&serde_json::json!({"default_value": 0.5, "section": "sky", "section_order": 2}),
|
||||
);
|
||||
c.add_entry("p:rain".into(), &serde_json::json!({"default_value": "drops", "allowed_values": ["drops", "streaks"], "section": "weather", "section_order": 1}));
|
||||
c.add_entry(
|
||||
"p:birds".into(),
|
||||
&serde_json::json!({"default_value": true}),
|
||||
);
|
||||
c
|
||||
}
|
||||
|
||||
/// Two entries with the pack-wide presets `fast` and `pretty`.
|
||||
fn with_presets() -> Configs {
|
||||
let mut c = Configs::default();
|
||||
let entry = |default: f64, fast: f64, pretty: f64| serde_json::json!({"default_value": default, "presets": {"fast": fast, "pretty": pretty}});
|
||||
c.add_entry("p:fog".into(), &entry(0.5, 0.0, 1.0));
|
||||
c.add_entry("p:dust".into(), &entry(0.5, 0.0, 0.8));
|
||||
c
|
||||
}
|
||||
|
||||
fn shell_of(configs: Configs) -> Shell<PackOptionsScreen> {
|
||||
let mut shell = Shell::default();
|
||||
shell.screen = PackOptionsScreen::open(configs);
|
||||
shell
|
||||
}
|
||||
|
||||
/// The row of entry `id`, and the page it is on.
|
||||
fn entry(shell: &Shell<PackOptionsScreen>, id: &str) -> (u16, Item) {
|
||||
let i = shell.screen.ids.iter().position(|e| e == id).unwrap() as u16;
|
||||
let page = shell.screen.sections.iter().position(|s| s.1.contains(&i));
|
||||
(page.unwrap() as u16, Item::Entry(i))
|
||||
}
|
||||
|
||||
fn press(shell: &mut Shell<PackOptionsScreen>, s: &mut Settings, item: Item) -> Vec<Output> {
|
||||
let at = shell.point_at(s, SIZE, item);
|
||||
shell.click(s, at, SIZE, false, false, false)
|
||||
}
|
||||
|
||||
fn value<'a>(shell: &'a Shell<PackOptionsScreen>, id: &str) -> Option<&'a ConfigValue> {
|
||||
shell.screen.configs.get(id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sections_sort_by_their_order_and_the_unsectioned_last() {
|
||||
let screen = PackOptionsScreen::open(configs());
|
||||
let order: Vec<_> = screen.sections.iter().map(|(s, _)| s.as_deref()).collect();
|
||||
assert_eq!(order, [Some("weather"), Some("sky"), None]);
|
||||
// Each section is a page, the first one holding Reset all before its entries.
|
||||
let sheets = screen.sheets(&Settings::default());
|
||||
let names: Vec<_> = sheets.iter().map(|p| p.name.as_str()).collect();
|
||||
assert_eq!(names, ["Weather", "Sky", "horizon.pack.options.general"]);
|
||||
let first: Vec<Vec<Item>> = (sheets[0].groups.iter())
|
||||
.map(|g| g.rows.iter().map(|r| r.id).collect())
|
||||
.collect();
|
||||
let rain = screen.ids.iter().position(|e| e == "p:rain").unwrap() as u16;
|
||||
assert_eq!(first, [vec![Item::ResetAll], vec![Item::Entry(rain)]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_switch_cycle_and_slide() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = shell_of(configs());
|
||||
let (page, birds) = entry(&shell, "p:birds");
|
||||
shell.show(page);
|
||||
assert_eq!(
|
||||
press(&mut shell, &mut s, birds)[0],
|
||||
Output::Event(Event::ButtonClick)
|
||||
);
|
||||
assert_eq!(value(&shell, "p:birds"), Some(&ConfigValue::Bool(false)));
|
||||
let (page, rain) = entry(&shell, "p:rain");
|
||||
shell.show(page);
|
||||
press(&mut shell, &mut s, rain);
|
||||
let streaks = ConfigValue::Text("streaks".into());
|
||||
assert_eq!(value(&shell, "p:rain"), Some(&streaks));
|
||||
// The slider's far right end is its maximum.
|
||||
let (page, fog) = entry(&shell, "p:fog");
|
||||
shell.show(page);
|
||||
let at = shell.point_at(&s, SIZE, fog);
|
||||
let (track, width) = Columns::of(SIZE.0, SIZE.1).track();
|
||||
shell.click(&mut s, (track + width, at.1), SIZE, false, false, false);
|
||||
shell.release();
|
||||
assert_eq!(value(&shell, "p:fog"), Some(&ConfigValue::Number(1.0)));
|
||||
assert_eq!(shell.screen.configs.chosen().len(), 3);
|
||||
// Reset all, on the first page, puts them all back, and is greyed once they are.
|
||||
shell.show(0);
|
||||
press(&mut shell, &mut s, Item::ResetAll);
|
||||
assert!(shell.screen.configs.chosen().is_empty());
|
||||
let sheets = shell.screen.sheets(&s);
|
||||
assert!(!sheets[0].groups[0].rows[0].live);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_presets_row_cycles_the_pack_wide_presets_and_shows_custom_otherwise() {
|
||||
let mut screen = PackOptionsScreen::open(with_presets());
|
||||
// The defaults are no preset.
|
||||
assert_eq!(screen.current_preset(), None);
|
||||
screen.cycle_preset(false);
|
||||
assert_eq!(screen.current_preset().as_deref(), Some("fast"));
|
||||
assert_eq!(screen.configs.get("p:fog"), Some(&ConfigValue::Number(0.0)));
|
||||
screen.cycle_preset(false);
|
||||
assert_eq!(screen.current_preset().as_deref(), Some("pretty"));
|
||||
screen.cycle_preset(true);
|
||||
assert_eq!(screen.current_preset().as_deref(), Some("fast"));
|
||||
// One value off the preset: custom again.
|
||||
screen.set("p:dust", ConfigValue::Number(0.3));
|
||||
assert_eq!(screen.current_preset(), None);
|
||||
let presets = &screen.sheets(&Settings::default())[0].groups[0].rows[0];
|
||||
assert_eq!(presets.id, Item::Presets);
|
||||
let custom = lang::get("horizon.pack.options.presets.custom");
|
||||
assert_eq!(presets.value, Value::Choice(custom));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_and_right_step_the_presets() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = shell_of(with_presets());
|
||||
shell.set_focus(Some(Target::Row(Item::Presets)));
|
||||
shell.key(&mut s, K::ArrowRight, None, false, SIZE);
|
||||
assert_eq!(shell.screen.current_preset().as_deref(), Some("fast"));
|
||||
shell.key(&mut s, K::ArrowRight, None, false, SIZE);
|
||||
assert_eq!(shell.screen.current_preset().as_deref(), Some("pretty"));
|
||||
shell.key(&mut s, K::ArrowLeft, None, false, SIZE);
|
||||
assert_eq!(shell.screen.current_preset().as_deref(), Some("fast"));
|
||||
assert_eq!(shell.focused_row(), Some(Item::Presets));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sliders_stop_at_the_entrys_step() {
|
||||
let mut c = Configs::default();
|
||||
let json = serde_json::json!({"default_value": 4.0, "min": 0.0, "max": 10.0, "step": 2.0});
|
||||
c.add_entry("p:clouds".into(), &json);
|
||||
let mut s = Settings::default();
|
||||
let mut shell = shell_of(c);
|
||||
let row = &shell.screen.sheets(&s)[0].groups[1].rows[0];
|
||||
let stops = Value::Slider {
|
||||
position: 0.4,
|
||||
steps: Some(5),
|
||||
text: "4".into(),
|
||||
};
|
||||
assert_eq!(row.value, stops);
|
||||
// A press a little past the middle of the track lands on the step nearest it.
|
||||
let at = shell.point_at(&s, SIZE, Item::Entry(0));
|
||||
let (track, width) = Columns::of(SIZE.0, SIZE.1).track();
|
||||
shell.click(
|
||||
&mut s,
|
||||
(track + width * 0.55, at.1),
|
||||
SIZE,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
shell.release();
|
||||
assert_eq!(value(&shell, "p:clouds"), Some(&ConfigValue::Number(6.0)));
|
||||
// Held by the press, Right goes one step on and Left one back.
|
||||
shell.set_focus(Some(Target::Row(Item::Entry(0))));
|
||||
shell.key(&mut s, K::ArrowRight, None, false, SIZE);
|
||||
assert_eq!(value(&shell, "p:clouds"), Some(&ConfigValue::Number(8.0)));
|
||||
shell.key(&mut s, K::ArrowLeft, None, false, SIZE);
|
||||
shell.key(&mut s, K::ArrowLeft, None, false, SIZE);
|
||||
assert_eq!(value(&shell, "p:clouds"), Some(&ConfigValue::Number(4.0)));
|
||||
}
|
||||
|
||||
/// A language with the pack's own strings: a name, a section and a tooltip.
|
||||
struct PackLang;
|
||||
impl lang::ResourceSource for PackLang {
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
(path == "assets/minecraft/lang/en_us.json").then(|| {
|
||||
let strings = serde_json::json!({
|
||||
"config.minecraft.haze": "Haze",
|
||||
"config.minecraft.haze.tooltip": "Thickens the distant air.",
|
||||
"config.minecraft.section.sky": "Sky and fog",
|
||||
});
|
||||
serde_json::to_vec(&strings).unwrap()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn haze() -> Configs {
|
||||
let mut c = configs();
|
||||
let json =
|
||||
serde_json::json!({"default_value": false, "section": "sky", "performance_impact": "high"});
|
||||
c.add_entry("minecraft:haze".into(), &json);
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_search_finds_an_entry_by_its_tooltip_and_the_strip_has_its_impact() {
|
||||
let language = lang::Language::load(&[&PackLang], "en_us");
|
||||
lang::scoped(language, || {
|
||||
let s = Settings::default();
|
||||
let mut shell = shell_of(haze());
|
||||
let (page, haze) = entry(&shell, "minecraft:haze");
|
||||
shell.set_search("distant air");
|
||||
assert_eq!(shell.listed(&s, SIZE), [(page, haze)]);
|
||||
let sheets = shell.screen.sheets(&s);
|
||||
assert_eq!(sheets[usize::from(page)].name, "Sky and fog");
|
||||
let row = sheets.iter().flat_map(|p| &p.groups).flat_map(|g| &g.rows);
|
||||
let row = row.into_iter().find(|r| r.id == haze).unwrap();
|
||||
assert_eq!(row.name, "Haze");
|
||||
assert_eq!(
|
||||
row.about,
|
||||
"Thickens the distant air. Performance impact: high"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_reaches_every_row_of_every_page() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = shell_of(haze());
|
||||
let pages = shell.screen.sheets(&s).len() as u16;
|
||||
let mut seen = Vec::new();
|
||||
for page in 0..pages {
|
||||
shell.show(page);
|
||||
shell.set_focus(None);
|
||||
for _ in 0..8 {
|
||||
shell.key(&mut s, K::Tab, None, false, SIZE);
|
||||
if let Some(row) = shell.focused_row()
|
||||
&& !seen.contains(&row)
|
||||
{
|
||||
seen.push(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reset all is greyed at the defaults; every entry is reached.
|
||||
let mut entries: Vec<Item> = (0..shell.screen.ids.len() as u16)
|
||||
.map(Item::Entry)
|
||||
.collect();
|
||||
seen.sort_by_key(|item| format!("{item:?}"));
|
||||
entries.sort_by_key(|item| format!("{item:?}"));
|
||||
assert_eq!(seen, entries);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_puts_the_entries_back_and_then_leaving_sends_nothing() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = shell_of(configs());
|
||||
let (page, birds) = entry(&shell, "p:birds");
|
||||
shell.show(page);
|
||||
press(&mut shell, &mut s, birds);
|
||||
assert!(shell.foot_at(&s, SIZE).iter().any(|f| f.0 == Target::Undo));
|
||||
assert!(shell.screen.finished().is_some());
|
||||
let undo = shell
|
||||
.foot_at(&s, SIZE)
|
||||
.into_iter()
|
||||
.find(|f| f.0 == Target::Undo);
|
||||
let [x, y, w, h] = undo.unwrap().1;
|
||||
shell.click(
|
||||
&mut s,
|
||||
(x + w / 2.0, y + h / 2.0),
|
||||
SIZE,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert_eq!(value(&shell, "p:birds"), Some(&ConfigValue::Bool(true)));
|
||||
assert!(!shell.foot_at(&s, SIZE).iter().any(|f| f.0 == Target::Undo));
|
||||
assert_eq!(shell.screen.finished(), None);
|
||||
// Stepped there and back by hand, it sends nothing either.
|
||||
press(&mut shell, &mut s, birds);
|
||||
press(&mut shell, &mut s, birds);
|
||||
assert_eq!(shell.screen.finished(), None);
|
||||
press(&mut shell, &mut s, birds);
|
||||
let Some(Event::PackOptions { entries, chosen }) = shell.screen.finished() else {
|
||||
panic!("a change is sent");
|
||||
};
|
||||
assert_eq!(entries.len(), 3);
|
||||
assert_eq!(chosen.get("p:birds"), Some(&ConfigValue::Bool(false)));
|
||||
}
|
||||
@@ -1100,6 +1100,9 @@ impl PackScreen {
|
||||
0,
|
||||
);
|
||||
self.search.draw(&mut out, m, blink_ms, None);
|
||||
if self.browser.is_none() && self.search.contains(f64::from(mouse.0), f64::from(mouse.1)) {
|
||||
crate::widgets::text_cursor(true, true);
|
||||
}
|
||||
for side in 0..2 {
|
||||
self.draw_list(&mut out, m, side, mouse);
|
||||
}
|
||||
@@ -1302,6 +1305,9 @@ impl PackScreen {
|
||||
field.x = qx;
|
||||
field.y = qy;
|
||||
field.draw(out, m, 0, None);
|
||||
if field.contains(f64::from(mouse.0), f64::from(mouse.1)) {
|
||||
crate::widgets::text_cursor(true, true);
|
||||
}
|
||||
let (sbx, sby) = self.browser_search_button();
|
||||
d::button(
|
||||
out,
|
||||
|
||||
@@ -468,17 +468,20 @@ fn read_meta(source: &Source) -> Option<serde_json::Value> {
|
||||
/// file with a comment after the closing brace. Gson reads past a UTF-8 byte order mark;
|
||||
/// serde_json rejects it.
|
||||
pub(crate) fn parse_meta(bytes: &[u8]) -> Option<serde_json::Value> {
|
||||
first_json(bytes)
|
||||
first_json(bytes).ok()
|
||||
}
|
||||
|
||||
/// Reads the first JSON value from resource metadata, matching Gson's
|
||||
/// `JsonReader` (which ignores a trailing signature or comment). Resource
|
||||
/// packs commonly append such text to texture metadata as well as pack.mcmeta.
|
||||
pub(crate) fn first_json(bytes: &[u8]) -> Option<serde_json::Value> {
|
||||
serde_json::Deserializer::from_slice(bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes))
|
||||
/// The first JSON value of a `.mcmeta` file, as `ResourceMetadata.fromJsonStream` reads it:
|
||||
/// Gson's `TypeAdapter.read` stops after the value, so text after it (packs leave `//` notes
|
||||
/// there) is never read, and a UTF-8 byte order mark is skipped.
|
||||
pub(crate) fn first_json<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> serde_json::Result<T> {
|
||||
let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
|
||||
match serde_json::Deserializer::from_slice(bytes)
|
||||
.into_iter()
|
||||
.next()?
|
||||
.ok()
|
||||
.next()
|
||||
{
|
||||
Some(value) => value,
|
||||
None => Err(serde::de::Error::custom("empty metadata")),
|
||||
}
|
||||
}
|
||||
/// A pack's Polytone config entries (`assets/<namespace>/polytone/config_entries/<name>.json`,
|
||||
/// in the pack's base, not its overlays) at their default values.
|
||||
@@ -827,13 +830,6 @@ fn build_overlay_uncached(
|
||||
}
|
||||
// Vanilla sits below every pack, so filters hide its files too.
|
||||
let filters: Vec<&Filter> = packs.iter().flat_map(|p| &p.filter).collect();
|
||||
let touched_dirs: BTreeSet<String> = files
|
||||
.keys()
|
||||
.flat_map(|f| {
|
||||
let parts: Vec<&str> = f.split('/').collect();
|
||||
(1..parts.len()).map(move |n| parts[..n].join("/"))
|
||||
})
|
||||
.collect();
|
||||
let zips: Vec<Option<Zip>> = packs
|
||||
.iter()
|
||||
.map(|p| {
|
||||
@@ -882,6 +878,21 @@ fn build_overlay_uncached(
|
||||
merged.insert(name.clone(), bytes);
|
||||
}
|
||||
}
|
||||
add_sprite_renames(
|
||||
vanilla,
|
||||
&merged,
|
||||
&filters,
|
||||
&mut files,
|
||||
&mut hidden,
|
||||
dependencies,
|
||||
);
|
||||
let touched_dirs: BTreeSet<String> = files
|
||||
.keys()
|
||||
.flat_map(|f| {
|
||||
let parts: Vec<&str> = f.split('/').collect();
|
||||
(1..parts.len()).map(move |n| parts[..n].join("/"))
|
||||
})
|
||||
.collect();
|
||||
/// A zip entry to write: its pack, its name in the zip and where it goes.
|
||||
type Extract<'a> = (usize, &'a str, PathBuf);
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -1032,6 +1043,123 @@ fn build_overlay_uncached(
|
||||
}
|
||||
extracted
|
||||
}
|
||||
/// `SingleFile` with a `sprite`: the atlas holds the resource's pixels, and the resource's
|
||||
/// `.mcmeta`, under the sprite's id. Every other sprite of the blocks and items atlases is its
|
||||
/// texture's own id, and the loaders read a model's sprite from `textures/<sprite>.png`, so the
|
||||
/// overlay gives a renamed sprite that file. ItemsAdder and Nexo packs name their models'
|
||||
/// textures only through these renames (#14485).
|
||||
///
|
||||
/// A sprite gets its resource's file only when the whole source list, in order, ends on the
|
||||
/// rename (`SpriteSourceList.list`: a later source of the same id replaces it), when the other
|
||||
/// atlas, which reads the same file, lists it from that resource too or not at all, and when it
|
||||
/// has no texture of its own. A texture that is there may be read as itself (another source's
|
||||
/// resource, an unstitched or paletted sprite's input), so no alias ever replaces one; such a
|
||||
/// sprite draws its own texture, as before aliases.
|
||||
fn add_sprite_renames(
|
||||
vanilla: &Path,
|
||||
merged: &BTreeMap<String, Vec<u8>>,
|
||||
filters: &[&Filter],
|
||||
files: &mut BTreeMap<String, (usize, Provided)>,
|
||||
hidden: &mut BTreeSet<String>,
|
||||
dependencies: &mut cache::Dependencies,
|
||||
) {
|
||||
use crate::atlas_sources::{
|
||||
SpriteOrigin, SpriteSource, decode, list, list_textures, texture_path,
|
||||
};
|
||||
let png = |id: &str| {
|
||||
texture_path(Path::new(""), id, "textures")
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
};
|
||||
let atlases: Vec<_> = ["blocks", "items"]
|
||||
.into_iter()
|
||||
.filter_map(|atlas| merged.get(&format!("assets/minecraft/atlases/{atlas}.json")))
|
||||
.filter_map(|bytes| decode(std::str::from_utf8(bytes).ok()?))
|
||||
.collect();
|
||||
let renamed = |source: &SpriteSource| matches!(source, SpriteSource::Single { resource, sprite: Some(sprite) } if sprite != resource);
|
||||
if !atlases.iter().flatten().any(renamed) {
|
||||
return;
|
||||
}
|
||||
// The textures a source can list: the packs' and vanilla's the packs leave visible.
|
||||
let mut textures: Vec<String> = list_textures(vanilla)
|
||||
.into_iter()
|
||||
.filter(|id| {
|
||||
let name = png(id);
|
||||
!hidden.contains(&name) && !filters.iter().any(|f| f.blocks(&name))
|
||||
})
|
||||
.chain(files.keys().filter_map(|name| {
|
||||
let (namespace, rest) = name.strip_prefix("assets/")?.split_once('/')?;
|
||||
let path = rest.strip_prefix("textures/")?.strip_suffix(".png")?;
|
||||
Some(format!("{namespace}:{path}"))
|
||||
}))
|
||||
.collect();
|
||||
textures.sort();
|
||||
textures.dedup();
|
||||
// Each sprite's resource, `None` where an atlas lists it any other way.
|
||||
let mut renames: BTreeMap<String, Option<String>> = BTreeMap::new();
|
||||
for sources in &atlases {
|
||||
for (sprite, origin) in list(sources, &textures) {
|
||||
let from = match origin {
|
||||
SpriteOrigin::File(resource) if resource != sprite => Some(resource),
|
||||
_ => None,
|
||||
};
|
||||
renames
|
||||
.entry(sprite)
|
||||
.and_modify(|seen| {
|
||||
if *seen != from {
|
||||
*seen = None;
|
||||
}
|
||||
})
|
||||
.or_insert(from);
|
||||
}
|
||||
}
|
||||
let renames = renames
|
||||
.into_iter()
|
||||
.filter(|(sprite, _)| textures.binary_search(sprite).is_err())
|
||||
.filter_map(|(sprite, resource)| Some((png(&resource?), png(&sprite))));
|
||||
// Each resource is the file the packs give, never another rename's sprite.
|
||||
let mut found = Vec::new();
|
||||
for (from, to) in renames {
|
||||
// `texture_path` names an id that would leave the root as a file that is never there.
|
||||
if !stays_inside(&from) || !stays_inside(&to) {
|
||||
continue;
|
||||
}
|
||||
let from_meta = format!("{from}.mcmeta");
|
||||
// A pack's texture with its own `.mcmeta`, or vanilla's below every pack.
|
||||
let (file, meta) = match files.get(&from) {
|
||||
Some(file) => (file.clone(), files.get(&from_meta).cloned()),
|
||||
None => {
|
||||
let (path, meta_path) = (vanilla.join(&from), vanilla.join(&from_meta));
|
||||
dependencies.file(&path);
|
||||
dependencies.file(&meta_path);
|
||||
if !path.is_file() || filters.iter().any(|f| f.blocks(&from)) {
|
||||
continue;
|
||||
}
|
||||
// `createStackMetadataFinder`: a pack's filter hides vanilla's `.mcmeta` too.
|
||||
let meta = files.get(&from_meta).cloned().or_else(|| {
|
||||
(meta_path.is_file() && !filters.iter().any(|f| f.blocks(&from_meta)))
|
||||
.then(|| (0, (Some(meta_path), from_meta.clone())))
|
||||
});
|
||||
((0, (Some(path), from.clone())), meta)
|
||||
}
|
||||
};
|
||||
found.push((to, file, meta));
|
||||
}
|
||||
for (to, file, meta) in found {
|
||||
let to_meta = format!("{to}.mcmeta");
|
||||
files.insert(to, file);
|
||||
match meta {
|
||||
Some(meta) => {
|
||||
hidden.remove(&to_meta);
|
||||
files.insert(to_meta, meta);
|
||||
}
|
||||
None => {
|
||||
files.remove(&to_meta);
|
||||
hidden.insert(to_meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The most the zip packs in `packs` may unpack to in one overlay: 16 times their size on disk
|
||||
/// (real packs are mostly PNGs and JSON, a few times smaller zipped at most), at least 64 MiB
|
||||
/// for a small pack of text, and never past 8 GiB.
|
||||
@@ -1906,6 +2034,94 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
#[test]
|
||||
fn a_renamed_atlas_sprite_reads_its_resource() {
|
||||
let base =
|
||||
std::env::temp_dir().join(format!("horizon-sprite-rename-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&base);
|
||||
let write = |path: PathBuf, bytes: &[u8]| {
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, bytes).unwrap();
|
||||
};
|
||||
let vanilla = base.join("vanilla");
|
||||
let textures = vanilla.join("assets/minecraft/textures");
|
||||
let animation = b"{\"animation\":{}}";
|
||||
write(textures.join("block/sea_lantern.png"), b"lantern");
|
||||
write(textures.join("block/sea_lantern.png.mcmeta"), animation);
|
||||
write(textures.join("item/stick.png"), b"vanilla stick");
|
||||
write(textures.join("item/stick.png.mcmeta"), animation);
|
||||
let meta = br#"{"pack":{"description":"d","pack_format":97}}"#;
|
||||
let pack = base.join("packs/IA");
|
||||
write(pack.join("pack.mcmeta"), meta);
|
||||
write(pack.join("assets/iageneric/textures/item/box.png"), b"box");
|
||||
// An ItemsAdder pack's items atlas: its models name only the `ia:` sprites.
|
||||
write(
|
||||
pack.join("assets/minecraft/atlases/items.json"),
|
||||
br#"{"sources":[
|
||||
{"type":"single","resource":"iageneric:item/box","sprite":"ia:0"},
|
||||
{"type":"single","resource":"minecraft:block/sea_lantern","sprite":"ia:1199"},
|
||||
{"type":"single","resource":"iageneric:item/box","sprite":"minecraft:item/stick"},
|
||||
{"type":"single","resource":"minecraft:item/stick","sprite":"ia:3"},
|
||||
{"type":"single","resource":"iageneric:item/gone","sprite":"ia:2"},
|
||||
{"type":"single","resource":"iageneric:item/box","sprite":"ia:../../../escape"},
|
||||
{"type":"single","resource":"iageneric:item/box","sprite":"ia:7"},
|
||||
{"type":"single","resource":"iageneric:item/box","sprite":"ia:8"},
|
||||
{"type":"single","resource":"iageneric:item/box","sprite":"ia:9"}
|
||||
]}"#,
|
||||
);
|
||||
// A pack above it: later sources of three of those sprites, a blocks atlas that
|
||||
// disagrees about another, and a filter hiding vanilla's animation of the stick.
|
||||
let higher = base.join("packs/Top");
|
||||
write(
|
||||
higher.join("pack.mcmeta"),
|
||||
br#"{"pack":{"description":"d","pack_format":97},
|
||||
"filter":{"block":[{"path":"textures/item/stick\\.png\\.mcmeta"}]}}"#,
|
||||
);
|
||||
write(
|
||||
higher.join("assets/minecraft/atlases/items.json"),
|
||||
br#"{"sources":[
|
||||
{"type":"single","resource":"minecraft:block/sea_lantern","sprite":"ia:8"},
|
||||
{"type":"filter","pattern":{"namespace":"ia","path":"7"}}
|
||||
]}"#,
|
||||
);
|
||||
write(
|
||||
higher.join("assets/minecraft/atlases/blocks.json"),
|
||||
br#"{"sources":[
|
||||
{"type":"single","resource":"minecraft:block/sea_lantern","sprite":"ia:9"}
|
||||
]}"#,
|
||||
);
|
||||
let packs = discover(&vanilla, &base.join("packs"));
|
||||
let out = base.join("overlay");
|
||||
build_overlay(&vanilla, &packs, &out).unwrap();
|
||||
let read = |rel: &str| fs::read(out.join(rel)).ok();
|
||||
let texture = |id: &str| read(&format!("assets/ia/textures/{id}.png"));
|
||||
assert_eq!(texture("0").as_deref(), Some(&b"box"[..]));
|
||||
assert_eq!(read("assets/ia/textures/0.png.mcmeta"), None);
|
||||
// A vanilla resource comes with its animation.
|
||||
assert_eq!(texture("1199").as_deref(), Some(&b"lantern"[..]));
|
||||
assert_eq!(
|
||||
read("assets/ia/textures/1199.png.mcmeta").as_deref(),
|
||||
Some(&animation[..])
|
||||
);
|
||||
// A texture that is there stays itself: other sources may read it (here as `ia:3`'s
|
||||
// resource; an unstitched or paletted sprite's input alike).
|
||||
assert_eq!(
|
||||
read("assets/minecraft/textures/item/stick.png").as_deref(),
|
||||
Some(&b"vanilla stick"[..])
|
||||
);
|
||||
// ...with its `.mcmeta` only where no filter hides it.
|
||||
assert_eq!(texture("3").as_deref(), Some(&b"vanilla stick"[..]));
|
||||
assert_eq!(read("assets/ia/textures/3.png.mcmeta"), None);
|
||||
// A missing resource adds nothing, and no rename leaves the overlay.
|
||||
assert_eq!(texture("2"), None);
|
||||
assert!(!out.join("escape.png").exists());
|
||||
// The last source of a sprite wins: a filter drops it, a later rename replaces it.
|
||||
assert_eq!(texture("7"), None);
|
||||
assert_eq!(texture("8").as_deref(), Some(&b"lantern"[..]));
|
||||
// The two atlases read one file, and disagree about `ia:9`: it gets neither.
|
||||
assert_eq!(texture("9"), None);
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
#[test]
|
||||
fn every_file_of_a_big_stack_comes_from_its_top_pack() {
|
||||
use std::io::Write as _;
|
||||
let base = std::env::temp_dir().join(format!("horizon-pack-stack-{}", std::process::id()));
|
||||
|
||||
@@ -13,7 +13,8 @@ use std::{collections::BTreeSet, fs, io, path::Path, path::PathBuf};
|
||||
// Version 5 read every vanilla file to stamp it on Windows.
|
||||
// Version 6 stamps vanilla's files by their metadata on Windows too.
|
||||
// Version 7 stacks blockstate files (every pack's definition, bottom first).
|
||||
const VERSION: u32 = 8;
|
||||
// Version 8 left an atlas `single` source's renamed sprite without its texture file (#14485).
|
||||
const VERSION: u32 = 9;
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
pub(super) struct Dependencies {
|
||||
|
||||
@@ -1402,100 +1402,8 @@ impl ParticleLightCache {
|
||||
}
|
||||
}
|
||||
|
||||
const PARTICLE_SECTION_CACHE_SIZE: usize = 128;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum SectionFrustumResult {
|
||||
Outside,
|
||||
Intersects,
|
||||
Inside,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct CachedSectionFrustum {
|
||||
section: [i32; 3],
|
||||
radius_quarters: i32,
|
||||
result: SectionFrustumResult,
|
||||
}
|
||||
|
||||
struct ParticleSectionFrustumCache {
|
||||
entries: [Option<CachedSectionFrustum>; PARTICLE_SECTION_CACHE_SIZE],
|
||||
}
|
||||
|
||||
impl Default for ParticleSectionFrustumCache {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
entries: [None; PARTICLE_SECTION_CACHE_SIZE],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ParticleSectionFrustumCache {
|
||||
fn result(
|
||||
&mut self,
|
||||
world: [f64; 3],
|
||||
camera: DVec3,
|
||||
radius: f64,
|
||||
frustum: &crate::gfx::Frustum,
|
||||
) -> SectionFrustumResult {
|
||||
let Some(section) = world_section(world) else {
|
||||
return SectionFrustumResult::Intersects;
|
||||
};
|
||||
if !radius.is_finite() || radius < 0.0 || radius * 4.0 > f64::from(i32::MAX) {
|
||||
return SectionFrustumResult::Intersects;
|
||||
}
|
||||
let radius_quarters = (radius * 4.0).ceil() as i32;
|
||||
let hash = (section[0] as u32).wrapping_mul(73_856_093)
|
||||
^ (section[1] as u32).wrapping_mul(19_349_663)
|
||||
^ (section[2] as u32).wrapping_mul(83_492_791)
|
||||
^ (radius_quarters as u32).wrapping_mul(2_654_435_761);
|
||||
let slot = hash as usize & (PARTICLE_SECTION_CACHE_SIZE - 1);
|
||||
if let Some(entry) = self.entries[slot]
|
||||
&& entry.section == section
|
||||
&& entry.radius_quarters == radius_quarters
|
||||
{
|
||||
return entry.result;
|
||||
}
|
||||
|
||||
let pad = f64::from(radius_quarters) * 0.25;
|
||||
let camera = camera.to_array();
|
||||
let min = array::from_fn(|axis| f64::from(section[axis]) * 16.0 - pad - camera[axis]);
|
||||
let max = array::from_fn(|axis| min[axis] + 16.0 + 2.0 * pad);
|
||||
let result = if min.into_iter().chain(max).any(|v| !v.is_finite()) {
|
||||
SectionFrustumResult::Intersects
|
||||
} else {
|
||||
let min = FVec3::from_array(min.map(|v| v as f32));
|
||||
let max = FVec3::from_array(max.map(|v| v as f32));
|
||||
if !min.is_finite() || !max.is_finite() {
|
||||
SectionFrustumResult::Intersects
|
||||
} else if frustum.contains(min, max) {
|
||||
SectionFrustumResult::Inside
|
||||
} else if frustum.intersects(min, max) {
|
||||
SectionFrustumResult::Intersects
|
||||
} else {
|
||||
SectionFrustumResult::Outside
|
||||
}
|
||||
};
|
||||
self.entries[slot] = Some(CachedSectionFrustum {
|
||||
section,
|
||||
radius_quarters,
|
||||
result,
|
||||
});
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
fn world_section(position: [f64; 3]) -> Option<[i32; 3]> {
|
||||
let mut block = [0; 3];
|
||||
for axis in 0..3 {
|
||||
let value = position[axis].floor();
|
||||
if !value.is_finite() || value < f64::from(i32::MIN) || value > f64::from(i32::MAX) {
|
||||
return None;
|
||||
}
|
||||
block[axis] = value as i32;
|
||||
}
|
||||
Some([block[0] >> 4, block[1] >> 4, block[2] >> 4])
|
||||
}
|
||||
mod view_cull;
|
||||
use view_cull::{PARTICLE_FRUSTUM_OFFSET, ParticleSectionFrustumCache, SectionFrustumResult};
|
||||
|
||||
fn below_half_pixel(radius: f64, distance_squared: f64, pixel_focal_length: f32) -> bool {
|
||||
let projected_limit = 4.0 * radius * f64::from(pixel_focal_length);
|
||||
@@ -4693,27 +4601,17 @@ impl ParticleEngine {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Camera-facing quads (`QuadParticleRenderState.renderRotatedQuad`).
|
||||
/// Camera-facing quads (`QuadParticleRenderState.renderRotatedQuad`), turned by
|
||||
/// `Camera.rotation()`: `rotationYXZ(PI - yRot, -xRot, 0)`.
|
||||
pub fn render(
|
||||
&self,
|
||||
camera: DVec3,
|
||||
yaw: f32,
|
||||
pitch: f32,
|
||||
base: Quat,
|
||||
partial: f32,
|
||||
light_at: &dyn Fn([i32; 3]) -> u8,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
self.render_inner::<false>(
|
||||
camera,
|
||||
yaw,
|
||||
pitch,
|
||||
partial,
|
||||
0.0,
|
||||
None,
|
||||
&mut |_, _| false,
|
||||
light_at,
|
||||
out,
|
||||
);
|
||||
self.render_in_view(camera, base, partial, None, light_at, out);
|
||||
}
|
||||
|
||||
/// Render particles after rejecting view-frustum misses and any particles
|
||||
@@ -4721,8 +4619,7 @@ impl ParticleEngine {
|
||||
pub fn render_culled(
|
||||
&self,
|
||||
camera: DVec3,
|
||||
yaw: f32,
|
||||
pitch: f32,
|
||||
base: Quat,
|
||||
partial: f32,
|
||||
pixel_focal_length: f32,
|
||||
culling_enabled: bool,
|
||||
@@ -4732,13 +4629,12 @@ impl ParticleEngine {
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
if !culling_enabled {
|
||||
self.render(camera, yaw, pitch, partial, light_at, out);
|
||||
self.render_in_view(camera, base, partial, frustum, light_at, out);
|
||||
return;
|
||||
}
|
||||
self.render_inner::<true>(
|
||||
camera,
|
||||
yaw,
|
||||
pitch,
|
||||
base,
|
||||
partial,
|
||||
pixel_focal_length,
|
||||
frustum,
|
||||
@@ -4751,8 +4647,7 @@ impl ParticleEngine {
|
||||
fn render_inner<const CULL: bool>(
|
||||
&self,
|
||||
camera: DVec3,
|
||||
yaw: f32,
|
||||
pitch: f32,
|
||||
base: Quat,
|
||||
partial: f32,
|
||||
pixel_focal_length: f32,
|
||||
frustum: Option<&crate::gfx::Frustum>,
|
||||
@@ -4760,18 +4655,21 @@ impl ParticleEngine {
|
||||
light_at: &dyn Fn([i32; 3]) -> u8,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
// Camera.setRotation: rotationYXZ(PI - yRot, -xRot, 0).
|
||||
let base = Quat::from_rotation_y(std::f32::consts::PI - yaw.to_radians())
|
||||
* Quat::from_rotation_x(-pitch.to_radians());
|
||||
let mut light_cache = ParticleLightCache::default();
|
||||
let mut additive = self.additive.lock().unwrap_or_else(|e| e.into_inner());
|
||||
additive.clear();
|
||||
let mut section_cache =
|
||||
(CULL && frustum.is_some()).then(ParticleSectionFrustumCache::default);
|
||||
let view = frustum.map(|f| f.pulled_back(PARTICLE_FRUSTUM_OFFSET));
|
||||
for p in &self.particles {
|
||||
if p.no_render {
|
||||
continue;
|
||||
}
|
||||
if let Some(view) = &view
|
||||
&& !view_cull::in_view(view, p, camera)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// SparkParticle.extract: twinkling sparks blink after a third of their life.
|
||||
if let Behaviour::Spark { twinkle: true, .. } = p.behaviour
|
||||
&& p.age >= p.lifetime / 3
|
||||
@@ -5173,12 +5071,11 @@ mod tests {
|
||||
|
||||
let light_at = |_: [i32; 3]| 0xf0;
|
||||
let mut regular = Vec::new();
|
||||
engine.render(DVec3::ZERO, 0.0, 0.0, 0.5, &light_at, &mut regular);
|
||||
engine.render(DVec3::ZERO, Quat::IDENTITY, 0.5, &light_at, &mut regular);
|
||||
let mut disabled = Vec::new();
|
||||
engine.render_culled(
|
||||
DVec3::ZERO,
|
||||
0.0,
|
||||
0.0,
|
||||
Quat::IDENTITY,
|
||||
0.5,
|
||||
900.0,
|
||||
false,
|
||||
@@ -5207,6 +5104,145 @@ mod tests {
|
||||
assert_eq!(attributes(&disabled), attributes(®ular));
|
||||
}
|
||||
|
||||
/// An engine whose smoke particles stand at `positions`, camera-relative (the camera is at
|
||||
/// the origin looking down -z, like `Mat4::IDENTITY` as a view).
|
||||
fn smoke_at(positions: &[[f64; 3]]) -> ParticleEngine {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
let sprite = "test:smoke".to_owned();
|
||||
engine.atlas.insert(
|
||||
sprite.clone(),
|
||||
(
|
||||
Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 16,
|
||||
h: 16,
|
||||
},
|
||||
false,
|
||||
),
|
||||
);
|
||||
let mut random = LegacyRandom::new(7);
|
||||
for &at in positions {
|
||||
let mut particle = Particle::moving(&mut random, ParticleType::Smoke, at, [0.0; 3]);
|
||||
particle.sprite = Sprite::Named(sprite.clone());
|
||||
engine.particles.push(particle);
|
||||
}
|
||||
engine
|
||||
}
|
||||
|
||||
fn quad_vertices(engine: &ParticleEngine, view: Option<&crate::gfx::Frustum>) -> usize {
|
||||
let mut out = Vec::new();
|
||||
engine.render_in_view(DVec3::ZERO, Quat::IDENTITY, 0.5, view, &|_| 0xf0, &mut out);
|
||||
out.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn particles_outside_the_pulled_back_frustum_make_no_quads() {
|
||||
use crate::gfx::{Frustum, world_projection};
|
||||
let view = Frustum::from_view_projection(world_projection(70.0, 16.0 / 9.0, 512.0));
|
||||
let kept = [
|
||||
[0.0, 0.0, -5.0], // straight ahead
|
||||
[0.0, 0.0, 2.5], // behind the camera, inside the 3 blocks of offset
|
||||
[0.0, 0.0, -300.0], // far, inside the far plane
|
||||
];
|
||||
let dropped = [
|
||||
[0.0, 0.0, 10.0], // well behind the camera
|
||||
[0.0, 0.0, 3.5], // just past the offset
|
||||
[100.0, 0.0, -5.0], // far to the side
|
||||
[0.0, 60.0, -5.0], // far above
|
||||
[0.0, 0.0, -600.0], // beyond the far plane
|
||||
];
|
||||
let all: Vec<_> = kept.iter().chain(&dropped).copied().collect();
|
||||
let engine = smoke_at(&all);
|
||||
let only_kept = quad_vertices(&smoke_at(&kept), None);
|
||||
assert!(only_kept > 0);
|
||||
assert_eq!(quad_vertices(&engine, Some(&view)), only_kept);
|
||||
// Without a view every particle is drawn, as before.
|
||||
assert_eq!(
|
||||
quad_vertices(&engine, None),
|
||||
only_kept * all.len() / kept.len()
|
||||
);
|
||||
// `render` is the same as no view.
|
||||
let mut regular = Vec::new();
|
||||
engine.render(DVec3::ZERO, Quat::IDENTITY, 0.5, &|_| 0xf0, &mut regular);
|
||||
assert_eq!(regular.len(), quad_vertices(&engine, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_culled_path_applies_the_same_frustum_test() {
|
||||
use crate::gfx::{Frustum, world_projection};
|
||||
let view = Frustum::from_view_projection(world_projection(70.0, 16.0 / 9.0, 512.0));
|
||||
let engine = smoke_at(&[[0.0, 0.0, -5.0], [0.0, 0.0, 10.0]]);
|
||||
let mut out = Vec::new();
|
||||
// The disabled setting still keeps vanilla's always-on test.
|
||||
engine.render_culled(
|
||||
DVec3::ZERO,
|
||||
Quat::IDENTITY,
|
||||
0.5,
|
||||
900.0,
|
||||
false,
|
||||
Some(&view),
|
||||
|_, _| panic!("disabled culling must not query particle visibility"),
|
||||
&|_| 0xf0,
|
||||
&mut out,
|
||||
);
|
||||
assert_eq!(
|
||||
out.len(),
|
||||
quad_vertices(&smoke_at(&[[0.0, 0.0, -5.0]]), None)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_particle_is_tested_at_its_position_this_tick_not_an_interpolated_one() {
|
||||
use crate::gfx::{Frustum, world_projection};
|
||||
let view = Frustum::from_view_projection(world_projection(70.0, 16.0 / 9.0, 512.0));
|
||||
// The frame is drawn half way (0.5) between `previous` and `position`.
|
||||
// Ahead now, far behind a tick ago (half way: 12.5 behind, outside): kept.
|
||||
let mut coming = smoke_at(&[[0.0, 0.0, -5.0]]);
|
||||
coming.particles[0].previous = [0.0, 0.0, 30.0];
|
||||
assert!(quad_vertices(&coming, None) > 0);
|
||||
assert_eq!(
|
||||
quad_vertices(&coming, Some(&view)),
|
||||
quad_vertices(&coming, None)
|
||||
);
|
||||
// Behind now, ahead a tick ago (half way: 5 ahead, inside): dropped.
|
||||
let mut going = smoke_at(&[[0.0, 0.0, 10.0]]);
|
||||
going.particles[0].previous = [0.0, 0.0, -20.0];
|
||||
assert_eq!(quad_vertices(&going, Some(&view)), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quads_face_the_rotation_they_are_given() {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
let sprite = "test:smoke".to_owned();
|
||||
let rect = Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 16,
|
||||
h: 16,
|
||||
};
|
||||
engine.atlas.insert(sprite.clone(), (rect, false));
|
||||
let mut random = LegacyRandom::new(7);
|
||||
let mut particle =
|
||||
Particle::moving(&mut random, ParticleType::Smoke, [0.0, 0.0, -3.0], [0.0; 3]);
|
||||
particle.sprite = Sprite::Named(sprite);
|
||||
engine.particles.push(particle);
|
||||
let draw = |rotation: Quat| {
|
||||
let mut out = Vec::new();
|
||||
engine.render(DVec3::ZERO, rotation, 0.5, &|_| 0xf0, &mut out);
|
||||
out.iter().map(|v| v.pos).collect::<Vec<_>>()
|
||||
};
|
||||
// A bed's view turns the quads as the player's own yaw would not.
|
||||
let ahead = draw(Quat::IDENTITY);
|
||||
let turned = draw(Quat::from_rotation_y(std::f32::consts::FRAC_PI_2));
|
||||
assert_eq!(ahead.len(), 4);
|
||||
assert_ne!(ahead, turned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mod_particles_json_becomes_its_sprite_set() {
|
||||
let dir =
|
||||
@@ -5600,7 +5636,7 @@ mod tests {
|
||||
particle.sprite = Sprite::Index(0);
|
||||
engine.particles.push(particle);
|
||||
let mut vertices = Vec::new();
|
||||
engine.render(DVec3::ZERO, 0.0, 0.0, 0.0, &|_| 0, &mut vertices);
|
||||
engine.render(DVec3::ZERO, Quat::IDENTITY, 0.0, &|_| 0, &mut vertices);
|
||||
assert_eq!(vertices.len(), 4);
|
||||
assert!(vertices.iter().all(
|
||||
|v| v.color >> 24 == 127 && v.layer & crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY != 0
|
||||
|
||||
@@ -143,21 +143,39 @@ mod tests {
|
||||
p.size = 0.1;
|
||||
e.particles.push(p.clone());
|
||||
let mut vertices = Vec::new();
|
||||
e.render(DVec3::ZERO, 0.0, 0.0, 0.5, &|_| 0, &mut vertices);
|
||||
e.render(
|
||||
DVec3::ZERO,
|
||||
glam::Quat::IDENTITY,
|
||||
0.5,
|
||||
&|_| 0,
|
||||
&mut vertices,
|
||||
);
|
||||
assert_eq!(vertices.len(), 4);
|
||||
assert!(vertices.iter().all(
|
||||
|v| v.color >> 24 == 127 && v.layer & crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY != 0
|
||||
));
|
||||
e.particles[0].set_visible(false);
|
||||
vertices.clear();
|
||||
e.render(DVec3::ZERO, 0.0, 0.0, 0.5, &|_| 0, &mut vertices);
|
||||
e.render(
|
||||
DVec3::ZERO,
|
||||
glam::Quat::IDENTITY,
|
||||
0.5,
|
||||
&|_| 0,
|
||||
&mut vertices,
|
||||
);
|
||||
assert!(vertices.is_empty());
|
||||
assert_eq!(e.particles[0].age, 2);
|
||||
e.pending.push(copy);
|
||||
e.reload(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
assert!(e.pending.is_empty());
|
||||
vertices.clear();
|
||||
e.render(DVec3::ZERO, 0.0, 0.0, 0.5, &|_| 0, &mut vertices);
|
||||
e.render(
|
||||
DVec3::ZERO,
|
||||
glam::Quat::IDENTITY,
|
||||
0.5,
|
||||
&|_| 0,
|
||||
&mut vertices,
|
||||
);
|
||||
assert!(vertices.is_empty());
|
||||
e.tick_particle(&mut p, &level);
|
||||
assert!(p.removed);
|
||||
|
||||
@@ -1971,7 +1971,7 @@ mod tests {
|
||||
// The camera 5 blocks south, at the particle's height.
|
||||
let camera = DVec3::new(p.position[0], p.position[1], p.position[2] + 5.0);
|
||||
let mut out = Vec::new();
|
||||
engine.render(camera, 0.0, 0.0, 0.0, &|_| 0xF0, &mut out);
|
||||
engine.render(camera, glam::Quat::IDENTITY, 0.0, &|_| 0xF0, &mut out);
|
||||
assert_eq!(out.len(), 4);
|
||||
for v in &out {
|
||||
// The texture's top row (v 0) under the centre, its bottom row over it.
|
||||
@@ -2023,8 +2023,7 @@ mod tests {
|
||||
let mut out = Vec::new();
|
||||
engine.render(
|
||||
DVec3::new(1.0, 64.0, 6.0),
|
||||
0.0,
|
||||
0.0,
|
||||
glam::Quat::IDENTITY,
|
||||
0.0,
|
||||
&|_| 0xF0,
|
||||
&mut out,
|
||||
@@ -2138,8 +2137,7 @@ mod tests {
|
||||
let mut out = Vec::new();
|
||||
engine.render(
|
||||
DVec3::new(1.0, 70.0, 1.0),
|
||||
0.0,
|
||||
0.0,
|
||||
glam::Quat::IDENTITY,
|
||||
0.0,
|
||||
&|_| 0xF0,
|
||||
&mut out,
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
//! The camera's view frustum for particles: vanilla's always-on point test, and the section
|
||||
//! cache behind the opt-in Particle Culling setting.
|
||||
|
||||
use super::{Particle, ParticleEngine, Vertex};
|
||||
use glam::{DVec3, Quat, Vec3 as FVec3};
|
||||
use std::array;
|
||||
|
||||
const PARTICLE_SECTION_CACHE_SIZE: usize = 128;
|
||||
/// How far back, in blocks, the camera's frustum is pulled for the particle test (vanilla's
|
||||
/// `Frustum.offset` in `ParticleEngine.extract`).
|
||||
pub(super) const PARTICLE_FRUSTUM_OFFSET: f32 = 3.0;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum SectionFrustumResult {
|
||||
Outside,
|
||||
Intersects,
|
||||
Inside,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct CachedSectionFrustum {
|
||||
section: [i32; 3],
|
||||
radius_quarters: i32,
|
||||
result: SectionFrustumResult,
|
||||
}
|
||||
|
||||
pub(super) struct ParticleSectionFrustumCache {
|
||||
entries: [Option<CachedSectionFrustum>; PARTICLE_SECTION_CACHE_SIZE],
|
||||
}
|
||||
|
||||
impl Default for ParticleSectionFrustumCache {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
entries: [None; PARTICLE_SECTION_CACHE_SIZE],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ParticleSectionFrustumCache {
|
||||
pub(super) fn result(
|
||||
&mut self,
|
||||
world: [f64; 3],
|
||||
camera: DVec3,
|
||||
radius: f64,
|
||||
frustum: &crate::gfx::Frustum,
|
||||
) -> SectionFrustumResult {
|
||||
let Some(section) = world_section(world) else {
|
||||
return SectionFrustumResult::Intersects;
|
||||
};
|
||||
if !radius.is_finite() || radius < 0.0 || radius * 4.0 > f64::from(i32::MAX) {
|
||||
return SectionFrustumResult::Intersects;
|
||||
}
|
||||
let radius_quarters = (radius * 4.0).ceil() as i32;
|
||||
let hash = (section[0] as u32).wrapping_mul(73_856_093)
|
||||
^ (section[1] as u32).wrapping_mul(19_349_663)
|
||||
^ (section[2] as u32).wrapping_mul(83_492_791)
|
||||
^ (radius_quarters as u32).wrapping_mul(2_654_435_761);
|
||||
let slot = hash as usize & (PARTICLE_SECTION_CACHE_SIZE - 1);
|
||||
if let Some(entry) = self.entries[slot]
|
||||
&& entry.section == section
|
||||
&& entry.radius_quarters == radius_quarters
|
||||
{
|
||||
return entry.result;
|
||||
}
|
||||
|
||||
let pad = f64::from(radius_quarters) * 0.25;
|
||||
let camera = camera.to_array();
|
||||
let min = array::from_fn(|axis| f64::from(section[axis]) * 16.0 - pad - camera[axis]);
|
||||
let max = array::from_fn(|axis| min[axis] + 16.0 + 2.0 * pad);
|
||||
let result = if min.into_iter().chain(max).any(|v| !v.is_finite()) {
|
||||
SectionFrustumResult::Intersects
|
||||
} else {
|
||||
let min = FVec3::from_array(min.map(|v| v as f32));
|
||||
let max = FVec3::from_array(max.map(|v| v as f32));
|
||||
if !min.is_finite() || !max.is_finite() {
|
||||
SectionFrustumResult::Intersects
|
||||
} else if frustum.contains(min, max) {
|
||||
SectionFrustumResult::Inside
|
||||
} else if frustum.intersects(min, max) {
|
||||
SectionFrustumResult::Intersects
|
||||
} else {
|
||||
SectionFrustumResult::Outside
|
||||
}
|
||||
};
|
||||
self.entries[slot] = Some(CachedSectionFrustum {
|
||||
section,
|
||||
radius_quarters,
|
||||
result,
|
||||
});
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
fn world_section(position: [f64; 3]) -> Option<[i32; 3]> {
|
||||
let mut block = [0; 3];
|
||||
for axis in 0..3 {
|
||||
let value = position[axis].floor();
|
||||
if !value.is_finite() || value < f64::from(i32::MIN) || value > f64::from(i32::MAX) {
|
||||
return None;
|
||||
}
|
||||
block[axis] = value as i32;
|
||||
}
|
||||
Some([block[0] >> 4, block[1] >> 4, block[2] >> 4])
|
||||
}
|
||||
|
||||
/// Whether `QuadParticleGroup`'s test keeps `particle`: its position this tick (camera-relative, not
|
||||
/// interpolated) is on or inside `view`, the camera's frustum pulled back [`PARTICLE_FRUSTUM_OFFSET`] blocks.
|
||||
pub(super) fn in_view(view: &crate::gfx::Frustum, particle: &Particle, camera: DVec3) -> bool {
|
||||
let at = DVec3::from_array(particle.position) - camera;
|
||||
view.contains_point(at.as_vec3())
|
||||
}
|
||||
|
||||
impl ParticleEngine {
|
||||
/// [`Self::render`], keeping only the particles inside `view`, the camera's frustum pulled
|
||||
/// back [`PARTICLE_FRUSTUM_OFFSET`] blocks (vanilla's `ParticleEngine.extract` and
|
||||
/// `QuadParticleGroup`): the test runs whatever the Particle Culling setting says. With
|
||||
/// `None`, every particle is drawn.
|
||||
pub fn render_in_view(
|
||||
&self,
|
||||
camera: DVec3,
|
||||
base: Quat,
|
||||
partial: f32,
|
||||
view: Option<&crate::gfx::Frustum>,
|
||||
light_at: &dyn Fn([i32; 3]) -> u8,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
self.render_inner::<false>(
|
||||
camera,
|
||||
base,
|
||||
partial,
|
||||
0.0,
|
||||
view,
|
||||
&mut |_, _| false,
|
||||
light_at,
|
||||
out,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
//! A stopped sampler session as the capture's `samples` message (docs/perf-platform.md §3.5).
|
||||
use std::collections::HashMap;
|
||||
|
||||
use horizon_perf_format::Interner;
|
||||
use horizon_perf_format::v1::{
|
||||
BlockUnits, Contexts, Functions, Nodes, Overhead, SampleKind, SampleProfile, SampleSource,
|
||||
SampleUnit, SampledThread, Side, Unit, Weights,
|
||||
};
|
||||
use horizon_threads::heap_sample::HeapProfile;
|
||||
use horizon_threads::profile::{Aggregate, Frame, Profile, State, Symbols, symbolize};
|
||||
use horizon_threads::work::{self, Space};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
const UNKNOWN: &str = "[unknown]";
|
||||
|
||||
/// The name `work::set_dim` ids stand for: 1 overworld, 2 the nether, 3 the end.
|
||||
fn dimension_name(id: u8) -> Option<&'static str> {
|
||||
match id {
|
||||
1 => Some("overworld"),
|
||||
2 => Some("the_nether"),
|
||||
3 => Some("the_end"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The crate a demangled function name belongs to, or "" when it names none.
|
||||
fn crate_of(name: &str) -> &str {
|
||||
if let Some(inner) = name.strip_prefix('[') {
|
||||
let module = inner.split([' ', ']']).next().unwrap_or("");
|
||||
return if module == "unmapped" { "" } else { module };
|
||||
}
|
||||
let name = name.trim_start_matches(['<', '&', '*']);
|
||||
let name = name.strip_prefix("dyn ").unwrap_or(name);
|
||||
match name.split_once("::") {
|
||||
Some((krate, _)) if krate.chars().all(|c| c.is_alphanumeric() || c == '_') => krate,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FunctionTable {
|
||||
rows: Functions,
|
||||
index: HashMap<(u32, u32), u32>,
|
||||
allocator: Vec<bool>,
|
||||
}
|
||||
|
||||
impl FunctionTable {
|
||||
fn id(
|
||||
&mut self,
|
||||
strings: &mut Interner,
|
||||
name: &str,
|
||||
file: Option<&str>,
|
||||
line: u32,
|
||||
addr: u64,
|
||||
) -> u32 {
|
||||
let (name_id, file_id) = (
|
||||
strings.intern(name),
|
||||
file.map_or(0, |file| strings.intern(file)),
|
||||
);
|
||||
if let Some(&row) = self.index.get(&(name_id, file_id)) {
|
||||
return row;
|
||||
}
|
||||
let row = self.rows.name.len() as u32;
|
||||
self.rows.name.push(name_id);
|
||||
self.rows.file.push(file_id);
|
||||
self.rows.line.push(line);
|
||||
self.rows.crate_.push(match crate_of(name) {
|
||||
"" => 0,
|
||||
krate => strings.intern(krate),
|
||||
});
|
||||
self.rows.addr.push(addr);
|
||||
self.allocator.push(allocator_frame(name));
|
||||
self.index.insert((name_id, file_id), row);
|
||||
row
|
||||
}
|
||||
}
|
||||
|
||||
/// The thread each trie node belongs to; node 0 is the root.
|
||||
fn thread_of(agg: &Aggregate) -> Vec<u16> {
|
||||
let mut of = vec![0u16; agg.nodes()];
|
||||
for n in 1..agg.nodes() {
|
||||
of[n] = match agg.frame(n as u32) {
|
||||
Frame::Thread(i) => i,
|
||||
_ => of[agg.parent(n as u32) as usize],
|
||||
};
|
||||
}
|
||||
of
|
||||
}
|
||||
|
||||
/// The trie nodes some counted sample passes through.
|
||||
fn live_nodes(agg: &Aggregate) -> Vec<bool> {
|
||||
let mut live = vec![false; agg.nodes()];
|
||||
for key in agg.counts.keys() {
|
||||
let mut n = key.node as usize;
|
||||
while n != 0 && !live[n] {
|
||||
live[n] = true;
|
||||
n = agg.parent(n as u32) as usize;
|
||||
}
|
||||
}
|
||||
live
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct NodeTable {
|
||||
rows: Nodes,
|
||||
index: HashMap<(Option<u32>, u32, u16), u32>,
|
||||
}
|
||||
|
||||
impl NodeTable {
|
||||
fn id(&mut self, parent: Option<u32>, function: u32, thread: u16) -> u32 {
|
||||
let key = (parent, function, if parent.is_none() { thread } else { 0 });
|
||||
if let Some(&row) = self.index.get(&key) {
|
||||
return row;
|
||||
}
|
||||
let nodes = &mut self.rows;
|
||||
nodes.parent.push(parent.unwrap_or(0));
|
||||
nodes.function.push(function);
|
||||
nodes.thread_root.push(match parent {
|
||||
None => u32::from(thread) + 1,
|
||||
Some(_) => 0,
|
||||
});
|
||||
let row = nodes.parent.len() as u32 - 1;
|
||||
self.index.insert(key, row);
|
||||
row
|
||||
}
|
||||
}
|
||||
|
||||
fn named(strings: &mut Interner, space: Space, id: u16) -> u32 {
|
||||
match id {
|
||||
0 => 0,
|
||||
id => match work::name(space, id) {
|
||||
Some(name) => strings.intern(name),
|
||||
None => strings.intern(&format!("#{id}")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `name` is the allocator's own frame, which a heap sample's stack drops from its leaf end.
|
||||
fn allocator_frame(name: &str) -> bool {
|
||||
let name = name.trim_start_matches('<');
|
||||
name.starts_with("horizon_threads::heap_sample::")
|
||||
|| name.starts_with("__rust")
|
||||
|| name.starts_with("__rg_")
|
||||
|| name.starts_with("__rdl_")
|
||||
|| name.starts_with("alloc::alloc::")
|
||||
|| name.starts_with("alloc::raw_vec::")
|
||||
|| name.starts_with("mimalloc::")
|
||||
}
|
||||
|
||||
/// One aggregate's trie nodes as rows of the shared tables: `last[n]` is the output node of trie node `n`.
|
||||
fn add_tree(
|
||||
agg: &Aggregate,
|
||||
threads: &[u16],
|
||||
symbols: &Symbols,
|
||||
functions: &mut FunctionTable,
|
||||
nodes: &mut NodeTable,
|
||||
strings: &mut Interner,
|
||||
) -> (Vec<u16>, Vec<Option<u32>>) {
|
||||
let (local, live) = (thread_of(agg), live_nodes(agg));
|
||||
let thread_of: Vec<u16> = local
|
||||
.iter()
|
||||
.map(|&t| threads.get(usize::from(t)).copied().unwrap_or(0))
|
||||
.collect();
|
||||
let mut last: Vec<Option<u32>> = vec![None; agg.nodes()];
|
||||
for n in (1..agg.nodes()).filter(|&n| live[n]) {
|
||||
let inherited = last[agg.parent(n as u32) as usize];
|
||||
last[n] = match agg.frame(n as u32) {
|
||||
Frame::Thread(_) => None,
|
||||
Frame::Wall(_) => inherited,
|
||||
Frame::Addr(addr) => {
|
||||
let offset = symbols.module_offset(addr);
|
||||
let mut at = inherited;
|
||||
for sym in symbols.frames(addr).iter().rev() {
|
||||
let f = functions.id(strings, &sym.name, sym.file.as_deref(), sym.line, offset);
|
||||
at = Some(nodes.id(at, f, thread_of[n]));
|
||||
}
|
||||
if at == inherited {
|
||||
let f = functions.id(strings, UNKNOWN, None, 0, offset);
|
||||
at = Some(nodes.id(at, f, thread_of[n]));
|
||||
}
|
||||
at
|
||||
}
|
||||
};
|
||||
}
|
||||
(thread_of, last)
|
||||
}
|
||||
|
||||
fn trie_addrs(agg: &Aggregate) -> impl Iterator<Item = u64> + '_ {
|
||||
let live = live_nodes(agg);
|
||||
(1..agg.nodes())
|
||||
.filter(move |&n| live[n])
|
||||
.filter_map(|n| match agg.frame(n as u32) {
|
||||
Frame::Addr(a) => Some(a),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `profile` (and the heap session's `heap`) as a `SampleProfile` of `side`; strings go through `strings`, whose new entries the caller appends first.
|
||||
pub fn sample_profile(
|
||||
profile: &Profile,
|
||||
heap: Option<&HeapProfile>,
|
||||
side: Side,
|
||||
unit: Unit,
|
||||
block_name: &dyn Fn(u16) -> Option<String>,
|
||||
strings: &mut Interner,
|
||||
) -> SampleProfile {
|
||||
let agg = &profile.agg;
|
||||
let addrs: Vec<u64> = trie_addrs(agg)
|
||||
.chain(heap.into_iter().flat_map(|h| trie_addrs(&h.agg)))
|
||||
.collect();
|
||||
let symbols = symbolize(addrs);
|
||||
|
||||
let mut names: Vec<String> = agg.threads.clone();
|
||||
let cpu_threads: Vec<u16> = (0..agg.threads.len() as u16).collect();
|
||||
let heap_threads: Vec<u16> = heap.map_or_else(Vec::new, |h| {
|
||||
h.agg
|
||||
.threads
|
||||
.iter()
|
||||
.map(|name| match names.iter().position(|n| n == name) {
|
||||
Some(at) => at as u16,
|
||||
None => {
|
||||
names.push(name.clone());
|
||||
names.len() as u16 - 1
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
let mut functions = FunctionTable::default();
|
||||
let mut nodes = NodeTable::default();
|
||||
let mut trees = vec![(
|
||||
agg,
|
||||
add_tree(
|
||||
agg,
|
||||
&cpu_threads,
|
||||
&symbols,
|
||||
&mut functions,
|
||||
&mut nodes,
|
||||
strings,
|
||||
),
|
||||
)];
|
||||
if let Some(h) = heap {
|
||||
let tree = add_tree(
|
||||
&h.agg,
|
||||
&heap_threads,
|
||||
&symbols,
|
||||
&mut functions,
|
||||
&mut nodes,
|
||||
strings,
|
||||
);
|
||||
trees.push((&h.agg, tree));
|
||||
}
|
||||
|
||||
let wall = agg.counts.keys().any(|k| k.state != State::Cpu);
|
||||
let period_ms = 1000.0 / f64::from(profile.config.sampler.hz.max(1));
|
||||
let kind = |source: SampleSource, unit: SampleUnit, period: f64| SampleKind {
|
||||
source: source as i32,
|
||||
unit: unit as i32,
|
||||
period,
|
||||
};
|
||||
let mut kinds = vec![kind(SampleSource::Cpu, SampleUnit::Ms, period_ms)];
|
||||
if wall {
|
||||
kinds.push(kind(SampleSource::Wall, SampleUnit::Ms, period_ms));
|
||||
}
|
||||
if heap.is_some() {
|
||||
kinds.push(kind(SampleSource::Alloc, SampleUnit::Bytes, 1.0));
|
||||
kinds.push(kind(SampleSource::Live, SampleUnit::Bytes, 1.0));
|
||||
}
|
||||
let kind_of = |state: State| -> u32 {
|
||||
let source = match state {
|
||||
State::Cpu => SampleSource::Cpu,
|
||||
State::Running | State::Waiting => SampleSource::Wall,
|
||||
State::Alloc => SampleSource::Alloc,
|
||||
State::Live => SampleSource::Live,
|
||||
};
|
||||
kinds
|
||||
.iter()
|
||||
.position(|k| k.source == source as i32)
|
||||
.unwrap_or(0) as u32
|
||||
};
|
||||
|
||||
let mut contexts = Contexts::default();
|
||||
let mut context_ids: HashMap<[u32; 6], u32> = HashMap::new();
|
||||
let mut weights = Weights::default();
|
||||
let mut unknown_roots: HashMap<u16, u32> = HashMap::new();
|
||||
for (agg, (thread_of, last)) in &trees {
|
||||
let mut counted: Vec<_> = agg.counts.iter().collect();
|
||||
counted.sort_by_key(|(k, _)| {
|
||||
(
|
||||
thread_of[k.node as usize],
|
||||
k.node,
|
||||
k.state as u8,
|
||||
k.slow,
|
||||
k.time_block,
|
||||
(k.span, k.entity, k.block, k.dim, k.chunk),
|
||||
)
|
||||
});
|
||||
for (key, &count) in counted {
|
||||
let thread = thread_of[key.node as usize];
|
||||
let mut node = match last[key.node as usize] {
|
||||
Some(node) => node,
|
||||
None => *unknown_roots.entry(thread).or_insert_with(|| {
|
||||
let f = functions.id(strings, UNKNOWN, None, 0, 0);
|
||||
nodes.id(None, f, thread)
|
||||
}),
|
||||
};
|
||||
if matches!(key.state, State::Alloc | State::Live) {
|
||||
while nodes.rows.thread_root[node as usize] == 0
|
||||
&& functions.allocator[nodes.rows.function[node as usize] as usize]
|
||||
{
|
||||
node = nodes.rows.parent[node as usize];
|
||||
}
|
||||
}
|
||||
let row = [
|
||||
named(strings, Space::Span, key.span),
|
||||
named(strings, Space::Entity, key.entity),
|
||||
match key.block {
|
||||
0 => 0,
|
||||
id => match block_name(id) {
|
||||
Some(name) => strings.intern(&name),
|
||||
None => strings.intern(&format!("#{id}")),
|
||||
},
|
||||
},
|
||||
dimension_name(key.dim).map_or(0, |name| strings.intern(name)),
|
||||
key.chunk.0 as u32,
|
||||
key.chunk.1 as u32,
|
||||
];
|
||||
let context = *context_ids.entry(row).or_insert_with(|| {
|
||||
contexts.span.push(row[0]);
|
||||
contexts.entity.push(row[1]);
|
||||
contexts.block.push(row[2]);
|
||||
contexts.dim.push(row[3]);
|
||||
contexts.chunk_x.push(key.chunk.0);
|
||||
contexts.chunk_z.push(key.chunk.1);
|
||||
contexts.span.len() as u32 - 1
|
||||
});
|
||||
weights.node.push(node);
|
||||
weights.thread.push(u32::from(thread));
|
||||
weights.kind.push(kind_of(key.state));
|
||||
weights.state.push(match key.state {
|
||||
State::Running => 1,
|
||||
State::Waiting => 2,
|
||||
State::Cpu | State::Alloc | State::Live => 0,
|
||||
});
|
||||
weights.slow.push(key.slow);
|
||||
weights.context.push(context);
|
||||
weights.block.push(u32::from(key.time_block));
|
||||
weights.count.push(count);
|
||||
}
|
||||
}
|
||||
|
||||
let blocks = agg.time_blocks();
|
||||
let mut threads = Vec::new();
|
||||
let mut block_units = Vec::new();
|
||||
for (i, name) in names.iter().enumerate() {
|
||||
let ticks = agg.ticks.get(&(i as u16)).filter(|_| i < agg.threads.len());
|
||||
threads.push(SampledThread {
|
||||
name: strings.intern(name),
|
||||
units: ticks.map_or(0, |t| t.count),
|
||||
slow_units: ticks.map_or(0, |t| t.slow),
|
||||
unit: unit as i32,
|
||||
});
|
||||
if i >= agg.threads.len() {
|
||||
continue;
|
||||
}
|
||||
let mut units = agg
|
||||
.block_ticks
|
||||
.get(&(i as u16))
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
units.resize(blocks, 0);
|
||||
block_units.push(BlockUnits {
|
||||
thread: i as u32,
|
||||
units,
|
||||
});
|
||||
}
|
||||
|
||||
SampleProfile {
|
||||
side: side as i32,
|
||||
kinds,
|
||||
threads,
|
||||
block_s: profile.config.block_s,
|
||||
block_units,
|
||||
functions: Some(functions.rows),
|
||||
nodes: Some(nodes.rows),
|
||||
contexts: Some(contexts),
|
||||
weights: Some(weights),
|
||||
slow_over_ms: profile.config.ticks_over_ms as f32,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the sampler and its writer thread cost during the session.
|
||||
pub fn overhead(profile: &Profile, heap: Option<&HeapProfile>) -> Overhead {
|
||||
let wall_ns = profile.elapsed.as_nanos().max(1) as f64;
|
||||
let handler_ns = profile.stats.handler_ns.unwrap_or(0.0) * profile.stats.samples as f64;
|
||||
Overhead {
|
||||
sampler_share_of_thread: (handler_ns / wall_ns) as f32,
|
||||
writer_core_share: (profile.writer_cpu_ns as f64 / wall_ns) as f32,
|
||||
dropped_samples: profile.stats.dropped + profile.agg.lost + heap.map_or(0, |h| h.agg.lost),
|
||||
heap_samples: heap.map_or(0, |h| h.samples),
|
||||
heap_sample_ns: heap.map_or(0, |h| h.sample_ns),
|
||||
..Overhead::default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
use std::collections::HashMap;
|
||||
use std::hint::black_box;
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use horizon_perf_format::v1::{
|
||||
Kind as CaptureKind, SampleProfile, Side, Start, Stop, StringEntry, Unit,
|
||||
};
|
||||
use horizon_perf_format::{CaptureWriter, Field};
|
||||
use horizon_threads::profile::{self, Aggregate, Config, Profile};
|
||||
use horizon_threads::sampler::{self, Context, Kind, Record, Stats};
|
||||
use horizon_threads::unwind::MAX_FRAMES;
|
||||
use horizon_threads::work;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[inline(never)]
|
||||
fn alpha() {
|
||||
black_box(1);
|
||||
}
|
||||
|
||||
#[inline(never)]
|
||||
fn beta() {
|
||||
black_box(2);
|
||||
}
|
||||
|
||||
#[inline(never)]
|
||||
fn gamma() {
|
||||
black_box(3);
|
||||
}
|
||||
|
||||
fn at(f: fn(), offset: usize) -> usize {
|
||||
f as *const () as usize + offset
|
||||
}
|
||||
|
||||
fn record(kind: Kind, ctx: Context, value: u64, stack: &[usize]) -> Record {
|
||||
let mut frames = [0; MAX_FRAMES];
|
||||
frames[..stack.len()].copy_from_slice(stack);
|
||||
Record {
|
||||
kind,
|
||||
depth: stack.len() as u8,
|
||||
ctx,
|
||||
value,
|
||||
frames,
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_of(agg: Aggregate, config: Config) -> Profile {
|
||||
Profile {
|
||||
agg,
|
||||
config,
|
||||
stats: Stats::default(),
|
||||
elapsed: Duration::from_secs(10),
|
||||
started: SystemTime::now(),
|
||||
writer_cpu_ns: 0,
|
||||
}
|
||||
}
|
||||
|
||||
struct Converted {
|
||||
samples: SampleProfile,
|
||||
strings: Vec<StringEntry>,
|
||||
}
|
||||
|
||||
impl Converted {
|
||||
fn text(&self, id: u32) -> &str {
|
||||
match id {
|
||||
0 => "",
|
||||
id => &self.strings.iter().find(|s| s.id == id).unwrap().text,
|
||||
}
|
||||
}
|
||||
|
||||
fn function(&self, row: u32) -> &str {
|
||||
self.text(self.samples.functions.as_ref().unwrap().name[row as usize])
|
||||
}
|
||||
|
||||
/// Function names from the root down to `node`.
|
||||
fn path(&self, node: u32) -> Vec<&str> {
|
||||
let nodes = self.samples.nodes.as_ref().unwrap();
|
||||
let mut path = vec![self.function(nodes.function[node as usize])];
|
||||
let mut at = node as usize;
|
||||
while nodes.thread_root[at] == 0 {
|
||||
at = nodes.parent[at] as usize;
|
||||
path.push(self.function(nodes.function[at]));
|
||||
}
|
||||
path.reverse();
|
||||
path
|
||||
}
|
||||
|
||||
/// Samples per entity name, optionally only the slow ones.
|
||||
fn by_entity(&self, slow_only: bool) -> HashMap<String, u64> {
|
||||
let (w, c) = (
|
||||
self.samples.weights.as_ref().unwrap(),
|
||||
self.samples.contexts.as_ref().unwrap(),
|
||||
);
|
||||
let mut by = HashMap::new();
|
||||
for i in 0..w.node.len() {
|
||||
if slow_only && !w.slow[i] {
|
||||
continue;
|
||||
}
|
||||
let entity = self.text(c.entity[w.context[i] as usize]).to_owned();
|
||||
*by.entry(entity).or_default() += w.count[i];
|
||||
}
|
||||
by
|
||||
}
|
||||
}
|
||||
|
||||
fn convert(profile: &Profile) -> Converted {
|
||||
let mut interner = Interner::default();
|
||||
let samples = sample_profile(
|
||||
profile,
|
||||
None,
|
||||
Side::Server,
|
||||
Unit::Tick,
|
||||
&|id| (id == 7).then(|| "minecraft:hopper".to_owned()),
|
||||
&mut interner,
|
||||
);
|
||||
Converted {
|
||||
samples,
|
||||
strings: interner.take_new(Side::Server),
|
||||
}
|
||||
}
|
||||
|
||||
fn short(name: &str) -> &str {
|
||||
name.rsplit("::").next().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stack_becomes_a_root_to_leaf_chain_with_its_context() {
|
||||
let entity = work::entity_id("test:zombie");
|
||||
let ctx = Context {
|
||||
tick: 1,
|
||||
entity_type: entity,
|
||||
block: 7,
|
||||
dim: 2,
|
||||
chunk: (-3, 9),
|
||||
..Context::default()
|
||||
};
|
||||
let mut agg = Aggregate::new(50_000_000);
|
||||
for _ in 0..5 {
|
||||
agg.add(
|
||||
"tick",
|
||||
&record(Kind::Cpu, ctx, 0, &[at(alpha, 4), at(gamma, 3)]),
|
||||
);
|
||||
}
|
||||
agg.add("tick", &record(Kind::Tick, ctx, 9_000_000, &[]));
|
||||
agg.finish();
|
||||
let c = convert(&profile_of(agg, Config::default()));
|
||||
let w = c.samples.weights.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
(w.node.len(), w.count[0], w.state[0], w.slow[0]),
|
||||
(1, 5, 0, false)
|
||||
);
|
||||
let path: Vec<_> = c.path(w.node[0]).into_iter().map(short).collect();
|
||||
assert_eq!(path, ["gamma", "alpha"]);
|
||||
let nodes = c.samples.nodes.as_ref().unwrap();
|
||||
assert_eq!(nodes.thread_root, [1, 0]);
|
||||
let ctxs = c.samples.contexts.as_ref().unwrap();
|
||||
let row = w.context[0] as usize;
|
||||
assert_eq!(c.text(ctxs.entity[row]), "test:zombie");
|
||||
assert_eq!(c.text(ctxs.block[row]), "minecraft:hopper");
|
||||
assert_eq!(c.text(ctxs.dim[row]), "the_nether");
|
||||
assert_eq!((ctxs.chunk_x[row], ctxs.chunk_z[row]), (-3, 9));
|
||||
assert_eq!(c.text(c.samples.threads[0].name), "tick");
|
||||
assert_eq!(
|
||||
(c.samples.threads[0].units, c.samples.threads[0].slow_units),
|
||||
(1, 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn addresses_of_one_function_share_one_row() {
|
||||
let ctx = Context::default();
|
||||
let mut agg = Aggregate::new(50_000_000);
|
||||
for (inner, count) in [(4, 2), (8, 3)] {
|
||||
for _ in 0..count {
|
||||
agg.add(
|
||||
"tick",
|
||||
&record(Kind::Cpu, ctx, 0, &[at(alpha, inner), at(gamma, 3)]),
|
||||
);
|
||||
}
|
||||
}
|
||||
agg.add(
|
||||
"tick",
|
||||
&record(Kind::Cpu, ctx, 0, &[at(beta, 4), at(gamma, 3)]),
|
||||
);
|
||||
agg.finish();
|
||||
let c = convert(&profile_of(agg, Config::default()));
|
||||
let f = c.samples.functions.as_ref().unwrap();
|
||||
let names: Vec<_> = (0..f.name.len() as u32)
|
||||
.map(|row| short(c.function(row)))
|
||||
.collect();
|
||||
let mut sorted = names.clone();
|
||||
sorted.sort_unstable();
|
||||
assert_eq!(sorted, ["alpha", "beta", "gamma"]);
|
||||
let w = c.samples.weights.as_ref().unwrap();
|
||||
let on_alpha: u64 = (0..w.node.len())
|
||||
.filter(|&i| short(c.path(w.node[i]).last().unwrap()) == "alpha")
|
||||
.map(|i| w.count[i])
|
||||
.sum();
|
||||
assert_eq!(on_alpha, 5);
|
||||
let n = c.samples.nodes.as_ref().unwrap();
|
||||
let alphas: Vec<usize> = (0..n.function.len())
|
||||
.filter(|&i| short(c.function(n.function[i])) == "alpha")
|
||||
.collect();
|
||||
assert_eq!(alphas.len(), 1, "one node per (parent, function)");
|
||||
assert_eq!(
|
||||
short(c.function(n.function[n.parent[alphas[0]] as usize])),
|
||||
"gamma"
|
||||
);
|
||||
let weight_on_alpha: u64 = (0..w.node.len())
|
||||
.filter(|&i| w.node[i] as usize == alphas[0])
|
||||
.map(|i| w.count[i])
|
||||
.sum();
|
||||
assert_eq!(weight_on_alpha, 5);
|
||||
let alpha_row = names.iter().position(|n| *n == "alpha").unwrap();
|
||||
assert!(f.addr[alpha_row] > 0, "a module-relative address is kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_blocks_and_ticks_per_block_follow_when_they_were_added() {
|
||||
let ctx = |tick| Context {
|
||||
tick,
|
||||
..Context::default()
|
||||
};
|
||||
let mut agg = Aggregate::new(50_000_000);
|
||||
for (block, tick, samples) in [(0, 1, 2u64), (1, 2, 3), (1, 3, 1), (3, 4, 4)] {
|
||||
agg.set_time_block(block);
|
||||
for _ in 0..samples {
|
||||
agg.add("tick", &record(Kind::Cpu, ctx(tick), 0, &[at(alpha, 1)]));
|
||||
}
|
||||
agg.add("tick", &record(Kind::Tick, ctx(tick), 1_000_000, &[]));
|
||||
}
|
||||
agg.finish();
|
||||
let config = Config {
|
||||
block_s: 2.5,
|
||||
..Config::default()
|
||||
};
|
||||
let c = convert(&profile_of(agg, config));
|
||||
let w = c.samples.weights.as_ref().unwrap();
|
||||
let mut per_block = [0u64; 4];
|
||||
for i in 0..w.node.len() {
|
||||
per_block[w.block[i] as usize] += w.count[i];
|
||||
}
|
||||
assert_eq!(per_block, [2, 4, 0, 4]);
|
||||
assert_eq!(c.samples.block_s, 2.5);
|
||||
assert_eq!(c.samples.block_units[0].units, [1, 2, 0, 1]);
|
||||
assert_eq!(c.samples.threads[0].units, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sample_with_no_stack_hangs_off_an_unknown_root() {
|
||||
let mut agg = Aggregate::new(50_000_000);
|
||||
agg.add("tick", &record(Kind::Cpu, Context::default(), 0, &[]));
|
||||
agg.finish();
|
||||
let c = convert(&profile_of(agg, Config::default()));
|
||||
let w = c.samples.weights.as_ref().unwrap();
|
||||
assert_eq!(c.path(w.node[0]), ["[unknown]"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wall_samples_carry_their_state_and_a_second_kind() {
|
||||
let mut agg = Aggregate::new(50_000_000);
|
||||
let ctx = Context::default();
|
||||
agg.add("tick", &record(Kind::Cpu, ctx, 0, &[at(alpha, 1)]));
|
||||
agg.add("tick", &record(Kind::Wall, ctx, 1, &[at(alpha, 1)]));
|
||||
agg.add("tick", &record(Kind::Wall, ctx, 0, &[at(beta, 1)]));
|
||||
agg.finish();
|
||||
let config = Config {
|
||||
sampler: sampler::Config {
|
||||
hz: 200,
|
||||
..sampler::Config::default()
|
||||
},
|
||||
..Config::default()
|
||||
};
|
||||
let c = convert(&profile_of(agg, config));
|
||||
assert_eq!(c.samples.kinds.len(), 2);
|
||||
assert_eq!(c.samples.kinds[0].period, 5.0);
|
||||
let w = c.samples.weights.as_ref().unwrap();
|
||||
let mut seen: Vec<_> = (0..w.node.len())
|
||||
.map(|i| {
|
||||
(
|
||||
short(c.path(w.node[i]).last().unwrap()).to_owned(),
|
||||
w.kind[i],
|
||||
w.state[i],
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
seen.sort();
|
||||
assert_eq!(
|
||||
seen,
|
||||
[
|
||||
("alpha".into(), 0, 0),
|
||||
("alpha".into(), 1, 1),
|
||||
("beta".into(), 1, 2)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[inline(never)]
|
||||
fn spin_fast() {
|
||||
spin(8);
|
||||
black_box(0);
|
||||
}
|
||||
|
||||
#[inline(never)]
|
||||
fn spin_slow() {
|
||||
spin(120);
|
||||
black_box(1);
|
||||
}
|
||||
|
||||
fn spin(cpu_ms: u64) {
|
||||
let until = sampler::thread_cpu_ns() + cpu_ms * 1_000_000;
|
||||
let mut x = 1u64;
|
||||
while sampler::thread_cpu_ns() < until {
|
||||
for _ in 0..1 << 14 {
|
||||
x = black_box(x.wrapping_mul(6364136223846793005).wrapping_add(1));
|
||||
}
|
||||
}
|
||||
black_box(x);
|
||||
}
|
||||
|
||||
fn live_config() -> Config {
|
||||
Config {
|
||||
sampler: sampler::Config {
|
||||
hz: 1000,
|
||||
ring_samples: 512,
|
||||
unwind: sampler::Unwind::StackCopy,
|
||||
stack_copy_kib: 16,
|
||||
wall: false,
|
||||
},
|
||||
ticks_over_ms: 50.0,
|
||||
..Config::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `body` on a thread opted in as `name`, profiled for exactly its duration.
|
||||
fn profiled(name: &'static str, body: fn()) -> Profile {
|
||||
let (ready, wait) = mpsc::channel();
|
||||
let (go, start) = mpsc::channel::<()>();
|
||||
let worker = thread::spawn(move || {
|
||||
assert!(sampler::sampled_thread(name));
|
||||
ready.send(()).unwrap();
|
||||
start.recv().unwrap();
|
||||
body();
|
||||
});
|
||||
wait.recv().unwrap();
|
||||
let session = profile::start(Config {
|
||||
threads: vec![name.into()],
|
||||
..live_config()
|
||||
})
|
||||
.expect("start profiling");
|
||||
go.send(()).unwrap();
|
||||
worker.join().unwrap();
|
||||
session.stop().expect("stop profiling")
|
||||
}
|
||||
|
||||
fn entity_units() {
|
||||
let (a, b) = (work::entity_id("test:a"), work::entity_id("test:b"));
|
||||
for unit in 0..4 {
|
||||
work::tick_start();
|
||||
work::set_entity(if unit < 3 { a } else { b });
|
||||
let started = sampler::thread_cpu_ns();
|
||||
spin(40);
|
||||
work::clear_work();
|
||||
work::tick_end(sampler::thread_cpu_ns() - started);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_units_of_a_and_one_of_b_split_the_samples_three_to_one() {
|
||||
let c = convert(&profiled("perf-capture-entities", entity_units));
|
||||
let by = c.by_entity(false);
|
||||
let total = (by["test:a"] + by["test:b"]) as f64;
|
||||
let (a, b) = (by["test:a"] as f64 / total, by["test:b"] as f64 / total);
|
||||
assert!((a - 0.75).abs() < 0.1 && (b - 0.25).abs() < 0.1, "{by:?}");
|
||||
assert!(total > 100.0, "{total} samples");
|
||||
assert_eq!(c.samples.threads[0].units, 4);
|
||||
}
|
||||
|
||||
fn ticks_with_one_slow() {
|
||||
for tick in 0..6 {
|
||||
work::tick_start();
|
||||
let started = sampler::thread_cpu_ns();
|
||||
if tick == 3 {
|
||||
spin_slow();
|
||||
} else {
|
||||
spin_fast();
|
||||
}
|
||||
work::tick_end(sampler::thread_cpu_ns() - started);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_slow_ticks_function_is_in_the_slow_weights() {
|
||||
let c = convert(&profiled("perf-capture-slow", ticks_with_one_slow));
|
||||
let w = c.samples.weights.as_ref().unwrap();
|
||||
let (mut slow, mut fast) = (0, 0);
|
||||
for i in 0..w.node.len() {
|
||||
let names: Vec<_> = c.path(w.node[i]).into_iter().map(short).collect();
|
||||
if w.slow[i] {
|
||||
slow += w.count[i];
|
||||
assert!(
|
||||
names.contains(&"spin_slow") && !names.contains(&"spin_fast"),
|
||||
"{names:?}"
|
||||
);
|
||||
} else {
|
||||
fast += w.count[i];
|
||||
assert!(!names.contains(&"spin_slow"), "{names:?}");
|
||||
}
|
||||
}
|
||||
assert!(slow > 60 && fast > 20, "{slow} slow, {fast} other");
|
||||
assert_eq!(
|
||||
(c.samples.threads[0].units, c.samples.threads[0].slow_units),
|
||||
(6, 1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overhead_counts_lost_samples_and_the_writers_share_of_a_core() {
|
||||
let mut agg = Aggregate::new(1);
|
||||
agg.lost = 4;
|
||||
let mut profile = profile_of(agg, Config::default());
|
||||
profile.stats.dropped = 3;
|
||||
profile.stats.samples = 1000;
|
||||
profile.stats.handler_ns = Some(2_000.0);
|
||||
profile.writer_cpu_ns = 50_000_000;
|
||||
let o = overhead(&profile, None);
|
||||
assert_eq!(o.dropped_samples, 7);
|
||||
assert!((o.sampler_share_of_thread - 0.0002).abs() < 1e-7);
|
||||
assert!((o.writer_core_share - 0.005).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crates_come_from_the_first_path_segment() {
|
||||
for (name, krate) in [
|
||||
(
|
||||
"horizon_world::level::tick::random_tick_at",
|
||||
"horizon_world",
|
||||
),
|
||||
(
|
||||
"<horizon_world::X as core::ops::Drop>::drop",
|
||||
"horizon_world",
|
||||
),
|
||||
("core::ptr::drop_in_place<alloc::vec::Vec<u8>>", "core"),
|
||||
("clone3", ""),
|
||||
("[horizon-server 0x4f2a]", "horizon-server"),
|
||||
("[unmapped 0x7ffd]", ""),
|
||||
] {
|
||||
assert_eq!(crate_of(name), krate, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
fn ask(
|
||||
file: &std::path::Path,
|
||||
parts: &[&str],
|
||||
) -> horizon_perf::wire::Envelope<horizon_perf::query::Row> {
|
||||
let env = horizon_perf::cli::Env {
|
||||
horizon_home: None,
|
||||
poll: Duration::from_millis(2),
|
||||
};
|
||||
let mut args = vec!["query".to_owned()];
|
||||
args.extend(parts.iter().map(|p| (*p).to_owned()));
|
||||
args.push(file.to_string_lossy().into_owned());
|
||||
let mut out = Vec::new();
|
||||
if let Err(e) = horizon_perf::cli::run(&args, &env, &mut out) {
|
||||
panic!("{e:?}");
|
||||
}
|
||||
serde_json::from_slice(&out)
|
||||
.unwrap_or_else(|e| panic!("{e}: {}", String::from_utf8_lossy(&out)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_converted_profile_written_to_a_file_reads_back_through_the_queries() {
|
||||
let ctx = Context::default();
|
||||
let mut agg = Aggregate::new(50_000_000);
|
||||
for _ in 0..5 {
|
||||
agg.add(
|
||||
"tick",
|
||||
&record(Kind::Cpu, ctx, 0, &[at(alpha, 4), at(gamma, 3)]),
|
||||
);
|
||||
}
|
||||
for _ in 0..2 {
|
||||
agg.add(
|
||||
"tick",
|
||||
&record(Kind::Cpu, ctx, 0, &[at(beta, 4), at(gamma, 3)]),
|
||||
);
|
||||
}
|
||||
for _ in 0..2 {
|
||||
agg.add("tick", &record(Kind::Tick, ctx, 9_000_000, &[]));
|
||||
}
|
||||
for _ in 0..3 {
|
||||
agg.add("worker", &record(Kind::Cpu, ctx, 0, &[at(beta, 4)]));
|
||||
}
|
||||
agg.add("worker", &record(Kind::Tick, ctx, 9_000_000, &[]));
|
||||
agg.finish();
|
||||
let config = Config {
|
||||
sampler: sampler::Config {
|
||||
hz: 100,
|
||||
..sampler::Config::default()
|
||||
},
|
||||
..Config::default()
|
||||
};
|
||||
let c = convert(&profile_of(agg, config));
|
||||
let mut fields = vec![Field::Start(Start {
|
||||
side: Side::Server as i32,
|
||||
kind: CaptureKind::Dedicated as i32,
|
||||
..Default::default()
|
||||
})];
|
||||
fields.extend(c.strings.into_iter().map(Field::String));
|
||||
fields.push(Field::Samples(c.samples));
|
||||
fields.push(Field::Stop(Stop {
|
||||
side: Side::Server as i32,
|
||||
t: 10.0,
|
||||
..Default::default()
|
||||
}));
|
||||
let dir = std::env::temp_dir().join(format!("horizon-render-perf-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("real.hzperf");
|
||||
let mut writer = CaptureWriter::new(std::fs::File::create(&file).unwrap()).unwrap();
|
||||
for field in fields {
|
||||
writer.write(field).unwrap();
|
||||
}
|
||||
writer.finish().unwrap();
|
||||
|
||||
// 10 ms samples: alpha has 5 over the tick thread's 2 ticks.
|
||||
let hot = ask(&file, &["hot"]);
|
||||
let alpha = hot.rows().iter().find_map(|r| match r {
|
||||
horizon_perf::query::Row::Hot(h) if h.full.ends_with("alpha") => Some(h),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(alpha.map(|h| (h.self_ms, h.samples)), Some((25.0, 5)));
|
||||
let worker = ask(&file, &["split", "t:worker", "--by", "thread"]);
|
||||
let sel = worker.selection.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
(sel.thread.as_deref(), sel.ticks, sel.samples),
|
||||
(Some("worker"), Some(1), Some(3))
|
||||
);
|
||||
let values: Vec<_> = worker
|
||||
.rows()
|
||||
.iter()
|
||||
.map(|r| match r {
|
||||
horizon_perf::query::Row::Split(s) => (s.key.clone(), s.value),
|
||||
other => panic!("{other:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(values, [(Some("t:worker".to_owned()), 30.0)]);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heap_samples_share_the_tables_without_the_allocators_own_frames() {
|
||||
let mut cpu = Aggregate::new(50_000_000);
|
||||
cpu.add(
|
||||
"tick",
|
||||
&record(
|
||||
Kind::Cpu,
|
||||
Context::default(),
|
||||
0,
|
||||
&[at(alpha, 4), at(gamma, 3)],
|
||||
),
|
||||
);
|
||||
cpu.finish();
|
||||
let mut heap = Aggregate::new(0);
|
||||
let inside = HeapProfile::bytes as *const () as usize + 4;
|
||||
let on_tick = heap.thread_index("tick");
|
||||
let key = heap.stack_key(
|
||||
on_tick,
|
||||
profile::State::Alloc,
|
||||
&Context::default(),
|
||||
&[inside, at(beta, 4), at(gamma, 3)],
|
||||
);
|
||||
heap.add_weight(key, 3000);
|
||||
heap.add_weight(
|
||||
profile::Key {
|
||||
state: profile::State::Live,
|
||||
..key
|
||||
},
|
||||
1000,
|
||||
);
|
||||
let on_worker = heap.thread_index("worker");
|
||||
let key = heap.stack_key(
|
||||
on_worker,
|
||||
profile::State::Live,
|
||||
&Context::default(),
|
||||
&[inside, at(alpha, 4)],
|
||||
);
|
||||
heap.add_weight(key, 500);
|
||||
let heap = HeapProfile {
|
||||
agg: heap,
|
||||
interval: 4096,
|
||||
samples: 3,
|
||||
live_samples: 2,
|
||||
sample_ns: 0,
|
||||
freed: 2000,
|
||||
elapsed: Duration::from_secs(10),
|
||||
};
|
||||
let mut interner = Interner::default();
|
||||
let samples = sample_profile(
|
||||
&profile_of(cpu, Config::default()),
|
||||
Some(&heap),
|
||||
Side::Server,
|
||||
Unit::Tick,
|
||||
&|_| None,
|
||||
&mut interner,
|
||||
);
|
||||
let c = Converted {
|
||||
samples,
|
||||
strings: interner.take_new(Side::Server),
|
||||
};
|
||||
let sources: Vec<_> = c
|
||||
.samples
|
||||
.kinds
|
||||
.iter()
|
||||
.map(|k| (k.source(), k.period))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
sources,
|
||||
[
|
||||
(SampleSource::Cpu, 4.0),
|
||||
(SampleSource::Alloc, 1.0),
|
||||
(SampleSource::Live, 1.0)
|
||||
]
|
||||
);
|
||||
let names: Vec<_> = c.samples.threads.iter().map(|t| c.text(t.name)).collect();
|
||||
assert_eq!(names, ["tick", "worker"]);
|
||||
let w = c.samples.weights.as_ref().unwrap();
|
||||
let mut rows: Vec<_> = (0..w.node.len())
|
||||
.map(|i| {
|
||||
let path: Vec<_> = c.path(w.node[i]).into_iter().map(short).collect();
|
||||
(w.kind[i], w.thread[i], path.join(">"), w.count[i])
|
||||
})
|
||||
.collect();
|
||||
rows.sort();
|
||||
assert_eq!(
|
||||
rows,
|
||||
[
|
||||
(0, 0, "gamma>alpha".to_owned(), 1),
|
||||
(1, 0, "gamma>beta".to_owned(), 3000),
|
||||
(2, 0, "gamma>beta".to_owned(), 1000),
|
||||
(2, 1, "alpha".to_owned(), 500),
|
||||
]
|
||||
);
|
||||
let o = overhead(
|
||||
&profile_of(Aggregate::new(0), Config::default()),
|
||||
Some(&heap),
|
||||
);
|
||||
assert_eq!(o.heap_samples, 3);
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
//! The GPU pass timers' readings as the capture's `gpu` messages (docs/perf-platform.md §3.5); apart from `perf_capture`, which only Linux has.
|
||||
use horizon_perf_format::Interner;
|
||||
use horizon_perf_format::v1::{
|
||||
GpuBackend, GpuCounter, GpuCounts, GpuDevice, GpuFragmentUnit, GpuFrame, GpuFrames, GpuPart,
|
||||
GpuPartCounts, GpuPartsMode, GpuPass, GpuPassSplit, GpuPasses, GpuSource, Side,
|
||||
};
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::gfx::pack_timing::{
|
||||
Counters, FragmentUnit, GpuReadout, PartsMode, PassCounters, Readings, TimedFrame, TimedFrames,
|
||||
};
|
||||
|
||||
/// Every time one timer's frames gave a pass or a part during a capture.
|
||||
#[derive(Default)]
|
||||
struct Times {
|
||||
passes: Vec<(String, Vec<f32>)>,
|
||||
parts: Vec<(String, Vec<(String, Vec<f32>)>)>,
|
||||
}
|
||||
|
||||
fn times_of<'a>(list: &'a mut Vec<(String, Vec<f32>)>, name: &str) -> &'a mut Vec<f32> {
|
||||
let i = match list.iter().position(|e| e.0 == name) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
list.push((name.to_owned(), Vec::new()));
|
||||
list.len() - 1
|
||||
}
|
||||
};
|
||||
&mut list[i].1
|
||||
}
|
||||
|
||||
impl Times {
|
||||
fn add(&mut self, frame: &TimedFrame) {
|
||||
for (pass, ms) in &frame.passes {
|
||||
times_of(&mut self.passes, pass).push(*ms);
|
||||
}
|
||||
for (pass, parts) in &frame.parts {
|
||||
let i = match self.parts.iter().position(|e| e.0 == *pass) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
self.parts.push((pass.clone(), Vec::new()));
|
||||
self.parts.len() - 1
|
||||
}
|
||||
};
|
||||
for (part, ms) in parts {
|
||||
times_of(&mut self.parts[i].1, part).push(*ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.passes.is_empty() && self.parts.is_empty()
|
||||
}
|
||||
|
||||
/// `readings` with its times the medians over the whole capture instead of its last frames.
|
||||
fn medians_into(&self, readings: &Readings) -> Readings {
|
||||
let median = |v: &[f32]| {
|
||||
let mut v = v.to_vec();
|
||||
v.sort_by(f32::total_cmp);
|
||||
v[v.len() / 2]
|
||||
};
|
||||
Readings {
|
||||
passes: self
|
||||
.passes
|
||||
.iter()
|
||||
.map(|(name, v)| (name.clone(), median(v)))
|
||||
.collect(),
|
||||
parts: self
|
||||
.parts
|
||||
.iter()
|
||||
.map(|(pass, parts)| {
|
||||
let parts = parts.iter().map(|(n, v)| (n.clone(), median(v))).collect();
|
||||
(pass.clone(), parts)
|
||||
})
|
||||
.collect(),
|
||||
..readings.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A capture's timed frames on their way into `GpuFrames` blocks, every time kept for the whole capture's medians.
|
||||
#[derive(Default)]
|
||||
pub struct FrameLog {
|
||||
pending: Vec<GpuFrame>,
|
||||
cpu_frame_ms: Vec<f32>,
|
||||
own: Times,
|
||||
pack: Times,
|
||||
}
|
||||
|
||||
impl FrameLog {
|
||||
/// The CPU's own ms of one frame the window drew.
|
||||
pub fn cpu_frame(&mut self, ms: f32) {
|
||||
self.cpu_frame_ms.push(ms);
|
||||
}
|
||||
|
||||
/// The frames timed since the last call; `t_of` puts a frame's start on the capture clock.
|
||||
pub fn add(
|
||||
&mut self,
|
||||
frames: &TimedFrames,
|
||||
t_of: impl Fn(Instant) -> f64,
|
||||
strings: &mut Interner,
|
||||
) {
|
||||
for (source, list, times) in [
|
||||
(GpuSource::Horizon, &frames.own, &mut self.own),
|
||||
(GpuSource::ShaderPack, &frames.pack, &mut self.pack),
|
||||
] {
|
||||
for frame in list {
|
||||
times.add(frame);
|
||||
self.pending
|
||||
.push(frame_message(frame, source, t_of(frame.at), strings));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The frames and CPU frame times since the last block; none when there are neither.
|
||||
pub fn take_block(&mut self, side: Side) -> Option<GpuFrames> {
|
||||
if self.pending.is_empty() && self.cpu_frame_ms.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(GpuFrames {
|
||||
side: side as i32,
|
||||
frames: std::mem::take(&mut self.pending),
|
||||
cpu_frame_ms: std::mem::take(&mut self.cpu_frame_ms),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn frame_message(
|
||||
frame: &TimedFrame,
|
||||
source: GpuSource,
|
||||
t: f64,
|
||||
strings: &mut Interner,
|
||||
) -> GpuFrame {
|
||||
let mut passes: Vec<GpuPass> = frame
|
||||
.passes
|
||||
.iter()
|
||||
.map(|(name, ms)| GpuPass {
|
||||
name: strings.intern(name),
|
||||
ms: Some(*ms),
|
||||
parts: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
for (pass, parts) in &frame.parts {
|
||||
let parts = parts
|
||||
.iter()
|
||||
.map(|(part, ms)| GpuPart {
|
||||
name: strings.intern(part),
|
||||
ms: *ms,
|
||||
})
|
||||
.collect();
|
||||
let name = strings.intern(pass);
|
||||
match passes.iter_mut().find(|p| p.name == name) {
|
||||
Some(p) => p.parts = parts,
|
||||
None => passes.push(GpuPass {
|
||||
name,
|
||||
ms: None,
|
||||
parts,
|
||||
}),
|
||||
}
|
||||
}
|
||||
GpuFrame {
|
||||
source: source as i32,
|
||||
t,
|
||||
total_ms: frame.total_ms,
|
||||
passes,
|
||||
}
|
||||
}
|
||||
|
||||
fn backend(backend: wgpu::Backend) -> GpuBackend {
|
||||
match backend {
|
||||
wgpu::Backend::Vulkan => GpuBackend::Vulkan,
|
||||
wgpu::Backend::Metal => GpuBackend::Metal,
|
||||
wgpu::Backend::Dx12 => GpuBackend::Dx12,
|
||||
wgpu::Backend::Gl => GpuBackend::Gl,
|
||||
_ => GpuBackend::Other,
|
||||
}
|
||||
}
|
||||
|
||||
fn counts(c: Counters) -> GpuCounts {
|
||||
GpuCounts {
|
||||
vertex_shader_invocations: c.vertex_shader_invocations,
|
||||
clipper_invocations: c.clipper_invocations,
|
||||
clipper_primitives_out: c.clipper_primitives_out,
|
||||
fragment_shader_invocations: c.fragment_shader_invocations,
|
||||
}
|
||||
}
|
||||
|
||||
fn counter(strings: &mut Interner, pass: &str, c: &PassCounters) -> GpuCounter {
|
||||
GpuCounter {
|
||||
pass: strings.intern(pass),
|
||||
total: Some(counts(c.total)),
|
||||
pixels: c.pixels,
|
||||
parts: c
|
||||
.parts
|
||||
.iter()
|
||||
.map(|(name, part)| GpuPartCounts {
|
||||
name: strings.intern(name),
|
||||
counts: Some(counts(*part)),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn device(adapter: &wgpu::AdapterInfo, readings: &Readings) -> GpuDevice {
|
||||
let driver = format!("{} {}", adapter.driver, adapter.driver_info);
|
||||
GpuDevice {
|
||||
adapter: adapter.name.clone(),
|
||||
backend: backend(adapter.backend) as i32,
|
||||
driver: driver.trim().to_owned(),
|
||||
parts_mode: match readings.support.parts {
|
||||
PartsMode::InsidePasses => GpuPartsMode::InsidePasses,
|
||||
PartsMode::SeparatePasses => GpuPartsMode::SeparatePasses,
|
||||
} as i32,
|
||||
pass_split: match readings.support.parts {
|
||||
PartsMode::InsidePasses => GpuPassSplit::DrawGroups,
|
||||
PartsMode::SeparatePasses => GpuPassSplit::PassesOnly,
|
||||
} as i32,
|
||||
counters: readings.support.counters,
|
||||
fragment_unit: match readings.support.fragment_unit {
|
||||
None => GpuFragmentUnit::Unspecified,
|
||||
Some(FragmentUnit::Pixel) => GpuFragmentUnit::Pixel,
|
||||
Some(FragmentUnit::Quad) => GpuFragmentUnit::Quad,
|
||||
} as i32,
|
||||
}
|
||||
}
|
||||
|
||||
fn section(
|
||||
adapter: &wgpu::AdapterInfo,
|
||||
side: Side,
|
||||
source: GpuSource,
|
||||
readings: &Readings,
|
||||
strings: &mut Interner,
|
||||
) -> GpuPasses {
|
||||
let mut passes: Vec<GpuPass> = readings
|
||||
.passes
|
||||
.iter()
|
||||
.map(|(name, ms)| GpuPass {
|
||||
name: strings.intern(name),
|
||||
ms: Some(*ms),
|
||||
parts: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
for (pass, parts) in &readings.parts {
|
||||
let name = strings.intern(pass);
|
||||
let at = passes
|
||||
.iter()
|
||||
.position(|p| p.name == name)
|
||||
.unwrap_or_else(|| {
|
||||
passes.push(GpuPass {
|
||||
name,
|
||||
ms: None,
|
||||
parts: Vec::new(),
|
||||
});
|
||||
passes.len() - 1
|
||||
});
|
||||
passes[at].parts = parts
|
||||
.iter()
|
||||
.map(|(part, ms)| GpuPart {
|
||||
name: strings.intern(part),
|
||||
ms: *ms,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
GpuPasses {
|
||||
side: side as i32,
|
||||
device: Some(device(adapter, readings)),
|
||||
passes,
|
||||
counters: readings
|
||||
.counters
|
||||
.iter()
|
||||
.map(|(pass, c)| counter(strings, pass, c))
|
||||
.collect(),
|
||||
source: source as i32,
|
||||
}
|
||||
}
|
||||
|
||||
/// One `GpuPasses` per timer that has timed something, its times the medians of `log` where it has them; the caller appends `strings`' new entries first.
|
||||
pub fn gpu_passes(
|
||||
readout: &GpuReadout,
|
||||
log: &FrameLog,
|
||||
side: Side,
|
||||
strings: &mut Interner,
|
||||
) -> Vec<GpuPasses> {
|
||||
[
|
||||
(GpuSource::Horizon, &readout.own, &log.own),
|
||||
(GpuSource::ShaderPack, &readout.pack, &log.pack),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(source, readings, times)| {
|
||||
let readings = readings.as_ref().filter(|r| !r.is_empty())?;
|
||||
let whole = (!times.is_empty()).then(|| times.medians_into(readings));
|
||||
let readings = whole.as_ref().unwrap_or(readings);
|
||||
Some(section(&readout.adapter, side, source, readings, strings))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::gfx::pack_timing::DetailSupport;
|
||||
|
||||
fn adapter() -> wgpu::AdapterInfo {
|
||||
wgpu::AdapterInfo {
|
||||
name: "Test GPU".into(),
|
||||
vendor: 0x10de,
|
||||
device: 1,
|
||||
device_type: wgpu::DeviceType::DiscreteGpu,
|
||||
device_pci_bus_id: String::new(),
|
||||
driver: "NVIDIA".into(),
|
||||
driver_info: "570.1".into(),
|
||||
backend: wgpu::Backend::Vulkan,
|
||||
subgroup_min_size: 0,
|
||||
subgroup_max_size: 0,
|
||||
transient_saves_memory: None,
|
||||
limit_bucket: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn support(unit: Option<FragmentUnit>) -> DetailSupport {
|
||||
DetailSupport {
|
||||
parts: PartsMode::InsidePasses,
|
||||
encoder_stamps: true,
|
||||
counters: true,
|
||||
fragment_unit: unit,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_medians_parts_and_counters_come_through_with_their_names() {
|
||||
let c = |v, f| Counters {
|
||||
vertex_shader_invocations: v,
|
||||
clipper_invocations: 2_050_000,
|
||||
clipper_primitives_out: 1_200_000,
|
||||
fragment_shader_invocations: f,
|
||||
};
|
||||
let own = Readings {
|
||||
passes: vec![("World".into(), 1.5), ("HUD".into(), 0.25)],
|
||||
parts: vec![("GPU culling".into(), vec![("cull".into(), 0.125)])],
|
||||
counters: vec![(
|
||||
"World".into(),
|
||||
PassCounters {
|
||||
total: c(4_100_000, 6_300_000),
|
||||
pixels: 2_073_600,
|
||||
parts: vec![
|
||||
("before translucent".into(), c(3_000_000, 5_100_000)),
|
||||
("translucent terrain".into(), c(1_100_000, 1_200_000)),
|
||||
],
|
||||
},
|
||||
)],
|
||||
support: support(Some(FragmentUnit::Quad)),
|
||||
};
|
||||
let readout = GpuReadout {
|
||||
adapter: adapter(),
|
||||
own: Some(own),
|
||||
pack: None,
|
||||
};
|
||||
let mut strings = Interner::default();
|
||||
let got = gpu_passes(&readout, &FrameLog::default(), Side::Client, &mut strings);
|
||||
assert_eq!(got.len(), 1);
|
||||
let g = &got[0];
|
||||
assert_eq!((g.side(), g.source()), (Side::Client, GpuSource::Horizon));
|
||||
let d = g.device.as_ref().unwrap();
|
||||
assert_eq!(d.adapter, "Test GPU");
|
||||
assert_eq!(d.backend(), GpuBackend::Vulkan);
|
||||
assert_eq!(d.driver, "NVIDIA 570.1");
|
||||
assert_eq!(d.parts_mode(), GpuPartsMode::InsidePasses);
|
||||
assert!(d.counters);
|
||||
assert_eq!(d.fragment_unit(), GpuFragmentUnit::Quad);
|
||||
|
||||
// World, HUD, then the pass known only through its parts.
|
||||
let entries = strings.take_new(Side::Client);
|
||||
let name = |id: u32| entries.iter().find(|e| e.id == id).unwrap().text.as_str();
|
||||
let named: Vec<_> = g.passes.iter().map(|p| name(p.name)).collect();
|
||||
assert_eq!(named, ["World", "HUD", "GPU culling"]);
|
||||
let shown: Vec<_> = g.passes.iter().map(|p| p.ms).collect();
|
||||
assert_eq!(shown, [Some(1.5), Some(0.25), None]);
|
||||
assert_eq!(name(g.passes[2].parts[0].name), "cull");
|
||||
assert_eq!(g.passes[2].parts[0].ms, 0.125);
|
||||
let counted = &g.counters[0];
|
||||
assert_eq!(counted.pixels, 2_073_600);
|
||||
assert_eq!(
|
||||
counted.total.as_ref().unwrap().fragment_shader_invocations,
|
||||
6_300_000
|
||||
);
|
||||
assert_eq!(counted.parts.len(), 2);
|
||||
assert_eq!(
|
||||
counted.parts[1]
|
||||
.counts
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.vertex_shader_invocations,
|
||||
1_100_000
|
||||
);
|
||||
// The pass's counters and its time name the same string.
|
||||
assert_eq!(counted.pass, g.passes[0].name);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_timer_that_timed_nothing_adds_no_section() {
|
||||
let idle = Readings {
|
||||
passes: Vec::new(),
|
||||
parts: Vec::new(),
|
||||
counters: Vec::new(),
|
||||
support: support(None),
|
||||
};
|
||||
let pack = Readings {
|
||||
passes: vec![("composite".into(), 0.75)],
|
||||
..idle.clone()
|
||||
};
|
||||
let readout = GpuReadout {
|
||||
adapter: adapter(),
|
||||
own: Some(idle),
|
||||
pack: Some(pack),
|
||||
};
|
||||
let got = gpu_passes(
|
||||
&readout,
|
||||
&FrameLog::default(),
|
||||
Side::Client,
|
||||
&mut Interner::default(),
|
||||
);
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].source(), GpuSource::ShaderPack);
|
||||
assert_eq!(
|
||||
got[0].device.as_ref().unwrap().fragment_unit(),
|
||||
GpuFragmentUnit::Unspecified
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_end_section_takes_the_whole_capture_median_and_each_block_its_frames() {
|
||||
let start = Instant::now();
|
||||
let frame = |i: u32, world: f32| TimedFrame {
|
||||
at: start + std::time::Duration::from_millis(u64::from(i) * 10),
|
||||
total_ms: Some(world + 1.0),
|
||||
passes: vec![("World".into(), world), ("HUD".into(), 0.5)],
|
||||
parts: vec![("World".into(), vec![("sky".into(), world / 4.0)])],
|
||||
};
|
||||
let mut log = FrameLog::default();
|
||||
let mut strings = Interner::default();
|
||||
let worlds = [1.0, 2.0, 3.0, 4.0, 100.0];
|
||||
let frames = TimedFrames {
|
||||
own: worlds
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &w)| frame(i as u32, w))
|
||||
.collect(),
|
||||
pack: Vec::new(),
|
||||
};
|
||||
log.add(
|
||||
&frames,
|
||||
|at| at.duration_since(start).as_secs_f64(),
|
||||
&mut strings,
|
||||
);
|
||||
log.cpu_frame(6.0);
|
||||
let block = log.take_block(Side::Client).expect("a block");
|
||||
assert_eq!(block.frames.len(), 5);
|
||||
assert_eq!(block.cpu_frame_ms, [6.0]);
|
||||
assert!((block.frames[4].t - 0.04).abs() < 1e-9);
|
||||
assert_eq!(block.frames[4].total_ms, Some(101.0));
|
||||
assert_eq!(block.frames[4].passes[0].parts[0].ms, 25.0);
|
||||
assert!(log.take_block(Side::Client).is_none());
|
||||
|
||||
// The timer's own median (9.0) is of its last frames; the capture's is the third of five sorted.
|
||||
let own = Readings {
|
||||
passes: vec![("World".into(), 9.0)],
|
||||
parts: Vec::new(),
|
||||
counters: Vec::new(),
|
||||
support: support(None),
|
||||
};
|
||||
let readout = GpuReadout {
|
||||
adapter: adapter(),
|
||||
own: Some(own),
|
||||
pack: None,
|
||||
};
|
||||
let got = gpu_passes(&readout, &log, Side::Client, &mut strings);
|
||||
let world = &got[0].passes[0];
|
||||
assert_eq!(world.ms, Some(3.0));
|
||||
assert_eq!(world.parts[0].ms, 0.75);
|
||||
assert_eq!(
|
||||
got[0].device.as_ref().unwrap().pass_split(),
|
||||
GpuPassSplit::DrawGroups
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -580,6 +580,15 @@ pub fn button(
|
||||
"widget/button"
|
||||
};
|
||||
sprite(out, sprite_id, x, y, w, h);
|
||||
// `AbstractWidget.WithInactiveMessage.defaultInactiveMessage`: an inactive
|
||||
// widget's message is -6250336 (0xFFA0A0A0), not the active white.
|
||||
let inactive;
|
||||
let label = if active {
|
||||
label
|
||||
} else {
|
||||
inactive = label.clone().with_color(0xFFA0_A0A0);
|
||||
&inactive
|
||||
};
|
||||
scrolling_string_over_contents(out, m, label, x, y, w, h, 2, now_ms);
|
||||
}
|
||||
/// `Tooltip.splitTooltip` of a widget's tooltip, shown under the mouse.
|
||||
@@ -1150,6 +1159,33 @@ mod button_label_tests {
|
||||
button(&mut out, m, text, 10, 20, w, 20, true, false, now_ms);
|
||||
out
|
||||
}
|
||||
/// `WithInactiveMessage`: a disabled button's message is grey, not white.
|
||||
#[test]
|
||||
fn an_inactive_buttons_label_is_grey() {
|
||||
let atlas = atlas();
|
||||
let m = Measure(&atlas.font);
|
||||
let mut out = Vec::new();
|
||||
button(
|
||||
&mut out,
|
||||
&m,
|
||||
&Text::literal("Go"),
|
||||
10,
|
||||
20,
|
||||
100,
|
||||
20,
|
||||
false,
|
||||
false,
|
||||
0,
|
||||
);
|
||||
let color = out
|
||||
.iter()
|
||||
.find_map(|d| match d {
|
||||
Draw::Text { text, .. } => text.spans.iter().find_map(|s| s.style.color),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the label is drawn with its inactive style");
|
||||
assert_eq!(color & 0xFF_FFFF, 0xA0_A0A0);
|
||||
}
|
||||
fn scissor_of(draws: &[Draw]) -> Option<[i32; 4]> {
|
||||
draws.iter().find_map(|d| match d {
|
||||
Draw::Scissor { x0, y0, x1, y1 } => Some([*x0, *y0, *x1, *y1]),
|
||||
|
||||
@@ -222,11 +222,6 @@ impl<P: Copy, S: Copy> Lines<P, S> {
|
||||
}
|
||||
self.y += style::GROUP_GAP;
|
||||
}
|
||||
/// A group of rows under its page's own name (`list`'s `page_name`), for pages named at run
|
||||
/// time, such as a resource pack's config entries (`pack_options_ui`).
|
||||
pub(crate) fn titled(&mut self, page: P, rows: Vec<S>) {
|
||||
self.group(page, "", rows);
|
||||
}
|
||||
/// The lines and the content's height.
|
||||
pub(crate) fn finish(self) -> (Vec<Line<P, S>>, f32) {
|
||||
(self.lines, (self.y - style::GROUP_GAP).max(0.0))
|
||||
@@ -310,6 +305,9 @@ pub(crate) fn search_field(
|
||||
let frame = [x, y, clear[0] - x, h];
|
||||
search.frame = Some(frame);
|
||||
search.draw(batch, atlas, None);
|
||||
if ui::hovered(cursor, x, y, frame[2], h) {
|
||||
crate::widgets::text_cursor(true, true);
|
||||
}
|
||||
if search.value.is_empty() && !focused {
|
||||
let hint = Text::literal(search.message.clone());
|
||||
batch.text_styled(atlas, &hint, x + 5.0, search.y, style::muted(), true);
|
||||
@@ -349,8 +347,7 @@ pub(crate) fn plate(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, head
|
||||
}
|
||||
|
||||
/// The list, clipped to its view: each group's panel, then its names and rows on it. `row`
|
||||
/// draws a setting at its `y`; `page_name` names a page, and a group without a name of its own
|
||||
/// (`Lines::titled`), in the player's language.
|
||||
/// draws a setting at its `y`; `page_name` names a page, in the player's language.
|
||||
pub(crate) fn list<P: Copy, S: Copy>(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
@@ -383,7 +380,6 @@ pub(crate) fn list<P: Copy, S: Copy>(
|
||||
}
|
||||
let Some(setting) = line.setting else {
|
||||
let (name, color, dy) = match line.group {
|
||||
Some(("", _)) => (page_name(line.page), style::muted(), 3.0),
|
||||
Some((name, _)) => (lang::get(name), style::muted(), 3.0),
|
||||
None => (page_name(line.page), style::accent(), 4.0),
|
||||
};
|
||||
@@ -949,22 +945,10 @@ pub(crate) fn dock_note(
|
||||
}
|
||||
}
|
||||
|
||||
/// Done or Undo at the foot of the page list.
|
||||
/// Done, Undo or Apply at the foot of the page list, registered for a controller. Its label
|
||||
/// leaves `room` at its left for a controller's glyph: a button that declared its job gets one,
|
||||
/// as vanilla's do (`ui::action_button`), which a half-width Undo or Apply needs.
|
||||
pub(crate) fn foot_button(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
rect: [f32; 4],
|
||||
label: &Text,
|
||||
cursor: (f32, f32),
|
||||
flags: (bool, bool),
|
||||
) {
|
||||
foot_button_with_room(batch, atlas, rect, label, cursor, flags, 0.0);
|
||||
}
|
||||
|
||||
/// A foot button whose label leaves `room` at its left for a controller's glyph: a button that
|
||||
/// declared its job gets one, as vanilla's do (`ui::action_button`), which a half-width Undo or
|
||||
/// Apply needs.
|
||||
fn foot_button_with_room(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
rect: [f32; 4],
|
||||
@@ -2411,7 +2395,7 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
}
|
||||
None => 0.0,
|
||||
};
|
||||
foot_button_with_room(batch, atlas, rect, &label, cursor, flags, room);
|
||||
foot_button(batch, atlas, rect, &label, cursor, flags, room);
|
||||
}
|
||||
}
|
||||
fn click(
|
||||
|
||||
@@ -93,18 +93,17 @@ pub fn unpack(value: &str, signature: Signature, valid_uris: &[impl AsRef<str>])
|
||||
signed: signature == Signature::Signed,
|
||||
..empty.clone()
|
||||
};
|
||||
// Each texture is loaded independently: an invalid or disallowed URL for one
|
||||
// texture (cape, elytra) must not drop another (the skin). Vanilla's
|
||||
// SkinManager works the same way.
|
||||
// Authlib's MinecraftServicesSessionService validates every URL in
|
||||
// unpackTextures before returning any textures. A bad cape rejects the skin too.
|
||||
for (kind, texture) in textures {
|
||||
let Some(url) = texture.get("url").and_then(|u| u.as_str()) else {
|
||||
continue;
|
||||
return empty;
|
||||
};
|
||||
let allowed = valid_uris
|
||||
.iter()
|
||||
.any(|u| url.starts_with(&u.as_ref().replace("{textureId}", "")));
|
||||
if !allowed {
|
||||
continue;
|
||||
return empty;
|
||||
}
|
||||
if kind == "SKIN" {
|
||||
out.slim = texture
|
||||
@@ -1388,10 +1387,9 @@ mod tests {
|
||||
&super::TEXTURE_URIS,
|
||||
);
|
||||
assert_eq!(textures.slim, slim && !url.contains("evil"));
|
||||
// Textures are judged per URL (vanilla's SkinManager): the
|
||||
// cape's URL is always allowed here, so it is kept even when
|
||||
// the skin's URL is not.
|
||||
assert!(textures.cape.is_some());
|
||||
// Authlib rejects the whole payload if any URL is invalid,
|
||||
// including the allowed cape when the skin is disallowed.
|
||||
assert_eq!(textures.cape.is_some(), !url.contains("evil"));
|
||||
assert_eq!(parsed.skin.is_some(), !url.contains("evil"));
|
||||
assert!(std::sync::Arc::ptr_eq(&parsed, &cache.get(&profile)));
|
||||
}
|
||||
@@ -1544,18 +1542,14 @@ mod tests {
|
||||
assert!(t.slim);
|
||||
assert!(!unpack(wide, Signature::Signed, &TEXTURE_URIS).slim);
|
||||
}
|
||||
/// `unpack` keeps each texture independently: a cape from another origin
|
||||
/// must not drop the skin.
|
||||
/// Authlib's `MinecraftServicesSessionService` rejects the whole profile in
|
||||
/// `unpackTextures` when any texture URL is outside the allowed origins.
|
||||
#[test]
|
||||
fn unpack_keeps_the_skin_when_another_texture_is_rejected() {
|
||||
fn unpack_rejects_the_skin_when_another_texture_is_rejected() {
|
||||
use super::{Signature, TEXTURE_URIS, unpack};
|
||||
let mixed = "eyJ0ZXh0dXJlcyI6IHsiU0tJTiI6IHsidXJsIjogImh0dHBzOi8vdGV4dHVyZXMubWluZWNyYWZ0Lm5ldC90ZXh0dXJlL3NraW4xIn0sICJDQVBFIjogeyJ1cmwiOiAiaHR0cHM6Ly9ldmlsLmV4YW1wbGUvdGV4dHVyZS9jYXBlMSJ9fX0=";
|
||||
let t = unpack(mixed, Signature::Unsigned, &TEXTURE_URIS);
|
||||
assert_eq!(
|
||||
t.skin.as_deref(),
|
||||
Some("https://textures.minecraft.net/texture/skin1")
|
||||
);
|
||||
assert_eq!(t.cape, None);
|
||||
assert_eq!(t, super::Textures::default());
|
||||
}
|
||||
#[test]
|
||||
fn profiles_parse_like_resolvable_profiles() {
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
//! Named timings within a frame, for finding what makes a frame slow:
|
||||
//! `HORIZON_SPIKES=MS` prints the spans of every frame whose CPU time
|
||||
//! exceeds MS milliseconds, and of every server tick (dedicated or
|
||||
//! integrated) longer than MS. Each span keeps its wall time and the
|
||||
//! thread's CPU time inside it, so a span that waited (for a lock, the GPU
|
||||
//! or a core on a busy machine) shows apart from one that worked. Costs two
|
||||
//! clock reads per span when enabled and a relaxed load otherwise.
|
||||
//! Named timings (wall and thread CPU) of a frame or tick, on with `HORIZON_SPIKES` or while a capture records; the slow ones go to the slow ring, not as span lists to the log.
|
||||
use std::{
|
||||
cell::{Cell, RefCell},
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
@@ -58,10 +52,10 @@ pub fn record_here(on: bool) {
|
||||
/// Whether this thread records spans: all of them on, or [`record_here`].
|
||||
#[inline]
|
||||
pub fn recording_here() -> bool {
|
||||
ENABLED.load(Ordering::Relaxed) || HERE.with(Cell::get) || trace::on()
|
||||
ENABLED.load(Ordering::Relaxed) || HERE.with(Cell::get) || timeline::on()
|
||||
}
|
||||
/// The thread's CPU time when spans are on for every thread (`HORIZON_SPIKES`): only they
|
||||
/// print it.
|
||||
/// The thread's CPU time when spans are on for every thread (`HORIZON_SPIKES`): spans recorded
|
||||
/// by `record_here` alone keep wall time only.
|
||||
#[inline]
|
||||
fn cpu_if_enabled() -> Option<Duration> {
|
||||
if ENABLED.load(Ordering::Relaxed) {
|
||||
@@ -82,31 +76,41 @@ pub fn time<T>(name: &'static str, f: impl FnOnce() -> T) -> T {
|
||||
pub struct Guard {
|
||||
open: Option<(&'static str, Instant, Option<Duration>, u8)>,
|
||||
_heap: Option<horizon_threads::heap_census::Tag>,
|
||||
/// The sampler's span before this one.
|
||||
outer: u16,
|
||||
}
|
||||
/// Start a span named `name` that ends when the returned [`Guard`] is dropped.
|
||||
#[inline]
|
||||
pub fn guard(name: &'static str) -> Guard {
|
||||
let _heap = horizon_threads::heap_tag(name);
|
||||
let outer = horizon_threads::work::enter_span(name);
|
||||
if !recording_here() {
|
||||
return Guard { open: None, _heap };
|
||||
return Guard {
|
||||
open: None,
|
||||
_heap,
|
||||
outer,
|
||||
};
|
||||
}
|
||||
let depth = DEPTH.with(|d| d.replace(d.get().saturating_add(1)));
|
||||
Guard {
|
||||
open: Some((name, Instant::now(), cpu_if_enabled(), depth)),
|
||||
_heap,
|
||||
outer,
|
||||
}
|
||||
}
|
||||
impl Drop for Guard {
|
||||
fn drop(&mut self) {
|
||||
horizon_threads::work::leave_span(self.outer);
|
||||
let Some((name, started, cpu, depth)) = self.open.take() else {
|
||||
return;
|
||||
};
|
||||
let ms = started.elapsed().as_secs_f32() * 1000.0;
|
||||
let elapsed = started.elapsed();
|
||||
let ms = elapsed.as_secs_f32() * 1000.0;
|
||||
let cpu_ms = cpu
|
||||
.and_then(|start| Some((thread_cpu()?, start)))
|
||||
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
|
||||
DEPTH.with(|d| d.set(depth));
|
||||
trace::span(name, started, ms);
|
||||
timeline::span(name, started, elapsed, depth);
|
||||
push(Span {
|
||||
name,
|
||||
ms,
|
||||
@@ -115,6 +119,22 @@ impl Drop for Guard {
|
||||
});
|
||||
}
|
||||
}
|
||||
/// How many spans this thread holds.
|
||||
fn recorded() -> usize {
|
||||
SPANS.with(|s| s.borrow().len())
|
||||
}
|
||||
/// Moves the spans recorded since `mark` one level in: they ran inside the lap now ending.
|
||||
fn nest_since(mark: usize, depth: u8) {
|
||||
SPANS.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
let start = mark.min(s.len());
|
||||
for span in &mut s[start..] {
|
||||
if span.depth >= depth {
|
||||
span.depth = span.depth.saturating_add(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
/// Keeps a finished span for the thread's next [`take_raw`].
|
||||
fn push(span: Span) {
|
||||
SPANS.with(|s| {
|
||||
@@ -125,22 +145,6 @@ fn push(span: Span) {
|
||||
s.push(span);
|
||||
});
|
||||
}
|
||||
/// Attach per-tick counts without interpreting them as durations.
|
||||
pub fn counters(counts: &[(&str, u64)]) {
|
||||
if !enabled() {
|
||||
return;
|
||||
}
|
||||
NOTES.with(|n| {
|
||||
n.borrow_mut().push((
|
||||
0.0,
|
||||
counts
|
||||
.iter()
|
||||
.map(|(name, count)| format!("{name}={count}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
))
|
||||
});
|
||||
}
|
||||
/// Notes that one step inside a span took `ms` milliseconds doing `what` (one player's
|
||||
/// packets, say), when this thread records spans and the step took at least [`NOTE_MS`]: a
|
||||
/// span says where a slow tick went, its notes whose doing it was. Only the slowest few are
|
||||
@@ -173,34 +177,36 @@ pub fn take_notes() -> Vec<(f32, String)> {
|
||||
}
|
||||
/// Consecutive steps of one function timed as spans: each [`Laps::lap`] records the time since
|
||||
/// the previous one (or since [`laps`]) under its name. Nothing is read while spans are off.
|
||||
pub struct Laps(Option<(Instant, Option<Duration>)>);
|
||||
pub struct Laps(Option<(Instant, Option<Duration>, usize)>);
|
||||
/// Start timing the steps of a function (see [`Laps`]).
|
||||
#[inline]
|
||||
pub fn laps() -> Laps {
|
||||
Laps(recording_here().then(|| (Instant::now(), cpu_if_enabled())))
|
||||
Laps(recording_here().then(|| (Instant::now(), cpu_if_enabled(), recorded())))
|
||||
}
|
||||
impl Laps {
|
||||
/// Record the step that just ended under `name`, and start the next.
|
||||
#[inline]
|
||||
pub fn lap(&mut self, name: &'static str) {
|
||||
let Some((started, cpu)) = &mut self.0 else {
|
||||
let Some((started, cpu, mark)) = &mut self.0 else {
|
||||
return;
|
||||
};
|
||||
let now = Instant::now();
|
||||
let now_cpu = cpu.and_then(|_| thread_cpu());
|
||||
let ms = (now - *started).as_secs_f32() * 1000.0;
|
||||
let elapsed = now - *started;
|
||||
let ms = elapsed.as_secs_f32() * 1000.0;
|
||||
let cpu_ms = now_cpu
|
||||
.zip(*cpu)
|
||||
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
|
||||
let depth = DEPTH.with(Cell::get);
|
||||
trace::span(name, *started, ms);
|
||||
timeline::span(name, *started, elapsed, depth);
|
||||
nest_since(*mark, depth);
|
||||
push(Span {
|
||||
name,
|
||||
ms,
|
||||
cpu_ms,
|
||||
depth,
|
||||
});
|
||||
(*started, *cpu) = (now, now_cpu);
|
||||
(*started, *cpu, *mark) = (now, now_cpu, recorded());
|
||||
}
|
||||
}
|
||||
/// The CPU time this thread has used, where the platform tells it: a tick
|
||||
@@ -283,173 +289,176 @@ pub fn merge(spans: &[Span]) -> Vec<Span> {
|
||||
sums.sort_by(|a, b| b.ms.total_cmp(&a.ms));
|
||||
sums
|
||||
}
|
||||
/// `name wall` for spans of at least 0.5 ms, with `/cpu` when the thread
|
||||
/// worked for less than nine tenths of it: the slow-frame and slow-tick lines.
|
||||
pub fn describe(spans: &[Span]) -> String {
|
||||
let list: Vec<String> = spans
|
||||
.iter()
|
||||
.filter(|s| s.ms >= 0.5)
|
||||
.map(|s| {
|
||||
if s.cpu_ms < s.ms * 0.9 {
|
||||
format!("{} {:.1}/{:.1}", s.name, s.ms, s.cpu_ms)
|
||||
} else {
|
||||
format!("{} {:.1}", s.name, s.ms)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
list.join(", ")
|
||||
}
|
||||
|
||||
/// The run's timeline: every span ([`time`], [`guard`], [`Laps::lap`]) on every thread, as
|
||||
/// Chrome trace events that Perfetto (ui.perfetto.dev) opens as they are. `HORIZON_TRACE=FILE`
|
||||
/// writes it from the start; [`start`](trace::start) and [`stop`](trace::stop) write one for a
|
||||
/// while (`horizon-trace` on a dedicated server's console, `trace` on the control socket). Spans
|
||||
/// show on their threads where they ran, nested by time, so what ran beside what, and what a
|
||||
/// thread waited for, can be read off. Off, a span costs one relaxed load more.
|
||||
pub mod trace {
|
||||
/// The run's timeline: every span on every thread, kept in per-thread buffers until a capture drains them as `timeline` chunks.
|
||||
pub mod timeline {
|
||||
use std::{
|
||||
cell::Cell,
|
||||
fs::File,
|
||||
io::{BufWriter, Write as _},
|
||||
path::Path,
|
||||
cell::RefCell,
|
||||
sync::{
|
||||
Mutex, PoisonError,
|
||||
Arc, Mutex, PoisonError,
|
||||
atomic::{AtomicBool, AtomicU32, Ordering},
|
||||
},
|
||||
time::Instant,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
/// The most spans a thread holds between two drains; a thread that records more drops them.
|
||||
const THREAD_EVENTS_MAX: usize = 1 << 18;
|
||||
|
||||
static ON: AtomicBool = AtomicBool::new(false);
|
||||
static SINK: Mutex<Option<Sink>> = Mutex::new(None);
|
||||
/// Counts the traces started: a thread names itself again in each one.
|
||||
static TRACES: AtomicU32 = AtomicU32::new(0);
|
||||
static NEXT_THREAD: AtomicU32 = AtomicU32::new(1);
|
||||
thread_local! {
|
||||
/// This thread's id in traces (0: none yet).
|
||||
static THREAD: Cell<u32> = const { Cell::new(0) };
|
||||
/// The trace this thread last named itself in.
|
||||
static NAMED_IN: Cell<u32> = const { Cell::new(0) };
|
||||
}
|
||||
/// How often a trace's buffer goes to the file, at most: a run that ends without
|
||||
/// [`stop`] loses no more than this.
|
||||
const FLUSH_EVERY: std::time::Duration = std::time::Duration::from_secs(1);
|
||||
static GENERATION: AtomicU32 = AtomicU32::new(0);
|
||||
static RUN: Mutex<Option<Run>> = Mutex::new(None);
|
||||
|
||||
struct Sink {
|
||||
file: BufWriter<File>,
|
||||
struct Run {
|
||||
origin: Instant,
|
||||
/// Until when it records ([`start`]'s `seconds`).
|
||||
until: Option<Instant>,
|
||||
flushed: Instant,
|
||||
/// Events written: the next one is preceded by a comma.
|
||||
events: u64,
|
||||
}
|
||||
impl Sink {
|
||||
fn event(&mut self, event: std::fmt::Arguments) {
|
||||
let comma = if self.events == 0 { "" } else { ",\n" };
|
||||
let _ = write!(self.file, "{comma}{{\"pid\":1,{event}}}");
|
||||
self.events += 1;
|
||||
}
|
||||
threads: Vec<Arc<Mutex<Thread>>>,
|
||||
}
|
||||
|
||||
/// Whether a trace is being written.
|
||||
struct Thread {
|
||||
name: String,
|
||||
label: String,
|
||||
events: Vec<Event>,
|
||||
}
|
||||
|
||||
/// This thread's buffer in the timeline it last joined.
|
||||
struct Mine {
|
||||
generation: u32,
|
||||
origin: Instant,
|
||||
thread: Arc<Mutex<Thread>>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static MINE: RefCell<Option<Mine>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// One finished span.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Event {
|
||||
pub name: &'static str,
|
||||
/// Microseconds from the timeline's start.
|
||||
pub start_us: u64,
|
||||
pub dur_us: u32,
|
||||
/// How many spans it ran inside on its thread.
|
||||
pub depth: u8,
|
||||
}
|
||||
|
||||
/// The spans one thread finished since the last drain, in the order they finished.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct ThreadEvents {
|
||||
/// The thread's name; threads alive together under one name get `#2`, `#3`... after it.
|
||||
pub thread: String,
|
||||
pub events: Vec<Event>,
|
||||
}
|
||||
|
||||
/// Whether a timeline is being recorded.
|
||||
#[inline]
|
||||
pub fn on() -> bool {
|
||||
ON.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Starts a trace written to `path` (replacing a trace being written), for `seconds` or
|
||||
/// until [`stop`].
|
||||
pub fn start(path: &Path, seconds: Option<u32>) -> std::io::Result<()> {
|
||||
let mut file = BufWriter::with_capacity(1 << 18, File::create(path)?);
|
||||
file.write_all(b"[\n")?;
|
||||
let now = Instant::now();
|
||||
let sink = Sink {
|
||||
file,
|
||||
origin: now,
|
||||
until: seconds.map(|s| now + std::time::Duration::from_secs(u64::from(s))),
|
||||
flushed: now,
|
||||
events: 0,
|
||||
};
|
||||
let mut slot = SINK.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some(old) = slot.take() {
|
||||
close(old);
|
||||
/// Starts recording, timed from `origin`; false when a timeline already runs (the process has one).
|
||||
pub fn start(origin: Instant) -> bool {
|
||||
let mut run = RUN.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if run.is_some() {
|
||||
return false;
|
||||
}
|
||||
*slot = Some(sink);
|
||||
TRACES.fetch_add(1, Ordering::Relaxed);
|
||||
*run = Some(Run {
|
||||
origin,
|
||||
threads: Vec::new(),
|
||||
});
|
||||
GENERATION.fetch_add(1, Ordering::Relaxed);
|
||||
ON.store(true, Ordering::Relaxed);
|
||||
Ok(())
|
||||
true
|
||||
}
|
||||
|
||||
/// Starts the trace `HORIZON_TRACE` names, if it names one.
|
||||
pub fn start_from_env() {
|
||||
if let Some(path) = std::env::var_os("HORIZON_TRACE")
|
||||
&& let Err(e) = start(Path::new(&path), None)
|
||||
{
|
||||
log::warn!("Horizon: HORIZON_TRACE: {e}");
|
||||
}
|
||||
/// Takes what the threads finished since the last call.
|
||||
pub fn drain() -> Vec<ThreadEvents> {
|
||||
let run = RUN.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
run.as_ref().map_or_else(Vec::new, take_all)
|
||||
}
|
||||
|
||||
/// Ends the trace being written: the file is complete JSON. The events written so far
|
||||
/// and whether one was being written.
|
||||
pub fn stop() -> Option<u64> {
|
||||
/// Stops recording and returns what the threads finished since the last drain.
|
||||
pub fn finish() -> Vec<ThreadEvents> {
|
||||
ON.store(false, Ordering::Relaxed);
|
||||
let sink = SINK.lock().unwrap_or_else(PoisonError::into_inner).take()?;
|
||||
let events = sink.events;
|
||||
close(sink);
|
||||
Some(events)
|
||||
let run = RUN.lock().unwrap_or_else(PoisonError::into_inner).take();
|
||||
run.as_ref().map_or_else(Vec::new, take_all)
|
||||
}
|
||||
|
||||
/// Whether the trace being written has run its time ([`start`]'s `seconds`): the caller
|
||||
/// then [`stop`]s it.
|
||||
pub fn due() -> bool {
|
||||
on() && SINK
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.as_ref()
|
||||
.and_then(|s| s.until)
|
||||
.is_some_and(|until| Instant::now() >= until)
|
||||
fn take_all(run: &Run) -> Vec<ThreadEvents> {
|
||||
run.threads
|
||||
.iter()
|
||||
.filter_map(|thread| {
|
||||
let mut thread = thread.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
(!thread.events.is_empty()).then(|| ThreadEvents {
|
||||
thread: thread.label.clone(),
|
||||
events: std::mem::take(&mut thread.events),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn close(mut sink: Sink) {
|
||||
let _ = sink.file.write_all(b"\n]\n");
|
||||
let _ = sink.file.flush();
|
||||
/// Takes a buffer for this thread: the one of an exited thread of the same name, else a new one.
|
||||
fn join() -> Option<Mine> {
|
||||
let mut slot = RUN.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let run = slot.as_mut()?;
|
||||
let name = std::thread::current()
|
||||
.name()
|
||||
.unwrap_or("unnamed")
|
||||
.to_owned();
|
||||
let of_name =
|
||||
|t: &Arc<Mutex<Thread>>| t.lock().unwrap_or_else(PoisonError::into_inner).name == name;
|
||||
let thread = match run
|
||||
.threads
|
||||
.iter()
|
||||
.find(|t| Arc::strong_count(t) == 1 && of_name(t))
|
||||
{
|
||||
Some(exited) => Arc::clone(exited),
|
||||
None => {
|
||||
let same = run.threads.iter().filter(|t| of_name(t)).count();
|
||||
let label = if same == 0 {
|
||||
name.clone()
|
||||
} else {
|
||||
format!("{name} #{}", same + 1)
|
||||
};
|
||||
let thread = Arc::new(Mutex::new(Thread {
|
||||
name,
|
||||
label,
|
||||
events: Vec::new(),
|
||||
}));
|
||||
run.threads.push(Arc::clone(&thread));
|
||||
thread
|
||||
}
|
||||
};
|
||||
Some(Mine {
|
||||
generation: GENERATION.load(Ordering::Relaxed),
|
||||
origin: run.origin,
|
||||
thread,
|
||||
})
|
||||
}
|
||||
|
||||
/// A span of this thread from `started`, `ms` long.
|
||||
pub(super) fn span(name: &'static str, started: Instant, ms: f32) {
|
||||
/// A span of this thread from `started`, `elapsed` long, inside `depth` others.
|
||||
pub(super) fn span(name: &'static str, started: Instant, elapsed: Duration, depth: u8) {
|
||||
if !on() {
|
||||
return;
|
||||
}
|
||||
let tid = THREAD.with(|t| {
|
||||
if t.get() == 0 {
|
||||
t.set(NEXT_THREAD.fetch_add(1, Ordering::Relaxed));
|
||||
let generation = GENERATION.load(Ordering::Relaxed);
|
||||
let _ = MINE.try_with(|mine| {
|
||||
let mut mine = mine.borrow_mut();
|
||||
if mine.as_ref().is_none_or(|m| m.generation != generation) {
|
||||
*mine = join();
|
||||
}
|
||||
let Some(mine) = mine.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let event = Event {
|
||||
name,
|
||||
start_us: started.saturating_duration_since(mine.origin).as_micros() as u64,
|
||||
dur_us: elapsed.as_micros().min(u128::from(u32::MAX)) as u32,
|
||||
depth,
|
||||
};
|
||||
let mut thread = mine.thread.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if thread.events.len() < THREAD_EVENTS_MAX {
|
||||
thread.events.push(event);
|
||||
}
|
||||
t.get()
|
||||
});
|
||||
let trace = TRACES.load(Ordering::Relaxed);
|
||||
let mut slot = SINK.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let Some(sink) = slot.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if NAMED_IN.with(|n| n.replace(trace)) != trace {
|
||||
let name = std::thread::current()
|
||||
.name()
|
||||
.unwrap_or("unnamed")
|
||||
.replace(['"', '\\'], "_");
|
||||
sink.event(format_args!(
|
||||
r#""ph":"M","name":"thread_name","tid":{tid},"args":{{"name":"{name}"}}"#
|
||||
));
|
||||
}
|
||||
let ts = started.saturating_duration_since(sink.origin).as_secs_f64() * 1e6;
|
||||
sink.event(format_args!(
|
||||
r#""ph":"X","name":"{name}","tid":{tid},"ts":{ts:.3},"dur":{:.3}"#,
|
||||
f64::from(ms) * 1e3
|
||||
));
|
||||
let now = Instant::now();
|
||||
if now.duration_since(sink.flushed) >= FLUSH_EVERY {
|
||||
let _ = sink.file.flush();
|
||||
sink.flushed = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -654,6 +663,46 @@ mod tests {
|
||||
SWITCH.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Samples taken inside a named span carry its id, with spans off, and the span is left after it.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn samples_inside_a_span_carry_its_id() {
|
||||
use horizon_threads::{sampler, work};
|
||||
use std::time::{Duration, Instant};
|
||||
let worker = std::thread::spawn(|| {
|
||||
assert!(sampler::sampled_thread("spans-id-test"));
|
||||
let config = sampler::Config {
|
||||
hz: 1000,
|
||||
..Default::default()
|
||||
};
|
||||
let session = sampler::start(config, |n| n == "spans-id-test").unwrap();
|
||||
let busy = |ms| {
|
||||
let until = Instant::now() + Duration::from_millis(ms);
|
||||
while Instant::now() < until {
|
||||
std::hint::black_box(0);
|
||||
}
|
||||
};
|
||||
super::time("spans_id_test_named", || busy(60));
|
||||
assert_eq!(sampler::span(), 0);
|
||||
busy(60);
|
||||
let mut spans = Vec::new();
|
||||
session.stop(|_, r| spans.push(r.ctx.span));
|
||||
spans
|
||||
});
|
||||
let spans = worker.join().unwrap();
|
||||
let inside = |s: &u16| work::name(work::Space::Span, *s) == Some("spans_id_test_named");
|
||||
let (named, plain) = (
|
||||
spans.iter().filter(|s| inside(s)).count(),
|
||||
spans.iter().filter(|s| **s == 0).count(),
|
||||
);
|
||||
assert!(
|
||||
named > 30 && plain > 30,
|
||||
"{named} named, {plain} outside of {} samples",
|
||||
spans.len()
|
||||
);
|
||||
assert_eq!(named + plain, spans.len(), "no other span id");
|
||||
}
|
||||
|
||||
/// A slow tick's notes are its slowest steps, slowest first; short steps and steps on a
|
||||
/// thread that records no spans leave none.
|
||||
#[test]
|
||||
@@ -680,8 +729,7 @@ mod tests {
|
||||
assert!(super::take_notes().is_empty(), "taking them clears them");
|
||||
}
|
||||
|
||||
/// Spans of one name add up; a span the thread spent mostly waiting
|
||||
/// shows its CPU time after the wall time, short ones are left out.
|
||||
/// Spans of one name add up, the slowest first; a span that waited keeps its small CPU time.
|
||||
#[test]
|
||||
fn spans_sum_by_name_and_show_waits() {
|
||||
super::SPANS.with(|s| {
|
||||
@@ -723,7 +771,6 @@ mod tests {
|
||||
}
|
||||
);
|
||||
assert_eq!((spans[1].name, spans[1].ms), ("tick", 5.0));
|
||||
assert_eq!(super::describe(&spans), "submit 30.0/0.2, tick 5.0");
|
||||
assert!(super::take().is_empty());
|
||||
}
|
||||
|
||||
@@ -843,6 +890,22 @@ mod tests {
|
||||
assert!(spans[0].ms >= 2.0);
|
||||
}
|
||||
|
||||
/// A span that ran during a lap belongs inside that lap, and not inside the next one.
|
||||
#[test]
|
||||
fn a_span_during_a_lap_nests_under_that_lap() {
|
||||
let _switch = spans_switch();
|
||||
let _ = super::take_raw();
|
||||
super::enable(true);
|
||||
let mut laps = super::laps();
|
||||
super::time("inner", || {});
|
||||
laps.lap("first");
|
||||
laps.lap("second");
|
||||
super::enable(false);
|
||||
let spans = super::take_raw();
|
||||
let depths: Vec<_> = spans.iter().map(|s| (s.name, s.depth)).collect();
|
||||
assert_eq!(depths, [("inner", 1), ("first", 0), ("second", 0)]);
|
||||
}
|
||||
|
||||
/// The thread's CPU clock moves with work done on it, not with time
|
||||
/// spent asleep.
|
||||
#[cfg(any(unix, all(windows, target_arch = "x86_64")))]
|
||||
@@ -865,4 +928,74 @@ mod tests {
|
||||
assert!(slept.as_millis() < 25, "{slept:?} while asleep");
|
||||
assert!(worked.as_millis() >= 10, "{worked:?} while busy");
|
||||
}
|
||||
|
||||
/// Each thread's finished spans reach the timeline with their depth and nesting in time; threads alive together under one name get labels of their own, ones after another share a lane.
|
||||
#[test]
|
||||
fn the_timeline_keeps_each_threads_spans_with_their_depth_and_place_in_time() {
|
||||
use super::timeline;
|
||||
use std::sync::{Arc, Barrier};
|
||||
use std::time::{Duration, Instant};
|
||||
let _switch = spans_switch();
|
||||
assert!(timeline::start(Instant::now()));
|
||||
assert!(
|
||||
!timeline::start(Instant::now()),
|
||||
"the process has one timeline"
|
||||
);
|
||||
let spawn = |name: &str, alive_with: Option<Arc<Barrier>>| {
|
||||
std::thread::Builder::new()
|
||||
.name(name.into())
|
||||
.spawn(move || {
|
||||
super::time("outer", || {
|
||||
std::thread::sleep(Duration::from_millis(4));
|
||||
super::time("inner", || std::thread::sleep(Duration::from_millis(3)));
|
||||
});
|
||||
if let Some(barrier) = alive_with {
|
||||
barrier.wait();
|
||||
}
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
spawn("tl-solo", None).join().unwrap();
|
||||
let together = Arc::new(Barrier::new(2));
|
||||
let twins = [
|
||||
spawn("tl-twin", Some(together.clone())),
|
||||
spawn("tl-twin", Some(together)),
|
||||
];
|
||||
for twin in twins {
|
||||
twin.join().unwrap();
|
||||
}
|
||||
spawn("tl-seq", None).join().unwrap();
|
||||
spawn("tl-seq", None).join().unwrap();
|
||||
let mut threads = timeline::finish();
|
||||
threads.retain(|t| t.thread.starts_with("tl-"));
|
||||
threads.sort_by(|a, b| a.thread.cmp(&b.thread));
|
||||
let labels: Vec<_> = threads.iter().map(|t| t.thread.as_str()).collect();
|
||||
assert_eq!(labels, ["tl-seq", "tl-solo", "tl-twin", "tl-twin #2"]);
|
||||
for thread in &threads {
|
||||
let runs = if thread.thread == "tl-seq" { 2 } else { 1 };
|
||||
assert_eq!(thread.events.len(), 2 * runs, "{thread:?}");
|
||||
for pair in thread.events.chunks(2) {
|
||||
let [inner, outer] = pair else { unreachable!() };
|
||||
assert_eq!((inner.name, inner.depth), ("inner", 1));
|
||||
assert_eq!((outer.name, outer.depth), ("outer", 0));
|
||||
assert!(
|
||||
inner.dur_us >= 3_000 && outer.dur_us >= 7_000,
|
||||
"{inner:?} {outer:?}"
|
||||
);
|
||||
assert!(inner.start_us >= outer.start_us + 4_000);
|
||||
assert!(
|
||||
inner.start_us + u64::from(inner.dur_us)
|
||||
<= outer.start_us + u64::from(outer.dur_us)
|
||||
);
|
||||
}
|
||||
}
|
||||
let seq = threads.iter().find(|t| t.thread == "tl-seq").unwrap();
|
||||
assert!(seq.events[2].start_us >= seq.events[1].start_us + u64::from(seq.events[1].dur_us));
|
||||
|
||||
spawn("tl-after", None).join().unwrap();
|
||||
assert!(
|
||||
timeline::drain().is_empty(),
|
||||
"a stopped timeline records nothing"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -478,9 +478,9 @@ impl Sprites {
|
||||
let metadata = match horizon_core::prepared_files::read_to_string(
|
||||
&path.with_extension("png.mcmeta"),
|
||||
) {
|
||||
Ok(text) => Some(serde_json::from_str::<
|
||||
Ok(text) => Some(crate::packs::first_json::<
|
||||
BTreeMap<String, Box<serde_json::value::RawValue>>,
|
||||
>(&text)?),
|
||||
>(text.as_bytes())?),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
@@ -589,9 +589,9 @@ impl Sprites {
|
||||
let metadata = match horizon_core::prepared_files::read_to_string(
|
||||
&path.with_extension("png.mcmeta"),
|
||||
) {
|
||||
Ok(text) => Some(serde_json::from_str::<
|
||||
Ok(text) => Some(crate::packs::first_json::<
|
||||
BTreeMap<String, Box<serde_json::value::RawValue>>,
|
||||
>(&text)?),
|
||||
>(text.as_bytes())?),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
|
||||
@@ -28,37 +28,7 @@ pub const IDLE_RELEASE: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
/// Worker CPU time and job counts by kind, for benchmarks
|
||||
/// (`examples/gen_light_bench.rs`). Off unless enabled: a relaxed load per
|
||||
/// job then.
|
||||
pub mod job_stats {
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
pub const KINDS: [&str; 7] = [
|
||||
"load", "generate", "decorate", "finish", "light", "mesh", "other",
|
||||
];
|
||||
static ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
static NANOS: [AtomicU64; 7] = [const { AtomicU64::new(0) }; 7];
|
||||
static COUNT: [AtomicU64; 7] = [const { AtomicU64::new(0) }; 7];
|
||||
pub fn enable(on: bool) {
|
||||
ENABLED.store(on, Ordering::Relaxed);
|
||||
}
|
||||
pub(super) fn enabled() -> bool {
|
||||
ENABLED.load(Ordering::Relaxed)
|
||||
}
|
||||
pub(super) fn add(kind: usize, nanos: u64) {
|
||||
NANOS[kind].fetch_add(nanos, Ordering::Relaxed);
|
||||
COUNT[kind].fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
/// (kind, nanoseconds, count) since the last call; resets them.
|
||||
pub fn take() -> Vec<(&'static str, u64, u64)> {
|
||||
(0..KINDS.len())
|
||||
.map(|i| {
|
||||
(
|
||||
KINDS[i],
|
||||
NANOS[i].swap(0, Ordering::Relaxed),
|
||||
COUNT[i].swap(0, Ordering::Relaxed),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
pub mod job_stats;
|
||||
/// A chunk under generation, opaque to the streamer.
|
||||
pub type Proto = Box<dyn std::any::Any + Send>;
|
||||
/// Chunk generation in vanilla's stages: a neighbour-independent proto chunk
|
||||
@@ -506,8 +476,8 @@ pub struct Streaming {
|
||||
decoration_order: DecorationOrder,
|
||||
order_area: Option<((i32, i32), i32)>,
|
||||
/// What the queued jobs' priorities were computed from (`requeue`): the
|
||||
/// centre, the band, and the extra areas.
|
||||
queue_basis: (((i32, i32), Option<Band>), Vec<((i32, i32), i32)>),
|
||||
/// centre, the band, the extra areas, and the forced chunks.
|
||||
queue_basis: forced_ring::Basis,
|
||||
finishing: FxHashSet<(i32, i32)>,
|
||||
decorates: bool,
|
||||
/// `ChunkGenerator::decorates_beside_final`.
|
||||
@@ -1070,7 +1040,10 @@ pub struct WorkerContext {
|
||||
pub light: Arc<LightTables>,
|
||||
}
|
||||
mod bounded;
|
||||
mod queue_census;
|
||||
pub use queue_census::QueueCensus;
|
||||
mod forced_cover;
|
||||
mod forced_ring;
|
||||
mod relight_now;
|
||||
use bounded::nearest_first;
|
||||
use forced_cover::ForcedCover;
|
||||
@@ -1354,11 +1327,9 @@ impl Streaming {
|
||||
/// render distance 32 nothing was decorated until every proto chunk had
|
||||
/// been generated.
|
||||
fn requeue(&mut self) {
|
||||
let basis = (self.center, self.band);
|
||||
if self.queue_basis.0 == basis && self.queue_basis.1 == self.extra {
|
||||
if !forced_ring::basis_changed(self) {
|
||||
return;
|
||||
}
|
||||
self.queue_basis = (basis, self.extra.clone());
|
||||
let mut queue = self.shared.queue.lock().unwrap();
|
||||
rank_again(&mut queue, |x, z| self.distance(x, z));
|
||||
}
|
||||
@@ -1467,7 +1438,7 @@ impl Streaming {
|
||||
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));
|
||||
world.queue_entity_nbt((chunk.x, chunk.z), entities);
|
||||
}
|
||||
self.generated_total += 1;
|
||||
// A chunk saved with its light needs no light job; its unlit
|
||||
@@ -1684,6 +1655,7 @@ impl Streaming {
|
||||
&mut self,
|
||||
world: &mut World,
|
||||
) -> (Vec<((i32, i32, i32), SectionMesh)>, Vec<Arc<Chunk>>) {
|
||||
let mut hz_laps = crate::spans::laps();
|
||||
let mut uploads = Vec::new();
|
||||
if self.paused {
|
||||
return (uploads, Vec::new());
|
||||
@@ -1709,6 +1681,7 @@ impl Streaming {
|
||||
self.scan_age = if sweep { 0 } else { self.scan_age + 1 };
|
||||
self.requeue();
|
||||
self.relight_edits(world);
|
||||
hz_laps.lap("st_relight_edits");
|
||||
let deferred = std::mem::take(&mut self.deferred);
|
||||
let received = self.shared.done.take();
|
||||
// Meshes made stale meanwhile, freed with the uploaded ones (`free`).
|
||||
@@ -1758,6 +1731,7 @@ impl Streaming {
|
||||
}
|
||||
}
|
||||
}
|
||||
hz_laps.lap("st_results");
|
||||
self.free(stale);
|
||||
let mut removed = Vec::new();
|
||||
if sweep {
|
||||
@@ -1828,6 +1802,7 @@ impl Streaming {
|
||||
}
|
||||
self.save_checked_out(written);
|
||||
}
|
||||
hz_laps.lap("st_sweep");
|
||||
let (protos, finishing) = (&self.protos, &self.finishing);
|
||||
self.decorated.retain(|k| {
|
||||
protos.contains_key(k) || finishing.contains(k) || world.chunks.contains_key(k)
|
||||
@@ -1859,6 +1834,7 @@ impl Streaming {
|
||||
self.submit(Some((x, z)), 0, Job::Finish(proto));
|
||||
}
|
||||
}
|
||||
hz_laps.lap("st_finalize");
|
||||
// Decorate chunks whose 3x3 proto neighbourhood is available, in
|
||||
// vanilla's order: decoration is order dependent where write regions
|
||||
// overlap (see `DecorationOrder`).
|
||||
@@ -1984,7 +1960,9 @@ impl Streaming {
|
||||
self.submit(Some((x, z)), 1, Job::Decorate(x, z, region, finals));
|
||||
}
|
||||
}
|
||||
hz_laps.lap("st_decorate");
|
||||
self.schedule_generation(world, workers);
|
||||
hz_laps.lap("st_generate");
|
||||
// Lighting once all neighbours exist, nearest first and only as
|
||||
// much of it as the workers can chew. Vanilla's light engine also
|
||||
// hands a bounded queue to its workers and reschedules what did
|
||||
@@ -2020,6 +1998,7 @@ impl Streaming {
|
||||
Job::Light(x, z, region, self.sequence, self.sky_light),
|
||||
);
|
||||
}
|
||||
hz_laps.lap("st_light");
|
||||
// A server draws nothing: its sections are never meshed.
|
||||
if !self.mesh {
|
||||
world.dirty_sections.clear();
|
||||
@@ -2060,6 +2039,7 @@ impl Streaming {
|
||||
!back
|
||||
});
|
||||
}
|
||||
hz_laps.lap("st_waiting");
|
||||
// Column by column: the checks below hold for all of a column's
|
||||
// sections, and columns waiting for light cost one look per frame.
|
||||
columns.extend(world.dirty_sections.columns());
|
||||
@@ -2092,6 +2072,7 @@ impl Streaming {
|
||||
}
|
||||
candidates.push((self.distance(x, z), x, z, sections));
|
||||
}
|
||||
hz_laps.lap("st_candidates");
|
||||
// Nearest first, and only as many sections as the workers can chew.
|
||||
// Every ready column used to be submitted at once, so at a wide
|
||||
// render distance the queue — and `meshing`, and each job's 3x3
|
||||
@@ -2862,7 +2843,7 @@ impl Ranges<'_> {
|
||||
_ => own,
|
||||
}
|
||||
}
|
||||
fn distance(&self, x: i32, z: i32) -> i64 {
|
||||
fn player_distance(&self, x: i32, z: i32) -> i64 {
|
||||
let d = |c: (i32, i32)| {
|
||||
let (dx, dz) = (i64::from(x - c.0), i64::from(z - c.1));
|
||||
dx * dx + dz * dz
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
//! How a forced chunk's ring is ranked (#13175).
|
||||
use super::{Band, FORCED_TERRAIN_RING, Ranges, Streaming};
|
||||
use std::collections::HashSet;
|
||||
|
||||
type Chunks = HashSet<(i32, i32)>;
|
||||
|
||||
/// What queued jobs were ranked from (`Streaming::requeue`): the centre, the
|
||||
/// band, the extra areas, and the forced chunks.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Basis {
|
||||
player: ((i32, i32), Option<Band>),
|
||||
extra: Vec<((i32, i32), i32)>,
|
||||
forced: (Chunks, Chunks),
|
||||
}
|
||||
|
||||
/// Whether the queued jobs' ranking is out of date; it is brought up to date.
|
||||
pub(super) fn basis_changed(s: &mut Streaming) -> bool {
|
||||
let b = &mut s.queue_basis;
|
||||
let same = b.player == (s.center, s.band)
|
||||
&& b.extra == s.extra
|
||||
&& b.forced.0 == s.forced
|
||||
&& b.forced.1 == s.mod_forced;
|
||||
if !same {
|
||||
b.player = (s.center, s.band);
|
||||
b.extra.clone_from(&s.extra);
|
||||
b.forced.0.clone_from(&s.forced);
|
||||
b.forced.1.clone_from(&s.mod_forced);
|
||||
}
|
||||
!same
|
||||
}
|
||||
|
||||
impl Ranges<'_> {
|
||||
/// `player_distance`, or the nearer one to a forced chunk whose ring reaches
|
||||
/// (x, z): see `forced_distance`.
|
||||
pub(super) fn distance(&self, x: i32, z: i32) -> i64 {
|
||||
self.player_distance(x, z).min(self.forced_distance(x, z))
|
||||
}
|
||||
|
||||
/// Distance (squared) to the nearest forced chunk whose ring reaches
|
||||
/// (x, z), or `i64::MAX`. A forced chunk's ring is loaded with its ticket
|
||||
/// (levels 32 to 35 around the FORCED ticket's 31), so the 3x3 it needs to
|
||||
/// tick is ranked around it, not by how far it lies from the player: vanilla
|
||||
/// ticks the chunk on the first tick (`ChunkMap.prepareTickingChunk`).
|
||||
pub(super) fn forced_distance(&self, x: i32, z: i32) -> i64 {
|
||||
const SIDE: usize = (2 * FORCED_TERRAIN_RING + 1) as usize;
|
||||
let held = self.forced.len() + self.mod_forced.len();
|
||||
if held == 0 {
|
||||
return i64::MAX;
|
||||
}
|
||||
let d = |f: (i32, i32)| {
|
||||
let (dx, dz) = (i64::from(x - f.0), i64::from(z - f.1));
|
||||
dx * dx + dz * dz
|
||||
};
|
||||
if held <= SIDE * SIDE {
|
||||
return self
|
||||
.forced_chunks()
|
||||
.filter(|f| (x - f.0).abs().max((z - f.1).abs()) <= FORCED_TERRAIN_RING)
|
||||
.map(|&f| d(f))
|
||||
.min()
|
||||
.unwrap_or(i64::MAX);
|
||||
}
|
||||
// Many forced chunks: look at the columns around (x, z) instead.
|
||||
(-FORCED_TERRAIN_RING..=FORCED_TERRAIN_RING)
|
||||
.flat_map(|dz| (-FORCED_TERRAIN_RING..=FORCED_TERRAIN_RING).map(move |dx| (dx, dz)))
|
||||
.map(|(dx, dz)| (x + dx, z + dz))
|
||||
.filter(|f| self.is_forced(f.0, f.1))
|
||||
.map(d)
|
||||
.min()
|
||||
.unwrap_or(i64::MAX)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::{GenerationInputs, GenerationScan, NO_CHUNKS};
|
||||
use super::*;
|
||||
use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use crate::world::World;
|
||||
|
||||
/// A forced chunk's ring is loaded with its ticket, so it ranks around
|
||||
/// the forced chunk and not by how far it lies from the player. Vanilla
|
||||
/// then ticks the chunk on the first tick after a reload
|
||||
/// (`ChunkMap.prepareTickingChunk`: its 3x3 is full at once); ranked from
|
||||
/// the player at the origin, a ring 4096 blocks out arrived west/north
|
||||
/// first, over tens of ticks.
|
||||
#[test]
|
||||
fn a_forced_chunks_ring_is_ranked_around_it() {
|
||||
let ranges = |forced| Ranges {
|
||||
center: (0, 0),
|
||||
radius: 2,
|
||||
extra: &[],
|
||||
forced,
|
||||
mod_forced: &NO_CHUNKS,
|
||||
cover: None,
|
||||
forced_cover: None,
|
||||
band: None,
|
||||
};
|
||||
let forced: HashSet<(i32, i32)> = [(256, 256)].into();
|
||||
let r = ranges(&forced);
|
||||
// Squared distance to the forced chunk, by hand: (255, 254) is
|
||||
// one west and two north of it.
|
||||
assert_eq!(r.priority(256, 256), 0);
|
||||
assert_eq!(r.priority(255, 254), 1 + 4);
|
||||
assert_eq!(r.priority(260, 252), 16 + 16);
|
||||
// Past the fourth ring (level 36 and up) the player's distance rules,
|
||||
// as does the whole map without a forced chunk.
|
||||
assert_eq!(r.priority(261, 256), 261 * 261 + 256 * 256);
|
||||
let none = HashSet::new();
|
||||
assert_eq!(ranges(&none).priority(255, 254), 255 * 255 + 254 * 254);
|
||||
// Many forced chunks (the neighbour lookups) agree with few.
|
||||
let many: HashSet<(i32, i32)> = (0..100).map(|i| (256 + 20 * i, 256)).collect();
|
||||
assert_eq!(ranges(&many).priority(255, 254), 5);
|
||||
assert_eq!(ranges(&many).priority(276, 258), 4);
|
||||
// The generation plan fetches the 3x3 the chunk ticks with first.
|
||||
let world = World::new(horizon_world::registry::BlockStateId(0), 0);
|
||||
let (generating, finishing) = (FxHashSet::default(), FxHashSet::default());
|
||||
let protos = FxHashMap::default();
|
||||
let input = GenerationInputs {
|
||||
ranges: ranges(&forced),
|
||||
world: &world,
|
||||
generating: &generating,
|
||||
protos: &protos,
|
||||
finishing: &finishing,
|
||||
decorates: true,
|
||||
remote: false,
|
||||
};
|
||||
let candidates = GenerationScan::default()
|
||||
.candidates(input, usize::MAX)
|
||||
.expect("a scan");
|
||||
let ring: Vec<(i32, i32)> = candidates
|
||||
.iter()
|
||||
.map(|&(_, x, z)| (x, z))
|
||||
.filter(|&(x, z)| (x - 256).abs().max((z - 256).abs()) <= FORCED_TERRAIN_RING)
|
||||
.collect();
|
||||
assert_eq!(ring.len(), 81);
|
||||
assert_eq!(ring[0], (256, 256));
|
||||
let d = |(x, z): (i32, i32)| (x - 256).pow(2) + (z - 256).pow(2);
|
||||
for pair in ring.windows(2) {
|
||||
assert!(
|
||||
d(pair[0]) <= d(pair[1]),
|
||||
"{:?} before {:?}",
|
||||
pair[0],
|
||||
pair[1]
|
||||
);
|
||||
}
|
||||
let first_nine: HashSet<(i32, i32)> = ring[..9].iter().copied().collect();
|
||||
let expected: HashSet<(i32, i32)> = (-1..=1)
|
||||
.flat_map(|dx| (-1..=1).map(move |dz| (256 + dx, 256 + dz)))
|
||||
.collect();
|
||||
assert_eq!(first_nine, expected);
|
||||
}
|
||||
|
||||
/// Jobs queued before a chunk was forced follow it once it is
|
||||
/// (`Streaming::requeue` ranks them again).
|
||||
#[test]
|
||||
fn queued_jobs_follow_a_forced_chunk() {
|
||||
use super::super::{Job, Queued, rank_again};
|
||||
use std::collections::BinaryHeap;
|
||||
let job = |x, z, order| Queued {
|
||||
priority: 0,
|
||||
at: Some((x, z)),
|
||||
bias: 0,
|
||||
order,
|
||||
job: Job::Generate(x, z),
|
||||
};
|
||||
let first_after = |forced: &HashSet<(i32, i32)>| {
|
||||
let ranges = Ranges {
|
||||
center: (0, 0),
|
||||
radius: 2,
|
||||
extra: &[],
|
||||
forced,
|
||||
mod_forced: &NO_CHUNKS,
|
||||
cover: None,
|
||||
forced_cover: None,
|
||||
band: None,
|
||||
};
|
||||
// (255, 254) lies nearer the origin than (258, 256), the
|
||||
// forced chunk's neighbour two chunks east.
|
||||
let mut queue = BinaryHeap::from(vec![job(255, 254, 1), job(258, 256, 2)]);
|
||||
rank_again(&mut queue, |x, z| ranges.priority(x, z));
|
||||
queue.pop().and_then(|q| q.at)
|
||||
};
|
||||
assert_eq!(first_after(&HashSet::new()), Some((255, 254)));
|
||||
assert_eq!(first_after(&[(256, 256)].into()), Some((258, 256)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
pub const KINDS: [&str; 7] = [
|
||||
"load", "generate", "decorate", "finish", "light", "mesh", "other",
|
||||
];
|
||||
static ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
static NANOS: [AtomicU64; 7] = [const { AtomicU64::new(0) }; 7];
|
||||
static COUNT: [AtomicU64; 7] = [const { AtomicU64::new(0) }; 7];
|
||||
pub fn enable(on: bool) {
|
||||
ENABLED.store(on, Ordering::Relaxed);
|
||||
}
|
||||
pub(super) fn enabled() -> bool {
|
||||
ENABLED.load(Ordering::Relaxed)
|
||||
}
|
||||
pub(super) fn add(kind: usize, nanos: u64) {
|
||||
NANOS[kind].fetch_add(nanos, Ordering::Relaxed);
|
||||
COUNT[kind].fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
/// (kind, nanoseconds, count) since the last call; resets them.
|
||||
pub fn take() -> Vec<(&'static str, u64, u64)> {
|
||||
(0..KINDS.len())
|
||||
.map(|i| {
|
||||
(
|
||||
KINDS[i],
|
||||
NANOS[i].swap(0, Ordering::Relaxed),
|
||||
COUNT[i].swap(0, Ordering::Relaxed),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//! What the streamer's queues hold, for `horizon-ctl memory` and captures.
|
||||
use super::{Queued, Streaming};
|
||||
|
||||
/// What the streamer's work queues hold, each as (entries, estimated bytes).
|
||||
#[derive(Debug, Default)]
|
||||
pub struct QueueCensus {
|
||||
/// Jobs queued and chunks generating.
|
||||
pub generation: (usize, usize),
|
||||
/// Chunks being lit and the edit log their jobs replay.
|
||||
pub light: (usize, usize),
|
||||
}
|
||||
|
||||
impl Streaming {
|
||||
/// The queues' lengths and the bytes of their tables, from capacities. For `horizon-ctl memory`.
|
||||
pub fn queue_census(&self) -> QueueCensus {
|
||||
use std::mem::size_of;
|
||||
let table = |capacity: usize, entry: usize| capacity * (entry + 1);
|
||||
let queued = {
|
||||
let queue = self.shared.queue.lock().unwrap();
|
||||
(queue.len(), queue.capacity() * size_of::<Queued>())
|
||||
};
|
||||
QueueCensus {
|
||||
generation: (
|
||||
queued.0 + self.generating.len(),
|
||||
queued.1 + table(self.generating.capacity(), size_of::<(i32, i32)>()),
|
||||
),
|
||||
light: (
|
||||
self.lighting.len(),
|
||||
table(self.lighting.capacity(), size_of::<((i32, i32), u64)>())
|
||||
+ table(
|
||||
self.light_snapshots.capacity(),
|
||||
size_of::<((i32, i32), u64)>(),
|
||||
)
|
||||
+ self.light_log.capacity() * size_of::<(u64, (i32, i32, i32))>()
|
||||
+ self.light_scratch.capacity() * size_of::<(i64, i32, i32)>(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ struct Globals {
|
||||
// w: apply the no-pack HDR gain; HDR_WRITE_LIGHT independently keeps float alpha as coverage.
|
||||
hdr_light: vec4<f32>,
|
||||
hdr_settings: vec4<f32>,
|
||||
hdr_shares: vec4<f32>,
|
||||
hdr_gains: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||
@group(0) @binding(1) var sprites: texture_2d_array<f32>;
|
||||
@@ -788,7 +788,7 @@ fn solid_finish(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>) -> vec4<f32>
|
||||
fn solid_light(finished: vec4<f32>) -> vec4<f32> {
|
||||
if HDR_WRITE_LIGHT {
|
||||
if globals.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(finished.rgb, finished.a, globals.hdr_settings, globals.hdr_shares), 1.0);
|
||||
return vec4(hdr_written(finished.rgb, finished.a, globals.hdr_settings, globals.hdr_gains), 1.0);
|
||||
}
|
||||
return frame_stored(vec4(finished.rgb, 1.0));
|
||||
}
|
||||
@@ -870,7 +870,7 @@ fn solid_finish_at(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>, du: vec2<
|
||||
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 vec4(hdr_written(finished.rgb, mark, globals.hdr_settings, globals.hdr_gains), finished.a);
|
||||
}
|
||||
return frame_stored(finished);
|
||||
}
|
||||
@@ -1076,7 +1076,7 @@ fn sky_finished(color: vec3<f32>) -> vec4<f32> {
|
||||
let mark = 1.0 - 0.24 * globals.hdr_light.y;
|
||||
if HDR_WRITE_LIGHT {
|
||||
if globals.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(color, mark, globals.hdr_settings, globals.hdr_shares), 1.0);
|
||||
return vec4(hdr_written(color, mark, globals.hdr_settings, globals.hdr_gains), 1.0);
|
||||
}
|
||||
return frame_stored(vec4(color, 1.0));
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ pub fn read_metadata(root: &Path) -> UiMetadata {
|
||||
let Ok(text) = horizon_core::prepared_files::read_to_string(&p) else {
|
||||
continue;
|
||||
};
|
||||
let Some(nine_slice) = serde_json::from_str(&text)
|
||||
let Some(nine_slice) = crate::packs::first_json(text.as_bytes())
|
||||
.ok()
|
||||
.as_ref()
|
||||
.and_then(parse_nine_slice)
|
||||
@@ -1104,6 +1104,34 @@ pub fn tooltip_menu(
|
||||
);
|
||||
tooltip_box(batch, atlas, lines, x as f32, y as f32, w, h);
|
||||
}
|
||||
/// `BelowOrAboveWidgetTooltipPositioner.positionTooltip`: a keyboard-focused widget's tooltip.
|
||||
pub fn tooltip_focused(batch: &mut HudBatch, atlas: &HudAtlas, lines: &[Text], rect: [f32; 4]) {
|
||||
if lines.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (w, h) = tooltip_size(atlas, lines);
|
||||
let (x, y) = focused_tooltip_position(
|
||||
(batch.width as i32, batch.height as i32),
|
||||
rect.map(|v| v as i32),
|
||||
(w.ceil() as i32, h.ceil() as i32),
|
||||
);
|
||||
tooltip_box(batch, atlas, lines, x as f32, y as f32, w, h);
|
||||
}
|
||||
|
||||
/// `BelowOrAboveWidgetTooltipPositioner.positionTooltip`: below, or above when it does not fit.
|
||||
fn focused_tooltip_position(screen: (i32, i32), rect: [i32; 4], size: (i32, i32)) -> (i32, i32) {
|
||||
let [left, top, width, height] = rect;
|
||||
let mut x = left + 3;
|
||||
let mut y = top + height + 4;
|
||||
if y + size.1 + 3 > screen.1 {
|
||||
y = top - size.1 - 4;
|
||||
}
|
||||
if x + size.0 > screen.0 {
|
||||
x = (left + width - size.0 - 3).max(4);
|
||||
}
|
||||
(x, y)
|
||||
}
|
||||
|
||||
/// A widget's tooltip while the controller's focus is on it (Horizon's: vanilla has no gamepad
|
||||
/// input, and the hidden pointer mid-widget would put it over the widget).
|
||||
pub fn tooltip_under(batch: &mut HudBatch, atlas: &HudAtlas, lines: &[Text], rect: [f32; 4]) {
|
||||
@@ -1997,6 +2025,28 @@ impl EditBox {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn notes_after_a_sprites_metadata_keep_its_nine_slice() {
|
||||
// Gson stops reading after the first value, so vanilla keeps this nine slice
|
||||
// (CozyUI+'s highlighted button, #3046).
|
||||
let root = std::env::temp_dir().join(format!("horizon-ui-meta-{}", std::process::id()));
|
||||
let widget = root.join("assets/minecraft/textures/gui/sprites/widget");
|
||||
std::fs::create_dir_all(&widget).unwrap();
|
||||
std::fs::write(
|
||||
widget.join("button_highlighted.png.mcmeta"),
|
||||
"{\"gui\": {\"scaling\": {\"type\": \"nine_slice\", \"width\": 80, \"height\": 20,\n\
|
||||
\"border\": {\"left\": 34, \"top\": 8, \"right\": 34, \"bottom\": 8}}}}\n\n// 0 10\n// 1 8\n",
|
||||
)
|
||||
.unwrap();
|
||||
let metadata = read_metadata(&root);
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
let slice = metadata.0["gui/sprites/widget/button_highlighted"];
|
||||
assert_eq!(
|
||||
(slice.width, slice.height, slice.border),
|
||||
(80, 20, [34, 8, 34, 8])
|
||||
);
|
||||
}
|
||||
|
||||
/// The quads' destination rectangles and their UVs.
|
||||
fn quads(batch: &HudBatch) -> Vec<([f32; 4], [f32; 4])> {
|
||||
batch
|
||||
@@ -2614,6 +2664,33 @@ mod tests {
|
||||
assert!(!delete_words(&mut v, 1));
|
||||
assert_eq!(v, "stone");
|
||||
}
|
||||
#[test]
|
||||
fn a_keyboard_focused_tooltip_uses_vanillas_widget_edges() {
|
||||
let screen = (320, 240);
|
||||
assert_eq!(
|
||||
focused_tooltip_position(screen, [7, 160, 98, 20], (170, 40)),
|
||||
(10, 184)
|
||||
);
|
||||
// Exact fit below; one pixel taller goes above.
|
||||
assert_eq!(
|
||||
focused_tooltip_position(screen, [7, 160, 98, 20], (170, 53)),
|
||||
(10, 184)
|
||||
);
|
||||
assert_eq!(
|
||||
focused_tooltip_position(screen, [7, 160, 98, 20], (170, 54)),
|
||||
(10, 102)
|
||||
);
|
||||
// Right overflow aligns against the widget's right edge, not the screen's.
|
||||
assert_eq!(
|
||||
focused_tooltip_position(screen, [250, 100, 60, 20], (170, 40)),
|
||||
(137, 124)
|
||||
);
|
||||
assert_eq!(
|
||||
focused_tooltip_position((120, 240), [7, 160, 98, 20], (170, 40)),
|
||||
(4, 184)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pad_focused_tooltip_hangs_from_its_widgets_bottom_left() {
|
||||
let screen = (427.0, 240.0);
|
||||
|
||||
@@ -13,6 +13,8 @@ pub enum Cursor {
|
||||
#[default]
|
||||
Arrow,
|
||||
PointingHand,
|
||||
/// `CursorTypes.IBEAM`: over a text field that takes input.
|
||||
Ibeam,
|
||||
NotAllowed,
|
||||
/// `CursorTypes.RESIZE_NS`: a scroller held vertically (`MerchantScreen`).
|
||||
ResizeNs,
|
||||
@@ -55,6 +57,18 @@ pub fn slider_cursor(hovered: bool, active: bool, dragging: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `EditBox.handleCursor`: a hovered text field shows the I-beam, or the
|
||||
/// forbidden sign when it takes no input.
|
||||
pub fn text_cursor(hovered: bool, editable: bool) {
|
||||
if hovered {
|
||||
request_cursor(if editable {
|
||||
Cursor::Ibeam
|
||||
} else {
|
||||
Cursor::NotAllowed
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The cursor the frame asked for, and back to the arrow for the next one
|
||||
/// (the window shows the arrow unless a widget asks).
|
||||
pub fn take_cursor() -> Cursor {
|
||||
@@ -429,6 +443,16 @@ pub fn record_as(label: &str, [x, y, w, h]: [f32; 4], enabled: bool, kind: Kind)
|
||||
mod cursor_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_hovered_text_field_asks_for_the_i_beam() {
|
||||
let _ = take_cursor();
|
||||
text_cursor(false, true);
|
||||
assert_eq!(take_cursor(), Cursor::Arrow);
|
||||
text_cursor(true, true);
|
||||
assert_eq!(take_cursor(), Cursor::Ibeam);
|
||||
text_cursor(true, false);
|
||||
assert_eq!(take_cursor(), Cursor::NotAllowed);
|
||||
}
|
||||
#[test]
|
||||
fn a_dragged_slider_asks_for_the_horizontal_resize_cursor() {
|
||||
let _ = take_cursor();
|
||||
|
||||
@@ -9,6 +9,9 @@ use horizon_core::random::{LegacyRandom, LevelRandom};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::sync::Arc;
|
||||
|
||||
mod pending_entity;
|
||||
pub use pending_entity::{EntityLoadReason, PendingEntity};
|
||||
|
||||
mod modified;
|
||||
pub use modified::ModifiedChunks;
|
||||
|
||||
@@ -81,7 +84,7 @@ pub struct World {
|
||||
/// incrementally by streaming.
|
||||
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>)>,
|
||||
pub loaded_entities: Vec<((i32, i32), Vec<PendingEntity>)>,
|
||||
/// 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`).
|
||||
@@ -215,6 +218,12 @@ impl Weather {
|
||||
}
|
||||
}
|
||||
impl World {
|
||||
/// Queue ordinary NBT; transient transfer state is never decoded from it.
|
||||
pub fn queue_entity_nbt(&mut self, key: (i32, i32), tags: Vec<horizon_world::nbt::Tag>) {
|
||||
self.loaded_entities
|
||||
.push((key, tags.into_iter().map(PendingEntity::from).collect()));
|
||||
}
|
||||
|
||||
pub fn new(air: BlockStateId, seed: i64) -> Self {
|
||||
Self {
|
||||
shape: LevelShape::OVERWORLD,
|
||||
@@ -319,6 +328,12 @@ impl World {
|
||||
pub fn bottom_y(&self) -> i32 {
|
||||
self.layout().min_y()
|
||||
}
|
||||
/// `Entity.checkBelowWorld`'s floor, `Level.getMinY() - 64`: the level's own
|
||||
/// lowest Y (0 in the Nether and the End), not the chunks' widened column.
|
||||
/// An entity below it is out of the world.
|
||||
pub fn below_world_y(&self) -> f64 {
|
||||
f64::from(self.shape.min_y - 64)
|
||||
}
|
||||
/// One above the highest block Y the level's chunks hold.
|
||||
pub fn top_y(&self) -> i32 {
|
||||
self.layout().max_y()
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Pending entity NBT and the runtime context that must not be stored inside it.
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum EntityLoadReason {
|
||||
Ordinary,
|
||||
TeleportedTnt,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PendingEntity {
|
||||
tag: Tag,
|
||||
reason: EntityLoadReason,
|
||||
}
|
||||
|
||||
impl From<Tag> for PendingEntity {
|
||||
fn from(tag: Tag) -> Self {
|
||||
Self {
|
||||
tag,
|
||||
reason: EntityLoadReason::Ordinary,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PendingEntity {
|
||||
/// Only an in-memory TNT transfer supplies this context, never saved NBT.
|
||||
pub fn after_tnt_portal(tag: Tag) -> Self {
|
||||
Self {
|
||||
tag,
|
||||
reason: EntityLoadReason::TeleportedTnt,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_parts(self) -> (Tag, EntityLoadReason) {
|
||||
(self.tag, self.reason)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ordinary_nbt_cannot_request_transient_portal_state() {
|
||||
for marker in [0, 1] {
|
||||
let tag = Tag::compound([("horizon:tnt_used_portal", Tag::Byte(marker))]);
|
||||
let (loaded, reason) = PendingEntity::from(tag.clone()).into_parts();
|
||||
assert_eq!(loaded, tag);
|
||||
assert_eq!(reason, EntityLoadReason::Ordinary);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_swaps_and_appends_keep_each_entitys_context_and_order() {
|
||||
let ordinary = Tag::compound([("id", Tag::String("ordinary".into()))]);
|
||||
let transfer = Tag::compound([("id", Tag::String("transfer".into()))]);
|
||||
let mut batches = vec![(
|
||||
(1, 2),
|
||||
vec![
|
||||
ordinary.clone().into(),
|
||||
PendingEntity::after_tnt_portal(transfer.clone()),
|
||||
],
|
||||
)];
|
||||
let mut waiting = std::mem::take(&mut batches);
|
||||
batches.push(((3, 4), vec![ordinary.clone().into()]));
|
||||
waiting.append(&mut batches);
|
||||
assert!(batches.is_empty());
|
||||
let mut batches = waiting.into_iter();
|
||||
let (key, entities) = batches.next().unwrap();
|
||||
assert_eq!(key, (1, 2));
|
||||
let mut entities = entities.into_iter();
|
||||
assert_eq!(
|
||||
entities.next().unwrap().into_parts(),
|
||||
(ordinary.clone(), EntityLoadReason::Ordinary)
|
||||
);
|
||||
assert_eq!(
|
||||
entities.next().unwrap().into_parts(),
|
||||
(transfer, EntityLoadReason::TeleportedTnt)
|
||||
);
|
||||
assert!(entities.next().is_none());
|
||||
let (key, entities) = batches.next().unwrap();
|
||||
assert_eq!(key, (3, 4));
|
||||
assert_eq!(
|
||||
entities.into_iter().next().unwrap().into_parts(),
|
||||
(ordinary, EntityLoadReason::Ordinary)
|
||||
);
|
||||
assert!(batches.next().is_none());
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ horizon-hooks = { path = "../horizon-hooks" }
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
horizon-perf-format = { path = "../horizon-perf-format" }
|
||||
horizon-hdr = { path = "../horizon-hdr" }
|
||||
horizon-world = { path = "../horizon-world" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
@@ -73,6 +74,8 @@ horizon-theme = { path = "../horizon-theme" }
|
||||
# anonymous "I'm playing" ping is the Horizon layer's (`horizon-heartbeat`, in the official executable).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
# The machine and process probes (the dedicated server's performance report).
|
||||
horizon-system = { path = "../horizon-system" }
|
||||
crc32fast = "1"
|
||||
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
|
||||
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
|
||||
@@ -132,7 +135,9 @@ friends = ["dep:horizon-friends"]
|
||||
rng-trace = ["horizon-core/rng-trace", "horizon-client-lib/rng-trace"]
|
||||
# Rust's allocations go to the system heap instead of mimalloc, where heap profilers that replace
|
||||
# malloc (heaptrack, valgrind's massif) see them. For profiling only: mimalloc is faster.
|
||||
system-allocator = []
|
||||
system-allocator = ["horizon-threads/system-allocator"]
|
||||
# mimalloc that counts each thread's allocations and frees (`stats` replies with `alloc`).
|
||||
alloc-count = ["horizon-threads/alloc-count"]
|
||||
# mimalloc with a tag on each block: `horizon-ctl memory` lists the live heap by startup step and
|
||||
# thread (`heap_by_tag_mb`). For measuring only: each block costs 16 bytes more.
|
||||
alloc-census = ["horizon-threads/alloc-census"]
|
||||
@@ -176,5 +181,6 @@ workspace = true
|
||||
[dev-dependencies]
|
||||
horizon-client-render = { path = "../horizon-client-render", features = ["test-support"] }
|
||||
horizon-items = { path = "../horizon-items", features = ["test-support"] }
|
||||
horizon-perf = { path = "../horizon-perf" }
|
||||
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
|
||||
@@ -316,6 +316,7 @@
|
||||
"horizon.multiplayer.invite_code": "Invite Code",
|
||||
"horizon.multiplayer.relay": "Relay (optional)",
|
||||
"Preset": "Preset",
|
||||
"Horizon Preset": "Horizon Preset",
|
||||
"LOD Distance": "LOD Distance",
|
||||
"Far Detail": "Far Detail",
|
||||
"Cave Culling": "Cave Culling",
|
||||
@@ -707,6 +708,9 @@
|
||||
"Pick a shader pack. Picking the one in use turns shaders off.": "Pick a shader pack. Picking the one in use turns shaders off.",
|
||||
"Changed options apply with Apply, or when you leave.": "Changed options apply with Apply, or when you leave.",
|
||||
"Pack settings": "Pack settings",
|
||||
"Whole pack": "Whole pack",
|
||||
"Reset all": "Reset all",
|
||||
"Every option is at its default already.": "Every option is at its default already.",
|
||||
"Built-in settings": "Built-in settings",
|
||||
"%s settings": "%s settings",
|
||||
"Settings made by the pack's author: they change only this pack.": "Settings made by the pack's author: they change only this pack.",
|
||||
@@ -1129,6 +1133,34 @@
|
||||
"horizon.recipe_transfer.no_room": "Inventory full",
|
||||
"horizon.recipe_transfer.unavailable": "Recipe does not fit this menu",
|
||||
"horizon.recipe_transfer.unsupported": "Server requires a known recipe",
|
||||
"horizon.debug.pass_detail.title": "Horizon pass detail (%s)",
|
||||
"horizon.debug.pass_detail.inside_passes": "timestamps inside passes",
|
||||
"horizon.debug.pass_detail.separate_passes": "separate passes, adds GPU time",
|
||||
"horizon.debug.pass_detail.part": "%s › %s: %s ms",
|
||||
"horizon.debug.pass_detail.counters": "%s: %s fragments, %s per pixel, %s vertices, %s of %s triangles kept",
|
||||
"horizon.debug.pass_detail.no_counters": "GPU counters: Vulkan only",
|
||||
"horizon.perf.started": "Profiling the client and server. %s",
|
||||
"horizon.perf.uploading": "Uploading the profile…",
|
||||
"horizon.perf.shared": "Shared: %s %s",
|
||||
"horizon.perf.upload_failed": "Could not share the profile: %s. %s",
|
||||
"horizon.perf.upload_interrupted": "the upload worker stopped",
|
||||
"horizon.perf.unavailable": "Opening and sharing profiles is not available in this build.",
|
||||
"horizon.perf.share_missing": "No profile saved in this session yet. %s",
|
||||
"horizon.perf.saved": "Profile saved (%s s). %s",
|
||||
"horizon.perf.open_button": "[Open]",
|
||||
"horizon.perf.open_hover": "Open this profile in your browser. It is served once to your own browser and goes nowhere else.",
|
||||
"horizon.perf.share_button": "[Share]",
|
||||
"horizon.perf.share_hover": "Upload this profile and get a link to share. Nothing is uploaded until you click this.",
|
||||
"horizon.perf.opened": "Opened in your browser",
|
||||
"horizon.perf.open_failed": "Could not open the profile: %s. %s",
|
||||
"horizon.perf.not_found": "No saved profile named %s.",
|
||||
"horizon.perf.error": "Profiler: %s",
|
||||
"horizon.perf.not_horizon": "this server isn't running Horizon",
|
||||
"horizon.perf.denied": "a server profile needs operator level 4",
|
||||
"horizon.perf.already_running": "the server is already running a profile",
|
||||
"horizon.perf.not_running": "no server profile of yours is running",
|
||||
"horizon.perf.failed": "the server could not write the profile; see its log",
|
||||
"horizon.perf.part_incomplete": "the server's part of the profile did not arrive whole",
|
||||
"horizon.studio.timeline.freecam_on": "Freecam: ON",
|
||||
"horizon.studio.timeline.freecam_off": "Freecam: OFF",
|
||||
"horizon.studio.timeline.freecam_short": "Cam",
|
||||
@@ -1164,5 +1196,25 @@
|
||||
"horizon.studio.topbar.split_scene": "Split Scene",
|
||||
"horizon.recipes.fuel.options": "Hold SHIFT to pin this tooltip, then click an ingredient to view recipes.",
|
||||
"horizon.recipes.fuel.any": "Any Fuel",
|
||||
"horizon.recipes.fuel.minecraft": "Minecraft"
|
||||
"horizon.recipes.fuel.minecraft": "Minecraft",
|
||||
"horizon.perf.idle": "Not profiling. %s",
|
||||
"horizon.perf.marked": "Marked %s",
|
||||
"horizon.perf.started.client": "Profiling the client. %s",
|
||||
"horizon.perf.started.server": "Profiling the server. %s",
|
||||
"horizon.perf.saving": "Saving the profile…",
|
||||
"horizon.perf.running.client": "Profiling the client for %s s. %s",
|
||||
"horizon.perf.running.server": "Profiling the server for %s s. %s",
|
||||
"horizon.perf.running.both": "Profiling the client and server for %s s. %s",
|
||||
"horizon.perf.client_only": "Profiling the client only: %s",
|
||||
"horizon.perf.stop_button": "[Stop]",
|
||||
"horizon.perf.stop_hover": "Stop profiling and save the profile.",
|
||||
"horizon.perf.start_button": "[Start]",
|
||||
"horizon.perf.start_hover": "Start profiling.",
|
||||
"horizon.perf.folder_button": "[Folder]",
|
||||
"horizon.perf.folder_hover": "Show %s in its folder.",
|
||||
"horizon.perf.copy_button": "[Copy]",
|
||||
"horizon.perf.copy_hover": "Copy the link.",
|
||||
"horizon.perf.retry_button": "[Retry]",
|
||||
"horizon.perf.retry_hover": "Try the upload again. The profile is still saved on your computer.",
|
||||
"horizon.perf.link_hover": "Open the shared profile in your browser."
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 335 B |
@@ -21,5 +21,19 @@ fn main() {
|
||||
if !staged.is_empty() {
|
||||
println!("cargo:rustc-env=HORIZON_MIXIN_MODS={}", staged.join(","));
|
||||
}
|
||||
// Whether a native mod can run in a mob's turn (`game/creatures/mob_islands.rs`). The
|
||||
// launcher adds trait packages to the workspace's members.
|
||||
println!(
|
||||
"cargo:rerun-if-changed={}",
|
||||
root.join("Cargo.toml").display()
|
||||
);
|
||||
for dir in ["native-api", "native-crates"] {
|
||||
if root.join(dir).is_dir() {
|
||||
println!("cargo:rerun-if-changed={}", root.join(dir).display());
|
||||
}
|
||||
}
|
||||
if let Some(found) = horizon_mod_api_gen::mob_turn(&root) {
|
||||
println!("cargo:rustc-env=HORIZON_MOB_TURN={found}");
|
||||
}
|
||||
windows_exe::resources("horizon-client");
|
||||
}
|
||||
@@ -318,6 +318,8 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
drops::Drop::Orb(v) => writeln!(out, "orb {v}"),
|
||||
// The star's `setExtendedLifetime` is no part of the loot.
|
||||
drops::Drop::Aged(s, _) => writeln!(out, "{}", line(&s)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,7 +237,7 @@ fn goal_name(m: &Mob, name: &str) -> String {
|
||||
"RestrictSun" => "RestrictSunGoal",
|
||||
"FleeSun" => "FleeSunGoal",
|
||||
// `Cat.CatAvoidEntityGoal`, `Fox.FoxAvoidEntityGoal`... name their subclass.
|
||||
"AvoidEntity" if m.species() == Species::Cat => "CatAvoidEntityGoal",
|
||||
"AvoidEntity" | "CatAvoid" if m.species() == Species::Cat => "CatAvoidEntityGoal",
|
||||
"AvoidEntity" => "AvoidEntityGoal",
|
||||
"BowAttack" => "RangedBowAttackGoal",
|
||||
"Swell" => "SwellGoal",
|
||||
|
||||
@@ -1517,10 +1517,8 @@ fn predicate_references(
|
||||
.unwrap_or("");
|
||||
match &*full(kind) {
|
||||
"minecraft:inverted" => predicate_references(&v["term"], w, active),
|
||||
"minecraft:all_of" | "minecraft:any_of" => v["terms"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
"minecraft:all_of" | "minecraft:any_of" => terms(v)
|
||||
.iter()
|
||||
.try_for_each(|v| predicate_references(v, w, active)),
|
||||
_ => Ok(()),
|
||||
}
|
||||
@@ -1529,6 +1527,16 @@ fn predicate_references(
|
||||
}
|
||||
}
|
||||
|
||||
/// `terms` as `HolderSetCodec` reads it: a list, or one holder alone; a `#tag` is not expanded.
|
||||
fn terms(v: &Value) -> &[Value] {
|
||||
match &v["terms"] {
|
||||
Value::Array(list) => list,
|
||||
Value::Null => &[],
|
||||
Value::String(tag) if tag.starts_with('#') => &[],
|
||||
one => std::slice::from_ref(one),
|
||||
}
|
||||
}
|
||||
|
||||
/// The existing condition traversal with a caller-owned leaf context.
|
||||
/// Leaves run synchronously, in order, with normal boolean short-circuiting.
|
||||
pub fn conditions_with(
|
||||
@@ -1547,16 +1555,8 @@ pub fn conditions_with(
|
||||
.unwrap_or("");
|
||||
match &*full(ty) {
|
||||
"minecraft:inverted" => !conditions_with(&v["term"], ctx, test),
|
||||
"minecraft:all_of" => v["terms"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.all(|t| conditions_with(t, ctx, test)),
|
||||
"minecraft:any_of" => v["terms"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|t| conditions_with(t, ctx, test)),
|
||||
"minecraft:all_of" => terms(v).iter().all(|t| conditions_with(t, ctx, test)),
|
||||
"minecraft:any_of" => terms(v).iter().any(|t| conditions_with(t, ctx, test)),
|
||||
_ => test(v, ctx),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -862,7 +862,7 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
if !self.st.first_tick && self.st.fluids.is_in_fluid(&Tags, LAVA) {
|
||||
self.st.fall_distance *= 0.5;
|
||||
}
|
||||
if self.position().y < f64::from(self.scene.world.bottom_y() - 64) {
|
||||
if self.position().y < self.scene.world.below_world_y() {
|
||||
self.st.removed = true;
|
||||
}
|
||||
self.st.first_tick = false;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! banners, and the loom rules of `LoomMenu` (selectable patterns, the
|
||||
//! six-layer limit and the result stack).
|
||||
use crate::{block_entity_render::DYES, items::ItemStack, items::Items};
|
||||
use horizon_world::nbt::Tag;
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, fs, path::Path, sync::Arc};
|
||||
|
||||
@@ -30,6 +31,8 @@ pub struct Banners {
|
||||
dyes: HashMap<Arc<str>, Arc<str>>,
|
||||
/// Item -> `provides_banner_patterns`, in tag order.
|
||||
provided: HashMap<Arc<str>, Vec<Arc<str>>>,
|
||||
/// Banner-pattern tag id -> its members, for a component that names a tag.
|
||||
pattern_tags: HashMap<String, Vec<Arc<str>>>,
|
||||
}
|
||||
|
||||
fn read_tag(root: &Path, name: &str, depth: u32) -> Vec<Arc<str>> {
|
||||
@@ -98,6 +101,31 @@ impl Banners {
|
||||
});
|
||||
}
|
||||
banners.no_item_required = read_tag(root, "no_item_required", 0);
|
||||
// Every tag, `pattern_item/globe` included, for a component naming one.
|
||||
let tags = root.join("data/minecraft/tags/banner_pattern");
|
||||
let mut dirs = vec![tags.clone()];
|
||||
while let Some(dir) = dirs.pop() {
|
||||
for path in fs::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
{
|
||||
if path.is_dir() {
|
||||
dirs.push(path);
|
||||
} else if path.extension().is_some_and(|e| e == "json")
|
||||
&& let Some(name) = path
|
||||
.strip_prefix(&tags)
|
||||
.ok()
|
||||
.and_then(|p| p.with_extension("").to_str().map(|s| s.replace('\\', "/")))
|
||||
{
|
||||
let members = read_tag(root, &name, 0);
|
||||
banners
|
||||
.pattern_tags
|
||||
.insert(format!("minecraft:{name}"), members);
|
||||
}
|
||||
}
|
||||
}
|
||||
let owned_defaults;
|
||||
let defaults = match defaults {
|
||||
Some(defaults) => Some(defaults),
|
||||
@@ -159,27 +187,88 @@ impl Banners {
|
||||
}
|
||||
/// `LoomMenu.isDyeItem`: the dye colour of a loom dye.
|
||||
pub fn loom_dye(&self, items: &Items, stack: &ItemStack) -> Option<Arc<str>> {
|
||||
let tagged = items
|
||||
if !items
|
||||
.item_tags
|
||||
.get("minecraft:loom_dyes")
|
||||
.is_some_and(|t| t.contains(&*stack.item));
|
||||
tagged
|
||||
.then(|| self.dyes.get(&stack.item).cloned())
|
||||
.flatten()
|
||||
.is_some_and(|t| t.contains(&*stack.item))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let data = stack.data();
|
||||
if data.is_some_and(|d| d.other("!minecraft:dye").is_some()) {
|
||||
return None;
|
||||
}
|
||||
if let Some(Tag::String(color)) = data.and_then(|d| d.other("minecraft:dye")) {
|
||||
return Some(Arc::from(
|
||||
color.to_string_lossy().trim_start_matches("minecraft:"),
|
||||
));
|
||||
}
|
||||
self.dyes.get(&stack.item).cloned()
|
||||
}
|
||||
/// `LoomMenu.isPatternItem`.
|
||||
/// `LoomMenu.isPatternItem`: the item tag and a present
|
||||
/// `provides_banner_patterns` component (the stack's, else the item's).
|
||||
pub fn is_pattern_item(&self, items: &Items, stack: &ItemStack) -> bool {
|
||||
items
|
||||
if !items
|
||||
.item_tags
|
||||
.get("minecraft:loom_patterns")
|
||||
.is_some_and(|t| t.contains(&*stack.item))
|
||||
&& self.provided.contains_key(&stack.item)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let data = stack.data();
|
||||
if data.is_some_and(|d| d.other("!minecraft:provides_banner_patterns").is_some()) {
|
||||
return false;
|
||||
}
|
||||
data.is_some_and(|d| d.other("minecraft:provides_banner_patterns").is_some())
|
||||
|| self.provided.contains_key(&stack.item)
|
||||
}
|
||||
/// `LoomMenu.getSelectablePatterns`.
|
||||
pub fn selectable(&self, pattern_item: Option<&ItemStack>) -> Vec<Arc<str>> {
|
||||
match pattern_item {
|
||||
None => self.no_item_required.clone(),
|
||||
Some(stack) => self.provided.get(&stack.item).cloned().unwrap_or_default(),
|
||||
Some(stack) => {
|
||||
let data = stack.data();
|
||||
if data.is_some_and(|d| d.other("!minecraft:provides_banner_patterns").is_some()) {
|
||||
return Vec::new();
|
||||
}
|
||||
match data.and_then(|d| d.other("minecraft:provides_banner_patterns")) {
|
||||
Some(tag) => self.provides(tag),
|
||||
None => self.provided.get(&stack.item).cloned().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A `provides_banner_patterns` value: a pattern id, a tag, or a list of
|
||||
/// either (`HolderSet`'s saved forms).
|
||||
fn provides(&self, tag: &Tag) -> Vec<Arc<str>> {
|
||||
match tag {
|
||||
Tag::String(value) => self.provides_one(&value.to_string_lossy()),
|
||||
Tag::List(values) => values
|
||||
.iter()
|
||||
.flat_map(|value| match value {
|
||||
Tag::String(value) => self.provides_one(&value.to_string_lossy()),
|
||||
_ => Vec::new(),
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
fn provides_one(&self, value: &str) -> Vec<Arc<str>> {
|
||||
// `Identifier.parse`: an id or tag without a namespace is `minecraft`'s.
|
||||
let namespaced = |id: &str| {
|
||||
if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
}
|
||||
};
|
||||
match value.strip_prefix('#') {
|
||||
Some(tag) => self
|
||||
.pattern_tags
|
||||
.get(&namespaced(tag))
|
||||
.cloned()
|
||||
.unwrap_or_default(),
|
||||
None => vec![Arc::from(namespaced(value))],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -249,6 +338,81 @@ mod tests {
|
||||
assert_eq!(&*b.selectable(Some(&item))[0], "minecraft:flower");
|
||||
}
|
||||
#[test]
|
||||
fn prepared_dye_component() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let items = Items::tags_only(&root, &horizon_world::loot::PackOverlay::NONE).unwrap();
|
||||
let b = Banners::load(&root);
|
||||
let mut dye = ItemStack::new("minecraft:white_dye", 1);
|
||||
assert_eq!(&*b.loom_dye(&items, &dye).unwrap(), "white");
|
||||
dye.data_mut()
|
||||
.set_other("minecraft:dye", Some(Tag::String("red".into())));
|
||||
assert_eq!(&*b.loom_dye(&items, &dye).unwrap(), "red");
|
||||
dye.data_mut().set_other(
|
||||
"!minecraft:dye",
|
||||
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
|
||||
);
|
||||
assert!(b.loom_dye(&items, &dye).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn prepared_pattern_component_presence() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let items = Items::tags_only(&root, &horizon_world::loot::PackOverlay::NONE).unwrap();
|
||||
let b = Banners::load(&root);
|
||||
let mut pattern = ItemStack::new("minecraft:globe_banner_pattern", 1);
|
||||
assert!(b.is_pattern_item(&items, &pattern));
|
||||
pattern.data_mut().set_other(
|
||||
"!minecraft:provides_banner_patterns",
|
||||
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
|
||||
);
|
||||
assert!(!b.is_pattern_item(&items, &pattern));
|
||||
}
|
||||
#[test]
|
||||
fn prepared_pattern_component_selection() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let b = Banners::load(&root);
|
||||
let mut pattern = ItemStack::new("minecraft:globe_banner_pattern", 1);
|
||||
assert_eq!(&*b.selectable(Some(&pattern))[0], "minecraft:globe");
|
||||
pattern.data_mut().set_other(
|
||||
"minecraft:provides_banner_patterns",
|
||||
Some(Tag::List(vec![Tag::String("minecraft:creeper".into())])),
|
||||
);
|
||||
assert_eq!(&*b.selectable(Some(&pattern))[0], "minecraft:creeper");
|
||||
// One id without its namespace, or a tag, as `HolderSet`'s codec reads them.
|
||||
pattern.data_mut().set_other(
|
||||
"minecraft:provides_banner_patterns",
|
||||
Some(Tag::String("creeper".into())),
|
||||
);
|
||||
assert_eq!(
|
||||
b.selectable(Some(&pattern)),
|
||||
[Arc::from("minecraft:creeper")]
|
||||
);
|
||||
pattern.data_mut().set_other(
|
||||
"minecraft:provides_banner_patterns",
|
||||
Some(Tag::String("#pattern_item/globe".into())),
|
||||
);
|
||||
assert_eq!(b.selectable(Some(&pattern)), [Arc::from("minecraft:globe")]);
|
||||
pattern.data_mut().set_other(
|
||||
"!minecraft:provides_banner_patterns",
|
||||
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
|
||||
);
|
||||
assert!(b.selectable(Some(&pattern)).is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn banner_colour_comes_from_the_item() {
|
||||
assert_eq!(
|
||||
banner_color("minecraft:light_gray_banner"),
|
||||
|
||||
@@ -11,6 +11,7 @@ use std::sync::mpsc::{self, Sender};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use horizon_bench::compare::Ab;
|
||||
use horizon_bench::{App, Frames, Kind, Report};
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -54,14 +55,105 @@ const FLIGHT_SECONDS: f64 = 12.0;
|
||||
const SETTLE: Duration = Duration::from_secs(2);
|
||||
const LOAD_LIMIT: Duration = Duration::from_secs(600);
|
||||
|
||||
/// `standard` flies every scene; a scene's name flies only that one.
|
||||
pub fn scenes(scenario: &str) -> Option<Vec<Scene>> {
|
||||
match scenario {
|
||||
"standard" => Some(SCENES.to_vec()),
|
||||
name => SCENES.iter().find(|s| s.0 == name).map(|s| vec![*s]),
|
||||
/// Where the trip starts and ends: 512 blocks at 16 blocks/s, the pace that scrambles section ids.
|
||||
const TRAVEL_FROM: [f64; 3] = [-960.0, 150.0, -176.0];
|
||||
const TRAVEL_TO: [f64; 3] = [-960.0, 150.0, 336.0];
|
||||
const TRAVEL_PITCH: f64 = 30.0;
|
||||
const TRAVEL_SECONDS: f64 = 32.0;
|
||||
const HOLD_BLOCKS: u32 = 4;
|
||||
const BLOCK_SECONDS: f64 = 4.0;
|
||||
const WARMUP_SECONDS: f64 = 2.0;
|
||||
const PAIRS: u32 = 8;
|
||||
/// Frame-rate settings the benchmark can set for a run; `a` is the default.
|
||||
pub struct Switch {
|
||||
pub name: &'static str,
|
||||
pub word: &'static str,
|
||||
pub key: &'static str,
|
||||
pub a: &'static str,
|
||||
pub b: &'static str,
|
||||
}
|
||||
pub const SWITCHES: [Switch; 4] = [
|
||||
Switch {
|
||||
name: "gpu-culling",
|
||||
word: "gpu-culling",
|
||||
key: "gpu_culling",
|
||||
a: "off",
|
||||
b: "on",
|
||||
},
|
||||
Switch {
|
||||
name: "section-cull",
|
||||
word: "section-cull",
|
||||
key: "cull",
|
||||
a: "on",
|
||||
b: "off",
|
||||
},
|
||||
Switch {
|
||||
name: "section-sides",
|
||||
word: "section-sides",
|
||||
key: "sides",
|
||||
a: "on",
|
||||
b: "off",
|
||||
},
|
||||
Switch {
|
||||
name: "section-draws",
|
||||
word: "section-draws",
|
||||
key: "flat",
|
||||
a: "flat",
|
||||
b: "indirect",
|
||||
},
|
||||
];
|
||||
|
||||
/// What `--benchmark` runs.
|
||||
pub enum Scenario {
|
||||
/// `standard` flies every scene; a scene's name flies only that one.
|
||||
Scenes(Vec<Scene>),
|
||||
/// A 512-block flight, then held at its end.
|
||||
Travel,
|
||||
/// The flight with the switch's B applied, then its two values interleaved B A A B.
|
||||
Ab(&'static Switch),
|
||||
}
|
||||
|
||||
impl Scenario {
|
||||
pub fn parse(name: &str) -> Result<Scenario, String> {
|
||||
match name {
|
||||
"standard" => Ok(Scenario::Scenes(SCENES.to_vec())),
|
||||
"travel" => Ok(Scenario::Travel),
|
||||
name if name.starts_with("ab:") => SWITCHES
|
||||
.iter()
|
||||
.find(|s| s.name == &name[3..])
|
||||
.map(Scenario::Ab)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"unknown switch {}: {}",
|
||||
&name[3..],
|
||||
SWITCHES.map(|s| s.name).join(", ")
|
||||
)
|
||||
}),
|
||||
name => SCENES
|
||||
.iter()
|
||||
.find(|s| s.0 == name)
|
||||
.map(|s| Scenario::Scenes(vec![*s]))
|
||||
.ok_or_else(|| {
|
||||
format!("unknown benchmark {name}: standard, village, forest, ocean, mountain, travel or ab:SWITCH ({})", SWITCHES.map(|s| s.name).join(", "))
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `--benchmark-set SWITCH=VALUE`: the value must be one of the switch's two.
|
||||
pub fn parse_set(value: &str) -> Result<(String, String), String> {
|
||||
let (name, setting) = value.split_once('=').ok_or("expected SWITCH=VALUE")?;
|
||||
let names = || SWITCHES.map(|s| s.name).join(", ");
|
||||
let switch = SWITCHES
|
||||
.iter()
|
||||
.find(|s| s.name == name)
|
||||
.ok_or_else(|| format!("unknown switch {name}: {}", names()))?;
|
||||
if setting != switch.a && setting != switch.b {
|
||||
return Err(format!("{name} is {} or {}", switch.a, switch.b));
|
||||
}
|
||||
Ok((name.to_owned(), setting.to_owned()))
|
||||
}
|
||||
|
||||
/// What the game knows about the run before it starts.
|
||||
pub struct Setup {
|
||||
pub scenario: String,
|
||||
@@ -73,6 +165,10 @@ pub struct Setup {
|
||||
pub fps_cap: Option<u32>,
|
||||
pub offscreen: bool,
|
||||
pub game_dir: PathBuf,
|
||||
/// `--benchmark-set`: a switch and its value, applied before the world opens.
|
||||
pub set: Option<(String, String)>,
|
||||
/// `--profile`: record a .hzperf capture of the run and embed the report in it.
|
||||
pub profile: bool,
|
||||
}
|
||||
|
||||
/// Starts the driver; the game quits when it is done, and the report's path is printed.
|
||||
@@ -150,8 +246,7 @@ fn time_passes(control: &Control, scene: &str, report: &mut Report) -> Result<bo
|
||||
}
|
||||
|
||||
fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
|
||||
let scenes =
|
||||
scenes(&setup.scenario).ok_or_else(|| format!("unknown scenario {}", setup.scenario))?;
|
||||
let scenario = Scenario::parse(&setup.scenario)?;
|
||||
let mut report = Report::new(Kind::Game, setup.app.clone());
|
||||
let pid = std::process::id();
|
||||
report.conditions.load_avg_1m = horizon_bench::load_average();
|
||||
@@ -191,85 +286,12 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
|
||||
horizon_telemetry::system::storage(&setup.game_dir).map(str::to_owned);
|
||||
report.hardware.display = serde_json::from_value(title["display"].clone()).ok();
|
||||
|
||||
replace_world(&setup.game_dir)?;
|
||||
control.call(&format!("open {WORLD} {SEED} creative"))?;
|
||||
// The world opens on a worker: its folder may not exist yet when `open` replies.
|
||||
std::fs::create_dir_all(setup.game_dir.join("saves").join(WORLD)).map_err(|e| e.to_string())?;
|
||||
std::fs::write(
|
||||
setup.game_dir.join("saves").join(WORLD).join(MARKER),
|
||||
"Created by horizon-client --benchmark; replaced by the next run.\n",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let loaded = wait_loaded(control, |_| true)?;
|
||||
report.metric("startup.world_ms", &[ms(loaded - setup.started)]);
|
||||
|
||||
let mut steady = Vec::new();
|
||||
for (name, start, end, pitch) in scenes {
|
||||
control.call("time 6000")?;
|
||||
let meshed = control.status()["meshed_sections"].as_f64().unwrap_or(0.0);
|
||||
let generated = control.status()["generated_chunks"].as_f64().unwrap_or(0.0);
|
||||
let [x, y, z] = start;
|
||||
control.call(&format!("tp {x} {y} {z}"))?;
|
||||
let teleported = Instant::now();
|
||||
// A zero-length flight holds the player at the start, flying.
|
||||
control.call(&format!("fly {x} {y} {z} {x} {y} {z} 0.1 {pitch}"))?;
|
||||
control.call(&format!("look {:.3} {pitch}", yaw(start, end)))?;
|
||||
let near = |p: &Value| {
|
||||
let p: Vec<f64> = p
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_f64)
|
||||
.collect();
|
||||
p.len() == 3 && (0..3).map(|i| (p[i] - start[i]).powi(2)).sum::<f64>() < 4.0
|
||||
};
|
||||
let loaded = wait_loaded(control, near)?;
|
||||
let load = loaded - teleported;
|
||||
report.metric(&format!("chunks.{name}.load_ms"), &[ms(load)]);
|
||||
let status = control.status();
|
||||
let meshes = status["meshed_sections"].as_f64().unwrap_or(0.0) - meshed;
|
||||
let chunks = status["generated_chunks"].as_f64().unwrap_or(0.0) - generated;
|
||||
report.metric(&format!("chunks.{name}.meshed_count"), &[meshes]);
|
||||
report.metric(&format!("chunks.{name}.generated_count"), &[chunks]);
|
||||
if load.as_secs_f64() > 0.0 {
|
||||
report.metric(
|
||||
&format!("chunks.{name}.generated_per_s"),
|
||||
&[chunks / load.as_secs_f64()],
|
||||
);
|
||||
}
|
||||
if !time_passes(control, name, &mut report)? {
|
||||
thread::sleep(SETTLE);
|
||||
}
|
||||
let [ex, ey, ez] = end;
|
||||
let begin = Instant::now();
|
||||
let flight = control.call(&format!(
|
||||
"fly {x} {y} {z} {ex} {ey} {ez} {FLIGHT_SECONDS} {pitch} raw"
|
||||
))?;
|
||||
steady.push((begin, Instant::now()));
|
||||
let series = |key: &str| -> Vec<f32> {
|
||||
flight[key]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_f64)
|
||||
.map(|v| v as f32)
|
||||
.collect()
|
||||
};
|
||||
let frames = Frames::of(
|
||||
name,
|
||||
0,
|
||||
&series("frame_ms"),
|
||||
&series("cpu_ms"),
|
||||
&series("gpu_ms"),
|
||||
)
|
||||
.ok_or("the flight drew no frames")?;
|
||||
report.metric(&format!("fps.{name}.avg_fps"), &[frames.avg_fps]);
|
||||
report.metric(&format!("fps.{name}.low_1pct_fps"), &[frames.low_1pct_fps]);
|
||||
report.metric(
|
||||
&format!("fps.{name}.low_01pct_fps"),
|
||||
&[frames.low_01pct_fps],
|
||||
);
|
||||
report.frames.push(frames);
|
||||
match &scenario {
|
||||
Scenario::Ab(switch) => keeping_switch(control, switch, || {
|
||||
measure(control, &setup, &scenario, &mut report, &mut steady)
|
||||
})?,
|
||||
_ => measure(control, &setup, &scenario, &mut report, &mut steady)?,
|
||||
}
|
||||
|
||||
let status = control.status();
|
||||
@@ -287,8 +309,12 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
|
||||
c.vsync = Some(setup.vsync);
|
||||
c.fps_cap = setup.fps_cap;
|
||||
c.offscreen = Some(setup.offscreen);
|
||||
c.repeats = Some(1);
|
||||
c.warmup_s = Some(SETTLE.as_secs_f64());
|
||||
let (repeats, warmup) = match scenario {
|
||||
Scenario::Travel => (HOLD_BLOCKS, None),
|
||||
Scenario::Ab(_) => (2 * PAIRS, Some(WARMUP_SECONDS)),
|
||||
Scenario::Scenes(_) => (1, Some(SETTLE.as_secs_f64())),
|
||||
};
|
||||
(c.repeats, c.warmup_s) = (Some(repeats), warmup);
|
||||
if let Some(bytes) = renderer["gpu_allocated_bytes"].as_f64() {
|
||||
report.metric("memory.gpu_mb", &[bytes / f64::from(1 << 20)]);
|
||||
}
|
||||
@@ -312,9 +338,342 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
|
||||
report.metric("game.binary_bytes", &[meta.len() as f64]);
|
||||
}
|
||||
crate::telemetry::keep_benchmark(&crate::logging::game_home(), &report);
|
||||
report
|
||||
let written = report
|
||||
.write_to(&setup.game_dir.join("benchmarks"))
|
||||
.map_err(|e| e.to_string())
|
||||
.map_err(|e| e.to_string())?;
|
||||
if setup.profile {
|
||||
match control.call(&stop_capture(&written)) {
|
||||
Ok(done) => println!("Horizon: benchmark capture {}", text(&done["path"])),
|
||||
Err(e) => eprintln!("Horizon: the benchmark capture failed: {e}"),
|
||||
}
|
||||
}
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
/// Starts the capture the run's frames feed; its trigger is the benchmark's.
|
||||
const START_CAPTURE: &str = "profile client start";
|
||||
|
||||
/// Ends the capture with the written report embedded in it.
|
||||
fn stop_capture(report: &Path) -> String {
|
||||
format!("profile client stop --bench {}", report.display())
|
||||
}
|
||||
|
||||
/// Opens the benchmark world and flies the scenario in it.
|
||||
fn measure(
|
||||
control: &Control,
|
||||
setup: &Setup,
|
||||
scenario: &Scenario,
|
||||
report: &mut Report,
|
||||
steady: &mut Vec<(Instant, Instant)>,
|
||||
) -> Result<(), String> {
|
||||
match (scenario, &setup.set) {
|
||||
(Scenario::Ab(switch), _) => apply_set(control, switch.name, switch.b)?,
|
||||
(_, Some((name, value))) => apply_set(control, name, value)?,
|
||||
_ => {}
|
||||
}
|
||||
replace_world(&setup.game_dir)?;
|
||||
control.call(&format!("open {WORLD} {SEED} creative"))?;
|
||||
// The world opens on a worker: its folder may not exist yet when `open` replies.
|
||||
std::fs::create_dir_all(setup.game_dir.join("saves").join(WORLD)).map_err(|e| e.to_string())?;
|
||||
std::fs::write(
|
||||
setup.game_dir.join("saves").join(WORLD).join(MARKER),
|
||||
"Created by horizon-client --benchmark; replaced by the next run.\n",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let loaded = wait_loaded(control, |_| true)?;
|
||||
report.metric("startup.world_ms", &[ms(loaded - setup.started)]);
|
||||
|
||||
if setup.profile {
|
||||
control.call(START_CAPTURE)?;
|
||||
}
|
||||
|
||||
match scenario {
|
||||
Scenario::Scenes(scenes) => {
|
||||
for &(name, start, end, pitch) in scenes {
|
||||
control.call("time 6000")?;
|
||||
load_scene(control, name, start, end, pitch, report)?;
|
||||
if !time_passes(control, name, report)? {
|
||||
thread::sleep(SETTLE);
|
||||
}
|
||||
let frames = fly(control, name, start, end, FLIGHT_SECONDS, pitch, steady)?;
|
||||
fps_metrics(report, name, std::slice::from_ref(&frames));
|
||||
report.frames.push(frames);
|
||||
}
|
||||
}
|
||||
Scenario::Travel => travel(control, report, steady)?,
|
||||
Scenario::Ab(switch) => {
|
||||
trip(control, report, steady)?;
|
||||
let plan = Plan {
|
||||
pairs: PAIRS,
|
||||
warmup: Duration::from_secs_f64(WARMUP_SECONDS),
|
||||
};
|
||||
ab_blocks(control, switch, &plan, report, steady)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reads a switch before the run and puts it back after, whatever the run did and however it ended.
|
||||
fn keeping_switch<T>(
|
||||
control: &Control,
|
||||
switch: &Switch,
|
||||
run: impl FnOnce() -> Result<T, String>,
|
||||
) -> Result<T, String> {
|
||||
let initial = control.call(switch.word)?[switch.key].clone();
|
||||
let result = run();
|
||||
let restored = restore(control, switch, &initial);
|
||||
match result {
|
||||
Ok(value) => restored.map(|()| value),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends the arm whose reply has the value the switch had.
|
||||
fn restore(control: &Control, switch: &Switch, initial: &Value) -> Result<(), String> {
|
||||
if control.call(switch.word)?[switch.key] == *initial {
|
||||
return Ok(());
|
||||
}
|
||||
for value in [switch.a, switch.b] {
|
||||
if control.call(&format!("{} {value}", switch.word))?[switch.key] == *initial {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(format!("{} could not be put back", switch.name))
|
||||
}
|
||||
|
||||
/// Sets a switch with its control word; a refusal (GPU culling not offered) is the game's reason.
|
||||
fn apply_set(control: &Control, name: &str, value: &str) -> Result<(), String> {
|
||||
let switch = SWITCHES
|
||||
.iter()
|
||||
.find(|s| s.name == name)
|
||||
.ok_or("unknown switch")?;
|
||||
control.call(&format!("{} {value}", switch.word)).map(drop)
|
||||
}
|
||||
|
||||
/// Teleports to a scene's start, holds there and waits for the view to load, reporting the load.
|
||||
fn load_scene(
|
||||
control: &Control,
|
||||
name: &str,
|
||||
start: [f64; 3],
|
||||
end: [f64; 3],
|
||||
pitch: f64,
|
||||
report: &mut Report,
|
||||
) -> Result<(), String> {
|
||||
let meshed = control.status()["meshed_sections"].as_f64().unwrap_or(0.0);
|
||||
let generated = control.status()["generated_chunks"].as_f64().unwrap_or(0.0);
|
||||
let [x, y, z] = start;
|
||||
control.call(&format!("tp {x} {y} {z}"))?;
|
||||
let teleported = Instant::now();
|
||||
// A zero-length flight holds the player at the start, flying.
|
||||
control.call(&format!("fly {x} {y} {z} {x} {y} {z} 0.1 {pitch}"))?;
|
||||
control.call(&format!("look {:.3} {pitch}", yaw(start, end)))?;
|
||||
let loaded = wait_loaded(control, near(start))?;
|
||||
let load = loaded - teleported;
|
||||
report.metric(&format!("chunks.{name}.load_ms"), &[ms(load)]);
|
||||
let status = control.status();
|
||||
let meshes = status["meshed_sections"].as_f64().unwrap_or(0.0) - meshed;
|
||||
let chunks = status["generated_chunks"].as_f64().unwrap_or(0.0) - generated;
|
||||
report.metric(&format!("chunks.{name}.meshed_count"), &[meshes]);
|
||||
report.metric(&format!("chunks.{name}.generated_count"), &[chunks]);
|
||||
if load.as_secs_f64() > 0.0 {
|
||||
report.metric(
|
||||
&format!("chunks.{name}.generated_per_s"),
|
||||
&[chunks / load.as_secs_f64()],
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether a status position is within 2 blocks of `target`.
|
||||
fn near(target: [f64; 3]) -> impl Fn(&Value) -> bool {
|
||||
move |p| {
|
||||
let p: Vec<f64> = p
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_f64)
|
||||
.collect();
|
||||
p.len() == 3 && (0..3).map(|i| (p[i] - target[i]).powi(2)).sum::<f64>() < 4.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Flies the camera from `start` to `end` and returns the flight's frames.
|
||||
fn fly(
|
||||
control: &Control,
|
||||
phase: &str,
|
||||
start: [f64; 3],
|
||||
end: [f64; 3],
|
||||
seconds: f64,
|
||||
pitch: f64,
|
||||
steady: &mut Vec<(Instant, Instant)>,
|
||||
) -> Result<Frames, String> {
|
||||
let ([x, y, z], [ex, ey, ez]) = (start, end);
|
||||
let begin = Instant::now();
|
||||
let flight = control.call(&format!(
|
||||
"fly {x} {y} {z} {ex} {ey} {ez} {seconds} {pitch} raw"
|
||||
))?;
|
||||
steady.push((begin, Instant::now()));
|
||||
frames_of(&flight, phase, 0)
|
||||
}
|
||||
|
||||
/// The frames of a `fly ... raw` or `bench ... raw` reply.
|
||||
fn frames_of(reply: &Value, phase: &str, repeat: u32) -> Result<Frames, String> {
|
||||
let series = |key: &str| -> Vec<f32> {
|
||||
reply[key]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_f64)
|
||||
.map(|v| v as f32)
|
||||
.collect()
|
||||
};
|
||||
Frames::of(
|
||||
phase,
|
||||
repeat,
|
||||
&series("frame_ms"),
|
||||
&series("cpu_ms"),
|
||||
&series("gpu_ms"),
|
||||
)
|
||||
.ok_or_else(|| format!("{phase} drew no frames"))
|
||||
}
|
||||
|
||||
/// The phase's frame figures over its blocks (one for a flight).
|
||||
fn fps_metrics(report: &mut Report, phase: &str, blocks: &[Frames]) {
|
||||
let each = |f: fn(&Frames) -> f64| blocks.iter().map(f).collect::<Vec<_>>();
|
||||
report.metric(&format!("fps.{phase}.avg_fps"), &each(|b| b.avg_fps));
|
||||
report.metric(
|
||||
&format!("fps.{phase}.low_1pct_fps"),
|
||||
&each(|b| b.low_1pct_fps),
|
||||
);
|
||||
report.metric(
|
||||
&format!("fps.{phase}.low_01pct_fps"),
|
||||
&each(|b| b.low_01pct_fps),
|
||||
);
|
||||
}
|
||||
|
||||
/// The median CPU and GPU time of a frame over the phase's blocks, where measured.
|
||||
fn timing_metrics(report: &mut Report, phase: &str, blocks: &[Frames]) {
|
||||
let cpu: Vec<f64> = blocks.iter().filter_map(|b| b.cpu_p50_ms).collect();
|
||||
let gpu: Vec<f64> = blocks.iter().filter_map(|b| b.gpu_p50_ms).collect();
|
||||
report.metric(&format!("cpu.{phase}.p50_ms"), &cpu);
|
||||
report.metric(&format!("gpu.{phase}.p50_ms"), &gpu);
|
||||
}
|
||||
|
||||
/// The flight, then the view at its end loaded.
|
||||
fn trip(
|
||||
control: &Control,
|
||||
report: &mut Report,
|
||||
steady: &mut Vec<(Instant, Instant)>,
|
||||
) -> Result<(), String> {
|
||||
for line in [
|
||||
"command gamerule advance_time false",
|
||||
"command gamerule advance_weather false",
|
||||
"command weather clear",
|
||||
"time 6000",
|
||||
] {
|
||||
control.call(line)?;
|
||||
}
|
||||
let (from, to) = (TRAVEL_FROM, TRAVEL_TO);
|
||||
load_scene(control, "travel", from, to, TRAVEL_PITCH, report)?;
|
||||
let flight = fly(
|
||||
control,
|
||||
"travel",
|
||||
from,
|
||||
to,
|
||||
TRAVEL_SECONDS,
|
||||
TRAVEL_PITCH,
|
||||
steady,
|
||||
)?;
|
||||
let blocks = std::slice::from_ref(&flight);
|
||||
fps_metrics(report, "travel", blocks);
|
||||
timing_metrics(report, "travel", blocks);
|
||||
report.frames.push(flight);
|
||||
|
||||
let [x, y, z] = to;
|
||||
control.call(&format!("fly {x} {y} {z} {x} {y} {z} 0.1 {TRAVEL_PITCH}"))?;
|
||||
control.call(&format!("look {:.3} {TRAVEL_PITCH}", yaw(from, to)))?;
|
||||
wait_loaded(control, near(to))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The state that matters: fly 512 blocks over terrain that loads and unloads, then hold at the end.
|
||||
fn travel(
|
||||
control: &Control,
|
||||
report: &mut Report,
|
||||
steady: &mut Vec<(Instant, Instant)>,
|
||||
) -> Result<(), String> {
|
||||
trip(control, report, steady)?;
|
||||
let mut blocks = Vec::new();
|
||||
for repeat in 0..HOLD_BLOCKS {
|
||||
let held = control.call(&format!("bench {BLOCK_SECONDS} raw"))?;
|
||||
blocks.push(frames_of(&held, "hold", repeat)?);
|
||||
}
|
||||
fps_metrics(report, "hold", &blocks);
|
||||
timing_metrics(report, "hold", &blocks);
|
||||
report.frames.extend(blocks);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Plan {
|
||||
pairs: u32,
|
||||
warmup: Duration,
|
||||
}
|
||||
|
||||
/// The two values of a switch in B A A B order, a block each, then each arm's passes timed once.
|
||||
fn ab_blocks(
|
||||
control: &Control,
|
||||
switch: &Switch,
|
||||
plan: &Plan,
|
||||
report: &mut Report,
|
||||
steady: &mut Vec<(Instant, Instant)>,
|
||||
) -> Result<(), String> {
|
||||
let began = Instant::now();
|
||||
let mut blocks = Vec::new();
|
||||
for pair in 0..plan.pairs {
|
||||
control.call("time 6000")?;
|
||||
for (n, b) in [(0, pair % 2 == 0), (1, pair % 2 == 1)] {
|
||||
let (phase, value) = if b {
|
||||
("ab.b", switch.b)
|
||||
} else {
|
||||
("ab.a", switch.a)
|
||||
};
|
||||
control.call(&format!("{} {value}", switch.word))?;
|
||||
thread::sleep(plan.warmup);
|
||||
let held = control.call(&format!("bench {BLOCK_SECONDS} raw"))?;
|
||||
blocks.push(frames_of(&held, phase, 2 * pair + n)?);
|
||||
}
|
||||
}
|
||||
steady.push((began, Instant::now()));
|
||||
for phase in ["ab.a", "ab.b"] {
|
||||
let arm: Vec<Frames> = blocks
|
||||
.iter()
|
||||
.filter(|f| f.phase == phase)
|
||||
.cloned()
|
||||
.collect();
|
||||
fps_metrics(report, phase, &arm);
|
||||
timing_metrics(report, phase, &arm);
|
||||
}
|
||||
let ab = Ab::from_frames(
|
||||
switch.name,
|
||||
switch.a,
|
||||
switch.b,
|
||||
BLOCK_SECONDS,
|
||||
plan.warmup.as_secs_f64(),
|
||||
&blocks,
|
||||
)?;
|
||||
if ab.first_block_outside_range {
|
||||
eprintln!(
|
||||
"Horizon: benchmark warning: the first B block, measured before any switch, is outside the later B blocks: switching {} may reset what is measured",
|
||||
switch.name
|
||||
);
|
||||
}
|
||||
report.frames.extend(blocks);
|
||||
report.ab = Some(ab);
|
||||
for (scene, value) in [("ab_a", switch.a), ("ab_b", switch.b)] {
|
||||
control.call(&format!("{} {value}", switch.word))?;
|
||||
time_passes(control, scene, report)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes the previous run's world, never one the benchmark didn't create.
|
||||
@@ -472,11 +831,65 @@ impl RssSampler {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_capture_commands_are_the_ones_the_game_parses() {
|
||||
use crate::client_perf::request::{self, ProfileSide, Request, StartOptions};
|
||||
let words = |line: &str| request::parse(&line.split(' ').skip(1).collect::<Vec<_>>());
|
||||
let start = words(START_CAPTURE).unwrap();
|
||||
assert_eq!(start.side, Some(ProfileSide::Client));
|
||||
assert_eq!(start.request, Request::Start(StartOptions::default()));
|
||||
let path = Path::new("/home/a b/game/benchmarks/game-20261108-travel.json");
|
||||
let stop = words(&stop_capture(path)).unwrap();
|
||||
assert_eq!(stop.side, Some(ProfileSide::Client));
|
||||
assert_eq!(
|
||||
stop.request,
|
||||
Request::Stop {
|
||||
bench: Some(path.display().to_string())
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenarios() {
|
||||
assert_eq!(scenes("standard").unwrap().len(), 4);
|
||||
assert_eq!(scenes("ocean").unwrap()[0].0, "ocean");
|
||||
assert!(scenes("nether").is_none());
|
||||
let scenes = |name: &str| match Scenario::parse(name) {
|
||||
Ok(Scenario::Scenes(scenes)) => scenes,
|
||||
_ => panic!("{name} is not scenes"),
|
||||
};
|
||||
assert_eq!(scenes("standard").len(), 4);
|
||||
assert_eq!(scenes("ocean")[0].0, "ocean");
|
||||
assert!(matches!(Scenario::parse("travel"), Ok(Scenario::Travel)));
|
||||
assert!(matches!(
|
||||
Scenario::parse("ab:gpu-culling"),
|
||||
Ok(Scenario::Ab(s)) if s.name == "gpu-culling"
|
||||
));
|
||||
let Err(e) = Scenario::parse("ab:nope") else {
|
||||
panic!("nope is no switch")
|
||||
};
|
||||
assert!(
|
||||
e.contains("gpu-culling") && e.contains("section-draws"),
|
||||
"{e}"
|
||||
);
|
||||
let Err(e) = Scenario::parse("nether") else {
|
||||
panic!("nether is no scenario")
|
||||
};
|
||||
assert!(e.contains("travel") && e.contains("mountain"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_set_names_a_switch_and_one_of_its_two_values() {
|
||||
assert_eq!(
|
||||
parse_set("gpu-culling=on"),
|
||||
Ok(("gpu-culling".into(), "on".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_set("section-draws=indirect"),
|
||||
Ok(("section-draws".into(), "indirect".into()))
|
||||
);
|
||||
let nope = parse_set("gpu-culling=maybe").unwrap_err();
|
||||
assert!(nope.contains("off or on"), "{nope}");
|
||||
let unknown = parse_set("fog=on").unwrap_err();
|
||||
assert!(unknown.contains("gpu-culling") && unknown.contains("section-draws"));
|
||||
assert!(parse_set("gpu-culling").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -588,6 +1001,325 @@ mod tests {
|
||||
assert!(metrics.is_empty());
|
||||
}
|
||||
|
||||
/// A fake game for the trip: the player stands where `tp` or `fly` put it and counts settle.
|
||||
fn trip_server(rx: mpsc::Receiver<Request>) -> thread::JoinHandle<Vec<String>> {
|
||||
thread::spawn(move || {
|
||||
let (mut lines, mut at, mut polls) = (Vec::new(), [0.0_f64; 3], 0_u64);
|
||||
while let Ok(request) = rx.recv() {
|
||||
let words: Vec<&str> = request.line.split(' ').collect();
|
||||
let reply = match words[0] {
|
||||
"status" => {
|
||||
polls += 1;
|
||||
let mut s: Value =
|
||||
serde_json::from_str(&status(400 + polls.min(3))).unwrap();
|
||||
s["player"]["position"] = serde_json::json!(at);
|
||||
s
|
||||
}
|
||||
"bench" | "fly" if words.contains(&"raw") => {
|
||||
serde_json::json!({
|
||||
"frames": 200,
|
||||
"frame_ms": vec![5.0; 200],
|
||||
"cpu_ms": vec![2.0; 200],
|
||||
"gpu_ms": vec![4.0; 200],
|
||||
})
|
||||
}
|
||||
_ => serde_json::json!({ "ok": true }),
|
||||
};
|
||||
if matches!(words[0], "tp" | "fly") {
|
||||
let n: Vec<f64> = words[1..4].iter().filter_map(|w| w.parse().ok()).collect();
|
||||
at = [n[0], n[1], n[2]];
|
||||
polls = 0;
|
||||
}
|
||||
let _ = request.reply.send(reply.to_string());
|
||||
lines.push(request.line);
|
||||
}
|
||||
lines
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_trip_flies_512_blocks_then_holds_four_blocks_at_its_end() {
|
||||
let (tx, rx) = mpsc::channel::<Request>();
|
||||
let server = trip_server(rx);
|
||||
let mut report = Report::new(Kind::Game, App::default());
|
||||
let control = Control(tx);
|
||||
let mut steady = Vec::new();
|
||||
travel(&control, &mut report, &mut steady).expect("the trip runs");
|
||||
drop(control);
|
||||
let lines: Vec<String> = server
|
||||
.join()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.filter(|l| l != "status")
|
||||
.map(|l| l.replace("-0.000", "0.000"))
|
||||
.collect();
|
||||
// Facing +Z is yaw 0, which the formatter may print as -0.
|
||||
let mut expected = vec![
|
||||
"command gamerule advance_time false",
|
||||
"command gamerule advance_weather false",
|
||||
"command weather clear",
|
||||
"time 6000",
|
||||
"tp -960 150 -176",
|
||||
"fly -960 150 -176 -960 150 -176 0.1 30",
|
||||
"look 0.000 30",
|
||||
"fly -960 150 -176 -960 150 336 32 30 raw",
|
||||
"fly -960 150 336 -960 150 336 0.1 30",
|
||||
"look 0.000 30",
|
||||
];
|
||||
expected.extend(["bench 4 raw"; 4]);
|
||||
assert_eq!(lines, expected);
|
||||
|
||||
let phases: Vec<_> = report
|
||||
.frames
|
||||
.iter()
|
||||
.map(|f| (f.phase.as_str(), f.repeat))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
phases,
|
||||
[
|
||||
("travel", 0),
|
||||
("hold", 0),
|
||||
("hold", 1),
|
||||
("hold", 2),
|
||||
("hold", 3)
|
||||
]
|
||||
);
|
||||
assert_eq!(steady.len(), 1);
|
||||
let metric = |name: &str| report.metrics.get(name).map(|s| (s.n, s.mean));
|
||||
assert_eq!(metric("fps.travel.avg_fps"), Some((1, 200.0)));
|
||||
assert_eq!(metric("fps.hold.avg_fps"), Some((4, 200.0)));
|
||||
assert_eq!(metric("cpu.hold.p50_ms"), Some((4, 2.0)));
|
||||
assert_eq!(metric("gpu.hold.p50_ms"), Some((4, 4.0)));
|
||||
assert_eq!(metric("gpu.travel.p50_ms"), Some((1, 4.0)));
|
||||
assert!(metric("chunks.travel.load_ms").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_command_ends_the_trip_with_the_games_reason() {
|
||||
let (tx, rx) = mpsc::channel::<Request>();
|
||||
let server = thread::spawn(move || {
|
||||
while let Ok(request) = rx.recv() {
|
||||
let reply = if request.line.starts_with("command weather") {
|
||||
serde_json::json!({ "error": "no such weather" })
|
||||
} else {
|
||||
serde_json::json!({ "ok": true })
|
||||
};
|
||||
let _ = request.reply.send(reply.to_string());
|
||||
}
|
||||
});
|
||||
let mut report = Report::new(Kind::Game, App::default());
|
||||
let control = Control(tx);
|
||||
let error = travel(&control, &mut report, &mut Vec::new()).unwrap_err();
|
||||
assert!(error.contains("no such weather"), "{error}");
|
||||
assert!(report.frames.is_empty());
|
||||
drop(control);
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_switch_is_the_games_reason() {
|
||||
let (tx, rx) = mpsc::channel::<Request>();
|
||||
let server = thread::spawn(move || {
|
||||
let mut lines = Vec::new();
|
||||
while let Ok(request) = rx.recv() {
|
||||
let reply = match request.line.as_str() {
|
||||
"gpu-culling on" => serde_json::json!({ "error": "not offered here" }),
|
||||
_ => serde_json::json!({ "ok": true }),
|
||||
};
|
||||
let _ = request.reply.send(reply.to_string());
|
||||
lines.push(request.line);
|
||||
}
|
||||
lines
|
||||
});
|
||||
let control = Control(tx);
|
||||
apply_set(&control, "section-draws", "indirect").unwrap();
|
||||
let error = apply_set(&control, "gpu-culling", "on").unwrap_err();
|
||||
assert!(error.contains("not offered here"), "{error}");
|
||||
drop(control);
|
||||
assert_eq!(
|
||||
server.join().unwrap(),
|
||||
["section-draws indirect", "gpu-culling on"]
|
||||
);
|
||||
}
|
||||
|
||||
/// A fake game with GPU culling: `on` is its state; `fail_at` makes that `bench` (counted from 1) fail.
|
||||
fn culling_server(
|
||||
rx: mpsc::Receiver<Request>,
|
||||
mut on: bool,
|
||||
offered: bool,
|
||||
fail_at: Option<usize>,
|
||||
) -> thread::JoinHandle<(Vec<String>, bool)> {
|
||||
thread::spawn(move || {
|
||||
let (mut lines, mut benches) = (Vec::new(), 0);
|
||||
while let Ok(request) = rx.recv() {
|
||||
let reply = match request.line.as_str() {
|
||||
"gpu-culling on" if !offered => {
|
||||
serde_json::json!({ "error": "not offered here" })
|
||||
}
|
||||
"gpu-culling on" | "gpu-culling off" => {
|
||||
on = request.line.ends_with("on");
|
||||
serde_json::json!({ "gpu_culling": on })
|
||||
}
|
||||
"gpu-culling" => serde_json::json!({ "gpu_culling": on }),
|
||||
"bench 4 raw" => {
|
||||
benches += 1;
|
||||
let gpu = if on { 5.0 } else { 3.0 };
|
||||
if Some(benches) == fail_at {
|
||||
serde_json::json!({ "error": "the window closed" })
|
||||
} else {
|
||||
serde_json::json!({
|
||||
"frame_ms": vec![gpu + 1.0; 200],
|
||||
"cpu_ms": vec![1.0; 200],
|
||||
"gpu_ms": vec![gpu; 200],
|
||||
})
|
||||
}
|
||||
}
|
||||
"bench 2" => serde_json::json!({
|
||||
"gpu_passes": {"World": if on { 1.5 } else { 0.5 }},
|
||||
}),
|
||||
_ => serde_json::json!({ "ok": true }),
|
||||
};
|
||||
let _ = request.reply.send(reply.to_string());
|
||||
lines.push(request.line);
|
||||
}
|
||||
(lines, on)
|
||||
})
|
||||
}
|
||||
|
||||
const GPU_CULLING: &Switch = &SWITCHES[0];
|
||||
|
||||
fn quick(pairs: u32) -> Plan {
|
||||
Plan {
|
||||
pairs,
|
||||
warmup: Duration::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
fn vulkan_report() -> Report {
|
||||
let gpu = horizon_bench::Gpu {
|
||||
backend: "vulkan".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let hardware = horizon_bench::Hardware {
|
||||
gpu: Some(gpu),
|
||||
..Default::default()
|
||||
};
|
||||
Report::with_hardware(Kind::Game, App::default(), hardware)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_blocks_run_b_a_a_b_and_each_arm_is_timed_by_pass() {
|
||||
let (tx, rx) = mpsc::channel::<Request>();
|
||||
let server = culling_server(rx, false, true, None);
|
||||
let mut report = vulkan_report();
|
||||
let control = Control(tx);
|
||||
keeping_switch(&control, GPU_CULLING, || {
|
||||
ab_blocks(
|
||||
&control,
|
||||
GPU_CULLING,
|
||||
&quick(6),
|
||||
&mut report,
|
||||
&mut Vec::new(),
|
||||
)
|
||||
})
|
||||
.expect("the blocks run");
|
||||
drop(control);
|
||||
let (lines, on) = server.join().unwrap();
|
||||
let arms: String = lines
|
||||
.iter()
|
||||
.take_while(|l| *l != "gpu-passes on")
|
||||
.filter_map(|l| match l.as_str() {
|
||||
"gpu-culling on" => Some('B'),
|
||||
"gpu-culling off" => Some('A'),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(arms, "BAABBAABBAABA");
|
||||
assert_eq!(lines.iter().filter(|l| *l == "time 6000").count(), 6);
|
||||
assert!(!on, "the initial value is back");
|
||||
assert_eq!(lines.last().unwrap(), "gpu-culling off");
|
||||
|
||||
let positions: Vec<_> = report
|
||||
.frames
|
||||
.iter()
|
||||
.map(|f| (f.phase.as_str(), f.repeat))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
positions[..4],
|
||||
[("ab.b", 0), ("ab.a", 1), ("ab.a", 2), ("ab.b", 3)]
|
||||
);
|
||||
let ab = report.ab.as_ref().expect("an A/B run has its result");
|
||||
let gpu = &ab.result["gpu_p50_ms"];
|
||||
assert_eq!((gpu.pairs, gpu.a, gpu.b, gpu.diff), (6, 3.0, 5.0, 2.0));
|
||||
assert_eq!(gpu.verdict, horizon_bench::compare::Verdict::Slower);
|
||||
assert!(!ab.first_block_outside_range);
|
||||
let metric = |name: &str| report.metrics.get(name).map(|s| (s.n, s.mean));
|
||||
assert_eq!(metric("gpu.ab.a.p50_ms"), Some((6, 3.0)));
|
||||
assert_eq!(metric("gpu.ab.b.p50_ms"), Some((6, 5.0)));
|
||||
assert_eq!(metric("gpu_pass.ab_a.world_ms"), Some((1, 0.5)));
|
||||
assert_eq!(metric("gpu_pass.ab_b.world_ms"), Some((1, 1.5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_setting_that_was_on_is_on_again_after_the_run() {
|
||||
let (tx, rx) = mpsc::channel::<Request>();
|
||||
let server = culling_server(rx, true, true, None);
|
||||
let control = Control(tx);
|
||||
keeping_switch(&control, GPU_CULLING, || {
|
||||
ab_blocks(
|
||||
&control,
|
||||
GPU_CULLING,
|
||||
&quick(2),
|
||||
&mut vulkan_report(),
|
||||
&mut Vec::new(),
|
||||
)
|
||||
})
|
||||
.expect("the blocks run");
|
||||
drop(control);
|
||||
assert!(server.join().unwrap().1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_setting_is_put_back_when_the_run_fails_midway() {
|
||||
let (tx, rx) = mpsc::channel::<Request>();
|
||||
// The 3rd block is A: the setting is off when it fails.
|
||||
let server = culling_server(rx, true, true, Some(3));
|
||||
let control = Control(tx);
|
||||
let mut report = vulkan_report();
|
||||
let error = keeping_switch(&control, GPU_CULLING, || {
|
||||
ab_blocks(
|
||||
&control,
|
||||
GPU_CULLING,
|
||||
&quick(6),
|
||||
&mut report,
|
||||
&mut Vec::new(),
|
||||
)
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(error.contains("the window closed"), "{error}");
|
||||
assert!(report.frames.is_empty() && report.ab.is_none());
|
||||
drop(control);
|
||||
let (lines, on) = server.join().unwrap();
|
||||
assert!(on, "on was the player's setting");
|
||||
assert_eq!(lines.last().unwrap(), "gpu-culling on");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_switch_ends_the_run_with_the_games_reason_and_changes_nothing() {
|
||||
let (tx, rx) = mpsc::channel::<Request>();
|
||||
let server = culling_server(rx, false, false, None);
|
||||
let control = Control(tx);
|
||||
let error = keeping_switch(&control, GPU_CULLING, || {
|
||||
apply_set(&control, "gpu-culling", "on")
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(error.contains("not offered here"), "{error}");
|
||||
drop(control);
|
||||
let (lines, on) = server.join().unwrap();
|
||||
assert_eq!(lines, ["gpu-culling", "gpu-culling on", "gpu-culling"]);
|
||||
assert!(!on);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaw_matches_vanilla() {
|
||||
// South (+Z) is 0, west (-X) 90, east (+X) -90.
|
||||
|
||||
@@ -589,12 +589,22 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
cmd!("mcp", Scripting, "mcp [--paced]", "serve the Model Context Protocol on stdin/stdout, one tool per command",
|
||||
"Add it to an MCP client as a stdio server: command horizon-ctl, args [\"mcp\"] (with --socket PATH first if needed). The screenshot tool returns the image. --paced stops a single-player world between tool calls: the pause menu opens after each action and closes before the next, so time passes only while an action (or wait) runs, not while the agent thinks; reading tools (state, inventory, entities, events...) leave it paused.",
|
||||
["horizon-ctl mcp", "horizon-ctl mcp --paced"], &[], mcp: false, local: true),
|
||||
cmd!("bench", Scripting, "bench SECONDS", "reset frame stats, wait, reply with the summary", "", ["horizon-ctl bench 10"], &[p("seconds", Ty::Num, "how long to measure")], mcp: true, local: false),
|
||||
cmd!("stats", Scripting, "stats [reset] [raw]", "frame-time summary since the last reset", "", ["horizon-ctl stats"], &[opt("reset", Ty::Bool, "start counting again"), opt("raw", Ty::Bool, "every frame time too (frame_ms, cpu_ms, gpu_ms)")], mcp: true, local: false),
|
||||
cmd!("gpu-passes", Scripting, "gpu-passes [on|off]", "time Horizon's own render passes on the GPU, shown in F3 and in stats and bench", "", ["horizon-ctl gpu-passes on"], &[opt("mode", Ty::Enum(&["on", "off"]), "on or off; without it, only reports")], mcp: false, local: false),
|
||||
cmd!("trace", Scripting, "trace [FILE [SECONDS] | stop]", "write every thread's timed spans to FILE as a timeline that ui.perfetto.dev opens, for SECONDS or until stop",
|
||||
"Each span (ticks by system, streaming, uploads, far terrain, lighting, the renderer) shows on the thread that ran it, nested by time. Without arguments, only reports whether a trace is being written. HORIZON_TRACE=FILE writes one from the start; horizon-trace on a dedicated server's console writes one to reports/. See docs/graphics.md.",
|
||||
["horizon-ctl trace /tmp/frame.json 10", "horizon-ctl trace stop"], &[], mcp: false, local: false),
|
||||
cmd!("bench", Scripting, "bench SECONDS [raw]", "reset frame stats, wait, reply with the summary", "", ["horizon-ctl bench 10", "horizon-ctl bench 4 raw"], &[p("seconds", Ty::Num, "how long to measure"), opt("raw", Ty::Bool, "every frame time too (frame_ms, cpu_ms, gpu_ms; alloc_count and alloc_bytes in an alloc-count build)")], mcp: true, local: false),
|
||||
cmd!("stats", Scripting, "stats [reset] [raw]", "frame-time summary since the last reset", "A build with the alloc-count feature adds alloc: the window thread's allocations and KiB per frame and all threads' allocations per second, else null.", ["horizon-ctl stats"], &[opt("reset", Ty::Bool, "start counting again"), opt("raw", Ty::Bool, "every frame time too (frame_ms, cpu_ms, gpu_ms; alloc_count and alloc_bytes in an alloc-count build)")], mcp: true, local: false),
|
||||
cmd!("gpu-passes", Scripting, "gpu-passes [on|off|detail]", "time Horizon's own render passes on the GPU, shown in F3 and in stats and bench; detail adds dispatch times and, on Vulkan, fragment and vertex counts per world pass", "", ["horizon-ctl gpu-passes on"], &[opt("mode", Ty::Enum(&["on", "off", "detail"]), "on, off or detail; without it, only reports")], mcp: false, local: false),
|
||||
cmd!("trace", Scripting, "trace [FILE [SECONDS] | stop]", "write every thread's timed spans to FILE as a .hzperf capture holding a timeline, for SECONDS or until stop",
|
||||
"Each span (ticks by system, streaming, uploads, far terrain, lighting, the renderer) is recorded on the thread that ran it, with its depth; the timeline stops after 60 seconds. trace stop replies with the file's path once it is whole. Without arguments, only reports whether a trace is being written. HORIZON_TRACE=FILE writes one from the start; horizon-trace on a dedicated server's console writes one to reports/. Open the file in the web UI. See docs/graphics.md.",
|
||||
["horizon-ctl trace /tmp/frame.hzperf 10", "horizon-ctl trace stop"], &[], mcp: false, local: false),
|
||||
cmd!("profile", Scripting, "profile [client|server] start [--seconds S] [--hz N] [--over MS] [--threads main|all] [--wall] [--gpu[=detail]] [--no-samples] [--timeline] | stop [--bench REPORT] | status | mark NAME | open FILE | share FILE", "capture the game's performance to a .hzperf file in reports/ in the game home",
|
||||
"start records the frame times, the health history and the render thread's call trees until stop or --seconds (at most 600). stop replies with the file's path once the file is whole, and with the viewer's address (open_at) only in a build that has a part to open captures; open FILE serves the file once on 127.0.0.1 and opens the viewer on it, share FILE uploads it and replies with the link; both fail in a build without that part and work with every telemetry switch off; status says whether a capture runs, its age and its size; mark NAME puts a label at the current time. F3+O does start and stop from the keyboard; HORIZON_PROFILE=DIR records the first 10 minutes of a run into DIR. --gpu adds each render pass's median GPU time and --gpu=detail the dispatch times and, on Vulkan, fragment and vertex counts per pass; HORIZON_SHADER_PACK_TIMING and HORIZON_GPU_PASS_DETAIL start a capture with them. stop --bench REPORT embeds a benchmark report (benchmarks/*.json) in the capture; --benchmark --profile does this by itself. Read the file with horizon-perf (query gpu). With no side a single-player run captures both sides in one file (the window, and the server thread as its own section); client or server captures one. Connected to a Horizon server, server asks that server to capture its side: it needs operator level 4, only one server capture runs at a time, and the server keeps the file in its reports/ and logs who asked; a refusal is a warning in the window's own capture.",
|
||||
["horizon-ctl profile start --seconds 30", "horizon-ctl profile mark loaded-the-nether", "horizon-ctl profile stop", "horizon-ctl profile open reports/profile-20251107-143844Z.hzperf"],
|
||||
&[p("action", Ty::Enum(&["start", "stop", "status", "mark", "open", "share"]), "what to do"), opt("name", Ty::Rest, "mark: the label; open and share: the file"),
|
||||
flag("side", Ty::Enum(&["client", "server"]), "client or server, for the window or the server (the single-player server thread, or the server joined); both when omitted"), flag("seconds", Ty::Int, "start: stop after this many seconds, 1 to 600"),
|
||||
flag("hz", Ty::Int, "start: samples per second, 10 to 1000 (default 250)"), flag("over", Ty::Num, "start: sample only frames slower than this many milliseconds"),
|
||||
flag("threads", Ty::Enum(&["main", "all"]), "start: the render thread (default) or every thread that opted in"), flag("wall", Ty::Bool, "start: also sample on the wall clock, to find waits"),
|
||||
flag("gpu", Ty::Enum(&["passes", "detail"]), "start: also record each render pass's GPU time (detail: its parts and counters too)"),
|
||||
flag("no-samples", Ty::Bool, "start: record frame times and health only"), flag("timeline", Ty::Bool, "start: also record every thread's spans, for 60 seconds at most"),
|
||||
flag("bench", Ty::Str, "stop: the path of a benchmark report to embed in the capture")], mcp: true, local: false),
|
||||
cmd!("screenshot-sequence", Scripting, "screenshot-sequence FRAMES STEP_MS PATH|cancel", "draw a shader pack's first FRAMES frames on a set clock and write the last as a PNG",
|
||||
"Needs HORIZON_STILL and a frozen world (docs/agent-control.md). The pack starts over, frame n's clock is n frames and (n - 1) × STEP_MS milliseconds, the PNG is written as screenshot PATH writes it, and the reply comes once it is. Relative paths are taken from horizon-ctl's directory. cancel stops a running sequence.",
|
||||
["horizon-ctl screenshot-sequence 64 50 frame64.png", "horizon-ctl screenshot-sequence 64 16.666667 frame64.png", "horizon-ctl screenshot-sequence cancel"],
|
||||
|
||||
@@ -71,6 +71,10 @@ pub fn trample_allowed(entity: i32, kind: &str, position: BlockPos, distance: f6
|
||||
let (Some(dimension), Some(hook)) = (LEVEL.get(), TRAMPLE.get()) else {
|
||||
return true;
|
||||
};
|
||||
// A grouped mob turn: no mod hooks it (`native_client_hooks::Deferred`).
|
||||
if crate::native_client_hooks::deferring() {
|
||||
return true;
|
||||
}
|
||||
let mut cancel = false;
|
||||
let at = [position.x, position.y, position.z];
|
||||
hook(dimension, entity, kind, at, distance, &mut cancel);
|
||||
@@ -87,7 +91,8 @@ fn dispatch(event: Event<'_>, damage: &mut f32) {
|
||||
let contact = match event {
|
||||
Event::StepOn(c) | Event::Inside(c) | Event::FallOn(c, _) => c,
|
||||
};
|
||||
if contact.block.starts_with("minecraft:") {
|
||||
// A grouped mob turn reaches no mod's hook (`native_client_hooks::Deferred`).
|
||||
if contact.block.starts_with("minecraft:") || crate::native_client_hooks::deferring() {
|
||||
return;
|
||||
}
|
||||
if let Some(dimension) = LEVEL.get()
|
||||
@@ -213,6 +218,15 @@ mod tests {
|
||||
let bounds = bounds.move_by(Vec3::new(-1.0, 0.0, 0.0));
|
||||
swept_inside(17, c.kind, at, at, bounds, block);
|
||||
assert_eq!(SEEN.get(), 8, "stationary overlap still notifies");
|
||||
// A grouped mob turn, on any thread: no mod hears its contacts or tramples.
|
||||
let ((), _) = crate::native_client_hooks::deferring_turn(|| {
|
||||
emit(Event::StepOn(c));
|
||||
assert_eq!(fall(c, 12.0, 10.0), 10.0);
|
||||
let _ = TRAMPLE.set(|_, _, _, _, _, cancel| *cancel = true);
|
||||
assert!(trample_allowed(17, c.kind, c.position, 12.0));
|
||||
});
|
||||
assert_eq!(SEEN.get(), 8, "a grouped turn reaches no contact hook");
|
||||
assert!(!trample_allowed(17, c.kind, c.position, 12.0));
|
||||
});
|
||||
let _ =
|
||||
std::panic::catch_unwind(|| in_level(Some("minecraft:the_nether"), || panic!("test")));
|
||||
|
||||
@@ -40,6 +40,17 @@ pub fn carries_record(stack: &crate::items::ItemStack) -> bool {
|
||||
.is_some_and(|data| data.get("RecordItem").is_some())
|
||||
}
|
||||
|
||||
/// `LecternBlock.getStateForPlacement`: the item's `minecraft:block_entity_data`
|
||||
/// carries a `Book` (`TypedEntityData.contains` checks the key only), so the
|
||||
/// placed lectern's `has_book` state says so. Vanilla reads the item's data,
|
||||
/// not the block entity the placement just filled.
|
||||
pub fn carries_book(stack: &crate::items::ItemStack) -> bool {
|
||||
stack
|
||||
.data()
|
||||
.and_then(|d| d.other(BLOCK_ENTITY_DATA))
|
||||
.is_some_and(|data| data.get("Book").is_some())
|
||||
}
|
||||
|
||||
/// `BlockItem.shouldPrintOpWarning` for a player with game master permission:
|
||||
/// the item carries data of an op-only type.
|
||||
pub fn op_warning(stack: &crate::items::ItemStack) -> bool {
|
||||
@@ -112,18 +123,32 @@ pub fn spawn_egg_entity_data<'a>(
|
||||
|
||||
/// `EntityType.appendDefaultStackConfig` for a hanging entity or armor stand an
|
||||
/// item places: `Entity.applyImplicitComponents` stores the item's
|
||||
/// `minecraft:custom_data` as the entity's `data`, then `updateCustomEntityTag`
|
||||
/// `minecraft:custom_name` as the entity's `CustomName` and its
|
||||
/// `minecraft:custom_data` as its `data`, then `updateCustomEntityTag`
|
||||
/// (`TypedEntityData.loadInto`) merges the item's `minecraft:entity_data` over
|
||||
/// the entity's saved tag `own` when its type is `kind`.
|
||||
pub fn stack_configured_entity(stack: &crate::items::ItemStack, kind: &str, own: &Tag) -> Tag {
|
||||
stack_configured_entity_by(stack, kind, own, false)
|
||||
}
|
||||
|
||||
/// [`stack_configured_entity`], `op` saying whether the user may load the
|
||||
/// data of an op-only entity type (`EntityType.onlyOpCanSetNbt`).
|
||||
pub fn stack_configured_entity_by(
|
||||
stack: &crate::items::ItemStack,
|
||||
kind: &str,
|
||||
own: &Tag,
|
||||
op: bool,
|
||||
) -> Tag {
|
||||
let mut tag = own.clone();
|
||||
if let (Tag::Compound(map), Some(custom)) = (
|
||||
&mut tag,
|
||||
stack.data().and_then(|d| d.other("minecraft:custom_data")),
|
||||
) {
|
||||
map.insert("data".into(), custom.clone());
|
||||
if let Tag::Compound(map) = &mut tag {
|
||||
if let Some(name) = stack.data().and_then(|d| d.custom_name.clone()) {
|
||||
map.insert("CustomName".into(), name);
|
||||
}
|
||||
if let Some(custom) = stack.data().and_then(|d| d.other("minecraft:custom_data")) {
|
||||
map.insert("data".into(), custom.clone());
|
||||
}
|
||||
}
|
||||
match spawn_egg_entity_data(stack, kind, false) {
|
||||
match spawn_egg_entity_data(stack, kind, op) {
|
||||
Some(data) => crate::data_cmd::merged(&tag, data),
|
||||
None => tag,
|
||||
}
|
||||
|
||||
Loaded 100 of 775 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user