mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
648
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d7704f3f3 | ||
|
|
771541d109 | ||
|
|
6b75c9cb79 | ||
|
|
2baf3a4847 | ||
|
|
b46279d5b5 | ||
|
|
cee89b5498 | ||
|
|
b208196224 | ||
|
|
b5c41f49d8 | ||
|
|
e0798a4224 | ||
|
|
4921de8282 | ||
|
|
b319334189 | ||
|
|
555c69406f | ||
|
|
900416b606 | ||
|
|
8045ecd4e7 | ||
|
|
c5fb2ba820 | ||
|
|
795024bfc4 | ||
|
|
036a2a2cb6 | ||
|
|
55501878f1 | ||
|
|
815a3434fe | ||
|
|
6be843b43c | ||
|
|
f8de9f502d | ||
|
|
3cb1f356ab | ||
|
|
c6d447748c | ||
|
|
92694f6d2c | ||
|
|
a226114661 | ||
|
|
84e9ba93b5 | ||
|
|
decba13c64 | ||
|
|
c41444f589 | ||
|
|
c844022a28 | ||
|
|
ae0453ebba | ||
|
|
a59c1ae8b0 | ||
|
|
23bbd81179 | ||
|
|
87a5fc6ad0 | ||
|
|
a22f5b6e36 | ||
|
|
73d6a71826 | ||
|
|
6ddd14e727 | ||
|
|
a89eb944c6 | ||
|
|
3038fdf208 | ||
|
|
4cd2759bb2 | ||
|
|
23a886d936 | ||
|
|
862be0a0d2 | ||
|
|
b2ebfbe833 | ||
|
|
41ba00cc91 | ||
|
|
01634eab69 | ||
|
|
53e6fa637a | ||
|
|
d4cbf5a5f9 | ||
|
|
c5d6cafb96 | ||
|
|
f6c4c0b17e | ||
|
|
337fbf9f4d | ||
|
|
f7484e79d9 | ||
|
|
b4adeca3fc | ||
|
|
1684a32896 | ||
|
|
8a170c33ec | ||
|
|
0d762cbcbe | ||
|
|
8125129805 | ||
|
|
ae3823b97a | ||
|
|
6461774f70 | ||
|
|
f826a42c32 | ||
|
|
1768e0d3f4 | ||
|
|
e5774bf3ba | ||
|
|
6275a3a20e | ||
|
|
008c42e28f | ||
|
|
abeecdf0a4 | ||
|
|
9bfd438a2f | ||
|
|
d571d80a06 | ||
|
|
553de75e96 | ||
|
|
c4e241f755 | ||
|
|
954baa8a14 | ||
|
|
871a75e6f2 | ||
|
|
2b2ab7878c | ||
|
|
2f8c8ddab7 | ||
|
|
cc4b539cd0 | ||
|
|
f181cc2e47 | ||
|
|
f8c94db47c | ||
|
|
a1e620cb84 | ||
|
|
bf16f0232f | ||
|
|
5ded38a494 | ||
|
|
2fa97798c2 | ||
|
|
25980cfd03 | ||
|
|
7ce774c5d6 | ||
|
|
5c6cdd645b | ||
|
|
575cf3e0e7 | ||
|
|
fab37a4094 | ||
|
|
8069aa8ced | ||
|
|
e09c4098ad | ||
|
|
be704330d9 | ||
|
|
88c640e8a7 | ||
|
|
9e0cefdef9 | ||
|
|
8d09551a20 | ||
|
|
d529272b02 | ||
|
|
a82ff8ba68 | ||
|
|
d190e2b642 | ||
|
|
8d856de117 | ||
|
|
4bb36fac4c | ||
|
|
40e4d970b0 | ||
|
|
56827bc8f8 | ||
|
|
6fc1ba82b4 | ||
|
|
fed60fb986 | ||
|
|
69c3e37905 | ||
|
|
9014294e55 | ||
|
|
3aac8eb917 | ||
|
|
21d3fffde2 | ||
|
|
010ab500f9 | ||
|
|
ff8a09cc62 | ||
|
|
94a184e236 | ||
|
|
b9091525a7 | ||
|
|
381517f87f | ||
|
|
1e9a62f124 | ||
|
|
47f1c39b7e | ||
|
|
2980894c35 | ||
|
|
e57b1d34bb | ||
|
|
dc5a91ac44 | ||
|
|
d08d24e9c2 | ||
|
|
1a91ea6936 | ||
|
|
cddb55263c | ||
|
|
ef4e5c049d | ||
|
|
42ae7fffe1 | ||
|
|
6b91fe90d5 | ||
|
|
3070364215 | ||
|
|
aab356c6c4 | ||
|
|
46322061d7 | ||
|
|
f85777b490 | ||
|
|
c496609c3a | ||
|
|
e9448bd997 | ||
|
|
5568e9d693 | ||
|
|
cb0b56a9ed | ||
|
|
e7174a8571 | ||
|
|
1bae636ef6 | ||
|
|
637d885453 | ||
|
|
bb99dd0fb9 | ||
|
|
7ef2b97025 | ||
|
|
79d298d339 | ||
|
|
8733f83fd1 | ||
|
|
59d8cc38a2 | ||
|
|
be11ce8db1 | ||
|
|
7db3c5afef | ||
|
|
1bacc5cee0 | ||
|
|
2181ab22eb | ||
|
|
eeeabc13fc | ||
|
|
79576177db | ||
|
|
47f857e3c5 | ||
|
|
5eb115301b | ||
|
|
031ce8d748 | ||
|
|
7acb499c7e | ||
|
|
4093c1de28 | ||
|
|
3e5987615d | ||
|
|
7dbc6f7c4c | ||
|
|
da82eadb2a | ||
|
|
4fc709af20 | ||
|
|
da4f89e3d7 | ||
|
|
3a8609eada | ||
|
|
84f20cd34e | ||
|
|
88f0faf67d | ||
|
|
25f65c8441 | ||
|
|
03e77c9fb9 | ||
|
|
c909ca58e6 | ||
|
|
5150ff66bf | ||
|
|
9f346f9794 | ||
|
|
9f69fa38e3 | ||
|
|
9fac526fef | ||
|
|
5eef8152d8 | ||
|
|
d0ac649f08 | ||
|
|
5d8f9fc2fc | ||
|
|
12306e9a47 | ||
|
|
234bccad77 | ||
|
|
5a5573a7c1 | ||
|
|
712d52f326 | ||
|
|
f3386151d2 | ||
|
|
bd0a318660 | ||
|
|
e8d6861b39 | ||
|
|
d2e6d705a7 | ||
|
|
02e68e1592 | ||
|
|
ee3915a050 | ||
|
|
1ac9480b90 | ||
|
|
47ad28cee8 | ||
|
|
27d625d859 | ||
|
|
818cb416b3 | ||
|
|
56282c484f | ||
|
|
d562d76fd9 | ||
|
|
ee06d18c39 | ||
|
|
529f1660b0 | ||
|
|
ecdd4eb222 | ||
|
|
527c6de7cd | ||
|
|
c5ae81b41f | ||
|
|
220717bd56 | ||
|
|
c62113c79b | ||
|
|
4e8febfe57 | ||
|
|
e914dfb4b9 | ||
|
|
764989a2d4 | ||
|
|
6bf332f5c5 | ||
|
|
a6e6ad25c5 | ||
|
|
0533124415 | ||
|
|
b7a1dd4e41 | ||
|
|
90ecdc3dd0 | ||
|
|
2e5a2629a0 | ||
|
|
909f6c4e96 | ||
|
|
132d0f3022 | ||
|
|
1267b1e360 | ||
|
|
5ff12dfaf4 | ||
|
|
6e215078fe | ||
|
|
9cc1db4268 | ||
|
|
3c3d54137b | ||
|
|
527152f6f4 | ||
|
|
56a2de7d42 | ||
|
|
a61f360fa5 | ||
|
|
8788891c88 | ||
|
|
d9901d9fb4 | ||
|
|
908d81bd4c | ||
|
|
bb1f287b39 | ||
|
|
9e642fbe77 | ||
|
|
5839363b51 | ||
|
|
16acafe526 | ||
|
|
6db44f17af | ||
|
|
cf667d7e21 | ||
|
|
7cf2b63e87 | ||
|
|
a65ee808dc | ||
|
|
3b8896b3b9 | ||
|
|
c0703a62bc | ||
|
|
137956911f | ||
|
|
7740127129 | ||
|
|
6c5cd897fc | ||
|
|
c787285cfb | ||
|
|
3b2da02d11 | ||
|
|
b1d0b765d4 | ||
|
|
0805df6e08 | ||
|
|
86f9a3f780 | ||
|
|
ff63338824 | ||
|
|
411ace47fe | ||
|
|
1a3e0f94e7 | ||
|
|
d52e1187b4 | ||
|
|
4682b7838c | ||
|
|
22a7070cdb | ||
|
|
5d06f30d10 | ||
|
|
84b3bf3027 | ||
|
|
168a30e617 | ||
|
|
ab6d67da82 | ||
|
|
4fdf97368b | ||
|
|
5d5de221a7 | ||
|
|
b815d63191 | ||
|
|
1e7cf8b5c8 | ||
|
|
b156e0570c | ||
|
|
6d07525616 | ||
|
|
c34cd71603 | ||
|
|
b9dc4c1f96 | ||
|
|
67a19214c3 | ||
|
|
17ce0331f8 | ||
|
|
08a514dc26 | ||
|
|
c915f2a878 | ||
|
|
e013d898ff | ||
|
|
99eb6630b7 | ||
|
|
c036a7473e | ||
|
|
bdb45ed319 | ||
|
|
0492f8db4e | ||
|
|
20ae91bbde | ||
|
|
153ef53e42 | ||
|
|
0e58346b64 | ||
|
|
6f62647a23 | ||
|
|
1aea26b3e0 | ||
|
|
569f9644dc | ||
|
|
75b6327008 | ||
|
|
cd8d1c5fd7 | ||
|
|
5013115571 | ||
|
|
db733482fd | ||
|
|
6b33c32f7b | ||
|
|
bf939d4926 | ||
|
|
d36aaf9c0c | ||
|
|
613b1f9574 | ||
|
|
14e59bf1ad | ||
|
|
58ffef7e6b | ||
|
|
77f9739ab0 | ||
|
|
22148d740e | ||
|
|
405186aa36 | ||
|
|
89e8dd9c16 | ||
|
|
e30618104b | ||
|
|
dfd4d41e63 | ||
|
|
85d380ff3c | ||
|
|
640ff80c83 | ||
|
|
695feba0bc | ||
|
|
d0da65d108 | ||
|
|
6d9c504d06 | ||
|
|
98e73cd84b | ||
|
|
d580df1589 | ||
|
|
180e7b2631 | ||
|
|
a4d2184bdf | ||
|
|
5752c7bb5b | ||
|
|
38c31cac5d | ||
|
|
2c7b49062e | ||
|
|
94ccd9c8ee | ||
|
|
15f1090ed1 | ||
|
|
4d7b9f28b7 | ||
|
|
12e9795542 | ||
|
|
7bacc86a5e | ||
|
|
e9109d87f4 | ||
|
|
39a8fc809b | ||
|
|
7486e7ba45 | ||
|
|
7a1256e51d | ||
|
|
8cfa002c12 | ||
|
|
7704a05c0b | ||
|
|
ae2d4b2442 | ||
|
|
bc2adf84dc | ||
|
|
19b0fd00ca | ||
|
|
1fb681318d | ||
|
|
48c2142692 | ||
|
|
fed2d76e02 | ||
|
|
bc9d7d3807 | ||
|
|
bd72a6a4eb | ||
|
|
6c593adc80 | ||
|
|
2f904b7019 | ||
|
|
8719c32206 | ||
|
|
3d2e7d93d4 | ||
|
|
4fc40ee8e0 | ||
|
|
815b10e907 | ||
|
|
9c11a961b4 | ||
|
|
72352fd257 | ||
|
|
076ed2dd4a | ||
|
|
fda782feeb | ||
|
|
04542d1eff | ||
|
|
fc381670ce | ||
|
|
bb9d8c7d5b | ||
|
|
b8eaa4cb3c | ||
|
|
6e4d10fcd1 | ||
|
|
aabda3e079 | ||
|
|
94b8b2fb47 | ||
|
|
492887393e | ||
|
|
f8fe006043 | ||
|
|
badc87b99e | ||
|
|
de08a70aa5 | ||
|
|
2f4de5382f | ||
|
|
81977ac47e | ||
|
|
b5efda5f7d | ||
|
|
e61dd55a1e | ||
|
|
d610f6ed98 | ||
|
|
4bcc83ce33 | ||
|
|
74e2e70389 | ||
|
|
7f39f7025c | ||
|
|
807b7a62f5 | ||
|
|
c74d24ac9d | ||
|
|
54ef85585d | ||
|
|
0d67cfd734 | ||
|
|
ec037e9e7f | ||
|
|
b6204bf860 | ||
|
|
c2aa51c15f | ||
|
|
bac0d3ca47 | ||
|
|
850c43dedb | ||
|
|
7474284c81 | ||
|
|
603bd9e4ad | ||
|
|
c6093b3821 | ||
|
|
2a410bab80 | ||
|
|
f9e59fa328 | ||
|
|
671e8750e9 | ||
|
|
dbdfed59aa | ||
|
|
f1566f80d3 | ||
|
|
67cfb2dc40 | ||
|
|
d75f5a4280 | ||
|
|
2c87445fff | ||
|
|
218c8db228 | ||
|
|
be4ee9131a | ||
|
|
8fdb2ffce7 | ||
|
|
b13109ea12 | ||
|
|
210234f2ad | ||
|
|
8237939970 | ||
|
|
e91ba5d21a | ||
|
|
3f9ccee0fd | ||
|
|
87b5918538 | ||
|
|
2358c282ca | ||
|
|
e8c7ebb11f | ||
|
|
f2a95400e3 | ||
|
|
f0d96d8648 | ||
|
|
3572bf84fd | ||
|
|
9255ab808d | ||
|
|
d3a1908ac1 | ||
|
|
0c8a5431ab | ||
|
|
f5a937cb4c | ||
|
|
c388810601 | ||
|
|
f557d78c34 | ||
|
|
1ab1d98c1c | ||
|
|
5dc8279a5d | ||
|
|
663ce80174 | ||
|
|
7b300cc61e | ||
|
|
e60a96790d | ||
|
|
18f07a0304 | ||
|
|
631ededd91 | ||
|
|
6e602c4c2a | ||
|
|
caf9530c20 | ||
|
|
24b89f986d | ||
|
|
e11b83fc8e | ||
|
|
ee746cf569 | ||
|
|
6a627e54b1 | ||
|
|
0a6527a5f3 | ||
|
|
f8a85f1bf9 | ||
|
|
007df3944e | ||
|
|
ee85900b87 | ||
|
|
64afd9f58a | ||
|
|
705645a7ab | ||
|
|
5ce693ae48 | ||
|
|
83380b3b30 | ||
|
|
33ba27d9f5 | ||
|
|
4439459e71 | ||
|
|
e128384ac8 | ||
|
|
de3604215d | ||
|
|
4c7bdc63a6 | ||
|
|
58b0da9a87 | ||
|
|
7dfcbcaa0b | ||
|
|
12541fdeeb | ||
|
|
84eb7390db | ||
|
|
18498a7e2d | ||
|
|
e3806f3017 | ||
|
|
8b83cd8285 | ||
|
|
553483c421 | ||
|
|
04bed4f9d8 | ||
|
|
bf460e86cf | ||
|
|
de877a581d | ||
|
|
7ae5d35547 | ||
|
|
b3678682e4 | ||
|
|
217d39afd8 | ||
|
|
80c02acdf1 | ||
|
|
afe23fbba6 | ||
|
|
04322a6f52 | ||
|
|
25574a2d21 | ||
|
|
661e038c08 | ||
|
|
241155c490 | ||
|
|
5d6b0be54b | ||
|
|
ad47d9b3fd | ||
|
|
55fa82033f | ||
|
|
81d02ec5d4 | ||
|
|
ea92f9ea77 | ||
|
|
7ec86a7aec | ||
|
|
62e8a49947 | ||
|
|
1ef6bdd171 | ||
|
|
606a58c04c | ||
|
|
b011ec1e66 | ||
|
|
d230a5dee8 | ||
|
|
7d5f66a915 | ||
|
|
270cd628bb | ||
|
|
c92fcbb17e | ||
|
|
9443236b20 | ||
|
|
2d3d6a0c6e | ||
|
|
f0214c19b5 | ||
|
|
57d465a5a9 | ||
|
|
929aa267e0 | ||
|
|
b2ebcee304 | ||
|
|
25a0cf0396 | ||
|
|
ab167cadb9 | ||
|
|
88bd95bb32 | ||
|
|
f106b832ef | ||
|
|
5198fae465 | ||
|
|
4baa918620 | ||
|
|
344a829699 | ||
|
|
f0634ed6f2 | ||
|
|
73715cdab4 | ||
|
|
f83a9c1ae9 | ||
|
|
2e4a5a13a6 | ||
|
|
b873ea2dd6 | ||
|
|
f1f7847050 | ||
|
|
5bdbffb5a5 | ||
|
|
f46a9c83a0 | ||
|
|
f198e226ee | ||
|
|
c25f708490 | ||
|
|
17bc3161d5 | ||
|
|
4cc3fc51d9 | ||
|
|
e05ce228d6 | ||
|
|
1013b2dc5e | ||
|
|
6c4a43178f | ||
|
|
9515965bd5 | ||
|
|
785fd33992 | ||
|
|
0ddc08b053 | ||
|
|
80c9d8d5a7 | ||
|
|
ca31bc0c02 | ||
|
|
2f24d605ce | ||
|
|
51ea650d28 | ||
|
|
d46ebbdaa5 | ||
|
|
48b17308b2 | ||
|
|
e2e4df377c | ||
|
|
6cd68ac305 | ||
|
|
fcb7d020cb | ||
|
|
96afb04b19 | ||
|
|
acd5d78e64 | ||
|
|
0d1c7706fc | ||
|
|
d4d03d6219 | ||
|
|
e50732827a | ||
|
|
621f9496db | ||
|
|
3382af51d8 | ||
|
|
35af5c3a6c | ||
|
|
632746dbb7 | ||
|
|
6588538fe8 | ||
|
|
2354aeaf29 | ||
|
|
018098927c | ||
|
|
25bf0ae04d | ||
|
|
ba06ac2671 | ||
|
|
22488873a6 | ||
|
|
e52df2a4e7 | ||
|
|
a9e26418ad | ||
|
|
f7f0e04f7a | ||
|
|
c152d0c7dd | ||
|
|
4d79106840 | ||
|
|
3ab97d81fb | ||
|
|
a631a2c4fc | ||
|
|
71efcb3bd7 | ||
|
|
a43750547f | ||
|
|
d5ca800e8f | ||
|
|
70bfeb0b45 | ||
|
|
bea9bad765 | ||
|
|
b26016c7d0 | ||
|
|
35122d81fb | ||
|
|
02f5c497a9 | ||
|
|
6bab8124e1 | ||
|
|
b566311709 | ||
|
|
0ab6729535 | ||
|
|
dd1fc46290 | ||
|
|
1ade779174 | ||
|
|
765697c19b | ||
|
|
9f5d8f7992 | ||
|
|
8a05c5c899 | ||
|
|
0cff71db71 | ||
|
|
b723f5e5c5 | ||
|
|
824af41138 | ||
|
|
8cac78d7fa | ||
|
|
1a3125fcfd | ||
|
|
e3e7502659 | ||
|
|
84e3266fcc | ||
|
|
c54af83a72 | ||
|
|
6351cb3e1d | ||
|
|
933ed06f02 | ||
|
|
1ef25329f9 | ||
|
|
2f6f693c7b | ||
|
|
815cada227 | ||
|
|
ebcd2d2ba0 | ||
|
|
81a99d60f0 | ||
|
|
21dba43d9f | ||
|
|
31167b1290 | ||
|
|
306fdba861 | ||
|
|
5ebc5279dc | ||
|
|
db38db7622 | ||
|
|
1a1887f52e | ||
|
|
5101d519d2 | ||
|
|
c1a8542360 | ||
|
|
858c0a8de1 | ||
|
|
9f666f514a | ||
|
|
1301409191 | ||
|
|
c785e69eda | ||
|
|
1d4869bf83 | ||
|
|
e6fa96799f | ||
|
|
f65b7642e7 | ||
|
|
cfb2db51a7 | ||
|
|
caf3d88be6 | ||
|
|
c7d577e4c8 | ||
|
|
0ec74c25b5 | ||
|
|
b97c098c23 | ||
|
|
9c63f70f62 | ||
|
|
a96240dcfb | ||
|
|
6ebcff17a2 | ||
|
|
eacb3e16a8 | ||
|
|
16b49d2810 | ||
|
|
699fde5c01 | ||
|
|
9f8dc4e9f2 | ||
|
|
993d04b098 | ||
|
|
275856c8bb | ||
|
|
bc97e77d55 | ||
|
|
8786783616 | ||
|
|
a9db1d3291 | ||
|
|
e46fdc8aba | ||
|
|
98f679bd84 | ||
|
|
e7db06bfca | ||
|
|
414e7a5c07 | ||
|
|
437f078e47 | ||
|
|
ea85770a6f | ||
|
|
5db48acf25 | ||
|
|
1c57d808d8 | ||
|
|
ae59c5dde7 | ||
|
|
f4f901f2b1 | ||
|
|
7b47de35c3 | ||
|
|
c9ecd5d900 | ||
|
|
47b64a3288 | ||
|
|
6fabf6d69a | ||
|
|
c600fea060 | ||
|
|
ff02139e7f | ||
|
|
2e669e614d | ||
|
|
154d703413 | ||
|
|
312885da94 | ||
|
|
7ff407670a | ||
|
|
b5635af3b1 | ||
|
|
478bdb0d27 | ||
|
|
6fd33b70f7 | ||
|
|
09dd6322b4 | ||
|
|
2082bd559c | ||
|
|
0954041bd5 | ||
|
|
458199f845 | ||
|
|
4a30280e22 | ||
|
|
0a5b6efe10 | ||
|
|
31ee745f7e | ||
|
|
1c88130124 | ||
|
|
e602ed84ac | ||
|
|
ba9dcb41e4 | ||
|
|
a1c4fa382e | ||
|
|
c0cd050fac | ||
|
|
ca17074fd9 | ||
|
|
28c50a1f0b | ||
|
|
f725a92d3d | ||
|
|
be258bc235 | ||
|
|
7140f3cbd2 | ||
|
|
61791d3e20 | ||
|
|
7d59356e42 | ||
|
|
012eaf84f0 | ||
|
|
13698219ce | ||
|
|
710a2ad3a9 | ||
|
|
9ae15a3eac | ||
|
|
faa8667704 | ||
|
|
2ef3c9d5f2 | ||
|
|
2a2d59e505 | ||
|
|
425c842a51 | ||
|
|
60bbc4f278 | ||
|
|
a283fd939b | ||
|
|
2282df048d | ||
|
|
703ab70218 | ||
|
|
c217b00548 | ||
|
|
78dd8d2032 | ||
|
|
55d3014458 | ||
|
|
325f338203 | ||
|
|
49886f7154 | ||
|
|
4f1704263f | ||
|
|
68f626d9f6 | ||
|
|
85556f651b | ||
|
|
b5d039487b | ||
|
|
fa62beefdf | ||
|
|
b8740c5d9d | ||
|
|
bb55cab336 | ||
|
|
a2d36b2f31 | ||
|
|
e3b0f06133 | ||
|
|
52e29c1ac7 | ||
|
|
59140732d6 | ||
|
|
ab212945d7 | ||
|
|
765cec5be7 | ||
|
|
9267d457a0 | ||
|
|
284fba6c68 | ||
|
|
34543f259f | ||
|
|
45b1e501de | ||
|
|
ed7f1f4428 | ||
|
|
5bd3afa1ad | ||
|
|
f545530d3e | ||
|
|
71566a3268 | ||
|
|
0b47accde1 | ||
|
|
349deacbb3 | ||
|
|
d0bdbc9eb0 | ||
|
|
03f2b226ff | ||
|
|
d9e8401558 | ||
|
|
2c9dac6bbc | ||
|
|
7df27f1cf9 | ||
|
|
e7bdd16474 |
No files matched your search
@@ -1,3 +1,4 @@
|
||||
self-hosted-runner:
|
||||
# The Mac mini's runner (ludovics-mac-mini), which holds the maintainer's own Minecraft files.
|
||||
labels: [horizon-game, ludovics-mac-mini]
|
||||
# windows-11-vs2026-arm is GitHub's Windows ARM64 image (2026-08), newer than actionlint's label list.
|
||||
labels: [horizon-game, ludovics-mac-mini, windows-11-vs2026-arm]
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
outputs:
|
||||
platforms: ${{ steps.platforms.outputs.platforms }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- id: platforms
|
||||
run: echo "platforms=$(python3 -c 'import json, tools.horizon_mod_binstall as m; print(json.dumps(m.PLATFORMS))')" >> "$GITHUB_OUTPUT"
|
||||
build:
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
[ -f "$binary.exe" ] && binary="$binary.exe"
|
||||
mkdir -p dist
|
||||
tar -czf "dist/$(basename "$binary" | sed 's/\.exe$//')-${{ matrix.target }}.tar.gz" -C "$(dirname "$binary")" "$(basename "$binary")"
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: cargo-horizon-mod-${{ matrix.target }}
|
||||
path: dist/*.tar.gz
|
||||
@@ -81,8 +81,8 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: cargo-horizon-mod-*
|
||||
path: dist
|
||||
|
||||
+93
-10
@@ -41,10 +41,10 @@ jobs:
|
||||
fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v7
|
||||
if: runner.environment == 'github-hosted'
|
||||
with:
|
||||
python-version: '3.12'
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Does this pull request change the launcher or a crate it builds?
|
||||
@@ -159,6 +159,89 @@ jobs:
|
||||
cargo +stable fmt --check
|
||||
cargo +stable clippy --all-targets --locked -- -D warnings
|
||||
cargo +stable test --locked --lib
|
||||
# Compile and test Windows ARM64 on an actual ARM64 host, and inspect the resulting PE headers. By hand only
|
||||
# (`gh workflow run checks --ref <branch>`): GitHub's runner queue is the bottleneck, and main's runs gate the
|
||||
# releases (tools/release_train.py waits for them), so neither pull requests nor main's pushes start this
|
||||
# 90-minute job. The Nightly and launcher releases build and check these binaries on every run.
|
||||
windows-arm64:
|
||||
name: Windows ARM64 game and launcher
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: windows-11-vs2026-arm
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
RUSTFLAGS: -C target-feature=+crt-static
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Use every runner core for Cargo
|
||||
shell: pwsh
|
||||
run: |
|
||||
$cores = (Get-CimInstance Win32_Processor | Measure-Object -Property NumberOfLogicalProcessors -Sum).Sum
|
||||
"CARGO_BUILD_JOBS=$cores" >> $env:GITHUB_ENV
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-pc-windows-msvc
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: windows-arm64-game
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: launcher
|
||||
shared-key: windows-arm64-launcher
|
||||
- name: Check LLVM assembler used by ring
|
||||
shell: pwsh
|
||||
run: |
|
||||
$llvmBin = Join-Path $env:ProgramFiles 'LLVM\bin'
|
||||
if (-not (Get-Command clang -ErrorAction SilentlyContinue) -and (Test-Path (Join-Path $llvmBin 'clang.exe'))) {
|
||||
$env:PATH = "$llvmBin;$env:PATH"
|
||||
"PATH=$env:PATH" >> $env:GITHUB_ENV
|
||||
}
|
||||
$clang = Get-Command clang -ErrorAction Stop
|
||||
Write-Output "Using $($clang.Source)"
|
||||
& $clang.Source --version
|
||||
- name: Build the game for native Windows ARM64
|
||||
run: cargo build --release --locked --target aarch64-pc-windows-msvc -p horizon-client -p horizon-client-lib --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
- name: Check the game's PE machine
|
||||
run: python tools/check_pe_arch.py arm64 target/aarch64-pc-windows-msvc/release/horizon-client.exe target/aarch64-pc-windows-msvc/release/horizon-ctl.exe target/aarch64-pc-windows-msvc/release/horizon-prepare.exe target/aarch64-pc-windows-msvc/release/horizon-server.exe
|
||||
- name: Check game DLL imports
|
||||
run: python tools/check_imports.py target/aarch64-pc-windows-msvc/release/horizon-client.exe target/aarch64-pc-windows-msvc/release/horizon-ctl.exe target/aarch64-pc-windows-msvc/release/horizon-prepare.exe target/aarch64-pc-windows-msvc/release/horizon-server.exe
|
||||
- name: Start every game executable
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$client = 'target/aarch64-pc-windows-msvc/release/horizon-client.exe'
|
||||
$output = & $client --help 2>&1
|
||||
if ($LASTEXITCODE -ne 0 -or ($output -join "`n") -notmatch '--resources') { throw 'horizon-client --help failed' }
|
||||
foreach ($binary in @('horizon-ctl.exe', 'horizon-prepare.exe', 'horizon-server.exe')) {
|
||||
$path = "target/aarch64-pc-windows-msvc/release/$binary"
|
||||
$output = & $path --help 2>&1
|
||||
if ($LASTEXITCODE -ne 0 -or -not $output) { throw "$binary --help failed" }
|
||||
}
|
||||
foreach ($binary in @('horizon-prepare.exe', 'horizon-server.exe')) {
|
||||
$path = "target/aarch64-pc-windows-msvc/release/$binary"
|
||||
$output = & $path --version 2>&1
|
||||
if ($LASTEXITCODE -ne 0 -or -not $output) { throw "$binary --version failed" }
|
||||
Write-Output ($output -join "`n")
|
||||
}
|
||||
- name: Test and build the native Windows ARM64 launcher
|
||||
working-directory: launcher
|
||||
run: |
|
||||
cargo test --release --locked --target aarch64-pc-windows-msvc --lib
|
||||
cargo build --release --locked --target aarch64-pc-windows-msvc
|
||||
- name: Check the launcher's PE machine
|
||||
working-directory: launcher
|
||||
run: python ../tools/check_pe_arch.py arm64 target/aarch64-pc-windows-msvc/release/horizon-launcher.exe
|
||||
- name: Check launcher DLL imports and command-line startup
|
||||
working-directory: launcher
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$launcher = 'target/aarch64-pc-windows-msvc/release/horizon-launcher.exe'
|
||||
python ../tools/check_imports.py $launcher
|
||||
$output = & $launcher --version 2>&1
|
||||
if ($LASTEXITCODE -ne 0 -or -not $output) { throw 'horizon-launcher --version failed' }
|
||||
Write-Output ($output -join "`n")
|
||||
# Most pull requests leave the harnesses to parity-main, which runs them on main a batch of merges at a time
|
||||
# (.github/workflows/parity-main.yml; releases wait for it). A pull request still runs them itself when it changes
|
||||
# a harness, an oracle, a probe or the runner, or carries the run-parity label (run-parity.yml, when it is added).
|
||||
@@ -169,7 +252,7 @@ jobs:
|
||||
outputs:
|
||||
run: ${{ steps.scope.outputs.run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -204,13 +287,13 @@ jobs:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: [self-hosted, linux, horizon-game]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Install Rust toolchain
|
||||
run: rustup toolchain install stable --profile minimal
|
||||
- uses: actions/setup-java@v4
|
||||
- uses: actions/setup-java@v6
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '25'
|
||||
@@ -552,11 +635,11 @@ jobs:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: [self-hosted, linux, horizon-game]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- uses: actions/setup-java@v4
|
||||
- uses: actions/setup-java@v6
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '25'
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: game build
|
||||
# Builds the game for every platform from `ref`: the Java exporters, then Linux and the Linux server and Windows on
|
||||
# GitHub, macOS on the Mac mini's runner with a 1500-frame offscreen run of a new world (smoke-test.json): Apple
|
||||
# Builds the game for every platform from `ref`: the Java exporters, then Linux and the Linux server, Windows x64
|
||||
# and ARM64 on GitHub, and macOS on the Mac mini's runner with a 1500-frame offscreen run of a new world (smoke-test.json): Apple
|
||||
# Silicon, and Intel cross-built there for x86_64-apple-darwin and checked under Rosetta 2 (#1161). Called by
|
||||
# release.yml (the releases) and nightly-build.yml (the opt-in Nightly), always from main's copy of this file: the
|
||||
# caller's commit, never the one being built. It holds no secrets: signing the checksums is the callers' own job,
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
# Three full preparations (two isolated, one batched) besides the compile.
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
persist-credentials: false
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
! \( -name '*.class' \( -path 'exporters/server/dev/horizonlab/parity/*' -o -path 'exporters/client/dev/horizonlab/parity/*' \) \))
|
||||
[ -z "$stray" ] || { echo "::error::not one of our classes: $stray"; exit 1; }
|
||||
python3 tools/check_prepare_exporters.py --binary target/release/horizon-prepare
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: original-exporters
|
||||
path: exporters/
|
||||
@@ -83,6 +83,10 @@ jobs:
|
||||
build:
|
||||
needs: exporters
|
||||
name: build ${{ matrix.name }}
|
||||
# Windows ARM64 is new: its failure never holds the other platforms' release (the signing job
|
||||
# needs this job, and a failed entry of it would fail it). The release steps leave the missing
|
||||
# ARM64 files out with a warning.
|
||||
continue-on-error: ${{ matrix.name == 'windows-arm64' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -99,8 +103,18 @@ jobs:
|
||||
dir: horizon-windows-x64
|
||||
asset: horizon-windows-x64.zip
|
||||
exe: .exe
|
||||
pe_arch: x64
|
||||
# The C runtime linked in, so the game runs without the Visual C++ redistributable.
|
||||
rustflags: -C target-feature=+crt-static
|
||||
- name: windows-arm64
|
||||
os: windows-11-vs2026-arm
|
||||
target: aarch64-pc-windows-msvc
|
||||
dir: horizon-windows-arm64
|
||||
asset: horizon-windows-arm64.zip
|
||||
exe: .exe
|
||||
pe_arch: arm64
|
||||
# The ARM64 runner builds and runs native ARM64 executables; keep the C runtime in the archive.
|
||||
rustflags: -C target-feature=+crt-static
|
||||
- name: macos
|
||||
mac: true
|
||||
dir: horizon-macos-arm64
|
||||
@@ -128,7 +142,7 @@ jobs:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
persist-credentials: false
|
||||
@@ -156,6 +170,12 @@ jobs:
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
test -f "$BIN/$binary${{ matrix.exe }}" || { echo "::error::$BIN/$binary${{ matrix.exe }} is missing"; exit 1; }
|
||||
done
|
||||
- name: Verify Windows executable architecture
|
||||
if: runner.os == 'Windows'
|
||||
run: |
|
||||
python tools/check_pe_arch.py "${{ matrix.pe_arch }}" \
|
||||
"$BIN/horizon-client${{ matrix.exe }}" "$BIN/horizon-ctl${{ matrix.exe }}" \
|
||||
"$BIN/horizon-prepare${{ matrix.exe }}" "$BIN/horizon-server${{ matrix.exe }}"
|
||||
- name: The signing keychain, unlocked
|
||||
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
|
||||
env:
|
||||
@@ -188,7 +208,7 @@ jobs:
|
||||
- name: Baseline x86-64 CPUs only
|
||||
# No POPCNT or LZCNT at all (the script says why only those): a C library's count intrinsic killed the
|
||||
# game before main on a Core 2 (#7264). Not the Mac builds: every Intel Mac that runs macOS 11 has both.
|
||||
if: runner.os != 'macOS'
|
||||
if: runner.os != 'macOS' && matrix.pe_arch != 'arm64'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rustup component add llvm-tools
|
||||
@@ -239,14 +259,14 @@ jobs:
|
||||
# Published with the release; the publish job checks it again before it publishes anything.
|
||||
cp "$home/smoke.json" "$RUNNER_TEMP/smoke-test.json"
|
||||
# One smoke-test.json per build, the Apple Silicon one's; the Intel build must pass its own run too.
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: runner.environment == 'self-hosted' && !matrix.target
|
||||
with:
|
||||
name: smoke-test.json
|
||||
path: ${{ runner.temp }}/smoke-test.json
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: original-exporters
|
||||
path: exporters
|
||||
@@ -277,7 +297,7 @@ jobs:
|
||||
codesign --verify --strict "dist/${{ matrix.dir }}/$binary"
|
||||
done
|
||||
packaging/macos/notarize.sh "dist/${{ matrix.asset }}"
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ matrix.asset }}
|
||||
path: dist/${{ matrix.asset }}
|
||||
@@ -299,7 +319,7 @@ jobs:
|
||||
rm -f "${{ matrix.server_asset }}"
|
||||
tar czf "${{ matrix.server_asset }}" "${{ matrix.server_dir }}"
|
||||
ls -l "${{ matrix.server_asset }}"
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: matrix.server_asset
|
||||
with:
|
||||
name: ${{ matrix.server_asset }}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: launcher
|
||||
# Builds the launcher on Linux, macOS and Windows, and releases it by itself (docs/release-flow.md): after a
|
||||
# Builds the launcher on Linux, macOS, Windows x64 and ARM64, and releases it by itself (docs/release-flow.md): after a
|
||||
# push to main that changes the launcher or a crate it builds in (the paths below),
|
||||
# every 10 minutes (a release waiting for merges to settle goes out) and by hand, tools/release_train.py decides as
|
||||
# for the game.
|
||||
@@ -15,8 +15,9 @@ name: launcher
|
||||
# under Rosetta 2 (#1161). With the repository variable MACOS_SIGN_IDENTITY set, Horizon.app and the .dmg are signed with
|
||||
# Developer ID, notarized and stapled, from the runner's signing keychain (packaging/macos/README.md);
|
||||
# unset (forks), they are signed ad hoc.
|
||||
# Windows ships the launcher's own binary twice: in the ZIP (what a launcher updates itself from) and as
|
||||
# horizon-launcher-setup.exe, which opens the per-user installer instead of Home (launcher/src/installer.rs).
|
||||
# Windows ships each architecture's launcher in its ZIP (what a launcher updates itself from) and as
|
||||
# horizon-launcher-setup.exe or horizon-launcher-setup-arm64.exe, which opens the per-user installer instead of Home
|
||||
# (launcher/src/installer.rs).
|
||||
# gh workflow run launcher --ref <branch> # dry run: builds everything, publishes nothing
|
||||
# gh workflow run launcher --ref main -f dry_run=false -f force=true
|
||||
|
||||
@@ -73,7 +74,7 @@ jobs:
|
||||
version: ${{ steps.decide.outputs.version }}
|
||||
dry_run: ${{ steps.mode.outputs.dry_run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: mode
|
||||
@@ -102,6 +103,10 @@ jobs:
|
||||
needs: decide
|
||||
if: needs.decide.outputs.release == 'true'
|
||||
name: build ${{ matrix.name }}
|
||||
# Windows ARM64 is new: its failure never holds the other platforms' release (the signing job
|
||||
# needs this job, and a failed entry of it would fail it). The release steps leave the missing
|
||||
# ARM64 files out with a warning.
|
||||
continue-on-error: ${{ matrix.name == 'windows-arm64' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -125,9 +130,20 @@ jobs:
|
||||
os: windows-latest
|
||||
asset: horizon-launcher-windows-x64.zip
|
||||
binary: horizon-launcher.exe
|
||||
pe_arch: x64
|
||||
setup_asset: horizon-launcher-setup.exe
|
||||
# The C runtime linked in: without it the launcher asks for VCRUNTIME140.dll,
|
||||
# which only the Visual C++ redistributable installs.
|
||||
rustflags: -C target-feature=+crt-static
|
||||
- name: windows-arm64
|
||||
os: windows-11-vs2026-arm
|
||||
target: aarch64-pc-windows-msvc
|
||||
asset: horizon-launcher-windows-arm64.zip
|
||||
binary: horizon-launcher.exe
|
||||
pe_arch: arm64
|
||||
setup_asset: horizon-launcher-setup-arm64.exe
|
||||
# The C runtime linked in, so the standalone ARM64 launcher needs no redistributable.
|
||||
rustflags: -C target-feature=+crt-static
|
||||
runs-on: ${{ matrix.mac && (vars.MAC_RUNNER || 'ludovics-mac-mini') || matrix.os }}
|
||||
# Notarizing the app and then the .dmg can take a few minutes each.
|
||||
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
|
||||
@@ -150,7 +166,7 @@ jobs:
|
||||
working-directory: launcher
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ needs.decide.outputs.head }}
|
||||
persist-credentials: false
|
||||
@@ -182,6 +198,9 @@ jobs:
|
||||
# Every test target (the library, the binary, tests/), on each system the launcher ships for.
|
||||
- run: cargo test --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
|
||||
- run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
|
||||
- name: Verify Windows executable architecture
|
||||
if: runner.os == 'Windows'
|
||||
run: python ../tools/check_pe_arch.py "${{ matrix.pe_arch }}" "$BIN/${{ matrix.binary }}"
|
||||
- name: The version is the release's
|
||||
run: |
|
||||
out=$("$BIN/${{ matrix.binary }}" --version)
|
||||
@@ -212,7 +231,8 @@ jobs:
|
||||
"$launcher" links --json
|
||||
# The Intel Mac launcher can come out before any game build has its asset (#1161): then there is nothing
|
||||
# to install yet. Only that entry skips, and only on a version list that parsed and had no build here
|
||||
# (exit 3); a launcher or a script that fails still fails the step.
|
||||
# (exit 3); a launcher or a script that fails still fails the step. The Windows ARM64 launcher always has
|
||||
# one: it installs the x64 build where a release has no ARM64 one (backend.rs, GAME_FALLBACK).
|
||||
if [[ "${{ matrix.name }}" == macos-x64 ]]; then
|
||||
versions=$(retry "$launcher" --json versions)
|
||||
no_build='import json, sys; sys.exit(3 if not any(r["available_here"] for r in json.load(sys.stdin)) else 0)'
|
||||
@@ -236,6 +256,11 @@ jobs:
|
||||
# release linked to a Nix store is a warning here, not a broken launcher.
|
||||
soft() { if [[ "$RUNNER_OS" == "Linux" ]]; then "$@" || echo "::warning::$* failed"; else "$@"; fi; }
|
||||
if [[ "$RUNNER_OS" == "Windows" ]]; then
|
||||
if ! python ../tools/check_pe_arch.py "${{ matrix.pe_arch }}" "$client"; then
|
||||
# A Nightly without an ARM64 build: the ARM64 launcher installs the x64 one (backend.rs, GAME_FALLBACK).
|
||||
[[ "${{ matrix.pe_arch }}" == arm64 ]] && python ../tools/check_pe_arch.py x64 "$client" || exit 1
|
||||
echo "::warning::the latest game has no ARM64 build: the launcher installed the x64 one"
|
||||
fi
|
||||
python ../tools/check_imports.py "$client"
|
||||
elif [[ "$RUNNER_OS" == "Linux" ]]; then
|
||||
soft python3 ../tools/check_imports.py "$client"
|
||||
@@ -295,8 +320,8 @@ jobs:
|
||||
# installer instead of Home (launcher/src/installer.rs). Its name is what makes it the setup,
|
||||
# so the ZIP's copy stays the launcher. Both go into the release's signed SHA256SUMS.
|
||||
if: runner.os == 'Windows'
|
||||
run: cp "$BIN/${{ matrix.binary }}" out/horizon-launcher-setup.exe
|
||||
- uses: actions/upload-artifact@v4
|
||||
run: cp "$BIN/${{ matrix.binary }}" "out/${{ matrix.setup_asset }}"
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ matrix.asset }}
|
||||
path: launcher/out/*
|
||||
@@ -314,12 +339,12 @@ jobs:
|
||||
# tools/ci/sign_release.sh, which calls only the runner's sha256sum and OpenSSL (docs/release-flow.md).
|
||||
environment: ${{ needs.decide.outputs.dry_run != 'true' && 'release' || '' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
sparse-checkout: tools/ci/sign_release.sh
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: dist
|
||||
pattern: horizon-launcher-*
|
||||
@@ -331,13 +356,13 @@ jobs:
|
||||
EPHEMERAL: ${{ needs.decide.outputs.dry_run == 'true' && 'yes' || '' }}
|
||||
TAG: ${{ needs.decide.outputs.tag }}
|
||||
run: bash tools/ci/sign_release.sh "$TAG" dist/horizon-launcher-*
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: signature
|
||||
path: dist/SHA256SUMS*
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: needs.decide.outputs.dry_run == 'true'
|
||||
with:
|
||||
name: ephemeral.pub
|
||||
@@ -351,7 +376,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Notes
|
||||
@@ -361,7 +386,7 @@ jobs:
|
||||
python3 tools/release_train.py notes --kind launcher --tag "${{ needs.decide.outputs.tag }}" \
|
||||
--head "${{ needs.decide.outputs.head }}" --out notes > /dev/null
|
||||
{ echo "## $(cat notes/title.txt)"; echo; cat notes/body.md; } >> "$GITHUB_STEP_SUMMARY"
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
@@ -373,6 +398,13 @@ jobs:
|
||||
key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)")
|
||||
cargo run --locked --quiet -p horizon-release-sign -- verify --tag "$TAG" --publishing "${key[@]}" dist
|
||||
# Never marked latest: Horizon's own releases stay the repository's latest.
|
||||
- name: The Windows ARM64 launcher, if it built
|
||||
# Its build may fail without holding the release (continue-on-error): the release then has no ARM64
|
||||
# files, and ARM64 launchers stay on their version until one has (backend::launcher_update_from).
|
||||
run: |
|
||||
for file in horizon-launcher-windows-arm64.zip horizon-launcher-setup-arm64.exe; do
|
||||
[[ -f "dist/$file" ]] || echo "::warning::$file is missing: this release goes out without it"
|
||||
done
|
||||
- name: Publish ${{ needs.decide.outputs.tag }}
|
||||
if: needs.decide.outputs.dry_run != 'true'
|
||||
env:
|
||||
@@ -387,7 +419,7 @@ jobs:
|
||||
run: |
|
||||
echo "Would publish ${{ needs.decide.outputs.tag }} at ${{ needs.decide.outputs.head }}: $(cat notes/title.txt)"
|
||||
ls -l dist
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: launcher-release-notes
|
||||
path: notes/
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
run:
|
||||
working-directory: crates/horizon-mod-registry
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy
|
||||
# Ours keep their build folder between jobs.
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && fromJSON('["self-hosted", "linux", "horizon-light"]') || 'ubuntu-latest' }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- name: Check the manifest
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
pull-requests: write
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
@@ -52,4 +52,8 @@ jobs:
|
||||
git fetch origin "+refs/heads/$branch:refs/remotes/origin/$branch"
|
||||
fi
|
||||
git push --force-with-lease origin "$branch"
|
||||
gh pr create --base main --head "$branch" --title 'Stamp native hook API fragments' --body 'Assign API numbers to the pending native hook fragments. Close and reopen this PR to run CI.'
|
||||
# The repo doesn't let GitHub Actions open pull requests (that setting would also let it approve them):
|
||||
# the branch is pushed, and a maintainer opens the PR from it.
|
||||
if ! gh pr create --base main --head "$branch" --title 'Stamp native hook API fragments' --body 'Assign API numbers to the pending native hook fragments. Close and reopen this PR to run CI.'; then
|
||||
echo "::warning::Stamped fragments are on $branch; open a pull request from it (GitHub Actions may not open one here)."
|
||||
fi
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
source_tag: ${{ steps.pick.outputs.source_tag }}
|
||||
dry_run: ${{ steps.mode.outputs.dry_run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -124,14 +124,14 @@ jobs:
|
||||
env:
|
||||
SHA: ${{ steps.pick.outputs.sha }}
|
||||
run: git archive --format=tar.gz --prefix="horizon-${SHA:0:7}/" -o horizon-source.tar.gz "$SHA"
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: steps.pick.outputs.build == 'true'
|
||||
with:
|
||||
name: horizon-source
|
||||
path: horizon-source.tar.gz
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: nightly-notes
|
||||
path: nightly-notes.md
|
||||
@@ -155,12 +155,12 @@ jobs:
|
||||
# The key's only job: main's sign_release.sh hashes the archives and signs with the runner's OpenSSL.
|
||||
environment: ${{ needs.pick.outputs.dry_run != 'true' && 'release' || '' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
sparse-checkout: tools/ci/sign_release.sh
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: dist
|
||||
pattern: horizon-*
|
||||
@@ -171,16 +171,20 @@ jobs:
|
||||
RELEASE_SIGNING_KEY_NEXT: ${{ secrets.RELEASE_SIGNING_KEY_NEXT }}
|
||||
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
|
||||
run: |
|
||||
# Windows ARM64's build may fail without holding the Nightly (game-build.yml): then it is left out.
|
||||
arm64=(); if [[ -f dist/horizon-windows-arm64.zip ]]; then arm64=(dist/horizon-windows-arm64.zip)
|
||||
else echo "::warning::horizon-windows-arm64.zip is missing: SHA256SUMS goes out without it"; fi
|
||||
bash tools/ci/sign_release.sh nightly dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
|
||||
"${arm64[@]}" \
|
||||
dist/horizon-macos-arm64.zip dist/horizon-macos-x64.zip dist/horizon-server-linux-x86_64.tar.gz \
|
||||
dist/horizon-source.tar.gz
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: signature
|
||||
path: dist/SHA256SUMS*
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: needs.pick.outputs.dry_run == 'true'
|
||||
with:
|
||||
name: ephemeral.pub
|
||||
@@ -195,10 +199,10 @@ jobs:
|
||||
contents: write
|
||||
issues: read # the open release-blocker issues (gate)
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
@@ -230,12 +234,20 @@ jobs:
|
||||
set -euo pipefail
|
||||
python3 tools/ci/nightly.py rotate
|
||||
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
|
||||
# The ARM64 archive only when its build succeeded (the sign job left it out of SHA256SUMS otherwise).
|
||||
arm64=()
|
||||
if [[ -f dist/horizon-windows-arm64.zip ]]; then
|
||||
arm64=("dist/horizon-windows-arm64.zip#Game files the launcher installs (Nightly): Windows ARM64 (horizon-windows-arm64.zip)")
|
||||
else
|
||||
echo "::warning::horizon-windows-arm64.zip is missing: the Nightly goes out without it (ARM64 launchers install the x64 build)"
|
||||
fi
|
||||
# A pre-release, never GitHub's latest: the latest stays the last vX.Y.Z, which launchers and servers from
|
||||
# before the Nightly-only switch keep (they don't read Nightlies).
|
||||
gh release create nightly --prerelease --latest=false --target "$SHA" \
|
||||
--title "Nightly $DATE · ${SHA:0:7}" --notes-file dist/nightly-notes.md \
|
||||
"dist/horizon-linux-x86_64.tar.gz#Game files the launcher installs (Nightly): Linux (horizon-linux-x86_64.tar.gz)" \
|
||||
"dist/horizon-windows-x64.zip#Game files the launcher installs (Nightly): Windows (horizon-windows-x64.zip)" \
|
||||
"${arm64[@]}" \
|
||||
"dist/horizon-macos-arm64.zip#Game files the launcher installs (Nightly): macOS on Apple Silicon (horizon-macos-arm64.zip)" \
|
||||
"dist/horizon-macos-x64.zip#Game files the launcher installs (Nightly): macOS on Intel (horizon-macos-x64.zip)" \
|
||||
"dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
issues: write # answers in the thread
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
else
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
if: steps.when.outputs.run == 'true'
|
||||
with:
|
||||
fetch-depth: 0
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
run: python3 tools/ci/parity_issues.py --since "${{ steps.audit.outputs.since }}" ${{ inputs.dry_run && '--dry-run' || '' }} | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
- name: Logs of the failed harnesses
|
||||
if: steps.when.outputs.run == 'true' && steps.audit.outputs.exit != '0'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: nightly-parity-logs
|
||||
path: .local/parity-runner/logs/
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && fromJSON('["self-hosted", "linux", "horizon-checks"]') || 'ubuntu-latest' }}
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install Rust and the ALSA and udev headers
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
runs-on: [self-hosted, macOS, horizon-parity-main]
|
||||
timeout-minutes: 240 # a core-crate batch reruns ~300 harnesses (~60-90 min), a bisect a few rounds of the failures
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
python3 tools/parity_main.py bisect "$BASE_SHA" --comment
|
||||
- name: Logs of the failed harnesses
|
||||
if: steps.harnesses.outcome == 'failure'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: parity-main-logs
|
||||
path: .local/parity-main-logs/
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
# harnesses at a time) up to a few hours, and any pull request's job may land there.
|
||||
timeout-minutes: ${{ inputs.full && 360 || 180 }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
# The commit the event names: a fork's later pushes need another run-parity label. The next step merges
|
||||
# main into it.
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
run: python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400 # world_spawn takes ~1480 s on a busy runner
|
||||
- name: Logs of the failed harnesses
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: parity-logs
|
||||
path: .local/parity-runner/logs/
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
urgent: ${{ steps.decide.outputs.urgent }}
|
||||
dry_run: ${{ steps.mode.outputs.dry_run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: mode
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
run:
|
||||
working-directory: crates/horizon-telemetry-server
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy
|
||||
# Ours keep their build folder between jobs.
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
|
||||
+2
-2
@@ -868,7 +868,7 @@ Banners in End cities show as plain magenta instead of with their pattern.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Compare the banner block entities the End city templates place with vanilla's (`EndCityPieces`, the templates' block entity NBT, `BannerBlockEntity`'s patterns), in `crates/horizon-world/src/worldgen/structure/`. Issue [#1438](https://github.com/horizon-rust/horizon/issues/1438); possibly related: [#526](https://github.com/horizon-rust/horizon/issues/526).
|
||||
Compare the banner block entities the End city templates place with vanilla's (`EndCityPieces`, the templates' block entity NBT, `BannerBlockEntity`'s patterns), in `crates/horizon-world/src/worldgen/structure/`. Issue [#1438](https://github.com/horizon-rust/horizon/issues/1438); possibly related: [#526](https://github.com/horizon-rust/horizon/issues/526). The templates' block entities now keep their patterns ([#1928](https://github.com/horizon-rust/horizon/issues/1928)).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -878,7 +878,7 @@ Armour and tools in End city chests should come enchanted, as in vanilla.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`loot.rs` implements `enchant_with_levels`: find why the End city chest table's rolls come out bare, and compare them with vanilla's for a few seeds. Issues [#1437](https://github.com/horizon-rust/horizon/issues/1437), [#1660](https://github.com/horizon-rust/horizon/issues/1660).
|
||||
`loot.rs` implements `enchant_with_levels`: find why the End city chest table's rolls come out bare, and compare them with vanilla's for a few seeds. Issues [#1437](https://github.com/horizon-rust/horizon/issues/1437), [#1660](https://github.com/horizon-rust/horizon/issues/1660). The loot's enchantment functions now reach every table that rolls ([#1911](https://github.com/horizon-rust/horizon/issues/1911), [#2037](https://github.com/horizon-rust/horizon/issues/2037)).
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
|
||||
|
||||
The GLSL frontend also resets the common type for each comma-separated declarator so one
|
||||
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.
|
||||
|
||||
## libmimalloc-sys and mimalloc
|
||||
|
||||
|
||||
@@ -0,0 +1,870 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-04T13:00:15Z",
|
||||
"app": {
|
||||
"name": "horizon-client",
|
||||
"version": "nightly-2026-11-04-67dad0d",
|
||||
"commit": "67dad0d"
|
||||
},
|
||||
"hardware": {
|
||||
"os": "linux",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 24,
|
||||
"os_version": "NixOS 26.05 (Yarara) (kernel 6.18.57)",
|
||||
"cpu": "13th Gen Intel(R) Core(TM) i7-13700KF",
|
||||
"ram_mb": 31904,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 4080",
|
||||
"vendor": "nvidia",
|
||||
"driver": "NVIDIA 595.71.05",
|
||||
"backend": "vulkan",
|
||||
"kind": "discrete"
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 12,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 28.408,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 66658.183,
|
||||
"stdev": 61839.502,
|
||||
"min": 1827.42,
|
||||
"max": 124993.694,
|
||||
"ci95": 153630.237
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 74754.411,
|
||||
"stdev": 59189.587,
|
||||
"min": 13600.651,
|
||||
"max": 131761.773,
|
||||
"ci95": 147046.952
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 5571.499,
|
||||
"stdev": 1900.32,
|
||||
"min": 3378.38,
|
||||
"max": 6730.394,
|
||||
"ci95": 4721.037
|
||||
},
|
||||
"chunks.village.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 15287.0,
|
||||
"stdev": 527.501,
|
||||
"min": 14788.0,
|
||||
"max": 15839.0,
|
||||
"ci95": 1310.49
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2958.557,
|
||||
"stdev": 376.543,
|
||||
"min": 2526.453,
|
||||
"max": 3216.434,
|
||||
"ci95": 935.46
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 368.457,
|
||||
"stdev": 90.668,
|
||||
"min": 300.631,
|
||||
"max": 471.438,
|
||||
"ci95": 225.25
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 91.04,
|
||||
"stdev": 40.506,
|
||||
"min": 46.124,
|
||||
"max": 124.796,
|
||||
"ci95": 100.63
|
||||
},
|
||||
"memory.village.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 581.0,
|
||||
"stdev": 48.0,
|
||||
"min": 533.0,
|
||||
"max": 629.0,
|
||||
"ci95": 119.248
|
||||
},
|
||||
"memory.village.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1277.531,
|
||||
"stdev": 25.546,
|
||||
"min": 1253.879,
|
||||
"max": 1304.623,
|
||||
"ci95": 63.466
|
||||
},
|
||||
"tick.village.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.529,
|
||||
"stdev": 0.127,
|
||||
"min": 0.396,
|
||||
"max": 0.649,
|
||||
"ci95": 0.315
|
||||
},
|
||||
"cpu.village.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.185,
|
||||
"stdev": 0.034,
|
||||
"min": 0.162,
|
||||
"max": 0.224,
|
||||
"ci95": 0.085
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 3318.3,
|
||||
"stdev": 911.608,
|
||||
"min": 2266.627,
|
||||
"max": 3883.075,
|
||||
"ci95": 2264.743
|
||||
},
|
||||
"chunks.forest.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 16565.333,
|
||||
"stdev": 172.755,
|
||||
"min": 16367.0,
|
||||
"max": 16683.0,
|
||||
"ci95": 429.182
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2333.626,
|
||||
"stdev": 569.124,
|
||||
"min": 1745.365,
|
||||
"max": 2881.457,
|
||||
"ci95": 1413.897
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 336.615,
|
||||
"stdev": 185.666,
|
||||
"min": 208.133,
|
||||
"max": 549.487,
|
||||
"ci95": 461.257
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 160.734,
|
||||
"stdev": 127.586,
|
||||
"min": 39.777,
|
||||
"max": 294.05,
|
||||
"ci95": 316.967
|
||||
},
|
||||
"memory.forest.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 666.333,
|
||||
"stdev": 24.44,
|
||||
"min": 645.0,
|
||||
"max": 693.0,
|
||||
"ci95": 60.718
|
||||
},
|
||||
"memory.forest.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1346.816,
|
||||
"stdev": 42.279,
|
||||
"min": 1305.547,
|
||||
"max": 1390.037,
|
||||
"ci95": 105.035
|
||||
},
|
||||
"tick.forest.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.669,
|
||||
"stdev": 0.084,
|
||||
"min": 0.598,
|
||||
"max": 0.761,
|
||||
"ci95": 0.208
|
||||
},
|
||||
"cpu.forest.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.222,
|
||||
"stdev": 0.032,
|
||||
"min": 0.197,
|
||||
"max": 0.258,
|
||||
"ci95": 0.079
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 2463.434,
|
||||
"stdev": 1056.0,
|
||||
"min": 1527.516,
|
||||
"max": 3608.283,
|
||||
"ci95": 2623.462
|
||||
},
|
||||
"chunks.ocean.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 14226.667,
|
||||
"stdev": 307.077,
|
||||
"min": 14038.0,
|
||||
"max": 14581.0,
|
||||
"ci95": 762.883
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2192.874,
|
||||
"stdev": 1320.904,
|
||||
"min": 673.829,
|
||||
"max": 3071.415,
|
||||
"ci95": 3281.573
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 345.406,
|
||||
"stdev": 294.904,
|
||||
"min": 8.977,
|
||||
"max": 559.232,
|
||||
"ci95": 732.642
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 172.573,
|
||||
"stdev": 149.386,
|
||||
"min": 1.038,
|
||||
"max": 274.089,
|
||||
"ci95": 371.125
|
||||
},
|
||||
"memory.ocean.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 629.0,
|
||||
"stdev": 32.0,
|
||||
"min": 597.0,
|
||||
"max": 661.0,
|
||||
"ci95": 79.499
|
||||
},
|
||||
"memory.ocean.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1352.223,
|
||||
"stdev": 51.951,
|
||||
"min": 1301.492,
|
||||
"max": 1405.314,
|
||||
"ci95": 129.065
|
||||
},
|
||||
"tick.ocean.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.512,
|
||||
"stdev": 0.099,
|
||||
"min": 0.406,
|
||||
"max": 0.603,
|
||||
"ci95": 0.246
|
||||
},
|
||||
"cpu.ocean.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.178,
|
||||
"stdev": 0.038,
|
||||
"min": 0.135,
|
||||
"max": 0.206,
|
||||
"ci95": 0.094
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 6260.675,
|
||||
"stdev": 3077.348,
|
||||
"min": 2976.342,
|
||||
"max": 9077.55,
|
||||
"ci95": 7645.173
|
||||
},
|
||||
"chunks.mountain.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 16015.333,
|
||||
"stdev": 209.872,
|
||||
"min": 15826.0,
|
||||
"max": 16241.0,
|
||||
"ci95": 521.393
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1412.095,
|
||||
"stdev": 962.145,
|
||||
"min": 714.773,
|
||||
"max": 2509.775,
|
||||
"ci95": 2390.293
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 182.479,
|
||||
"stdev": 177.563,
|
||||
"min": 13.364,
|
||||
"max": 367.429,
|
||||
"ci95": 441.126
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 88.451,
|
||||
"stdev": 87.578,
|
||||
"min": 1.526,
|
||||
"max": 176.667,
|
||||
"ci95": 217.572
|
||||
},
|
||||
"memory.mountain.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 799.667,
|
||||
"stdev": 18.475,
|
||||
"min": 789.0,
|
||||
"max": 821.0,
|
||||
"ci95": 45.899
|
||||
},
|
||||
"memory.mountain.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1491.572,
|
||||
"stdev": 36.719,
|
||||
"min": 1459.775,
|
||||
"max": 1531.76,
|
||||
"ci95": 91.221
|
||||
},
|
||||
"tick.mountain.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.697,
|
||||
"stdev": 0.155,
|
||||
"min": 0.602,
|
||||
"max": 0.876,
|
||||
"ci95": 0.386
|
||||
},
|
||||
"cpu.mountain.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.325,
|
||||
"stdev": 0.115,
|
||||
"min": 0.239,
|
||||
"max": 0.456,
|
||||
"ci95": 0.286
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 1545.896,
|
||||
"stdev": 30.591,
|
||||
"min": 1524.965,
|
||||
"max": 1581.004,
|
||||
"ci95": 75.999
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1348.389,
|
||||
"stdev": 52.5,
|
||||
"min": 1296.172,
|
||||
"max": 1401.168,
|
||||
"ci95": 130.428
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 37594,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3132.783,
|
||||
"low_1pct_fps": 471.438,
|
||||
"low_01pct_fps": 124.796,
|
||||
"p50_ms": 0.271,
|
||||
"p95_ms": 0.494,
|
||||
"p99_ms": 1.127,
|
||||
"p999_ms": 2.15,
|
||||
"max_ms": 155.204,
|
||||
"cpu_p50_ms": 0.168,
|
||||
"gpu_p50_ms": 0.104,
|
||||
"histogram": [
|
||||
37151,
|
||||
394,
|
||||
30,
|
||||
8,
|
||||
5,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 38598,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3216.434,
|
||||
"low_1pct_fps": 300.631,
|
||||
"low_01pct_fps": 46.124,
|
||||
"p50_ms": 0.267,
|
||||
"p95_ms": 0.446,
|
||||
"p99_ms": 1.034,
|
||||
"p999_ms": 1.941,
|
||||
"max_ms": 493.993,
|
||||
"cpu_p50_ms": 0.162,
|
||||
"gpu_p50_ms": 0.104,
|
||||
"histogram": [
|
||||
38177,
|
||||
387,
|
||||
16,
|
||||
8,
|
||||
4,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 30318,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2526.453,
|
||||
"low_1pct_fps": 333.302,
|
||||
"low_01pct_fps": 102.199,
|
||||
"p50_ms": 0.305,
|
||||
"p95_ms": 0.757,
|
||||
"p99_ms": 1.69,
|
||||
"p999_ms": 3.539,
|
||||
"max_ms": 170.856,
|
||||
"cpu_p50_ms": 0.224,
|
||||
"gpu_p50_ms": 0.106,
|
||||
"histogram": [
|
||||
29368,
|
||||
759,
|
||||
139,
|
||||
35,
|
||||
11,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 34578,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2881.457,
|
||||
"low_1pct_fps": 549.487,
|
||||
"low_01pct_fps": 294.05,
|
||||
"p50_ms": 0.32,
|
||||
"p95_ms": 0.483,
|
||||
"p99_ms": 1.213,
|
||||
"p999_ms": 2.5,
|
||||
"max_ms": 5.358,
|
||||
"cpu_p50_ms": 0.197,
|
||||
"gpu_p50_ms": 0.154,
|
||||
"histogram": [
|
||||
34119,
|
||||
379,
|
||||
64,
|
||||
9,
|
||||
5,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 28489,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2374.055,
|
||||
"low_1pct_fps": 208.133,
|
||||
"low_01pct_fps": 39.777,
|
||||
"p50_ms": 0.313,
|
||||
"p95_ms": 0.937,
|
||||
"p99_ms": 1.863,
|
||||
"p999_ms": 4.318,
|
||||
"max_ms": 571.48,
|
||||
"cpu_p50_ms": 0.211,
|
||||
"gpu_p50_ms": 0.151,
|
||||
"histogram": [
|
||||
27193,
|
||||
1071,
|
||||
140,
|
||||
51,
|
||||
19,
|
||||
9,
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 20945,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1745.365,
|
||||
"low_1pct_fps": 252.226,
|
||||
"low_01pct_fps": 148.375,
|
||||
"p50_ms": 0.343,
|
||||
"p95_ms": 1.505,
|
||||
"p99_ms": 2.758,
|
||||
"p999_ms": 5.341,
|
||||
"max_ms": 11.642,
|
||||
"cpu_p50_ms": 0.258,
|
||||
"gpu_p50_ms": 0.162,
|
||||
"histogram": [
|
||||
18145,
|
||||
2293,
|
||||
337,
|
||||
106,
|
||||
38,
|
||||
13,
|
||||
6,
|
||||
5,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 36856,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3071.415,
|
||||
"low_1pct_fps": 559.232,
|
||||
"low_01pct_fps": 274.089,
|
||||
"p50_ms": 0.286,
|
||||
"p95_ms": 0.492,
|
||||
"p99_ms": 1.179,
|
||||
"p999_ms": 2.711,
|
||||
"max_ms": 6.718,
|
||||
"cpu_p50_ms": 0.135,
|
||||
"gpu_p50_ms": 0.127,
|
||||
"histogram": [
|
||||
36366,
|
||||
405,
|
||||
60,
|
||||
13,
|
||||
10,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 34001,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2833.377,
|
||||
"low_1pct_fps": 468.008,
|
||||
"low_01pct_fps": 242.591,
|
||||
"p50_ms": 0.295,
|
||||
"p95_ms": 0.589,
|
||||
"p99_ms": 1.41,
|
||||
"p999_ms": 3.048,
|
||||
"max_ms": 7.7,
|
||||
"cpu_p50_ms": 0.206,
|
||||
"gpu_p50_ms": 0.126,
|
||||
"histogram": [
|
||||
33314,
|
||||
544,
|
||||
106,
|
||||
23,
|
||||
10,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 8086,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 673.829,
|
||||
"low_1pct_fps": 8.977,
|
||||
"low_01pct_fps": 1.038,
|
||||
"p50_ms": 0.302,
|
||||
"p95_ms": 0.665,
|
||||
"p99_ms": 2.495,
|
||||
"p999_ms": 15.716,
|
||||
"max_ms": 4849.364,
|
||||
"cpu_p50_ms": 0.194,
|
||||
"gpu_p50_ms": 0.128,
|
||||
"histogram": [
|
||||
7808,
|
||||
166,
|
||||
50,
|
||||
26,
|
||||
8,
|
||||
2,
|
||||
3,
|
||||
5,
|
||||
2,
|
||||
5,
|
||||
1,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 12141,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1011.738,
|
||||
"low_1pct_fps": 166.643,
|
||||
"low_01pct_fps": 87.16,
|
||||
"p50_ms": 0.641,
|
||||
"p95_ms": 2.642,
|
||||
"p99_ms": 4.468,
|
||||
"p999_ms": 7.821,
|
||||
"max_ms": 22.536,
|
||||
"cpu_p50_ms": 0.456,
|
||||
"gpu_p50_ms": 0.138,
|
||||
"histogram": [
|
||||
7840,
|
||||
2996,
|
||||
866,
|
||||
278,
|
||||
87,
|
||||
38,
|
||||
16,
|
||||
11,
|
||||
2,
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 30118,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2509.775,
|
||||
"low_1pct_fps": 367.429,
|
||||
"low_01pct_fps": 176.667,
|
||||
"p50_ms": 0.316,
|
||||
"p95_ms": 0.771,
|
||||
"p99_ms": 1.931,
|
||||
"p999_ms": 4.2,
|
||||
"max_ms": 14.633,
|
||||
"cpu_p50_ms": 0.239,
|
||||
"gpu_p50_ms": 0.127,
|
||||
"histogram": [
|
||||
29101,
|
||||
775,
|
||||
173,
|
||||
38,
|
||||
16,
|
||||
8,
|
||||
4,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 8577,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 714.773,
|
||||
"low_1pct_fps": 13.364,
|
||||
"low_01pct_fps": 1.526,
|
||||
"p50_ms": 0.416,
|
||||
"p95_ms": 2.257,
|
||||
"p99_ms": 4.36,
|
||||
"p999_ms": 212.217,
|
||||
"max_ms": 1051.428,
|
||||
"cpu_p50_ms": 0.28,
|
||||
"gpu_p50_ms": 0.132,
|
||||
"histogram": [
|
||||
7017,
|
||||
978,
|
||||
377,
|
||||
111,
|
||||
39,
|
||||
24,
|
||||
10,
|
||||
6,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
4,
|
||||
2
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "horizon-desktop"
|
||||
}
|
||||
@@ -0,0 +1,870 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-04T14:47:19Z",
|
||||
"app": {
|
||||
"name": "horizon-client",
|
||||
"version": "nightly-2026-11-04-67dad0d",
|
||||
"commit": "67dad0d"
|
||||
},
|
||||
"hardware": {
|
||||
"os": "linux",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 24,
|
||||
"os_version": "NixOS 26.05 (Yarara) (kernel 6.18.57)",
|
||||
"cpu": "13th Gen Intel(R) Core(TM) i7-13700KF",
|
||||
"ram_mb": 31904,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 4080",
|
||||
"vendor": "nvidia",
|
||||
"driver": "NVIDIA 595.71.05",
|
||||
"backend": "vulkan",
|
||||
"kind": "discrete"
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 32,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 29.665,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 45386.16,
|
||||
"stdev": 38970.921,
|
||||
"min": 1077.276,
|
||||
"max": 74343.156,
|
||||
"ci95": 96816.948
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 86585.608,
|
||||
"stdev": 71331.593,
|
||||
"min": 11201.581,
|
||||
"max": 153020.143,
|
||||
"ci95": 177211.803
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 11082.587,
|
||||
"stdev": 5351.291,
|
||||
"min": 7620.454,
|
||||
"max": 17246.096,
|
||||
"ci95": 13294.417
|
||||
},
|
||||
"chunks.village.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 48440.333,
|
||||
"stdev": 656.16,
|
||||
"min": 48060.0,
|
||||
"max": 49198.0,
|
||||
"ci95": 1630.124
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 853.775,
|
||||
"stdev": 345.646,
|
||||
"min": 524.913,
|
||||
"max": 1214.059,
|
||||
"ci95": 858.701
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 130.235,
|
||||
"stdev": 117.39,
|
||||
"min": 9.024,
|
||||
"max": 243.388,
|
||||
"ci95": 291.635
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 70.65,
|
||||
"stdev": 68.628,
|
||||
"min": 1.125,
|
||||
"max": 138.345,
|
||||
"ci95": 170.496
|
||||
},
|
||||
"memory.village.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 2202.333,
|
||||
"stdev": 33.307,
|
||||
"min": 2165.0,
|
||||
"max": 2229.0,
|
||||
"ci95": 82.745
|
||||
},
|
||||
"memory.village.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1652.647,
|
||||
"stdev": 73.452,
|
||||
"min": 1568.063,
|
||||
"max": 1700.344,
|
||||
"ci95": 182.478
|
||||
},
|
||||
"tick.village.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.493,
|
||||
"stdev": 0.169,
|
||||
"min": 0.319,
|
||||
"max": 0.657,
|
||||
"ci95": 0.42
|
||||
},
|
||||
"cpu.village.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.692,
|
||||
"stdev": 0.207,
|
||||
"min": 0.531,
|
||||
"max": 0.925,
|
||||
"ci95": 0.513
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 15019.921,
|
||||
"stdev": 12855.4,
|
||||
"min": 5613.942,
|
||||
"max": 29668.11,
|
||||
"ci95": 31937.162
|
||||
},
|
||||
"chunks.forest.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 80224.667,
|
||||
"stdev": 678.709,
|
||||
"min": 79626.0,
|
||||
"max": 80962.0,
|
||||
"ci95": 1686.142
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 898.208,
|
||||
"stdev": 446.3,
|
||||
"min": 382.944,
|
||||
"max": 1163.61,
|
||||
"ci95": 1108.759
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 178.513,
|
||||
"stdev": 107.096,
|
||||
"min": 54.933,
|
||||
"max": 244.255,
|
||||
"ci95": 266.064
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 103.369,
|
||||
"stdev": 78.403,
|
||||
"min": 12.974,
|
||||
"max": 152.87,
|
||||
"ci95": 194.779
|
||||
},
|
||||
"memory.forest.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 2201.667,
|
||||
"stdev": 34.429,
|
||||
"min": 2163.0,
|
||||
"max": 2229.0,
|
||||
"ci95": 85.532
|
||||
},
|
||||
"memory.forest.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1809.063,
|
||||
"stdev": 93.103,
|
||||
"min": 1707.98,
|
||||
"max": 1891.305,
|
||||
"ci95": 231.3
|
||||
},
|
||||
"tick.forest.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.585,
|
||||
"stdev": 0.147,
|
||||
"min": 0.493,
|
||||
"max": 0.754,
|
||||
"ci95": 0.365
|
||||
},
|
||||
"cpu.forest.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.989,
|
||||
"stdev": 0.725,
|
||||
"min": 0.57,
|
||||
"max": 1.826,
|
||||
"ci95": 1.802
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 12399.047,
|
||||
"stdev": 15052.073,
|
||||
"min": 3174.115,
|
||||
"max": 29768.486,
|
||||
"ci95": 37394.441
|
||||
},
|
||||
"chunks.ocean.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 64737.0,
|
||||
"stdev": 16149.666,
|
||||
"min": 46089.0,
|
||||
"max": 74089.0,
|
||||
"ci95": 40121.232
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 735.22,
|
||||
"stdev": 601.75,
|
||||
"min": 108.108,
|
||||
"max": 1307.912,
|
||||
"ci95": 1494.952
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 134.654,
|
||||
"stdev": 147.391,
|
||||
"min": 1.252,
|
||||
"max": 292.881,
|
||||
"ci95": 366.169
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 62.062,
|
||||
"stdev": 66.294,
|
||||
"min": 0.202,
|
||||
"max": 132.042,
|
||||
"ci95": 164.697
|
||||
},
|
||||
"memory.ocean.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 1942.333,
|
||||
"stdev": 251.725,
|
||||
"min": 1657.0,
|
||||
"max": 2133.0,
|
||||
"ci95": 625.369
|
||||
},
|
||||
"memory.ocean.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1812.661,
|
||||
"stdev": 102.065,
|
||||
"min": 1697.912,
|
||||
"max": 1893.313,
|
||||
"ci95": 253.564
|
||||
},
|
||||
"tick.ocean.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.421,
|
||||
"stdev": 0.227,
|
||||
"min": 0.193,
|
||||
"max": 0.646,
|
||||
"ci95": 0.563
|
||||
},
|
||||
"cpu.ocean.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.513,
|
||||
"stdev": 0.194,
|
||||
"min": 0.303,
|
||||
"max": 0.686,
|
||||
"ci95": 0.483
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 31815.539,
|
||||
"stdev": 36758.245,
|
||||
"min": 9582.166,
|
||||
"max": 74243.988,
|
||||
"ci95": 91319.913
|
||||
},
|
||||
"chunks.mountain.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 90876.333,
|
||||
"stdev": 1525.588,
|
||||
"min": 89793.0,
|
||||
"max": 92621.0,
|
||||
"ci95": 3790.075
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 712.657,
|
||||
"stdev": 252.815,
|
||||
"min": 450.565,
|
||||
"max": 955.041,
|
||||
"ci95": 628.077
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 117.93,
|
||||
"stdev": 85.922,
|
||||
"min": 24.389,
|
||||
"max": 193.34,
|
||||
"ci95": 213.46
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 72.407,
|
||||
"stdev": 65.468,
|
||||
"min": 3.577,
|
||||
"max": 133.895,
|
||||
"ci95": 162.646
|
||||
},
|
||||
"memory.mountain.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 2783.667,
|
||||
"stdev": 48.881,
|
||||
"min": 2741.0,
|
||||
"max": 2837.0,
|
||||
"ci95": 121.436
|
||||
},
|
||||
"memory.mountain.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1931.359,
|
||||
"stdev": 106.681,
|
||||
"min": 1808.184,
|
||||
"max": 1994.242,
|
||||
"ci95": 265.031
|
||||
},
|
||||
"tick.mountain.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.03,
|
||||
"stdev": 0.685,
|
||||
"min": 0.564,
|
||||
"max": 1.817,
|
||||
"ci95": 1.702
|
||||
},
|
||||
"cpu.mountain.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.025,
|
||||
"stdev": 0.315,
|
||||
"min": 0.765,
|
||||
"max": 1.376,
|
||||
"ci95": 0.783
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2051.333,
|
||||
"stdev": 147.151,
|
||||
"min": 1884.176,
|
||||
"max": 2161.316,
|
||||
"ci95": 365.573
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1810.92,
|
||||
"stdev": 94.623,
|
||||
"min": 1707.342,
|
||||
"max": 1892.828,
|
||||
"ci95": 235.075
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 6301,
|
||||
"seconds": 12.004,
|
||||
"avg_fps": 524.913,
|
||||
"low_1pct_fps": 9.024,
|
||||
"low_01pct_fps": 1.125,
|
||||
"p50_ms": 0.555,
|
||||
"p95_ms": 2.072,
|
||||
"p99_ms": 4.067,
|
||||
"p999_ms": 282.455,
|
||||
"max_ms": 3525.74,
|
||||
"cpu_p50_ms": 0.531,
|
||||
"gpu_p50_ms": 0.218,
|
||||
"histogram": [
|
||||
4878,
|
||||
1060,
|
||||
217,
|
||||
86,
|
||||
19,
|
||||
15,
|
||||
6,
|
||||
2,
|
||||
5,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
4,
|
||||
3,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 9869,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 822.353,
|
||||
"low_1pct_fps": 138.293,
|
||||
"low_01pct_fps": 72.479,
|
||||
"p50_ms": 0.976,
|
||||
"p95_ms": 2.871,
|
||||
"p99_ms": 5.065,
|
||||
"p999_ms": 9.942,
|
||||
"max_ms": 23.337,
|
||||
"cpu_p50_ms": 0.925,
|
||||
"gpu_p50_ms": 0.245,
|
||||
"histogram": [
|
||||
5262,
|
||||
3404,
|
||||
771,
|
||||
248,
|
||||
81,
|
||||
49,
|
||||
23,
|
||||
11,
|
||||
12,
|
||||
3,
|
||||
0,
|
||||
3,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 14570,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1214.059,
|
||||
"low_1pct_fps": 243.388,
|
||||
"low_01pct_fps": 138.345,
|
||||
"p50_ms": 0.655,
|
||||
"p95_ms": 1.486,
|
||||
"p99_ms": 2.883,
|
||||
"p999_ms": 5.366,
|
||||
"max_ms": 26.145,
|
||||
"cpu_p50_ms": 0.62,
|
||||
"gpu_p50_ms": 0.239,
|
||||
"histogram": [
|
||||
11502,
|
||||
2683,
|
||||
257,
|
||||
77,
|
||||
25,
|
||||
23,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 4595,
|
||||
"seconds": 11.999,
|
||||
"avg_fps": 382.944,
|
||||
"low_1pct_fps": 54.933,
|
||||
"low_01pct_fps": 12.974,
|
||||
"p50_ms": 2.269,
|
||||
"p95_ms": 5.193,
|
||||
"p99_ms": 8.719,
|
||||
"p999_ms": 21.933,
|
||||
"max_ms": 243.247,
|
||||
"cpu_p50_ms": 1.826,
|
||||
"gpu_p50_ms": 0.502,
|
||||
"histogram": [
|
||||
273,
|
||||
1654,
|
||||
1395,
|
||||
836,
|
||||
172,
|
||||
122,
|
||||
52,
|
||||
38,
|
||||
26,
|
||||
10,
|
||||
7,
|
||||
5,
|
||||
0,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 13964,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1163.61,
|
||||
"low_1pct_fps": 244.255,
|
||||
"low_01pct_fps": 152.87,
|
||||
"p50_ms": 0.682,
|
||||
"p95_ms": 1.524,
|
||||
"p99_ms": 2.877,
|
||||
"p999_ms": 5.801,
|
||||
"max_ms": 7.977,
|
||||
"cpu_p50_ms": 0.57,
|
||||
"gpu_p50_ms": 0.467,
|
||||
"histogram": [
|
||||
11067,
|
||||
2532,
|
||||
235,
|
||||
82,
|
||||
23,
|
||||
16,
|
||||
6,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 13777,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1148.071,
|
||||
"low_1pct_fps": 236.351,
|
||||
"low_01pct_fps": 144.264,
|
||||
"p50_ms": 0.691,
|
||||
"p95_ms": 1.637,
|
||||
"p99_ms": 3.131,
|
||||
"p999_ms": 5.794,
|
||||
"max_ms": 9.301,
|
||||
"cpu_p50_ms": 0.57,
|
||||
"gpu_p50_ms": 0.465,
|
||||
"histogram": [
|
||||
10826,
|
||||
2511,
|
||||
284,
|
||||
106,
|
||||
23,
|
||||
16,
|
||||
5,
|
||||
5,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 1224,
|
||||
"seconds": 11.322,
|
||||
"avg_fps": 108.108,
|
||||
"low_1pct_fps": 1.252,
|
||||
"low_01pct_fps": 0.202,
|
||||
"p50_ms": 0.439,
|
||||
"p95_ms": 3.015,
|
||||
"p99_ms": 7.359,
|
||||
"p999_ms": 2880.23,
|
||||
"max_ms": 7005.436,
|
||||
"cpu_p50_ms": 0.303,
|
||||
"gpu_p50_ms": 0.258,
|
||||
"histogram": [
|
||||
1081,
|
||||
31,
|
||||
49,
|
||||
10,
|
||||
3,
|
||||
7,
|
||||
23,
|
||||
12,
|
||||
4,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 15695,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1307.912,
|
||||
"low_1pct_fps": 292.881,
|
||||
"low_01pct_fps": 132.042,
|
||||
"p50_ms": 0.624,
|
||||
"p95_ms": 1.219,
|
||||
"p99_ms": 2.173,
|
||||
"p999_ms": 4.841,
|
||||
"max_ms": 16.764,
|
||||
"cpu_p50_ms": 0.551,
|
||||
"gpu_p50_ms": 0.401,
|
||||
"histogram": [
|
||||
13558,
|
||||
1932,
|
||||
140,
|
||||
37,
|
||||
14,
|
||||
6,
|
||||
3,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 9476,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 789.641,
|
||||
"low_1pct_fps": 109.83,
|
||||
"low_01pct_fps": 53.941,
|
||||
"p50_ms": 0.802,
|
||||
"p95_ms": 3.406,
|
||||
"p99_ms": 5.844,
|
||||
"p999_ms": 14.977,
|
||||
"max_ms": 22.481,
|
||||
"cpu_p50_ms": 0.686,
|
||||
"gpu_p50_ms": 0.403,
|
||||
"histogram": [
|
||||
5819,
|
||||
2151,
|
||||
787,
|
||||
455,
|
||||
112,
|
||||
65,
|
||||
36,
|
||||
18,
|
||||
7,
|
||||
7,
|
||||
7,
|
||||
5,
|
||||
4,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 5352,
|
||||
"seconds": 11.878,
|
||||
"avg_fps": 450.565,
|
||||
"low_1pct_fps": 24.389,
|
||||
"low_01pct_fps": 3.577,
|
||||
"p50_ms": 1.431,
|
||||
"p95_ms": 4.548,
|
||||
"p99_ms": 7.601,
|
||||
"p999_ms": 96.086,
|
||||
"max_ms": 714.027,
|
||||
"cpu_p50_ms": 1.376,
|
||||
"gpu_p50_ms": 0.398,
|
||||
"histogram": [
|
||||
1412,
|
||||
2323,
|
||||
811,
|
||||
460,
|
||||
138,
|
||||
84,
|
||||
49,
|
||||
34,
|
||||
13,
|
||||
9,
|
||||
8,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 8789,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 732.365,
|
||||
"low_1pct_fps": 136.06,
|
||||
"low_01pct_fps": 79.749,
|
||||
"p50_ms": 0.972,
|
||||
"p95_ms": 3.329,
|
||||
"p99_ms": 5.68,
|
||||
"p999_ms": 9.7,
|
||||
"max_ms": 18.289,
|
||||
"cpu_p50_ms": 0.935,
|
||||
"gpu_p50_ms": 0.383,
|
||||
"histogram": [
|
||||
4529,
|
||||
3007,
|
||||
715,
|
||||
287,
|
||||
120,
|
||||
61,
|
||||
37,
|
||||
16,
|
||||
12,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 11461,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 955.041,
|
||||
"low_1pct_fps": 193.34,
|
||||
"low_01pct_fps": 133.895,
|
||||
"p50_ms": 0.787,
|
||||
"p95_ms": 2.026,
|
||||
"p99_ms": 3.953,
|
||||
"p999_ms": 6.511,
|
||||
"max_ms": 9.761,
|
||||
"cpu_p50_ms": 0.765,
|
||||
"gpu_p50_ms": 0.379,
|
||||
"histogram": [
|
||||
7754,
|
||||
3114,
|
||||
328,
|
||||
165,
|
||||
46,
|
||||
33,
|
||||
14,
|
||||
5,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "horizon-desktop"
|
||||
}
|
||||
@@ -0,0 +1,854 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-04T12:44:40Z",
|
||||
"app": {
|
||||
"name": "horizon-client",
|
||||
"version": "nightly-2026-11-04-67dad0d",
|
||||
"commit": "67dad0d"
|
||||
},
|
||||
"hardware": {
|
||||
"os": "macos",
|
||||
"arch": "aarch64",
|
||||
"cpu_threads": 14,
|
||||
"os_version": "27.0.1",
|
||||
"cpu": "Apple M4 Pro",
|
||||
"ram_mb": 65536,
|
||||
"gpu": {
|
||||
"name": "Apple M4 Pro",
|
||||
"vendor": "",
|
||||
"driver": "",
|
||||
"backend": "metal",
|
||||
"kind": "integrated"
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 12,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": true,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 17.39,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 1516.155,
|
||||
"stdev": 34.227,
|
||||
"min": 1476.782,
|
||||
"max": 1538.813,
|
||||
"ci95": 85.032
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 4080.9,
|
||||
"stdev": 748.712,
|
||||
"min": 3273.516,
|
||||
"max": 4752.301,
|
||||
"ci95": 1860.054
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 3363.749,
|
||||
"stdev": 224.552,
|
||||
"min": 3105.622,
|
||||
"max": 3514.059,
|
||||
"ci95": 557.864
|
||||
},
|
||||
"chunks.village.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 15473.667,
|
||||
"stdev": 339.602,
|
||||
"min": 15123.0,
|
||||
"max": 15801.0,
|
||||
"ci95": 843.686
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 999.753,
|
||||
"stdev": 41.901,
|
||||
"min": 970.527,
|
||||
"max": 1047.758,
|
||||
"ci95": 104.095
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 336.821,
|
||||
"stdev": 17.834,
|
||||
"min": 318.068,
|
||||
"max": 353.566,
|
||||
"ci95": 44.306
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 263.782,
|
||||
"stdev": 31.556,
|
||||
"min": 227.767,
|
||||
"max": 286.582,
|
||||
"ci95": 78.395
|
||||
},
|
||||
"memory.village.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1147.654,
|
||||
"stdev": 31.82,
|
||||
"min": 1116.875,
|
||||
"max": 1180.422,
|
||||
"ci95": 79.052
|
||||
},
|
||||
"tick.village.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.414,
|
||||
"stdev": 0.036,
|
||||
"min": 0.373,
|
||||
"max": 0.44,
|
||||
"ci95": 0.089
|
||||
},
|
||||
"cpu.village.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.52,
|
||||
"stdev": 0.071,
|
||||
"min": 0.44,
|
||||
"max": 0.577,
|
||||
"ci95": 0.177
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 3685.368,
|
||||
"stdev": 844.43,
|
||||
"min": 2932.117,
|
||||
"max": 4598.204,
|
||||
"ci95": 2097.85
|
||||
},
|
||||
"chunks.forest.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 16795.333,
|
||||
"stdev": 437.802,
|
||||
"min": 16325.0,
|
||||
"max": 17191.0,
|
||||
"ci95": 1087.648
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 592.615,
|
||||
"stdev": 20.339,
|
||||
"min": 580.401,
|
||||
"max": 616.094,
|
||||
"ci95": 50.529
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 250.069,
|
||||
"stdev": 31.227,
|
||||
"min": 217.065,
|
||||
"max": 279.149,
|
||||
"ci95": 77.58
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 151.684,
|
||||
"stdev": 77.064,
|
||||
"min": 64.033,
|
||||
"max": 208.811,
|
||||
"ci95": 191.454
|
||||
},
|
||||
"memory.forest.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1064.344,
|
||||
"stdev": 148.185,
|
||||
"min": 901.203,
|
||||
"max": 1190.609,
|
||||
"ci95": 368.142
|
||||
},
|
||||
"tick.forest.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.424,
|
||||
"stdev": 0.003,
|
||||
"min": 0.422,
|
||||
"max": 0.427,
|
||||
"ci95": 0.007
|
||||
},
|
||||
"cpu.forest.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.617,
|
||||
"stdev": 0.016,
|
||||
"min": 0.599,
|
||||
"max": 0.629,
|
||||
"ci95": 0.039
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 3594.311,
|
||||
"stdev": 1381.026,
|
||||
"min": 2011.153,
|
||||
"max": 4551.541,
|
||||
"ci95": 3430.936
|
||||
},
|
||||
"chunks.ocean.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 14527.0,
|
||||
"stdev": 319.629,
|
||||
"min": 14166.0,
|
||||
"max": 14774.0,
|
||||
"ci95": 794.068
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 789.003,
|
||||
"stdev": 25.076,
|
||||
"min": 766.83,
|
||||
"max": 816.216,
|
||||
"ci95": 62.297
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 276.887,
|
||||
"stdev": 51.785,
|
||||
"min": 217.373,
|
||||
"max": 311.665,
|
||||
"ci95": 128.651
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 169.278,
|
||||
"stdev": 29.119,
|
||||
"min": 137.156,
|
||||
"max": 193.944,
|
||||
"ci95": 72.341
|
||||
},
|
||||
"memory.ocean.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 982.995,
|
||||
"stdev": 287.819,
|
||||
"min": 651.109,
|
||||
"max": 1164.063,
|
||||
"ci95": 715.039
|
||||
},
|
||||
"tick.ocean.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.397,
|
||||
"stdev": 0.028,
|
||||
"min": 0.38,
|
||||
"max": 0.429,
|
||||
"ci95": 0.069
|
||||
},
|
||||
"cpu.ocean.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.569,
|
||||
"stdev": 0.045,
|
||||
"min": 0.533,
|
||||
"max": 0.62,
|
||||
"ci95": 0.112
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 5682.993,
|
||||
"stdev": 1272.063,
|
||||
"min": 4476.873,
|
||||
"max": 7012.069,
|
||||
"ci95": 3160.234
|
||||
},
|
||||
"chunks.mountain.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 15640.0,
|
||||
"stdev": 204.105,
|
||||
"min": 15507.0,
|
||||
"max": 15875.0,
|
||||
"ci95": 507.067
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 716.475,
|
||||
"stdev": 8.139,
|
||||
"min": 707.613,
|
||||
"max": 723.617,
|
||||
"ci95": 20.221
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 313.736,
|
||||
"stdev": 3.759,
|
||||
"min": 311.007,
|
||||
"max": 318.023,
|
||||
"ci95": 9.338
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 270.042,
|
||||
"stdev": 19.572,
|
||||
"min": 247.802,
|
||||
"max": 284.642,
|
||||
"ci95": 48.624
|
||||
},
|
||||
"memory.mountain.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 997.474,
|
||||
"stdev": 231.733,
|
||||
"min": 790.266,
|
||||
"max": 1247.703,
|
||||
"ci95": 575.704
|
||||
},
|
||||
"tick.mountain.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.385,
|
||||
"stdev": 0.023,
|
||||
"min": 0.359,
|
||||
"max": 0.401,
|
||||
"ci95": 0.057
|
||||
},
|
||||
"cpu.mountain.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.609,
|
||||
"stdev": 0.044,
|
||||
"min": 0.56,
|
||||
"max": 0.645,
|
||||
"ci95": 0.109
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 1213.255,
|
||||
"stdev": 81.897,
|
||||
"min": 1136.078,
|
||||
"max": 1299.172,
|
||||
"ci95": 203.461
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1034.279,
|
||||
"stdev": 188.802,
|
||||
"min": 816.453,
|
||||
"max": 1150.961,
|
||||
"ci95": 469.049
|
||||
},
|
||||
"memory.footprint_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2167.747,
|
||||
"stdev": 14.039,
|
||||
"min": 2153.721,
|
||||
"max": 2181.799,
|
||||
"ci95": 34.878
|
||||
},
|
||||
"memory.footprint_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1889.468,
|
||||
"stdev": 24.444,
|
||||
"min": 1862.799,
|
||||
"max": 1910.807,
|
||||
"ci95": 60.727
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 12573,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1047.758,
|
||||
"low_1pct_fps": 353.566,
|
||||
"low_01pct_fps": 286.582,
|
||||
"p50_ms": 0.921,
|
||||
"p95_ms": 1.077,
|
||||
"p99_ms": 2.675,
|
||||
"p999_ms": 2.946,
|
||||
"max_ms": 5.36,
|
||||
"cpu_p50_ms": 0.44,
|
||||
"gpu_p50_ms": 0.914,
|
||||
"histogram": [
|
||||
11056,
|
||||
1291,
|
||||
218,
|
||||
6,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 11646,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 970.527,
|
||||
"low_1pct_fps": 318.068,
|
||||
"low_01pct_fps": 227.767,
|
||||
"p50_ms": 0.973,
|
||||
"p95_ms": 1.264,
|
||||
"p99_ms": 2.805,
|
||||
"p999_ms": 3.808,
|
||||
"max_ms": 5.305,
|
||||
"cpu_p50_ms": 0.577,
|
||||
"gpu_p50_ms": 0.969,
|
||||
"histogram": [
|
||||
7035,
|
||||
4334,
|
||||
230,
|
||||
39,
|
||||
6,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 11771,
|
||||
"seconds": 11.999,
|
||||
"avg_fps": 980.973,
|
||||
"low_1pct_fps": 338.828,
|
||||
"low_01pct_fps": 276.996,
|
||||
"p50_ms": 0.985,
|
||||
"p95_ms": 1.157,
|
||||
"p99_ms": 2.779,
|
||||
"p999_ms": 3.068,
|
||||
"max_ms": 4.308,
|
||||
"cpu_p50_ms": 0.542,
|
||||
"gpu_p50_ms": 0.98,
|
||||
"histogram": [
|
||||
6880,
|
||||
4663,
|
||||
208,
|
||||
18,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 7394,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 616.094,
|
||||
"low_1pct_fps": 253.993,
|
||||
"low_01pct_fps": 182.207,
|
||||
"p50_ms": 1.565,
|
||||
"p95_ms": 1.915,
|
||||
"p99_ms": 3.329,
|
||||
"p999_ms": 4.661,
|
||||
"max_ms": 7.32,
|
||||
"cpu_p50_ms": 0.599,
|
||||
"gpu_p50_ms": 1.551,
|
||||
"histogram": [
|
||||
82,
|
||||
6988,
|
||||
144,
|
||||
162,
|
||||
14,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 6965,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 580.401,
|
||||
"low_1pct_fps": 217.065,
|
||||
"low_01pct_fps": 64.033,
|
||||
"p50_ms": 1.654,
|
||||
"p95_ms": 1.983,
|
||||
"p99_ms": 3.233,
|
||||
"p999_ms": 3.893,
|
||||
"max_ms": 81.863,
|
||||
"cpu_p50_ms": 0.629,
|
||||
"gpu_p50_ms": 1.645,
|
||||
"histogram": [
|
||||
11,
|
||||
6622,
|
||||
153,
|
||||
174,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 6977,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 581.35,
|
||||
"low_1pct_fps": 279.149,
|
||||
"low_01pct_fps": 208.811,
|
||||
"p50_ms": 1.659,
|
||||
"p95_ms": 2.006,
|
||||
"p99_ms": 3.273,
|
||||
"p999_ms": 3.982,
|
||||
"max_ms": 5.889,
|
||||
"cpu_p50_ms": 0.623,
|
||||
"gpu_p50_ms": 1.653,
|
||||
"histogram": [
|
||||
17,
|
||||
6606,
|
||||
164,
|
||||
186,
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 9793,
|
||||
"seconds": 11.998,
|
||||
"avg_fps": 816.216,
|
||||
"low_1pct_fps": 301.624,
|
||||
"low_01pct_fps": 176.734,
|
||||
"p50_ms": 1.171,
|
||||
"p95_ms": 1.499,
|
||||
"p99_ms": 2.928,
|
||||
"p999_ms": 3.333,
|
||||
"max_ms": 23.472,
|
||||
"cpu_p50_ms": 0.533,
|
||||
"gpu_p50_ms": 1.163,
|
||||
"histogram": [
|
||||
259,
|
||||
9293,
|
||||
182,
|
||||
56,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 9202,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 766.83,
|
||||
"low_1pct_fps": 217.373,
|
||||
"low_01pct_fps": 137.156,
|
||||
"p50_ms": 1.226,
|
||||
"p95_ms": 1.912,
|
||||
"p99_ms": 3.396,
|
||||
"p999_ms": 6.003,
|
||||
"max_ms": 13.562,
|
||||
"cpu_p50_ms": 0.62,
|
||||
"gpu_p50_ms": 1.219,
|
||||
"histogram": [
|
||||
635,
|
||||
8130,
|
||||
261,
|
||||
124,
|
||||
27,
|
||||
15,
|
||||
7,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 9408,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 783.964,
|
||||
"low_1pct_fps": 311.665,
|
||||
"low_01pct_fps": 193.944,
|
||||
"p50_ms": 1.22,
|
||||
"p95_ms": 1.541,
|
||||
"p99_ms": 2.865,
|
||||
"p999_ms": 3.298,
|
||||
"max_ms": 13.861,
|
||||
"cpu_p50_ms": 0.555,
|
||||
"gpu_p50_ms": 1.214,
|
||||
"histogram": [
|
||||
100,
|
||||
9066,
|
||||
204,
|
||||
34,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 8618,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 718.194,
|
||||
"low_1pct_fps": 312.177,
|
||||
"low_01pct_fps": 277.681,
|
||||
"p50_ms": 1.338,
|
||||
"p95_ms": 1.664,
|
||||
"p99_ms": 3.068,
|
||||
"p999_ms": 3.364,
|
||||
"max_ms": 4.008,
|
||||
"cpu_p50_ms": 0.622,
|
||||
"gpu_p50_ms": 1.329,
|
||||
"histogram": [
|
||||
62,
|
||||
8301,
|
||||
130,
|
||||
125,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 8492,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 707.613,
|
||||
"low_1pct_fps": 311.007,
|
||||
"low_01pct_fps": 284.642,
|
||||
"p50_ms": 1.35,
|
||||
"p95_ms": 1.707,
|
||||
"p99_ms": 3.08,
|
||||
"p999_ms": 3.329,
|
||||
"max_ms": 3.796,
|
||||
"cpu_p50_ms": 0.645,
|
||||
"gpu_p50_ms": 1.342,
|
||||
"histogram": [
|
||||
32,
|
||||
8216,
|
||||
111,
|
||||
133,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 8684,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 723.617,
|
||||
"low_1pct_fps": 318.023,
|
||||
"low_01pct_fps": 247.802,
|
||||
"p50_ms": 1.318,
|
||||
"p95_ms": 1.699,
|
||||
"p99_ms": 2.875,
|
||||
"p999_ms": 3.573,
|
||||
"max_ms": 5.186,
|
||||
"cpu_p50_ms": 0.56,
|
||||
"gpu_p50_ms": 1.306,
|
||||
"histogram": [
|
||||
46,
|
||||
8389,
|
||||
199,
|
||||
49,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "horizon-macmini"
|
||||
}
|
||||
@@ -0,0 +1,854 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-04T13:17:26Z",
|
||||
"app": {
|
||||
"name": "horizon-client",
|
||||
"version": "nightly-2026-11-04-67dad0d",
|
||||
"commit": "67dad0d"
|
||||
},
|
||||
"hardware": {
|
||||
"os": "macos",
|
||||
"arch": "aarch64",
|
||||
"cpu_threads": 14,
|
||||
"os_version": "27.0.1",
|
||||
"cpu": "Apple M4 Pro",
|
||||
"ram_mb": 65536,
|
||||
"gpu": {
|
||||
"name": "Apple M4 Pro",
|
||||
"vendor": "",
|
||||
"driver": "",
|
||||
"backend": "metal",
|
||||
"kind": "integrated"
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 32,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": true,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 17.712,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 1528.444,
|
||||
"stdev": 181.21,
|
||||
"min": 1319.58,
|
||||
"max": 1643.783,
|
||||
"ci95": 450.188
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 12368.716,
|
||||
"stdev": 6475.37,
|
||||
"min": 7168.429,
|
||||
"max": 19621.62,
|
||||
"ci95": 16087.01
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 6926.208,
|
||||
"stdev": 3712.691,
|
||||
"min": 4208.555,
|
||||
"max": 11156.419,
|
||||
"ci95": 9223.581
|
||||
},
|
||||
"chunks.village.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 46790.333,
|
||||
"stdev": 1381.89,
|
||||
"min": 45236.0,
|
||||
"max": 47880.0,
|
||||
"ci95": 3433.082
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 453.505,
|
||||
"stdev": 14.384,
|
||||
"min": 441.517,
|
||||
"max": 469.455,
|
||||
"ci95": 35.735
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 222.897,
|
||||
"stdev": 10.449,
|
||||
"min": 212.173,
|
||||
"max": 233.048,
|
||||
"ci95": 25.96
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 173.55,
|
||||
"stdev": 31.798,
|
||||
"min": 142.967,
|
||||
"max": 206.437,
|
||||
"ci95": 78.996
|
||||
},
|
||||
"memory.village.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1544.565,
|
||||
"stdev": 26.422,
|
||||
"min": 1516.75,
|
||||
"max": 1569.328,
|
||||
"ci95": 65.64
|
||||
},
|
||||
"tick.village.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.528,
|
||||
"stdev": 0.018,
|
||||
"min": 0.511,
|
||||
"max": 0.548,
|
||||
"ci95": 0.045
|
||||
},
|
||||
"cpu.village.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.496,
|
||||
"stdev": 0.049,
|
||||
"min": 1.453,
|
||||
"max": 1.549,
|
||||
"ci95": 0.121
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 8297.116,
|
||||
"stdev": 689.909,
|
||||
"min": 7716.447,
|
||||
"max": 9059.776,
|
||||
"ci95": 1713.966
|
||||
},
|
||||
"chunks.forest.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 78617.667,
|
||||
"stdev": 494.532,
|
||||
"min": 78228.0,
|
||||
"max": 79174.0,
|
||||
"ci95": 1228.586
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 216.265,
|
||||
"stdev": 6.372,
|
||||
"min": 209.501,
|
||||
"max": 222.156,
|
||||
"ci95": 15.831
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 137.027,
|
||||
"stdev": 3.787,
|
||||
"min": 133.07,
|
||||
"max": 140.617,
|
||||
"ci95": 9.408
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 114.683,
|
||||
"stdev": 7.985,
|
||||
"min": 106.585,
|
||||
"max": 122.551,
|
||||
"ci95": 19.839
|
||||
},
|
||||
"memory.forest.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1420.638,
|
||||
"stdev": 247.812,
|
||||
"min": 1266.047,
|
||||
"max": 1706.469,
|
||||
"ci95": 615.649
|
||||
},
|
||||
"tick.forest.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.423,
|
||||
"stdev": 0.059,
|
||||
"min": 0.355,
|
||||
"max": 0.458,
|
||||
"ci95": 0.148
|
||||
},
|
||||
"cpu.forest.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.605,
|
||||
"stdev": 0.269,
|
||||
"min": 1.295,
|
||||
"max": 1.779,
|
||||
"ci95": 0.668
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 8354.659,
|
||||
"stdev": 2877.749,
|
||||
"min": 5454.947,
|
||||
"max": 11209.93,
|
||||
"ci95": 7149.301
|
||||
},
|
||||
"chunks.ocean.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 74315.667,
|
||||
"stdev": 63.106,
|
||||
"min": 74246.0,
|
||||
"max": 74369.0,
|
||||
"ci95": 156.776
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 274.828,
|
||||
"stdev": 4.993,
|
||||
"min": 270.041,
|
||||
"max": 280.004,
|
||||
"ci95": 12.404
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 165.765,
|
||||
"stdev": 7.21,
|
||||
"min": 157.538,
|
||||
"max": 170.985,
|
||||
"ci95": 17.912
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 140.185,
|
||||
"stdev": 9.74,
|
||||
"min": 129.976,
|
||||
"max": 149.377,
|
||||
"ci95": 24.198
|
||||
},
|
||||
"memory.ocean.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1306.578,
|
||||
"stdev": 97.011,
|
||||
"min": 1199.094,
|
||||
"max": 1387.641,
|
||||
"ci95": 241.007
|
||||
},
|
||||
"tick.ocean.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.416,
|
||||
"stdev": 0.029,
|
||||
"min": 0.392,
|
||||
"max": 0.448,
|
||||
"ci95": 0.071
|
||||
},
|
||||
"cpu.ocean.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.636,
|
||||
"stdev": 0.121,
|
||||
"min": 1.496,
|
||||
"max": 1.708,
|
||||
"ci95": 0.301
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 17124.873,
|
||||
"stdev": 3461.08,
|
||||
"min": 13599.217,
|
||||
"max": 20517.544,
|
||||
"ci95": 8598.494
|
||||
},
|
||||
"chunks.mountain.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 91909.0,
|
||||
"stdev": 815.603,
|
||||
"min": 91157.0,
|
||||
"max": 92776.0,
|
||||
"ci95": 2026.235
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 242.657,
|
||||
"stdev": 3.405,
|
||||
"min": 240.66,
|
||||
"max": 246.588,
|
||||
"ci95": 8.459
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 110.95,
|
||||
"stdev": 14.622,
|
||||
"min": 99.907,
|
||||
"max": 127.532,
|
||||
"ci95": 36.325
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 51.329,
|
||||
"stdev": 28.253,
|
||||
"min": 30.391,
|
||||
"max": 83.465,
|
||||
"ci95": 70.191
|
||||
},
|
||||
"memory.mountain.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1451.688,
|
||||
"stdev": 127.471,
|
||||
"min": 1310.969,
|
||||
"max": 1559.43,
|
||||
"ci95": 316.681
|
||||
},
|
||||
"tick.mountain.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.614,
|
||||
"stdev": 0.017,
|
||||
"min": 0.594,
|
||||
"max": 0.627,
|
||||
"ci95": 0.043
|
||||
},
|
||||
"cpu.mountain.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 2.233,
|
||||
"stdev": 0.05,
|
||||
"min": 2.194,
|
||||
"max": 2.289,
|
||||
"ci95": 0.124
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 1697.984,
|
||||
"stdev": 29.735,
|
||||
"min": 1667.406,
|
||||
"max": 1726.797,
|
||||
"ci95": 73.871
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1428.19,
|
||||
"stdev": 120.658,
|
||||
"min": 1333.0,
|
||||
"max": 1563.891,
|
||||
"ci95": 299.756
|
||||
},
|
||||
"memory.footprint_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 4921.753,
|
||||
"stdev": 30.436,
|
||||
"min": 4890.971,
|
||||
"max": 4951.831,
|
||||
"ci95": 75.613
|
||||
},
|
||||
"memory.footprint_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 4079.43,
|
||||
"stdev": 10.978,
|
||||
"min": 4068.049,
|
||||
"max": 4089.956,
|
||||
"ci95": 27.273
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 5634,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 469.455,
|
||||
"low_1pct_fps": 223.469,
|
||||
"low_01pct_fps": 171.246,
|
||||
"p50_ms": 2.054,
|
||||
"p95_ms": 2.492,
|
||||
"p99_ms": 3.993,
|
||||
"p999_ms": 5.425,
|
||||
"max_ms": 6.645,
|
||||
"cpu_p50_ms": 1.453,
|
||||
"gpu_p50_ms": 1.965,
|
||||
"histogram": [
|
||||
0,
|
||||
2082,
|
||||
3324,
|
||||
193,
|
||||
24,
|
||||
9,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 5395,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 449.543,
|
||||
"low_1pct_fps": 233.048,
|
||||
"low_01pct_fps": 206.437,
|
||||
"p50_ms": 2.155,
|
||||
"p95_ms": 2.611,
|
||||
"p99_ms": 4.068,
|
||||
"p999_ms": 4.456,
|
||||
"max_ms": 5.305,
|
||||
"cpu_p50_ms": 1.549,
|
||||
"gpu_p50_ms": 2.043,
|
||||
"histogram": [
|
||||
0,
|
||||
902,
|
||||
4255,
|
||||
206,
|
||||
29,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 5298,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 441.517,
|
||||
"low_1pct_fps": 212.173,
|
||||
"low_01pct_fps": 142.967,
|
||||
"p50_ms": 2.171,
|
||||
"p95_ms": 3.249,
|
||||
"p99_ms": 4.138,
|
||||
"p999_ms": 5.591,
|
||||
"max_ms": 9.676,
|
||||
"cpu_p50_ms": 1.485,
|
||||
"gpu_p50_ms": 2.092,
|
||||
"histogram": [
|
||||
0,
|
||||
709,
|
||||
4314,
|
||||
229,
|
||||
36,
|
||||
6,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 2606,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 217.137,
|
||||
"low_1pct_fps": 140.617,
|
||||
"low_01pct_fps": 106.585,
|
||||
"p50_ms": 4.471,
|
||||
"p95_ms": 5.926,
|
||||
"p99_ms": 6.575,
|
||||
"p999_ms": 7.735,
|
||||
"max_ms": 12.187,
|
||||
"cpu_p50_ms": 1.74,
|
||||
"gpu_p50_ms": 4.238,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
221,
|
||||
2094,
|
||||
172,
|
||||
110,
|
||||
8,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 2514,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 209.501,
|
||||
"low_1pct_fps": 137.393,
|
||||
"low_01pct_fps": 122.551,
|
||||
"p50_ms": 4.627,
|
||||
"p95_ms": 6.304,
|
||||
"p99_ms": 6.93,
|
||||
"p999_ms": 7.971,
|
||||
"max_ms": 8.506,
|
||||
"cpu_p50_ms": 1.779,
|
||||
"gpu_p50_ms": 4.409,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
35,
|
||||
2097,
|
||||
200,
|
||||
162,
|
||||
17,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 2667,
|
||||
"seconds": 12.005,
|
||||
"avg_fps": 222.156,
|
||||
"low_1pct_fps": 133.07,
|
||||
"low_01pct_fps": 114.913,
|
||||
"p50_ms": 4.37,
|
||||
"p95_ms": 5.756,
|
||||
"p99_ms": 6.742,
|
||||
"p999_ms": 8.444,
|
||||
"max_ms": 8.984,
|
||||
"cpu_p50_ms": 1.295,
|
||||
"gpu_p50_ms": 4.152,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
440,
|
||||
1978,
|
||||
144,
|
||||
85,
|
||||
16,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 3294,
|
||||
"seconds": 12.003,
|
||||
"avg_fps": 274.44,
|
||||
"low_1pct_fps": 157.538,
|
||||
"low_01pct_fps": 129.976,
|
||||
"p50_ms": 3.521,
|
||||
"p95_ms": 4.814,
|
||||
"p99_ms": 5.592,
|
||||
"p999_ms": 7.491,
|
||||
"max_ms": 7.868,
|
||||
"cpu_p50_ms": 1.703,
|
||||
"gpu_p50_ms": 3.277,
|
||||
"histogram": [
|
||||
0,
|
||||
4,
|
||||
55,
|
||||
2967,
|
||||
130,
|
||||
122,
|
||||
10,
|
||||
6,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 3241,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 270.041,
|
||||
"low_1pct_fps": 168.771,
|
||||
"low_01pct_fps": 141.201,
|
||||
"p50_ms": 3.578,
|
||||
"p95_ms": 4.942,
|
||||
"p99_ms": 5.631,
|
||||
"p999_ms": 6.498,
|
||||
"max_ms": 7.601,
|
||||
"cpu_p50_ms": 1.708,
|
||||
"gpu_p50_ms": 3.323,
|
||||
"histogram": [
|
||||
0,
|
||||
2,
|
||||
8,
|
||||
2970,
|
||||
110,
|
||||
144,
|
||||
5,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 3360,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 280.004,
|
||||
"low_1pct_fps": 170.985,
|
||||
"low_01pct_fps": 149.377,
|
||||
"p50_ms": 3.477,
|
||||
"p95_ms": 4.678,
|
||||
"p99_ms": 5.462,
|
||||
"p999_ms": 6.626,
|
||||
"max_ms": 6.788,
|
||||
"cpu_p50_ms": 1.496,
|
||||
"gpu_p50_ms": 3.23,
|
||||
"histogram": [
|
||||
0,
|
||||
1,
|
||||
5,
|
||||
3125,
|
||||
129,
|
||||
90,
|
||||
10,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 2888,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 240.66,
|
||||
"low_1pct_fps": 127.532,
|
||||
"low_01pct_fps": 83.465,
|
||||
"p50_ms": 3.984,
|
||||
"p95_ms": 5.171,
|
||||
"p99_ms": 6.823,
|
||||
"p999_ms": 9.168,
|
||||
"max_ms": 15.341,
|
||||
"cpu_p50_ms": 2.194,
|
||||
"gpu_p50_ms": 3.786,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
2048,
|
||||
638,
|
||||
144,
|
||||
33,
|
||||
16,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 2889,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 240.722,
|
||||
"low_1pct_fps": 99.907,
|
||||
"low_01pct_fps": 40.132,
|
||||
"p50_ms": 3.961,
|
||||
"p95_ms": 5.706,
|
||||
"p99_ms": 7.364,
|
||||
"p999_ms": 12.597,
|
||||
"max_ms": 49.156,
|
||||
"cpu_p50_ms": 2.289,
|
||||
"gpu_p50_ms": 3.802,
|
||||
"histogram": [
|
||||
0,
|
||||
5,
|
||||
126,
|
||||
1859,
|
||||
599,
|
||||
194,
|
||||
72,
|
||||
22,
|
||||
8,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 2959,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 246.588,
|
||||
"low_1pct_fps": 105.411,
|
||||
"low_01pct_fps": 30.391,
|
||||
"p50_ms": 3.851,
|
||||
"p95_ms": 5.353,
|
||||
"p99_ms": 6.248,
|
||||
"p999_ms": 9.05,
|
||||
"max_ms": 54.378,
|
||||
"cpu_p50_ms": 2.216,
|
||||
"gpu_p50_ms": 3.667,
|
||||
"histogram": [
|
||||
0,
|
||||
2,
|
||||
58,
|
||||
2196,
|
||||
462,
|
||||
196,
|
||||
32,
|
||||
10,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "horizon-macmini"
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# Horizon Nightly 2026-11-04 vs vanilla 26.3 on the Mac mini and the desktop (2026-11-04)
|
||||
|
||||
> **The Mac mini reports (`*-macmini-rd*.json`) are offscreen, not comparable.** Horizon rendered offscreen (no presenting)
|
||||
> while vanilla drew into a window, so the Mac rows below are not a Horizon-vs-vanilla comparison, and the website does not
|
||||
> publish them: it keeps the Mac mini's 2026-10-22 runs. They are kept here only as a record of that day's offscreen run.
|
||||
|
||||
The desktop runs are the ones behind playhorizon.dev/benchmarks as of 2026-11-04 (horizon-rust/horizon-site#58): `tools/perf_bench.py`
|
||||
reports, render distance 12 and 32, three games each, every game from a fresh world (seed 1234), 1280x720, VSync off. Files:
|
||||
`{horizon,vanilla}-macmini-rd{12,32}.json` and `{horizon,vanilla}-desktop-rd{12,32}.json` next to this page.
|
||||
|
||||
## Builds
|
||||
|
||||
- **Horizon:** the Nightly 2026-11-04 release bundle (67dad0d), as the launcher installs it: macOS arm64 on the Mac,
|
||||
Linux x86_64 on the desktop. Its resources come from the bundle's own `horizon-prepare`. perf_bench ran from main
|
||||
a55b9a2e52 and wrote that checkout's crate version and commit; `app.version` and `app.commit` were set to the bundle's by hand.
|
||||
- **Vanilla:** the 26.3 client jar with Fabric Loader and the driver mod, Java 25, the official launcher's JVM flags.
|
||||
|
||||
## Machines and conditions
|
||||
|
||||
- **Mac mini, Apple M4 Pro (14 cores, 20-core GPU), macOS: offscreen, not comparable.** The session was locked, so Horizon ran `--offscreen`: a locked
|
||||
session gives a window no frames, and windowed Horizon measured 0 FPS. Vanilla drew into its window. Offscreen skips
|
||||
presenting, which favours Horizon, so its averages aren't held at the 144 Hz display as they were on 2026-10-22. Each game
|
||||
started at a 1-minute load of 17-18 (the run waited for < 20); during the runs the load rose to 30-50.
|
||||
- **Desktop, RTX 4080 + i7-13700KF, NixOS, NVIDIA 595.** Both games drew into a headless sway rendering on the RTX 4080 (never
|
||||
on its screen), both through Wayland on Vulkan. Vanilla went through SDL's Wayland driver: through Xwayland each of its
|
||||
frames took about 2 s on this day. The machine also runs CI and agents' builds: each game started at a load of 28-30 on
|
||||
24 threads (the run waited for < 30) and the load rose to 40-70 during the runs. Vanilla at RD 32 crashed twice under that
|
||||
load (`GPU timeout attempting to acquire next frame`) before a third series finished. Start-up times are inflated by the
|
||||
load and by a cold disk on each series' first start (start → world up to 130-235 s).
|
||||
- **Order:** RD 12 ran Horizon then vanilla; RD 32 ran vanilla then Horizon.
|
||||
|
||||
## Results (average over the flights of the three games; FPS average / 1% low)
|
||||
|
||||
| Machine | RD | Horizon | Vanilla | Horizon ÷ vanilla (avg, 1% low) |
|
||||
|---|---|---|---|---|
|
||||
| Mac mini (**offscreen, not comparable**) | 12 | 774 / 294 | 132 / 27 | — |
|
||||
| Mac mini (**offscreen, not comparable**) | 32 | 297 / 159 | 68 / 16 | — |
|
||||
| Desktop | 12 | 2,224 / 308 | 1,824 / 201 | 1.2x, 1.5x |
|
||||
| Desktop | 32 | 800 / 140 | 696 / 64 | 1.15x, 2.2x |
|
||||
|
||||
Neither machine was idle, so these say how both games behave on busy machines. They are not clean ratios; for those, see
|
||||
`windows-9800x3d-5080-2026-10-29.md`. The scripts and logs are in horizon-parity `shots/site-bench-2026-11-04/`.
|
||||
@@ -0,0 +1,787 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-04T13:28:55Z",
|
||||
"app": {
|
||||
"name": "minecraft",
|
||||
"version": "26.3",
|
||||
"mods": [
|
||||
{
|
||||
"name": "fabric-loader",
|
||||
"version": "0.19.5"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"os": "linux",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 24,
|
||||
"os_version": "NixOS 26.05 (Yarara) (kernel 6.18.57)",
|
||||
"cpu": "13th Gen Intel(R) Core(TM) i7-13700KF",
|
||||
"ram_mb": 31904,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 4080",
|
||||
"vendor": "NVIDIA",
|
||||
"driver": "1.4.329 NVIDIA 595.71.05",
|
||||
"backend": "vulkan",
|
||||
"kind": ""
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 12,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 28.788,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 121075.467,
|
||||
"stdev": 30646.429,
|
||||
"min": 93272.034,
|
||||
"max": 153935.834,
|
||||
"ci95": 76136.094
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 191063.8,
|
||||
"stdev": 34368.16,
|
||||
"min": 153895.533,
|
||||
"max": 221692.034,
|
||||
"ci95": 85382.134
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 18724.322,
|
||||
"stdev": 9639.048,
|
||||
"min": 13107.549,
|
||||
"max": 29854.376,
|
||||
"ci95": 23946.655
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2161.532,
|
||||
"stdev": 287.109,
|
||||
"min": 1830.069,
|
||||
"max": 2332.81,
|
||||
"ci95": 713.276
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 162.575,
|
||||
"stdev": 29.799,
|
||||
"min": 136.023,
|
||||
"max": 194.804,
|
||||
"ci95": 74.03
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 27.752,
|
||||
"stdev": 3.186,
|
||||
"min": 24.403,
|
||||
"max": 30.746,
|
||||
"ci95": 7.916
|
||||
},
|
||||
"memory.village.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 13.0,
|
||||
"stdev": 0.0,
|
||||
"min": 13.0,
|
||||
"max": 13.0,
|
||||
"ci95": 0.0
|
||||
},
|
||||
"memory.village.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 2902.054,
|
||||
"stdev": 158.708,
|
||||
"min": 2747.244,
|
||||
"max": 3064.393,
|
||||
"ci95": 394.285
|
||||
},
|
||||
"tick.village.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.691,
|
||||
"stdev": 0.097,
|
||||
"min": 0.601,
|
||||
"max": 0.794,
|
||||
"ci95": 0.241
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 8852.332,
|
||||
"stdev": 530.809,
|
||||
"min": 8350.811,
|
||||
"max": 9408.241,
|
||||
"ci95": 1318.709
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1841.581,
|
||||
"stdev": 1186.781,
|
||||
"min": 471.209,
|
||||
"max": 2529.847,
|
||||
"ci95": 2948.365
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 255.087,
|
||||
"stdev": 217.128,
|
||||
"min": 5.798,
|
||||
"max": 402.877,
|
||||
"ci95": 539.419
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 92.882,
|
||||
"stdev": 81.722,
|
||||
"min": 0.61,
|
||||
"max": 156.13,
|
||||
"ci95": 203.024
|
||||
},
|
||||
"memory.forest.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 13.0,
|
||||
"stdev": 0.0,
|
||||
"min": 13.0,
|
||||
"max": 13.0,
|
||||
"ci95": 0.0
|
||||
},
|
||||
"memory.forest.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 3008.178,
|
||||
"stdev": 149.07,
|
||||
"min": 2875.082,
|
||||
"max": 3169.256,
|
||||
"ci95": 370.34
|
||||
},
|
||||
"tick.forest.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.687,
|
||||
"stdev": 0.132,
|
||||
"min": 0.562,
|
||||
"max": 0.825,
|
||||
"ci95": 0.327
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 21215.745,
|
||||
"stdev": 22821.547,
|
||||
"min": 8034.077,
|
||||
"max": 47567.797,
|
||||
"ci95": 56696.441
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1525.483,
|
||||
"stdev": 1472.651,
|
||||
"min": 633.984,
|
||||
"max": 3225.275,
|
||||
"ci95": 3658.563
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 191.398,
|
||||
"stdev": 272.942,
|
||||
"min": 10.346,
|
||||
"max": 505.335,
|
||||
"ci95": 678.08
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 49.506,
|
||||
"stdev": 66.145,
|
||||
"min": 1.104,
|
||||
"max": 124.874,
|
||||
"ci95": 164.326
|
||||
},
|
||||
"memory.ocean.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 13.0,
|
||||
"stdev": 0.0,
|
||||
"min": 13.0,
|
||||
"max": 13.0,
|
||||
"ci95": 0.0
|
||||
},
|
||||
"memory.ocean.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 3062.136,
|
||||
"stdev": 128.594,
|
||||
"min": 2962.541,
|
||||
"max": 3207.313,
|
||||
"ci95": 319.472
|
||||
},
|
||||
"tick.ocean.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.029,
|
||||
"stdev": 0.573,
|
||||
"min": 0.595,
|
||||
"max": 1.678,
|
||||
"ci95": 1.423
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 33255.15,
|
||||
"stdev": 20451.99,
|
||||
"min": 16655.144,
|
||||
"max": 56102.104,
|
||||
"ci95": 50809.661
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1769.12,
|
||||
"stdev": 996.863,
|
||||
"min": 818.512,
|
||||
"max": 2806.557,
|
||||
"ci95": 2476.544
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 195.916,
|
||||
"stdev": 195.285,
|
||||
"min": 11.586,
|
||||
"max": 400.567,
|
||||
"ci95": 485.154
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 52.202,
|
||||
"stdev": 51.208,
|
||||
"min": 1.461,
|
||||
"max": 103.864,
|
||||
"ci95": 127.217
|
||||
},
|
||||
"memory.mountain.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 13.0,
|
||||
"stdev": 0.0,
|
||||
"min": 13.0,
|
||||
"max": 13.0,
|
||||
"ci95": 0.0
|
||||
},
|
||||
"memory.mountain.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 2957.948,
|
||||
"stdev": 340.935,
|
||||
"min": 2586.504,
|
||||
"max": 3256.625,
|
||||
"ci95": 846.998
|
||||
},
|
||||
"tick.mountain.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.436,
|
||||
"stdev": 1.406,
|
||||
"min": 0.462,
|
||||
"max": 3.048,
|
||||
"ci95": 3.494
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 3107.953,
|
||||
"stdev": 141.888,
|
||||
"min": 3020.105,
|
||||
"max": 3271.645,
|
||||
"ci95": 352.498
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 2985.317,
|
||||
"stdev": 210.327,
|
||||
"min": 2761.164,
|
||||
"max": 3178.35,
|
||||
"ci95": 522.523
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 21963,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1830.069,
|
||||
"low_1pct_fps": 136.023,
|
||||
"low_01pct_fps": 30.746,
|
||||
"p50_ms": 0.331,
|
||||
"p95_ms": 1.153,
|
||||
"p99_ms": 3.001,
|
||||
"p999_ms": 9.997,
|
||||
"max_ms": 326.36,
|
||||
"histogram": [
|
||||
20336,
|
||||
1238,
|
||||
167,
|
||||
108,
|
||||
40,
|
||||
23,
|
||||
12,
|
||||
10,
|
||||
8,
|
||||
7,
|
||||
5,
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 27994,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2332.81,
|
||||
"low_1pct_fps": 194.804,
|
||||
"low_01pct_fps": 28.107,
|
||||
"p50_ms": 0.321,
|
||||
"p95_ms": 0.771,
|
||||
"p99_ms": 1.356,
|
||||
"p999_ms": 3.836,
|
||||
"max_ms": 331.931,
|
||||
"histogram": [
|
||||
27270,
|
||||
652,
|
||||
34,
|
||||
13,
|
||||
5,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 27861,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2321.717,
|
||||
"low_1pct_fps": 156.898,
|
||||
"low_01pct_fps": 24.403,
|
||||
"p50_ms": 0.281,
|
||||
"p95_ms": 0.852,
|
||||
"p99_ms": 1.73,
|
||||
"p999_ms": 6.331,
|
||||
"max_ms": 301.991,
|
||||
"histogram": [
|
||||
26932,
|
||||
726,
|
||||
124,
|
||||
42,
|
||||
4,
|
||||
4,
|
||||
3,
|
||||
2,
|
||||
9,
|
||||
2,
|
||||
3,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 30359,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2529.847,
|
||||
"low_1pct_fps": 356.586,
|
||||
"low_01pct_fps": 121.906,
|
||||
"p50_ms": 0.353,
|
||||
"p95_ms": 0.563,
|
||||
"p99_ms": 1.405,
|
||||
"p999_ms": 4.444,
|
||||
"max_ms": 20.302,
|
||||
"histogram": [
|
||||
29756,
|
||||
460,
|
||||
57,
|
||||
52,
|
||||
11,
|
||||
5,
|
||||
3,
|
||||
5,
|
||||
2,
|
||||
4,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 5781,
|
||||
"seconds": 12.268,
|
||||
"avg_fps": 471.209,
|
||||
"low_1pct_fps": 5.798,
|
||||
"low_01pct_fps": 0.61,
|
||||
"p50_ms": 0.359,
|
||||
"p95_ms": 0.77,
|
||||
"p99_ms": 1.78,
|
||||
"p999_ms": 20.826,
|
||||
"max_ms": 5834.37,
|
||||
"histogram": [
|
||||
5586,
|
||||
158,
|
||||
12,
|
||||
11,
|
||||
0,
|
||||
1,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 30285,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2523.687,
|
||||
"low_1pct_fps": 402.877,
|
||||
"low_01pct_fps": 156.13,
|
||||
"p50_ms": 0.351,
|
||||
"p95_ms": 0.539,
|
||||
"p99_ms": 1.574,
|
||||
"p999_ms": 3.683,
|
||||
"max_ms": 16.022,
|
||||
"histogram": [
|
||||
29666,
|
||||
490,
|
||||
74,
|
||||
35,
|
||||
7,
|
||||
2,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
5,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 11685,
|
||||
"seconds": 16.293,
|
||||
"avg_fps": 717.19,
|
||||
"low_1pct_fps": 10.346,
|
||||
"low_01pct_fps": 1.104,
|
||||
"p50_ms": 0.299,
|
||||
"p95_ms": 1.119,
|
||||
"p99_ms": 2.568,
|
||||
"p999_ms": 12.054,
|
||||
"max_ms": 5250.537,
|
||||
"histogram": [
|
||||
10993,
|
||||
502,
|
||||
102,
|
||||
39,
|
||||
14,
|
||||
8,
|
||||
6,
|
||||
4,
|
||||
4,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 38704,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3225.275,
|
||||
"low_1pct_fps": 505.335,
|
||||
"low_01pct_fps": 124.874,
|
||||
"p50_ms": 0.283,
|
||||
"p95_ms": 0.36,
|
||||
"p99_ms": 0.909,
|
||||
"p999_ms": 2.015,
|
||||
"max_ms": 132.801,
|
||||
"histogram": [
|
||||
38385,
|
||||
279,
|
||||
25,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
4,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 7609,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 633.984,
|
||||
"low_1pct_fps": 58.513,
|
||||
"low_01pct_fps": 22.539,
|
||||
"p50_ms": 1.141,
|
||||
"p95_ms": 4.438,
|
||||
"p99_ms": 9.113,
|
||||
"p999_ms": 27.496,
|
||||
"max_ms": 64.424,
|
||||
"histogram": [
|
||||
3452,
|
||||
2398,
|
||||
956,
|
||||
368,
|
||||
143,
|
||||
96,
|
||||
61,
|
||||
46,
|
||||
20,
|
||||
29,
|
||||
6,
|
||||
10,
|
||||
6,
|
||||
7,
|
||||
7,
|
||||
0,
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 10168,
|
||||
"seconds": 12.423,
|
||||
"avg_fps": 818.512,
|
||||
"low_1pct_fps": 11.586,
|
||||
"low_01pct_fps": 1.461,
|
||||
"p50_ms": 0.267,
|
||||
"p95_ms": 0.892,
|
||||
"p99_ms": 3.176,
|
||||
"p999_ms": 198.757,
|
||||
"max_ms": 1882.64,
|
||||
"histogram": [
|
||||
9733,
|
||||
205,
|
||||
121,
|
||||
49,
|
||||
12,
|
||||
9,
|
||||
8,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
4,
|
||||
5,
|
||||
1,
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 33679,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2806.557,
|
||||
"low_1pct_fps": 400.567,
|
||||
"low_01pct_fps": 103.864,
|
||||
"p50_ms": 0.324,
|
||||
"p95_ms": 0.411,
|
||||
"p99_ms": 1.167,
|
||||
"p999_ms": 3.508,
|
||||
"max_ms": 83.572,
|
||||
"histogram": [
|
||||
33272,
|
||||
315,
|
||||
46,
|
||||
22,
|
||||
2,
|
||||
1,
|
||||
4,
|
||||
2,
|
||||
9,
|
||||
1,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 20188,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1682.292,
|
||||
"low_1pct_fps": 175.594,
|
||||
"low_01pct_fps": 51.28,
|
||||
"p50_ms": 0.374,
|
||||
"p95_ms": 1.37,
|
||||
"p99_ms": 2.821,
|
||||
"p999_ms": 8.947,
|
||||
"max_ms": 53.494,
|
||||
"histogram": [
|
||||
18109,
|
||||
1592,
|
||||
314,
|
||||
83,
|
||||
36,
|
||||
19,
|
||||
7,
|
||||
6,
|
||||
6,
|
||||
1,
|
||||
3,
|
||||
1,
|
||||
4,
|
||||
2,
|
||||
3,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-desktop"
|
||||
}
|
||||
@@ -0,0 +1,787 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-04T15:46:25Z",
|
||||
"app": {
|
||||
"name": "minecraft",
|
||||
"version": "26.3",
|
||||
"mods": [
|
||||
{
|
||||
"name": "fabric-loader",
|
||||
"version": "0.19.5"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"os": "linux",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 24,
|
||||
"os_version": "NixOS 26.05 (Yarara) (kernel 6.18.57)",
|
||||
"cpu": "13th Gen Intel(R) Core(TM) i7-13700KF",
|
||||
"ram_mb": 31904,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 4080",
|
||||
"vendor": "NVIDIA",
|
||||
"driver": "1.4.329 NVIDIA 595.71.05",
|
||||
"backend": "vulkan",
|
||||
"kind": ""
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 32,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 28.799,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 112742.179,
|
||||
"stdev": 27885.804,
|
||||
"min": 81110.692,
|
||||
"max": 133773.748,
|
||||
"ci95": 69277.769
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 235049.179,
|
||||
"stdev": 31461.958,
|
||||
"min": 203486.748,
|
||||
"max": 266409.692,
|
||||
"ci95": 78162.144
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 79828.05,
|
||||
"stdev": 109107.365,
|
||||
"min": 16685.972,
|
||||
"max": 205814.266,
|
||||
"ci95": 271059.597
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 525.667,
|
||||
"stdev": 390.505,
|
||||
"min": 101.073,
|
||||
"max": 869.433,
|
||||
"ci95": 970.146
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 44.209,
|
||||
"stdev": 39.491,
|
||||
"min": 1.644,
|
||||
"max": 79.658,
|
||||
"ci95": 98.109
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 10.635,
|
||||
"stdev": 9.759,
|
||||
"min": 0.686,
|
||||
"max": 20.192,
|
||||
"ci95": 24.244
|
||||
},
|
||||
"memory.village.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 34.333,
|
||||
"stdev": 36.95,
|
||||
"min": 13.0,
|
||||
"max": 77.0,
|
||||
"ci95": 91.797
|
||||
},
|
||||
"memory.village.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 3296.229,
|
||||
"stdev": 296.464,
|
||||
"min": 2956.234,
|
||||
"max": 3500.773,
|
||||
"ci95": 736.517
|
||||
},
|
||||
"tick.village.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.23,
|
||||
"stdev": 0.449,
|
||||
"min": 0.897,
|
||||
"max": 1.74,
|
||||
"ci95": 1.114
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 61172.527,
|
||||
"stdev": 17434.762,
|
||||
"min": 46779.931,
|
||||
"max": 80559.429,
|
||||
"ci95": 43313.846
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 599.408,
|
||||
"stdev": 492.96,
|
||||
"min": 64.173,
|
||||
"max": 1034.813,
|
||||
"ci95": 1224.68
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 61.923,
|
||||
"stdev": 56.534,
|
||||
"min": 0.737,
|
||||
"max": 112.221,
|
||||
"ci95": 140.449
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 15.771,
|
||||
"stdev": 14.11,
|
||||
"min": 0.098,
|
||||
"max": 27.463,
|
||||
"ci95": 35.054
|
||||
},
|
||||
"memory.forest.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 34.333,
|
||||
"stdev": 36.95,
|
||||
"min": 13.0,
|
||||
"max": 77.0,
|
||||
"ci95": 91.797
|
||||
},
|
||||
"memory.forest.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 3278.964,
|
||||
"stdev": 301.465,
|
||||
"min": 2947.268,
|
||||
"max": 3536.27,
|
||||
"ci95": 748.941
|
||||
},
|
||||
"tick.forest.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 5.908,
|
||||
"stdev": 8.813,
|
||||
"min": 0.693,
|
||||
"max": 16.084,
|
||||
"ci95": 21.895
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 61762.098,
|
||||
"stdev": 3569.17,
|
||||
"min": 57919.097,
|
||||
"max": 64972.973,
|
||||
"ci95": 8867.026
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1175.044,
|
||||
"stdev": 131.94,
|
||||
"min": 1093.801,
|
||||
"max": 1327.281,
|
||||
"ci95": 327.785
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 119.758,
|
||||
"stdev": 68.419,
|
||||
"min": 77.252,
|
||||
"max": 198.683,
|
||||
"ci95": 169.976
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 24.224,
|
||||
"stdev": 18.982,
|
||||
"min": 12.308,
|
||||
"max": 46.114,
|
||||
"ci95": 47.159
|
||||
},
|
||||
"memory.ocean.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 34.333,
|
||||
"stdev": 36.95,
|
||||
"min": 13.0,
|
||||
"max": 77.0,
|
||||
"ci95": 91.797
|
||||
},
|
||||
"memory.ocean.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 3275.349,
|
||||
"stdev": 317.864,
|
||||
"min": 2924.113,
|
||||
"max": 3543.23,
|
||||
"ci95": 789.681
|
||||
},
|
||||
"tick.ocean.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.654,
|
||||
"stdev": 0.066,
|
||||
"min": 0.579,
|
||||
"max": 0.699,
|
||||
"ci95": 0.163
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 113439.529,
|
||||
"stdev": 64746.162,
|
||||
"min": 55046.887,
|
||||
"max": 183068.386,
|
||||
"ci95": 160851.363
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 485.832,
|
||||
"stdev": 317.254,
|
||||
"min": 121.312,
|
||||
"max": 699.617,
|
||||
"ci95": 788.165
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 28.612,
|
||||
"stdev": 23.334,
|
||||
"min": 2.229,
|
||||
"max": 46.542,
|
||||
"ci95": 57.97
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 4.56,
|
||||
"stdev": 3.408,
|
||||
"min": 0.75,
|
||||
"max": 7.318,
|
||||
"ci95": 8.467
|
||||
},
|
||||
"memory.mountain.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 34.333,
|
||||
"stdev": 36.95,
|
||||
"min": 13.0,
|
||||
"max": 77.0,
|
||||
"ci95": 91.797
|
||||
},
|
||||
"memory.mountain.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 3248.708,
|
||||
"stdev": 480.962,
|
||||
"min": 2700.645,
|
||||
"max": 3600.484,
|
||||
"ci95": 1194.873
|
||||
},
|
||||
"tick.mountain.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.832,
|
||||
"stdev": 2.076,
|
||||
"min": 0.618,
|
||||
"max": 4.229,
|
||||
"ci95": 5.157
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 3371.958,
|
||||
"stdev": 280.347,
|
||||
"min": 3059.926,
|
||||
"max": 3602.613,
|
||||
"ci95": 696.476
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 3291.249,
|
||||
"stdev": 316.441,
|
||||
"min": 2935.975,
|
||||
"max": 3542.848,
|
||||
"ci95": 786.147
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 1248,
|
||||
"seconds": 12.348,
|
||||
"avg_fps": 101.073,
|
||||
"low_1pct_fps": 1.644,
|
||||
"low_01pct_fps": 0.686,
|
||||
"p50_ms": 1.225,
|
||||
"p95_ms": 11.161,
|
||||
"p99_ms": 226.597,
|
||||
"p999_ms": 1457.605,
|
||||
"max_ms": 1457.874,
|
||||
"histogram": [
|
||||
548,
|
||||
232,
|
||||
97,
|
||||
118,
|
||||
51,
|
||||
49,
|
||||
24,
|
||||
39,
|
||||
16,
|
||||
17,
|
||||
13,
|
||||
6,
|
||||
3,
|
||||
2,
|
||||
5,
|
||||
3,
|
||||
2,
|
||||
2,
|
||||
4,
|
||||
2,
|
||||
3,
|
||||
8,
|
||||
1,
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 10435,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 869.433,
|
||||
"low_1pct_fps": 79.658,
|
||||
"low_01pct_fps": 20.192,
|
||||
"p50_ms": 0.813,
|
||||
"p95_ms": 2.537,
|
||||
"p99_ms": 5.685,
|
||||
"p999_ms": 13.292,
|
||||
"max_ms": 351.251,
|
||||
"histogram": [
|
||||
7598,
|
||||
2071,
|
||||
393,
|
||||
171,
|
||||
55,
|
||||
53,
|
||||
22,
|
||||
24,
|
||||
20,
|
||||
10,
|
||||
9,
|
||||
2,
|
||||
2,
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 7278,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 606.495,
|
||||
"low_1pct_fps": 51.326,
|
||||
"low_01pct_fps": 11.026,
|
||||
"p50_ms": 1.105,
|
||||
"p95_ms": 3.854,
|
||||
"p99_ms": 7.067,
|
||||
"p999_ms": 24.484,
|
||||
"max_ms": 443.95,
|
||||
"histogram": [
|
||||
3206,
|
||||
2621,
|
||||
800,
|
||||
360,
|
||||
112,
|
||||
71,
|
||||
34,
|
||||
26,
|
||||
12,
|
||||
13,
|
||||
3,
|
||||
5,
|
||||
2,
|
||||
7,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 975,
|
||||
"seconds": 15.193,
|
||||
"avg_fps": 64.173,
|
||||
"low_1pct_fps": 0.737,
|
||||
"low_01pct_fps": 0.098,
|
||||
"p50_ms": 0.864,
|
||||
"p95_ms": 5.083,
|
||||
"p99_ms": 77.689,
|
||||
"p999_ms": 10225.237,
|
||||
"max_ms": 10225.237,
|
||||
"histogram": [
|
||||
680,
|
||||
99,
|
||||
104,
|
||||
12,
|
||||
30,
|
||||
13,
|
||||
8,
|
||||
4,
|
||||
2,
|
||||
3,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 8391,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 699.237,
|
||||
"low_1pct_fps": 72.811,
|
||||
"low_01pct_fps": 19.752,
|
||||
"p50_ms": 1.087,
|
||||
"p95_ms": 2.927,
|
||||
"p99_ms": 5.909,
|
||||
"p999_ms": 15.268,
|
||||
"max_ms": 252.557,
|
||||
"histogram": [
|
||||
3342,
|
||||
3998,
|
||||
656,
|
||||
231,
|
||||
56,
|
||||
26,
|
||||
15,
|
||||
19,
|
||||
12,
|
||||
12,
|
||||
11,
|
||||
6,
|
||||
1,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 12418,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1034.813,
|
||||
"low_1pct_fps": 112.221,
|
||||
"low_01pct_fps": 27.463,
|
||||
"p50_ms": 0.837,
|
||||
"p95_ms": 1.394,
|
||||
"p99_ms": 3.491,
|
||||
"p999_ms": 13.217,
|
||||
"max_ms": 245.59,
|
||||
"histogram": [
|
||||
11155,
|
||||
1004,
|
||||
100,
|
||||
64,
|
||||
29,
|
||||
16,
|
||||
16,
|
||||
10,
|
||||
2,
|
||||
6,
|
||||
5,
|
||||
3,
|
||||
4,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 13126,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1093.801,
|
||||
"low_1pct_fps": 77.252,
|
||||
"low_01pct_fps": 12.308,
|
||||
"p50_ms": 0.714,
|
||||
"p95_ms": 1.402,
|
||||
"p99_ms": 2.875,
|
||||
"p999_ms": 12.067,
|
||||
"max_ms": 238.158,
|
||||
"histogram": [
|
||||
11605,
|
||||
1207,
|
||||
191,
|
||||
54,
|
||||
17,
|
||||
11,
|
||||
7,
|
||||
12,
|
||||
4,
|
||||
4,
|
||||
2,
|
||||
3,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 15928,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1327.281,
|
||||
"low_1pct_fps": 198.683,
|
||||
"low_01pct_fps": 46.114,
|
||||
"p50_ms": 0.67,
|
||||
"p95_ms": 1.172,
|
||||
"p99_ms": 2.111,
|
||||
"p999_ms": 7.067,
|
||||
"max_ms": 193.743,
|
||||
"histogram": [
|
||||
14891,
|
||||
856,
|
||||
102,
|
||||
42,
|
||||
7,
|
||||
6,
|
||||
8,
|
||||
6,
|
||||
0,
|
||||
5,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 13249,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1104.051,
|
||||
"low_1pct_fps": 83.338,
|
||||
"low_01pct_fps": 14.249,
|
||||
"p50_ms": 0.711,
|
||||
"p95_ms": 1.403,
|
||||
"p99_ms": 3.046,
|
||||
"p999_ms": 10.625,
|
||||
"max_ms": 451.869,
|
||||
"histogram": [
|
||||
11824,
|
||||
1165,
|
||||
124,
|
||||
58,
|
||||
13,
|
||||
16,
|
||||
11,
|
||||
14,
|
||||
7,
|
||||
7,
|
||||
3,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 1469,
|
||||
"seconds": 12.109,
|
||||
"avg_fps": 121.312,
|
||||
"low_1pct_fps": 2.229,
|
||||
"low_01pct_fps": 0.75,
|
||||
"p50_ms": 2.12,
|
||||
"p95_ms": 13.173,
|
||||
"p99_ms": 125.189,
|
||||
"p999_ms": 727.045,
|
||||
"max_ms": 1939.723,
|
||||
"histogram": [
|
||||
196,
|
||||
440,
|
||||
438,
|
||||
136,
|
||||
50,
|
||||
36,
|
||||
24,
|
||||
25,
|
||||
22,
|
||||
17,
|
||||
17,
|
||||
13,
|
||||
6,
|
||||
11,
|
||||
8,
|
||||
4,
|
||||
1,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
3,
|
||||
7,
|
||||
2,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 8396,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 699.617,
|
||||
"low_1pct_fps": 46.542,
|
||||
"low_01pct_fps": 7.318,
|
||||
"p50_ms": 1.022,
|
||||
"p95_ms": 2.433,
|
||||
"p99_ms": 5.528,
|
||||
"p999_ms": 13.993,
|
||||
"max_ms": 305.464,
|
||||
"histogram": [
|
||||
3248,
|
||||
4509,
|
||||
361,
|
||||
113,
|
||||
52,
|
||||
37,
|
||||
21,
|
||||
26,
|
||||
14,
|
||||
4,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 7639,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 636.566,
|
||||
"low_1pct_fps": 37.064,
|
||||
"low_01pct_fps": 5.612,
|
||||
"p50_ms": 1.023,
|
||||
"p95_ms": 3.0,
|
||||
"p99_ms": 7.11,
|
||||
"p999_ms": 17.521,
|
||||
"max_ms": 268.877,
|
||||
"histogram": [
|
||||
3051,
|
||||
3794,
|
||||
413,
|
||||
158,
|
||||
66,
|
||||
43,
|
||||
35,
|
||||
21,
|
||||
31,
|
||||
12,
|
||||
3,
|
||||
4,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
2,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-desktop"
|
||||
}
|
||||
@@ -0,0 +1,771 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-04T12:54:29Z",
|
||||
"app": {
|
||||
"name": "minecraft",
|
||||
"version": "26.3",
|
||||
"mods": [
|
||||
{
|
||||
"name": "fabric-loader",
|
||||
"version": "0.19.5"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"os": "macos",
|
||||
"arch": "aarch64",
|
||||
"cpu_threads": 14,
|
||||
"os_version": "27.0.1",
|
||||
"cpu": "Apple M4 Pro",
|
||||
"ram_mb": 65536,
|
||||
"gpu": {
|
||||
"name": "Apple M4 Pro",
|
||||
"vendor": "Apple",
|
||||
"driver": "4.1 Metal - 91.7",
|
||||
"backend": "opengl",
|
||||
"kind": ""
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 12,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 17.628,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 12652.182,
|
||||
"stdev": 3231.342,
|
||||
"min": 9526.004,
|
||||
"max": 15979.309,
|
||||
"ci95": 8027.746
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 31329.516,
|
||||
"stdev": 10016.129,
|
||||
"min": 20777.004,
|
||||
"max": 40705.309,
|
||||
"ci95": 24883.451
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 12623.084,
|
||||
"stdev": 4958.724,
|
||||
"min": 7251.986,
|
||||
"max": 17026.894,
|
||||
"ci95": 12319.147
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 92.729,
|
||||
"stdev": 58.527,
|
||||
"min": 28.713,
|
||||
"max": 143.496,
|
||||
"ci95": 145.402
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 16.52,
|
||||
"stdev": 10.949,
|
||||
"min": 4.001,
|
||||
"max": 24.308,
|
||||
"ci95": 27.2
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 11.515,
|
||||
"stdev": 7.155,
|
||||
"min": 3.549,
|
||||
"max": 17.394,
|
||||
"ci95": 17.774
|
||||
},
|
||||
"memory.village.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1834.469,
|
||||
"stdev": 329.198,
|
||||
"min": 1455.688,
|
||||
"max": 2051.516,
|
||||
"ci95": 817.839
|
||||
},
|
||||
"tick.village.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.903,
|
||||
"stdev": 1.114,
|
||||
"min": 1.131,
|
||||
"max": 3.18,
|
||||
"ci95": 2.767
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 14590.694,
|
||||
"stdev": 3260.615,
|
||||
"min": 10942.613,
|
||||
"max": 17221.108,
|
||||
"ci95": 8100.47
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 106.817,
|
||||
"stdev": 104.558,
|
||||
"min": 43.857,
|
||||
"max": 227.513,
|
||||
"ci95": 259.759
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 23.0,
|
||||
"stdev": 26.394,
|
||||
"min": 7.338,
|
||||
"max": 53.473,
|
||||
"ci95": 65.57
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 14.841,
|
||||
"stdev": 15.773,
|
||||
"min": 5.157,
|
||||
"max": 33.042,
|
||||
"ci95": 39.186
|
||||
},
|
||||
"memory.forest.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1814.245,
|
||||
"stdev": 154.974,
|
||||
"min": 1635.453,
|
||||
"max": 1910.125,
|
||||
"ci95": 385.007
|
||||
},
|
||||
"tick.forest.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.452,
|
||||
"stdev": 0.542,
|
||||
"min": 0.826,
|
||||
"max": 1.777,
|
||||
"ci95": 1.346
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 13309.049,
|
||||
"stdev": 8088.157,
|
||||
"min": 6394.403,
|
||||
"max": 22203.194,
|
||||
"ci95": 20093.716
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 164.405,
|
||||
"stdev": 141.4,
|
||||
"min": 62.162,
|
||||
"max": 325.77,
|
||||
"ci95": 351.285
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 36.242,
|
||||
"stdev": 37.051,
|
||||
"min": 14.736,
|
||||
"max": 79.024,
|
||||
"ci95": 92.047
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 20.898,
|
||||
"stdev": 17.899,
|
||||
"min": 8.855,
|
||||
"max": 41.466,
|
||||
"ci95": 44.468
|
||||
},
|
||||
"memory.ocean.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1724.453,
|
||||
"stdev": 347.692,
|
||||
"min": 1323.5,
|
||||
"max": 1942.734,
|
||||
"ci95": 863.784
|
||||
},
|
||||
"tick.ocean.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.866,
|
||||
"stdev": 0.248,
|
||||
"min": 0.58,
|
||||
"max": 1.03,
|
||||
"ci95": 0.617
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 17989.43,
|
||||
"stdev": 3969.295,
|
||||
"min": 13444.423,
|
||||
"max": 20774.261,
|
||||
"ci95": 9861.07
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 162.084,
|
||||
"stdev": 160.057,
|
||||
"min": 68.44,
|
||||
"max": 346.896,
|
||||
"ci95": 397.635
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 33.74,
|
||||
"stdev": 36.146,
|
||||
"min": 11.158,
|
||||
"max": 75.429,
|
||||
"ci95": 89.798
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 17.194,
|
||||
"stdev": 14.232,
|
||||
"min": 7.903,
|
||||
"max": 33.579,
|
||||
"ci95": 35.357
|
||||
},
|
||||
"memory.mountain.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1977.602,
|
||||
"stdev": 108.627,
|
||||
"min": 1872.516,
|
||||
"max": 2089.453,
|
||||
"ci95": 269.866
|
||||
},
|
||||
"tick.mountain.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.772,
|
||||
"stdev": 0.332,
|
||||
"min": 0.463,
|
||||
"max": 1.123,
|
||||
"ci95": 0.825
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2135.313,
|
||||
"stdev": 54.617,
|
||||
"min": 2072.344,
|
||||
"max": 2169.828,
|
||||
"ci95": 135.686
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1836.44,
|
||||
"stdev": 223.694,
|
||||
"min": 1578.141,
|
||||
"max": 1965.781,
|
||||
"ci95": 555.732
|
||||
},
|
||||
"memory.footprint_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 3253.456,
|
||||
"stdev": 68.323,
|
||||
"min": 3178.409,
|
||||
"max": 3312.05,
|
||||
"ci95": 169.737
|
||||
},
|
||||
"memory.footprint_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 3144.363,
|
||||
"stdev": 69.868,
|
||||
"min": 3069.394,
|
||||
"max": 3207.66,
|
||||
"ci95": 173.576
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 1272,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 105.979,
|
||||
"low_1pct_fps": 21.25,
|
||||
"low_01pct_fps": 17.394,
|
||||
"p50_ms": 7.527,
|
||||
"p95_ms": 22.34,
|
||||
"p99_ms": 40.461,
|
||||
"p999_ms": 54.28,
|
||||
"max_ms": 60.7,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
56,
|
||||
161,
|
||||
170,
|
||||
180,
|
||||
167,
|
||||
184,
|
||||
118,
|
||||
71,
|
||||
61,
|
||||
26,
|
||||
25,
|
||||
28,
|
||||
12,
|
||||
10,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 1723,
|
||||
"seconds": 12.007,
|
||||
"avg_fps": 143.496,
|
||||
"low_1pct_fps": 24.308,
|
||||
"low_01pct_fps": 13.602,
|
||||
"p50_ms": 5.317,
|
||||
"p95_ms": 16.181,
|
||||
"p99_ms": 29.771,
|
||||
"p999_ms": 56.832,
|
||||
"max_ms": 90.209,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
63,
|
||||
413,
|
||||
271,
|
||||
273,
|
||||
185,
|
||||
148,
|
||||
112,
|
||||
89,
|
||||
47,
|
||||
43,
|
||||
28,
|
||||
22,
|
||||
18,
|
||||
4,
|
||||
5,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 346,
|
||||
"seconds": 12.05,
|
||||
"avg_fps": 28.713,
|
||||
"low_1pct_fps": 4.001,
|
||||
"low_01pct_fps": 3.549,
|
||||
"p50_ms": 23.684,
|
||||
"p95_ms": 110.057,
|
||||
"p99_ms": 225.236,
|
||||
"p999_ms": 281.776,
|
||||
"max_ms": 281.776,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
8,
|
||||
6,
|
||||
6,
|
||||
12,
|
||||
21,
|
||||
20,
|
||||
23,
|
||||
25,
|
||||
24,
|
||||
32,
|
||||
50,
|
||||
28,
|
||||
24,
|
||||
27,
|
||||
17,
|
||||
11,
|
||||
6,
|
||||
2,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 589,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 49.081,
|
||||
"low_1pct_fps": 8.19,
|
||||
"low_01pct_fps": 6.325,
|
||||
"p50_ms": 15.443,
|
||||
"p95_ms": 53.004,
|
||||
"p99_ms": 94.69,
|
||||
"p999_ms": 158.114,
|
||||
"max_ms": 158.114,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
7,
|
||||
20,
|
||||
30,
|
||||
46,
|
||||
53,
|
||||
48,
|
||||
56,
|
||||
62,
|
||||
61,
|
||||
66,
|
||||
63,
|
||||
18,
|
||||
22,
|
||||
20,
|
||||
10,
|
||||
4,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 2731,
|
||||
"seconds": 12.004,
|
||||
"avg_fps": 227.513,
|
||||
"low_1pct_fps": 53.473,
|
||||
"low_01pct_fps": 33.042,
|
||||
"p50_ms": 3.801,
|
||||
"p95_ms": 7.955,
|
||||
"p99_ms": 14.177,
|
||||
"p999_ms": 24.479,
|
||||
"max_ms": 38.218,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
526,
|
||||
1157,
|
||||
432,
|
||||
257,
|
||||
151,
|
||||
88,
|
||||
46,
|
||||
32,
|
||||
13,
|
||||
15,
|
||||
6,
|
||||
6,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 527,
|
||||
"seconds": 12.016,
|
||||
"avg_fps": 43.857,
|
||||
"low_1pct_fps": 7.338,
|
||||
"low_01pct_fps": 5.157,
|
||||
"p50_ms": 16.784,
|
||||
"p95_ms": 57.353,
|
||||
"p99_ms": 111.984,
|
||||
"p999_ms": 193.905,
|
||||
"max_ms": 193.905,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
9,
|
||||
16,
|
||||
29,
|
||||
40,
|
||||
61,
|
||||
50,
|
||||
54,
|
||||
52,
|
||||
62,
|
||||
57,
|
||||
35,
|
||||
28,
|
||||
11,
|
||||
13,
|
||||
5,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 746,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 62.162,
|
||||
"low_1pct_fps": 14.965,
|
||||
"low_01pct_fps": 12.372,
|
||||
"p50_ms": 13.76,
|
||||
"p95_ms": 35.144,
|
||||
"p99_ms": 57.63,
|
||||
"p999_ms": 80.827,
|
||||
"max_ms": 80.827,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
13,
|
||||
12,
|
||||
41,
|
||||
37,
|
||||
53,
|
||||
67,
|
||||
82,
|
||||
80,
|
||||
99,
|
||||
82,
|
||||
76,
|
||||
58,
|
||||
17,
|
||||
11,
|
||||
12,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 3910,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 325.77,
|
||||
"low_1pct_fps": 79.024,
|
||||
"low_01pct_fps": 41.466,
|
||||
"p50_ms": 2.685,
|
||||
"p95_ms": 5.125,
|
||||
"p99_ms": 8.342,
|
||||
"p999_ms": 20.782,
|
||||
"max_ms": 30.571,
|
||||
"histogram": [
|
||||
0,
|
||||
78,
|
||||
2526,
|
||||
892,
|
||||
203,
|
||||
95,
|
||||
49,
|
||||
27,
|
||||
13,
|
||||
11,
|
||||
7,
|
||||
3,
|
||||
2,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 1268,
|
||||
"seconds": 12.044,
|
||||
"avg_fps": 105.282,
|
||||
"low_1pct_fps": 14.736,
|
||||
"low_01pct_fps": 8.855,
|
||||
"p50_ms": 6.542,
|
||||
"p95_ms": 25.245,
|
||||
"p99_ms": 50.465,
|
||||
"p999_ms": 79.065,
|
||||
"max_ms": 146.809,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
171,
|
||||
190,
|
||||
169,
|
||||
167,
|
||||
133,
|
||||
105,
|
||||
93,
|
||||
53,
|
||||
43,
|
||||
35,
|
||||
41,
|
||||
26,
|
||||
13,
|
||||
11,
|
||||
10,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 822,
|
||||
"seconds": 12.011,
|
||||
"avg_fps": 68.44,
|
||||
"low_1pct_fps": 14.632,
|
||||
"low_01pct_fps": 10.101,
|
||||
"p50_ms": 11.396,
|
||||
"p95_ms": 35.387,
|
||||
"p99_ms": 55.24,
|
||||
"p999_ms": 99.005,
|
||||
"max_ms": 99.005,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
10,
|
||||
42,
|
||||
62,
|
||||
55,
|
||||
86,
|
||||
89,
|
||||
97,
|
||||
64,
|
||||
73,
|
||||
74,
|
||||
72,
|
||||
48,
|
||||
21,
|
||||
17,
|
||||
8,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 4165,
|
||||
"seconds": 12.006,
|
||||
"avg_fps": 346.896,
|
||||
"low_1pct_fps": 75.429,
|
||||
"low_01pct_fps": 33.579,
|
||||
"p50_ms": 2.541,
|
||||
"p95_ms": 4.838,
|
||||
"p99_ms": 7.998,
|
||||
"p999_ms": 20.209,
|
||||
"max_ms": 53.577,
|
||||
"histogram": [
|
||||
0,
|
||||
640,
|
||||
2307,
|
||||
855,
|
||||
178,
|
||||
93,
|
||||
34,
|
||||
22,
|
||||
15,
|
||||
3,
|
||||
3,
|
||||
8,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 851,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 70.916,
|
||||
"low_1pct_fps": 11.158,
|
||||
"low_01pct_fps": 7.903,
|
||||
"p50_ms": 10.128,
|
||||
"p95_ms": 36.498,
|
||||
"p99_ms": 68.434,
|
||||
"p999_ms": 126.541,
|
||||
"max_ms": 126.541,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
38,
|
||||
61,
|
||||
87,
|
||||
76,
|
||||
69,
|
||||
84,
|
||||
100,
|
||||
63,
|
||||
53,
|
||||
68,
|
||||
53,
|
||||
48,
|
||||
15,
|
||||
9,
|
||||
16,
|
||||
9,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-macmini"
|
||||
}
|
||||
@@ -0,0 +1,771 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-04T13:08:37Z",
|
||||
"app": {
|
||||
"name": "minecraft",
|
||||
"version": "26.3",
|
||||
"mods": [
|
||||
{
|
||||
"name": "fabric-loader",
|
||||
"version": "0.19.5"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"os": "macos",
|
||||
"arch": "aarch64",
|
||||
"cpu_threads": 14,
|
||||
"os_version": "27.0.1",
|
||||
"cpu": "Apple M4 Pro",
|
||||
"ram_mb": 65536,
|
||||
"gpu": {
|
||||
"name": "Apple M4 Pro",
|
||||
"vendor": "Apple",
|
||||
"driver": "4.1 Metal - 91.7",
|
||||
"backend": "opengl",
|
||||
"kind": ""
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 32,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 18.159,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 8607.88,
|
||||
"stdev": 949.596,
|
||||
"min": 8010.323,
|
||||
"max": 9702.854,
|
||||
"ci95": 2359.119
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 44182.88,
|
||||
"stdev": 4031.933,
|
||||
"min": 40172.323,
|
||||
"max": 48235.854,
|
||||
"ci95": 10016.685
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 17227.171,
|
||||
"stdev": 5351.234,
|
||||
"min": 13892.279,
|
||||
"max": 23399.566,
|
||||
"ci95": 13294.274
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 76.237,
|
||||
"stdev": 45.661,
|
||||
"min": 28.922,
|
||||
"max": 120.041,
|
||||
"ci95": 113.437
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 17.319,
|
||||
"stdev": 12.99,
|
||||
"min": 5.593,
|
||||
"max": 31.283,
|
||||
"ci95": 32.272
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 12.642,
|
||||
"stdev": 9.463,
|
||||
"min": 4.308,
|
||||
"max": 22.93,
|
||||
"ci95": 23.51
|
||||
},
|
||||
"memory.village.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 2674.987,
|
||||
"stdev": 30.71,
|
||||
"min": 2641.352,
|
||||
"max": 2701.531,
|
||||
"ci95": 76.294
|
||||
},
|
||||
"tick.village.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.341,
|
||||
"stdev": 0.407,
|
||||
"min": 0.924,
|
||||
"max": 1.737,
|
||||
"ci95": 1.012
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 34112.389,
|
||||
"stdev": 19305.487,
|
||||
"min": 22633.094,
|
||||
"max": 56401.09,
|
||||
"ci95": 47961.36
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 72.848,
|
||||
"stdev": 51.859,
|
||||
"min": 18.307,
|
||||
"max": 121.528,
|
||||
"ci95": 128.837
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 22.096,
|
||||
"stdev": 20.299,
|
||||
"min": 3.973,
|
||||
"max": 44.03,
|
||||
"ci95": 50.429
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 16.093,
|
||||
"stdev": 14.608,
|
||||
"min": 3.688,
|
||||
"max": 32.194,
|
||||
"ci95": 36.291
|
||||
},
|
||||
"memory.forest.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 2519.729,
|
||||
"stdev": 82.097,
|
||||
"min": 2426.297,
|
||||
"max": 2580.328,
|
||||
"ci95": 203.957
|
||||
},
|
||||
"tick.forest.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.115,
|
||||
"stdev": 0.411,
|
||||
"min": 0.726,
|
||||
"max": 1.545,
|
||||
"ci95": 1.02
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 19066.516,
|
||||
"stdev": 9372.33,
|
||||
"min": 13163.781,
|
||||
"max": 29873.383,
|
||||
"ci95": 23284.039
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 69.735,
|
||||
"stdev": 46.656,
|
||||
"min": 16.524,
|
||||
"max": 103.635,
|
||||
"ci95": 115.909
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 17.636,
|
||||
"stdev": 13.616,
|
||||
"min": 3.179,
|
||||
"max": 30.217,
|
||||
"ci95": 33.827
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 12.115,
|
||||
"stdev": 10.798,
|
||||
"min": 2.584,
|
||||
"max": 23.842,
|
||||
"ci95": 26.825
|
||||
},
|
||||
"memory.ocean.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 2557.143,
|
||||
"stdev": 84.375,
|
||||
"min": 2463.781,
|
||||
"max": 2627.945,
|
||||
"ci95": 209.616
|
||||
},
|
||||
"tick.ocean.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.056,
|
||||
"stdev": 0.233,
|
||||
"min": 0.815,
|
||||
"max": 1.281,
|
||||
"ci95": 0.58
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 62214.712,
|
||||
"stdev": 4884.13,
|
||||
"min": 58937.045,
|
||||
"max": 67828.14,
|
||||
"ci95": 12133.83
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 51.809,
|
||||
"stdev": 7.118,
|
||||
"min": 45.235,
|
||||
"max": 59.369,
|
||||
"ci95": 17.685
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 6.336,
|
||||
"stdev": 1.097,
|
||||
"min": 5.545,
|
||||
"max": 7.588,
|
||||
"ci95": 2.725
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 3.458,
|
||||
"stdev": 0.756,
|
||||
"min": 2.827,
|
||||
"max": 4.296,
|
||||
"ci95": 1.878
|
||||
},
|
||||
"memory.mountain.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 2604.227,
|
||||
"stdev": 15.043,
|
||||
"min": 2588.234,
|
||||
"max": 2618.094,
|
||||
"ci95": 37.371
|
||||
},
|
||||
"tick.mountain.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.957,
|
||||
"stdev": 0.03,
|
||||
"min": 0.926,
|
||||
"max": 0.985,
|
||||
"ci95": 0.074
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2731.016,
|
||||
"stdev": 56.767,
|
||||
"min": 2688.516,
|
||||
"max": 2795.484,
|
||||
"ci95": 141.03
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 2577.339,
|
||||
"stdev": 77.295,
|
||||
"min": 2488.203,
|
||||
"max": 2625.875,
|
||||
"ci95": 192.028
|
||||
},
|
||||
"memory.footprint_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 4612.571,
|
||||
"stdev": 20.245,
|
||||
"min": 4595.363,
|
||||
"max": 4634.879,
|
||||
"ci95": 50.296
|
||||
},
|
||||
"memory.footprint_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 4271.925,
|
||||
"stdev": 26.308,
|
||||
"min": 4246.207,
|
||||
"max": 4298.785,
|
||||
"ci95": 65.357
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 349,
|
||||
"seconds": 12.067,
|
||||
"avg_fps": 28.922,
|
||||
"low_1pct_fps": 5.593,
|
||||
"low_01pct_fps": 4.308,
|
||||
"p50_ms": 24.684,
|
||||
"p95_ms": 92.764,
|
||||
"p99_ms": 150.338,
|
||||
"p999_ms": 232.136,
|
||||
"max_ms": 232.136,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
3,
|
||||
13,
|
||||
17,
|
||||
26,
|
||||
31,
|
||||
40,
|
||||
46,
|
||||
50,
|
||||
32,
|
||||
25,
|
||||
20,
|
||||
30,
|
||||
10,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 1441,
|
||||
"seconds": 12.004,
|
||||
"avg_fps": 120.041,
|
||||
"low_1pct_fps": 31.283,
|
||||
"low_01pct_fps": 22.93,
|
||||
"p50_ms": 7.267,
|
||||
"p95_ms": 13.622,
|
||||
"p99_ms": 23.449,
|
||||
"p999_ms": 42.564,
|
||||
"max_ms": 44.657,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
64,
|
||||
543,
|
||||
397,
|
||||
218,
|
||||
100,
|
||||
54,
|
||||
24,
|
||||
20,
|
||||
7,
|
||||
7,
|
||||
5,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 958,
|
||||
"seconds": 12.013,
|
||||
"avg_fps": 79.749,
|
||||
"low_1pct_fps": 15.082,
|
||||
"low_01pct_fps": 10.689,
|
||||
"p50_ms": 9.138,
|
||||
"p95_ms": 28.848,
|
||||
"p99_ms": 52.247,
|
||||
"p999_ms": 93.55,
|
||||
"max_ms": 93.55,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
19,
|
||||
144,
|
||||
232,
|
||||
148,
|
||||
111,
|
||||
64,
|
||||
56,
|
||||
59,
|
||||
57,
|
||||
32,
|
||||
17,
|
||||
9,
|
||||
6,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 220,
|
||||
"seconds": 12.017,
|
||||
"avg_fps": 18.307,
|
||||
"low_1pct_fps": 3.973,
|
||||
"low_01pct_fps": 3.688,
|
||||
"p50_ms": 40.635,
|
||||
"p95_ms": 157.363,
|
||||
"p99_ms": 235.253,
|
||||
"p999_ms": 271.153,
|
||||
"max_ms": 271.153,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
3,
|
||||
6,
|
||||
14,
|
||||
17,
|
||||
25,
|
||||
25,
|
||||
17,
|
||||
27,
|
||||
33,
|
||||
24,
|
||||
16,
|
||||
11,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 1459,
|
||||
"seconds": 12.005,
|
||||
"avg_fps": 121.528,
|
||||
"low_1pct_fps": 44.03,
|
||||
"low_01pct_fps": 32.194,
|
||||
"p50_ms": 7.381,
|
||||
"p95_ms": 12.386,
|
||||
"p99_ms": 18.354,
|
||||
"p999_ms": 29.428,
|
||||
"max_ms": 32.695,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
357,
|
||||
642,
|
||||
300,
|
||||
80,
|
||||
28,
|
||||
28,
|
||||
14,
|
||||
7,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 945,
|
||||
"seconds": 12.006,
|
||||
"avg_fps": 78.709,
|
||||
"low_1pct_fps": 18.284,
|
||||
"low_01pct_fps": 12.397,
|
||||
"p50_ms": 9.554,
|
||||
"p95_ms": 27.657,
|
||||
"p99_ms": 43.253,
|
||||
"p999_ms": 80.663,
|
||||
"max_ms": 80.663,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
18,
|
||||
304,
|
||||
191,
|
||||
119,
|
||||
67,
|
||||
59,
|
||||
53,
|
||||
70,
|
||||
34,
|
||||
15,
|
||||
10,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 201,
|
||||
"seconds": 12.164,
|
||||
"avg_fps": 16.524,
|
||||
"low_1pct_fps": 3.179,
|
||||
"low_01pct_fps": 2.584,
|
||||
"p50_ms": 44.933,
|
||||
"p95_ms": 155.019,
|
||||
"p99_ms": 263.919,
|
||||
"p999_ms": 387.044,
|
||||
"max_ms": 387.044,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
4,
|
||||
7,
|
||||
20,
|
||||
8,
|
||||
29,
|
||||
18,
|
||||
25,
|
||||
29,
|
||||
29,
|
||||
17,
|
||||
8,
|
||||
4,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 1244,
|
||||
"seconds": 12.004,
|
||||
"avg_fps": 103.635,
|
||||
"low_1pct_fps": 30.217,
|
||||
"low_01pct_fps": 23.842,
|
||||
"p50_ms": 8.349,
|
||||
"p95_ms": 17.445,
|
||||
"p99_ms": 28.276,
|
||||
"p999_ms": 38.037,
|
||||
"max_ms": 45.85,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
162,
|
||||
453,
|
||||
312,
|
||||
129,
|
||||
71,
|
||||
47,
|
||||
32,
|
||||
18,
|
||||
16,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 1069,
|
||||
"seconds": 12.005,
|
||||
"avg_fps": 89.047,
|
||||
"low_1pct_fps": 19.512,
|
||||
"low_01pct_fps": 9.92,
|
||||
"p50_ms": 9.541,
|
||||
"p95_ms": 20.819,
|
||||
"p99_ms": 31.409,
|
||||
"p999_ms": 99.668,
|
||||
"max_ms": 101.942,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
64,
|
||||
266,
|
||||
283,
|
||||
160,
|
||||
112,
|
||||
74,
|
||||
51,
|
||||
33,
|
||||
17,
|
||||
4,
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 551,
|
||||
"seconds": 12.181,
|
||||
"avg_fps": 45.235,
|
||||
"low_1pct_fps": 5.545,
|
||||
"low_01pct_fps": 3.252,
|
||||
"p50_ms": 14.921,
|
||||
"p95_ms": 50.78,
|
||||
"p99_ms": 105.154,
|
||||
"p999_ms": 307.476,
|
||||
"max_ms": 307.476,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
42,
|
||||
137,
|
||||
72,
|
||||
58,
|
||||
50,
|
||||
47,
|
||||
57,
|
||||
39,
|
||||
20,
|
||||
15,
|
||||
8,
|
||||
3,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 718,
|
||||
"seconds": 12.094,
|
||||
"avg_fps": 59.369,
|
||||
"low_1pct_fps": 7.588,
|
||||
"low_01pct_fps": 4.296,
|
||||
"p50_ms": 12.178,
|
||||
"p95_ms": 38.273,
|
||||
"p99_ms": 65.694,
|
||||
"p999_ms": 232.789,
|
||||
"max_ms": 232.789,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
140,
|
||||
202,
|
||||
115,
|
||||
72,
|
||||
48,
|
||||
47,
|
||||
43,
|
||||
19,
|
||||
19,
|
||||
6,
|
||||
4,
|
||||
0,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 610,
|
||||
"seconds": 12.003,
|
||||
"avg_fps": 50.822,
|
||||
"low_1pct_fps": 5.875,
|
||||
"low_01pct_fps": 2.827,
|
||||
"p50_ms": 13.894,
|
||||
"p95_ms": 44.208,
|
||||
"p99_ms": 75.023,
|
||||
"p999_ms": 353.686,
|
||||
"max_ms": 353.686,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
67,
|
||||
115,
|
||||
125,
|
||||
88,
|
||||
55,
|
||||
54,
|
||||
43,
|
||||
25,
|
||||
13,
|
||||
15,
|
||||
7,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-macmini"
|
||||
}
|
||||
@@ -489,15 +489,36 @@ pub fn parse_duration(text: &str) -> Option<i64> {
|
||||
(count > 0).then(|| count.checked_mul(unit)).flatten()
|
||||
}
|
||||
|
||||
/// `millis` for players, rounded up: minutes up to an hour, hours up to two days, then days.
|
||||
pub fn duration_text(millis: i64) -> String {
|
||||
let up = |unit: i64| (millis + unit - 1) / unit;
|
||||
let (count, unit) = if millis <= 60 * MINUTE {
|
||||
(up(MINUTE), "minute")
|
||||
/// The unit used when presenting a moderation duration to a player.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DurationUnit {
|
||||
Minute,
|
||||
Hour,
|
||||
Day,
|
||||
}
|
||||
|
||||
/// A duration's rounded count and unit, shared by logs and translated replies.
|
||||
pub fn duration_parts(millis: i64) -> (i64, DurationUnit) {
|
||||
let (unit, kind) = if millis <= 60 * MINUTE {
|
||||
(MINUTE, DurationUnit::Minute)
|
||||
} else if millis <= 48 * 60 * MINUTE {
|
||||
(up(60 * MINUTE), "hour")
|
||||
(60 * MINUTE, DurationUnit::Hour)
|
||||
} else {
|
||||
(up(24 * 60 * MINUTE), "day")
|
||||
(24 * 60 * MINUTE, DurationUnit::Day)
|
||||
};
|
||||
(
|
||||
millis.div_euclid(unit) + i64::from(millis.rem_euclid(unit) > 0),
|
||||
kind,
|
||||
)
|
||||
}
|
||||
|
||||
/// `millis` for logs, rounded up: minutes up to an hour, hours up to two days, then days.
|
||||
pub fn duration_text(millis: i64) -> String {
|
||||
let (count, unit) = duration_parts(millis);
|
||||
let unit = match unit {
|
||||
DurationUnit::Minute => "minute",
|
||||
DurationUnit::Hour => "hour",
|
||||
DurationUnit::Day => "day",
|
||||
};
|
||||
format!("{count} {unit}{}", if count == 1 { "" } else { "s" })
|
||||
}
|
||||
|
||||
@@ -327,3 +327,19 @@ fn an_allow_line_without_letters_allows_nothing() {
|
||||
assert!(f.matches("nigger"), "{line}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_parts_preserve_rounding_without_overflow() {
|
||||
for (millis, expected) in [
|
||||
(1, (1, DurationUnit::Minute)),
|
||||
(60_000, (1, DurationUnit::Minute)),
|
||||
(61_000, (2, DurationUnit::Minute)),
|
||||
(3_600_000, (60, DurationUnit::Minute)),
|
||||
(3_600_001, (2, DurationUnit::Hour)),
|
||||
(172_800_000, (48, DurationUnit::Hour)),
|
||||
(172_800_001, (3, DurationUnit::Day)),
|
||||
(i64::MAX, (106_751_991_168, DurationUnit::Day)),
|
||||
] {
|
||||
assert_eq!(duration_parts(millis), expected);
|
||||
}
|
||||
}
|
||||
@@ -79,28 +79,65 @@ fn elements(tag: &Tag) -> Option<Vec<Tag>> {
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
fn number(t: &Tag) -> Option<i64> {
|
||||
match t {
|
||||
Tag::Byte(v) => Some(i64::from(*v)),
|
||||
Tag::Short(v) => Some(i64::from(*v)),
|
||||
Tag::Int(v) => Some(i64::from(*v)),
|
||||
Tag::Long(v) => Some(*v),
|
||||
_ => None,
|
||||
/// `DoubleTag.intValue` / `FloatTag.intValue` floor before the i32 conversion;
|
||||
/// integral tags use their intValue narrowing. Byte arrays then keep the low byte.
|
||||
fn int_value(t: &Tag) -> Option<i32> {
|
||||
Some(match t {
|
||||
Tag::Byte(v) => i32::from(*v),
|
||||
Tag::Short(v) => i32::from(*v),
|
||||
Tag::Int(v) => *v,
|
||||
Tag::Long(v) => *v as i32,
|
||||
Tag::Float(v) => f32::from_bits(*v).floor() as i32,
|
||||
Tag::Double(v) => f64::from_bits(*v).floor() as i32,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
/// `FloatTag.longValue` truncates; `DoubleTag.longValue` floors before narrowing.
|
||||
fn long_value(t: &Tag) -> Option<i64> {
|
||||
Some(match t {
|
||||
Tag::Byte(v) => i64::from(*v),
|
||||
Tag::Short(v) => i64::from(*v),
|
||||
Tag::Int(v) => i64::from(*v),
|
||||
Tag::Long(v) => *v,
|
||||
// FloatTag.longValue truncates; DoubleTag.longValue floors first.
|
||||
Tag::Float(v) => f32::from_bits(*v) as i64,
|
||||
Tag::Double(v) => f64::from_bits(*v).floor() as i64,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
enum ElementKind {
|
||||
List,
|
||||
NumericArray,
|
||||
}
|
||||
impl ElementKind {
|
||||
// Admission only: arrays require NumericTag; lists accept every tag type.
|
||||
fn accepts(self, value: &Tag) -> bool {
|
||||
matches!(self, Self::List)
|
||||
|| matches!(
|
||||
value,
|
||||
Tag::Byte(_)
|
||||
| Tag::Short(_)
|
||||
| Tag::Int(_)
|
||||
| Tag::Long(_)
|
||||
| Tag::Float(_)
|
||||
| Tag::Double(_)
|
||||
)
|
||||
}
|
||||
}
|
||||
/// Run `f` over the elements of a list/array, writing array results back.
|
||||
/// Rust view adapter: run `f` over the elements, then write arrays using their numeric accessor.
|
||||
/// None when `tag` is not list-like.
|
||||
fn with_elements<R>(tag: &mut Tag, f: impl FnOnce(&mut Vec<Tag>) -> R) -> Option<R> {
|
||||
fn with_elements<R>(tag: &mut Tag, f: impl FnOnce(&mut Vec<Tag>, ElementKind) -> R) -> Option<R> {
|
||||
match tag {
|
||||
Tag::List(l) => Some(f(l)),
|
||||
Tag::List(l) => Some(f(l, ElementKind::List)),
|
||||
Tag::ByteArray(_) | Tag::IntArray(_) | Tag::LongArray(_) => {
|
||||
let mut v = elements(tag)?;
|
||||
let r = f(&mut v);
|
||||
let nums: Vec<i64> = v.iter().filter_map(number).collect();
|
||||
let r = f(&mut v, ElementKind::NumericArray);
|
||||
match tag {
|
||||
Tag::ByteArray(a) => *a = nums.iter().map(|n| *n as u8).collect(),
|
||||
Tag::IntArray(a) => *a = nums.iter().map(|n| *n as i32).collect(),
|
||||
Tag::LongArray(a) => *a = nums,
|
||||
// Float/double byteValue uses intValue's floor, then narrows.
|
||||
Tag::ByteArray(a) => *a = v.iter().filter_map(int_value).map(|n| n as u8).collect(),
|
||||
Tag::IntArray(a) => *a = v.iter().filter_map(int_value).collect(),
|
||||
Tag::LongArray(a) => *a = v.iter().filter_map(long_value).collect(),
|
||||
_ => {}
|
||||
}
|
||||
Some(r)
|
||||
@@ -349,7 +386,7 @@ impl NbtPath {
|
||||
});
|
||||
removed
|
||||
}
|
||||
/// `insert` (also append and prepend): into every list the path reaches.
|
||||
/// `NbtPathArgument.NbtPath.insert`: insert accepted tags into every reached collection.
|
||||
pub fn insert(
|
||||
&self,
|
||||
index: i32,
|
||||
@@ -372,19 +409,23 @@ impl NbtPath {
|
||||
return;
|
||||
}
|
||||
let shown = crate::snbt::to_snbt(target);
|
||||
let result = with_elements(target, |list| {
|
||||
let result = with_elements(target, |list, kind| {
|
||||
let start = if index < 0 {
|
||||
list.len() as i64 + i64::from(index) + 1
|
||||
} else {
|
||||
i64::from(index)
|
||||
};
|
||||
let mut any = false;
|
||||
for (n, v) in values.iter().enumerate() {
|
||||
let at = start + n as i64;
|
||||
let mut at = start;
|
||||
for v in values {
|
||||
if !kind.accepts(v) {
|
||||
continue;
|
||||
}
|
||||
if at < 0 || at as usize > list.len() {
|
||||
return Err(CollectionError::InvalidIndex(at as i32));
|
||||
}
|
||||
list.insert(at as usize, v.clone());
|
||||
at += 1;
|
||||
any = true;
|
||||
}
|
||||
Ok(any)
|
||||
@@ -428,7 +469,8 @@ fn preferred_parent(node: &Node) -> Tag {
|
||||
_ => Tag::Compound(Compound::new()),
|
||||
}
|
||||
}
|
||||
/// `getOrCreateTag` (or `getTag` without `create`): the children of `parent`.
|
||||
/// Children of `parent`; the array creation arm follows
|
||||
/// `NbtPathArgument.AllElementsNode.getOrCreateTag` admission.
|
||||
fn step_mut(
|
||||
node: &Node,
|
||||
parent: &mut Tag,
|
||||
@@ -468,11 +510,14 @@ fn step_mut(
|
||||
}
|
||||
Node::AllElements => {
|
||||
let make = create;
|
||||
let _ = with_elements(parent, |list| {
|
||||
let _ = with_elements(parent, |list, kind| {
|
||||
if list.is_empty() {
|
||||
if let Some(make) = make {
|
||||
list.push(make());
|
||||
out(&mut list[0]);
|
||||
let value = make();
|
||||
if kind.accepts(&value) {
|
||||
list.push(value);
|
||||
out(&mut list[0]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for t in list.iter_mut() {
|
||||
@@ -482,7 +527,7 @@ fn step_mut(
|
||||
});
|
||||
}
|
||||
Node::Index(i) => {
|
||||
let _ = with_elements(parent, |list| {
|
||||
let _ = with_elements(parent, |list, _kind| {
|
||||
let n = list.len() as i64;
|
||||
let idx = if *i < 0 {
|
||||
n + i64::from(*i)
|
||||
@@ -518,7 +563,8 @@ fn step_mut(
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Node.setTag`: replace the child(ren) of `parent` with `value`.
|
||||
/// Element setters follow `NbtPathArgument.AllElementsNode.setTag` and
|
||||
/// `NbtPathArgument.IndexedElementNode.setTag`; object nodes replace their children.
|
||||
fn set_tag(node: &Node, parent: &mut Tag, value: &Tag) -> usize {
|
||||
match node {
|
||||
Node::Child(name) => {
|
||||
@@ -538,29 +584,36 @@ fn set_tag(node: &Node, parent: &mut Tag, value: &Tag) -> usize {
|
||||
}
|
||||
0
|
||||
}
|
||||
Node::AllElements => with_elements(parent, |list| {
|
||||
if list.is_empty() {
|
||||
list.push(value.clone());
|
||||
Node::AllElements => with_elements(parent, |list, kind| {
|
||||
let len = list.len();
|
||||
if len == 0 {
|
||||
if kind.accepts(value) {
|
||||
list.push(value.clone());
|
||||
}
|
||||
// AllElementsNode returns 1 here even when addTag rejects.
|
||||
return 1;
|
||||
}
|
||||
// Compare the original tags, before numeric conversion.
|
||||
let changed = list.iter().filter(|t| *t != value).count();
|
||||
if changed == 0 {
|
||||
return 0;
|
||||
}
|
||||
for t in list.iter_mut() {
|
||||
*t = value.clone();
|
||||
list.clear();
|
||||
if !kind.accepts(value) {
|
||||
return 0;
|
||||
}
|
||||
list.resize(len, value.clone());
|
||||
changed
|
||||
})
|
||||
.unwrap_or(0),
|
||||
Node::Index(i) => with_elements(parent, |list| {
|
||||
Node::Index(i) => with_elements(parent, |list, kind| {
|
||||
let n = list.len() as i64;
|
||||
let idx = if *i < 0 {
|
||||
n + i64::from(*i)
|
||||
} else {
|
||||
i64::from(*i)
|
||||
};
|
||||
if (0..n).contains(&idx) && list[idx as usize] != *value {
|
||||
if (0..n).contains(&idx) && list[idx as usize] != *value && kind.accepts(value) {
|
||||
list[idx as usize] = value.clone();
|
||||
1
|
||||
} else {
|
||||
@@ -586,6 +639,7 @@ fn set_tag(node: &Node, parent: &mut Tag, value: &Tag) -> usize {
|
||||
Node::MatchRoot(_) => 0,
|
||||
}
|
||||
}
|
||||
/// Removal arms, including `NbtPathArgument.AllElementsNode.removeTag`.
|
||||
fn remove_tag(node: &Node, parent: &mut Tag) -> usize {
|
||||
match node {
|
||||
Node::Child(name) => match parent {
|
||||
@@ -603,13 +657,13 @@ fn remove_tag(node: &Node, parent: &mut Tag) -> usize {
|
||||
}
|
||||
_ => 0,
|
||||
},
|
||||
Node::AllElements => with_elements(parent, |l| {
|
||||
Node::AllElements => with_elements(parent, |l, _kind| {
|
||||
let n = l.len();
|
||||
l.clear();
|
||||
n
|
||||
})
|
||||
.unwrap_or(0),
|
||||
Node::Index(i) => with_elements(parent, |l| {
|
||||
Node::Index(i) => with_elements(parent, |l, _kind| {
|
||||
let n = l.len() as i64;
|
||||
let idx = if *i < 0 {
|
||||
n + i64::from(*i)
|
||||
@@ -887,6 +941,100 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_array_edits_use_the_destination_numeric_accessor() {
|
||||
for (initial, value, inserted, indexed, all) in [
|
||||
(
|
||||
"{v:[B;1b,2b]}",
|
||||
"-3.5f",
|
||||
"{v:[B;1b,-4b,2b]}",
|
||||
"{v:[B;-4b,2b]}",
|
||||
"{v:[B;-4b,-4b]}",
|
||||
),
|
||||
(
|
||||
"{v:[B;1b,2b]}",
|
||||
"-3.5d",
|
||||
"{v:[B;1b,-4b,2b]}",
|
||||
"{v:[B;-4b,2b]}",
|
||||
"{v:[B;-4b,-4b]}",
|
||||
),
|
||||
(
|
||||
"{v:[I;1,2]}",
|
||||
"-3.5f",
|
||||
"{v:[I;1,-4,2]}",
|
||||
"{v:[I;-4,2]}",
|
||||
"{v:[I;-4,-4]}",
|
||||
),
|
||||
(
|
||||
"{v:[I;1,2]}",
|
||||
"-3.5d",
|
||||
"{v:[I;1,-4,2]}",
|
||||
"{v:[I;-4,2]}",
|
||||
"{v:[I;-4,-4]}",
|
||||
),
|
||||
(
|
||||
"{v:[L;1L,2L]}",
|
||||
"-3.5f",
|
||||
"{v:[L;1L,-3L,2L]}",
|
||||
"{v:[L;-3L,2L]}",
|
||||
"{v:[L;-3L,-3L]}",
|
||||
),
|
||||
(
|
||||
"{v:[L;1L,2L]}",
|
||||
"-3.5d",
|
||||
"{v:[L;1L,-4L,2L]}",
|
||||
"{v:[L;-4L,2L]}",
|
||||
"{v:[L;-4L,-4L]}",
|
||||
),
|
||||
] {
|
||||
let value = tag(value);
|
||||
let mut root = tag(initial);
|
||||
assert_eq!(
|
||||
path("v")
|
||||
.insert(1, &mut root, std::slice::from_ref(&value))
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(root, tag(inserted));
|
||||
let mut root = tag(initial);
|
||||
assert_eq!(path("v[0]").set(&mut root, &value).unwrap(), 1);
|
||||
assert_eq!(root, tag(indexed));
|
||||
let mut root = tag(initial);
|
||||
assert_eq!(path("v[]").set(&mut root, &value).unwrap(), 2);
|
||||
assert_eq!(root, tag(all));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_arrays_reject_nonnumeric_insert_and_indexed_set() {
|
||||
for initial in ["{v:[B;1b,2b]}", "{v:[I;1,2]}", "{v:[L;1L,2L]}"] {
|
||||
let original = tag(initial);
|
||||
let value = tag("\"bad\"");
|
||||
for index in [0, 99, -99] {
|
||||
let mut root = original.clone();
|
||||
assert_eq!(
|
||||
path("v")
|
||||
.insert(index, &mut root, std::slice::from_ref(&value))
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(root, original);
|
||||
}
|
||||
let mut root = original.clone();
|
||||
assert_eq!(path("v[0]").set(&mut root, &value).unwrap(), 0);
|
||||
assert_eq!(root, original);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_array_wildcard_set_preserves_vanillas_count_on_rejection() {
|
||||
for initial in ["{v:[B;]}", "{v:[I;]}", "{v:[L;]}"] {
|
||||
let mut root = tag(initial);
|
||||
assert_eq!(path("v[]").set(&mut root, &tag("\"bad\"")).unwrap(), 1);
|
||||
assert_eq!(root, tag(initial));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_comparison() {
|
||||
let actual = tag("{a:1,b:{c:[1,2,3],d:\"x\"},e:2}");
|
||||
|
||||
@@ -179,12 +179,13 @@ impl PadState {
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Edges {
|
||||
held: u32,
|
||||
suppressed: u32,
|
||||
}
|
||||
impl Edges {
|
||||
pub fn update(&mut self, state: &PadState, threshold: f32) -> Vec<(Button, bool)> {
|
||||
let mut now = state.buttons;
|
||||
for (trigger, button) in [(0, Button::LeftTrigger), (1, Button::RightTrigger)] {
|
||||
let was = self.held & button.bit() != 0;
|
||||
let was = (self.held | self.suppressed) & button.bit() != 0;
|
||||
let value = state.triggers[trigger];
|
||||
let on = if was {
|
||||
value >= (threshold - 0.1).max(0.05)
|
||||
@@ -197,6 +198,8 @@ impl Edges {
|
||||
now &= !button.bit();
|
||||
}
|
||||
}
|
||||
self.suppressed &= now;
|
||||
now &= !self.suppressed;
|
||||
let changed = now ^ self.held;
|
||||
self.held = now;
|
||||
Button::ALL
|
||||
@@ -217,6 +220,50 @@ impl Edges {
|
||||
pub fn is_held(&self, button: Button) -> bool {
|
||||
self.held & button.bit() != 0
|
||||
}
|
||||
/// Cancel a context without turning its still-held buttons into new presses.
|
||||
pub fn cancel(&mut self) -> Vec<(Button, bool)> {
|
||||
self.suppressed |= self.held;
|
||||
self.release_all()
|
||||
}
|
||||
}
|
||||
|
||||
/// Intentional activity may take input ownership; an unchanged hold may not.
|
||||
#[derive(Default)]
|
||||
pub struct Activity {
|
||||
buttons: u32,
|
||||
sticks: [[f32; 2]; 2],
|
||||
}
|
||||
impl Activity {
|
||||
pub fn observe(&mut self, state: &PadState, settings: &PadSettings) -> bool {
|
||||
let mut buttons = state.buttons;
|
||||
for (i, button) in [(0, Button::LeftTrigger), (1, Button::RightTrigger)] {
|
||||
let threshold = if self.buttons & button.bit() != 0 {
|
||||
(settings.trigger_threshold - 0.1).max(0.05)
|
||||
} else {
|
||||
settings.trigger_threshold
|
||||
};
|
||||
if state.triggers[i] >= threshold {
|
||||
buttons |= button.bit();
|
||||
}
|
||||
}
|
||||
let mut activity = buttons & !self.buttons != 0;
|
||||
self.buttons = buttons;
|
||||
for (i, (stick, deadzone)) in [
|
||||
(state.left, settings.move_deadzone),
|
||||
(state.right, settings.look_deadzone),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
if length(stick) <= deadzone {
|
||||
self.sticks[i] = [0.0; 2];
|
||||
} else if length([stick[0] - self.sticks[i][0], stick[1] - self.sticks[i][1]]) > 0.08 {
|
||||
activity = true;
|
||||
self.sticks[i] = stick;
|
||||
}
|
||||
}
|
||||
activity
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizon's pad-only actions (no vanilla key mapping).
|
||||
@@ -248,6 +295,39 @@ pub const ACTIONS: &[(&str, Option<Button>)] = &[
|
||||
("key.smoothCamera", None),
|
||||
];
|
||||
|
||||
/// How a bindable action reaches the game: one of its own handlers, shared
|
||||
/// with the keyboard where the keyboard has one, or else the vanilla default
|
||||
/// key or button of its mapping, as a rebound keyboard key would.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Route {
|
||||
HotbarStep {
|
||||
next: bool,
|
||||
},
|
||||
/// Held, or flipped when the pad's toggle sneak is on.
|
||||
Sneak,
|
||||
Screenshot,
|
||||
SmoothCamera,
|
||||
Key(winit::keyboard::KeyCode),
|
||||
Mouse(winit::event::MouseButton),
|
||||
}
|
||||
|
||||
/// The route of a bindable action; `None` for one the pad can't reach.
|
||||
pub fn route(action: &str) -> Option<Route> {
|
||||
match action {
|
||||
HOTBAR_PREVIOUS => Some(Route::HotbarStep { next: false }),
|
||||
HOTBAR_NEXT => Some(Route::HotbarStep { next: true }),
|
||||
"key.sneak" => Some(Route::Sneak),
|
||||
"key.screenshot" => Some(Route::Screenshot),
|
||||
"key.smoothCamera" => Some(Route::SmoothCamera),
|
||||
_ => {
|
||||
let default = crate::keybinds::KeyBinds::default_of(action);
|
||||
crate::keybinds::mouse_button(default)
|
||||
.map(Route::Mouse)
|
||||
.or_else(|| crate::keybinds::key_code(default).map(Route::Key))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An action's name in the binds list: vanilla's translation key, or
|
||||
/// Horizon's own English for the pad-only actions.
|
||||
pub fn action_caption(action: &str) -> &str {
|
||||
@@ -313,31 +393,6 @@ impl GlyphStyle {
|
||||
}
|
||||
}
|
||||
|
||||
/// How a controller drives menus (docs/controllers.md, "Menus and
|
||||
/// inventories").
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum MenuNav {
|
||||
/// A console-style highlight moved between widgets and slots.
|
||||
#[default]
|
||||
Focus,
|
||||
/// A virtual mouse pointer.
|
||||
Pointer,
|
||||
}
|
||||
impl MenuNav {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
MenuNav::Focus => "focus",
|
||||
MenuNav::Pointer => "pointer",
|
||||
}
|
||||
}
|
||||
pub fn parse(name: &str) -> Self {
|
||||
match name {
|
||||
"pointer" => MenuNav::Pointer,
|
||||
_ => MenuNav::Focus,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The on-screen keyboard A opens on a text field in focus mode.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum TextKeyboard {
|
||||
@@ -399,9 +454,12 @@ pub struct PadSettings {
|
||||
/// holding it, unless vanilla's toggle crouch is on already.
|
||||
pub toggle_sneak: bool,
|
||||
pub glyphs: GlyphStyle,
|
||||
/// How the pad drives menus: a focus highlight or a virtual pointer.
|
||||
pub menu_nav: MenuNav,
|
||||
pub keyboard: TextKeyboard,
|
||||
/// Menu pointer multiplier, scroll lines/second, and repeat times in seconds.
|
||||
pub pointer_speed: f32,
|
||||
pub scroll_speed: f32,
|
||||
pub repeat_delay: f32,
|
||||
pub repeat_interval: f32,
|
||||
/// Action name -> button, `None` for unbound; missing means the default.
|
||||
bindings: BTreeMap<&'static str, Option<Button>>,
|
||||
}
|
||||
@@ -420,8 +478,11 @@ impl Default for PadSettings {
|
||||
trigger_threshold: 0.4,
|
||||
toggle_sneak: true,
|
||||
glyphs: GlyphStyle::Auto,
|
||||
menu_nav: MenuNav::Focus,
|
||||
keyboard: TextKeyboard::Auto,
|
||||
pointer_speed: 1.0,
|
||||
scroll_speed: 12.0,
|
||||
repeat_delay: 0.35,
|
||||
repeat_interval: 0.09,
|
||||
bindings: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
@@ -462,6 +523,13 @@ impl PadSettings {
|
||||
.filter(|v| v.is_finite())
|
||||
.map_or(d, |v| v.clamp(0.0, 1.0))
|
||||
};
|
||||
let bounded = |default: f32, min: f32, max: f32| {
|
||||
value
|
||||
.parse::<f32>()
|
||||
.ok()
|
||||
.filter(|v| v.is_finite())
|
||||
.map_or(default, |v| v.clamp(min, max))
|
||||
};
|
||||
match key.trim() {
|
||||
"enabled" => s.enabled = flag(s.enabled),
|
||||
"vibration" => s.vibration = flag(s.vibration),
|
||||
@@ -477,8 +545,11 @@ impl PadSettings {
|
||||
}
|
||||
"toggleSneak" => s.toggle_sneak = flag(s.toggle_sneak),
|
||||
"glyphs" => s.glyphs = GlyphStyle::parse(value),
|
||||
"menuNavigation" => s.menu_nav = MenuNav::parse(value),
|
||||
"keyboard" => s.keyboard = TextKeyboard::parse(value),
|
||||
"pointerSpeed" => s.pointer_speed = bounded(s.pointer_speed, 0.25, 3.0),
|
||||
"scrollSpeed" => s.scroll_speed = bounded(s.scroll_speed, 1.0, 36.0),
|
||||
"repeatDelay" => s.repeat_delay = bounded(s.repeat_delay, 0.1, 1.0),
|
||||
"repeatInterval" => s.repeat_interval = bounded(s.repeat_interval, 0.03, 0.3),
|
||||
other => {
|
||||
if let Some(action) = other.strip_prefix("bind_") {
|
||||
let button = if value == "none" {
|
||||
@@ -497,7 +568,7 @@ impl PadSettings {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
let mut text = format!(
|
||||
"enabled:{}\nvibration:{}\nadaptiveTriggers:{}\nlightbar:{}\nlookSensitivity:{}\ninvertX:{}\ninvertY:{}\nmoveDeadzone:{}\nlookDeadzone:{}\ntriggerThreshold:{}\ntoggleSneak:{}\nglyphs:{}\nmenuNavigation:{}\nkeyboard:{}\n",
|
||||
"enabled:{}\nvibration:{}\nadaptiveTriggers:{}\nlightbar:{}\nlookSensitivity:{}\ninvertX:{}\ninvertY:{}\nmoveDeadzone:{}\nlookDeadzone:{}\ntriggerThreshold:{}\ntoggleSneak:{}\nglyphs:{}\nkeyboard:{}\n",
|
||||
bool_text(self.enabled),
|
||||
bool_text(self.vibration),
|
||||
bool_text(self.adaptive_triggers),
|
||||
@@ -510,9 +581,12 @@ impl PadSettings {
|
||||
self.trigger_threshold,
|
||||
bool_text(self.toggle_sneak),
|
||||
self.glyphs.name(),
|
||||
self.menu_nav.name(),
|
||||
self.keyboard.name(),
|
||||
);
|
||||
text.push_str(&format!(
|
||||
"pointerSpeed:{}\nscrollSpeed:{}\nrepeatDelay:{}\nrepeatInterval:{}\n",
|
||||
self.pointer_speed, self.scroll_speed, self.repeat_delay, self.repeat_interval,
|
||||
));
|
||||
for (action, button) in &self.bindings {
|
||||
text.push_str(&format!(
|
||||
"bind_{action}:{}\n",
|
||||
@@ -1163,6 +1237,131 @@ pub fn bow_power(ticks: u32) -> f32 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_bindable_action_reaches_the_game() {
|
||||
for (action, _) in ACTIONS {
|
||||
assert!(route(action).is_some(), "{action} has nowhere to go");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_preferences_migrate_and_stay_with_each_player() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"horizon-pad-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
let p2 = dir.join("P2");
|
||||
fs::create_dir_all(&p2).unwrap();
|
||||
let fresh = PadSettings::load(&dir);
|
||||
assert_eq!(fresh, PadSettings::default());
|
||||
// An older file, with the removed menuNavigation key: bindings kept.
|
||||
let legacy =
|
||||
PadSettings::parse("menuNavigation:pointer\nbind_key.jump:east\nlookSensitivity:0.7\n");
|
||||
assert_eq!(legacy.binding("key.jump"), Some(Button::East));
|
||||
assert_eq!(legacy.look_sensitivity, 0.7);
|
||||
fresh.save(&dir);
|
||||
assert!(!dir.join(FILE).exists(), "defaults write no file");
|
||||
let mut other = legacy;
|
||||
other.pointer_speed = 2.5;
|
||||
other.scroll_speed = 24.0;
|
||||
other.repeat_delay = 0.6;
|
||||
other.repeat_interval = 0.12;
|
||||
other.save(&p2);
|
||||
assert_eq!(PadSettings::load(&p2), other);
|
||||
assert_eq!(PadSettings::load(&dir), fresh, "P2 did not write P1");
|
||||
let invalid = PadSettings::parse(
|
||||
"pointerSpeed:NaN\nscrollSpeed:inf\nrepeatDelay:-10\nrepeatInterval:20\n",
|
||||
);
|
||||
assert_eq!((invalid.pointer_speed, invalid.scroll_speed), (1.0, 12.0));
|
||||
assert_eq!((invalid.repeat_delay, invalid.repeat_interval), (0.1, 0.3));
|
||||
let bounded = PadSettings::parse(
|
||||
"pointerSpeed:10\nscrollSpeed:-1\nrepeatDelay:NaN\nrepeatInterval:inf\n",
|
||||
);
|
||||
assert_eq!((bounded.pointer_speed, bounded.scroll_speed), (3.0, 1.0));
|
||||
assert_eq!(
|
||||
(bounded.repeat_delay, bounded.repeat_interval),
|
||||
(0.35, 0.09)
|
||||
);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ownership_requires_new_activity_and_ignores_drift() {
|
||||
let settings = PadSettings::default();
|
||||
let mut p1 = Activity::default();
|
||||
let mut p2 = Activity::default();
|
||||
let mut state = PadState {
|
||||
left: [0.04, 0.03],
|
||||
..PadState::default()
|
||||
};
|
||||
assert!(!p1.observe(&state, &settings));
|
||||
state.set(Button::South, true);
|
||||
assert!(p1.observe(&state, &settings));
|
||||
assert!(
|
||||
!p1.observe(&state, &settings),
|
||||
"held pad cannot reclaim mouse ownership"
|
||||
);
|
||||
assert!(
|
||||
p2.observe(&state, &settings),
|
||||
"another player's tracker is independent"
|
||||
);
|
||||
state.set(Button::South, false);
|
||||
assert!(!p1.observe(&state, &settings));
|
||||
state.left = [0.5, 0.0];
|
||||
assert!(p1.observe(&state, &settings));
|
||||
state.left = [0.52, 0.0];
|
||||
assert!(!p1.observe(&state, &settings));
|
||||
for x in [0.54, 0.56] {
|
||||
state.left = [x, 0.0];
|
||||
assert!(!p1.observe(&state, &settings));
|
||||
}
|
||||
state.left = [0.6, 0.0];
|
||||
assert!(
|
||||
p1.observe(&state, &settings),
|
||||
"slow intentional movement accumulates"
|
||||
);
|
||||
state.left = [0.0; 2];
|
||||
state.triggers[0] = 0.5;
|
||||
assert!(p1.observe(&state, &settings));
|
||||
state.triggers[0] = 0.35;
|
||||
assert!(!p1.observe(&state, &settings));
|
||||
state.triggers[0] = 0.45;
|
||||
assert!(
|
||||
!p1.observe(&state, &settings),
|
||||
"trigger threshold noise is not new activity"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_holds_stay_released_until_neutral_then_press_again() {
|
||||
let mut edges = Edges::default();
|
||||
let mut state = PadState::default();
|
||||
state.set(Button::South, true);
|
||||
state.triggers[1] = 0.8;
|
||||
assert_eq!(
|
||||
edges.update(&state, 0.4),
|
||||
vec![(Button::South, true), (Button::RightTrigger, true)]
|
||||
);
|
||||
assert_eq!(
|
||||
edges.cancel(),
|
||||
vec![(Button::South, false), (Button::RightTrigger, false)]
|
||||
);
|
||||
assert!(edges.update(&state, 0.4).is_empty());
|
||||
state.triggers[1] = 0.35;
|
||||
assert!(edges.update(&state, 0.4).is_empty());
|
||||
assert!(edges.update(&PadState::default(), 0.4).is_empty());
|
||||
assert_eq!(edges.update(&state, 0.4), vec![(Button::South, true)]);
|
||||
state.triggers[1] = 0.7;
|
||||
assert_eq!(
|
||||
edges.update(&state, 0.4),
|
||||
vec![(Button::RightTrigger, true)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_follow_bedrock() {
|
||||
let s = PadSettings::default();
|
||||
@@ -1414,14 +1613,13 @@ mod tests {
|
||||
assert_eq!(stick_direction([0.9, 0.2], 0.5), Some(Button::DPadRight));
|
||||
assert_eq!(stick_direction([-0.2, -0.8], 0.5), Some(Button::DPadDown));
|
||||
assert_eq!(stick_direction([0.3, 0.3], 0.5), None);
|
||||
let mut s = PadSettings::default();
|
||||
assert_eq!(s.menu_nav, MenuNav::Focus);
|
||||
s.menu_nav = MenuNav::Pointer;
|
||||
s.keyboard = TextKeyboard::Horizon;
|
||||
let back = PadSettings::parse(&s.serialize());
|
||||
let s = PadSettings {
|
||||
keyboard: TextKeyboard::Horizon,
|
||||
..PadSettings::default()
|
||||
};
|
||||
assert_eq!(
|
||||
(back.menu_nav, back.keyboard),
|
||||
(MenuNav::Pointer, TextKeyboard::Horizon)
|
||||
PadSettings::parse(&s.serialize()).keyboard,
|
||||
TextKeyboard::Horizon
|
||||
);
|
||||
assert!(steam_keyboard(TextKeyboard::Steam) && !steam_keyboard(TextKeyboard::Horizon));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
//! The keys a player's mappings hold, as their vanilla default keys, and which
|
||||
//! devices hold each one. A key stays held while any device holds it, so one
|
||||
//! device letting go (splitscreen moving the keyboard to another player, say)
|
||||
//! can't release what another still presses.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
/// Where a press came from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Source {
|
||||
/// The keyboard and mouse, and the scripting socket acting as them.
|
||||
Keyboard,
|
||||
Pad,
|
||||
}
|
||||
|
||||
impl Source {
|
||||
fn bit(self) -> u8 {
|
||||
match self {
|
||||
Source::Keyboard => 1,
|
||||
Source::Pad => 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct HeldKeys {
|
||||
held: HashMap<KeyCode, u8>,
|
||||
}
|
||||
|
||||
impl HeldKeys {
|
||||
pub fn contains(&self, key: &KeyCode) -> bool {
|
||||
self.held.contains_key(key)
|
||||
}
|
||||
/// `source` holds `key`. True when nothing held it before.
|
||||
pub fn press(&mut self, key: KeyCode, source: Source) -> bool {
|
||||
let sources = self.held.entry(key).or_insert(0);
|
||||
let new = *sources == 0;
|
||||
*sources |= source.bit();
|
||||
new
|
||||
}
|
||||
/// `source` lets go of `key`. True when nothing holds it any more.
|
||||
pub fn release(&mut self, key: &KeyCode, source: Source) -> bool {
|
||||
let Some(sources) = self.held.get_mut(key) else {
|
||||
return true;
|
||||
};
|
||||
*sources &= !source.bit();
|
||||
if *sources == 0 {
|
||||
self.held.remove(key);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
pub fn held_by(&self, key: &KeyCode, source: Source) -> bool {
|
||||
self.held
|
||||
.get(key)
|
||||
.is_some_and(|sources| sources & source.bit() != 0)
|
||||
}
|
||||
/// `source` lets go of everything: the keys nothing holds any more.
|
||||
pub fn release_source(&mut self, source: Source) -> Vec<KeyCode> {
|
||||
let keys: Vec<KeyCode> = self
|
||||
.held
|
||||
.iter()
|
||||
.filter(|(_, sources)| *sources & source.bit() != 0)
|
||||
.map(|(key, _)| *key)
|
||||
.collect();
|
||||
keys.into_iter()
|
||||
.filter(|key| self.release(key, source))
|
||||
.collect()
|
||||
}
|
||||
/// Held, by the keyboard unless a device already holds it: for callers
|
||||
/// that don't say where the press came from.
|
||||
pub fn insert(&mut self, key: KeyCode) -> bool {
|
||||
if self.contains(&key) {
|
||||
return false;
|
||||
}
|
||||
self.press(key, Source::Keyboard)
|
||||
}
|
||||
/// Let go of `key` whoever holds it.
|
||||
pub fn remove(&mut self, key: &KeyCode) -> bool {
|
||||
self.held.remove(key).is_some()
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.held.clear();
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.held.is_empty()
|
||||
}
|
||||
pub fn len(&self) -> usize {
|
||||
self.held.len()
|
||||
}
|
||||
pub fn iter(&self) -> impl Iterator<Item = &KeyCode> {
|
||||
self.held.keys()
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoIterator for HeldKeys {
|
||||
type Item = KeyCode;
|
||||
type IntoIter = std::collections::hash_map::IntoKeys<KeyCode, u8>;
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.held.into_keys()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> IntoIterator for &'a HeldKeys {
|
||||
type Item = &'a KeyCode;
|
||||
type IntoIter = std::collections::hash_map::Keys<'a, KeyCode, u8>;
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.held.keys()
|
||||
}
|
||||
}
|
||||
|
||||
impl FromIterator<KeyCode> for HeldKeys {
|
||||
fn from_iter<I: IntoIterator<Item = KeyCode>>(keys: I) -> Self {
|
||||
let mut held = HeldKeys::default();
|
||||
for key in keys {
|
||||
held.insert(key);
|
||||
}
|
||||
held
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_key_stays_held_while_any_device_holds_it() {
|
||||
let mut keys = HeldKeys::default();
|
||||
assert!(keys.press(KeyCode::Space, Source::Pad));
|
||||
assert!(!keys.press(KeyCode::Space, Source::Keyboard));
|
||||
assert!(keys.press(KeyCode::KeyW, Source::Keyboard));
|
||||
assert_eq!(keys.release_source(Source::Keyboard), vec![KeyCode::KeyW]);
|
||||
assert!(keys.contains(&KeyCode::Space) && !keys.contains(&KeyCode::KeyW));
|
||||
assert!(keys.held_by(&KeyCode::Space, Source::Pad));
|
||||
assert!(!keys.held_by(&KeyCode::Space, Source::Keyboard));
|
||||
assert!(keys.release(&KeyCode::Space, Source::Pad));
|
||||
assert!(keys.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untagged_inserts_and_removes_act_as_before() {
|
||||
let mut keys = HeldKeys::default();
|
||||
keys.press(KeyCode::ShiftLeft, Source::Pad);
|
||||
assert!(!keys.insert(KeyCode::ShiftLeft), "already held");
|
||||
assert!(!keys.held_by(&KeyCode::ShiftLeft, Source::Keyboard));
|
||||
assert!(
|
||||
keys.remove(&KeyCode::ShiftLeft),
|
||||
"remove lets go for everyone"
|
||||
);
|
||||
assert!(!keys.contains(&KeyCode::ShiftLeft));
|
||||
assert!(keys.release(&KeyCode::KeyA, Source::Keyboard), "never held");
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
pub mod clipboard;
|
||||
pub mod gamepad;
|
||||
pub mod held;
|
||||
pub mod input;
|
||||
pub mod interaction_input;
|
||||
pub mod key_names;
|
||||
@@ -12,4 +13,5 @@ pub mod keybinds;
|
||||
pub mod keymapping;
|
||||
pub mod modifiers;
|
||||
pub mod mouse;
|
||||
pub mod snap;
|
||||
pub mod window;
|
||||
@@ -0,0 +1,152 @@
|
||||
//! Where the controller's menu cursor settles once the stick is let go, and
|
||||
//! how it gets there. Every rule's number lives in [`Tuning`], so the feel can
|
||||
//! be adjusted in one place.
|
||||
|
||||
/// The snapping engine's knobs, in GUI pixels and seconds.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Tuning {
|
||||
/// A let-go cursor settles on a target this close to it.
|
||||
pub radius: f32,
|
||||
/// How long the glide onto a target takes.
|
||||
pub glide_seconds: f32,
|
||||
/// Movement shorter than this is a wobble: the cursor stays where it was.
|
||||
pub min_travel: f32,
|
||||
/// A flick that ends still nearest its starting target commits to the
|
||||
/// neighbour it was heading for once it covered this fraction of the way.
|
||||
pub commit: f32,
|
||||
/// How far off the flick's direction a neighbour may lie (cosine of the
|
||||
/// angle: 0.7 is about 45 degrees).
|
||||
pub cone: f32,
|
||||
/// The pointer's speed over a slot or control, as a fraction of its
|
||||
/// speed elsewhere (1 leaves it unchanged).
|
||||
pub over_target_speed: f32,
|
||||
}
|
||||
|
||||
pub const TUNING: Tuning = Tuning {
|
||||
radius: 16.0,
|
||||
glide_seconds: 0.025,
|
||||
min_travel: 1.5,
|
||||
commit: 0.15,
|
||||
cone: 0.7,
|
||||
over_target_speed: 1.0,
|
||||
};
|
||||
|
||||
type Point = (f32, f32);
|
||||
|
||||
fn distance(a: Point, b: Point) -> f32 {
|
||||
((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
/// The target nearest `at`, if one lies within `radius`.
|
||||
pub fn nearest(at: Point, targets: &[Point], radius: f32) -> Option<usize> {
|
||||
targets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, t)| (i, distance(at, *t)))
|
||||
.filter(|(_, d)| *d <= radius)
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.map(|(i, _)| i)
|
||||
}
|
||||
|
||||
/// Where a stick movement from `start` to `end` settles among `targets`
|
||||
/// (their centres), or `None` to leave the cursor where it is.
|
||||
pub fn settle(start: Point, end: Point, targets: &[Point], tuning: &Tuning) -> Option<usize> {
|
||||
let near = nearest(end, targets, tuning.radius);
|
||||
let travel = (end.0 - start.0, end.1 - start.1);
|
||||
let length = distance(start, end);
|
||||
if length < tuning.min_travel {
|
||||
return near;
|
||||
}
|
||||
let origin = nearest(start, targets, tuning.radius);
|
||||
if near != origin {
|
||||
return near;
|
||||
}
|
||||
// Still nearest where it started: a quick flick toward a neighbour.
|
||||
let Some(o) = origin else {
|
||||
return near;
|
||||
};
|
||||
let from = targets[o];
|
||||
let heading = (travel.0 / length, travel.1 / length);
|
||||
let next = targets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| *i != o)
|
||||
.filter_map(|(i, t)| {
|
||||
let to = (t.0 - from.0, t.1 - from.1);
|
||||
let gap = distance(from, *t);
|
||||
let aligned = (to.0 * heading.0 + to.1 * heading.1) / gap;
|
||||
(gap > 0.0 && aligned >= tuning.cone).then_some((i, gap, to))
|
||||
})
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1));
|
||||
match next {
|
||||
Some((i, gap, to)) => {
|
||||
let progress = ((end.0 - from.0) * to.0 + (end.1 - from.1) * to.1) / (gap * gap);
|
||||
if progress >= tuning.commit {
|
||||
Some(i)
|
||||
} else {
|
||||
near
|
||||
}
|
||||
}
|
||||
None => near,
|
||||
}
|
||||
}
|
||||
|
||||
/// The cursor `seconds` into a glide from `from` to `to`: eased out, so it
|
||||
/// slows as it lands, and exactly `to` once the glide is over.
|
||||
pub fn glide(from: Point, to: Point, seconds: f32, tuning: &Tuning) -> Point {
|
||||
let t = (seconds / tuning.glide_seconds).clamp(0.0, 1.0);
|
||||
let eased = 1.0 - (1.0 - t).powi(3);
|
||||
(
|
||||
from.0 + (to.0 - from.0) * eased,
|
||||
from.1 + (to.1 - from.1) * eased,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A row of inventory slots, 18 pixels apart, and the slot below the first.
|
||||
const SLOTS: [Point; 4] = [(8.0, 8.0), (26.0, 8.0), (44.0, 8.0), (8.0, 26.0)];
|
||||
|
||||
#[test]
|
||||
fn a_short_flick_moves_to_the_neighbour_it_heads_for() {
|
||||
// 4 pixels right of slot 0: still nearest slot 0, but heading for slot 1.
|
||||
assert_eq!(settle((8.0, 8.0), (12.0, 8.0), &SLOTS, &TUNING), Some(1));
|
||||
assert_eq!(settle((8.0, 8.0), (8.0, 12.0), &SLOTS, &TUNING), Some(3));
|
||||
assert_eq!(settle((26.0, 8.0), (22.0, 8.0), &SLOTS, &TUNING), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wobble_stays_put() {
|
||||
assert_eq!(settle((8.0, 8.0), (8.5, 8.4), &SLOTS, &TUNING), Some(0));
|
||||
// Barely started toward the neighbour: not committed.
|
||||
assert_eq!(settle((8.0, 8.0), (9.6, 8.0), &SLOTS, &TUNING), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_move_settles_on_the_nearest_target() {
|
||||
assert_eq!(settle((8.0, 8.0), (42.0, 9.0), &SLOTS, &TUNING), Some(2));
|
||||
// Far from everything: left where it is.
|
||||
assert_eq!(settle((8.0, 8.0), (200.0, 200.0), &SLOTS, &TUNING), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flick_off_any_row_does_not_jump_sideways() {
|
||||
// Up from slot 0: nothing lies that way, so it stays on slot 0.
|
||||
assert_eq!(settle((8.0, 8.0), (8.0, 4.0), &SLOTS, &TUNING), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_glide_eases_onto_its_target_and_stops_there() {
|
||||
let (from, to) = ((0.0, 0.0), (20.0, -10.0));
|
||||
assert_eq!(glide(from, to, 0.0, &TUNING), from);
|
||||
let half = glide(from, to, TUNING.glide_seconds / 2.0, &TUNING);
|
||||
assert!(
|
||||
half.0 > 10.0 && half.0 < 20.0,
|
||||
"eased out: past halfway at half time"
|
||||
);
|
||||
assert_eq!(glide(from, to, TUNING.glide_seconds, &TUNING), to);
|
||||
assert_eq!(glide(from, to, 1.0, &TUNING), to);
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
true,
|
||||
))?;
|
||||
assert!(!renderer.pack_entity_shadows(), "no selected pack");
|
||||
renderer.set_shader_pack(0, None, &[]);
|
||||
renderer.set_shader_pack(0, None, &[]).unwrap();
|
||||
assert!(
|
||||
!renderer.pack_entity_shadows(),
|
||||
"selected pack without a compiled post"
|
||||
@@ -165,6 +165,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
let (_, notes) = renderer
|
||||
.set_shader_pack(dimension, Some(post), &[])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
if break_pipeline {
|
||||
assert!(
|
||||
|
||||
@@ -190,8 +190,9 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
let one = [255u8; 4];
|
||||
let zero = [0u8; 4];
|
||||
let pixels = render.pixels.get();
|
||||
let prepared = PreparedTextures::new(
|
||||
&render.pixels,
|
||||
&pixels,
|
||||
render.sprite_size,
|
||||
render.texture_layers,
|
||||
&render.sprite_mips,
|
||||
@@ -206,7 +207,7 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut renderer = pollster::block_on(Renderer::new(
|
||||
window,
|
||||
true,
|
||||
&render.pixels,
|
||||
&pixels,
|
||||
render.sprite_size,
|
||||
render.sprite_minimum_size,
|
||||
render.texture_layers,
|
||||
@@ -224,6 +225,8 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
false,
|
||||
false,
|
||||
))?;
|
||||
drop(pixels);
|
||||
render.pixels.pack();
|
||||
let state = &scene.renderer;
|
||||
let [width, height] = o.size.unwrap_or(state.size);
|
||||
renderer.resize(width, height);
|
||||
|
||||
@@ -79,7 +79,10 @@ fn pack(renderer: &mut Renderer, folder: &Path, valid: bool) {
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let post = horizon_shaders::load_post(&pack, "", &properties, ¯os, &renderer.pack_inputs());
|
||||
assert_eq!(post.notes.is_empty(), valid, "{:?}", post.notes);
|
||||
let (_, notes) = renderer.set_shader_pack(0, Some(post), &[]).unwrap();
|
||||
let (_, notes) = renderer
|
||||
.set_shader_pack(0, Some(post), &[])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(
|
||||
!valid || notes.iter().all(|n| !n.contains("skipped")),
|
||||
"{notes:?}"
|
||||
|
||||
@@ -84,7 +84,9 @@ pub struct Assets {
|
||||
pub struct RenderAssets {
|
||||
pub sprite_size: u32,
|
||||
pub sprite_minimum_size: u32,
|
||||
pub pixels: Vec<u8>,
|
||||
/// The block sprites' RGBA, layer by layer: as built until a renderer has
|
||||
/// them, then kept compressed ([`SpritePixels`]).
|
||||
pub pixels: SpritePixels,
|
||||
pub texture_layers: u32,
|
||||
pub model_catalog: crate::models::StateModels,
|
||||
/// Shared parsed resources; item-only validation errors surface at item preparation.
|
||||
@@ -111,6 +113,76 @@ pub struct RenderAssets {
|
||||
/// sprites are when it changes (`native_block_sprites_version`).
|
||||
pub generation: u64,
|
||||
}
|
||||
/// The block sprites' pixels for a renderer to upload. Once a renderer has
|
||||
/// them ([`Self::pack`]) they are kept lz4-compressed: the GPU holds the
|
||||
/// texture array, and the copy only serves a renderer made again (another
|
||||
/// window, a lost device, another graphics API), which waits for
|
||||
/// [`Self::get`] to uncompress them. Shared by the window and the workers'
|
||||
/// `RenderAssets`, so the compressed form replaces the raw one in place.
|
||||
pub struct SpritePixels(Arc<PixelStore>);
|
||||
struct PixelStore {
|
||||
len: usize,
|
||||
/// Set by the first `pack`: later calls cost one atomic read.
|
||||
packing: std::sync::atomic::AtomicBool,
|
||||
/// As built; dropped once `packed` holds them.
|
||||
raw: std::sync::Mutex<Option<Arc<Vec<u8>>>>,
|
||||
packed: std::sync::OnceLock<Vec<u8>>,
|
||||
}
|
||||
impl SpritePixels {
|
||||
pub fn new(pixels: Vec<u8>) -> Self {
|
||||
Self(Arc::new(PixelStore {
|
||||
len: pixels.len(),
|
||||
packing: std::sync::atomic::AtomicBool::new(false),
|
||||
raw: std::sync::Mutex::new(Some(Arc::new(pixels))),
|
||||
packed: std::sync::OnceLock::new(),
|
||||
}))
|
||||
}
|
||||
/// Their size in bytes, uncompressed.
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.len
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.len == 0
|
||||
}
|
||||
/// The pixels as built, uncompressed again once packed.
|
||||
pub fn get(&self) -> Arc<Vec<u8>> {
|
||||
let raw = self.0.raw.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
raw.unwrap_or_else(|| {
|
||||
let packed = self.0.packed.get().map(Vec::as_slice).unwrap_or_default();
|
||||
Arc::new(
|
||||
lz4_flex::decompress_size_prepended(packed)
|
||||
.ok()
|
||||
.filter(|p| p.len() == self.0.len)
|
||||
.unwrap_or_else(|| vec![0; self.0.len]),
|
||||
)
|
||||
})
|
||||
}
|
||||
/// Keep them compressed from now on, once a renderer has uploaded them:
|
||||
/// compressed on a thread of their own, the raw copy dropped after.
|
||||
pub fn pack(&self) {
|
||||
use std::sync::atomic::Ordering;
|
||||
if self.0.len == 0 || self.0.packing.swap(true, Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let store = self.0.clone();
|
||||
let job = move || {
|
||||
let raw = store.raw.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
if let Some(raw) = raw {
|
||||
let _ = store.packed.set(lz4_flex::compress_prepend_size(&raw));
|
||||
// `get` reads the compressed copy from here on.
|
||||
*store.raw.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
}
|
||||
};
|
||||
let fallback = job.clone();
|
||||
if std::thread::Builder::new()
|
||||
.name("Sprite packing".into())
|
||||
.spawn(job)
|
||||
.is_err()
|
||||
{
|
||||
fallback();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The last `RenderAssets::generation` handed out.
|
||||
static GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
fn invalid(message: &str) -> io::Error {
|
||||
@@ -333,7 +405,7 @@ impl RenderAssets {
|
||||
sprite_size: sprites.size,
|
||||
sprite_minimum_size: sprites.minimum_size,
|
||||
texture_layers: u32::try_from(sprites.count())?,
|
||||
pixels: sprites.pixels,
|
||||
pixels: SpritePixels::new(sprites.pixels),
|
||||
model_catalog: models.catalog,
|
||||
model_library,
|
||||
model_placements: models.placements,
|
||||
@@ -431,4 +503,29 @@ mod tests {
|
||||
[ConsumeEffect::ApplyEffects { probability, .. }] if probability == 0.3
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprite_pixels_come_back_the_same_once_packed() {
|
||||
// Two layers of 16x16 RGBA with some structure, as sprites have.
|
||||
let pixels: Vec<u8> = (0..2 * 16 * 16 * 4).map(|i| (i * 7 % 251) as u8).collect();
|
||||
let stored = super::SpritePixels::new(pixels.clone());
|
||||
assert_eq!(*stored.get(), pixels);
|
||||
stored.pack();
|
||||
stored.pack();
|
||||
// The packing thread drops the raw copy once the compressed one is set.
|
||||
let started = std::time::Instant::now();
|
||||
while stored.0.raw.lock().unwrap().is_some() {
|
||||
assert!(started.elapsed().as_secs() < 10, "packing never finished");
|
||||
std::thread::yield_now();
|
||||
}
|
||||
assert!(
|
||||
stored
|
||||
.0
|
||||
.packed
|
||||
.get()
|
||||
.is_some_and(|p| p.len() < pixels.len())
|
||||
);
|
||||
assert_eq!(*stored.get(), pixels);
|
||||
assert_eq!(stored.len(), pixels.len());
|
||||
}
|
||||
}
|
||||
@@ -939,6 +939,14 @@ impl BlockEntityAnims {
|
||||
/// Drops the state of block entities that are gone or changed kind:
|
||||
/// `present` holds the loaded block entities' tickers, sorted by position.
|
||||
pub fn retain_present(&mut self, present: &[([i32; 3], Ticker)]) {
|
||||
self.retain_animations(present, false);
|
||||
}
|
||||
/// An integrated server owns bell state independently of renderer visibility.
|
||||
/// Its world ticker removes bells when their actual block changes.
|
||||
pub fn retain_present_preserving_bells(&mut self, present: &[([i32; 3], Ticker)]) {
|
||||
self.retain_animations(present, true);
|
||||
}
|
||||
fn retain_animations(&mut self, present: &[([i32; 3], Ticker)], keep_bells: bool) {
|
||||
let is = |p: &[i32; 3], f: fn(&Ticker) -> bool| {
|
||||
present
|
||||
.binary_search_by_key(p, |(q, _)| *q)
|
||||
@@ -948,7 +956,9 @@ impl BlockEntityAnims {
|
||||
self.books.retain(|p, _| is(p, |t| *t == Ticker::Book));
|
||||
self.shulkers
|
||||
.retain(|p, _| is(p, |t| matches!(t, Ticker::Shulker { .. })));
|
||||
self.bells.retain(|p, _| is(p, |t| *t == Ticker::Bell));
|
||||
if !keep_bells {
|
||||
self.bells.retain(|p, _| is(p, |t| *t == Ticker::Bell));
|
||||
}
|
||||
self.conduits
|
||||
.retain(|p, _| is(p, |t| *t == Ticker::Conduit));
|
||||
self.skulls
|
||||
|
||||
@@ -431,30 +431,6 @@ pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Opti
|
||||
)
|
||||
}
|
||||
|
||||
/// The report for a game that went over the player's RAM limit (`ram_limit`), written by the
|
||||
/// watcher thread: it says nothing about where the memory went, so it carries no stack. The player
|
||||
/// chose this limit, so it is no bug to send (`telemetry::keep_crash`), and leaving that out ends
|
||||
/// the game seconds sooner: it keeps running, over its limit, until the report is written.
|
||||
pub fn report_memory_limit(home: &Path, side: &'static str, message: String) {
|
||||
if REPORTED.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let report = Report {
|
||||
time: Local::now(),
|
||||
description: "RAM limit reached".into(),
|
||||
error: message,
|
||||
stack: String::new(),
|
||||
thread: thread_name(side),
|
||||
side,
|
||||
details: system_details(side),
|
||||
log: crate::logging::recent_lines(),
|
||||
};
|
||||
match report.save(home) {
|
||||
Ok(path) => eprintln!("{SAVED_LINE}{}", path.display()),
|
||||
Err(e) => eprintln!("Horizon: could not save a crash report: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn payload_text(payload: &(dyn std::any::Any + Send)) -> String {
|
||||
payload
|
||||
.downcast_ref::<&str>()
|
||||
|
||||
@@ -275,19 +275,34 @@ pub fn temp_folder() -> std::io::Result<PathBuf> {
|
||||
/// `PackSelectionScreen.onFilesDrop`'s `PackDetector`: a zip is a pack; a
|
||||
/// folder is one when it holds a `pack.mcmeta`, or exactly one folder that
|
||||
/// does (a folder zipped by the system, which adds a wrapper). Copied into
|
||||
/// `dir`; false when `path` is no pack.
|
||||
/// `dir`; false when `path` is no pack, or when part of it could not be copied.
|
||||
pub fn copy_pack(path: &Path, dir: &Path) -> bool {
|
||||
fn copy_dir(from: &Path, at: &Path) {
|
||||
let _ = std::fs::create_dir_all(at);
|
||||
fn copy_dir(from: &Path, at: &Path) -> bool {
|
||||
let mut whole = true;
|
||||
if let Err(e) = std::fs::create_dir_all(at) {
|
||||
log::warn!("Horizon: {} is not made: {e}", at.display());
|
||||
whole = false;
|
||||
}
|
||||
for entry in std::fs::read_dir(from).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
let to = at.join(entry.file_name());
|
||||
if path.is_dir() {
|
||||
copy_dir(&path, &to);
|
||||
let copied = if path.is_dir() {
|
||||
copy_dir(&path, &to)
|
||||
} else {
|
||||
let _ = std::fs::copy(&path, &to);
|
||||
}
|
||||
match std::fs::copy(&path, &to) {
|
||||
Ok(_) => true,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Horizon: {} is not copied into the pack: {e}",
|
||||
path.display()
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
};
|
||||
whole &= copied;
|
||||
}
|
||||
whole
|
||||
}
|
||||
let name = || {
|
||||
path.file_name()
|
||||
@@ -307,8 +322,7 @@ pub fn copy_pack(path: &Path, dir: &Path) -> bool {
|
||||
return false;
|
||||
}
|
||||
if path.join("pack.mcmeta").is_file() {
|
||||
copy_dir(path, &dir.join(name()));
|
||||
return true;
|
||||
return copy_dir(path, &dir.join(name()));
|
||||
}
|
||||
let own: Vec<PathBuf> = std::fs::read_dir(path)
|
||||
.into_iter()
|
||||
@@ -318,10 +332,7 @@ pub fn copy_pack(path: &Path, dir: &Path) -> bool {
|
||||
.filter(|p| p.is_dir() && p.join("pack.mcmeta").is_file())
|
||||
.collect();
|
||||
match own.as_slice() {
|
||||
[one] => {
|
||||
copy_dir(one, &dir.join(one.file_name().unwrap_or_default()));
|
||||
true
|
||||
}
|
||||
[one] => copy_dir(one, &dir.join(one.file_name().unwrap_or_default())),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -914,6 +925,24 @@ mod tests {
|
||||
assert_eq!(content_id("data/mymod/data_maps/item/fuel.txt"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pack_that_cannot_be_copied_is_reported() {
|
||||
let root = std::env::temp_dir().join(format!("hz-datapack-copy-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let pack = root.join("pack");
|
||||
std::fs::create_dir_all(pack.join("data")).unwrap();
|
||||
std::fs::write(pack.join("pack.mcmeta"), "{}").unwrap();
|
||||
std::fs::write(pack.join("data/functions.mcfunction"), "").unwrap();
|
||||
// A file where the pack's folder goes: every copy under it fails.
|
||||
let blocked = root.join("blocked");
|
||||
std::fs::write(&blocked, "").unwrap();
|
||||
assert!(!copy_pack(&pack, &blocked));
|
||||
let dir = root.join("packs");
|
||||
assert!(copy_pack(&pack, &dir));
|
||||
assert!(dir.join("pack/pack.mcmeta").is_file());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_packs_override_worldgen_in_order() {
|
||||
let dir = std::env::temp_dir().join(format!("hz-datapack-worldgen-{}", std::process::id()));
|
||||
|
||||
@@ -78,6 +78,13 @@ pub struct Extras {
|
||||
pub zoom_smooth: bool,
|
||||
/// Divide mouse turning by the zoom.
|
||||
pub zoom_sensitivity: bool,
|
||||
/// Far terrain a zoom magnifies is planned finer, so a spyglass does not
|
||||
/// show the coarse cells it was given for an unzoomed screen
|
||||
/// (`lod::Aim`). Costs far tiles while the zoom is held and gives them
|
||||
/// back after, and does nothing at all unless far terrain is on
|
||||
/// (`lodDistance`) and the view is actually zoomed. Off by default until
|
||||
/// a measurement shows the extra tiles land while the zoom is still held.
|
||||
pub lod_zoom_detail: bool,
|
||||
/// The FPS counter and keystrokes extras are official mods now: their
|
||||
/// old settings stay here, kept as they were.
|
||||
pub fps: bool,
|
||||
@@ -154,15 +161,20 @@ pub struct Extras {
|
||||
pub music_page: bool,
|
||||
/// Cosmetics (`horizon_cosmetics`, off unless enabled), the lines `horizon_cosmetics::prefs`
|
||||
/// shares with the launcher's Wardrobe: Show my cosmetics (the account's, sent to the service;
|
||||
/// on by default), how much of other players' to show (all by default), and their auras and
|
||||
/// trails (off by default).
|
||||
/// on by default), how much of other players' to show (no line until chosen:
|
||||
/// `effective_others`), and their auras and trails (off by default).
|
||||
pub cosmetics_mine: bool,
|
||||
pub cosmetics_others: horizon_cosmetics::prefs::Others,
|
||||
pub cosmetics_others: Option<horizon_cosmetics::prefs::Others>,
|
||||
pub cosmetics_auras: bool,
|
||||
/// Whether the signed-in account owns a cosmetic, as the service said (never saved; the game
|
||||
/// sets it): with no choice made, others' cosmetics show only then.
|
||||
pub cosmetics_owner: bool,
|
||||
/// 3D skin layers (`threeDSkinLayers`, Options → Skin Customization): each
|
||||
/// painted texel of a player's outer layer drawn as a slab
|
||||
/// (`horizon_client::mobs::skin_layers`).
|
||||
pub three_d_skin_layers: bool,
|
||||
/// The PvP & HUD page's options (`pvp`).
|
||||
pub pvp: crate::pvp::Pvp,
|
||||
}
|
||||
impl Default for Extras {
|
||||
fn default() -> Self {
|
||||
@@ -175,6 +187,7 @@ impl Default for Extras {
|
||||
zoom_level: 4,
|
||||
zoom_smooth: true,
|
||||
zoom_sensitivity: true,
|
||||
lod_zoom_detail: false,
|
||||
fps: false,
|
||||
fps_corner: Corner::TopLeft,
|
||||
fps_lows: false,
|
||||
@@ -210,9 +223,11 @@ impl Default for Extras {
|
||||
music_off: Vec::new(),
|
||||
music_page: false,
|
||||
cosmetics_mine: true,
|
||||
cosmetics_others: horizon_cosmetics::prefs::Others::Shown,
|
||||
cosmetics_others: None,
|
||||
cosmetics_auras: false,
|
||||
cosmetics_owner: false,
|
||||
three_d_skin_layers: false,
|
||||
pvp: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -220,6 +235,11 @@ fn bool_text(v: bool) -> &'static str {
|
||||
if v { "true" } else { "false" }
|
||||
}
|
||||
impl Extras {
|
||||
/// How much of other players' cosmetics shows: the player's choice, else all of it for an
|
||||
/// account that owns a cosmetic and none for any other (a vanilla game).
|
||||
pub fn effective_others(&self) -> horizon_cosmetics::prefs::Others {
|
||||
horizon_cosmetics::prefs::Others::effective(self.cosmetics_others, self.cosmetics_owner)
|
||||
}
|
||||
pub fn load(dir: &Path) -> Self {
|
||||
fs::read_to_string(dir.join(FILE))
|
||||
.map(|t| Self::parse(&t))
|
||||
@@ -250,6 +270,7 @@ impl Extras {
|
||||
}
|
||||
"zoomSmooth" => e.zoom_smooth = flag(e.zoom_smooth),
|
||||
"zoomSensitivity" => e.zoom_sensitivity = flag(e.zoom_sensitivity),
|
||||
"lodZoomDetail" => e.lod_zoom_detail = flag(e.lod_zoom_detail),
|
||||
"fps" => e.fps = flag(e.fps),
|
||||
"fpsCorner" => e.fps_corner = Corner::parse(value, e.fps_corner),
|
||||
"fpsLows" => e.fps_lows = flag(e.fps_lows),
|
||||
@@ -311,18 +332,20 @@ impl Extras {
|
||||
"cosmeticsMine" => e.cosmetics_mine = flag(e.cosmetics_mine),
|
||||
"cosmeticsOthers" => {
|
||||
e.cosmetics_others =
|
||||
horizon_cosmetics::prefs::Others::parse(value).unwrap_or(e.cosmetics_others)
|
||||
horizon_cosmetics::prefs::Others::parse(value).or(e.cosmetics_others)
|
||||
}
|
||||
"cosmeticsAuras" => e.cosmetics_auras = flag(e.cosmetics_auras),
|
||||
SKIN_LAYERS_KEY => e.three_d_skin_layers = flag(e.three_d_skin_layers),
|
||||
_ => {}
|
||||
key => {
|
||||
e.pvp.parse_line(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
e
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"packBrowser:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\ncosmeticsOthers:{}\ncosmeticsAuras:{}\n{}:{}\n",
|
||||
let mut text = format!(
|
||||
"packBrowser:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\n{}cosmeticsAuras:{}\n{}:{}\n",
|
||||
bool_text(self.pack_browser),
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.dynamic_lights),
|
||||
@@ -331,6 +354,7 @@ impl Extras {
|
||||
self.zoom_level,
|
||||
bool_text(self.zoom_smooth),
|
||||
bool_text(self.zoom_sensitivity),
|
||||
bool_text(self.lod_zoom_detail),
|
||||
bool_text(self.fps),
|
||||
self.fps_corner.name(),
|
||||
bool_text(self.fps_lows),
|
||||
@@ -371,11 +395,15 @@ impl Extras {
|
||||
self.music_off.join(","),
|
||||
bool_text(self.music_page),
|
||||
bool_text(self.cosmetics_mine),
|
||||
self.cosmetics_others.name(),
|
||||
// Written once the player chose.
|
||||
self.cosmetics_others
|
||||
.map_or(String::new(), |o| format!("cosmeticsOthers:{}\n", o.name())),
|
||||
bool_text(self.cosmetics_auras),
|
||||
SKIN_LAYERS_KEY,
|
||||
bool_text(self.three_d_skin_layers),
|
||||
)
|
||||
);
|
||||
self.pvp.serialize(&mut text);
|
||||
text
|
||||
}
|
||||
/// Adopts the settings `options.txt` held before they moved here, while
|
||||
/// this file (`own`) has no line of its own: 3D skin layers was an
|
||||
@@ -546,6 +574,8 @@ pub enum Item {
|
||||
CosmeticsMine,
|
||||
CosmeticsOthers,
|
||||
CosmeticsAuras,
|
||||
/// On the PvP & HUD page (`pvp::rows`).
|
||||
Pvp(crate::pvp::Opt),
|
||||
}
|
||||
/// The Extras page: headers (language keys) and items.
|
||||
pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
@@ -612,6 +642,7 @@ impl Item {
|
||||
Item::CosmeticsMine => "horizon.extras.cosmetics_mine",
|
||||
Item::CosmeticsOthers => "horizon.extras.cosmetics_others",
|
||||
Item::CosmeticsAuras => "horizon.extras.cosmetics_auras",
|
||||
Item::Pvp(o) => o.caption_key(),
|
||||
}
|
||||
}
|
||||
/// The language key of the description shown as the row's tooltip.
|
||||
@@ -640,6 +671,7 @@ impl Item {
|
||||
Item::CosmeticsMine => "horizon.extras.cosmetics_mine.tooltip",
|
||||
Item::CosmeticsOthers => "horizon.extras.cosmetics_others.tooltip",
|
||||
Item::CosmeticsAuras => "horizon.extras.cosmetics_auras.tooltip",
|
||||
Item::Pvp(o) => o.description_key(),
|
||||
}
|
||||
}
|
||||
/// The caption in the current language (English when the key is missing).
|
||||
@@ -677,6 +709,7 @@ impl Item {
|
||||
Item::CosmeticsMine => "My cosmetics",
|
||||
Item::CosmeticsOthers => "Other players' cosmetics",
|
||||
Item::CosmeticsAuras => "Others' auras and trails",
|
||||
Item::Pvp(o) => o.caption(),
|
||||
}
|
||||
}
|
||||
/// The switch this setting belongs to: it does nothing while that is off.
|
||||
@@ -687,6 +720,7 @@ impl Item {
|
||||
MouseTweaksWheel => MouseTweaks,
|
||||
SortKey => Sort,
|
||||
ChatIndicator => ChatAnimation,
|
||||
Pvp(o) => return o.parent().map(Pvp),
|
||||
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
|
||||
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
|
||||
| ChatAnimation | WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine
|
||||
@@ -752,9 +786,10 @@ impl Item {
|
||||
"Hidden: every Horizon player sees you without your cosmetics, you included. The same switch as the launcher's Wardrobe."
|
||||
}
|
||||
Item::CosmeticsOthers => {
|
||||
"What other Horizon players wear: everything, only their name tag styles, or nothing. Hidden, the game never asks what they wear."
|
||||
"What other Horizon players wear: everything, only their name tag styles, or nothing. Hidden unless you own cosmetics, until you choose. Hidden, the game never asks what they wear."
|
||||
}
|
||||
Item::CosmeticsAuras => "Auras and trails other players wear. Off by default.",
|
||||
Item::Pvp(o) => o.description(),
|
||||
Item::WheelCyclesSettings => {
|
||||
"The scroll wheel over a button in the settings changes its value, as in vanilla. Off, the wheel only scrolls the list."
|
||||
}
|
||||
@@ -807,6 +842,7 @@ impl Item {
|
||||
Item::WheelCyclesSettings => e.wheel_cycles_settings,
|
||||
Item::PackBrowser => e.pack_browser,
|
||||
Item::CosmeticsAuras => e.cosmetics_auras,
|
||||
Item::Pvp(o) => return o.switch(&e.pvp),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -814,13 +850,14 @@ impl Item {
|
||||
pub fn value(self, e: &Extras) -> String {
|
||||
match self {
|
||||
Item::SortKey => crate::keybinds::display_name(&e.sort_key).plain(),
|
||||
Item::Pvp(o) if o.switch(&e.pvp).is_none() => o.value(&e.pvp).unwrap_or_default(),
|
||||
Item::CosmeticsMine => match e.cosmetics_mine {
|
||||
true => crate::lang::get_or("horizon.extras.cosmetics.shown", "Shown"),
|
||||
false => crate::lang::get_or("horizon.extras.cosmetics.hidden", "Hidden"),
|
||||
},
|
||||
Item::CosmeticsOthers => {
|
||||
use horizon_cosmetics::prefs::Others;
|
||||
match e.cosmetics_others {
|
||||
match e.effective_others() {
|
||||
Others::Shown => crate::lang::get_or("horizon.extras.cosmetics.shown", "Shown"),
|
||||
Others::NameTags => {
|
||||
crate::lang::get_or("horizon.extras.cosmetics.name_tags", "Name tags only")
|
||||
@@ -861,10 +898,12 @@ impl Item {
|
||||
Item::PackBrowser => e.pack_browser = !e.pack_browser,
|
||||
Item::CosmeticsMine => e.cosmetics_mine = !e.cosmetics_mine,
|
||||
Item::CosmeticsOthers => {
|
||||
let o = e.cosmetics_others;
|
||||
e.cosmetics_others = if back { o.next().next() } else { o.next() };
|
||||
// From what the button shows; the result is the player's choice from now on.
|
||||
let o = e.effective_others();
|
||||
e.cosmetics_others = Some(if back { o.next().next() } else { o.next() });
|
||||
}
|
||||
Item::CosmeticsAuras => e.cosmetics_auras = !e.cosmetics_auras,
|
||||
Item::Pvp(o) => o.cycle(&mut e.pvp, back),
|
||||
Item::SortKey => {}
|
||||
}
|
||||
}
|
||||
@@ -884,31 +923,70 @@ mod tests {
|
||||
#[test]
|
||||
fn other_players_cosmetics_cycle_three_ways_and_read_the_old_switch() {
|
||||
use horizon_cosmetics::prefs::Others;
|
||||
let mut e = Extras::default();
|
||||
let mut e = Extras {
|
||||
cosmetics_owner: true,
|
||||
..Extras::default()
|
||||
};
|
||||
assert_eq!(
|
||||
(e.cosmetics_others, e.cosmetics_auras, e.cosmetics_mine),
|
||||
(e.effective_others(), e.cosmetics_auras, e.cosmetics_mine),
|
||||
(Others::Shown, false, true)
|
||||
);
|
||||
Item::CosmeticsOthers.cycle(&mut e, false);
|
||||
assert_eq!(e.cosmetics_others, Others::NameTags);
|
||||
assert_eq!(e.cosmetics_others, Some(Others::NameTags));
|
||||
Item::CosmeticsOthers.cycle(&mut e, false);
|
||||
assert_eq!(e.cosmetics_others, Others::Hidden);
|
||||
assert_eq!(e.cosmetics_others, Some(Others::Hidden));
|
||||
// The right button goes back.
|
||||
Item::CosmeticsOthers.cycle(&mut e, true);
|
||||
assert_eq!(e.cosmetics_others, Others::NameTags);
|
||||
assert_eq!(Extras::parse(&e.serialize()), e);
|
||||
assert_eq!(e.cosmetics_others, Some(Others::NameTags));
|
||||
// Ownership is the service's, never written.
|
||||
assert_eq!(
|
||||
Extras::parse(&e.serialize()),
|
||||
Extras {
|
||||
cosmetics_owner: false,
|
||||
..e.clone()
|
||||
}
|
||||
);
|
||||
// The launcher's Wardrobe writes the same lines.
|
||||
assert_eq!(
|
||||
horizon_cosmetics::prefs::Prefs::parse(&e.serialize()).others,
|
||||
Others::NameTags
|
||||
Some(Others::NameTags)
|
||||
);
|
||||
// Before this, the line was on or off.
|
||||
assert_eq!(
|
||||
Extras::parse("cosmeticsOthers:false").cosmetics_others,
|
||||
Others::Hidden
|
||||
Some(Others::Hidden)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn others_cosmetics_are_off_for_a_player_who_owns_none_until_chosen() {
|
||||
use horizon_cosmetics::prefs::Others;
|
||||
let mut e = Extras::default();
|
||||
// Nothing chosen, nothing owned: the button shows Hidden and no line is written.
|
||||
assert_eq!(e.effective_others(), Others::Hidden);
|
||||
assert_eq!(
|
||||
Item::CosmeticsOthers.value(&e),
|
||||
crate::lang::get_or("horizon.extras.cosmetics.hidden", "Hidden")
|
||||
);
|
||||
assert!(!e.serialize().contains("cosmeticsOthers"));
|
||||
// The service says the account owns one: shown, still not a choice.
|
||||
e.cosmetics_owner = true;
|
||||
assert_eq!(e.effective_others(), Others::Shown);
|
||||
assert!(!e.serialize().contains("cosmeticsOthers"));
|
||||
// A non-owner pressing the button goes on from Hidden, and that choice is kept.
|
||||
e.cosmetics_owner = false;
|
||||
Item::CosmeticsOthers.cycle(&mut e, false);
|
||||
assert_eq!(e.cosmetics_others, Some(Others::Shown));
|
||||
let saved = Extras::parse(&e.serialize());
|
||||
assert_eq!(saved.effective_others(), Others::Shown);
|
||||
// An owner who chose Hidden keeps it.
|
||||
let hidden = Extras {
|
||||
cosmetics_owner: true,
|
||||
..Extras::parse("cosmeticsOthers:hidden\n")
|
||||
};
|
||||
assert_eq!(hidden.effective_others(), Others::Hidden);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn everything_is_off_by_default_and_round_trips() {
|
||||
let d = Extras::default();
|
||||
|
||||
@@ -134,6 +134,7 @@ pub struct Font {
|
||||
/// Atlas size and the reserved dynamic region (y, height).
|
||||
atlas: (u32, u32),
|
||||
region: (u32, u32),
|
||||
metrics_only: bool,
|
||||
cache: RefCell<Cache>,
|
||||
/// Set anew for each font and each `reset_cache` (see `epoch`).
|
||||
generation: std::cell::Cell<u64>,
|
||||
@@ -284,6 +285,7 @@ fn load_bitmap(
|
||||
sheets: &HashMap<String, Rect>,
|
||||
pixels: &[u8],
|
||||
atlas: (u32, u32),
|
||||
metrics_only: bool,
|
||||
) -> HashMap<char, BitmapGlyph> {
|
||||
let mut out = HashMap::new();
|
||||
let file = p["file"].as_str().unwrap_or("");
|
||||
@@ -349,6 +351,10 @@ fn load_bitmap(
|
||||
advance: (0.5 + used as f32 * scale).floor() + 1.0,
|
||||
offset: 1.0,
|
||||
};
|
||||
if metrics_only {
|
||||
out.insert(*ch, BitmapGlyph::Atlas(glyph));
|
||||
continue;
|
||||
}
|
||||
let glyph = match &sheet {
|
||||
None => BitmapGlyph::Atlas(Glyph {
|
||||
rect: Some(glyph_rect(
|
||||
@@ -596,6 +602,30 @@ impl Font {
|
||||
atlas: (u32, u32),
|
||||
region: (u32, u32),
|
||||
white: Rect,
|
||||
) -> Self {
|
||||
Self::load_with(root, sprites, pixels, atlas, region, white, false)
|
||||
}
|
||||
/// A server measures text but never draws it. Decode bitmap sheets only
|
||||
/// to obtain advances, then release their pixels; never rasterize glyphs.
|
||||
pub fn metrics(root: &Path) -> Self {
|
||||
let white = Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 1,
|
||||
h: 1,
|
||||
};
|
||||
Self::load_with(root, &HashMap::new(), &[], (1, 1), (0, 0), white, true)
|
||||
}
|
||||
fn load_with(
|
||||
root: &Path,
|
||||
sprites: &HashMap<String, Rect>,
|
||||
pixels: &[u8],
|
||||
atlas: (u32, u32),
|
||||
region: (u32, u32),
|
||||
white: Rect,
|
||||
metrics_only: bool,
|
||||
) -> Self {
|
||||
let sheets = sprites
|
||||
.iter()
|
||||
@@ -617,6 +647,7 @@ impl Font {
|
||||
root: root.to_owned(),
|
||||
atlas,
|
||||
region,
|
||||
metrics_only,
|
||||
cache: RefCell::new(Cache {
|
||||
sets: Vec::new(),
|
||||
region: Region {
|
||||
@@ -665,7 +696,14 @@ impl Font {
|
||||
)),
|
||||
"bitmap" => Some(Provider::Bitmap(
|
||||
Filter::of(p),
|
||||
load_bitmap(&self.root, p, &self.sheets, pixels, self.atlas),
|
||||
load_bitmap(
|
||||
&self.root,
|
||||
p,
|
||||
&self.sheets,
|
||||
pixels,
|
||||
self.atlas,
|
||||
self.metrics_only,
|
||||
),
|
||||
)),
|
||||
"unihex" => Some(Provider::Unihex(Filter::of(p), p.clone(), None)),
|
||||
"ttf" => Some(Provider::TrueType(
|
||||
@@ -841,6 +879,14 @@ impl Font {
|
||||
if let Some(id) = ttf.ids.get(&c)
|
||||
&& !(non_fishy && fishy_advance(ttf.advance(*id)))
|
||||
{
|
||||
if self.metrics_only {
|
||||
return Some(Glyph {
|
||||
rect: None,
|
||||
advance: ttf.advance(*id),
|
||||
offset: 1.0,
|
||||
..self.missing
|
||||
});
|
||||
}
|
||||
let (glyph, bitmap) = ttf.glyph(*id);
|
||||
return match bitmap {
|
||||
Some((w, h, rgba)) => {
|
||||
@@ -861,6 +907,17 @@ impl Font {
|
||||
}
|
||||
fn rasterize_hex(&self, region: &mut Region, h: HexGlyph) -> Option<Glyph> {
|
||||
let width = h.right.saturating_sub(h.left) + 1;
|
||||
if self.metrics_only {
|
||||
return Some(Glyph {
|
||||
rect: None,
|
||||
left: 0.0,
|
||||
top: 0.0,
|
||||
right: width as f32 / 2.0,
|
||||
bottom: 8.0,
|
||||
advance: hex_advance(&h),
|
||||
offset: 0.5,
|
||||
});
|
||||
}
|
||||
let mut rgba = Vec::with_capacity((width * 16 * 4) as usize);
|
||||
for row in h.rows {
|
||||
for bit in h.left..=h.right {
|
||||
@@ -1361,6 +1418,55 @@ mod tests {
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_metrics_preserve_bitmap_true_type_and_unihex_advances() {
|
||||
let sheet = png(16, 8, |x, _| x < 2 || (8..14).contains(&x));
|
||||
let def = br#"{"providers":[{"type":"space","advances":{" ":4}},{"type":"bitmap","file":"minecraft:font/a.png","ascent":7,"chars":["ab"]}]}"#;
|
||||
let ttf = br#"{"providers":[{"type":"ttf","file":"minecraft:mono.ttf","size":11,"oversample":2}]}"#;
|
||||
let root = root(
|
||||
"metrics",
|
||||
&[
|
||||
("assets/minecraft/font/default.json", def),
|
||||
("assets/minecraft/font/alt.json", ttf),
|
||||
(
|
||||
"assets/minecraft/font/mono.ttf",
|
||||
horizon_theme::fonts::GEIST_MONO,
|
||||
),
|
||||
("assets/minecraft/textures/font/a.png", &sheet),
|
||||
],
|
||||
);
|
||||
let rendered = load(&root);
|
||||
let metrics = Font::metrics(&root);
|
||||
assert_eq!(metrics.width("a b"), 14.0);
|
||||
assert_eq!(metrics.width("§lab"), 12.0);
|
||||
let text = Text::literal("ab ab");
|
||||
assert_eq!(
|
||||
metrics.split_formatted(&text, 10.0),
|
||||
rendered.split_formatted(&text, 10.0)
|
||||
);
|
||||
let alt = Style {
|
||||
font: Some("minecraft:alt".into()),
|
||||
bold: Some(true),
|
||||
..Style::default()
|
||||
};
|
||||
for c in ['A', 'i', ' ', '\u{fffd}'] {
|
||||
assert_eq!(metrics.advance(c, &alt), rendered.advance(c, &alt));
|
||||
assert!(metrics.glyph_styled(c, &alt).rect.is_none());
|
||||
}
|
||||
let mut hex = HashMap::new();
|
||||
parse_hex(b"0041:0000000018242442427E424242420000\n", &mut hex);
|
||||
let h = hex[&0x41];
|
||||
let glyph = metrics
|
||||
.rasterize_hex(&mut metrics.cache.borrow_mut().region, h)
|
||||
.unwrap();
|
||||
assert_eq!(glyph.advance, 4.0);
|
||||
assert_eq!(glyph.offset, 0.5);
|
||||
assert!(glyph.rect.is_none());
|
||||
assert!(metrics.take_patches().is_empty());
|
||||
assert!(!rendered.take_patches().is_empty());
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn true_type_glyphs_are_rasterized_at_the_provider_size() {
|
||||
let def = br#"{"providers":[{"type":"ttf","file":"minecraft:mono.ttf","size":11,"oversample":2,"shift":[0.5,1],"skip":["B"]}]}"#;
|
||||
|
||||
@@ -90,6 +90,20 @@ fn occlusion_culling_requested(info: &wgpu::AdapterInfo, env: Option<&str>) -> b
|
||||
env != Some("0") && (env.is_some() || info.device_type != wgpu::DeviceType::Cpu)
|
||||
}
|
||||
|
||||
/// `DrawableGizmoPrimitives.getGroup`: a line whose ARGB alpha is 255 joins the
|
||||
/// opaque group, drawn with `RenderTypes.lines` (depth written); the rest
|
||||
/// with `RenderTypes.linesTranslucentNoDepthWrite`.
|
||||
fn debug_line_opaque(line: &[f32; 8]) -> bool {
|
||||
line[6].to_bits() >> 24 == 0xFF
|
||||
}
|
||||
|
||||
/// The depth-tested gizmo lines in their upload order: the opaque group first,
|
||||
/// then the translucent one, each in submission order.
|
||||
fn debug_lines_opaque_first(lines: &[[f32; 8]]) -> impl Iterator<Item = &[f32; 8]> {
|
||||
let opaque = lines.iter().filter(|l| debug_line_opaque(l));
|
||||
opaque.chain(lines.iter().filter(|l| !debug_line_opaque(l)))
|
||||
}
|
||||
|
||||
/// Whether Translucent Batching is offered (`graphics::offer_translucent_batching`): where
|
||||
/// indirect multi-draws (`multi_draw`) take its batches, but not OpenGL's, which loop over
|
||||
/// their entries on the CPU (and without them every section is a call of its own anyway).
|
||||
@@ -510,7 +524,8 @@ impl SectionGpu {
|
||||
}
|
||||
}
|
||||
/// GPU culling's record of it at `key`, its quads within `bounds`
|
||||
/// (`SectionMesh::bounds`), `FADING` while it fades (`gpu_fading`).
|
||||
/// (`SectionMesh::bounds`) and in their buckets by facing side (`sides`),
|
||||
/// `FADING` while it fades (`gpu_fading`).
|
||||
fn record(&self, key: SectionKey, bounds: Option<[Vec3; 2]>) -> gpu_culling::Record {
|
||||
let boxed = gpu_culling::rounded_box(bounds);
|
||||
gpu_culling::Record::new(
|
||||
@@ -520,6 +535,7 @@ impl SectionGpu {
|
||||
self.solid,
|
||||
boxed,
|
||||
)
|
||||
.facing(&self.sides)
|
||||
.fading(self.gpu_fading)
|
||||
}
|
||||
/// Whether GPU frames' walk lists it within the render distance
|
||||
@@ -1094,8 +1110,10 @@ pub struct Renderer {
|
||||
lines_secondary: wgpu::RenderPipeline,
|
||||
/// A resource pack's `core/rendertype_lines`, which draws the block outline in their place.
|
||||
core_lines: Option<core_lines::CoreLines>,
|
||||
/// Gizmo lines (`RenderTypes.linesTranslucentNoDepthWrite`) and the
|
||||
/// Gizmo lines: opaque ones (`RenderTypes.lines`, which writes depth),
|
||||
/// translucent ones (`RenderTypes.linesTranslucentNoDepthWrite`) and the
|
||||
/// always-on-top ones.
|
||||
debug_lines_opaque: wgpu::RenderPipeline,
|
||||
debug_lines: wgpu::RenderPipeline,
|
||||
debug_lines_on_top: wgpu::RenderPipeline,
|
||||
debug_line_buffer: wgpu::Buffer,
|
||||
@@ -1219,6 +1237,10 @@ pub struct Renderer {
|
||||
/// Whether `lightmap` holds `lightmap_pixels` yet.
|
||||
lightmap_uploaded: bool,
|
||||
sprite_minimum_size: u32,
|
||||
/// Each sprite layer's own side, before the arrays scaled it up (`mipmap::Settings::native_size`).
|
||||
sprite_native: Vec<u32>,
|
||||
/// The Mipmap Levels option (`set_mipmap_levels`), which sets a shader pack atlas's padding.
|
||||
mipmap_option: u32,
|
||||
indices: wgpu::Buffer,
|
||||
index_quads: u32,
|
||||
instances: wgpu::Buffer,
|
||||
@@ -1338,6 +1360,8 @@ pub struct Renderer {
|
||||
/// The marker file of an HDR output that has not presented a frame yet
|
||||
/// (`graphics::HDR_MARKER`).
|
||||
hdr_marker: Option<std::path::PathBuf>,
|
||||
/// The HDR Lighting setting, kept for the HDR output made when HDR turns on.
|
||||
hdr_lighting_setting: bool,
|
||||
/// The shader pack's post-processing, and the dimension (legacy id) it was loaded for.
|
||||
pack_post: Option<(i32, Option<pack_post::PackPost>)>,
|
||||
/// The post passes of one of Horizon's own shader packs; never beside `pack_post`'s.
|
||||
@@ -1427,6 +1451,10 @@ fn requested_split() -> Option<u32> {
|
||||
.and_then(|v| v.parse().ok())
|
||||
.filter(|&n| (1..=SPRITE_SPLIT).contains(&n))
|
||||
}
|
||||
fn adapter_requested_split(info: &wgpu::AdapterInfo, requested: Option<u32>) -> Option<u32> {
|
||||
// Metal can fail to sample large mipmapped array textures despite advertising the layer limit.
|
||||
requested.or_else(|| (info.backend == wgpu::Backend::Metal).then_some(512))
|
||||
}
|
||||
/// The split the CPU mip chains are cut by before the adapter is known.
|
||||
fn sprite_split() -> u32 {
|
||||
requested_split().unwrap_or(SPRITE_SPLIT)
|
||||
@@ -1793,18 +1821,28 @@ impl wgpu::rwh::HasDisplayHandle for WindowDisplay {
|
||||
/// The backends to open a device on, each tried in turn until one gives a
|
||||
/// device. `api` is the player's choice (`GraphicsApi::with_env` applied);
|
||||
/// `os` is `std::env::consts::OS`; `wgpu_backend`: whether `WGPU_BACKEND` is set,
|
||||
/// which picks the backends itself. Auto tries Vulkan, then DirectX 12 on Windows,
|
||||
/// then OpenGL (Windows and Linux) for GPUs with neither driver, each when the one
|
||||
/// before has no surface, adapter or device. A native crash in the driver can't be
|
||||
/// which picks the backends itself. Auto tries DirectX 12 first on Windows ARM64,
|
||||
/// then Vulkan, based on the device-loss observed in a Windows ARM64 test. Other
|
||||
/// Windows systems try Vulkan, then DirectX 12, then OpenGL; Linux tries Vulkan then
|
||||
/// OpenGL. Each is tried when the one before has no surface, adapter or device. A native
|
||||
/// crash in the driver can't be
|
||||
/// caught in the game; the launcher switches the setting to DirectX 12 after one at
|
||||
/// the renderer's start (#746). macOS has Metal only.
|
||||
fn backend_order(api: GraphicsApi, os: &str, wgpu_backend: bool) -> Vec<wgpu::Backends> {
|
||||
fn backend_order(
|
||||
api: GraphicsApi,
|
||||
os: &str,
|
||||
arch: &str,
|
||||
wgpu_backend: bool,
|
||||
) -> Vec<wgpu::Backends> {
|
||||
use wgpu::Backends;
|
||||
if os == "macos" || wgpu_backend {
|
||||
return vec![Backends::default()];
|
||||
}
|
||||
let windows = os == "windows";
|
||||
match api {
|
||||
GraphicsApi::Auto if windows && arch == "aarch64" => {
|
||||
vec![Backends::DX12, Backends::VULKAN, Backends::GL]
|
||||
}
|
||||
GraphicsApi::Auto if windows => vec![Backends::VULKAN, Backends::DX12, Backends::GL],
|
||||
GraphicsApi::Auto => vec![Backends::VULKAN, Backends::GL],
|
||||
GraphicsApi::Dx12 if windows => vec![Backends::DX12],
|
||||
@@ -1862,10 +1900,11 @@ fn describe_adapter(adapter: &wgpu::Adapter) {
|
||||
/// Whether an adapter's texture arrays hold the block textures Horizon puts
|
||||
/// in one array.
|
||||
fn check_layers(adapter: &wgpu::Adapter, layers: u32) -> Result<u32> {
|
||||
let info = adapter.get_info();
|
||||
Ok(layer_split(
|
||||
adapter.limits().max_texture_array_layers,
|
||||
adapter.get_info().backend == wgpu::Backend::Gl,
|
||||
requested_split(),
|
||||
info.backend == wgpu::Backend::Gl,
|
||||
adapter_requested_split(&info, requested_split()),
|
||||
layers,
|
||||
)?)
|
||||
}
|
||||
@@ -1944,7 +1983,12 @@ async fn open_device(
|
||||
) -> Result<Opened> {
|
||||
let wgpu_backend = std::env::var_os("WGPU_BACKEND").is_some();
|
||||
let mut first = None;
|
||||
for (order, allow_cpu) in device_search(api.with_env(), std::env::consts::OS, wgpu_backend) {
|
||||
for (order, allow_cpu) in device_search(
|
||||
api.with_env(),
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH,
|
||||
wgpu_backend,
|
||||
) {
|
||||
match open_device_with(window, offscreen, &order, layers, allow_cpu).await {
|
||||
Ok(opened) => {
|
||||
if let Some(first) = &first {
|
||||
@@ -1976,10 +2020,11 @@ async fn open_device(
|
||||
fn device_search(
|
||||
api: GraphicsApi,
|
||||
os: &str,
|
||||
arch: &str,
|
||||
wgpu_backend: bool,
|
||||
) -> Vec<(Vec<wgpu::Backends>, bool)> {
|
||||
let chosen = backend_order(api, os, wgpu_backend);
|
||||
let others: Vec<_> = backend_order(GraphicsApi::Auto, os, wgpu_backend)
|
||||
let chosen = backend_order(api, os, arch, wgpu_backend);
|
||||
let others: Vec<_> = backend_order(GraphicsApi::Auto, os, arch, wgpu_backend)
|
||||
.into_iter()
|
||||
.filter(|b| !chosen.contains(b))
|
||||
.filter(|&b| !(api == GraphicsApi::Dx12 && b == wgpu::Backends::VULKAN))
|
||||
@@ -2888,6 +2933,19 @@ impl Renderer {
|
||||
true,
|
||||
Some(alpha_blend),
|
||||
);
|
||||
// RenderPipelines.LINES: DepthStencilState.DEFAULT, so an opaque hitbox or
|
||||
// chunk border keeps the water and clouds drawn after it behind it.
|
||||
let debug_lines_opaque = pipeline(
|
||||
"Debug lines opaque",
|
||||
"vs_debug_line",
|
||||
"fs_debug_line",
|
||||
&debug_line_buffers,
|
||||
wgpu::PrimitiveTopology::TriangleStrip,
|
||||
None,
|
||||
true,
|
||||
true,
|
||||
Some(alpha_blend),
|
||||
);
|
||||
let debug_lines = pipeline(
|
||||
"Debug lines",
|
||||
"vs_debug_line",
|
||||
@@ -4450,6 +4508,7 @@ impl Renderer {
|
||||
lines,
|
||||
lines_secondary,
|
||||
core_lines: None,
|
||||
debug_lines_opaque,
|
||||
debug_lines,
|
||||
debug_lines_on_top,
|
||||
debug_line_buffer,
|
||||
@@ -4541,6 +4600,8 @@ impl Renderer {
|
||||
shrink_gate: ShrinkGate::default(),
|
||||
lightmap_uploaded: false,
|
||||
sprite_minimum_size,
|
||||
sprite_native: sprite_mips.iter().map(|s| s.native_size).collect(),
|
||||
mipmap_option: 4,
|
||||
indices,
|
||||
index_quads,
|
||||
instances,
|
||||
@@ -4595,6 +4656,7 @@ impl Renderer {
|
||||
frame_gen: frame_gen::FrameGen::new(),
|
||||
entity_atlas: atlas_view.clone(),
|
||||
hdr: None,
|
||||
hdr_lighting_setting: true,
|
||||
emission_stats,
|
||||
hdr_output,
|
||||
frame_format,
|
||||
@@ -6065,6 +6127,13 @@ impl Renderer {
|
||||
None => "HDR: not offered by this display".into(),
|
||||
}
|
||||
}
|
||||
/// The HDR Lighting setting (`Hdr::look`).
|
||||
pub fn set_hdr_lighting(&mut self, on: bool) {
|
||||
self.hdr_lighting_setting = on;
|
||||
if let Some(hdr) = &mut self.hdr {
|
||||
hdr.look = on;
|
||||
}
|
||||
}
|
||||
/// HDR output (Horizon setting): HDR10 or scRGB at `paper_white` nits, the world's
|
||||
/// highlights expanded up to `peak` nits. Off where the surface can't present it.
|
||||
/// Offscreen (`hdr::offscreen_output`) the frame is drawn the same and its HDR screenshot
|
||||
@@ -6109,6 +6178,9 @@ impl Renderer {
|
||||
let format = self.frame_format;
|
||||
self.hdr =
|
||||
output.map(|output| hdr::Hdr::new(&self.device, &self.queue, format, output));
|
||||
if let Some(hdr) = &mut self.hdr {
|
||||
hdr.look = self.hdr_lighting_setting;
|
||||
}
|
||||
self.configure_surface();
|
||||
match output {
|
||||
Some(output) => log::info!(
|
||||
@@ -6207,13 +6279,14 @@ impl Renderer {
|
||||
/// Runs `post` over the world from now on (`None`: no pack), for the dimension `dimension`;
|
||||
/// `block_ids` gives each block sprite layer's `mc_Entity` (id, fluid) and `at_midBlock.w`
|
||||
/// (its block's light), three values a layer.
|
||||
/// Returns the programs that run and the notes of loading them.
|
||||
/// Returns the programs that run and the notes of loading them, or why the pack can't run:
|
||||
/// the dimension then runs without a pack.
|
||||
pub fn set_shader_pack(
|
||||
&mut self,
|
||||
dimension: i32,
|
||||
pack: Option<horizon_shaders::Post>,
|
||||
block_ids: &[i32],
|
||||
) -> Option<(Vec<String>, Vec<String>)> {
|
||||
) -> std::result::Result<Option<(Vec<String>, Vec<String>)>, pack_post::Refused> {
|
||||
if pack.is_none() {
|
||||
self.pack_atlas = None;
|
||||
self.pack_material_atlases = None;
|
||||
@@ -6231,9 +6304,14 @@ impl Renderer {
|
||||
};
|
||||
let buffers = [Some(vertex), Some(instances)];
|
||||
let flat = self.pack_terrain_flat;
|
||||
let sprite_rects = self
|
||||
.pack_atlas
|
||||
.as_ref()
|
||||
.map_or_else(Vec::new, |a| a.rects_table());
|
||||
let post = pack.map(|post| {
|
||||
let world = pack_post::World {
|
||||
atlas: self.pack_atlas.as_ref().map(|a| (a.view.clone(), a.size())),
|
||||
sprite_rects: &sprite_rects,
|
||||
material_maps: self
|
||||
.pack_material_atlases
|
||||
.as_ref()
|
||||
@@ -6272,6 +6350,15 @@ impl Renderer {
|
||||
world,
|
||||
)
|
||||
});
|
||||
let post = match post.transpose() {
|
||||
Ok(post) => post,
|
||||
Err(e) => {
|
||||
self.pack_post = Some((dimension, None));
|
||||
self.pack_atlas = None;
|
||||
self.pack_material_atlases = None;
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let report = post.as_ref().map(|p| {
|
||||
let programs = p.programs().into_iter().map(str::to_owned).collect();
|
||||
(programs, p.notes.clone())
|
||||
@@ -6280,7 +6367,7 @@ impl Renderer {
|
||||
if self.pack_post.as_ref().is_some_and(|p| p.1.is_some()) {
|
||||
self.native = None;
|
||||
}
|
||||
report
|
||||
Ok(report)
|
||||
}
|
||||
/// Drops the running shader pack, of either kind.
|
||||
pub fn drop_shader_packs(&mut self) {
|
||||
@@ -6375,6 +6462,13 @@ impl Renderer {
|
||||
}
|
||||
|
||||
fn pack_terrain_inputs(&mut self) -> Option<horizon_shaders::GbuffersInputs> {
|
||||
// Flat draws whenever the device has them (`render` keeps them on with a pack).
|
||||
let flat = self.flat.is_some() && self.flat_on;
|
||||
// Flat draws read each sprite's place from a table: the atlas is stitched as the game's.
|
||||
// Other draws need the grid.
|
||||
if !flat && self.pack_atlas.as_ref().is_some_and(|a| a.is_stitched()) {
|
||||
self.pack_atlas = None;
|
||||
}
|
||||
if self.pack_atlas.is_none() {
|
||||
let mut encoder = self.device.create_command_encoder(&Default::default());
|
||||
let layers = self
|
||||
@@ -6382,14 +6476,42 @@ impl Renderer {
|
||||
.iter()
|
||||
.map(|t| t.depth_or_array_layers())
|
||||
.sum();
|
||||
let mut atlas = pack_atlas::PackAtlas::new(
|
||||
&self.device,
|
||||
&mut encoder,
|
||||
&self.sprite_textures,
|
||||
self.sprite_split,
|
||||
layers,
|
||||
self.sprite_textures[0].width(),
|
||||
);
|
||||
let sprite = self.sprite_textures[0].width();
|
||||
// Vanilla's padding counts the anisotropy bit only with anisotropic filtering.
|
||||
let anisotropy_bit = if self.texture_filtering == 2 {
|
||||
u32::from(self.max_anisotropy).trailing_zeros()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let stitched = flat
|
||||
.then(|| {
|
||||
pack_atlas::PackAtlas::stitched(
|
||||
&self.device,
|
||||
&mut encoder,
|
||||
&self.sprite_textures,
|
||||
self.sprite_split,
|
||||
sprite,
|
||||
&self.sprite_native,
|
||||
self.mipmap_option,
|
||||
anisotropy_bit,
|
||||
)
|
||||
})
|
||||
.flatten();
|
||||
if flat && stitched.is_none() {
|
||||
log::warn!(
|
||||
"shader pack block atlas: the sprites don't stitch on this device, a grid instead"
|
||||
);
|
||||
}
|
||||
let mut atlas = stitched.or_else(|| {
|
||||
pack_atlas::PackAtlas::new(
|
||||
&self.device,
|
||||
&mut encoder,
|
||||
&self.sprite_textures,
|
||||
self.sprite_split,
|
||||
layers,
|
||||
sprite,
|
||||
)
|
||||
});
|
||||
if let Some(atlas) = &mut atlas {
|
||||
atlas.separate_ao = self.separate_ao;
|
||||
}
|
||||
@@ -6410,8 +6532,7 @@ impl Renderer {
|
||||
self.pack_atlas = atlas;
|
||||
self.queue.submit([encoder.finish()]);
|
||||
}
|
||||
// Flat draws whenever the device has them (`render` keeps them on with a pack).
|
||||
self.pack_terrain_flat = self.flat.is_some() && self.flat_on;
|
||||
self.pack_terrain_flat = flat;
|
||||
Some(
|
||||
self.pack_atlas
|
||||
.as_ref()?
|
||||
@@ -6524,6 +6645,7 @@ impl Renderer {
|
||||
/// The Mipmap Levels option: block textures sample at most `levels`
|
||||
/// reduced copies (0: none).
|
||||
pub fn set_mipmap_levels(&mut self, levels: u32) {
|
||||
self.mipmap_option = levels;
|
||||
let levels = crate::sprites::sprite_mipmap_limit(
|
||||
levels,
|
||||
self.sprite_textures[0].width(),
|
||||
@@ -6677,12 +6799,18 @@ impl Renderer {
|
||||
if let Some(atlases) = &self.pack_material_atlases {
|
||||
for kind in 0..2 {
|
||||
let arrays: Vec<_> = arrays.iter().map(|pair| pair[kind].clone()).collect();
|
||||
atlases[kind].copy(encoder, &arrays, self.animations.changed().iter().copied());
|
||||
atlases[kind].copy(
|
||||
&self.device,
|
||||
encoder,
|
||||
&arrays,
|
||||
self.animations.changed().iter().copied(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(atlas) = &self.pack_atlas {
|
||||
atlas.copy(
|
||||
&self.device,
|
||||
encoder,
|
||||
&self.sprite_textures,
|
||||
self.animations.changed().iter().copied(),
|
||||
@@ -7321,6 +7449,7 @@ impl Renderer {
|
||||
radius,
|
||||
frustum: &view,
|
||||
reach: self.occlusion.reach().filter(|_| culling),
|
||||
eye,
|
||||
};
|
||||
let prepared = crate::spans::time("gpu culling", || {
|
||||
gpu.prepare(
|
||||
@@ -7332,7 +7461,7 @@ impl Renderer {
|
||||
)
|
||||
});
|
||||
// A frame whose planes leave the test's integers (the camera millions of blocks
|
||||
// above or below the level) is the CPU's.
|
||||
// above or below the level), or whose eye is not a number, is the CPU's.
|
||||
gpu_frame = prepared == gpu_culling::Prepared::Gpu;
|
||||
if prepared == gpu_culling::Prepared::OutOfMemory {
|
||||
log::warn!("Horizon renderer: GPU culling off: its buffers do not fit");
|
||||
@@ -7854,14 +7983,14 @@ impl Renderer {
|
||||
self.translucency_cursor += budget;
|
||||
let translucent_draws = self.draws.len() as u32 - solid_draws;
|
||||
self.section_draws = self.draws.len();
|
||||
// The shadow camera's sections after the camera's: quads that can face the light, then
|
||||
// translucent ones (in their last sorted order; depth decides there).
|
||||
// The shadow camera's sections after the camera's: quads that can face the light (all of
|
||||
// them for `every_face`), then translucent ones (in their last sorted order; depth
|
||||
// decides there).
|
||||
let shadow_first = self.draws.len() as u32;
|
||||
let mut shadow_translucent = Vec::new();
|
||||
self.shadow_runs.clear();
|
||||
if let Some(view) = &shadow_view {
|
||||
let base = self.drawn_sections;
|
||||
let toward = view.light * 1.0e5;
|
||||
// How far below the nearest section to the light (squared, as bits, as `PageRuns`
|
||||
// takes distances): each band's draws go by page, a call per page and band.
|
||||
let height = |v: &resident::Visible| glam::Vec3::from(v.origin).dot(view.light);
|
||||
@@ -7893,13 +8022,7 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
let before = self.draws.len();
|
||||
// Every face of a section within the unculled distance.
|
||||
let unculled = view.unculled.is_some_and(|d| {
|
||||
let o = glam::Vec3::from(v.origin) + 8.0;
|
||||
o.x.abs().max(o.z.abs()) <= d + 8.0
|
||||
});
|
||||
let toward = (!unculled).then_some(toward);
|
||||
facing_runs(&s.sides, toward, v.origin, |first, quads| {
|
||||
shadow_runs(&s.sides, view, v.origin, |first, quads| {
|
||||
let start = s.solid_base() + first * 4;
|
||||
self.draws.push([quads * 6, 1, 0, start, instance]);
|
||||
self.draw_pages.push((s.page, None));
|
||||
@@ -7958,6 +8081,11 @@ impl Renderer {
|
||||
self.section_draw_calls += gpu.draw_calls();
|
||||
}
|
||||
let debug_depth = frame.debug_lines.len() as u32;
|
||||
let debug_opaque = frame
|
||||
.debug_lines
|
||||
.iter()
|
||||
.filter(|l| debug_line_opaque(l))
|
||||
.count() as u32;
|
||||
let debug_top = frame.debug_lines_on_top.len() as u32;
|
||||
let crosshair = frame.debug_crosshair.len() as u32;
|
||||
if debug_depth + debug_top + crosshair > 0 {
|
||||
@@ -7971,9 +8099,7 @@ impl Renderer {
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
}
|
||||
let all: Vec<[f32; 8]> = frame
|
||||
.debug_lines
|
||||
.iter()
|
||||
let all: Vec<[f32; 8]> = debug_lines_opaque_first(frame.debug_lines)
|
||||
.chain(frame.debug_lines_on_top)
|
||||
.chain(frame.debug_crosshair)
|
||||
.copied()
|
||||
@@ -8071,7 +8197,7 @@ impl Renderer {
|
||||
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
|
||||
&& std::env::var_os("HORIZON_HDR_LIGHTING").is_none_or(|v| v != "0");
|
||||
if let Some(hdr) = &mut self.hdr {
|
||||
hdr.lighting = hdr_lighting;
|
||||
hdr.lighting = hdr_lighting && hdr.look;
|
||||
}
|
||||
let oit_on = self.improved_transparency
|
||||
&& !frame.title
|
||||
@@ -9549,10 +9675,16 @@ impl Renderer {
|
||||
if outline_edges > 0 && !frame.outline_after_terrain && !pack_draws_outline {
|
||||
self.draw_outline(&mut pass, outline_edges as u32, frame.outline_high_contrast);
|
||||
}
|
||||
if debug_depth > 0 {
|
||||
// DrawableGizmoPrimitives' opaque group writes depth, the translucent one does not.
|
||||
if debug_opaque > 0 {
|
||||
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.set_pipeline(&self.debug_lines);
|
||||
pass.set_vertex_buffer(0, self.debug_line_buffer.slice(..));
|
||||
pass.draw(0..4, 0..debug_depth);
|
||||
pass.draw(0..4, debug_opaque..debug_depth);
|
||||
}
|
||||
if debug_top > 0 {
|
||||
pass.set_pipeline(&self.debug_lines_on_top);
|
||||
@@ -11086,6 +11218,22 @@ fn projection_center(view_proj: Mat4) -> Option<Vec3> {
|
||||
let b = DVec3::new(rows[0].w, rows[1].w, rows[2].w);
|
||||
Some((a.inverse() * -b).as_vec3())
|
||||
}
|
||||
/// The runs of a section's solid quads the shadow pass draws: every face within the unculled
|
||||
/// distance, or everywhere for `every_face`; else the quads that can face the light.
|
||||
fn shadow_runs(
|
||||
sides: &Sides,
|
||||
view: &pack_post::ShadowView,
|
||||
origin: [f32; 3],
|
||||
draw: impl FnMut(u32, u32),
|
||||
) {
|
||||
let unculled = view.unculled.is_some_and(|d| {
|
||||
let o = Vec3::from(origin) + 8.0;
|
||||
o.x.abs().max(o.z.abs()) <= d + 8.0
|
||||
});
|
||||
let toward = (!unculled && !view.every_face).then_some(view.light * 1.0e5);
|
||||
facing_runs(sides, toward, origin, draw);
|
||||
}
|
||||
|
||||
/// Calls `draw(first, quads)` for each run of a mesh's solid quads that can
|
||||
/// face `eye` (`projection_center`), the mesh at the camera-relative
|
||||
/// `origin`; all of them when there is no eye.
|
||||
@@ -11126,6 +11274,20 @@ fn frustum(m: Mat4) -> [Vec4; 6] {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn opaque_gizmo_lines_upload_first_for_the_depth_writing_pipeline() {
|
||||
let line = |argb: u32| [0.0, 0.0, 0.0, 1.0, 0.0, 0.0, f32::from_bits(argb), 2.5];
|
||||
// A hitbox's white, the chunk border's outer red columns (alpha 0x7F),
|
||||
// and the chunk border's yellow.
|
||||
let lines = [line(0xFFFF_FFFF), line(0x7FFF_0000), line(0xFFFF_FF00)];
|
||||
assert!(super::debug_line_opaque(&lines[0]));
|
||||
assert!(!super::debug_line_opaque(&lines[1]));
|
||||
assert!(!super::debug_line_opaque(&line(0xFE00_0000)));
|
||||
let order: Vec<u32> = super::debug_lines_opaque_first(&lines)
|
||||
.map(|l| l[6].to_bits())
|
||||
.collect();
|
||||
assert_eq!(order, [0xFFFF_FFFF, 0xFFFF_FF00, 0x7FFF_0000]);
|
||||
}
|
||||
use crate::gfx::static_entity_shader;
|
||||
|
||||
fn adapter(device_type: wgpu::DeviceType) -> wgpu::AdapterInfo {
|
||||
@@ -11175,12 +11337,16 @@ mod tests {
|
||||
/// shaders with storage buffers enough, where Translucent Batching is (indirect
|
||||
/// multi-draws that aren't OpenGL's) and vanilla frames are never drawn flat, and never
|
||||
/// on Intel Gen11/Gen12's Vulkan driver, whose draws read instance-rate origins from the
|
||||
/// wrong first instance, even with flat draws off there.
|
||||
/// wrong first instance, even with flat draws off there, nor on DX12 with flat page
|
||||
/// groups, whose count draws leave the base vertex out of the pulled pipelines'
|
||||
/// `vertex_index`.
|
||||
#[test]
|
||||
fn gpu_culling_is_offered_where_counts_are_drawn_and_vanilla_never_draws_flat() {
|
||||
let vulkan = adapter(wgpu::DeviceType::DiscreteGpu);
|
||||
let mut gl = adapter(wgpu::DeviceType::DiscreteGpu);
|
||||
gl.backend = wgpu::Backend::Gl;
|
||||
let mut dx12 = adapter(wgpu::DeviceType::DiscreteGpu);
|
||||
dx12.backend = wgpu::Backend::Dx12;
|
||||
let mut intel = adapter(wgpu::DeviceType::IntegratedGpu);
|
||||
(intel.vendor, intel.device) = (0x8086, 0x9a49); // Tiger Lake
|
||||
let counts = wgpu::Features::MULTI_DRAW_INDIRECT_COUNT;
|
||||
@@ -11214,6 +11380,8 @@ mod tests {
|
||||
assert!(!offered(&vulkan, counts, compute, 8, false, None));
|
||||
assert!(!offered(&gl, counts, compute, 8, true, None));
|
||||
assert!(!offered(&intel, counts, compute, 8, true, None));
|
||||
assert!(offered(&dx12, counts, compute, 8, true, None));
|
||||
assert!(!offered(&dx12, counts, compute, 8, true, Some(usize::MAX)));
|
||||
}
|
||||
|
||||
/// The pack-flat rule: with Translucent Batching translucent quads have an arena of
|
||||
@@ -11538,27 +11706,49 @@ mod tests {
|
||||
assert!(!is_software(wgpu::DeviceType::VirtualGpu));
|
||||
}
|
||||
#[test]
|
||||
fn auto_tries_vulkan_then_directx_12_then_opengl() {
|
||||
fn auto_uses_directx_12_first_on_windows_arm64() {
|
||||
use super::{GraphicsApi, backend_order};
|
||||
use wgpu::Backends;
|
||||
let order = |api, os| backend_order(api, os, false);
|
||||
let order = |api, os, arch| backend_order(api, os, arch, false);
|
||||
assert_eq!(
|
||||
order(GraphicsApi::Auto, "windows"),
|
||||
order(GraphicsApi::Auto, "windows", "x86_64"),
|
||||
[Backends::VULKAN, Backends::DX12, Backends::GL]
|
||||
);
|
||||
assert_eq!(order(GraphicsApi::Vulkan, "windows"), [Backends::VULKAN]);
|
||||
assert_eq!(order(GraphicsApi::Dx12, "windows"), [Backends::DX12]);
|
||||
assert_eq!(order(GraphicsApi::OpenGl, "windows"), [Backends::GL]);
|
||||
assert_eq!(
|
||||
order(GraphicsApi::Auto, "linux"),
|
||||
order(GraphicsApi::Auto, "windows", "aarch64"),
|
||||
[Backends::DX12, Backends::VULKAN, Backends::GL]
|
||||
);
|
||||
assert_eq!(
|
||||
order(GraphicsApi::Vulkan, "windows", "aarch64"),
|
||||
[Backends::VULKAN]
|
||||
);
|
||||
assert_eq!(
|
||||
order(GraphicsApi::Dx12, "windows", "aarch64"),
|
||||
[Backends::DX12]
|
||||
);
|
||||
assert_eq!(
|
||||
order(GraphicsApi::OpenGl, "windows", "aarch64"),
|
||||
[Backends::GL]
|
||||
);
|
||||
assert_eq!(
|
||||
order(GraphicsApi::Auto, "linux", "x86_64"),
|
||||
[Backends::VULKAN, Backends::GL]
|
||||
);
|
||||
assert_eq!(order(GraphicsApi::Dx12, "linux"), [Backends::VULKAN]);
|
||||
assert_eq!(order(GraphicsApi::OpenGl, "linux"), [Backends::GL]);
|
||||
assert_eq!(
|
||||
order(GraphicsApi::Dx12, "linux", "x86_64"),
|
||||
[Backends::VULKAN]
|
||||
);
|
||||
assert_eq!(
|
||||
order(GraphicsApi::OpenGl, "linux", "x86_64"),
|
||||
[Backends::GL]
|
||||
);
|
||||
// WGPU_BACKEND picks the backends itself; macOS has Metal only.
|
||||
for api in GraphicsApi::ALL {
|
||||
assert_eq!(backend_order(api, "windows", true), [Backends::default()]);
|
||||
assert_eq!(order(api, "macos"), [Backends::default()]);
|
||||
assert_eq!(
|
||||
backend_order(api, "windows", "aarch64", true),
|
||||
[Backends::default()]
|
||||
);
|
||||
assert_eq!(order(api, "macos", "aarch64"), [Backends::default()]);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
@@ -11566,28 +11756,33 @@ mod tests {
|
||||
use super::{GraphicsApi, device_search};
|
||||
use wgpu::Backends;
|
||||
let (v, d, g) = (Backends::VULKAN, Backends::DX12, Backends::GL);
|
||||
let search = |api, os, arch| device_search(api, os, arch, false);
|
||||
// #5185: DirectX 12 chosen on an Intel HD 2500 (WARP only): OpenGL's GPU before WARP.
|
||||
assert_eq!(
|
||||
device_search(GraphicsApi::Dx12, "windows", false),
|
||||
search(GraphicsApi::Dx12, "windows", "x86_64"),
|
||||
[(vec![d], false), (vec![g], false), (vec![d], true)]
|
||||
);
|
||||
assert_eq!(
|
||||
device_search(GraphicsApi::Vulkan, "windows", false),
|
||||
search(GraphicsApi::Vulkan, "windows", "x86_64"),
|
||||
[(vec![v], false), (vec![d, g], false), (vec![v], true)]
|
||||
);
|
||||
assert_eq!(
|
||||
device_search(GraphicsApi::Auto, "windows", false),
|
||||
search(GraphicsApi::Auto, "windows", "x86_64"),
|
||||
[(vec![v, d, g], false), (vec![v, d, g], true)]
|
||||
);
|
||||
assert_eq!(
|
||||
device_search(GraphicsApi::OpenGl, "linux", false),
|
||||
search(GraphicsApi::Auto, "windows", "aarch64"),
|
||||
[(vec![d, v, g], false), (vec![d, v, g], true)]
|
||||
);
|
||||
assert_eq!(
|
||||
search(GraphicsApi::OpenGl, "linux", "x86_64"),
|
||||
[(vec![g], false), (vec![v], false), (vec![g], true)]
|
||||
);
|
||||
// WGPU_BACKEND and macOS: one set of backends, nothing else to try.
|
||||
let all = Backends::default();
|
||||
for (os, env) in [("windows", true), ("macos", false)] {
|
||||
assert_eq!(
|
||||
device_search(GraphicsApi::Vulkan, os, env),
|
||||
device_search(GraphicsApi::Vulkan, os, "x86_64", env),
|
||||
[(vec![all], false), (vec![all], true)]
|
||||
);
|
||||
}
|
||||
@@ -12429,6 +12624,30 @@ mod sprite_upload_tests {
|
||||
assert!(layer_split(512, false, None, 512 * SPRITE_ARRAYS + 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metal_uses_a_conservative_default_split_but_respects_overrides() {
|
||||
let mut info = wgpu::AdapterInfo {
|
||||
name: "test adapter".into(),
|
||||
vendor: 0,
|
||||
device: 0,
|
||||
device_type: wgpu::DeviceType::IntegratedGpu,
|
||||
device_pci_bus_id: String::new(),
|
||||
driver: String::new(),
|
||||
driver_info: String::new(),
|
||||
backend: wgpu::Backend::Vulkan,
|
||||
subgroup_min_size: 0,
|
||||
subgroup_max_size: 0,
|
||||
transient_saves_memory: None,
|
||||
limit_bucket: None,
|
||||
};
|
||||
info.backend = wgpu::Backend::Metal;
|
||||
assert_eq!(adapter_requested_split(&info, None), Some(512));
|
||||
assert_eq!(adapter_requested_split(&info, Some(1024)), Some(1024));
|
||||
|
||||
info.backend = wgpu::Backend::Vulkan;
|
||||
assert_eq!(adapter_requested_split(&info, None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mip_chain_allocates_exactly_its_size() {
|
||||
for size in [16, 64] {
|
||||
@@ -12626,3 +12845,62 @@ fn reported(l: horizon_hdr::wayland::Luminances) -> crate::graphics::Reported {
|
||||
black: hundredths_of_a_nit(l.target_min * 203.0 / l.reference.max(1.0)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod shadow_face_tests {
|
||||
use super::*;
|
||||
|
||||
/// The faces of one block at `at` (section-relative), each with its own layer:
|
||||
/// 0 +X, 1 -X, 2 +Y (up), 3 -Y (down), 4 +Z, 5 -Z; counter-clockwise from outside.
|
||||
fn block(at: [f32; 3]) -> Vec<TerrainVertex> {
|
||||
let [x, y, z] = at;
|
||||
let (a, b, c) = (x + 1.0, y + 1.0, z + 1.0);
|
||||
[
|
||||
[[a, y, z], [a, b, z], [a, b, c], [a, y, c]],
|
||||
[[x, y, z], [x, y, c], [x, b, c], [x, b, z]],
|
||||
[[x, b, z], [x, b, c], [a, b, c], [a, b, z]],
|
||||
[[x, y, z], [a, y, z], [a, y, c], [x, y, c]],
|
||||
[[x, y, c], [a, y, c], [a, b, c], [x, b, c]],
|
||||
[[x, y, z], [x, b, z], [a, b, z], [a, y, z]],
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.flat_map(|(layer, q)| q.map(|p| TerrainVertex::new(p, [0.0; 2], layer as u16, 0, 0)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The layers (faces) of the quads the shadow pass draws of `solid`.
|
||||
fn drawn(view: &pack_post::ShadowView, origin: [f32; 3]) -> Vec<u16> {
|
||||
let mut solid = block([7.0, 7.0, 7.0]);
|
||||
let sides = crate::mesher::sort_tinted(&mut solid, &mut Vec::new());
|
||||
let mut layers = Vec::new();
|
||||
shadow_runs(&sides, view, origin, |first, quads| {
|
||||
for q in first..first + quads {
|
||||
layers.push(solid[q as usize * 4].layer);
|
||||
}
|
||||
});
|
||||
layers.sort_unstable();
|
||||
layers
|
||||
}
|
||||
|
||||
fn view(every_face: bool) -> pack_post::ShadowView {
|
||||
pack_post::ShadowView {
|
||||
cull: Mat4::IDENTITY,
|
||||
light: Vec3::Y,
|
||||
distance: None,
|
||||
unculled: None,
|
||||
every_face,
|
||||
}
|
||||
}
|
||||
|
||||
/// Iris 1.11.7's shadow map holds faces turned away from the light (#9033): the block's
|
||||
/// bottom face, under a sun straight above, is drawn too.
|
||||
#[test]
|
||||
fn the_shadow_pass_draws_faces_turned_away_from_the_light() {
|
||||
// A section 32 blocks under the camera.
|
||||
let origin = [-8.0, -40.0, -8.0];
|
||||
assert_eq!(drawn(&view(true), origin), [0, 1, 2, 3, 4, 5]);
|
||||
// Horizon's own packs keep drawing only what can face the light: no bottom face.
|
||||
assert!(!drawn(&view(false), origin).contains(&3));
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,7 @@ struct Pipelines {
|
||||
luma_pyramid: wgpu::ComputePipeline,
|
||||
scd_histogram: wgpu::ComputePipeline,
|
||||
scd_divergence: wgpu::ComputePipeline,
|
||||
scd_finalize: wgpu::ComputePipeline,
|
||||
search: wgpu::ComputePipeline,
|
||||
filter: wgpu::ComputePipeline,
|
||||
scale: wgpu::ComputePipeline,
|
||||
@@ -84,6 +85,7 @@ pub struct OpticalFlow {
|
||||
frames: [Frame; 2],
|
||||
filter: [wgpu::BindGroup; LEVELS],
|
||||
scd_divergence: wgpu::BindGroup,
|
||||
scd_finalize: wgpu::BindGroup,
|
||||
scd_histogram: wgpu::Buffer,
|
||||
scd_previous_histogram: wgpu::Buffer,
|
||||
scd_temp: wgpu::Buffer,
|
||||
@@ -183,6 +185,7 @@ impl OpticalFlow {
|
||||
luma_pyramid: pipeline("luma_pyramid"),
|
||||
scd_histogram: pipeline("scd_histogram_pass"),
|
||||
scd_divergence: pipeline("scd_divergence_pass"),
|
||||
scd_finalize: pipeline("scd_finalize_pass"),
|
||||
search: pipeline("search_pass"),
|
||||
filter: pipeline("filter_flow"),
|
||||
scale: pipeline("scale_pass"),
|
||||
@@ -235,6 +238,7 @@ impl OpticalFlow {
|
||||
HISTOGRAM_WIDTH * 4,
|
||||
);
|
||||
let scd_temp = buffer(device, "Scene change temp", 12);
|
||||
let scd_next_histogram = buffer(device, "Next scene change histogram", HISTOGRAM_WIDTH * 4);
|
||||
let scd_output = buffer(device, "Scene change detection", 12);
|
||||
let frame = |current: usize| {
|
||||
let (cur, prev) = (&luma[current], &luma[1 - current]);
|
||||
@@ -298,11 +302,22 @@ impl OpticalFlow {
|
||||
let scd_divergence = bind(
|
||||
device,
|
||||
&pipelines.scd_divergence,
|
||||
&[
|
||||
(7, whole(&scd_histogram)),
|
||||
(8, whole(&scd_previous_histogram)),
|
||||
(9, whole(&scd_temp)),
|
||||
(11, whole(&scd_next_histogram)),
|
||||
],
|
||||
);
|
||||
let scd_finalize = bind(
|
||||
device,
|
||||
&pipelines.scd_finalize,
|
||||
&[
|
||||
(7, whole(&scd_histogram)),
|
||||
(8, whole(&scd_previous_histogram)),
|
||||
(9, whole(&scd_temp)),
|
||||
(10, whole(&scd_output)),
|
||||
(11, whole(&scd_next_histogram)),
|
||||
],
|
||||
);
|
||||
Self {
|
||||
@@ -312,6 +327,7 @@ impl OpticalFlow {
|
||||
frames,
|
||||
filter,
|
||||
scd_divergence,
|
||||
scd_finalize,
|
||||
scd_histogram,
|
||||
scd_previous_histogram,
|
||||
scd_temp,
|
||||
@@ -406,6 +422,7 @@ impl OpticalFlow {
|
||||
9,
|
||||
);
|
||||
run(&p.scd_divergence, &self.scd_divergence, 9, 3, 1);
|
||||
run(&p.scd_finalize, &self.scd_finalize, 9, 1, 1);
|
||||
for level in (0..LEVELS).rev() {
|
||||
let luma = self.display.map(|v| (v >> level).max(1));
|
||||
run(
|
||||
@@ -597,6 +614,22 @@ pub(super) mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn scd(device: &wgpu::Device, queue: &wgpu::Queue, flow: &OpticalFlow) -> u32 {
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 12,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_buffer_to_buffer(flow.scene_change(), 0, &buffer, 0, 12);
|
||||
queue.submit([encoder.finish()]);
|
||||
buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let bytes = buffer.slice(..).get_mapped_range().unwrap().to_vec();
|
||||
u32::from_le_bytes(bytes[4..8].try_into().unwrap())
|
||||
}
|
||||
|
||||
/// A frame moved right by 16 and down by 8: the blocks inside report where they were
|
||||
/// (-16, -8), and nothing reads as a scene change. A much darker frame then does.
|
||||
#[test]
|
||||
@@ -630,24 +663,40 @@ pub(super) mod tests {
|
||||
inside.len(),
|
||||
&inside[..8]
|
||||
);
|
||||
let scd = |device: &wgpu::Device, queue: &wgpu::Queue, flow: &OpticalFlow| {
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 12,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_buffer_to_buffer(flow.scene_change(), 0, &buffer, 0, 12);
|
||||
queue.submit([encoder.finish()]);
|
||||
buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let bytes = buffer.slice(..).get_mapped_range().unwrap().to_vec();
|
||||
u32::from_le_bytes(bytes[4..8].try_into().unwrap())
|
||||
};
|
||||
assert_eq!(scd(&device, &queue, &flow) & 0xf, 0);
|
||||
run(&device, &queue, &mut flow, &other, false);
|
||||
assert_eq!(scd(&device, &queue, &flow) & 1, 1);
|
||||
assert!(vectors(&device, &queue, &flow).iter().all(|v| *v == [0, 0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_cuts_and_stable_frames_update_the_history() {
|
||||
let Some((device, queue)) = device() else {
|
||||
return;
|
||||
};
|
||||
let size = [384, 216];
|
||||
let bright = upload(
|
||||
&device,
|
||||
&queue,
|
||||
size,
|
||||
&vec![255; (size[0] * size[1] * 4) as usize],
|
||||
);
|
||||
let dark_pixels: Vec<u8> = (0..size[0] * size[1])
|
||||
.flat_map(|_| [0, 0, 0, 255])
|
||||
.collect();
|
||||
let dark = upload(&device, &queue, size, &dark_pixels);
|
||||
let mut flow = OpticalFlow::new(&device, size);
|
||||
run(&device, &queue, &mut flow, &bright, true);
|
||||
for _ in 0..6 {
|
||||
run(&device, &queue, &mut flow, &bright, false);
|
||||
}
|
||||
for frame in 0..16 {
|
||||
let color = if frame % 2 == 0 { &dark } else { &bright };
|
||||
run(&device, &queue, &mut flow, color, false);
|
||||
assert_eq!(scd(&device, &queue, &flow) & 1, 1, "cut {frame}");
|
||||
assert!(vectors(&device, &queue, &flow).iter().all(|v| *v == [0, 0]));
|
||||
run(&device, &queue, &mut flow, color, false);
|
||||
assert_eq!(scd(&device, &queue, &flow) & 1, 0, "stable frame {frame}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,10 +53,10 @@ struct Params {
|
||||
@group(0) @binding(8) var<storage, read_write> scd_previous_histogram: array<f32>;
|
||||
@group(0) @binding(9) var<storage, read_write> scd_temp: array<atomic<u32>, 3>;
|
||||
@group(0) @binding(10) var<storage, read_write> scd_output: array<atomic<u32>, 3>;
|
||||
@group(0) @binding(11) var<storage, read_write> scd_next_histogram: array<f32>;
|
||||
|
||||
const SCD_OUTPUT_SCENE_CHANGE_SLOT: u32 = 0u;
|
||||
const SCD_OUTPUT_HISTORY_BITS_SLOT: u32 = 1u;
|
||||
const SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT: u32 = 2u;
|
||||
|
||||
fn load_luma(t: texture_2d<f32>, p: vec2<i32>) -> u32 {
|
||||
let size = vec2<i32>(textureDimensions(t));
|
||||
@@ -266,7 +266,6 @@ fn scd_divergence_pass(
|
||||
@builtin(local_invocation_index) local_index: u32,
|
||||
) {
|
||||
let factor = 1000000.0;
|
||||
let where_to_stop = 3u * 9u - 1u;
|
||||
let histogram_count = 9.0;
|
||||
let kernel = array(0.0088122291, 0.027143577, 0.065114059, 0.12164907, 0.17699835, 0.20056541);
|
||||
let li = i32(local_index);
|
||||
@@ -326,27 +325,32 @@ fn scd_divergence_pass(
|
||||
let res_float = 1.0 - exp(-(abs(sum.x) + abs(sum.y)));
|
||||
let res_uint = u32((res_float / histogram_count) * factor);
|
||||
atomicAdd(&scd_temp[gid.y], res_uint);
|
||||
let finished = atomicAdd(&scd_output[SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT], 1u);
|
||||
if finished == where_to_stop {
|
||||
let res0 = atomicLoad(&scd_temp[0]);
|
||||
let res1 = atomicLoad(&scd_temp[1]);
|
||||
let res2 = atomicLoad(&scd_temp[2]);
|
||||
let scene_change = f32(min(res0, min(res1, res2))) / factor;
|
||||
var history = atomicLoad(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT]) << 1u;
|
||||
if scene_change > 0.45 {
|
||||
history |= 1u;
|
||||
}
|
||||
atomicStore(&scd_output[SCD_OUTPUT_SCENE_CHANGE_SLOT], bitcast<u32>(scene_change));
|
||||
atomicStore(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT], history);
|
||||
atomicStore(&scd_output[SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT], 0u);
|
||||
atomicStore(&scd_temp[0], 0u);
|
||||
atomicStore(&scd_temp[1], 0u);
|
||||
atomicStore(&scd_temp[2], 0u);
|
||||
}
|
||||
}
|
||||
if gid.y == 1u {
|
||||
scd_previous_histogram[gid.x] = current;
|
||||
atomicStore(&scd_histogram[gid.x], 0u);
|
||||
scd_next_histogram[gid.x] = current;
|
||||
}
|
||||
}
|
||||
|
||||
// All divergence workgroups must finish reading both histograms before either is
|
||||
// replaced. A separate dispatch also makes every partial sum visible to the reducer.
|
||||
@compute @workgroup_size(256)
|
||||
fn scd_finalize_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
scd_previous_histogram[gid.x] = scd_next_histogram[gid.x];
|
||||
atomicStore(&scd_histogram[gid.x], 0u);
|
||||
if gid.x == 0u {
|
||||
let res0 = atomicLoad(&scd_temp[0]);
|
||||
let res1 = atomicLoad(&scd_temp[1]);
|
||||
let res2 = atomicLoad(&scd_temp[2]);
|
||||
let scene_change = f32(min(res0, min(res1, res2))) / 1000000.0;
|
||||
var history = atomicLoad(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT]) << 1u;
|
||||
if scene_change > 0.45 {
|
||||
history |= 1u;
|
||||
}
|
||||
atomicStore(&scd_output[SCD_OUTPUT_SCENE_CHANGE_SLOT], bitcast<u32>(scene_change));
|
||||
atomicStore(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT], history);
|
||||
atomicStore(&scd_temp[0], 0u);
|
||||
atomicStore(&scd_temp[1], 0u);
|
||||
atomicStore(&scd_temp[2], 0u);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,15 +1,18 @@
|
||||
// GPU culling (gfx/gpu_culling.rs). `apply` writes the records that changed.
|
||||
// `cull`: one invocation per section id keeps the sections whose column is
|
||||
// within the render distance, that cave culling's graph reaches (occlusion.rs)
|
||||
// and whose box is in the frustum, writes each one's origin and counts it in
|
||||
// its page, per block of 64 ids. `scan`: one workgroup per page turns the
|
||||
// blocks' counts into where each block's draws start, and the page's count.
|
||||
// `scatter`: each block writes its sections' draws there, in id order, so a
|
||||
// page's draws come in the same order every frame (atomics alone would order
|
||||
// them as the GPU schedules, and where faces of two sections meet at the same
|
||||
// depth the later draw keeps the pixel). Nothing here computes with floats: the
|
||||
// origins are the CPU's bits, copied, and the frustum test adds integers the
|
||||
// CPU computed. `cull` leaves sections fading in or out to the CPU (`FADING`).
|
||||
// and whose box is in the frustum, writes each one's origin and the runs of its
|
||||
// solid quads that can face the eye, a draw each, and counts them in its page,
|
||||
// per block of 64 ids. `scan`: one workgroup per page turns the blocks' counts
|
||||
// into where each block's draws start, and the page's count. `scatter`: each
|
||||
// block writes its sections' draws there, in id order and each section's in its
|
||||
// runs' order, so a page's draws come in the same order every frame (atomics
|
||||
// alone would order them as the GPU schedules, and where faces of two sections
|
||||
// meet at the same depth the later draw keeps the pixel). Nothing here computes
|
||||
// with floats: the origins are the CPU's bits, copied, the frustum test adds
|
||||
// integers the CPU computed, and facing compares keys that order as the floats
|
||||
// the CPU compares. `cull` leaves sections fading in or out to the CPU
|
||||
// (`FADING`).
|
||||
|
||||
struct Params {
|
||||
// Ids to test, records `apply` writes, the arena pages the counts and
|
||||
@@ -31,12 +34,13 @@ struct Params {
|
||||
// Each frustum plane's w term, its margin in, then 0, 0.
|
||||
w: array<vec4<i32>, 2>,
|
||||
// Cave culling's graph (occlusion.rs `Reach`): from x, from z, radius, lowest
|
||||
// section y; its height in sections and the level's; REACH and SKY; 0.
|
||||
// section y; its height in sections and the level's; REACH, SKY and EYE;
|
||||
// where the eye's keys start in `tables`.
|
||||
reach: vec4<i32>,
|
||||
reach_height: i32,
|
||||
reach_sections: i32,
|
||||
flags: u32,
|
||||
pad: u32,
|
||||
eyes: u32,
|
||||
}
|
||||
|
||||
// gpu_culling.rs `Record`.
|
||||
@@ -44,9 +48,16 @@ struct Section {
|
||||
// Section x, y (from the level's bottom), z, and its arena page (-1: a
|
||||
// free id).
|
||||
key: vec4<i32>,
|
||||
// The first vertex of its solid quads, its solid quads, its box
|
||||
// (gpu_culling.rs `rounded_box`), then its flags (`FADING`).
|
||||
// The first vertex of its solid quads, its solid quads (where its last
|
||||
// bucket by facing side ends), its box (gpu_culling.rs `rounded_box`), then
|
||||
// its flags (`FADING`).
|
||||
a: vec4<u32>,
|
||||
// Where its first six buckets end (mesher/sides.rs `BUCKETS`): +X, +Z, the
|
||||
// other bucket, +Y, -Z, -X.
|
||||
ends: array<u32, 6>,
|
||||
// What its buckets facing +X, +Z, +Y, -Z, -X and -Y compare the eye's keys
|
||||
// with (gpu_culling.rs `Record::facing`).
|
||||
keys: array<u32, 6>,
|
||||
}
|
||||
|
||||
// gpu_culling.rs `Update`: the record for id `id.x`.
|
||||
@@ -59,8 +70,9 @@ struct Update {
|
||||
@group(0) @binding(1) var<storage, read_write> sections: array<Section>;
|
||||
// gpu_culling.rs `tables`: the origins' f32 bits along x, y and z (n of them);
|
||||
// per frustum plane its terms for those origins, n each; per plane and axis its
|
||||
// terms for the 32 box codes; then each page's region of `draws` (its first
|
||||
// draw and its size).
|
||||
// terms for the 32 box codes; with an eye (`EYE`), the keys of the eye less
|
||||
// each origin, n of them; then each page's region of `draws` (its first draw
|
||||
// and its size).
|
||||
@group(0) @binding(2) var<storage, read> tables: array<u32>;
|
||||
@group(0) @binding(3) var<storage, read> updates: array<Update>;
|
||||
// Sections kept, draws, each page's draws, then per block of ids and page its
|
||||
@@ -70,7 +82,8 @@ struct Update {
|
||||
@group(0) @binding(5) var<storage, read_write> draws: array<u32>;
|
||||
// Per id the instance it is drawn with: origin and UV step, as f32 bits.
|
||||
@group(0) @binding(6) var<storage, read_write> origins: array<vec4<u32>>;
|
||||
// Per id its page + 1 when it is drawn this frame, else 0.
|
||||
// Per id `RUN_WORDS`: its page << PAGE_SHIFT | its draws when it is drawn this
|
||||
// frame, else 0; then each draw's first quad and quads.
|
||||
@group(0) @binding(7) var<storage, read_write> runs: array<u32>;
|
||||
// occlusion.rs `Reach::bits`: whether the search reached each cell of its box,
|
||||
// a bit each (32 a word, lowest first).
|
||||
@@ -93,6 +106,13 @@ const REACH: u32 = 1u;
|
||||
const SKY: u32 = 2u;
|
||||
// In a section's `a.w`: it fades in or out, and the CPU draws it with its fade.
|
||||
const FADING: u32 = 1u;
|
||||
// In `params.flags`: solid quads facing away from the eye are left out, its
|
||||
// keys in `tables` from `params.eyes`.
|
||||
const EYE: u32 = 4u;
|
||||
// Words per id in `runs`, and where its page starts in the first, its draws
|
||||
// (at most 4: gpu_culling.rs `RUNS`) below.
|
||||
const RUN_WORDS: u32 = 9u;
|
||||
const PAGE_SHIFT: u32 = 3u;
|
||||
|
||||
// The count of `block`'s draws in `page` (`cull`), then where they start (`scan`).
|
||||
fn block_count(block: u32, page: u32) -> u32 {
|
||||
@@ -170,6 +190,34 @@ fn in_frustum(c: vec3<u32>, b: u32) -> bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Where bucket `b` of section `id` ends (mesher/sides.rs `BUCKETS` order): its
|
||||
// solid quads for the last.
|
||||
fn bucket_end(id: u32, b: u32) -> u32 {
|
||||
if b == 6u {
|
||||
return sections[id].a.y;
|
||||
}
|
||||
return sections[id].ends[b];
|
||||
}
|
||||
|
||||
// mesher/sides.rs `Sides::visible_from` for bucket `b` of section `id` (+X, +Z,
|
||||
// the other bucket, +Y, -Z, -X, -Y), with `eye` the keys of the eye less its
|
||||
// origin: whether its quads can face the eye. -Y compares the eye's own y, as a
|
||||
// GPU frame has no water mirror. The keys order as the floats the CPU compares.
|
||||
fn seen(id: u32, b: u32, eye: vec3<u32>) -> bool {
|
||||
if b == 2u {
|
||||
return true;
|
||||
}
|
||||
let t = sections[id].keys[b - select(0u, 1u, b > 2u)];
|
||||
switch b {
|
||||
case 0u: { return eye.x > t; }
|
||||
case 1u: { return eye.z > t; }
|
||||
case 3u: { return eye.y > t; }
|
||||
case 4u: { return eye.z < t; }
|
||||
case 5u: { return eye.x < t; }
|
||||
default: { return eye.y < t; }
|
||||
}
|
||||
}
|
||||
|
||||
// One invocation per changed record; the CPU lists each id once.
|
||||
@compute @workgroup_size(64)
|
||||
fn apply(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
@@ -186,7 +234,8 @@ fn cull(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
if id >= params.ids {
|
||||
return;
|
||||
}
|
||||
runs[id] = 0u;
|
||||
let at = id * RUN_WORDS;
|
||||
runs[at] = 0u;
|
||||
let s = sections[id];
|
||||
if s.key.w < 0 || s.a.y == 0u || (s.a.w & FADING) != 0u || !near(s.key.x, s.key.z)
|
||||
|| !reached(s.key.xyz) {
|
||||
@@ -199,11 +248,52 @@ fn cull(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
if page >= params.pages || any(c >= params.cells.xyz) || !in_frustum(c, s.a.z) {
|
||||
return;
|
||||
}
|
||||
// Kept, whether or not a quad of it can face the eye.
|
||||
atomicAdd(&counts[0], 1u);
|
||||
let nx = params.cells.x;
|
||||
let ny = params.cells.y;
|
||||
origins[id] = vec4<u32>(tables[c.x], tables[nx + c.y], tables[nx + ny + c.z], STEP_BITS);
|
||||
runs[id] = page + 1u;
|
||||
atomicAdd(&counts[block_count(id / BLOCK, page)], 1u);
|
||||
// gfx.rs `facing_runs`: the runs of its buckets that can face the eye, an
|
||||
// empty bucket breaking none, a draw each; without an eye, its solid quads.
|
||||
var n = 0u;
|
||||
if (params.flags & EYE) == 0u {
|
||||
runs[at + 1u] = 0u;
|
||||
runs[at + 2u] = s.a.y;
|
||||
n = 1u;
|
||||
} else {
|
||||
let e = params.eyes;
|
||||
let eye = vec3<u32>(tables[e + c.x], tables[e + nx + c.y], tables[e + nx + ny + c.z]);
|
||||
var start = 0u;
|
||||
var first = 0u;
|
||||
var open = false;
|
||||
for (var b = 0u; b < 7u; b++) {
|
||||
let end = bucket_end(id, b);
|
||||
if end == start {
|
||||
continue;
|
||||
}
|
||||
let shown = seen(id, b, eye);
|
||||
if shown && !open {
|
||||
open = true;
|
||||
first = start;
|
||||
} else if !shown && open {
|
||||
runs[at + 1u + 2u * n] = first;
|
||||
runs[at + 2u + 2u * n] = start - first;
|
||||
n += 1u;
|
||||
open = false;
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
if open {
|
||||
runs[at + 1u + 2u * n] = first;
|
||||
runs[at + 2u + 2u * n] = start - first;
|
||||
n += 1u;
|
||||
}
|
||||
}
|
||||
if n == 0u {
|
||||
return;
|
||||
}
|
||||
runs[at] = (page << PAGE_SHIFT) | n;
|
||||
atomicAdd(&counts[block_count(id / BLOCK, page)], n);
|
||||
}
|
||||
|
||||
var<workgroup> partial: array<u32, 256>;
|
||||
@@ -242,49 +332,54 @@ fn scan(@builtin(workgroup_id) group: vec3<u32>, @builtin(local_invocation_index
|
||||
start += count;
|
||||
}
|
||||
if lane == 255u {
|
||||
// A region has room for every record of its page; were one short,
|
||||
// its draws past it go.
|
||||
// A region has room for every draw of its page's records; were one
|
||||
// short, its draws past it go.
|
||||
let total = min(partial[255], region(page).y);
|
||||
atomicStore(&counts[STATS + page], total);
|
||||
atomicAdd(&counts[0], partial[255]);
|
||||
atomicAdd(&counts[1], total);
|
||||
}
|
||||
}
|
||||
|
||||
var<workgroup> held: array<u32, 64>;
|
||||
|
||||
// One workgroup per block of ids: each section's draw goes after those of the
|
||||
// block's earlier sections in its page.
|
||||
// One workgroup per block of ids: each section's draws go after those of the
|
||||
// block's earlier sections in its page, in its runs' order.
|
||||
@compute @workgroup_size(64)
|
||||
fn scatter(@builtin(workgroup_id) group: vec3<u32>, @builtin(local_invocation_index) lane: u32) {
|
||||
let block = group.y * ROW + group.x;
|
||||
let id = block * BLOCK + lane;
|
||||
var mine = 0u;
|
||||
if id < params.ids {
|
||||
mine = runs[id];
|
||||
mine = runs[id * RUN_WORDS];
|
||||
}
|
||||
held[lane] = mine;
|
||||
workgroupBarrier();
|
||||
if mine == 0u {
|
||||
// Its draws below PAGE_SHIFT, its page above.
|
||||
let n = mine & 7u;
|
||||
if n == 0u {
|
||||
return;
|
||||
}
|
||||
let page = mine - 1u;
|
||||
let page = mine >> PAGE_SHIFT;
|
||||
var slot = atomicLoad(&counts[block_count(block, page)]);
|
||||
for (var other = 0u; other < lane; other++) {
|
||||
if held[other] == mine {
|
||||
slot += 1u;
|
||||
// A section not drawn holds 0: no draws.
|
||||
if (held[other] >> PAGE_SHIFT) == page {
|
||||
slot += held[other] & 7u;
|
||||
}
|
||||
}
|
||||
let r = region(page);
|
||||
if slot >= r.y {
|
||||
return;
|
||||
let at = id * RUN_WORDS;
|
||||
let base = sections[id].a.x;
|
||||
for (var k = 0u; k < n; k++) {
|
||||
if slot + k >= r.y {
|
||||
return;
|
||||
}
|
||||
// A run of its solid quads, from its first vertex, with its origin.
|
||||
let d = (r.x + slot + k) * 5u;
|
||||
draws[d] = runs[at + 2u + 2u * k] * 6u;
|
||||
draws[d + 1u] = 1u;
|
||||
draws[d + 2u] = 0u;
|
||||
draws[d + 3u] = base + runs[at + 1u + 2u * k] * 4u;
|
||||
draws[d + 4u] = id;
|
||||
}
|
||||
// All of its solid quads, from its first vertex, with its origin.
|
||||
let s = sections[id];
|
||||
let d = (r.x + slot) * 5u;
|
||||
draws[d] = s.a.y * 6u;
|
||||
draws[d + 1u] = 1u;
|
||||
draws[d + 2u] = 0u;
|
||||
draws[d + 3u] = s.a.x;
|
||||
draws[d + 4u] = id;
|
||||
}
|
||||
@@ -168,6 +168,9 @@ pub struct Hdr {
|
||||
/// HDR lighting: off while a shader pack's post or material stage runs, the pack's own, and
|
||||
/// with `HORIZON_HDR_LIGHTING=0`, to compare.
|
||||
pub lighting: bool,
|
||||
/// The HDR Lighting setting: off, nothing in the world is lifted, light sources and the
|
||||
/// sun included, so the picture is SDR's on the HDR canvas.
|
||||
pub look: bool,
|
||||
/// Calibration's test picture while it runs.
|
||||
pub calibration: Option<Calibration>,
|
||||
}
|
||||
@@ -313,6 +316,7 @@ impl Hdr {
|
||||
ui: 1.0,
|
||||
masks: MASKS,
|
||||
lighting: true,
|
||||
look: true,
|
||||
calibration: None,
|
||||
}
|
||||
}
|
||||
@@ -553,7 +557,7 @@ impl Hdr {
|
||||
params[..5].copy_from_slice(&[
|
||||
paper / 80.0,
|
||||
(self.peak / paper).max(1.0),
|
||||
f32::from(u8::from(world)),
|
||||
f32::from(u8::from(world && self.look)),
|
||||
f32::from(u8::from(self.debug)),
|
||||
f32::from(u8::from(pq)),
|
||||
]);
|
||||
|
||||
@@ -59,6 +59,25 @@ fn chosen_cells(
|
||||
chosen.unwrap_or(d.cells)
|
||||
}
|
||||
|
||||
/// `[shadow]` with the option values `constants`: none while its `option` is off, else the
|
||||
/// resolution and distance its `resolution_option` and `distance_option` pick.
|
||||
fn chosen_shadow(
|
||||
s: &horizon_shader_api::manifest::Shadow,
|
||||
options: &[horizon_shader_api::manifest::PackOption],
|
||||
constants: &[(String, f64)],
|
||||
) -> Option<horizon_shader_api::manifest::Shadow> {
|
||||
let number = |id: Option<&String>| {
|
||||
let option = options.iter().find(|o| Some(&o.id) == id)?;
|
||||
let (_, value) = constants.iter().find(|(k, _)| *k == option.id)?;
|
||||
option.kind.number(*value)
|
||||
};
|
||||
option_on(constants, s.option.as_ref()).then(|| horizon_shader_api::manifest::Shadow {
|
||||
resolution: number(s.resolution_option.as_ref()).map_or(s.resolution, |r| r as u32),
|
||||
distance: number(s.distance_option.as_ref()).map_or(s.distance, |d| d as f32),
|
||||
..s.clone()
|
||||
})
|
||||
}
|
||||
|
||||
impl NativePack {
|
||||
/// The pack of the checked stages `report` with the option values `constants`, drawing
|
||||
/// onto the world's targets as `terrain` describes; the sky reads the terrain's group 0.
|
||||
@@ -74,8 +93,12 @@ impl NativePack {
|
||||
) -> Result<(Self, Vec<String>), String> {
|
||||
let (globals, format) = (terrain.globals, terrain.format);
|
||||
let textures = super::native_post::pack_textures(device, queue, &report);
|
||||
let settings = report
|
||||
// `[shadow]`, unless its option is off, at the size its options pick.
|
||||
let chosen = report
|
||||
.shadow
|
||||
.as_ref()
|
||||
.and_then(|s| chosen_shadow(s, &report.manifest.options, constants));
|
||||
let settings = chosen
|
||||
.as_ref()
|
||||
.map(|settings| super::native_shadow::fitted(device, settings));
|
||||
let mut frame = NativeFrame::new(device, settings.as_ref(), textures, lights, terrain.pbr);
|
||||
@@ -103,8 +126,7 @@ impl NativePack {
|
||||
report.shadow.is_some(),
|
||||
)
|
||||
});
|
||||
let shadow = report
|
||||
.shadow
|
||||
let shadow = chosen
|
||||
.as_ref()
|
||||
.map(|s| {
|
||||
let vertex = report.stages.iter().find(|s| s.kind == StageKind::Vertex);
|
||||
@@ -240,3 +262,61 @@ impl NativePack {
|
||||
|| self.post.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::chosen_shadow;
|
||||
use horizon_shader_api::manifest::{OptionKind, PackOption, Shadow};
|
||||
|
||||
fn option(id: &str, kind: OptionKind) -> PackOption {
|
||||
PackOption {
|
||||
id: id.into(),
|
||||
name: id.into(),
|
||||
description: String::new(),
|
||||
kind,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shadow_map_follows_its_options() {
|
||||
let options = [
|
||||
option("shadows", OptionKind::Bool { default: true }),
|
||||
option(
|
||||
"quality",
|
||||
OptionKind::Choice {
|
||||
values: vec!["1024".into(), "4096".into()],
|
||||
default: 0,
|
||||
},
|
||||
),
|
||||
option(
|
||||
"reach",
|
||||
OptionKind::Int {
|
||||
default: 96,
|
||||
min: 32,
|
||||
max: 512,
|
||||
},
|
||||
),
|
||||
];
|
||||
let shadow = Shadow {
|
||||
option: Some("shadows".into()),
|
||||
resolution_option: Some("quality".into()),
|
||||
distance_option: Some("reach".into()),
|
||||
..Shadow::default()
|
||||
};
|
||||
let values = |on: f64, quality: f64, reach: f64| {
|
||||
[("shadows", on), ("quality", quality), ("reach", reach)].map(|(k, v)| (k.into(), v))
|
||||
};
|
||||
let size = |values: &[(String, f64)]| {
|
||||
chosen_shadow(&shadow, &options, values).map(|s| (s.resolution, s.distance))
|
||||
};
|
||||
assert_eq!(size(&values(1.0, 1.0, 200.0)), Some((4096, 200.0)));
|
||||
assert_eq!(size(&values(1.0, 0.0, 64.0)), Some((1024, 64.0)));
|
||||
assert_eq!(size(&values(0.0, 1.0, 200.0)), None);
|
||||
// Without the options' keys: the manifest's own numbers, always drawn.
|
||||
assert_eq!(
|
||||
chosen_shadow(&Shadow::default(), &options, &values(0.0, 1.0, 200.0))
|
||||
.map(|s| (s.resolution, s.distance)),
|
||||
Some((2048, 128.0))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -366,6 +366,7 @@ impl NativeFrame {
|
||||
// the sun's azimuth while the sun is low.
|
||||
distance: None,
|
||||
unculled: None,
|
||||
every_face: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -806,12 +806,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn what_the_camera_stands_on_is_inside_the_shadow_cameras_depth() {
|
||||
let settings = Settings {
|
||||
resolution: 2048,
|
||||
distance: 128.0,
|
||||
solid_materials: Vec::new(),
|
||||
};
|
||||
let shadow = camera(&settings);
|
||||
let shadow = camera(&Settings::default());
|
||||
let (view, projection) = super::super::pack_uniforms::shadow_camera(
|
||||
glam::Vec3::new(0.3, 0.9, 0.0).normalize(),
|
||||
glam::DVec3::new(-2064.5, 246.0, -4304.5),
|
||||
@@ -827,11 +822,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn the_centre_stays_near_the_player_on_both_sides_of_zero() {
|
||||
let shadow = camera(&Settings {
|
||||
resolution: 2048,
|
||||
distance: 128.0,
|
||||
solid_materials: Vec::new(),
|
||||
});
|
||||
let shadow = camera(&Settings::default());
|
||||
let light = glam::Vec3::Y;
|
||||
for x in [5.5, -5.5, -2064.5, 2064.5] {
|
||||
let camera = glam::DVec3::new(x, 64.0, x);
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
//! The block atlas of shader packs (spec 5.1, `atlasSize` in 5.2): packs sample one 2D atlas
|
||||
//! with atlas coordinates, Horizon keeps block sprites in texture arrays. With a pack, every
|
||||
//! layer is copied into a grid, row by row from the top left, and an animated layer again
|
||||
//! whenever its frame changes.
|
||||
//! layer is copied into the atlas, and an animated layer again whenever its frame changes.
|
||||
//!
|
||||
//! Flat draws, which read a table per sprite layer, get the game's own layout
|
||||
//! ([`PackAtlas::stitched`]): every sprite at its own size, placed by vanilla's `Stitcher` with its
|
||||
//! padding; this gives the `atlasSize` and spans our probes read in Iris 1.11.7. Other draws get
|
||||
//! a grid of array-sized cells ([`PackAtlas::new`]).
|
||||
|
||||
#[path = "pack_atlas/stitch.rs"]
|
||||
mod stitch;
|
||||
|
||||
pub(super) struct PackAtlas {
|
||||
texture: wgpu::Texture,
|
||||
@@ -14,6 +21,82 @@ pub(super) struct PackAtlas {
|
||||
split: u32,
|
||||
/// OptiFine/Iris separate ambient occlusion: pass vertex alpha to hz_Color.a.
|
||||
pub separate_ao: bool,
|
||||
/// The stitched layout, when the atlas has one.
|
||||
stitched: Option<Stitched>,
|
||||
}
|
||||
|
||||
/// A stitched atlas: where each layer's sprite is, and the pipeline that draws them in.
|
||||
struct Stitched {
|
||||
/// Each layer's sprite at the first level: its top left corner and its side.
|
||||
rects: Vec<[u32; 3]>,
|
||||
padding: u32,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
/// One per sprite array.
|
||||
groups: Vec<wgpu::BindGroup>,
|
||||
}
|
||||
|
||||
/// One sprite drawn into one level of a stitched atlas (`BLIT`'s instance).
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct BlitSprite {
|
||||
/// The sprite's top left corner, side and padding at that level.
|
||||
rect: [i32; 4],
|
||||
/// Its layer in its array, the array's level it comes from, the atlas level's size.
|
||||
source: [u32; 4],
|
||||
}
|
||||
|
||||
/// Draws sprites into a stitched atlas level, padding included: a pixel of the padding repeats
|
||||
/// the sprite's nearest edge pixel, as vanilla's `animate_sprite` shaders do with a clamped sampler.
|
||||
const BLIT: &str = r#"
|
||||
struct Out {
|
||||
@builtin(position) position: vec4<f32>,
|
||||
@location(0) @interpolate(flat) rect: vec4<i32>,
|
||||
@location(1) @interpolate(flat) source: vec2<u32>,
|
||||
}
|
||||
@group(0) @binding(0) var sprites: texture_2d_array<f32>;
|
||||
@vertex
|
||||
fn vs(@builtin(vertex_index) v: u32, @location(0) rect: vec4<i32>, @location(1) source: vec4<u32>) -> Out {
|
||||
var corners = array<vec2<f32>, 6>(vec2(0.0, 0.0), vec2(1.0, 0.0), vec2(0.0, 1.0),
|
||||
vec2(0.0, 1.0), vec2(1.0, 0.0), vec2(1.0, 1.0));
|
||||
let pixel = vec2<f32>(rect.xy - rect.w) + corners[v] * f32(rect.z + 2 * rect.w);
|
||||
let size = vec2<f32>(source.zw);
|
||||
var out: Out;
|
||||
out.position = vec4(pixel.x / size.x * 2.0 - 1.0, 1.0 - pixel.y / size.y * 2.0, 0.0, 1.0);
|
||||
out.rect = rect;
|
||||
out.source = source.xy;
|
||||
return out;
|
||||
}
|
||||
@fragment
|
||||
fn fs(in: Out) -> @location(0) vec4<f32> {
|
||||
let texel = clamp(vec2<i32>(floor(in.position.xy)) - in.rect.xy, vec2(0), vec2(in.rect.z - 1));
|
||||
return textureLoad(sprites, texel, i32(in.source.x), i32(in.source.y));
|
||||
}
|
||||
"#;
|
||||
|
||||
/// The side a layer of `sprite`-wide arrays has in a stitched atlas: its own, when the arrays
|
||||
/// hold it scaled up by a power of two (else the array's).
|
||||
fn atlas_side(native: u32, sprite: u32) -> u32 {
|
||||
if native == 0
|
||||
|| native > sprite
|
||||
|| !sprite.is_multiple_of(native)
|
||||
|| !(sprite / native).is_power_of_two()
|
||||
{
|
||||
sprite
|
||||
} else {
|
||||
native
|
||||
}
|
||||
}
|
||||
|
||||
/// `SpriteLoader.stitch`'s mip level: the option, lowered to fit the smallest sprite.
|
||||
fn stitch_mip_level(sides: &[u32], option: u32) -> u32 {
|
||||
let mut lowest = 1u32 << option.min(16);
|
||||
let mut smallest = u32::MAX;
|
||||
for &side in sides {
|
||||
smallest = smallest.min(side);
|
||||
lowest = lowest.min(side & side.wrapping_neg());
|
||||
}
|
||||
let level = smallest.min(lowest).max(1).ilog2();
|
||||
level.min(option)
|
||||
}
|
||||
|
||||
impl PackAtlas {
|
||||
@@ -91,11 +174,173 @@ impl PackAtlas {
|
||||
sprite,
|
||||
split,
|
||||
separate_ao: false,
|
||||
stitched: None,
|
||||
};
|
||||
atlas.copy(encoder, arrays, 0..layers);
|
||||
atlas.copy(device, encoder, arrays, 0..layers);
|
||||
Some(atlas)
|
||||
}
|
||||
|
||||
/// Every layer of `arrays` at its own size (`native`, its side before the arrays scaled it up
|
||||
/// to `sprite`), placed as vanilla's `Stitcher` places them with the padding of the Mipmap
|
||||
/// Levels option and the anisotropy bit; `None` when they don't fit the device.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn stitched(
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
arrays: &[wgpu::Texture],
|
||||
split: u32,
|
||||
sprite: u32,
|
||||
native: &[u32],
|
||||
mip_option: u32,
|
||||
anisotropy_bit: u32,
|
||||
) -> Option<Self> {
|
||||
let sides: Vec<u32> = if native.is_empty() {
|
||||
vec![sprite]
|
||||
} else {
|
||||
native.iter().map(|&n| atlas_side(n, sprite)).collect()
|
||||
};
|
||||
// A level of the atlas comes from the arrays' level as many halvings further down.
|
||||
let array_levels = arrays[0].mip_level_count();
|
||||
let deepest = sides
|
||||
.iter()
|
||||
.map(|&s| (sprite / s).ilog2())
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let mip =
|
||||
stitch_mip_level(&sides, mip_option).min(array_levels.saturating_sub(deepest + 1));
|
||||
let max = device.limits().max_texture_dimension_2d;
|
||||
let layout = stitch::stitch(&sides, mip, anisotropy_bit, max)?;
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Shader pack block atlas"),
|
||||
size: wgpu::Extent3d {
|
||||
width: layout.width.max(1),
|
||||
height: layout.height.max(1),
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: mip + 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_DST
|
||||
| wgpu::TextureUsages::COPY_SRC
|
||||
| wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = texture.create_view(&Default::default());
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Shader pack atlas sprites"),
|
||||
source: wgpu::ShaderSource::Wgsl(BLIT.into()),
|
||||
});
|
||||
let group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Shader pack atlas sprites"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2Array,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Shader pack atlas sprites"),
|
||||
bind_group_layouts: &[Some(&group_layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Shader pack atlas sprites"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("vs"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<BlitSprite>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &wgpu::vertex_attr_array![0 => Sint32x4, 1 => Uint32x4],
|
||||
})],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::TextureFormat::Rgba8Unorm.into())],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let groups = arrays
|
||||
.iter()
|
||||
.map(|array| {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Shader pack atlas sprites"),
|
||||
layout: &group_layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&array.create_view(
|
||||
&wgpu::TextureViewDescriptor {
|
||||
dimension: Some(wgpu::TextureViewDimension::D2Array),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
}],
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let rects = layout
|
||||
.holders
|
||||
.iter()
|
||||
.zip(&sides)
|
||||
.map(|(&[x, y], &side)| [x + layout.padding, y + layout.padding, side])
|
||||
.collect::<Vec<_>>();
|
||||
let layers = rects.len() as u32;
|
||||
let atlas = Self {
|
||||
texture,
|
||||
view,
|
||||
columns: 1,
|
||||
sprite,
|
||||
split,
|
||||
separate_ao: false,
|
||||
stitched: Some(Stitched {
|
||||
rects,
|
||||
padding: layout.padding,
|
||||
pipeline,
|
||||
groups,
|
||||
}),
|
||||
};
|
||||
atlas.copy(device, encoder, arrays, 0..layers);
|
||||
Some(atlas)
|
||||
}
|
||||
|
||||
/// Whether the sprites keep their own sizes ([`PackAtlas::stitched`]).
|
||||
pub fn is_stitched(&self) -> bool {
|
||||
self.stitched.is_some()
|
||||
}
|
||||
|
||||
/// The table flat terrain reads (`hz_sprite_rects`): per layer, its sprite's top left corner
|
||||
/// (x | y << 16) and side, in atlas pixels.
|
||||
pub fn rects_table(&self) -> Vec<u32> {
|
||||
if let Some(stitched) = &self.stitched {
|
||||
return stitched
|
||||
.rects
|
||||
.iter()
|
||||
.flat_map(|&[x, y, side]| [x | (y << 16), side])
|
||||
.collect();
|
||||
}
|
||||
(0..self.columns * self.texture.height() / self.sprite)
|
||||
.flat_map(|layer| {
|
||||
let (x, y) = (layer % self.columns, layer / self.columns);
|
||||
[(x * self.sprite) | ((y * self.sprite) << 16), self.sprite]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The atlas's size in pixels (`atlasSize`).
|
||||
pub fn size(&self) -> [u32; 2] {
|
||||
[self.texture.width(), self.texture.height()]
|
||||
@@ -104,10 +349,14 @@ impl PackAtlas {
|
||||
/// Copies `layers` from the sprite arrays again, every level.
|
||||
pub fn copy(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
arrays: &[wgpu::Texture],
|
||||
layers: impl IntoIterator<Item = u32>,
|
||||
) {
|
||||
if let Some(stitched) = &self.stitched {
|
||||
return self.draw_sprites(stitched, device, encoder, layers);
|
||||
}
|
||||
for layer in layers {
|
||||
let Some(array) = arrays.get((layer / self.split) as usize) else {
|
||||
continue;
|
||||
@@ -145,6 +394,91 @@ impl PackAtlas {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws `layers` into every level of a stitched atlas, from the arrays' level where each
|
||||
/// sprite has its own size.
|
||||
fn draw_sprites(
|
||||
&self,
|
||||
stitched: &Stitched,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
layers: impl IntoIterator<Item = u32>,
|
||||
) {
|
||||
let mut layers: Vec<u32> = layers
|
||||
.into_iter()
|
||||
.filter(|&l| (l as usize) < stitched.rects.len())
|
||||
.collect();
|
||||
if layers.is_empty() {
|
||||
return;
|
||||
}
|
||||
layers.sort_unstable_by_key(|&l| (l / self.split, l));
|
||||
let levels = self.texture.mip_level_count();
|
||||
let mut sprites = Vec::with_capacity(layers.len() * levels as usize);
|
||||
for level in 0..levels {
|
||||
let size = [
|
||||
self.texture.width() >> level,
|
||||
self.texture.height() >> level,
|
||||
];
|
||||
for &layer in &layers {
|
||||
let [x, y, side] = stitched.rects[layer as usize];
|
||||
sprites.push(BlitSprite {
|
||||
rect: [x, y, side, stitched.padding].map(|v| (v >> level) as i32),
|
||||
source: [
|
||||
layer % self.split,
|
||||
(self.sprite / side).ilog2() + level,
|
||||
size[0],
|
||||
size[1],
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
let buffer = wgpu::util::DeviceExt::create_buffer_init(
|
||||
device,
|
||||
&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Shader pack atlas sprites"),
|
||||
contents: bytemuck::cast_slice(&sprites),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
},
|
||||
);
|
||||
let per_level = layers.len() as u32;
|
||||
for level in 0..levels {
|
||||
let view = self.texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
base_mip_level: level,
|
||||
mip_level_count: Some(1),
|
||||
..Default::default()
|
||||
});
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Shader pack atlas sprites"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&stitched.pipeline);
|
||||
pass.set_vertex_buffer(0, buffer.slice(..));
|
||||
let first = level * per_level;
|
||||
let mut start = 0;
|
||||
while start < layers.len() {
|
||||
let array = layers[start] / self.split;
|
||||
let end = start
|
||||
+ layers[start..]
|
||||
.iter()
|
||||
.take_while(|&&l| l / self.split == array)
|
||||
.count();
|
||||
if let Some(group) = stitched.groups.get(array as usize) {
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.draw(0..6, first + start as u32..first + end as u32);
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `TerrainVertex` of flat draws' arena page (`hz_vertices`, six words each) into the vertex
|
||||
@@ -204,7 +538,8 @@ impl PackAtlas {
|
||||
// vertices (`TerrainVertex`, six words each).
|
||||
declarations.push_str(
|
||||
"layout(set = 3, binding = 0) readonly buffer HzBlockIds { int hz_block_ids[]; };\n\
|
||||
layout(set = 3, binding = 1) readonly buffer HzVertices { uint hz_vertices[]; };\n",
|
||||
layout(set = 3, binding = 1) readonly buffer HzVertices { uint hz_vertices[]; };\n\
|
||||
layout(set = 3, binding = 2) readonly buffer HzSpriteRects { uvec2 hz_sprite_rects[]; };\n",
|
||||
);
|
||||
"hz_slot_origins[hz_block_slots[uint(gl_VertexIndex) >> 8u]]"
|
||||
} else {
|
||||
@@ -212,11 +547,29 @@ impl PackAtlas {
|
||||
"hz_v_origin"
|
||||
};
|
||||
let [width, height] = self.size();
|
||||
let scale = [
|
||||
self.sprite as f32 / width as f32,
|
||||
self.sprite as f32 / height as f32,
|
||||
];
|
||||
// A sprite's cell in its own sides, and the share of the atlas a side is: from the table
|
||||
// in flat draws (`rects_table`), from the grid in others.
|
||||
let columns = self.columns;
|
||||
let (cell, side, scale) = if flat {
|
||||
(
|
||||
"uvec2 hz_rect = hz_sprite_rects[hz_layer];\n \
|
||||
hz_cell = vec2(float(hz_rect.x & 65535u), float(hz_rect.x >> 16u)) / float(hz_rect.y);\n "
|
||||
.to_owned(),
|
||||
"float(hz_rect.y)",
|
||||
[1.0 / width as f32, 1.0 / height as f32],
|
||||
)
|
||||
} else {
|
||||
(
|
||||
format!(
|
||||
"hz_cell = vec2(float(hz_layer % {columns}u), float(hz_layer / {columns}u));\n "
|
||||
),
|
||||
"1.0",
|
||||
[
|
||||
self.sprite as f32 / width as f32,
|
||||
self.sprite as f32 / height as f32,
|
||||
],
|
||||
)
|
||||
};
|
||||
// UVs past a sprite (models up to 2, distant tiles) cannot repeat in an atlas: clamped.
|
||||
// The colour's alpha holds HDR's emission level (`mesher::EMISSION_BITS`): packs get 1 unless separate_ao.
|
||||
let color_a = if self.separate_ao {
|
||||
@@ -227,8 +580,8 @@ impl PackAtlas {
|
||||
let body = format!(
|
||||
" vec4 hz_origin = {origin};\n \
|
||||
uint hz_layer = hz_v_layer & 32767u;\n \
|
||||
hz_cell = vec2(float(hz_layer % {columns}u), float(hz_layer / {columns}u));\n \
|
||||
hz_scale = vec2({:?}, {:?});\n \
|
||||
{cell}\
|
||||
hz_scale = vec2({:?}, {:?}) * {side};\n \
|
||||
hz_Vertex = vec4(hz_v_pos + hz_origin.xyz, 1.0);\n \
|
||||
hz_uv_scale = hz_origin.w > 0.0 ? uintBitsToFloat(floatBitsToUint(hz_origin.w) & 0xff800000u) : fract(abs(hz_origin.w));\n \
|
||||
hz_mid = vec2(0.5);\n \
|
||||
@@ -369,6 +722,128 @@ mod tests {
|
||||
assert_eq!([red(1, 3), red(3, 2), red(5, 2)], [120, 160, 0]);
|
||||
}
|
||||
|
||||
/// An array of `sprite`-pixel layers (`sprite` the largest of `native`), as the game builds
|
||||
/// them: each layer scaled up by whole pixels at level 0, its own pixels from the level where
|
||||
/// it has its own size. Every pixel of a layer's own image differs: (layer, x, y, 255).
|
||||
pub(super) fn upscaled_array(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
native: &[u32],
|
||||
) -> wgpu::Texture {
|
||||
let sprite = native.iter().copied().max().unwrap();
|
||||
let levels = sprite.ilog2() + 1;
|
||||
let mut data = Vec::new();
|
||||
for (layer, &side) in native.iter().enumerate() {
|
||||
for level in 0..levels {
|
||||
let edge = sprite >> level;
|
||||
// Above its own size a level repeats each pixel; below, it shrinks to the first.
|
||||
let scale = (edge / side).max(1);
|
||||
for y in 0..edge {
|
||||
for x in 0..edge {
|
||||
let (x, y) = if edge >= side {
|
||||
(x / scale, y / scale)
|
||||
} else {
|
||||
(0, 0)
|
||||
};
|
||||
data.extend([layer as u8 * 50, x as u8 * 16, y as u8 * 16, 255]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
wgpu::util::DeviceExt::create_texture_with_data(
|
||||
device,
|
||||
queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: sprite,
|
||||
height: sprite,
|
||||
depth_or_array_layers: native.len() as u32,
|
||||
},
|
||||
mip_level_count: levels,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
&data,
|
||||
)
|
||||
}
|
||||
|
||||
/// Sprites of 2, 4 and 1 px in a 4 px array, no mipmaps (one pixel of padding). Worked through
|
||||
/// vanilla's `Stitcher` by hand: holders of 6, 4 and 3 px at (0, 0), (8, 0) and (8, 4) in a 16
|
||||
/// by 8 atlas. Each sprite has its own pixels, and its padding repeats its edge.
|
||||
#[test]
|
||||
fn stitched_sprites_keep_their_own_size_and_padding() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let (device, queue) =
|
||||
pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let array = upscaled_array(&device, &queue, &[2, 4, 1]);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let atlas =
|
||||
PackAtlas::stitched(&device, &mut encoder, &[array], 3, 4, &[2, 4, 1], 0, 0).unwrap();
|
||||
assert_eq!(atlas.size(), [16, 8]);
|
||||
assert_eq!(
|
||||
atlas.rects_table(),
|
||||
[9 | 1 << 16, 2, 1 | 1 << 16, 4, 9 | 5 << 16, 1]
|
||||
);
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 256 * 8,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
atlas.texture.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &read,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: 16,
|
||||
height: 8,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let data = read.slice(..).get_mapped_range().unwrap();
|
||||
let at = |x: usize, y: usize| {
|
||||
let i = y * 256 + x * 4;
|
||||
[data[i], data[i + 1], data[i + 2], data[i + 3]]
|
||||
};
|
||||
// Layer 0's own 2 px: (layer 0, x, y); its padding repeats the nearest edge pixel.
|
||||
assert_eq!(
|
||||
[at(9, 1), at(10, 1), at(10, 2)],
|
||||
[[0, 0, 0, 255], [0, 16, 0, 255], [0, 16, 16, 255]]
|
||||
);
|
||||
assert_eq!(
|
||||
[at(8, 0), at(11, 3), at(11, 1)],
|
||||
[[0, 0, 0, 255], [0, 16, 16, 255], [0, 16, 0, 255]]
|
||||
);
|
||||
// Layer 1 fills its 4 px, padded; layer 2 is one pixel in the middle of its padding.
|
||||
assert_eq!(
|
||||
[at(1, 1), at(4, 4), at(0, 5), at(5, 0)],
|
||||
[
|
||||
[50, 0, 0, 255],
|
||||
[50, 48, 48, 255],
|
||||
[50, 0, 48, 255],
|
||||
[50, 48, 0, 255]
|
||||
]
|
||||
);
|
||||
assert_eq!([at(9, 5), at(8, 4), at(10, 6)], [[100, 0, 0, 255]; 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terrain_inputs_respects_separate_ao() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
@@ -398,6 +873,7 @@ mod tests {
|
||||
sprite: 16,
|
||||
split: 1,
|
||||
separate_ao: false,
|
||||
stitched: None,
|
||||
};
|
||||
let inputs_default = atlas.terrain_inputs(false);
|
||||
assert!(
|
||||
|
||||
@@ -123,6 +123,54 @@ fn mid_tex_coord_follows_each_quads_mapped_corners() {
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
/// A stitched atlas (the one `stitched_sprites_keep_their_own_size_and_padding` works out: 16 by
|
||||
/// 8, sprites of 2, 4 and 1 px at (9, 1), (1, 1) and (9, 5)): flat draws map a quad's UVs onto its
|
||||
/// sprite's own pixels, so a corner is half the sprite's own side from `mc_midTexCoord`.
|
||||
#[test]
|
||||
fn flat_draws_map_uvs_onto_a_stitched_sprite() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let array = super::tests::upscaled_array(&device, &queue, &[2, 4, 1]);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let atlas =
|
||||
PackAtlas::stitched(&device, &mut encoder, &[array], 3, 4, &[2, 4, 1], 0, 0).unwrap();
|
||||
queue.submit([encoder.finish()]);
|
||||
let whole = [[0., 0.], [1., 0.], [1., 1.], [0., 1.]];
|
||||
let positions = [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.]];
|
||||
let vertices: Vec<_> = (0..3)
|
||||
.flat_map(|layer| {
|
||||
(0..4).map(move |k| {
|
||||
TerrainVertex::new(positions[k], whole[k], layer as u16 | 0x8000, u32::MAX, 0)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// Corner and side of each layer's sprite, in atlas pixels.
|
||||
let sprites = [([9., 1.], 2.), ([1., 1.], 4.), ([9., 5.], 1.)];
|
||||
let origin = f32::from_bits((1. / UV_STEPS).to_bits());
|
||||
let pixels = sample(&device, &queue, &atlas, &vertices, origin, [true, false]);
|
||||
let mut failures = Vec::new();
|
||||
for (layer, ([x, y], side)) in sprites.into_iter().enumerate() {
|
||||
let mid = [(x + side / 2.) / 16., (y + side / 2.) / 8.];
|
||||
for (corner, [u, v]) in whole.iter().enumerate() {
|
||||
let expected = [mid[0], mid[1], (x + u * side) / 16., (y + v * side) / 8.];
|
||||
let actual = pixels[4 * layer + corner];
|
||||
if actual
|
||||
.iter()
|
||||
.zip(expected)
|
||||
.any(|(a, b)| (a - b).abs() > 1e-6)
|
||||
{
|
||||
failures.push(format!(
|
||||
"layer {layer} corner {corner}: {actual:?} != {expected:?}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
fn sample(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
@@ -183,7 +231,7 @@ fn sample(
|
||||
}],
|
||||
vec![storage(0), storage(1)],
|
||||
vec![],
|
||||
vec![storage(0), storage(1)],
|
||||
vec![storage(0), storage(1), storage(2)],
|
||||
];
|
||||
let layouts: Vec<_> = entries
|
||||
.iter()
|
||||
@@ -268,11 +316,15 @@ fn sample(
|
||||
bytemuck::cast_slice(vertices),
|
||||
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
|
||||
);
|
||||
let rects = buffer(
|
||||
bytemuck::cast_slice(&atlas.rects_table()),
|
||||
wgpu::BufferUsages::STORAGE,
|
||||
);
|
||||
let bindings = [
|
||||
vec![(0, &uniform)],
|
||||
vec![(0, &slots), (1, &origin)],
|
||||
vec![],
|
||||
vec![(0, &ids), (1, &data)],
|
||||
vec![(0, &ids), (1, &data), (2, &rects)],
|
||||
];
|
||||
let groups: Vec<_> = bindings
|
||||
.iter()
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
//! Where a shader pack's block atlas puts each sprite: vanilla's `Stitcher`, so every sprite keeps
|
||||
//! its own size and the atlas comes out as large as the game's (`atlasSize`). Holders are sorted
|
||||
//! by height, then width, then (where vanilla compares names) sprite layer.
|
||||
|
||||
/// The atlas a set of sprites stitches into.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(super) struct Stitched {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Each sprite's padded holder: its top left corner. The sprite itself starts `padding` in.
|
||||
pub holders: Vec<[u32; 2]>,
|
||||
pub padding: u32,
|
||||
}
|
||||
|
||||
/// `Stitcher.smallestFittingMinTexel`.
|
||||
fn fit(input: u32, mip_level: u32) -> u32 {
|
||||
let step = 1 << mip_level;
|
||||
input.div_ceil(step) * step
|
||||
}
|
||||
|
||||
/// `Mth.smallestEncompassingPowerOfTwo` (0 stays 0).
|
||||
fn power_of_two(input: u32) -> u32 {
|
||||
if input == 0 {
|
||||
0
|
||||
} else {
|
||||
input.next_power_of_two()
|
||||
}
|
||||
}
|
||||
|
||||
/// `Stitcher.Region`.
|
||||
struct Region {
|
||||
x: u32,
|
||||
y: u32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
holder: bool,
|
||||
sub: Vec<Region>,
|
||||
}
|
||||
|
||||
impl Region {
|
||||
fn new(x: u32, y: u32, width: u32, height: u32) -> Self {
|
||||
Self {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
holder: false,
|
||||
sub: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `Region.add`: the holder's top left corner, when it fits.
|
||||
fn add(&mut self, [w, h]: [u32; 2]) -> Option<[u32; 2]> {
|
||||
if self.holder || w > self.width || h > self.height {
|
||||
return None;
|
||||
}
|
||||
if w == self.width && h == self.height {
|
||||
self.holder = true;
|
||||
return Some([self.x, self.y]);
|
||||
}
|
||||
if self.sub.is_empty() {
|
||||
let (x, y) = (self.x, self.y);
|
||||
self.sub.push(Region::new(x, y, w, h));
|
||||
let (spare_w, spare_h) = (self.width - w, self.height - h);
|
||||
if spare_h > 0 && spare_w > 0 {
|
||||
let right = self.height.max(spare_w);
|
||||
let bottom = self.width.max(spare_h);
|
||||
if right >= bottom {
|
||||
self.sub.push(Region::new(x, y + h, w, spare_h));
|
||||
self.sub.push(Region::new(x + w, y, spare_w, self.height));
|
||||
} else {
|
||||
self.sub.push(Region::new(x + w, y, spare_w, h));
|
||||
self.sub.push(Region::new(x, y + h, self.width, spare_h));
|
||||
}
|
||||
} else if spare_w == 0 {
|
||||
self.sub.push(Region::new(x, y + h, w, spare_h));
|
||||
} else if spare_h == 0 {
|
||||
self.sub.push(Region::new(x + w, y, spare_w, h));
|
||||
}
|
||||
}
|
||||
self.sub.iter_mut().find_map(|r| r.add([w, h]))
|
||||
}
|
||||
}
|
||||
|
||||
/// `Stitcher` over square sprites of `sides` pixels, with vanilla's padding for `mip_level` and
|
||||
/// `anisotropy_bit`; `None` when they don't fit in `max` by `max`.
|
||||
pub(super) fn stitch(
|
||||
sides: &[u32],
|
||||
mip_level: u32,
|
||||
anisotropy_bit: u32,
|
||||
max: u32,
|
||||
) -> Option<Stitched> {
|
||||
let padding = (1 << mip_level) << anisotropy_bit.saturating_sub(1).min(4);
|
||||
let mut order: Vec<usize> = (0..sides.len()).collect();
|
||||
let holder = |i: usize| fit(sides[i] + padding * 2, mip_level);
|
||||
order.sort_by_key(|&i| (std::cmp::Reverse(holder(i)), i));
|
||||
let (mut storage_x, mut storage_y) = (0u32, 0u32);
|
||||
let mut storage: Vec<Region> = Vec::new();
|
||||
let mut holders = vec![[0, 0]; sides.len()];
|
||||
for i in order {
|
||||
let size = [holder(i); 2];
|
||||
if let Some(at) = storage.iter_mut().find_map(|r| r.add(size)) {
|
||||
holders[i] = at;
|
||||
continue;
|
||||
}
|
||||
// `Stitcher.expand`.
|
||||
let (x_now, y_now) = (power_of_two(storage_x), power_of_two(storage_y));
|
||||
let (x_new, y_new) = (
|
||||
power_of_two(storage_x + size[0]),
|
||||
power_of_two(storage_y + size[1]),
|
||||
);
|
||||
let (x_can, y_can) = (x_new <= max, y_new <= max);
|
||||
if !x_can && !y_can {
|
||||
return None;
|
||||
}
|
||||
let (x_will, y_will) = (x_can && x_now != x_new, y_can && y_now != y_new);
|
||||
let grow_x = if x_will ^ y_will {
|
||||
x_will
|
||||
} else {
|
||||
x_can && x_now <= y_now
|
||||
};
|
||||
let mut slot = if grow_x {
|
||||
if storage_y == 0 {
|
||||
storage_y = y_new;
|
||||
}
|
||||
let slot = Region::new(storage_x, 0, x_new - storage_x, storage_y);
|
||||
storage_x = x_new;
|
||||
slot
|
||||
} else {
|
||||
let slot = Region::new(0, storage_y, storage_x, y_new - storage_y);
|
||||
storage_y = y_new;
|
||||
slot
|
||||
};
|
||||
holders[i] = slot.add(size)?;
|
||||
storage.push(slot);
|
||||
}
|
||||
Some(Stitched {
|
||||
width: storage_x,
|
||||
height: storage_y,
|
||||
holders,
|
||||
padding,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Five 16 px sprites with one pixel of padding (no mipmaps), worked through vanilla's
|
||||
/// `Stitcher` by hand: 18 px holders in a 64 by 64 atlas.
|
||||
#[test]
|
||||
fn equal_sprites_follow_vanillas_regions() {
|
||||
let stitched = stitch(&[16; 5], 0, 0, 1 << 14).unwrap();
|
||||
assert_eq!(
|
||||
(stitched.width, stitched.height, stitched.padding),
|
||||
(64, 64, 1)
|
||||
);
|
||||
assert_eq!(
|
||||
stitched.holders,
|
||||
[[0, 0], [32, 0], [0, 32], [18, 32], [36, 32]]
|
||||
);
|
||||
}
|
||||
|
||||
/// The largest sprite goes first; padding and holders round up to the mip step.
|
||||
#[test]
|
||||
fn larger_sprites_go_first_and_holders_round_to_the_mip_step() {
|
||||
// Mip level 2: 4 px padding, holders 24 (16 + 8) and 40 (32 + 8) px.
|
||||
let stitched = stitch(&[16, 32], 2, 0, 1 << 14).unwrap();
|
||||
assert_eq!(stitched.padding, 4);
|
||||
assert_eq!(stitched.holders[1], [0, 0]);
|
||||
// 40 px grows the atlas to 64 by 64; the 24 px holder fits below the first.
|
||||
assert_eq!((stitched.width, stitched.height), (64, 64));
|
||||
assert_eq!(stitched.holders[0], [0, 40]);
|
||||
// Anisotropy 16x (bit 4) multiplies the padding by 8.
|
||||
assert_eq!(stitch(&[16], 4, 4, 1 << 14).unwrap().padding, 128);
|
||||
assert_eq!(stitch(&[16; 2], 0, 0, 16), None);
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,7 @@ void main() {
|
||||
let post = horizon_shaders::load_post(&pack, "", &properties, ¯os, &inputs);
|
||||
assert!(post.terrain.is_some(), "{:?}", post.notes);
|
||||
let world = World {
|
||||
sprite_rects: &[],
|
||||
atlas: Some((atlases[2].view.clone(), atlases[2].size())),
|
||||
material_maps: Some([atlases[1].view.clone(), atlases[0].view.clone()]),
|
||||
lightmap: atlases[2].view.clone(),
|
||||
@@ -148,7 +149,8 @@ void main() {
|
||||
post,
|
||||
wgpu::TextureFormat::Rgba8Unorm,
|
||||
world,
|
||||
);
|
||||
)
|
||||
.expect("the pack runs");
|
||||
let mut pass = post
|
||||
.terrain
|
||||
.take()
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use glam::{DMat4, DVec3};
|
||||
use horizon_shaders::{Format, Group, Kind, Post, SHADOW_COLORS, Val};
|
||||
use horizon_shaders::{Format, Group, Kind, Post, SHADOW_COLORS};
|
||||
|
||||
use super::pack_draw::DrawUniforms;
|
||||
use super::pack_entities::{PROGRAMS, PackEntities};
|
||||
@@ -29,12 +29,17 @@ use super::pack_uniforms::{self, FrameUniforms, PackInputs};
|
||||
|
||||
const BUFFERS: usize = horizon_shaders::BUFFERS;
|
||||
|
||||
mod packed;
|
||||
|
||||
#[cfg(test)]
|
||||
mod world_target_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod higher_shadowcolor_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod shadow_faces_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod forced_flip_tests;
|
||||
|
||||
@@ -436,6 +441,9 @@ struct Targets {
|
||||
pub(super) struct World<'a> {
|
||||
/// The block atlas and its size.
|
||||
pub atlas: Option<(wgpu::TextureView, [u32; 2])>,
|
||||
/// Of a stitched block atlas, each sprite layer's place (`PackAtlas::rects_table`); empty
|
||||
/// for a grid.
|
||||
pub sprite_rects: &'a [u32],
|
||||
pub material_maps: Option<[wgpu::TextureView; 2]>,
|
||||
pub lightmap: wgpu::TextureView,
|
||||
/// Terrain's geometry, when the pack draws it.
|
||||
@@ -562,9 +570,9 @@ pub struct PackPost {
|
||||
down: HashMap<wgpu::TextureFormat, (wgpu::RenderPipeline, wgpu::BindGroupLayout)>,
|
||||
/// The per-draw uniforms of gbuffers programs.
|
||||
draws: DrawUniforms,
|
||||
/// Set 3 of flat terrain (`mc_Entity`'s table) and its bind group for each arena page,
|
||||
/// with the page buffer it was made for.
|
||||
block_ids: Option<(wgpu::BindGroupLayout, wgpu::Buffer)>,
|
||||
/// Set 3 of flat terrain (`mc_Entity`'s table, then the sprites' places) and its bind group
|
||||
/// for each arena page, with the page buffer it was made for.
|
||||
block_ids: Option<(wgpu::BindGroupLayout, wgpu::Buffer, wgpu::Buffer)>,
|
||||
page_groups: HashMap<u32, (wgpu::Buffer, wgpu::BindGroup)>,
|
||||
gui_size: [u32; 2],
|
||||
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
|
||||
@@ -1030,22 +1038,36 @@ pub(super) struct ShadowView {
|
||||
/// Draw everything, every face, within this many blocks (`shadow.culling=false|reversed`):
|
||||
/// packs that voxelize the world in their shadow pass need all of it.
|
||||
pub unculled: Option<f32>,
|
||||
/// Every face of the drawn sections, not only those that can face the light. Iris 1.11.7's
|
||||
/// shadow map holds the faces turned away from the light too (#9033): under a hill at low sun
|
||||
/// a cave's ceiling, which faces away, is often all that shadows the cave.
|
||||
pub every_face: bool,
|
||||
}
|
||||
|
||||
/// Set 3 of flat terrain, read by the vertex stage: the sprite layers' table (`mc_Entity`'s id
|
||||
/// and fluid, `at_midBlock.w`; -1, -1, 0 when empty) and, per arena page, the page's vertices
|
||||
/// (a vertex reads its quad's corners for its normal).
|
||||
fn block_ids(device: &wgpu::Device, table: &[i32]) -> (wgpu::BindGroupLayout, wgpu::Buffer) {
|
||||
/// and fluid, `at_midBlock.w`; -1, -1, 0 when empty), per arena page, the page's vertices
|
||||
/// (a vertex reads its quad's corners for its normal), and each sprite's place in a stitched
|
||||
/// atlas (`rects`; zeros for a grid, which doesn't read them).
|
||||
fn block_ids(
|
||||
device: &wgpu::Device,
|
||||
table: &[i32],
|
||||
rects: &[u32],
|
||||
) -> (wgpu::BindGroupLayout, wgpu::Buffer, wgpu::Buffer) {
|
||||
let empty = [-1, -1, 0];
|
||||
let table = if table.is_empty() { &empty[..] } else { table };
|
||||
let buffer = wgpu::util::DeviceExt::create_buffer_init(
|
||||
device,
|
||||
&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Shader pack block ids"),
|
||||
contents: bytemuck::cast_slice(table),
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
},
|
||||
);
|
||||
let buffer = |label, contents: &[u8]| {
|
||||
wgpu::util::DeviceExt::create_buffer_init(
|
||||
device,
|
||||
&wgpu::util::BufferInitDescriptor {
|
||||
label: Some(label),
|
||||
contents,
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
},
|
||||
)
|
||||
};
|
||||
let rects = if rects.is_empty() { &[0, 0][..] } else { rects };
|
||||
let ids = buffer("Shader pack block ids", bytemuck::cast_slice(table));
|
||||
let rects = buffer("Shader pack sprite rects", bytemuck::cast_slice(rects));
|
||||
let storage = |binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::VERTEX,
|
||||
@@ -1058,9 +1080,9 @@ fn block_ids(device: &wgpu::Device, table: &[i32]) -> (wgpu::BindGroupLayout, wg
|
||||
};
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Shader pack terrain"),
|
||||
entries: &[storage(0), storage(1)],
|
||||
entries: &[storage(0), storage(1), storage(2)],
|
||||
});
|
||||
(layout, buffer)
|
||||
(layout, ids, rects)
|
||||
}
|
||||
|
||||
/// The game's geometry a gbuffers program draws: its vertex buffers and, for flat draws
|
||||
@@ -1094,6 +1116,8 @@ struct Resources<'a> {
|
||||
storable: u16,
|
||||
/// The buffers of a three-channel format (`rgb`): they have no alpha to write, and read 1.
|
||||
rgb: u16,
|
||||
/// The bits per channel a buffer of a small packed format keeps ([`packed::bits`]).
|
||||
packed: [Option<[u8; 4]>; BUFFERS],
|
||||
/// The stage (`texture.<stage>`) of the programs built so far, and the buffers one of them
|
||||
/// flipped: a `texture.<stage>.colortexN` no longer stands in for buffer N in the stage's
|
||||
/// later programs, which read what was written, as in Iris. `final` continues
|
||||
@@ -1178,9 +1202,18 @@ fn bindings(
|
||||
let released = pack.released.get();
|
||||
let custom = pack_textures::find(textures, group, &s.ty, &s.name).filter(|&i| {
|
||||
let t = &textures[i];
|
||||
t.stage != pack_textures::stage(group)
|
||||
|| released.0 != t.stage
|
||||
|| horizon_shaders::buffer_index(&t.name).is_none_or(|b| released.1 & 1 << b == 0)
|
||||
// A PNG under `texture.<stage>.<name>` whose name is no sampler of the game's
|
||||
// isn't bound: the sampler reads as if it had no texture. Iris 1.11.7 with a probe
|
||||
// pack of ours (#9064) bound it over `gaux2` and `noisetex` but not over names of its
|
||||
// own, where a raw texture was bound; this rule is a fit to those cases.
|
||||
let unbound = t.png
|
||||
&& t.stage != horizon_shaders::EVERY_STAGE
|
||||
&& source(&s.name, false, true, false) == Source::Dummy;
|
||||
!unbound
|
||||
&& (t.stage != pack_textures::stage(group)
|
||||
|| released.0 != t.stage
|
||||
|| horizon_shaders::buffer_index(&t.name)
|
||||
.is_none_or(|b| released.1 & 1 << b == 0))
|
||||
});
|
||||
let image = pack
|
||||
.images
|
||||
@@ -1490,6 +1523,8 @@ fn build_pass(
|
||||
let final_pass = p.group == Group::Final;
|
||||
let mut targets = Vec::new();
|
||||
let mut writes = Vec::new();
|
||||
// Outputs into a buffer of a small packed format, and the bits it keeps.
|
||||
let mut packed_outputs = Vec::new();
|
||||
for &location in &interface.outputs {
|
||||
let l = location as usize;
|
||||
if targets.len() <= l {
|
||||
@@ -1521,6 +1556,11 @@ fn build_pass(
|
||||
{
|
||||
t.blend = Some(blend_state(b));
|
||||
}
|
||||
if !p.group.shadow()
|
||||
&& let Some(bits) = pack.packed[buffer as usize]
|
||||
{
|
||||
packed_outputs.push((location, bits));
|
||||
}
|
||||
targets[l] = state;
|
||||
writes.push((location, buffer as usize));
|
||||
}
|
||||
@@ -1580,7 +1620,9 @@ fn build_pass(
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(m)),
|
||||
})
|
||||
};
|
||||
let (vs, fs) = (module(program.vertex), module(program.fragment));
|
||||
let mut fragment = program.fragment;
|
||||
packed::quantise_outputs(&mut fragment, &packed_outputs);
|
||||
let (vs, fs) = (module(program.vertex), module(fragment));
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(&p.name),
|
||||
layout: Some(&pipeline_layout),
|
||||
@@ -1826,8 +1868,16 @@ fn build_compute(
|
||||
})
|
||||
}
|
||||
|
||||
/// Why a pack can't run, and the notes of loading it until then (why programs were skipped).
|
||||
#[derive(Debug)]
|
||||
pub struct Refused {
|
||||
pub reason: String,
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
impl PackPost {
|
||||
/// Builds the pipelines of `post` for a screen of `screen` format.
|
||||
/// Builds the pipelines of `post` for a screen of `screen` format. An error when the pack
|
||||
/// can't show anything on the screen: the game turns it off, as a pack that fails to load.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
@@ -1835,7 +1885,7 @@ impl PackPost {
|
||||
mut post: Post,
|
||||
screen: wgpu::TextureFormat,
|
||||
world: World<'_>,
|
||||
) -> Self {
|
||||
) -> Result<Self, Refused> {
|
||||
// What the pack's shaders need of its storage buffers, before its parts move out.
|
||||
let minimums = post.buffer_minimums();
|
||||
let features = device.features();
|
||||
@@ -1851,6 +1901,10 @@ impl PackPost {
|
||||
let rgb = (0..BUFFERS)
|
||||
.filter(|&i| post.config.buffers[i].format.channels == 3)
|
||||
.fold(0u16, |m, i| m | 1 << i);
|
||||
// A small packed format's buffer reads back fewer bits, as in Iris: its clears and
|
||||
// what its passes write keep only those.
|
||||
let packed: [Option<[u8; 4]>; BUFFERS] =
|
||||
std::array::from_fn(|i| packed::bits(post.config.buffers[i].format));
|
||||
let clears = std::array::from_fn(|i| {
|
||||
let b = &post.config.buffers[i];
|
||||
b.clear.then(|| {
|
||||
@@ -1858,7 +1912,7 @@ impl PackPost {
|
||||
if rgb & 1 << i != 0 {
|
||||
c[3] = 1.0;
|
||||
}
|
||||
c
|
||||
packed[i].map_or(c, |bits| packed::quantise(c, bits))
|
||||
})
|
||||
});
|
||||
let mut notes = post.notes;
|
||||
@@ -1875,7 +1929,7 @@ impl PackPost {
|
||||
let mut custom = post.custom;
|
||||
let custom_names: Vec<String> = custom.uniforms().map(|u| u.0.to_owned()).collect();
|
||||
// A first run finds the names neither the frame nor the pack defines.
|
||||
custom.eval(0.0, &|n| pack_uniforms::index(n).map(|_| Val::scalar(0.0)));
|
||||
custom.eval(0.0, &pack_uniforms::zero);
|
||||
// Its zeros are no start for `smooth`: the first frame's values are, as in Iris.
|
||||
custom.restart_smoothing();
|
||||
if !custom.notes.is_empty() {
|
||||
@@ -1963,6 +2017,7 @@ impl PackPost {
|
||||
gpu,
|
||||
storable,
|
||||
rgb,
|
||||
packed,
|
||||
released: std::cell::Cell::new(("", 0)),
|
||||
stand_ins: &stand_ins,
|
||||
shadow_linear: if features.contains(wgpu::Features::FLOAT32_FILTERABLE) {
|
||||
@@ -2058,7 +2113,7 @@ impl PackPost {
|
||||
.terrain
|
||||
.as_ref()
|
||||
.filter(|g| g.table.is_some())
|
||||
.map(|_| block_ids(device, world.block_ids));
|
||||
.map(|_| block_ids(device, world.block_ids, world.sprite_rects));
|
||||
let terrain_geometry = world.terrain.as_ref().map(|g| Geometry {
|
||||
buffers: g.buffers,
|
||||
table: g.table,
|
||||
@@ -2076,13 +2131,24 @@ impl PackPost {
|
||||
.water
|
||||
.zip(terrain_geometry.as_ref())
|
||||
.and_then(|(p, g)| build(p, Some(g)));
|
||||
// The shadow pass draws terrain faces from both sides, as Iris 1.11.7 does (#9033): a
|
||||
// cave ceiling faces away from the sun, and is often all that shadows the cave.
|
||||
let shadow_geometry = terrain_geometry.as_ref().map(|g| Geometry {
|
||||
buffers: g.buffers,
|
||||
table: g.table,
|
||||
depth_write: g.depth_write,
|
||||
atlas: g.atlas.clone(),
|
||||
material_maps: g.material_maps,
|
||||
ids: g.ids,
|
||||
cull: false,
|
||||
});
|
||||
let shadow_terrain = post
|
||||
.shadow
|
||||
.zip(terrain_geometry.as_ref())
|
||||
.zip(shadow_geometry.as_ref())
|
||||
.and_then(|(p, g)| build(p, Some(g)));
|
||||
let shadow_water = post
|
||||
.shadow_water
|
||||
.zip(terrain_geometry.as_ref())
|
||||
.zip(shadow_geometry.as_ref())
|
||||
.and_then(|(p, g)| build(p, Some(g)));
|
||||
let (hides_sky, hides_celestial) = (post.sky.is_some(), post.sky_textured.is_some());
|
||||
let sky = (post.sky.is_some() || post.sky_textured.is_some()).then(|| {
|
||||
@@ -2242,6 +2308,22 @@ impl PackPost {
|
||||
.filter(|p| !skip.split(',').any(|s| s == p.name))
|
||||
.filter_map(|p| build(p, None))
|
||||
.collect();
|
||||
// The screen gets colortex0 through `final`, or else through Horizon's copy, which reads
|
||||
// floats: an integer colortex0 (`RGBA32UI`...) with no `final` left to draw can't reach
|
||||
// it (#9165).
|
||||
let shows = passes
|
||||
.iter()
|
||||
.any(|p| p.group == Group::Final && matches!(p.pipeline, Pipeline::Render(_)));
|
||||
if !shows && !holds_floats(formats[0]) {
|
||||
return Err(Refused {
|
||||
reason: format!(
|
||||
"colortex0 has an integer format ({:?}) and no `final` program draws it to \
|
||||
the screen",
|
||||
formats[0]
|
||||
),
|
||||
notes,
|
||||
});
|
||||
}
|
||||
let shadow_comp: Vec<Pass> = post
|
||||
.shadow_comp
|
||||
.into_iter()
|
||||
@@ -2538,7 +2620,7 @@ impl PackPost {
|
||||
let white = [255u8; 4];
|
||||
let black = [0u8; 4];
|
||||
let flat_normal = [128u8, 128, 255, 255];
|
||||
Self {
|
||||
Ok(Self {
|
||||
notes,
|
||||
setup_pending: true,
|
||||
passes,
|
||||
@@ -2701,7 +2783,7 @@ impl PackPost {
|
||||
u
|
||||
},
|
||||
shadow_cache: Default::default(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Writes `pass`'s uniforms for a target of `out` pixels; its bind group for the buffers'
|
||||
@@ -3723,6 +3805,7 @@ impl PackPost {
|
||||
horizon_shaders::ShadowCulling::Off => Some(f32::INFINITY),
|
||||
horizon_shaders::ShadowCulling::Reversed => Some(shadow.voxel_distance),
|
||||
},
|
||||
every_face: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4195,7 +4278,7 @@ impl PackPost {
|
||||
terrain: &mut TerrainDraw<'_>,
|
||||
) {
|
||||
self.resize(device, size);
|
||||
if let Some((layout, ids)) = &self.block_ids {
|
||||
if let Some((layout, ids, rects)) = &self.block_ids {
|
||||
// A page that grew has a new buffer.
|
||||
self.page_groups
|
||||
.retain(|p, (b, _)| pages.iter().any(|(q, c)| q == p && c == b));
|
||||
@@ -4213,6 +4296,10 @@ impl PackPost {
|
||||
binding: 1,
|
||||
resource: buffer.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: rects.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
(buffer.clone(), group)
|
||||
@@ -4571,14 +4658,8 @@ impl PackPost {
|
||||
.as_ref()
|
||||
.map(|t| &t[main(m, i) ^ usize::from(alt)])
|
||||
};
|
||||
// An integer colortex0 has no colour to show: the screen is only cleared.
|
||||
if !shown && !holds_floats(self.formats[0]) {
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Shader pack colortex0"),
|
||||
color_attachments: &[attach(screen, overwrite)],
|
||||
..Default::default()
|
||||
});
|
||||
} else if !shown {
|
||||
// Without `final`, colortex0 holds floats (`new` refuses an integer one).
|
||||
if !shown {
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Shader pack colortex0"),
|
||||
layout: &self.copy.1,
|
||||
@@ -4726,6 +4807,7 @@ mod tests {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
blur: false,
|
||||
clamp: false,
|
||||
png: false,
|
||||
image: pack_textures::Image::Atlas,
|
||||
}];
|
||||
let interface = horizon_shaders::Interface {
|
||||
@@ -4758,6 +4840,7 @@ mod tests {
|
||||
gpu: Gpu::of(&adapter, device.features()),
|
||||
storable: 0,
|
||||
rgb: 0,
|
||||
packed: [None; BUFFERS],
|
||||
released: std::cell::Cell::new(("", 0)),
|
||||
stand_ins: &stand_ins,
|
||||
shadow_linear: 0,
|
||||
@@ -4786,6 +4869,102 @@ mod tests {
|
||||
assert!(reads(Group::Final) == Source::Color(0));
|
||||
}
|
||||
|
||||
/// Iris 1.11.7 (a probe pack of ours, #9064) didn't bind a PNG under
|
||||
/// `texture.<stage>.<name>` when `name` was a name of the pack's own, no sampler of the
|
||||
/// game's; a raw texture there, a PNG over `gaux2` or `noisetex`, and a `customTexture` PNG
|
||||
/// were bound.
|
||||
#[test]
|
||||
fn a_png_under_a_name_of_its_own_is_not_bound() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&Default::default()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let texture = |stage: &str, name: &str, png: bool| pack_textures::PackTexture {
|
||||
stage: stage.into(),
|
||||
name: name.into(),
|
||||
dimension: wgpu::TextureViewDimension::D2,
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
blur: !png,
|
||||
clamp: !png,
|
||||
png,
|
||||
image: pack_textures::Image::Atlas,
|
||||
};
|
||||
let textures = [
|
||||
texture("composite", "tp", true),
|
||||
texture("composite", "tr", false),
|
||||
texture("composite", "gaux2", true),
|
||||
texture("composite", "noisetex", true),
|
||||
texture(horizon_shaders::EVERY_STAGE, "cp", true),
|
||||
];
|
||||
let names = ["tp", "tr", "gaux2", "noisetex", "cp"];
|
||||
let interface = horizon_shaders::Interface {
|
||||
uniform_size: 48,
|
||||
uniforms: Vec::new(),
|
||||
samplers: names
|
||||
.iter()
|
||||
.zip(1..)
|
||||
.map(|(name, k)| horizon_shaders::Sampler {
|
||||
name: (*name).into(),
|
||||
ty: "sampler2D".into(),
|
||||
binding: 2 * k - 1,
|
||||
})
|
||||
.collect(),
|
||||
outputs: vec![0],
|
||||
draw: false,
|
||||
images: Vec::new(),
|
||||
buffers: Vec::new(),
|
||||
initial_values: vec![0; 48],
|
||||
};
|
||||
let buffers = pack_images::Buffers::new(
|
||||
&device,
|
||||
Default::default(),
|
||||
Default::default(),
|
||||
wgpu::ShaderStages::FRAGMENT,
|
||||
);
|
||||
let stand_ins = std::cell::RefCell::new(Vec::new());
|
||||
let pack = Resources {
|
||||
textures: &textures,
|
||||
images: &[],
|
||||
left_out: &[],
|
||||
buffers: &buffers,
|
||||
indices: None,
|
||||
gpu: Gpu::of(&adapter, device.features()),
|
||||
storable: 0,
|
||||
rgb: 0,
|
||||
released: std::cell::Cell::new(("", 0)),
|
||||
stand_ins: &stand_ins,
|
||||
shadow_linear: 0,
|
||||
packed: [None; BUFFERS],
|
||||
};
|
||||
let (_, samplers, ..) = bindings(
|
||||
device.features(),
|
||||
&interface,
|
||||
"final",
|
||||
Group::Final,
|
||||
&[0],
|
||||
None,
|
||||
&[T::Rgba8Unorm; BUFFERS],
|
||||
None,
|
||||
&mut Vec::new(),
|
||||
&pack,
|
||||
)
|
||||
.expect("the pass is kept");
|
||||
let read: Vec<Source> = samplers.iter().map(|b| b.source).collect();
|
||||
assert!(
|
||||
read == [
|
||||
Source::Dummy,
|
||||
Source::Atlas,
|
||||
Source::Atlas,
|
||||
Source::Atlas,
|
||||
Source::Atlas
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// Iris 1.11.7 runs a program whose image no `image.*` gives, and its loads read zero
|
||||
/// (observed with a probe pack). Its pass is kept, the image bound to a stand-in of its
|
||||
/// declared format, zero again before each pass even after one stored into it.
|
||||
@@ -4838,6 +5017,7 @@ mod tests {
|
||||
gpu: Gpu::of(&adapter, device.features()),
|
||||
storable: 0,
|
||||
rgb: 0,
|
||||
packed: [None; BUFFERS],
|
||||
released: std::cell::Cell::new(("", 0)),
|
||||
stand_ins: &stand_ins,
|
||||
shadow_linear: 0,
|
||||
@@ -4979,6 +5159,7 @@ mod tests {
|
||||
gpu: Gpu::of(&adapter, device.features()),
|
||||
storable: 0,
|
||||
rgb: 0,
|
||||
packed: [None; BUFFERS],
|
||||
released: std::cell::Cell::new(("", 0)),
|
||||
stand_ins: &stand_ins,
|
||||
shadow_linear: 0,
|
||||
@@ -5151,6 +5332,9 @@ mod material_tests;
|
||||
#[cfg(test)]
|
||||
mod rgb_alpha_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod packed_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod trinary_tests;
|
||||
|
||||
|
||||
@@ -83,7 +83,8 @@ impl Gpu {
|
||||
post,
|
||||
T::Rgba8Unorm,
|
||||
world,
|
||||
);
|
||||
)
|
||||
.expect("the pack runs");
|
||||
assert!(
|
||||
built.passes.iter().any(|p| p.group == Group::Final),
|
||||
"{:?}",
|
||||
@@ -334,6 +335,7 @@ fn check(sampler: &str, stage: Stage, scene: Scene, expected: [u8; 4]) {
|
||||
cull: false,
|
||||
}),
|
||||
block_ids: &[],
|
||||
sprite_rects: &[],
|
||||
celestial: None,
|
||||
gui_size: SIZE,
|
||||
clouds: None,
|
||||
@@ -707,6 +709,7 @@ fn shadow_depth_filtering(nearest: &str) -> Option<Vec<[u8; 4]>> {
|
||||
let mut post = gpu.load(
|
||||
files,
|
||||
World {
|
||||
sprite_rects: &[],
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap: color.clone(),
|
||||
|
||||
@@ -199,8 +199,33 @@ impl Fixture {
|
||||
}
|
||||
|
||||
pub(super) fn build(&self, post: Post) -> PackPost {
|
||||
let post = self.try_build(post).expect("the pack runs");
|
||||
assert!(
|
||||
post.notes.iter().all(|n| n.starts_with("shadow map ")),
|
||||
"{:?}",
|
||||
post.notes
|
||||
);
|
||||
post
|
||||
}
|
||||
|
||||
/// [`Self::build`] for a pack that may be refused.
|
||||
pub(super) fn try_build(&self, post: Post) -> Result<PackPost, Refused> {
|
||||
self.try_build_with_terrain(post, None)
|
||||
}
|
||||
|
||||
/// `build`, with the game's terrain geometry handed to the pack.
|
||||
pub(super) fn build_with_terrain(&self, post: Post, terrain: Option<Geometry<'_>>) -> PackPost {
|
||||
self.try_build_with_terrain(post, terrain)
|
||||
.expect("the pack runs")
|
||||
}
|
||||
|
||||
fn try_build_with_terrain(
|
||||
&self,
|
||||
post: Post,
|
||||
terrain: Option<Geometry<'_>>,
|
||||
) -> Result<PackPost, Refused> {
|
||||
let lightmap = self.texture([1, 1], T::Rgba8Unorm);
|
||||
let post = PackPost::new(
|
||||
PackPost::new(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
&self.adapter,
|
||||
@@ -210,24 +235,29 @@ impl Fixture {
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap,
|
||||
terrain: None,
|
||||
terrain,
|
||||
block_ids: &[],
|
||||
sprite_rects: &[],
|
||||
celestial: None,
|
||||
gui_size: [16, 16],
|
||||
clouds: None,
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
post.notes.iter().all(|n| n.starts_with("shadow map ")),
|
||||
"{:?}",
|
||||
post.notes
|
||||
);
|
||||
post
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn render(&self, post: &mut PackPost, size: [u32; 2]) -> Vec<[u8; 4]> {
|
||||
self.render_with_terrain(post, size, &mut |_, _, _, _| {})
|
||||
}
|
||||
|
||||
/// `render`, with `terrain` drawing the game's terrain lists.
|
||||
pub(super) fn render_with_terrain(
|
||||
&self,
|
||||
post: &mut PackPost,
|
||||
size: [u32; 2],
|
||||
terrain: &mut TerrainDraw<'_>,
|
||||
) -> Vec<[u8; 4]> {
|
||||
let world = self.texture(size, T::Rgba8Unorm);
|
||||
let depth = self.texture(size, T::Depth32Float);
|
||||
let screen = self.texture(size, T::Rgba8Unorm);
|
||||
@@ -252,7 +282,7 @@ impl Fixture {
|
||||
size,
|
||||
size,
|
||||
&[],
|
||||
&mut |_, _, _, _| {},
|
||||
terrain,
|
||||
);
|
||||
encoder.copy_texture_to_buffer(
|
||||
screen.texture().as_image_copy(),
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
//! A pack may give colortex0 an integer format (OptiFine's `shaders.txt`, "Texture Formats",
|
||||
//! lists `RGBA32UI`, as #8459 quotes it): the pack still loads, and reads back what it wrote
|
||||
//! there. #8459's probe; #8459 reports Iris 1.11.7 drawing (0, 200, 0) at every point
|
||||
//! sampled from it.
|
||||
//! sampled from it. Without a `final` that draws it, the pack is refused (#9165).
|
||||
use super::destination_tests::Fixture;
|
||||
use super::*;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
|
||||
|
||||
/// A `final` that shows the integer colortex0.
|
||||
const FINAL: &str = "#version 330 compatibility\nuniform usampler2D colortex0;\n\
|
||||
void main() { gl_FragColor = vec4(texelFetch(colortex0, ivec2(gl_FragCoord.xy), 0)) / 255.0; }\n";
|
||||
|
||||
/// A `final` that doesn't compile (it calls a function nobody declares), so it is skipped.
|
||||
const BROKEN_FINAL: &str =
|
||||
"#version 330 compatibility\nvoid main() { gl_FragColor = undeclared_function(); }\n";
|
||||
|
||||
/// `depth`: the composite reads depthtex0, so the blit copies the world's depth; `last`: the
|
||||
/// pack has a `final` that shows colortex0 (without, Horizon shows colortex0 itself).
|
||||
fn pack(depth: bool, last: bool) -> Post {
|
||||
/// pack's `final`, if it has one.
|
||||
fn pack(depth: bool, last: Option<&str>) -> Post {
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut add = |name: &str, source: &str| {
|
||||
zip.start_file(
|
||||
@@ -34,13 +42,9 @@ fn pack(depth: bool, last: bool) -> Post {
|
||||
uniform sampler2D depthtex0;\nlayout(location = 0) out uvec4 o;\nvoid main() {{ o = {value}; }}\n"
|
||||
),
|
||||
);
|
||||
if last {
|
||||
if let Some(source) = last {
|
||||
add("final.vsh", VERTEX);
|
||||
add(
|
||||
"final.fsh",
|
||||
"#version 330 compatibility\nuniform usampler2D colortex0;\n\
|
||||
void main() { gl_FragColor = vec4(texelFetch(colortex0, ivec2(gl_FragCoord.xy), 0)) / 255.0; }\n",
|
||||
);
|
||||
add("final.fsh", source);
|
||||
}
|
||||
let pack = horizon_shaders::Pack::from_zip_bytes(
|
||||
"original-integer-colortex0",
|
||||
@@ -49,21 +53,14 @@ fn pack(depth: bool, last: bool) -> Post {
|
||||
.unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let post = horizon_shaders::load_post(&pack, "", &properties, ¯os, &Default::default());
|
||||
assert_eq!(
|
||||
post.programs.len(),
|
||||
1 + usize::from(last),
|
||||
"{:?}",
|
||||
post.notes
|
||||
);
|
||||
post
|
||||
horizon_shaders::load_post(&pack, "", &properties, ¯os, &Default::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_integer_colortex0_keeps_what_the_pack_wrote() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
for depth in [false, true] {
|
||||
let mut post = gpu.build(pack(depth, true));
|
||||
let mut post = gpu.build(pack(depth, Some(FINAL)));
|
||||
// Twice: the second frame's world blit runs over a colortex0 the first frame wrote.
|
||||
for frame in 0..2 {
|
||||
let pixels = gpu.render(&mut post, [8, 8]);
|
||||
@@ -73,13 +70,20 @@ fn an_integer_colortex0_keeps_what_the_pack_wrote() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Without a `final`, there are no colours to show from an integer colortex0; what Iris shows
|
||||
/// then was not measured. The frame must still run.
|
||||
/// Without a `final` left to draw it (none, or one that is skipped), an integer colortex0 has
|
||||
/// no colours the screen can show: the pack is refused, so the game turns it off and draws
|
||||
/// without it instead of a black screen (#9165). The refusal keeps why `final` was skipped.
|
||||
#[test]
|
||||
fn an_integer_colortex0_without_final_still_runs() {
|
||||
fn an_integer_colortex0_without_a_working_final_is_refused() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
let mut post = gpu.build(pack(false, false));
|
||||
for _ in 0..2 {
|
||||
gpu.render(&mut post, [8, 8]);
|
||||
for last in [None, Some(BROKEN_FINAL)] {
|
||||
let Err(refused) = gpu.try_build(pack(false, last)) else {
|
||||
panic!("{last:?}: a pack that can't show colortex0 runs");
|
||||
};
|
||||
assert_eq!(
|
||||
refused.notes.iter().any(|n| n.starts_with("final")),
|
||||
last.is_some(),
|
||||
"{refused:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! The small packed colortex formats `RGB5_A1`, `RGB565` and `R3_G3_B2`, read back as in Iris
|
||||
//! 1.11.7. wgpu has no such formats, so these buffers are 8-bit (`texture_format`); what a pass
|
||||
//! writes into one and its clear colour go through [`quantise`] first.
|
||||
//!
|
||||
//! Probe pack p8106c of our own wrote 600 values to each format and read them back with
|
||||
//! `texelFetch` in Iris 1.11.7 (NVIDIA 615.78.08). Our probe's Iris values fit this, with no
|
||||
//! exception: each channel becomes the byte an `RGBA8` buffer stores for it, keeps that byte's
|
||||
//! top bits (5, 5, 5 and 1 for `RGB5_A1`; 5, 6 and 5 for `RGB565` and also for `R3_G3_B2`), and
|
||||
//! reads back those bits repeated to fill 8 (`0.3337` → byte 85 → 10 of 31 → 82 / 255). This is
|
||||
//! a fit to one driver's values, not a rule Iris states. `RGBA4` and `RGBA2` read back the `RGBA8`
|
||||
//! bytes in the same probe, as Horizon's 8-bit buffers already do.
|
||||
|
||||
use horizon_shaders::{Format, Kind};
|
||||
use naga::{BinaryOperator, Expression, Literal, MathFunction, Statement, VectorSize};
|
||||
|
||||
/// The bits each channel keeps in a buffer of format `f`, for the formats that keep fewer than
|
||||
/// 8; a three-channel format's alpha keeps 8 (it reads 1 anyway).
|
||||
pub(super) fn bits(f: Format) -> Option<[u8; 4]> {
|
||||
match (f.kind, f.channels, f.bits) {
|
||||
(Kind::Unorm, 4, 5) => Some([5, 5, 5, 1]),
|
||||
// `RGB565`, and `R3_G3_B2` (3 × 3 bits in `config.rs`), which our probe's Iris values
|
||||
// put in 5, 6 and 5 bits too.
|
||||
(Kind::Unorm, 3, 6 | 3) => Some([5, 6, 5, 8]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The value a buffer keeping `bits` per channel reads back after `v` is written or cleared.
|
||||
pub(super) fn quantise(v: [f32; 4], bits: [u8; 4]) -> [f32; 4] {
|
||||
std::array::from_fn(|i| {
|
||||
let byte = (v[i].clamp(0.0, 1.0) * 255.0 + 0.5).floor() as u32;
|
||||
let n = u32::from(bits[i]);
|
||||
let kept = byte >> (8 - n);
|
||||
// The kept bits, repeated until there are 8.
|
||||
let mut wide = 0;
|
||||
let mut have = 0;
|
||||
while have < 8 {
|
||||
wide = wide << n | kept;
|
||||
have += n;
|
||||
}
|
||||
(wide >> (have - 8)) as f32 / 255.0
|
||||
})
|
||||
}
|
||||
|
||||
/// The factors of the shader's [`quantise`] for a channel keeping `n` bits: the kept value is
|
||||
/// `floor(byte / step)`, and it reads back `kept × step + floor(kept × repeat)`.
|
||||
fn factors(n: u8) -> (f32, f32) {
|
||||
let step = f32::from(1u16 << (8 - n));
|
||||
// `kept × repeat` is the kept bits shifted down to fill what `kept × step` leaves: one bit
|
||||
// fills the other 7 (× 127); 5 or 6 bits fill 3 or 2 with their top bits.
|
||||
let repeat = match n {
|
||||
1 => 127.0,
|
||||
_ => 2f32.powi(8 - 2 * i32::from(n)),
|
||||
};
|
||||
(step, repeat)
|
||||
}
|
||||
|
||||
/// Makes the fragment entry point of `module` write each output location of `outputs` as
|
||||
/// [`quantise`] gives it for its bits. The translator's entry point ends in one `return` of its
|
||||
/// outputs; a module without one is left as it is.
|
||||
pub(super) fn quantise_outputs(module: &mut naga::Module, outputs: &[(u32, [u8; 4])]) {
|
||||
let Some(ep) =
|
||||
(module.entry_points.iter_mut()).find(|e| e.stage == naga::ShaderStage::Fragment)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(result) = &ep.function.result else {
|
||||
return;
|
||||
};
|
||||
let naga::TypeInner::Struct { members, .. } = &module.types[result.ty].inner else {
|
||||
return;
|
||||
};
|
||||
let vec4 = |ty: naga::Handle<naga::Type>| {
|
||||
matches!(
|
||||
module.types[ty].inner,
|
||||
naga::TypeInner::Vector {
|
||||
size: VectorSize::Quad,
|
||||
scalar: naga::Scalar::F32
|
||||
}
|
||||
)
|
||||
};
|
||||
// (member, bits, the member's type) of each output to change.
|
||||
let changed: Vec<(usize, [u8; 4], naga::Handle<naga::Type>)> = (members.iter().enumerate())
|
||||
.filter_map(|(i, m)| {
|
||||
let Some(naga::Binding::Location { location, .. }) = m.binding else {
|
||||
return None;
|
||||
};
|
||||
let bits = outputs.iter().find(|o| o.0 == location)?.1;
|
||||
vec4(m.ty).then_some((i, bits, m.ty))
|
||||
})
|
||||
.collect();
|
||||
if changed.is_empty() {
|
||||
return;
|
||||
}
|
||||
let struct_ty = result.ty;
|
||||
let f = &mut ep.function;
|
||||
let body = std::mem::take(&mut f.body);
|
||||
let mut out = naga::Block::with_capacity(body.len() + 1);
|
||||
for (statement, span) in body.span_into_iter() {
|
||||
let Statement::Return { value: Some(value) } = statement else {
|
||||
out.push(statement, span);
|
||||
continue;
|
||||
};
|
||||
let Expression::Compose { components, .. } = &f.expressions[value] else {
|
||||
out.push(statement, span);
|
||||
continue;
|
||||
};
|
||||
let mut components = components.clone();
|
||||
let e = &mut f.expressions;
|
||||
let lit = |e: &mut naga::Arena<Expression>, v: f32| {
|
||||
e.append(Expression::Literal(Literal::F32(v)), span)
|
||||
};
|
||||
// Literals need no emitting: they come before the range that is emitted.
|
||||
let scalars: Vec<_> = (changed.iter())
|
||||
.map(|&(_, bits, _)| {
|
||||
let per = bits.map(factors);
|
||||
(
|
||||
per.map(|(step, _)| lit(e, step)),
|
||||
per.map(|(step, _)| lit(e, 1.0 / step)),
|
||||
per.map(|(_, repeat)| lit(e, repeat)),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let (zero, one, max, half) = (lit(e, 0.0), lit(e, 1.0), lit(e, 255.0), lit(e, 0.5));
|
||||
let mut add = |x: Expression| e.append(x, span);
|
||||
let splat = |add: &mut dyn FnMut(Expression) -> _, value| {
|
||||
add(Expression::Splat {
|
||||
size: VectorSize::Quad,
|
||||
value,
|
||||
})
|
||||
};
|
||||
let (zero, one, max, half) = (
|
||||
splat(&mut add, zero),
|
||||
splat(&mut add, one),
|
||||
splat(&mut add, max),
|
||||
splat(&mut add, half),
|
||||
);
|
||||
let binary = |add: &mut dyn FnMut(Expression) -> _, op, left, right| {
|
||||
add(Expression::Binary { op, left, right })
|
||||
};
|
||||
let math = |add: &mut dyn FnMut(Expression) -> _, fun, arg, arg1, arg2| {
|
||||
add(Expression::Math {
|
||||
fun,
|
||||
arg,
|
||||
arg1,
|
||||
arg2,
|
||||
arg3: None,
|
||||
})
|
||||
};
|
||||
for (&(member, _, ty), (steps, inverse, repeats)) in changed.iter().zip(scalars) {
|
||||
let mut vector = |components: [_; 4]| {
|
||||
add(Expression::Compose {
|
||||
ty,
|
||||
components: components.to_vec(),
|
||||
})
|
||||
};
|
||||
let (step, inverse, repeat) = (vector(steps), vector(inverse), vector(repeats));
|
||||
let x = components[member];
|
||||
let clamped = math(&mut add, MathFunction::Clamp, x, Some(zero), Some(one));
|
||||
let scaled = binary(&mut add, BinaryOperator::Multiply, clamped, max);
|
||||
let rounded = binary(&mut add, BinaryOperator::Add, scaled, half);
|
||||
let byte = math(&mut add, MathFunction::Floor, rounded, None, None);
|
||||
let shifted = binary(&mut add, BinaryOperator::Multiply, byte, inverse);
|
||||
let kept = math(&mut add, MathFunction::Floor, shifted, None, None);
|
||||
let high = binary(&mut add, BinaryOperator::Multiply, kept, step);
|
||||
let low = binary(&mut add, BinaryOperator::Multiply, kept, repeat);
|
||||
let low = math(&mut add, MathFunction::Floor, low, None, None);
|
||||
let wide = binary(&mut add, BinaryOperator::Add, high, low);
|
||||
components[member] = binary(&mut add, BinaryOperator::Divide, wide, max);
|
||||
}
|
||||
let value = add(Expression::Compose {
|
||||
ty: struct_ty,
|
||||
components,
|
||||
});
|
||||
// Everything from the first splat on is new and not a literal.
|
||||
let range = naga::Range::new_from_bounds(zero, value);
|
||||
out.push(Statement::Emit(range), span);
|
||||
out.push(Statement::Return { value: Some(value) }, span);
|
||||
}
|
||||
f.body = out;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn clear_colours_keep_the_same_bits() {
|
||||
// The CPU side, for clear colours. Iris 1.11.7 read p8106d's clear colour back as below in
|
||||
// RGB5_A1 and RGB565, and p8106c's 0.3337 as 82/255 in the 5-bit channels and 85/255 in
|
||||
// the 6-bit one.
|
||||
let to_255 = |v: [f32; 4]| v.map(|c| (c * 255.0 * 1000.0).round() / 1000.0);
|
||||
let rgb5_a1 = bits(Format::parse("RGB5_A1").unwrap()).unwrap();
|
||||
let rgb565 = bits(Format::parse("RGB565").unwrap()).unwrap();
|
||||
let r3_g3_b2 = bits(Format::parse("R3_G3_B2").unwrap()).unwrap();
|
||||
let clear = [0.3337, 0.1, 0.6, 0.3];
|
||||
assert_eq!(to_255(quantise(clear, rgb5_a1)), [82.0, 24.0, 156.0, 0.0]);
|
||||
assert_eq!(to_255(quantise(clear, rgb565))[..3], [82.0, 24.0, 156.0]);
|
||||
assert_eq!(
|
||||
to_255(quantise([0.3337; 4], r3_g3_b2))[..3],
|
||||
[82.0, 85.0, 82.0]
|
||||
);
|
||||
assert!(bits(Format::parse("RGBA4").unwrap()).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! `RGB5_A1`, `RGB565` and `R3_G3_B2` buffers read back as in Iris 1.11.7. Expected values are
|
||||
//! those probe packs p8106c (written values) and p8106d (clear colours) read back in Iris (the PR
|
||||
//! has them); the programs here are the probes', cut down. Written values are ones whose byte
|
||||
//! (× 255) is not near a half: GPUs may round those either way.
|
||||
use super::destination_tests::Fixture;
|
||||
use super::*;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
|
||||
|
||||
fn pack() -> Post {
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut add = |name: &str, source: &str| {
|
||||
zip.start_file(
|
||||
format!("shaders/{name}"),
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
};
|
||||
add("composite.vsh", VERTEX);
|
||||
add(
|
||||
"composite.fsh",
|
||||
"#version 120\n/* RENDERTARGETS: 1,2,3 */\n\
|
||||
/*\nconst int colortex1Format = RGB5_A1;\nconst int colortex2Format = RGB565;\n\
|
||||
const int colortex3Format = R3_G3_B2;\nconst int colortex4Format = RGB5_A1;\n\
|
||||
const int colortex5Format = RGB565;\n*/\n\
|
||||
const vec4 colortex4ClearColor = vec4(0.3337, 0.1, 0.6, 0.3);\n\
|
||||
const vec4 colortex5ClearColor = vec4(0.3337, 0.1, 0.6, 0.3);\n\
|
||||
void main() {\n\
|
||||
gl_FragData[0] = vec4(175.0, 176.0, 766.5, 256.0) / 511.0;\n\
|
||||
gl_FragData[1] = vec4(400.0, 175.0, 44.0, 0.0) / 511.0;\n\
|
||||
gl_FragData[2] = vec4(-255.5, 176.0, 300.0, 0.0) / 511.0;\n}\n",
|
||||
);
|
||||
add("final.vsh", VERTEX);
|
||||
// Pixel column x shows component x % 4 of buffer 1 + x / 4, as the bytes of its bits.
|
||||
add(
|
||||
"final.fsh",
|
||||
"#version 330 compatibility\nuniform sampler2D colortex1;\nuniform sampler2D colortex2;\n\
|
||||
uniform sampler2D colortex3;\nuniform sampler2D colortex4;\nuniform sampler2D colortex5;\n\
|
||||
void main() {\n ivec2 p = ivec2(gl_FragCoord.xy);\n vec4 c = vec4(0.0);\n\
|
||||
switch (p.x / 4) {\n\
|
||||
case 0: c = texelFetch(colortex1, p, 0); break;\n\
|
||||
case 1: c = texelFetch(colortex2, p, 0); break;\n\
|
||||
case 2: c = texelFetch(colortex3, p, 0); break;\n\
|
||||
case 3: c = texelFetch(colortex4, p, 0); break;\n\
|
||||
case 4: c = texelFetch(colortex5, p, 0); break;\n\
|
||||
}\n uint u = floatBitsToUint(c[p.x % 4]);\n\
|
||||
gl_FragColor = vec4(uvec4(u >> 24u, u >> 16u, u >> 8u, u) & 255u) / 255.0;\n}\n",
|
||||
);
|
||||
let pack = horizon_shaders::Pack::from_zip_bytes(
|
||||
"original-packed",
|
||||
zip.finish().unwrap().into_inner(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let post = horizon_shaders::load_post(&pack, "", &properties, ¯os, &Default::default());
|
||||
assert_eq!(post.programs.len(), 2, "{:?}", post.notes);
|
||||
post
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_packed_buffers_keep_their_bits() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
let mut post = gpu.build(pack());
|
||||
// Twice: the second frame writes the buffers' other sides.
|
||||
for frame in 0..2 {
|
||||
let pixels = gpu.render(&mut post, [20, 1]);
|
||||
let read: Vec<f32> = pixels
|
||||
.iter()
|
||||
.map(|p| f32::from_bits(u32::from_be_bytes(*p)))
|
||||
.collect();
|
||||
// Iris 1.11.7, in 255ths: RGB5_A1 written (175, 176, 766.5, 256) / 511; RGB565 written
|
||||
// (400, 175, 44, 0) / 511; R3_G3_B2 written (-255.5, 176, 300, 0) / 511; RGB5_A1 and
|
||||
// RGB565 cleared to (0.3337, 0.1, 0.6, 0.3). Three-channel formats read alpha 1.
|
||||
let expected: [f32; 20] = [
|
||||
82.0, 90.0, 255.0, 255.0, //
|
||||
206.0, 85.0, 16.0, 255.0, //
|
||||
0.0, 89.0, 148.0, 255.0, //
|
||||
82.0, 24.0, 156.0, 0.0, //
|
||||
82.0, 24.0, 156.0, 255.0,
|
||||
];
|
||||
for (n, (got, want)) in read.iter().zip(expected).enumerate() {
|
||||
assert!(
|
||||
(got - want / 255.0).abs() <= 1e-6,
|
||||
"frame {frame}, buffer {} component {}: {}/255, expected {want}/255; all {:?}",
|
||||
1 + n / 4,
|
||||
n % 4,
|
||||
got * 255.0,
|
||||
read.iter().map(|v| v * 255.0).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
//! The shadow pass draws terrain faces from both sides, as Iris 1.11.7 does (#9033: a probe pack
|
||||
//! of ours writing `gl_FrontFacing` into `shadowcolor0` found back faces in Iris's shadow map and
|
||||
//! none in Horizon's). The programs here are original.
|
||||
use super::destination_tests::Fixture;
|
||||
use super::*;
|
||||
use std::io::{Cursor, Write};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
const FINAL_VSH: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
|
||||
|
||||
fn pack() -> Post {
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut add = |name: &str, source: &str| {
|
||||
zip.start_file(
|
||||
format!("shaders/{name}"),
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
};
|
||||
// The terrain's positions are already in clip space.
|
||||
add(
|
||||
"shadow.vsh",
|
||||
"#version 120\nvoid main() { gl_Position=vec4(gl_Vertex.xy,0.5,1.0); }\n",
|
||||
);
|
||||
// Red where a front face was drawn, blue where a back face was.
|
||||
add(
|
||||
"shadow.fsh",
|
||||
"#version 120\nvoid main() {\n\
|
||||
gl_FragData[0]=gl_FrontFacing ? vec4(1.0,0.0,0.0,1.0) : vec4(0.0,0.0,1.0,1.0);\n}\n",
|
||||
);
|
||||
add("final.vsh", FINAL_VSH);
|
||||
add(
|
||||
"final.fsh",
|
||||
"#version 120\nconst int shadowMapResolution=16;\nuniform sampler2D shadowcolor0;\n\
|
||||
varying vec2 uv;\nvoid main() { gl_FragColor=texture2D(shadowcolor0,uv); }\n",
|
||||
);
|
||||
let pack = horizon_shaders::Pack::from_zip_bytes(
|
||||
"original-shadow-faces",
|
||||
zip.finish().unwrap().into_inner(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let inputs = horizon_shaders::GameInputs {
|
||||
terrain: Some(horizon_shaders::GbuffersInputs {
|
||||
item_id_declared: false,
|
||||
declarations: "layout(location=0)in vec3 fixture_position;\n".into(),
|
||||
body: "hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);\
|
||||
hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);\
|
||||
hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n"
|
||||
.into(),
|
||||
attributes: vec![],
|
||||
after: String::new(),
|
||||
alpha_test: None,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
horizon_shaders::load_post(&pack, "", &properties, ¯os, &inputs)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_pass_draws_terrain_back_faces() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
const ATTRS: [wgpu::VertexAttribute; 1] = wgpu::vertex_attr_array![0=>Float32x3];
|
||||
let layouts = [Some(wgpu::VertexBufferLayout {
|
||||
array_stride: 12,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &ATTRS,
|
||||
})];
|
||||
// Culled as the game's own terrain is (`Renderer` hands packs `cull: true`).
|
||||
let mut post = gpu.build_with_terrain(
|
||||
pack(),
|
||||
Some(Geometry {
|
||||
material_maps: false,
|
||||
buffers: &layouts,
|
||||
table: None,
|
||||
depth_write: true,
|
||||
atlas: None,
|
||||
ids: None,
|
||||
cull: true,
|
||||
}),
|
||||
);
|
||||
assert!(post.has_shadow(), "{:?}", post.notes);
|
||||
// GL clip space: the left half counter-clockwise (a front face), the right half
|
||||
// clockwise (a back face).
|
||||
let positions: [[f32; 3]; 12] = [
|
||||
[-1., -1., 0.],
|
||||
[0., -1., 0.],
|
||||
[0., 1., 0.],
|
||||
[-1., -1., 0.],
|
||||
[0., 1., 0.],
|
||||
[-1., 1., 0.],
|
||||
[0., -1., 0.],
|
||||
[1., 1., 0.],
|
||||
[1., -1., 0.],
|
||||
[0., -1., 0.],
|
||||
[0., 1., 0.],
|
||||
[1., 1., 0.],
|
||||
];
|
||||
let vertices = gpu
|
||||
.device
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Original shadow faces geometry"),
|
||||
contents: bytemuck::cast_slice(&positions),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
let size = [8, 8];
|
||||
let pixels = gpu.render_with_terrain(&mut post, size, &mut |rp, list, _, _| {
|
||||
if list == TerrainList::ShadowSolid {
|
||||
rp.set_vertex_buffer(0, vertices.slice(..));
|
||||
rp.draw(0..12, 0..1);
|
||||
}
|
||||
});
|
||||
let near = |got: [u8; 4], want: [u8; 4]| got.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 1);
|
||||
for (i, got) in pixels.iter().enumerate() {
|
||||
let x = i as u32 % size[0];
|
||||
let want = if x < size[0] / 2 {
|
||||
[255, 0, 0, 255]
|
||||
} else {
|
||||
[0, 0, 255, 255]
|
||||
};
|
||||
assert!(
|
||||
near(*got, want),
|
||||
"pixel ({x}, {}): {got:?}, expected {want:?} (red: front face, blue: back face)",
|
||||
i as u32 / size[0]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -219,13 +219,15 @@ fn pack(
|
||||
lightmap: lightmap.clone(),
|
||||
terrain: None,
|
||||
block_ids: &[],
|
||||
sprite_rects: &[],
|
||||
celestial: None,
|
||||
gui_size: [1, 1],
|
||||
clouds: None,
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
);
|
||||
)
|
||||
.expect("the pack runs");
|
||||
assert!(post.notes.is_empty(), "{:?}", post.notes);
|
||||
post
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ pub(super) struct PackTexture {
|
||||
pub(super) sample_type: wgpu::TextureSampleType,
|
||||
pub(super) blur: bool,
|
||||
pub(super) clamp: bool,
|
||||
/// Decoded from a PNG, the pack's or the game's (not a raw dump, not a generated texture).
|
||||
pub(super) png: bool,
|
||||
pub(super) image: Image,
|
||||
}
|
||||
|
||||
@@ -30,7 +32,7 @@ pub(super) fn upload(
|
||||
let features = device.features();
|
||||
let mut out = Vec::new();
|
||||
for t in textures {
|
||||
let (image, dimension, sample_type, blur, clamp) = match t.source {
|
||||
let (image, dimension, sample_type, blur, clamp, png) = match t.source {
|
||||
TextureSource::Image(texture) => {
|
||||
let (view, sample_type) = match create(device, queue, &texture, features) {
|
||||
Ok(created) => created,
|
||||
@@ -50,6 +52,7 @@ pub(super) fn upload(
|
||||
sample_type,
|
||||
texture.blur,
|
||||
texture.clamp,
|
||||
!texture.raw,
|
||||
)
|
||||
}
|
||||
TextureSource::Resource(location) => {
|
||||
@@ -66,7 +69,14 @@ pub(super) fn upload(
|
||||
continue;
|
||||
};
|
||||
let float = wgpu::TextureSampleType::Float { filterable: true };
|
||||
(image, wgpu::TextureViewDimension::D2, float, true, true)
|
||||
(
|
||||
image,
|
||||
wgpu::TextureViewDimension::D2,
|
||||
float,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
)
|
||||
}
|
||||
};
|
||||
out.push(PackTexture {
|
||||
@@ -76,6 +86,7 @@ pub(super) fn upload(
|
||||
sample_type,
|
||||
blur,
|
||||
clamp,
|
||||
png,
|
||||
image,
|
||||
});
|
||||
}
|
||||
@@ -285,6 +296,7 @@ mod tests {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
blur: false,
|
||||
clamp: false,
|
||||
png: false,
|
||||
image: Image::Lightmap,
|
||||
}
|
||||
}
|
||||
@@ -297,6 +309,7 @@ mod tests {
|
||||
texels,
|
||||
blur: false,
|
||||
clamp: false,
|
||||
raw: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -718,6 +718,13 @@ fn components(name: &str) -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
/// What `name` gives custom uniforms before the first frame: zeros, at its size, so their
|
||||
/// shapes check out as on a frame (#9086).
|
||||
pub fn zero(name: &str) -> Option<horizon_shaders::Val> {
|
||||
index(name)?;
|
||||
Some(horizon_shaders::Val::vector(&[0.0; 16][..components(name)]))
|
||||
}
|
||||
|
||||
/// The position of `name` in [`NAMES`].
|
||||
pub fn index(name: &str) -> Option<usize> {
|
||||
NAMES.iter().position(|n| *n == name)
|
||||
@@ -752,6 +759,22 @@ mod tests {
|
||||
assert_eq!(out[60..64], [0; 4], "a value past the block is dropped");
|
||||
}
|
||||
|
||||
/// The load-time run of custom uniforms sees each built-in at its size: Iris 1.11.7 reads 2
|
||||
/// for the first line of our probes K and X (#9086) and 0 for the others.
|
||||
#[test]
|
||||
fn custom_uniforms_check_the_shapes_of_built_ins_before_the_first_frame() {
|
||||
let properties = horizon_shaders::Properties::parse(
|
||||
"uniform.vec3.a = skyColor * vec3(0.0, 0.0, 0.0) + vec3(2.0, 2.0, 2.0)\n\
|
||||
uniform.float.b = skyColor\n\
|
||||
uniform.float.c = gbufferModelView * 0.0 + 2.0\n",
|
||||
&horizon_shaders::Macros::standard(),
|
||||
);
|
||||
let mut custom = horizon_shaders::Custom::parse(&properties);
|
||||
custom.eval(0.0, &zero);
|
||||
let broken: Vec<&str> = custom.notes.iter().map(|n| &n[1..2]).collect();
|
||||
assert_eq!(broken, ["b", "c"], "{:?}", custom.notes);
|
||||
}
|
||||
|
||||
/// Iris's status uniforms as its documentation defines them (shaders.properties, "Status").
|
||||
#[test]
|
||||
fn status_uniforms_follow_iris() {
|
||||
@@ -1006,11 +1029,6 @@ mod initial_history_tests {
|
||||
#[test]
|
||||
#[ignore = "requires a host GPU; run with --ignored --nocapture"]
|
||||
fn core_matrix_inputs_render_provider_values() {
|
||||
use horizon_shaders::{
|
||||
GbuffersInputs, Macros, Pack, Target, preprocess, translate_gbuffers,
|
||||
};
|
||||
use std::io::Write;
|
||||
use wgpu::util::DeviceExt;
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
|
||||
eprintln!("Core matrix GPU: {:?}", adapter.get_info());
|
||||
@@ -1070,137 +1088,7 @@ mod initial_history_tests {
|
||||
vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);\n\
|
||||
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);\n}}"
|
||||
);
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (path, source) in [
|
||||
("shaders/gbuffers_line.vsh", vertex.as_str()),
|
||||
(
|
||||
"shaders/gbuffers_line.fsh",
|
||||
"#version 330\nin vec3 result; out vec4 color; void main() { color = vec4(result, 1.0); }",
|
||||
),
|
||||
] {
|
||||
zip.start_file(path, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
}
|
||||
let pack = Pack::from_zip_bytes("matrix-probe.zip", zip.finish().unwrap().into_inner())
|
||||
.unwrap();
|
||||
let macros = Macros::standard();
|
||||
let program = translate_gbuffers(
|
||||
&preprocess(&pack, "gbuffers_line.vsh", ¯os),
|
||||
&preprocess(&pack, "gbuffers_line.fsh", ¯os),
|
||||
Target::Buffers,
|
||||
&GbuffersInputs::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut bytes = vec![0; program.interface.uniform_size as usize];
|
||||
for uniform in &program.interface.uniforms {
|
||||
if let Some(i) = index(&uniform.name) {
|
||||
values.values[i].write(&uniform.ty, &mut bytes, uniform.offset as usize);
|
||||
}
|
||||
}
|
||||
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: &bytes,
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let module = |module| {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
|
||||
})
|
||||
};
|
||||
let vs = module(program.vertex);
|
||||
let fs = module(program.fragment);
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &vs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &fs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: uniform.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = texture.create_view(&Default::default());
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 256,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
encoder.copy_texture_to_buffer(
|
||||
texture.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &read,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
texture.size(),
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |result| result.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let mapped = read.slice(..).get_mapped_range().unwrap();
|
||||
let mapped = draw_line_program(&device, &queue, &values, &vertex);
|
||||
// Independent result: translated point (1, .5, -2), perspective (aspect 2,
|
||||
// vertical FOV 90): clip (.5, .5, z, 2), hence (.25, .25, .5, 1).
|
||||
for (actual, expected) in mapped[..4].iter().zip(expected) {
|
||||
@@ -1213,6 +1101,203 @@ mod initial_history_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `normalMatrix` in a core-profile gbuffers program is the view's rotation; in the
|
||||
/// compatibility profile it stays zero (the issue's Iris 1.11.7 probes, #9136).
|
||||
#[test]
|
||||
#[ignore = "requires a host GPU; run with --ignored --nocapture"]
|
||||
fn core_normal_matrix_renders_the_view_rotation() {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
|
||||
eprintln!("Normal matrix GPU: {:?}", adapter.get_info());
|
||||
let (device, queue) =
|
||||
pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let hud = HudBatch::new([800, 400]);
|
||||
let mut f = frame(&hud);
|
||||
// Facing west (-X), level: view space looks down -Z with +X to the right (north).
|
||||
f.yaw = 90.0;
|
||||
f.pitch = 0.0;
|
||||
let mut values = FrameUniforms::default();
|
||||
values.update(&f, &PackInputs::default(), [800, 400]);
|
||||
for (profile, world, expected) in [
|
||||
// East (behind the camera) is view +Z.
|
||||
("330 core", "vec3(1.0, 0.0, 0.0)", [128u8, 128, 255, 255]),
|
||||
// South (to the left) is view -X; up stays up.
|
||||
("330 core", "vec3(0.0, 0.0, 1.0)", [0, 128, 128, 255]),
|
||||
("330 core", "vec3(0.0, 1.0, 0.0)", [128, 255, 128, 255]),
|
||||
(
|
||||
"330 compatibility",
|
||||
"vec3(1.0, 0.0, 0.0)",
|
||||
[128, 128, 128, 255],
|
||||
),
|
||||
] {
|
||||
let vertex = format!(
|
||||
"#version {profile}\nuniform mat3 normalMatrix;\nout vec3 result;\n\
|
||||
void main() {{\n\
|
||||
result = normalMatrix * {world} * 0.5 + 0.5;\n\
|
||||
vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);\n\
|
||||
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);\n}}"
|
||||
);
|
||||
let pixel = draw_line_program(&device, &queue, &values, &vertex);
|
||||
eprintln!("{profile} {world}: {pixel:?}");
|
||||
for (actual, expected) in pixel.iter().zip(expected) {
|
||||
assert!(
|
||||
actual.abs_diff(expected) <= 1,
|
||||
"{profile} {world}: {pixel:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws one fragment through a translated `gbuffers_line` program with `vertex` and the
|
||||
/// provider's `values`, and reads it back.
|
||||
fn draw_line_program(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
values: &FrameUniforms,
|
||||
vertex: &str,
|
||||
) -> [u8; 4] {
|
||||
use horizon_shaders::{
|
||||
GbuffersInputs, Macros, Pack, Target, preprocess, translate_gbuffers,
|
||||
};
|
||||
use std::io::Write;
|
||||
use wgpu::util::DeviceExt;
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (path, source) in [
|
||||
("shaders/gbuffers_line.vsh", vertex),
|
||||
(
|
||||
"shaders/gbuffers_line.fsh",
|
||||
"#version 330\nin vec3 result; out vec4 color; void main() { color = vec4(result, 1.0); }",
|
||||
),
|
||||
] {
|
||||
zip.start_file(path, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
}
|
||||
let pack =
|
||||
Pack::from_zip_bytes("matrix-probe.zip", zip.finish().unwrap().into_inner()).unwrap();
|
||||
let macros = Macros::standard();
|
||||
let program = translate_gbuffers(
|
||||
&preprocess(&pack, "gbuffers_line.vsh", ¯os),
|
||||
&preprocess(&pack, "gbuffers_line.fsh", ¯os),
|
||||
Target::Buffers,
|
||||
&GbuffersInputs::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut bytes = vec![0; program.interface.uniform_size as usize];
|
||||
for uniform in &program.interface.uniforms {
|
||||
if let Some(i) = index(&uniform.name) {
|
||||
values.values[i].write(&uniform.ty, &mut bytes, uniform.offset as usize);
|
||||
}
|
||||
}
|
||||
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: &bytes,
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let module = |module| {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
|
||||
})
|
||||
};
|
||||
let vs = module(program.vertex);
|
||||
let fs = module(program.fragment);
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &vs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &fs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: uniform.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = texture.create_view(&Default::default());
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 256,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
encoder.copy_texture_to_buffer(
|
||||
texture.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &read,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
texture.size(),
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |result| result.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let mapped = read.slice(..).get_mapped_range().unwrap();
|
||||
let pixel = mapped[..4].try_into().unwrap();
|
||||
drop(mapped);
|
||||
read.unmap();
|
||||
pixel
|
||||
}
|
||||
|
||||
fn int(u: &FrameUniforms, at: usize) -> i32 {
|
||||
match u.values[at] {
|
||||
Value::I(v) => v[0],
|
||||
|
||||
@@ -676,6 +676,9 @@ pub struct Graphics {
|
||||
/// HDR brightness of the HUD and menus, in percent of paper white (25..=100); the
|
||||
/// first-person hand stays at the world's.
|
||||
pub hdr_ui_brightness: u32,
|
||||
/// HDR Lighting: Horizon's own HDR look, light sources, the sun and sunlight above paper
|
||||
/// white. Off, HDR output shows the same picture as SDR, for shader packs or the vanilla look.
|
||||
pub hdr_lighting: bool,
|
||||
/// A vanilla key name (`HDR_KEY`): opens HDR's settings from the game. Unbound by default.
|
||||
pub hdr_key: String,
|
||||
/// Battery mode: at most 60 frames per second in a world and 30 in menus
|
||||
@@ -747,6 +750,7 @@ impl Default for Graphics {
|
||||
hdr_peak: 0,
|
||||
hdr_black_level: 0,
|
||||
hdr_ui_brightness: 100,
|
||||
hdr_lighting: true,
|
||||
hdr_key: "key.keyboard.unknown".into(),
|
||||
battery: false,
|
||||
shadow_cache: false,
|
||||
@@ -864,6 +868,7 @@ impl Graphics {
|
||||
}
|
||||
"frameGeneration" => g.frame_generation = FrameGeneration::parse(value),
|
||||
"hdr" => g.hdr = flag(g.hdr),
|
||||
"hdrLighting" => g.hdr_lighting = flag(g.hdr_lighting),
|
||||
"hdrPaperWhite" => {
|
||||
g.hdr_paper_white = match number(g.hdr_paper_white) {
|
||||
0 => 0,
|
||||
@@ -901,7 +906,7 @@ impl Graphics {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
let mut text = format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nparticleCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nparticleCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrLighting:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
@@ -946,6 +951,7 @@ impl Graphics {
|
||||
self.hdr_peak,
|
||||
self.hdr_black_level,
|
||||
self.hdr_ui_brightness,
|
||||
bool_text(self.hdr_lighting),
|
||||
self.hdr_key,
|
||||
bool_text(self.battery),
|
||||
bool_text(self.shadow_cache),
|
||||
@@ -1124,6 +1130,7 @@ mod tests {
|
||||
hdr_peak: 1400,
|
||||
hdr_black_level: 12,
|
||||
hdr_ui_brightness: 60,
|
||||
hdr_lighting: false,
|
||||
hdr_key: "key.keyboard.h".into(),
|
||||
battery: true,
|
||||
shadow_cache: true,
|
||||
|
||||
@@ -100,6 +100,7 @@ pub enum H {
|
||||
HdrPeak,
|
||||
HdrPaperWhite,
|
||||
HdrUiBrightness,
|
||||
HdrLighting,
|
||||
BatteryMode,
|
||||
ShadowCache,
|
||||
GraphicsApi,
|
||||
@@ -532,6 +533,11 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
None,
|
||||
"How bright the HUD and menus are, against paper white.",
|
||||
),
|
||||
h(
|
||||
H::HdrLighting,
|
||||
None,
|
||||
"Light sources, the sun and sunlight shine past white. Off: just as SDR.",
|
||||
),
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -824,6 +830,7 @@ impl H {
|
||||
n => labelled("Black Level", format!("{:.2}", n as f32 / 100.0)),
|
||||
},
|
||||
H::HdrUiBrightness => labelled_percent("UI Brightness", g.hdr_ui_brightness),
|
||||
H::HdrLighting => labelled("HDR Lighting", on_off(g.hdr_lighting)),
|
||||
H::HdrKey => {
|
||||
Text::translate("Shortcut: %s", &[crate::keybinds::display_name(&g.hdr_key)])
|
||||
}
|
||||
@@ -948,6 +955,7 @@ impl H {
|
||||
g.frame_generation = step(&FrameGeneration::ALL, g.frame_generation, back)
|
||||
}
|
||||
H::Hdr => g.hdr = !g.hdr,
|
||||
H::HdrLighting => g.hdr_lighting = !g.hdr_lighting,
|
||||
H::BatteryMode => g.battery = !g.battery,
|
||||
H::ShadowCache => g.shadow_cache = !g.shadow_cache,
|
||||
H::GraphicsApi => g.api = step(GraphicsApi::choices(), g.api, back),
|
||||
@@ -998,6 +1006,7 @@ impl Item {
|
||||
| H::HdrPeak
|
||||
| H::HdrPaperWhite
|
||||
| H::HdrUiBrightness
|
||||
| H::HdrLighting
|
||||
| H::HdrSmoothGradients,
|
||||
) if !crate::graphics::hdr_offered() => {
|
||||
Some("Your display or system doesn't offer HDR output.")
|
||||
@@ -1013,6 +1022,7 @@ impl Item {
|
||||
| H::HdrPeak
|
||||
| H::HdrPaperWhite
|
||||
| H::HdrUiBrightness
|
||||
| H::HdrLighting
|
||||
| H::HdrSmoothGradients,
|
||||
) if !s.horizon.hdr => Some("Turn HDR on to use it."),
|
||||
Item::Horizon(H::TranslucentBatching)
|
||||
@@ -1048,6 +1058,7 @@ impl Item {
|
||||
Item::Horizon(H::AnimatedTextures) => g.animated_textures,
|
||||
Item::Horizon(H::DitherCutoutMips) => g.dither_cutout_mips,
|
||||
Item::Horizon(H::Hdr) => g.hdr,
|
||||
Item::Horizon(H::HdrLighting) => g.hdr_lighting,
|
||||
Item::Horizon(H::BatteryMode) => g.battery,
|
||||
Item::Horizon(H::ShadowCache) => g.shadow_cache,
|
||||
Item::Horizon(H::TranslucentBatching) => {
|
||||
|
||||
@@ -887,13 +887,39 @@ impl HudAtlas {
|
||||
));
|
||||
}
|
||||
/// Only the font's metrics, for a game that draws nothing (the
|
||||
/// integrated server): text measures as with the full atlas, without
|
||||
/// holding its textures. The pixels are dropped once the glyph widths are
|
||||
/// read, since nothing uploads them.
|
||||
/// integrated or dedicated server): text measures as with the full atlas,
|
||||
/// without packing textures or rasterizing glyphs.
|
||||
pub fn fonts(root: &Path) -> Result<Self> {
|
||||
let mut atlas = Self::pack(root, root, &["font"], false, false)?;
|
||||
atlas.pixels = Vec::new();
|
||||
Ok(atlas)
|
||||
let white = Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 1,
|
||||
h: 1,
|
||||
};
|
||||
Ok(Self {
|
||||
revision: Self::next_revision(),
|
||||
pixels: Vec::new(),
|
||||
packed: None,
|
||||
galactic_widths: [0; 128],
|
||||
width: 1,
|
||||
height: 1,
|
||||
sprites: HashMap::new(),
|
||||
object_skins: HashMap::new(),
|
||||
translucent: HashSet::new(),
|
||||
flags: HashMap::new(),
|
||||
sprite_animations: Vec::new(),
|
||||
font: crate::font::Font::metrics(root),
|
||||
glint: (Vec::new(), 1, 1),
|
||||
glint_armor: (Vec::new(), 1, 1),
|
||||
text_shader: None,
|
||||
lines_shader: None,
|
||||
random_textures: Default::default(),
|
||||
emissive: Arc::default(),
|
||||
blinks: Default::default(),
|
||||
white,
|
||||
})
|
||||
}
|
||||
/// The boot screen's atlas, before any pack loads: the theme's run-time
|
||||
/// glyphs (`horizon/ui-text`), a white sprite and the glyph rows over
|
||||
@@ -1274,6 +1300,12 @@ impl HudAtlas {
|
||||
edition.3 = pixels;
|
||||
}
|
||||
images.push(("horizon/white".into(), 4, 4, vec![255; 64]));
|
||||
images.push((
|
||||
crate::pad_cursor::SPRITE.into(),
|
||||
crate::pad_cursor::SIZE,
|
||||
crate::pad_cursor::SIZE,
|
||||
crate::pad_cursor::pixels(),
|
||||
));
|
||||
if full {
|
||||
Self::slots(&mut images, crate::trims::slot_size(root), mod_image);
|
||||
// MissingTextureAtlasSprite.generateMissingImage: 16x16, magenta
|
||||
|
||||
@@ -74,6 +74,7 @@ pub mod overlay;
|
||||
pub mod pack_browser;
|
||||
pub mod pack_screen;
|
||||
pub mod packs;
|
||||
pub mod pad_cursor;
|
||||
pub mod par;
|
||||
pub mod particles;
|
||||
pub mod poi_sections;
|
||||
@@ -82,6 +83,7 @@ pub mod post_glsl;
|
||||
pub mod potent_sulfur;
|
||||
pub mod preedit;
|
||||
pub mod providers;
|
||||
pub mod pvp;
|
||||
pub mod screen_draw;
|
||||
pub mod screen_effects;
|
||||
pub mod sculk;
|
||||
|
||||
@@ -77,6 +77,110 @@ const HELD_SAMPLE_STEP: std::time::Duration = std::time::Duration::from_millis(5
|
||||
/// How long a tile whose sampling failed (`LodSampler::grid` gave no
|
||||
/// columns: a server refused or lost it) waits before it is asked again.
|
||||
const RETRY: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
/// The most a tile a zoomed view magnifies may split past the plan it would
|
||||
/// have had ([`ZoomAim`]): cells three times smaller, so nine times the tiles
|
||||
/// where it applies. A spyglass covers about a sixteenth of the circle, so at
|
||||
/// the 4078 tiles and ~1500 quads a tile measured at `lodDistance` 256 this
|
||||
/// asks for roughly 45% more far tiles while the zoom is held, and gives them
|
||||
/// back when it is released. Four would be about 85%: raise it only with a
|
||||
/// measurement, because the cost grows with the square.
|
||||
const MAX_ZOOM_BOOST: i32 = 3;
|
||||
/// Yaw is rounded to this many degrees before it reaches the plan, so turning
|
||||
/// the mouse does not rebuild the quadtree every frame. The cone is widened by
|
||||
/// as much, so rounding never swings it off a tile being looked at.
|
||||
const ZOOM_YAW_STEP: f32 = 5.0;
|
||||
|
||||
/// Where a zoomed view asks for finer far tiles (the `lodZoomDetail` extra).
|
||||
///
|
||||
/// `Plan::visit`'s falloff keeps a tile's cells roughly the same size on
|
||||
/// screen, which silently assumes the field of view never changes. A spyglass
|
||||
/// (`SpyglassItem`, a tenth of it) or the zoom mod breaks that: the same tile
|
||||
/// covers many times more pixels, so its cells look as coarse as a tile ten
|
||||
/// times nearer would. This asks the tiles the zoom actually magnifies to
|
||||
/// split further.
|
||||
///
|
||||
/// Only those: lifting the whole circle would ask for about a hundred times
|
||||
/// the tiles, which at the quads measured above is gigabytes of vertices. The
|
||||
/// cone is the narrowed view's, and `MAX_ZOOM_BOOST` caps how far it goes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ZoomAim {
|
||||
/// The view direction in x/z, in thousandths of a unit vector.
|
||||
dir: [i32; 2],
|
||||
/// The cosine of the cone's half-angle, in thousandths.
|
||||
cos_half: i32,
|
||||
/// How many times further than the unzoomed plan a tile inside the cone
|
||||
/// may split. Two or more; [`ZoomAim::new`] gives `None` for less.
|
||||
boost: i32,
|
||||
}
|
||||
|
||||
impl ZoomAim {
|
||||
/// `fov_degrees` is the zoomed field of view and `magnification` how many
|
||||
/// times the view is magnified (1 when it is not). `None` when the zoom is
|
||||
/// too slight to be worth replanning for.
|
||||
pub fn new(yaw: f32, fov_degrees: f32, magnification: f32) -> Option<Self> {
|
||||
if !magnification.is_finite() || !fov_degrees.is_finite() || fov_degrees <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let boost = (magnification.round() as i64).clamp(1, i64::from(MAX_ZOOM_BOOST)) as i32;
|
||||
if boost < 2 {
|
||||
return None;
|
||||
}
|
||||
// Half the field of view spans the screen's width; three quarters of
|
||||
// it reaches the corners, and the rounding step is added on top.
|
||||
let half = (fov_degrees * 0.75 + ZOOM_YAW_STEP).clamp(5.0, 90.0);
|
||||
let radians = ((yaw / ZOOM_YAW_STEP).round() * ZOOM_YAW_STEP).to_radians();
|
||||
// `view_vector`'s x and z: yaw 0 looks toward +z.
|
||||
let thousandths = |v: f32| (v * 1000.0).round() as i32;
|
||||
Some(Self {
|
||||
dir: [thousandths(-radians.sin()), thousandths(radians.cos())],
|
||||
cos_half: thousandths(half.to_radians().cos()),
|
||||
boost,
|
||||
})
|
||||
}
|
||||
/// Whether the cone reaches `key`, so the zoom magnifies it.
|
||||
///
|
||||
/// The tile is taken as the circle around its centre that covers it, and
|
||||
/// that circle's own angular radius widens the cone for the test, so a
|
||||
/// tile straddling the edge counts in rather than out. A tile whose circle
|
||||
/// reaches the camera is always in: there is no direction to compare, and
|
||||
/// it fills the view whichever way the player faces.
|
||||
fn covers(&self, key: TileKey, camera: [i32; 2]) -> bool {
|
||||
let [ox, _, oz] = key.origin();
|
||||
let size = key.size();
|
||||
// The camera stands in the tile: it is in view whichever way the
|
||||
// player faces, and there is no direction to compare it with. Tested
|
||||
// on the box rather than on the circle below, whose radius a camera
|
||||
// on a corner matches exactly -- a float coin-toss.
|
||||
// In i64: a level 22 tile is 67 million blocks wide, so the sum
|
||||
// at the far edge of the world overflows an i32.
|
||||
let (ox64, oz64, size64) = (i64::from(ox), i64::from(oz), i64::from(size));
|
||||
if (ox64..ox64 + size64).contains(&i64::from(camera[0]))
|
||||
&& (oz64..oz64 + size64).contains(&i64::from(camera[1]))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let half = f64::from(size) / 2.0;
|
||||
let cx = f64::from(ox) + half - f64::from(camera[0]);
|
||||
let cz = f64::from(oz) + half - f64::from(camera[1]);
|
||||
let distance = (cx * cx + cz * cz).sqrt();
|
||||
let radius = half * std::f64::consts::SQRT_2;
|
||||
if distance <= radius {
|
||||
return true;
|
||||
}
|
||||
let dir = [
|
||||
f64::from(self.dir[0]) / 1000.0,
|
||||
f64::from(self.dir[1]) / 1000.0,
|
||||
];
|
||||
let cos = (cx * dir[0] + cz * dir[1]) / distance;
|
||||
let sin = (1.0 - cos * cos).max(0.0).sqrt();
|
||||
// cos(angle - tile radius), expanded so no inverse trigonometry runs
|
||||
// per tile: the quadtree asks this of every node it visits.
|
||||
let radius_sin = (radius / distance).min(1.0);
|
||||
let radius_cos = (1.0 - radius_sin * radius_sin).max(0.0).sqrt();
|
||||
cos * radius_cos + sin * radius_sin >= f64::from(self.cos_half) / 1000.0
|
||||
}
|
||||
}
|
||||
|
||||
/// The coarsest tile level: `Plan` roots cover at most `MAX_CHUNKS` chunks
|
||||
/// around (a level-22 tile is 67 million blocks wide: the whole world).
|
||||
pub(crate) const MAX_LEVEL: u8 = 22;
|
||||
@@ -598,6 +702,8 @@ struct Plan {
|
||||
/// The squared distance within which planned tiles load before the
|
||||
/// coarse backdrop (`wanted_tiles`).
|
||||
first: i64,
|
||||
/// Where a zoomed view asks for finer tiles, if it does ([`ZoomAim`]).
|
||||
aim: Option<ZoomAim>,
|
||||
}
|
||||
/// Chunks past the render distance where only planned tiles are drawn (two
|
||||
/// past the last near chunk, `streaming::in_view_distance`), and where they
|
||||
@@ -607,7 +713,7 @@ const FIRST_CHUNKS: i32 = 4;
|
||||
impl Plan {
|
||||
#[cfg(test)]
|
||||
fn new(camera: [i32; 2], detail: i32, distance: u32) -> Self {
|
||||
Self::with_previous(camera, detail, distance, &Self::default(), 0, false)
|
||||
Self::with_previous(camera, detail, distance, &Self::default(), 0, false, None)
|
||||
}
|
||||
/// `reduced`: Reduced far detail (`graphics::FarDetail`), each level from
|
||||
/// half the distance.
|
||||
@@ -618,6 +724,7 @@ impl Plan {
|
||||
previous: &Self,
|
||||
elevation: i32,
|
||||
reduced: bool,
|
||||
aim: Option<ZoomAim>,
|
||||
) -> Self {
|
||||
let range = distance.min(MAX_CHUNKS) as i32 * 16;
|
||||
let mut level = 0;
|
||||
@@ -630,6 +737,7 @@ impl Plan {
|
||||
camera,
|
||||
near: i64::from((detail + SEAM_CHUNKS) * 16).pow(2),
|
||||
first: i64::from((detail + FIRST_CHUNKS) * 16).pow(2),
|
||||
aim,
|
||||
..Self::default()
|
||||
};
|
||||
for z in (camera[1] - range).div_euclid(size)..=(camera[1] + range).div_euclid(size) {
|
||||
@@ -665,6 +773,14 @@ impl Plan {
|
||||
// Beyond the old 1024-chunk limit the cells are as large as `reduced`'s:
|
||||
// the whole world is 22 levels, not all of them worth full cells.
|
||||
let world = key.distance(camera) >= WORLD_START;
|
||||
// A zoomed view magnifies the tiles its cone covers, so those split
|
||||
// further; every other tile keeps the plan it would have had
|
||||
// ([`ZoomAim`]). The world-wide threshold is boosted too: the horizon is
|
||||
// where a spyglass shows the coarsest cells of all.
|
||||
let boost = match self.aim {
|
||||
Some(aim) if aim.covers(key, camera) => i64::from(aim.boost),
|
||||
_ => 1,
|
||||
};
|
||||
let threshold = i64::from(key.size())
|
||||
* i64::from(if world {
|
||||
WORLD_SPLIT
|
||||
@@ -672,7 +788,8 @@ impl Plan {
|
||||
split / 2
|
||||
} else {
|
||||
split
|
||||
});
|
||||
})
|
||||
* boost;
|
||||
// The 8 to 10 band above is wide already. The ring of full detail
|
||||
// around the camera and the world-wide threshold have none: a tile
|
||||
// split last plan stays split until it is 5% past them, or it
|
||||
@@ -1059,7 +1176,7 @@ pub struct Lod {
|
||||
context: Option<Arc<WorkerContext>>,
|
||||
/// A finished mesh job may have changed the selection of its ancestors.
|
||||
reselect: bool,
|
||||
area: Option<([i32; 2], i32, u32, i32, bool)>,
|
||||
area: Option<([i32; 2], i32, u32, i32, bool, Option<ZoomAim>)>,
|
||||
plan: Plan,
|
||||
/// Reduced far detail (`set_reduced_detail`).
|
||||
reduced_detail: bool,
|
||||
@@ -2220,29 +2337,35 @@ impl Lod {
|
||||
self.shared.wake.notify_all();
|
||||
}
|
||||
}
|
||||
/// `aim` asks the tiles a zoomed view magnifies for finer detail
|
||||
/// ([`ZoomAim`]); `None` plans exactly as before.
|
||||
pub fn update(
|
||||
&mut self,
|
||||
camera: [f64; 3],
|
||||
detail: i32,
|
||||
distance: u32,
|
||||
covered: Option<FxHashSet<(i32, i32)>>,
|
||||
aim: Option<ZoomAim>,
|
||||
) -> Vec<(TileKey, SectionMesh)> {
|
||||
let elevation =
|
||||
(((camera[1] - f64::from(MIN_Y + crate::chunk::HEIGHT)).clamp(0., 16384.)) as i32 / 32)
|
||||
* 32;
|
||||
let camera = [camera[0].floor() as i32, camera[2].floor() as i32];
|
||||
// Replan at chunk boundaries, not on every camera movement.
|
||||
// `ZoomAim` rounds its yaw, so a zoom held still keeps one area and the
|
||||
// quadtree is not rebuilt as the player nudges the mouse.
|
||||
let area = (
|
||||
[camera[0].div_euclid(16), camera[1].div_euclid(16)],
|
||||
detail,
|
||||
distance,
|
||||
elevation,
|
||||
self.reduced_detail,
|
||||
aim,
|
||||
);
|
||||
if self.area != Some(area) {
|
||||
crate::spans::time("lod_plan", || {
|
||||
self.area = Some(area);
|
||||
self.replan(camera, detail, distance, elevation);
|
||||
self.replan(camera, detail, distance, elevation, aim);
|
||||
});
|
||||
}
|
||||
let mut meshes = Vec::new();
|
||||
@@ -2265,7 +2388,14 @@ impl Lod {
|
||||
}
|
||||
/// Rebuilds the quadtree and the selection it implies. Only runs when the
|
||||
/// planned area changes (a chunk boundary), never per finished sample.
|
||||
fn replan(&mut self, camera: [i32; 2], detail: i32, distance: u32, elevation: i32) {
|
||||
fn replan(
|
||||
&mut self,
|
||||
camera: [i32; 2],
|
||||
detail: i32,
|
||||
distance: u32,
|
||||
elevation: i32,
|
||||
aim: Option<ZoomAim>,
|
||||
) {
|
||||
let plan = Plan::with_previous(
|
||||
camera,
|
||||
detail,
|
||||
@@ -2273,6 +2403,7 @@ impl Lod {
|
||||
&self.plan,
|
||||
elevation,
|
||||
self.reduced_detail,
|
||||
aim,
|
||||
);
|
||||
// A tile that leaves the range before it was drawn stops being
|
||||
// unknown to its neighbours (`Snapshot::planned_leaf`): they remesh
|
||||
@@ -2962,6 +3093,104 @@ fn retiring_detail<'a>(
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// A spyglass or the zoom mod magnifies only what the narrowed view
|
||||
/// covers, so `ZoomAim` asks for finer tiles there and nowhere else. Without
|
||||
/// that cone the same request would reach the whole circle, which at the
|
||||
/// measured quads a tile is gigabytes of vertices.
|
||||
#[test]
|
||||
fn a_zoomed_view_asks_for_finer_tiles_only_where_it_looks() {
|
||||
// Yaw 0 looks toward +z (`view_vector`), ten times magnified.
|
||||
let aim = ZoomAim::new(0.0, 7.0, 10.0).expect("a tenth of the field of view zooms");
|
||||
// Capped, however far the zoom goes.
|
||||
assert_eq!(aim.boost, MAX_ZOOM_BOOST);
|
||||
let camera = [0, 0];
|
||||
let tile = |x, z, level| TileKey { x, z, level };
|
||||
// Straight ahead, far enough that the direction decides it.
|
||||
assert!(aim.covers(tile(0, 8, 4), camera), "ahead");
|
||||
// Behind, beside, and the opposite diagonal: the zoom does not
|
||||
// magnify these, so they keep the plan they would have had.
|
||||
assert!(!aim.covers(tile(0, -9, 4), camera), "behind");
|
||||
assert!(!aim.covers(tile(8, 0, 4), camera), "beside");
|
||||
assert!(!aim.covers(tile(-9, -9, 4), camera), "diagonally behind");
|
||||
// A tile the camera stands in is in view whichever way it faces.
|
||||
assert!(aim.covers(tile(0, 0, 4), camera), "underfoot");
|
||||
}
|
||||
|
||||
/// A zoom too slight to change which level a tile wants is not worth
|
||||
/// rebuilding the quadtree for, and an unzoomed view must plan exactly as
|
||||
/// it did before this feature.
|
||||
#[test]
|
||||
fn a_slight_zoom_does_not_replan() {
|
||||
assert_eq!(ZoomAim::new(0.0, 70.0, 1.0), None, "not zoomed");
|
||||
assert_eq!(
|
||||
ZoomAim::new(0.0, 60.0, 1.2),
|
||||
None,
|
||||
"a fifth is under one level"
|
||||
);
|
||||
assert!(ZoomAim::new(0.0, 35.0, 2.0).is_some(), "twice is worth it");
|
||||
// Nothing finite comes of a broken field of view.
|
||||
assert_eq!(ZoomAim::new(0.0, f32::NAN, 4.0), None);
|
||||
assert_eq!(ZoomAim::new(0.0, 0.0, 4.0), None);
|
||||
assert_eq!(ZoomAim::new(0.0, 70.0, f32::INFINITY), None);
|
||||
}
|
||||
|
||||
/// The cone turns with the player, and its yaw is rounded so that a
|
||||
/// mouse-twitch does not rebuild the quadtree: two aims within the
|
||||
/// rounding step compare equal, and `Lod::update` replans on that.
|
||||
#[test]
|
||||
fn the_cone_turns_and_rounds_with_the_view() {
|
||||
let camera = [0, 0];
|
||||
let east = ZoomAim::new(-90.0, 7.0, 10.0).expect("zoomed");
|
||||
let tile = |x, z| TileKey { x, z, level: 4 };
|
||||
// Yaw -90 looks toward +x.
|
||||
assert!(east.covers(tile(8, 0), camera), "ahead when facing +x");
|
||||
assert!(!east.covers(tile(0, 8), camera), "+z is no longer ahead");
|
||||
// Within the rounding step: the same aim, so no replan.
|
||||
assert_eq!(ZoomAim::new(0.0, 7.0, 10.0), ZoomAim::new(1.0, 7.0, 10.0));
|
||||
// Past it: a different aim, so the cone follows.
|
||||
assert_ne!(ZoomAim::new(0.0, 7.0, 10.0), ZoomAim::new(20.0, 7.0, 10.0));
|
||||
}
|
||||
|
||||
/// Inside the cone a tile splits further than it would unzoomed, and
|
||||
/// outside it the plan is unchanged, tile for tile.
|
||||
#[test]
|
||||
fn the_plan_is_finer_ahead_and_untouched_behind() {
|
||||
let camera = [0, 0];
|
||||
let plain = Plan::with_previous(camera, 12, 256, &Plan::default(), 0, false, None);
|
||||
let zoomed = Plan::with_previous(
|
||||
camera,
|
||||
12,
|
||||
256,
|
||||
&Plan::default(),
|
||||
0,
|
||||
false,
|
||||
ZoomAim::new(0.0, 7.0, 10.0),
|
||||
);
|
||||
// Finer somewhere: the zoomed plan holds tiles the plain one split
|
||||
// no further.
|
||||
assert!(
|
||||
zoomed.nodes.len() > plain.nodes.len(),
|
||||
"zoomed {} tiles, plain {}",
|
||||
zoomed.nodes.len(),
|
||||
plain.nodes.len()
|
||||
);
|
||||
// Behind the camera the two agree. Tiles there are the same level in
|
||||
// both, so every plain node well behind is still a node when zoomed.
|
||||
let behind = |key: &&TileKey| {
|
||||
let [_, _, oz] = key.origin();
|
||||
oz + key.size() < -1024
|
||||
};
|
||||
let plain_behind: std::collections::BTreeSet<_> =
|
||||
plain.nodes.iter().filter(behind).copied().collect();
|
||||
let zoomed_behind: std::collections::BTreeSet<_> =
|
||||
zoomed.nodes.iter().filter(behind).copied().collect();
|
||||
assert!(!plain_behind.is_empty(), "nothing behind to compare");
|
||||
assert_eq!(
|
||||
plain_behind, zoomed_behind,
|
||||
"the zoom changed the plan behind the player"
|
||||
);
|
||||
}
|
||||
|
||||
use super::*;
|
||||
use crate::mesher::TerrainVertex;
|
||||
#[test]
|
||||
@@ -3033,7 +3262,8 @@ mod tests {
|
||||
for camera in [[0, 0], [100, -40]] {
|
||||
for distance in [64, 128, 512, 1024] {
|
||||
let full = Plan::new(camera, 12, distance);
|
||||
let reduced = Plan::with_previous(camera, 12, distance, &Plan::default(), 0, true);
|
||||
let reduced =
|
||||
Plan::with_previous(camera, 12, distance, &Plan::default(), 0, true, None);
|
||||
// Never finer than Full anywhere, and as fine next to the
|
||||
// full-detail area, whose edge the finest tiles match.
|
||||
assert!(reduced.split.is_subset(&full.split));
|
||||
@@ -3093,10 +3323,24 @@ mod tests {
|
||||
([115, 135], 49_000_000),
|
||||
([-30_000_000, 29_999_000], 90),
|
||||
] {
|
||||
let plan =
|
||||
Plan::with_previous(camera, 12, MAX_CHUNKS, &Plan::default(), elevation, false);
|
||||
let reduced =
|
||||
Plan::with_previous(camera, 12, MAX_CHUNKS, &Plan::default(), elevation, true);
|
||||
let plan = Plan::with_previous(
|
||||
camera,
|
||||
12,
|
||||
MAX_CHUNKS,
|
||||
&Plan::default(),
|
||||
elevation,
|
||||
false,
|
||||
None,
|
||||
);
|
||||
let reduced = Plan::with_previous(
|
||||
camera,
|
||||
12,
|
||||
MAX_CHUNKS,
|
||||
&Plan::default(),
|
||||
elevation,
|
||||
true,
|
||||
None,
|
||||
);
|
||||
let top = plan.nodes.iter().map(|k| k.level).max().unwrap();
|
||||
assert_eq!(top, MAX_LEVEL, "{camera:?} y={elevation}: roots stop short");
|
||||
assert!(
|
||||
@@ -3123,7 +3367,7 @@ mod tests {
|
||||
#[test]
|
||||
fn aerial_distance_avoids_unseen_full_detail_outside_the_near_area() {
|
||||
let ground = Plan::new([0, 0], 12, 1024);
|
||||
let high = Plan::with_previous([0, 0], 12, 1024, &Plan::default(), 1024, false);
|
||||
let high = Plan::with_previous([0, 0], 12, 1024, &Plan::default(), 1024, false, None);
|
||||
assert!(high.nodes.len() < ground.nodes.len());
|
||||
assert!(
|
||||
high.nodes.contains(&TileKey {
|
||||
@@ -3178,7 +3422,7 @@ mod tests {
|
||||
// stand-in for a covered column, and only the roots are sampled.
|
||||
let camera = [8, 8];
|
||||
let before = Plan::new(camera, 64, 64);
|
||||
let after = Plan::with_previous(camera, 8, 64, &before, 0, false);
|
||||
let after = Plan::with_previous(camera, 8, 64, &before, 0, false, None);
|
||||
let mut ready: FxHashMap<TileKey, ()> = before
|
||||
.nodes
|
||||
.iter()
|
||||
@@ -3217,7 +3461,7 @@ mod tests {
|
||||
#[test]
|
||||
fn detail_survives_movement_across_a_split_threshold() {
|
||||
let before = Plan::new([0, 0], 2, 256);
|
||||
let after = Plan::with_previous([16, 0], 2, 256, &before, 0, false);
|
||||
let after = Plan::with_previous([16, 0], 2, 256, &before, 0, false, None);
|
||||
let mut retained = 0;
|
||||
let range = i64::from(256 * 16);
|
||||
for key in &before.split {
|
||||
@@ -3242,12 +3486,12 @@ mod tests {
|
||||
let before = Plan::new([0, 0], 40, 256);
|
||||
assert!(before.split.contains(&tile));
|
||||
// 670 blocks: past the ring, inside 5% of it.
|
||||
let held = Plan::with_previous([-30, 0], 40, 256, &before, 0, false);
|
||||
let held = Plan::with_previous([-30, 0], 40, 256, &before, 0, false, None);
|
||||
assert!(held.split.contains(&tile));
|
||||
// Not held without having been split: it was never in the ring.
|
||||
assert!(!Plan::new([-30, 0], 40, 256).split.contains(&tile));
|
||||
// 700 blocks: past the margin.
|
||||
let gone = Plan::with_previous([-60, 0], 40, 256, &before, 0, false);
|
||||
let gone = Plan::with_previous([-60, 0], 40, 256, &before, 0, false, None);
|
||||
assert!(!gone.split.contains(&tile));
|
||||
}
|
||||
#[test]
|
||||
@@ -3749,7 +3993,7 @@ mod tests {
|
||||
// Exercise the actual worker/update seam: a parent stays displayed
|
||||
// until all four child meshes can replace it in the same update.
|
||||
let camera = [-256.0, 100.0, -256.0];
|
||||
lod.area = Some(([-16, -16], 2, 64, 0, false));
|
||||
lod.area = Some(([-16, -16], 2, 64, 0, false, None));
|
||||
lod.plan.roots = vec![key];
|
||||
lod.plan.nodes.insert(key);
|
||||
// As a replan does (`update` only selects after a change).
|
||||
@@ -3757,7 +4001,7 @@ mod tests {
|
||||
let await_meshes = |lod: &mut Lod| {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
loop {
|
||||
let meshes = lod.update(camera, 2, 64, None);
|
||||
let meshes = lod.update(camera, 2, 64, None, None);
|
||||
if !meshes.is_empty() {
|
||||
return meshes;
|
||||
}
|
||||
@@ -3775,7 +4019,7 @@ mod tests {
|
||||
lod.ready.insert(child, lod.ready[&key].clone());
|
||||
}
|
||||
lod.reselect = true;
|
||||
assert!(lod.update(camera, 2, 64, None).is_empty());
|
||||
assert!(lod.update(camera, 2, 64, None, None).is_empty());
|
||||
assert!(
|
||||
lod.active.contains(&key),
|
||||
"parent retired before child meshes"
|
||||
@@ -3804,7 +4048,7 @@ mod tests {
|
||||
lod.reselect = true;
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
loop {
|
||||
lod.update(camera, 2, 64, None);
|
||||
lod.update(camera, 2, 64, None, None);
|
||||
if lod.outstanding_meshes == 0 && lod.active.is_empty() {
|
||||
break;
|
||||
}
|
||||
@@ -3844,7 +4088,7 @@ mod tests {
|
||||
.collect();
|
||||
lod.detail_request_pending = true;
|
||||
lod.wanted_stale = true;
|
||||
lod.update(camera, 2, 64, Some(FxHashSet::default()));
|
||||
lod.update(camera, 2, 64, Some(FxHashSet::default()), None);
|
||||
let Some(Job::Sample(nearby)) = lod.shared.jobs.lock().unwrap().pop_front() else {
|
||||
panic!("nearby detail was not scheduled");
|
||||
};
|
||||
@@ -3855,7 +4099,7 @@ mod tests {
|
||||
"the nearest tiles load first"
|
||||
);
|
||||
lod.finish_sample(nearby, vec![Column::default(); 256]);
|
||||
lod.update(camera, 2, 64, Some(FxHashSet::default()));
|
||||
lod.update(camera, 2, 64, Some(FxHashSet::default()), None);
|
||||
let Some(Job::Sample(next)) = lod.shared.jobs.lock().unwrap().pop_front() else {
|
||||
panic!("the next ring was not scheduled");
|
||||
};
|
||||
@@ -3894,7 +4138,7 @@ mod tests {
|
||||
level: 0,
|
||||
})
|
||||
.collect();
|
||||
lod.update(camera, 2, 64, Some(FxHashSet::default()));
|
||||
lod.update(camera, 2, 64, Some(FxHashSet::default()), None);
|
||||
let Some(Job::Sample(first)) = lod.shared.jobs.lock().unwrap().pop_front() else {
|
||||
panic!("no final tile scheduled")
|
||||
};
|
||||
@@ -3938,7 +4182,7 @@ mod tests {
|
||||
lod.shared.jobs.lock().unwrap().clear();
|
||||
lod.pending.clear();
|
||||
lod.wanted_stale = true;
|
||||
lod.update([-256., 64., -256.], 2, 64, Some(FxHashSet::default()));
|
||||
lod.update([-256., 64., -256.], 2, 64, Some(FxHashSet::default()), None);
|
||||
let jobs = lod.shared.jobs.lock().unwrap();
|
||||
jobs.iter()
|
||||
.filter(|job| matches!(job, Job::Sample(_)))
|
||||
@@ -3954,7 +4198,7 @@ mod tests {
|
||||
// Leaves' cells: 1 block to 256 blocks out, 2 to 512 (Full); Reduced
|
||||
// keeps 1 to the near terrain's edge and 2 to 256.
|
||||
for (reduced, [one, two]) in [(false, [256, 512]), (true, [208, 256])] {
|
||||
let plan = Plan::with_previous([8, 8], 12, 128, &Plan::default(), 0, reduced);
|
||||
let plan = Plan::with_previous([8, 8], 12, 128, &Plan::default(), 0, reduced, None);
|
||||
for key in plan.nodes.iter().filter(|key| !plan.split.contains(key)) {
|
||||
let distance = key.distance([8, 8]);
|
||||
if distance < one {
|
||||
@@ -3984,7 +4228,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
let plan = Plan::with_previous(camera, detail, distance, &Plan::default(), 0, reduced);
|
||||
let plan =
|
||||
Plan::with_previous(camera, detail, distance, &Plan::default(), 0, reduced, None);
|
||||
let leaf = |x: i32, z: i32| {
|
||||
(0..=MAX_LEVEL)
|
||||
.map(|level| TileKey {
|
||||
@@ -4250,7 +4495,7 @@ mod tests {
|
||||
lod.finish_sample(a, vec![Column::default(); CELLS * CELLS]);
|
||||
lod.dirty.clear();
|
||||
// A range of one chunk around (-10, 0) keeps `a` and drops `b`.
|
||||
lod.replan([-10, 0], 0, 1, 0);
|
||||
lod.replan([-10, 0], 0, 1, 0, None);
|
||||
assert!(lod.plan.nodes.contains(&a) && !lod.plan.nodes.contains(&b));
|
||||
assert!(lod.dirty.contains_key(&a), "its east wall is drawn now");
|
||||
}
|
||||
@@ -4305,7 +4550,7 @@ mod tests {
|
||||
keys
|
||||
};
|
||||
let none = || Some(FxHashSet::default());
|
||||
lod.update([8., 64., 8.], 2, 64, none());
|
||||
lod.update([8., 64., 8.], 2, 64, none(), None);
|
||||
let before = queued(&lod);
|
||||
assert_eq!(before.len(), 16, "a full queue once the near terrain is in");
|
||||
lod.shared
|
||||
@@ -4315,7 +4560,7 @@ mod tests {
|
||||
.push_back(Job::Observe((5, 5)));
|
||||
|
||||
// A chunk's move: the tiles still planned keep their place in line.
|
||||
lod.update([24., 64., 8.], 2, 64, none());
|
||||
lod.update([24., 64., 8.], 2, 64, none(), None);
|
||||
let moved = queued(&lod);
|
||||
let kept: Vec<_> = before
|
||||
.iter()
|
||||
@@ -4328,7 +4573,7 @@ mod tests {
|
||||
assert!(observing(&lod), "only sample jobs are dropped");
|
||||
|
||||
// A teleport: none of the old view's tiles is sampled any more.
|
||||
lod.update([100_000., 64., 100_000.], 2, 64, none());
|
||||
lod.update([100_000., 64., 100_000.], 2, 64, none(), None);
|
||||
let after = queued(&lod);
|
||||
assert_eq!(after.len(), 16);
|
||||
assert!(after.iter().all(|key| !moved.contains(key)));
|
||||
@@ -4346,7 +4591,7 @@ mod tests {
|
||||
assert!(lod.pending.is_empty());
|
||||
assert!(observing(&lod));
|
||||
lod.set_near_loading(false);
|
||||
lod.update([100_000., 64., 100_000.], 2, 64, none());
|
||||
lod.update([100_000., 64., 100_000.], 2, 64, none(), None);
|
||||
assert_eq!(queued(&lod), after);
|
||||
|
||||
// Larger machines keep the first `SAMPLE_JOBS` of the queue.
|
||||
@@ -4754,7 +4999,7 @@ mod tests {
|
||||
};
|
||||
for _ in 0..2 {
|
||||
for _ in 0..4000 {
|
||||
lod.update(camera, 2, 64, Some(FxHashSet::default()));
|
||||
lod.update(camera, 2, 64, Some(FxHashSet::default()), None);
|
||||
check(&lod);
|
||||
if !finish_queued(&mut lod) {
|
||||
break;
|
||||
@@ -4765,11 +5010,11 @@ mod tests {
|
||||
check(&lod);
|
||||
// Move a chunk: retained samples stay selected, new ones join.
|
||||
camera[0] += 16.0;
|
||||
lod.update(camera, 2, 64, Some(FxHashSet::default()));
|
||||
lod.update(camera, 2, 64, Some(FxHashSet::default()), None);
|
||||
check(&lod);
|
||||
}
|
||||
// A covered level-0 stand-in completes its parent but is not drawn.
|
||||
lod.update(camera, 2, 64, Some([(0, 0)].into_iter().collect()));
|
||||
lod.update(camera, 2, 64, Some([(0, 0)].into_iter().collect()), None);
|
||||
check(&lod);
|
||||
assert!(lod.ready.contains_key(&TileKey {
|
||||
x: 0,
|
||||
@@ -4782,7 +5027,7 @@ mod tests {
|
||||
level: 0
|
||||
}));
|
||||
// Shrinking the distance retires tiles and reports them once.
|
||||
lod.update(camera, 0, 32, Some(FxHashSet::default()));
|
||||
lod.update(camera, 0, 32, Some(FxHashSet::default()), None);
|
||||
check(&lod);
|
||||
let _ = lod.removed();
|
||||
}
|
||||
@@ -4866,7 +5111,7 @@ mod tests {
|
||||
let mut covered = Some(covered.clone());
|
||||
let mut quiet = 0;
|
||||
while quiet < if settle { 50 } else { 3 } {
|
||||
lod.update(camera, detail, distance, covered.take());
|
||||
lod.update(camera, detail, distance, covered.take(), None);
|
||||
lod.removed();
|
||||
// Every tile sampled, but those waiting for their data.
|
||||
let unsampled = lod.plan.nodes.iter().any(|key| {
|
||||
|
||||
@@ -27,6 +27,8 @@ use horizon_world::{
|
||||
fluid::{FluidKind, FluidState},
|
||||
registry::BlockStateId,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use sides::sort_tinted;
|
||||
pub use sides::{Part, Sides, group_parts};
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
@@ -103,6 +103,22 @@ impl Sides {
|
||||
}
|
||||
}
|
||||
|
||||
/// A sort with these parts' `ends` and `planes`, for tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn from_parts(
|
||||
ends: [u32; 14],
|
||||
grouped: bool,
|
||||
split: bool,
|
||||
planes: [f32; 7],
|
||||
) -> Self {
|
||||
Self {
|
||||
ends,
|
||||
grouped,
|
||||
split,
|
||||
planes,
|
||||
}
|
||||
}
|
||||
|
||||
/// The number of solid quads the buckets hold.
|
||||
pub fn quads(&self) -> u32 {
|
||||
self.ends[13]
|
||||
@@ -128,6 +144,26 @@ impl Sides {
|
||||
self.runs(seen, part, draw);
|
||||
}
|
||||
|
||||
/// For GPU culling's copy of `visible_from` with every part (`gfx::gpu_culling`),
|
||||
/// which then only compares: where each bucket of a mesh sorted for one pass ends,
|
||||
/// in `BUCKETS` order, and what `visible_from` compares the eye with for each. That
|
||||
/// is the lowest plane less `MARGIN` for a bucket facing a positive direction, the
|
||||
/// highest plus `MARGIN` for one facing a negative direction, and 0 for the other
|
||||
/// bucket, which it never compares. `None` for a mesh sorted for two passes
|
||||
/// (`group_parts`), whose buckets lie in two stretches each.
|
||||
pub fn thresholds(&self) -> Option<([u32; 7], [f32; 7])> {
|
||||
if self.grouped {
|
||||
return None;
|
||||
}
|
||||
let t: [f32; 7] = std::array::from_fn(|i| match BUCKETS[i] {
|
||||
Bucket::Side(_, true) => self.planes[i] - MARGIN,
|
||||
Bucket::Side(_, false) => self.planes[i] + MARGIN,
|
||||
Bucket::Other => 0.0,
|
||||
});
|
||||
// A bucket's cutout quads end it (`slot`).
|
||||
Some((std::array::from_fn(|b| self.ends[b * 2 + 1]), t))
|
||||
}
|
||||
|
||||
/// The runs of `part`'s quads facing every way.
|
||||
pub fn every(&self, part: Part, draw: impl FnMut(u32, u32)) {
|
||||
self.runs(|_| true, part, draw);
|
||||
|
||||
@@ -272,13 +272,16 @@ impl Opt {
|
||||
Opt::PadAdaptiveTriggers => "horizon.options.adaptive_triggers",
|
||||
Opt::PadLightbar => "horizon.options.lightbar",
|
||||
Opt::PadLookSensitivity => "horizon.options.look_sensitivity",
|
||||
Opt::PadPointerSpeed => "horizon.options.pointer_speed",
|
||||
Opt::PadScrollSpeed => "horizon.options.scroll_speed",
|
||||
Opt::PadRepeatDelay => "horizon.options.repeat_delay",
|
||||
Opt::PadRepeatInterval => "horizon.options.repeat_interval",
|
||||
Opt::PadInvertX => "horizon.options.invert_x",
|
||||
Opt::PadInvertY => "horizon.options.invert_y",
|
||||
Opt::PadToggleSneak => "horizon.options.sneak_button",
|
||||
Opt::PadMoveDeadzone => "horizon.options.move_deadzone",
|
||||
Opt::PadLookDeadzone => "horizon.options.look_deadzone",
|
||||
Opt::PadGlyphs => "horizon.options.button_glyphs",
|
||||
Opt::PadMenuNav => "horizon.options.menu_navigation",
|
||||
Opt::PadKeyboard => "horizon.options.on_screen_keyboard",
|
||||
Opt::ModelPart(i) => [
|
||||
"options.modelPart.cape",
|
||||
@@ -352,6 +355,10 @@ impl Opt {
|
||||
Opt::ChatHeightUnfocused => Kind::Slider(s.chat_height_unfocused),
|
||||
Opt::ChatLineSpacing => Kind::Slider(s.chat_line_spacing),
|
||||
Opt::PadLookSensitivity => Kind::Slider(s.controller.look_sensitivity),
|
||||
Opt::PadPointerSpeed => unit(s.controller.pointer_speed, 0.25, 3.0),
|
||||
Opt::PadScrollSpeed => unit(s.controller.scroll_speed, 1.0, 36.0),
|
||||
Opt::PadRepeatDelay => unit(s.controller.repeat_delay, 0.1, 1.0),
|
||||
Opt::PadRepeatInterval => unit(s.controller.repeat_interval, 0.03, 0.3),
|
||||
Opt::PadMoveDeadzone => unit(s.controller.move_deadzone, 0.0, 0.5),
|
||||
Opt::PadLookDeadzone => unit(s.controller.look_deadzone, 0.0, 0.5),
|
||||
Opt::SprintWindow => unit(s.sprint_window as f32, 0.0, 10.0),
|
||||
@@ -610,6 +617,23 @@ impl Opt {
|
||||
),
|
||||
),
|
||||
Opt::PadLookSensitivity => percent(c, 2.0 * s.controller.look_sensitivity),
|
||||
Opt::PadPointerSpeed => percent(c, s.controller.pointer_speed),
|
||||
Opt::PadScrollSpeed => percent(c, s.controller.scroll_speed / 12.0),
|
||||
Opt::PadRepeatDelay | Opt::PadRepeatInterval => generic(
|
||||
c,
|
||||
Text::tr(
|
||||
"horizon.options.milliseconds",
|
||||
&[&format!(
|
||||
"{:.0}",
|
||||
1000.0
|
||||
* if self == Opt::PadRepeatDelay {
|
||||
s.controller.repeat_delay
|
||||
} else {
|
||||
s.controller.repeat_interval
|
||||
}
|
||||
)],
|
||||
),
|
||||
),
|
||||
Opt::PadMoveDeadzone | Opt::PadLookDeadzone => {
|
||||
let v = if self == Opt::PadMoveDeadzone {
|
||||
s.controller.move_deadzone
|
||||
@@ -796,10 +820,6 @@ impl Opt {
|
||||
GlyphStyle::PlayStation => "horizon.options.glyphs.playstation",
|
||||
GlyphStyle::Nintendo => "horizon.options.glyphs.nintendo",
|
||||
}),
|
||||
Opt::PadMenuNav => tr(match s.controller.menu_nav {
|
||||
gamepad::MenuNav::Focus => "horizon.options.menu_focus",
|
||||
gamepad::MenuNav::Pointer => "horizon.options.menu_pointer",
|
||||
}),
|
||||
Opt::PadKeyboard => tr(match s.controller.keyboard {
|
||||
gamepad::TextKeyboard::Auto => "horizon.options.keyboard.auto",
|
||||
gamepad::TextKeyboard::Horizon => "horizon.options.keyboard.horizon",
|
||||
@@ -871,6 +891,10 @@ impl Opt {
|
||||
Opt::ChatHeightUnfocused => s.chat_height_unfocused = v,
|
||||
Opt::ChatLineSpacing => s.chat_line_spacing = v,
|
||||
Opt::PadLookSensitivity => s.controller.look_sensitivity = v,
|
||||
Opt::PadPointerSpeed => s.controller.pointer_speed = step(25.0, 300.0) / 100.0,
|
||||
Opt::PadScrollSpeed => s.controller.scroll_speed = step(1.0, 36.0),
|
||||
Opt::PadRepeatDelay => s.controller.repeat_delay = step(100.0, 1000.0) / 1000.0,
|
||||
Opt::PadRepeatInterval => s.controller.repeat_interval = step(30.0, 300.0) / 1000.0,
|
||||
Opt::PadMoveDeadzone => s.controller.move_deadzone = step(0.0, 50.0) / 100.0,
|
||||
Opt::PadLookDeadzone => s.controller.look_deadzone = step(0.0, 50.0) / 100.0,
|
||||
Opt::SprintWindow => s.sprint_window = step(0.0, 10.0) as i32,
|
||||
@@ -1094,12 +1118,6 @@ impl Opt {
|
||||
.unwrap_or(0) as u8;
|
||||
s.controller.glyphs = GlyphStyle::ALL[usize::from(next(i, 4))];
|
||||
}
|
||||
Opt::PadMenuNav => {
|
||||
s.controller.menu_nav = match s.controller.menu_nav {
|
||||
gamepad::MenuNav::Focus => gamepad::MenuNav::Pointer,
|
||||
gamepad::MenuNav::Pointer => gamepad::MenuNav::Focus,
|
||||
}
|
||||
}
|
||||
Opt::PadKeyboard => {
|
||||
let all = gamepad::TextKeyboard::ALL;
|
||||
let i = all
|
||||
@@ -1255,6 +1273,8 @@ pub enum Page {
|
||||
Controller,
|
||||
PadBinds,
|
||||
Extras,
|
||||
/// PvP & HUD (`pvp`): the extras a PvP player sets, on their own page.
|
||||
Pvp,
|
||||
/// CreditsAndAttributionScreen.
|
||||
Credits,
|
||||
/// Horizon Settings: every page Horizon adds, as a mod's settings
|
||||
@@ -1718,6 +1738,7 @@ fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
|
||||
Page::GraphicsAdvanced => "horizon.pages.graphics_advanced.tooltip",
|
||||
Page::Controller => "horizon.pages.controller.tooltip",
|
||||
Page::Extras => "horizon.pages.extras.tooltip",
|
||||
Page::Pvp => "horizon.pages.pvp.tooltip",
|
||||
Page::Agent => "horizon.pages.agent.tooltip",
|
||||
Page::SplitProfiles => "horizon.pages.split_profiles.tooltip",
|
||||
Page::ShaderPacks => "horizon.pages.shader_packs.tooltip",
|
||||
@@ -2080,6 +2101,13 @@ fn pairs(opts: &[Opt]) -> Vec<Row> {
|
||||
.collect()
|
||||
}
|
||||
impl OptionsUi {
|
||||
/// No widget keeps the highlight a click gave it; a focus the keyboard
|
||||
/// moved stays.
|
||||
pub fn clear_click_focus(&mut self) {
|
||||
if !self.focus_from_keyboard {
|
||||
self.focus = None;
|
||||
}
|
||||
}
|
||||
/// A switch of `horizon-extras.txt` as the Extras page draws it: "Caption: On".
|
||||
fn extra_button(&self, s: &Settings, item: crate::extras::Item) -> Widget {
|
||||
let label = Text::option_name_value(
|
||||
@@ -2317,6 +2345,7 @@ impl OptionsUi {
|
||||
Page::Controller => "horizon.pages.controller",
|
||||
Page::PadBinds => "horizon.pages.controller_binds",
|
||||
Page::Extras => "horizon.pages.extras",
|
||||
Page::Pvp => "horizon.pages.pvp",
|
||||
Page::Credits => "credits_and_attribution.screen.title",
|
||||
Page::Horizon => "horizon.pages.settings",
|
||||
Page::Agent => "horizon.pages.agent",
|
||||
@@ -2413,7 +2442,7 @@ impl OptionsUi {
|
||||
fn list_rows(&self, s: &Settings) -> Vec<Row> {
|
||||
match self.page() {
|
||||
Page::SplitSetup => Vec::new(),
|
||||
Page::Extras => {
|
||||
Page::Extras | Page::Pvp => {
|
||||
// Each section's switches two to a row, as vanilla's
|
||||
// OptionsList.addSmall lays out an options screen.
|
||||
let extra = |item: crate::extras::Item| {
|
||||
@@ -2450,7 +2479,14 @@ impl OptionsUi {
|
||||
);
|
||||
section.clear();
|
||||
};
|
||||
for row in crate::extras::rows() {
|
||||
let page_rows = match self.page() {
|
||||
Page::Pvp => crate::pvp::rows()
|
||||
.into_iter()
|
||||
.map(|r| r.map(crate::extras::Item::Pvp))
|
||||
.collect(),
|
||||
_ => crate::extras::rows(),
|
||||
};
|
||||
for row in page_rows {
|
||||
match row {
|
||||
Err(header) => {
|
||||
flush(&mut rows, &mut section);
|
||||
@@ -2492,7 +2528,7 @@ impl OptionsUi {
|
||||
Page::SplitSetup
|
||||
},
|
||||
),
|
||||
None,
|
||||
Some(open("horizon.pages.open.pvp", Page::Pvp)),
|
||||
),
|
||||
// No AI hides the AI Agent page and everything AI.
|
||||
Row::Buttons(
|
||||
@@ -2738,8 +2774,11 @@ impl OptionsUi {
|
||||
Opt::PadMoveDeadzone,
|
||||
Opt::PadLookDeadzone,
|
||||
Opt::PadGlyphs,
|
||||
Opt::PadMenuNav,
|
||||
Opt::PadKeyboard,
|
||||
Opt::PadPointerSpeed,
|
||||
Opt::PadScrollSpeed,
|
||||
Opt::PadRepeatDelay,
|
||||
Opt::PadRepeatInterval,
|
||||
]));
|
||||
rows
|
||||
}
|
||||
@@ -7556,6 +7595,7 @@ mod tests {
|
||||
Page::Extras,
|
||||
Page::Mods,
|
||||
Page::SplitSetup,
|
||||
Page::Pvp,
|
||||
Page::Agent,
|
||||
]
|
||||
);
|
||||
@@ -8295,20 +8335,22 @@ mod tests {
|
||||
.unwrap();
|
||||
let (ex, ey) = (extra_btn.x + 1.0, extra_btn.y + 1.0);
|
||||
use horizon_cosmetics::prefs::Others;
|
||||
assert_eq!(s.extras.cosmetics_others, Others::Shown);
|
||||
// Not chosen, nothing owned: shows Hidden.
|
||||
assert_eq!(s.extras.cosmetics_others, None);
|
||||
assert_eq!(s.extras.effective_others(), Others::Hidden);
|
||||
|
||||
// Forward
|
||||
// Forward (wraps to Shown), now a choice
|
||||
ui.click(&mut s, &atlas, ex, ey, size, [854, 480], false, false);
|
||||
assert_eq!(s.extras.cosmetics_others, Others::NameTags);
|
||||
assert_eq!(s.extras.cosmetics_others, Some(Others::Shown));
|
||||
|
||||
// Backward with shift
|
||||
ui.shift = true;
|
||||
ui.click(&mut s, &atlas, ex, ey, size, [854, 480], false, false);
|
||||
assert_eq!(s.extras.cosmetics_others, Others::Shown);
|
||||
assert_eq!(s.extras.cosmetics_others, Some(Others::Hidden));
|
||||
|
||||
// Wrap-around backward with shift
|
||||
// Backward again
|
||||
ui.click(&mut s, &atlas, ex, ey, size, [854, 480], false, false);
|
||||
assert_eq!(s.extras.cosmetics_others, Others::Hidden);
|
||||
assert_eq!(s.extras.cosmetics_others, Some(Others::NameTags));
|
||||
ui.shift = false;
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,67 @@
|
||||
//! The controller's menu cursor in inventories: a crosshair whose centre is
|
||||
//! the point it clicks. Horizon's own art (pad_cursor.png).
|
||||
|
||||
/// The atlas sprite.
|
||||
pub const SPRITE: &str = "horizon/pad_cursor";
|
||||
/// The texture's size in texels, and the size it's drawn at in GUI pixels
|
||||
/// (an item's).
|
||||
pub const SIZE: u32 = 32;
|
||||
pub const DRAWN: f32 = 16.0;
|
||||
|
||||
const PNG: &[u8] = include_bytes!("pad_cursor.png");
|
||||
|
||||
/// The texture as `SIZE` square RGBA texels.
|
||||
pub fn pixels() -> Vec<u8> {
|
||||
decode(PNG).expect("pad_cursor.png is a 32x32 PNG")
|
||||
}
|
||||
|
||||
fn decode(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info().ok()?;
|
||||
let info = reader.info();
|
||||
if (info.width, info.height) != (SIZE, SIZE) {
|
||||
return None;
|
||||
}
|
||||
let mut buf = vec![0; reader.output_buffer_size()?];
|
||||
let frame = reader.next_frame(&mut buf).ok()?;
|
||||
let channels = frame.color_type.samples();
|
||||
Some(
|
||||
buf.chunks(channels)
|
||||
.take((SIZE * SIZE) as usize)
|
||||
.flat_map(|p| match channels {
|
||||
4 => [p[0], p[1], p[2], p[3]],
|
||||
3 => [p[0], p[1], p[2], 255],
|
||||
2 => [p[0], p[0], p[0], p[1]],
|
||||
_ => [p[0], p[0], p[0], 255],
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Where to draw the cursor (top-left, GUI pixels) so its centre lands on
|
||||
/// the GUI point `at`.
|
||||
pub fn origin(at: (f32, f32)) -> (f32, f32) {
|
||||
(at.0 - DRAWN / 2.0, at.1 - DRAWN / 2.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_cursor_is_a_crosshair_centred_on_its_point() {
|
||||
let px = pixels();
|
||||
assert_eq!(px.len(), (SIZE * SIZE * 4) as usize);
|
||||
let alpha = |x: u32, y: u32| px[((y * SIZE + x) * 4 + 3) as usize];
|
||||
let middle = (SIZE / 2 - 1)..=(SIZE / 2);
|
||||
assert!(
|
||||
middle
|
||||
.clone()
|
||||
.any(|x| middle.clone().any(|y| alpha(x, y) > 0)),
|
||||
"the clicked point sits on the crosshair"
|
||||
);
|
||||
assert_eq!(alpha(0, 0), 0, "corners are clear: a plus, not a square");
|
||||
assert_eq!(origin((100.0, 50.0)), (92.0, 42.0));
|
||||
}
|
||||
}
|
||||
@@ -659,6 +659,30 @@ pub fn around_self(
|
||||
}
|
||||
}
|
||||
|
||||
/// `LivingEntity.handleEntityEvent(46)`: 128 `ParticleTypes.PORTAL` samples
|
||||
/// from the entity's old position (`xo`, `yo`, `zo`) to its current one,
|
||||
/// spread across the bounding box (`getBbWidth() * 2` on x and z, `getBbHeight`
|
||||
/// on y). The speeds are `(nextFloat() - 0.5) * 0.2`.
|
||||
pub fn living_teleport_particles(
|
||||
random: &mut impl RandomSource,
|
||||
from: [f64; 3],
|
||||
to: [f64; 3],
|
||||
width: f64,
|
||||
height: f64,
|
||||
mut emit: impl FnMut([f64; 3], [f64; 3]),
|
||||
) {
|
||||
for i in 0..128 {
|
||||
let d = i as f64 / 127.0;
|
||||
let velocity = [0; 3].map(|_| f64::from((random.next_float() - 0.5) * 0.2));
|
||||
let position = [
|
||||
from[0] + d * (to[0] - from[0]) + (random.next_double() - 0.5) * width * 2.0,
|
||||
from[1] + d * (to[1] - from[1]) + random.next_double() * height,
|
||||
from[2] + d * (to[2] - from[2]) + (random.next_double() - 0.5) * width * 2.0,
|
||||
];
|
||||
emit(position, velocity);
|
||||
}
|
||||
}
|
||||
|
||||
/// ClientLevel.addDestroyBlockEffect placement and pre-constructor velocities.
|
||||
pub fn destroy_positions(
|
||||
pos: [i32; 3],
|
||||
@@ -5411,6 +5435,47 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
/// `LivingEntity.handleEntityEvent(46)`: 128 samples, the first on the old
|
||||
/// box and the last on the new one, with speeds inside ±0.1.
|
||||
#[test]
|
||||
fn living_teleport_particles_span_the_old_and_new_boxes() {
|
||||
let mut random = LegacyRandom::new(1);
|
||||
let mut samples = Vec::new();
|
||||
living_teleport_particles(
|
||||
&mut random,
|
||||
[0.0, 1.0, 0.0],
|
||||
[8.0, 1.0, 0.0],
|
||||
0.6,
|
||||
1.8,
|
||||
|p, v| samples.push((p, v)),
|
||||
);
|
||||
assert_eq!(samples.len(), 128);
|
||||
let first = samples[0].0;
|
||||
let last = samples[127].0;
|
||||
assert!(first[0].abs() <= 0.6, "{first:?}");
|
||||
assert!((1.0..2.8).contains(&first[1]), "{first:?}");
|
||||
assert!(first[2].abs() <= 0.6, "{first:?}");
|
||||
assert!((7.4..8.6).contains(&last[0]), "{last:?}");
|
||||
assert!((1.0..2.8).contains(&last[1]), "{last:?}");
|
||||
assert!(last[2].abs() <= 0.6, "{last:?}");
|
||||
// `(nextDouble - 0.5) * width * 2` reaches ±width. A spread of only
|
||||
// `width` (the factor of 2 dropped) stays inside 0.3 of the line.
|
||||
let edge = samples
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (p, _))| {
|
||||
let along = (i as f64 / 127.0) * 8.0;
|
||||
(p[0] - along).abs()
|
||||
})
|
||||
.fold(0.0_f64, f64::max);
|
||||
assert!(edge > 0.45, "box spread reached only {edge}");
|
||||
for (_, v) in &samples {
|
||||
for c in v {
|
||||
assert!((-0.1..0.1).contains(c), "{v:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn egg_teleport_unpacks_offsets_and_preserves_seeded_draw_order() {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
|
||||
@@ -0,0 +1,687 @@
|
||||
//! PvP and HUD options (`docs/pvp.md`): a custom crosshair. Kept in
|
||||
//! `horizon-extras.txt` with the other extras ([`crate::extras::Extras::pvp`]) and shown on
|
||||
//! Horizon Settings > PvP & HUD. Every option is off by default, so the game looks like
|
||||
//! vanilla until the player changes one, and none of them shows anything vanilla doesn't
|
||||
//! already tell the player.
|
||||
|
||||
/// Colours the PvP options pick from. `Inverted` is vanilla's crosshair blend: the colour
|
||||
/// under it, inverted.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Colour {
|
||||
Inverted,
|
||||
White,
|
||||
Red,
|
||||
Orange,
|
||||
Yellow,
|
||||
Green,
|
||||
Cyan,
|
||||
Blue,
|
||||
Magenta,
|
||||
Black,
|
||||
}
|
||||
impl Colour {
|
||||
pub const ALL: [Colour; 10] = [
|
||||
Colour::Inverted,
|
||||
Colour::White,
|
||||
Colour::Red,
|
||||
Colour::Orange,
|
||||
Colour::Yellow,
|
||||
Colour::Green,
|
||||
Colour::Cyan,
|
||||
Colour::Blue,
|
||||
Colour::Magenta,
|
||||
Colour::Black,
|
||||
];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Colour::Inverted => "inverted",
|
||||
Colour::White => "white",
|
||||
Colour::Red => "red",
|
||||
Colour::Orange => "orange",
|
||||
Colour::Yellow => "yellow",
|
||||
Colour::Green => "green",
|
||||
Colour::Cyan => "cyan",
|
||||
Colour::Blue => "blue",
|
||||
Colour::Magenta => "magenta",
|
||||
Colour::Black => "black",
|
||||
}
|
||||
}
|
||||
fn english(self) -> &'static str {
|
||||
match self {
|
||||
Colour::Inverted => "Inverted",
|
||||
Colour::White => "White",
|
||||
Colour::Red => "Red",
|
||||
Colour::Orange => "Orange",
|
||||
Colour::Yellow => "Yellow",
|
||||
Colour::Green => "Green",
|
||||
Colour::Cyan => "Cyan",
|
||||
Colour::Blue => "Blue",
|
||||
Colour::Magenta => "Magenta",
|
||||
Colour::Black => "Black",
|
||||
}
|
||||
}
|
||||
/// The name in the current language.
|
||||
pub fn label(self) -> String {
|
||||
crate::lang::get_or(
|
||||
&format!("horizon.pvp.colour.{}", self.name()),
|
||||
self.english(),
|
||||
)
|
||||
}
|
||||
/// Opaque ARGB; white for `Inverted` (the blend makes it the inverse).
|
||||
pub fn argb(self) -> u32 {
|
||||
match self {
|
||||
Colour::Inverted | Colour::White => 0xFFFF_FFFF,
|
||||
Colour::Red => 0xFFFF_3030,
|
||||
Colour::Orange => 0xFFFF_A020,
|
||||
Colour::Yellow => 0xFFFF_FF40,
|
||||
Colour::Green => 0xFF40_FF40,
|
||||
Colour::Cyan => 0xFF40_FFFF,
|
||||
Colour::Blue => 0xFF40_80FF,
|
||||
Colour::Magenta => 0xFFFF_40FF,
|
||||
Colour::Black => 0xFF00_0000,
|
||||
}
|
||||
}
|
||||
fn parse(s: &str) -> Option<Colour> {
|
||||
Self::ALL.into_iter().find(|c| c.name() == s)
|
||||
}
|
||||
fn step(self, back: bool) -> Colour {
|
||||
step(&Self::ALL, self, back)
|
||||
}
|
||||
}
|
||||
|
||||
/// The next (or previous) value of `all` after `v`, wrapping.
|
||||
fn step<T: Copy + PartialEq>(all: &[T], v: T, back: bool) -> T {
|
||||
let i = all.iter().position(|&x| x == v).unwrap_or(0);
|
||||
let n = all.len();
|
||||
all[if back { (i + n - 1) % n } else { (i + 1) % n }]
|
||||
}
|
||||
/// `v` one step up or down within `lo..=hi`, wrapping.
|
||||
fn step_num(v: u8, lo: u8, hi: u8, back: bool) -> u8 {
|
||||
match (back, v) {
|
||||
(false, v) if v >= hi => lo,
|
||||
(false, v) => v + 1,
|
||||
(true, v) if v <= lo => hi,
|
||||
(true, v) => v - 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Shape {
|
||||
/// Four arms, as vanilla's.
|
||||
Cross,
|
||||
/// No arm above.
|
||||
T,
|
||||
/// Four diagonal arms.
|
||||
X,
|
||||
Circle,
|
||||
Square,
|
||||
/// Only the dot.
|
||||
Dot,
|
||||
}
|
||||
impl Shape {
|
||||
pub const ALL: [Shape; 6] = [
|
||||
Shape::Cross,
|
||||
Shape::T,
|
||||
Shape::X,
|
||||
Shape::Circle,
|
||||
Shape::Square,
|
||||
Shape::Dot,
|
||||
];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Shape::Cross => "cross",
|
||||
Shape::T => "t",
|
||||
Shape::X => "x",
|
||||
Shape::Circle => "circle",
|
||||
Shape::Square => "square",
|
||||
Shape::Dot => "dot",
|
||||
}
|
||||
}
|
||||
fn english(self) -> &'static str {
|
||||
match self {
|
||||
Shape::Cross => "Cross",
|
||||
Shape::T => "T",
|
||||
Shape::X => "X",
|
||||
Shape::Circle => "Circle",
|
||||
Shape::Square => "Square",
|
||||
Shape::Dot => "Dot",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> String {
|
||||
crate::lang::get_or(
|
||||
&format!("horizon.pvp.shape.{}", self.name()),
|
||||
self.english(),
|
||||
)
|
||||
}
|
||||
fn parse(s: &str) -> Option<Shape> {
|
||||
Self::ALL.into_iter().find(|c| c.name() == s)
|
||||
}
|
||||
}
|
||||
|
||||
/// The crosshair's look. Its defaults draw vanilla's: a 15-pixel cross, one pixel thick,
|
||||
/// inverting what is under it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Crosshair {
|
||||
/// Off: vanilla's crosshair sprite (a resource pack's included).
|
||||
pub custom: bool,
|
||||
pub shape: Shape,
|
||||
/// Arm length in GUI pixels (the circle's and square's radius past the gap).
|
||||
pub size: u8,
|
||||
pub gap: u8,
|
||||
pub thickness: u8,
|
||||
pub colour: Colour,
|
||||
/// A one-pixel black rim around every part.
|
||||
pub outline: bool,
|
||||
/// A dot in the middle.
|
||||
pub dot: bool,
|
||||
/// The colour while the crosshair is over a living entity; `None` keeps `colour`.
|
||||
pub hover: Option<Colour>,
|
||||
}
|
||||
pub const SIZES: (u8, u8) = (1, 12);
|
||||
pub const GAPS: (u8, u8) = (0, 8);
|
||||
pub const THICKNESSES: (u8, u8) = (1, 4);
|
||||
impl Default for Crosshair {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
custom: false,
|
||||
shape: Shape::Cross,
|
||||
size: 7,
|
||||
gap: 0,
|
||||
thickness: 1,
|
||||
colour: Colour::Inverted,
|
||||
outline: false,
|
||||
dot: false,
|
||||
hover: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The crosshair's pixels on a 64x64 grid centred on bit 32 of row 32 (the middle pixel of
|
||||
/// vanilla's 15x15 sprite): bit `x` of row `y` is the pixel `x - 32`, `y - 32` from it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Mask(pub [u64; 64]);
|
||||
pub const MASK_CENTRE: i32 = 32;
|
||||
impl Mask {
|
||||
fn set(&mut self, x: i32, y: i32) {
|
||||
let (gx, gy) = (x + MASK_CENTRE, y + MASK_CENTRE);
|
||||
if (0..64).contains(&gx) && (0..64).contains(&gy) {
|
||||
self.0[gy as usize] |= 1 << gx;
|
||||
}
|
||||
}
|
||||
fn rect(&mut self, x0: i32, y0: i32, x1: i32, y1: i32) {
|
||||
for y in y0..=y1 {
|
||||
for x in x0..=x1 {
|
||||
self.set(x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn get(&self, x: i32, y: i32) -> bool {
|
||||
let (gx, gy) = (x + MASK_CENTRE, y + MASK_CENTRE);
|
||||
(0..64).contains(&gx) && (0..64).contains(&gy) && self.0[gy as usize] >> gx & 1 == 1
|
||||
}
|
||||
/// The pixels around this mask's (eight neighbours) that are not in it.
|
||||
pub fn rim(&self) -> Mask {
|
||||
let mut grown = [0u64; 64];
|
||||
for (y, &row) in self.0.iter().enumerate() {
|
||||
let spread = row | row << 1 | row >> 1;
|
||||
for dy in [y.wrapping_sub(1), y, y + 1] {
|
||||
if let Some(g) = grown.get_mut(dy) {
|
||||
*g |= spread;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (g, &row) in grown.iter_mut().zip(&self.0) {
|
||||
*g &= !row;
|
||||
}
|
||||
Mask(grown)
|
||||
}
|
||||
/// Each row's runs of set pixels as (x0, y, x1) offsets from the centre, `x1` exclusive.
|
||||
pub fn runs(&self) -> impl Iterator<Item = (i32, i32, i32)> + '_ {
|
||||
self.0.iter().enumerate().flat_map(|(y, &row)| {
|
||||
let mut bits = row;
|
||||
std::iter::from_fn(move || {
|
||||
if bits == 0 {
|
||||
return None;
|
||||
}
|
||||
let start = bits.trailing_zeros();
|
||||
let len = (bits >> start).trailing_ones();
|
||||
bits &= !((((1u128 << len) - 1) as u64) << start);
|
||||
let y = y as i32 - MASK_CENTRE;
|
||||
let x0 = start as i32 - MASK_CENTRE;
|
||||
Some((x0, y, x0 + len as i32))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
impl Crosshair {
|
||||
/// The pixels of the shape and its dot.
|
||||
pub fn mask(&self) -> Mask {
|
||||
let mut m = Mask([0; 64]);
|
||||
let t = i32::from(self.thickness.clamp(THICKNESSES.0, THICKNESSES.1));
|
||||
// A thickness band centred on the middle pixel (the extra pixel of an even one
|
||||
// below and right).
|
||||
let (lo, hi) = (-(t - 1) / 2, -(t - 1) / 2 + t - 1);
|
||||
let size = i32::from(self.size.clamp(SIZES.0, SIZES.1));
|
||||
let gap = i32::from(self.gap.min(GAPS.1));
|
||||
let (near, far) = (hi + 1 + gap, hi + gap + size);
|
||||
let (near_neg, far_neg) = (lo - 1 - gap, lo - gap - size);
|
||||
match self.shape {
|
||||
Shape::Cross | Shape::T => {
|
||||
m.rect(near, lo, far, hi);
|
||||
m.rect(far_neg, lo, near_neg, hi);
|
||||
m.rect(lo, near, hi, far);
|
||||
if self.shape == Shape::Cross {
|
||||
m.rect(lo, far_neg, hi, near_neg);
|
||||
}
|
||||
if gap == 0 {
|
||||
m.rect(lo, lo, hi, hi);
|
||||
}
|
||||
}
|
||||
Shape::X => {
|
||||
for d in gap + 1..=gap + size {
|
||||
for (sx, sy) in [(1, 1), (1, -1), (-1, 1), (-1, -1)] {
|
||||
let (x, y) = (sx * d, sy * d);
|
||||
m.rect(x + lo, y + lo, x + hi, y + hi);
|
||||
}
|
||||
}
|
||||
if gap == 0 {
|
||||
m.rect(lo, lo, hi, hi);
|
||||
}
|
||||
}
|
||||
Shape::Circle | Shape::Square => {
|
||||
let outer = gap + size;
|
||||
let inner = outer - t;
|
||||
for y in -outer..=outer {
|
||||
for x in -outer..=outer {
|
||||
let inside = |r: i32| match self.shape {
|
||||
Shape::Circle => {
|
||||
let d = ((x * x + y * y) as f32).sqrt();
|
||||
d <= r as f32 + 0.5
|
||||
}
|
||||
_ => x.abs().max(y.abs()) <= r,
|
||||
};
|
||||
if inside(outer) && !inside(inner) {
|
||||
m.set(x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Shape::Dot => {}
|
||||
}
|
||||
if self.dot || self.shape == Shape::Dot {
|
||||
m.rect(lo, lo, hi, hi);
|
||||
}
|
||||
m
|
||||
}
|
||||
}
|
||||
|
||||
/// The crosshair's draw list, cached: rebuilt only when its settings change.
|
||||
#[derive(Default)]
|
||||
pub struct CrosshairCache {
|
||||
key: Option<Crosshair>,
|
||||
shape: Vec<(i32, i32, i32)>,
|
||||
rim: Vec<(i32, i32, i32)>,
|
||||
}
|
||||
impl CrosshairCache {
|
||||
/// The shape's runs and its outline's (empty without one), as [`Mask::runs`].
|
||||
pub fn runs(&mut self, c: &Crosshair) -> (&[(i32, i32, i32)], &[(i32, i32, i32)]) {
|
||||
if self.key != Some(*c) {
|
||||
let mask = c.mask();
|
||||
self.shape.clear();
|
||||
self.shape.extend(mask.runs());
|
||||
self.rim.clear();
|
||||
if c.outline {
|
||||
self.rim.extend(mask.rim().runs());
|
||||
}
|
||||
self.key = Some(*c);
|
||||
}
|
||||
(&self.shape, &self.rim)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Pvp {
|
||||
pub crosshair: Crosshair,
|
||||
}
|
||||
fn flag(value: &str, d: bool) -> bool {
|
||||
match value {
|
||||
"true" => true,
|
||||
"false" => false,
|
||||
_ => d,
|
||||
}
|
||||
}
|
||||
fn num(value: &str, (lo, hi): (u8, u8), d: u8) -> u8 {
|
||||
value.parse::<u8>().map_or(d, |v| v.clamp(lo, hi))
|
||||
}
|
||||
impl Pvp {
|
||||
/// One `key:value` line of `horizon-extras.txt`; false when the key isn't a PvP one.
|
||||
pub fn parse_line(&mut self, key: &str, value: &str) -> bool {
|
||||
let c = &mut self.crosshair;
|
||||
match key {
|
||||
"crosshair" => c.custom = flag(value, c.custom),
|
||||
"crosshairShape" => c.shape = Shape::parse(value).unwrap_or(c.shape),
|
||||
"crosshairSize" => c.size = num(value, SIZES, c.size),
|
||||
"crosshairGap" => c.gap = num(value, GAPS, c.gap),
|
||||
"crosshairThickness" => c.thickness = num(value, THICKNESSES, c.thickness),
|
||||
"crosshairColour" => c.colour = Colour::parse(value).unwrap_or(c.colour),
|
||||
"crosshairOutline" => c.outline = flag(value, c.outline),
|
||||
"crosshairDot" => c.dot = flag(value, c.dot),
|
||||
"crosshairHover" => {
|
||||
c.hover = match value {
|
||||
"off" => None,
|
||||
v => Colour::parse(v).or(c.hover),
|
||||
}
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
/// The PvP lines of `horizon-extras.txt`.
|
||||
pub fn serialize(&self, out: &mut String) {
|
||||
use std::fmt::Write;
|
||||
let c = &self.crosshair;
|
||||
let _ = write!(
|
||||
out,
|
||||
"crosshair:{}\ncrosshairShape:{}\ncrosshairSize:{}\ncrosshairGap:{}\ncrosshairThickness:{}\ncrosshairColour:{}\ncrosshairOutline:{}\ncrosshairDot:{}\ncrosshairHover:{}\n",
|
||||
c.custom,
|
||||
c.shape.name(),
|
||||
c.size,
|
||||
c.gap,
|
||||
c.thickness,
|
||||
c.colour.name(),
|
||||
c.outline,
|
||||
c.dot,
|
||||
c.hover.map_or("off", Colour::name),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The options of the PvP & HUD page: each one's id (its language keys are `horizon.pvp.<id>`
|
||||
/// and `horizon.pvp.<id>.tooltip`), English caption and description (en_us.json has the same
|
||||
/// text; a test keeps them equal).
|
||||
macro_rules! opts {
|
||||
($($name:ident, $id:literal, $caption:literal, $description:literal;)*) => {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Opt { $($name),* }
|
||||
impl Opt {
|
||||
pub const ALL: &'static [Opt] = &[$(Opt::$name),*];
|
||||
pub fn caption_key(self) -> &'static str {
|
||||
match self { $(Opt::$name => concat!("horizon.pvp", ".", $id)),* }
|
||||
}
|
||||
pub fn description_key(self) -> &'static str {
|
||||
match self { $(Opt::$name => concat!("horizon.pvp", ".", $id, ".tooltip")),* }
|
||||
}
|
||||
pub fn caption(self) -> &'static str {
|
||||
match self { $(Opt::$name => $caption),* }
|
||||
}
|
||||
pub fn description(self) -> &'static str {
|
||||
match self { $(Opt::$name => $description),* }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
opts! {
|
||||
Crosshair, "crosshair", "Custom crosshair",
|
||||
"Draws your own crosshair instead of vanilla's. Its first settings look like vanilla's.";
|
||||
CrosshairShape, "crosshair_shape", "Shape", "A cross, a T, an X, a circle, a square or only a dot.";
|
||||
CrosshairSize, "crosshair_size", "Size", "The length of each arm, in GUI pixels.";
|
||||
CrosshairGap, "crosshair_gap", "Gap", "The empty space in the middle.";
|
||||
CrosshairThickness, "crosshair_thickness", "Thickness", "How wide each line is.";
|
||||
CrosshairColour, "crosshair_colour", "Colour",
|
||||
"Inverted shows the colour under it inverted, as vanilla's crosshair does.";
|
||||
CrosshairOutline, "crosshair_outline", "Outline", "A thin black edge, so it shows on any background.";
|
||||
CrosshairDot, "crosshair_dot", "Centre dot", "A dot in the middle of the crosshair.";
|
||||
CrosshairHover, "crosshair_hover", "Colour on entities",
|
||||
"Another colour while the crosshair is over a mob or a player you could hit, as vanilla's attack indicator knows it.";
|
||||
}
|
||||
/// The PvP & HUD page: headers (language keys) and options.
|
||||
pub fn rows() -> Vec<Result<Opt, &'static str>> {
|
||||
use Opt::*;
|
||||
let mut rows = vec![Err("horizon.pvp.header.crosshair")];
|
||||
rows.extend(
|
||||
[
|
||||
Crosshair,
|
||||
CrosshairShape,
|
||||
CrosshairSize,
|
||||
CrosshairGap,
|
||||
CrosshairThickness,
|
||||
CrosshairColour,
|
||||
CrosshairOutline,
|
||||
CrosshairDot,
|
||||
CrosshairHover,
|
||||
]
|
||||
.map(Ok),
|
||||
);
|
||||
rows
|
||||
}
|
||||
impl Opt {
|
||||
/// The switch this option belongs to.
|
||||
pub fn parent(self) -> Option<Opt> {
|
||||
match self {
|
||||
Opt::Crosshair => None,
|
||||
_ => Some(Opt::Crosshair),
|
||||
}
|
||||
}
|
||||
pub fn switch(self, p: &Pvp) -> Option<bool> {
|
||||
let c = &p.crosshair;
|
||||
Some(match self {
|
||||
Opt::Crosshair => c.custom,
|
||||
Opt::CrosshairOutline => c.outline,
|
||||
Opt::CrosshairDot => c.dot,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
/// The value its button shows, for the options that aren't switches.
|
||||
pub fn value(self, p: &Pvp) -> Option<String> {
|
||||
let c = &p.crosshair;
|
||||
Some(match self {
|
||||
Opt::CrosshairShape => c.shape.label(),
|
||||
Opt::CrosshairSize => c.size.to_string(),
|
||||
Opt::CrosshairGap => c.gap.to_string(),
|
||||
Opt::CrosshairThickness => c.thickness.to_string(),
|
||||
Opt::CrosshairColour => c.colour.label(),
|
||||
Opt::CrosshairHover => match c.hover {
|
||||
Some(colour) => colour.label(),
|
||||
None => crate::lang::get_or("Off", "Off"),
|
||||
},
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
/// A click; `back` (right button, wheel up) goes the other way.
|
||||
pub fn cycle(self, p: &mut Pvp, back: bool) {
|
||||
let c = &mut p.crosshair;
|
||||
match self {
|
||||
Opt::Crosshair => c.custom = !c.custom,
|
||||
Opt::CrosshairShape => c.shape = step(&Shape::ALL, c.shape, back),
|
||||
Opt::CrosshairSize => c.size = step_num(c.size, SIZES.0, SIZES.1, back),
|
||||
Opt::CrosshairGap => c.gap = step_num(c.gap, GAPS.0, GAPS.1, back),
|
||||
Opt::CrosshairThickness => {
|
||||
c.thickness = step_num(c.thickness, THICKNESSES.0, THICKNESSES.1, back)
|
||||
}
|
||||
Opt::CrosshairColour => c.colour = c.colour.step(back),
|
||||
Opt::CrosshairOutline => c.outline = !c.outline,
|
||||
Opt::CrosshairDot => c.dot = !c.dot,
|
||||
Opt::CrosshairHover => {
|
||||
// Off, then every colour.
|
||||
let all: Vec<Option<Colour>> = std::iter::once(None)
|
||||
.chain(Colour::ALL.into_iter().map(Some))
|
||||
.collect();
|
||||
c.hover = step(&all, c.hover, back);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The mask drawn as rows of `#` and `.` over `-r..=r`.
|
||||
fn picture(m: &Mask, r: i32) -> Vec<String> {
|
||||
(-r..=r)
|
||||
.map(|y| {
|
||||
(-r..=r)
|
||||
.map(|x| if m.get(x, y) { '#' } else { '.' })
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_crosshair_covers_vanillas_sprite() {
|
||||
// hud/crosshair.png: a 15x15 plus sign, one pixel wide, through the middle.
|
||||
let m = Crosshair::default().mask();
|
||||
for y in -7..=7 {
|
||||
for x in -7..=7 {
|
||||
assert_eq!(m.get(x, y), x == 0 || y == 0, "({x}, {y})");
|
||||
}
|
||||
}
|
||||
assert!(!m.get(8, 0) && !m.get(0, -8));
|
||||
assert_eq!(
|
||||
m.runs().count(),
|
||||
15,
|
||||
"one run per row: 14 single pixels and the bar"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gap_thickness_shape_and_dot_change_the_pixels() {
|
||||
let c = Crosshair {
|
||||
size: 2,
|
||||
gap: 1,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
picture(&c.mask(), 3),
|
||||
[
|
||||
"...#...", "...#...", ".......", "##...##", ".......", "...#...", "...#...",
|
||||
]
|
||||
);
|
||||
let dotted = Crosshair { dot: true, ..c };
|
||||
assert!(dotted.mask().get(0, 0));
|
||||
let t = Crosshair {
|
||||
shape: Shape::T,
|
||||
thickness: 3,
|
||||
size: 1,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
picture(&t.mask(), 2),
|
||||
[".....", "#####", "#####", "#####", ".###."]
|
||||
);
|
||||
let x = Crosshair {
|
||||
shape: Shape::X,
|
||||
size: 2,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
picture(&x.mask(), 2),
|
||||
["#...#", ".#.#.", "..#..", ".#.#.", "#...#"]
|
||||
);
|
||||
let square = Crosshair {
|
||||
shape: Shape::Square,
|
||||
size: 2,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
picture(&square.mask(), 2),
|
||||
["#####", "#...#", "#...#", "#...#", "#####"]
|
||||
);
|
||||
let ring = Crosshair {
|
||||
shape: Shape::Circle,
|
||||
size: 3,
|
||||
..Default::default()
|
||||
};
|
||||
let p = picture(&ring.mask(), 3);
|
||||
assert_eq!(p[3], "#.....#", "{p:?}");
|
||||
assert_eq!(p[0], "..###..", "{p:?}");
|
||||
assert!(!ring.mask().get(0, 0));
|
||||
let dot = Crosshair {
|
||||
shape: Shape::Dot,
|
||||
thickness: 2,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(picture(&dot.mask(), 1), ["...", ".##", ".##"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_outline_rings_the_shape_without_covering_it() {
|
||||
let dot = Crosshair {
|
||||
shape: Shape::Dot,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
picture(&dot.mask().rim(), 2),
|
||||
[".....", ".###.", ".#.#.", ".###.", "....."]
|
||||
);
|
||||
let mut cache = CrosshairCache::default();
|
||||
let (shape, rim) = cache.runs(&Crosshair {
|
||||
outline: true,
|
||||
..Crosshair::default()
|
||||
});
|
||||
assert_eq!(shape.len(), 15);
|
||||
// Rows -8 and 8 (one run each), and the rows beside the bar: three runs each
|
||||
// (left of it, between, right of it) except where the arms meet it.
|
||||
assert!(rim.iter().all(|&(x0, _, x1)| x1 > x0));
|
||||
assert!(rim.iter().any(|&(_, y, _)| y == -8) && rim.iter().any(|&(_, y, _)| y == 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_round_trip_and_cycle_both_ways() {
|
||||
let d = Pvp::default();
|
||||
let mut text = String::new();
|
||||
d.serialize(&mut text);
|
||||
let mut back = Pvp::default();
|
||||
for line in text.lines() {
|
||||
let (k, v) = line.split_once(':').unwrap();
|
||||
assert!(back.parse_line(k, v), "{k}");
|
||||
}
|
||||
assert_eq!(back, d);
|
||||
let mut p = Pvp::default();
|
||||
for opt in rows().into_iter().flatten() {
|
||||
opt.cycle(&mut p, false);
|
||||
}
|
||||
assert!(p.crosshair.custom && p.crosshair.outline && p.crosshair.dot);
|
||||
assert_eq!(p.crosshair.shape, Shape::T);
|
||||
assert_eq!(p.crosshair.size, 8);
|
||||
assert_eq!(p.crosshair.hover, Some(Colour::Inverted));
|
||||
let mut text = String::new();
|
||||
p.serialize(&mut text);
|
||||
let mut read = Pvp::default();
|
||||
text.lines().for_each(|l| {
|
||||
let (k, v) = l.split_once(':').unwrap();
|
||||
read.parse_line(k, v);
|
||||
});
|
||||
assert_eq!(read, p);
|
||||
// Down from the lowest wraps to the highest; bad values keep what was there.
|
||||
Opt::CrosshairThickness.cycle(&mut p, true);
|
||||
Opt::CrosshairThickness.cycle(&mut p, true);
|
||||
assert_eq!(p.crosshair.thickness, THICKNESSES.1);
|
||||
Opt::CrosshairHover.cycle(&mut p, true);
|
||||
assert_eq!(p.crosshair.hover, None);
|
||||
assert!(p.parse_line("crosshairSize", "99") && p.crosshair.size == SIZES.1);
|
||||
assert!(p.parse_line("crosshairColour", "plaid") && p.crosshair.colour == Colour::White);
|
||||
assert!(!p.parse_line("fullbright", "true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_option_and_value_has_its_english_text() {
|
||||
let en = crate::lang::Language::load(&[], "en_us");
|
||||
for opt in Opt::ALL {
|
||||
assert_eq!(en.get(opt.caption_key()), Some(opt.caption()), "{opt:?}");
|
||||
assert_eq!(
|
||||
en.get(opt.description_key()),
|
||||
Some(opt.description()),
|
||||
"{opt:?}"
|
||||
);
|
||||
}
|
||||
for header in rows().into_iter().filter_map(Result::err) {
|
||||
assert!(en.get(header).is_some(), "{header}");
|
||||
}
|
||||
let names = Colour::ALL.map(|c| (format!("horizon.pvp.colour.{}", c.name()), c.english()));
|
||||
let shapes = Shape::ALL.map(|c| (format!("horizon.pvp.shape.{}", c.name()), c.english()));
|
||||
for (key, english) in names.iter().chain(&shapes) {
|
||||
assert_eq!(en.get(key), Some(*english), "{key}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -651,7 +651,9 @@ impl Settings {
|
||||
}
|
||||
pub fn save(&self, path: &Path) {
|
||||
self.describe_for_crashes();
|
||||
let _ = fs::write(path, self.serialize());
|
||||
if let Err(e) = fs::write(path, self.serialize()) {
|
||||
log::warn!("Horizon: options.txt is not saved: {e}");
|
||||
}
|
||||
if let Some(dir) = path.parent() {
|
||||
self.horizon.save(dir);
|
||||
self.controller.save(dir);
|
||||
@@ -803,13 +805,16 @@ pub enum Opt {
|
||||
PadAdaptiveTriggers,
|
||||
PadLightbar,
|
||||
PadLookSensitivity,
|
||||
PadPointerSpeed,
|
||||
PadScrollSpeed,
|
||||
PadRepeatDelay,
|
||||
PadRepeatInterval,
|
||||
PadInvertX,
|
||||
PadInvertY,
|
||||
PadToggleSneak,
|
||||
PadMoveDeadzone,
|
||||
PadLookDeadzone,
|
||||
PadGlyphs,
|
||||
PadMenuNav,
|
||||
PadKeyboard,
|
||||
/// `options.modelPart.*`: one skin layer (index into `options_file::MODEL_PARTS`).
|
||||
ModelPart(usize),
|
||||
|
||||
@@ -101,6 +101,9 @@ pub struct World {
|
||||
/// Every block position written since the log was last drained, kept
|
||||
/// only while the world is open to the network (see `lan`).
|
||||
pub block_log: Option<Vec<(i32, i32, i32)>>,
|
||||
/// Writes of this tick's block events and block entities, held out of
|
||||
/// the log while it is sent: they go out with the next tick's.
|
||||
pub block_log_held: Vec<(i32, i32, i32)>,
|
||||
/// Every write since the mobs last ticked (position, old and new state),
|
||||
/// for `ServerLevel.sendBlockUpdated`'s path recomputing; kept only while
|
||||
/// the mobs of this world tick (`game/creatures.rs`).
|
||||
@@ -217,6 +220,7 @@ impl World {
|
||||
day_time: 1000,
|
||||
weather: Weather::default(),
|
||||
block_log: None,
|
||||
block_log_held: Vec::new(),
|
||||
path_writes: None,
|
||||
bulk_log: Default::default(),
|
||||
light_log: None,
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
//! An ordinary read of a struct or buffer-block member that an atomic function also uses gets
|
||||
//! the member's current value (#9081), on a host GPU through the translated program.
|
||||
|
||||
use horizon_shaders::{Macros, Pack, preprocess, translate_compute};
|
||||
|
||||
/// Runs compute program `source` once (one work group), with `probe_buffer` (set 4, binding 2)
|
||||
/// starting at `start`, and returns `probe_result.result` (set 4, binding 1).
|
||||
fn run(device: &wgpu::Device, queue: &wgpu::Queue, source: &str, start: [u32; 2]) -> u32 {
|
||||
use std::io::Write;
|
||||
use wgpu::util::DeviceExt;
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
zip.start_file(
|
||||
"shaders/deferred.csh",
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
let pack =
|
||||
Pack::from_zip_bytes("atomic-values.zip", zip.finish().unwrap().into_inner()).unwrap();
|
||||
let program = translate_compute(&preprocess(&pack, "deferred.csh", &Macros::standard()))
|
||||
.unwrap_or_else(|e| panic!("{e:?}"));
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(program.module)),
|
||||
});
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
module: &module,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let storage = wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC;
|
||||
let uniforms = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(program.interface.uniform_size.max(48)),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let result = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: &[0; 4],
|
||||
usage: storage,
|
||||
});
|
||||
let probe: Vec<u8> = start.iter().flat_map(|v| v.to_le_bytes()).collect();
|
||||
let probe = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: &probe,
|
||||
usage: storage,
|
||||
});
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 4,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let groups: Vec<wgpu::BindGroup> = (0..5)
|
||||
.map(|set| {
|
||||
let entries: Vec<wgpu::BindGroupEntry> = match set {
|
||||
0 => vec![wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: uniforms.as_entire_binding(),
|
||||
}],
|
||||
4 if source.contains("probe_buffer") => vec![
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: result.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: probe.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
4 => vec![wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: result.as_entire_binding(),
|
||||
}],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(set),
|
||||
entries: &entries,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_compute_pass(&Default::default());
|
||||
pass.set_pipeline(&pipeline);
|
||||
for (set, group) in groups.iter().enumerate() {
|
||||
pass.set_bind_group(set as u32, group, &[]);
|
||||
}
|
||||
pass.dispatch_workgroups(1, 1, 1);
|
||||
}
|
||||
encoder.copy_buffer_to_buffer(&result, 0, &read, 0, 4);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let bytes = read.slice(..).get_mapped_range().unwrap();
|
||||
u32::from_le_bytes(bytes[..4].try_into().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a host GPU; run with --ignored --nocapture"]
|
||||
fn reads_of_atomic_members_see_the_atomic_updates() {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
|
||||
eprintln!("Atomic member GPU: {}", adapter.get_info().name);
|
||||
// Pack buffers sit in bind group 4, as in the game.
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
required_limits: wgpu::Limits {
|
||||
max_bind_groups: 5,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}))
|
||||
.unwrap();
|
||||
let result = "layout(std430, binding = 1) buffer probe_result { uint result; };\n";
|
||||
// Eight invocations each add 1 to a zeroed shared member, then read it: 8.
|
||||
for (host, member) in [
|
||||
("shared struct { uint other; uint count; } sh;", "sh.count"),
|
||||
(
|
||||
"struct Counter { uint count; uint other; };\nshared Counter counters[2];",
|
||||
"counters[1].count",
|
||||
),
|
||||
] {
|
||||
for (read, expected) in [
|
||||
("result = M * 10u + 1u;", 81),
|
||||
("uint r = M; result = r;", 8),
|
||||
("result = uint(float(M) * 0.5);", 4),
|
||||
("result = min(M, 5u);", 5),
|
||||
] {
|
||||
let source = format!(
|
||||
"#version 460 core\nlayout(local_size_x = 8) in;\n{host}\n{result}\
|
||||
void main() {{\n {member} = 0u;\n barrier();\n \
|
||||
atomicAdd({member}, 1u);\n barrier();\n {}\n}}\n",
|
||||
read.replace('M', member)
|
||||
);
|
||||
assert_eq!(
|
||||
run(&device, &queue, &source, [0, 0]),
|
||||
expected,
|
||||
"{member}: {read}"
|
||||
);
|
||||
}
|
||||
}
|
||||
// One invocation adds 2 to a buffer member that starts at 5, then reads it: 7.
|
||||
for (read, expected) in [
|
||||
("result = pc.count * 10u + 1u;", 71),
|
||||
("uint r = pc.count; result = r;", 7),
|
||||
("result = uint(int(pc.count) - 10 + 100);", 97),
|
||||
] {
|
||||
let source = format!(
|
||||
"#version 460 core\nlayout(local_size_x = 1) in;\n\
|
||||
layout(std430, binding = 2) buffer probe_buffer {{ uint count; uint other; }} pc;\n\
|
||||
{result}void main() {{\n atomicAdd(pc.count, 2u);\n {read}\n}}\n"
|
||||
);
|
||||
assert_eq!(run(&device, &queue, &source, [5, 0]), expected, "{read}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
//! Synthetic rendering checks (two output shapes mirror RenderPearl v2.7.2 @86de79c, MIT; see the
|
||||
//! PR): a fragment output's `component` layout qualifier (GLSL 4.60, 4.4.2) picks the channels of
|
||||
//! its location that it writes.
|
||||
use horizon_shaders::{Origin, Preprocessed, Target, translate_composite};
|
||||
|
||||
fn source(code: &str) -> Preprocessed {
|
||||
Preprocessed {
|
||||
code: code.into(),
|
||||
files: vec!["components.glsl".into()],
|
||||
lines: code
|
||||
.lines()
|
||||
.enumerate()
|
||||
.map(|(i, _)| Origin {
|
||||
file: 0,
|
||||
line: i as u32 + 1,
|
||||
})
|
||||
.collect(),
|
||||
version: Some((440, None)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws `fragment` over a 1×1 target of `format` and reads back its 16 bytes.
|
||||
fn draw(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
fragment: &str,
|
||||
format: wgpu::TextureFormat,
|
||||
) -> [u8; 16] {
|
||||
let program = translate_composite(
|
||||
&source("void main() { gl_Position = ftransform(); }"),
|
||||
&source(fragment),
|
||||
Target::Buffers,
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("{fragment}: {e:?}"));
|
||||
let module = |module| {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
|
||||
})
|
||||
};
|
||||
let (vertex, fragment_module) = (module(program.vertex), module(program.fragment));
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &vertex,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleStrip,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &fragment_module,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let extent = wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
};
|
||||
let target = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: extent,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let readback = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 256,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let view = target.create_view(&Default::default());
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&pipeline);
|
||||
// The composite program's uniform block, if it kept one, reads zero.
|
||||
if program.interface.uniform_size > 0 {
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(program.interface.uniform_size).max(16),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: buffer.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
}
|
||||
pass.draw(0..4, 0..1);
|
||||
}
|
||||
encoder.copy_texture_to_buffer(
|
||||
target.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &readback,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: Some(1),
|
||||
},
|
||||
},
|
||||
extent,
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
readback
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let bytes = readback.slice(..).get_mapped_range().unwrap()[..16]
|
||||
.try_into()
|
||||
.unwrap();
|
||||
readback.unmap();
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a host GPU; run with --ignored --nocapture"]
|
||||
fn component_qualified_outputs_write_their_own_channels() {
|
||||
let instance =
|
||||
wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle().with_env());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default()))
|
||||
.expect("GPU adapter required; missing adapter is not a pass");
|
||||
eprintln!("Output components GPU: {:?}", adapter.get_info());
|
||||
assert_ne!(adapter.get_info().device_type, wgpu::DeviceType::Cpu);
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
// The channels no output writes keep the fill of a narrow output: 0, and 1 in alpha.
|
||||
for (name, fragment, expected) in [
|
||||
(
|
||||
"uvec3 at component 1",
|
||||
"layout(location = 0, component = 1) out uvec3 words;\n\
|
||||
void main() { words = uvec3(7u, 8u, 9u); }",
|
||||
[0u32, 7, 8, 9],
|
||||
),
|
||||
(
|
||||
"uint at component 2",
|
||||
"layout(location = 0, component = 2) out uint word;\n\
|
||||
void main() { word = 5u; }",
|
||||
[0, 0, 5, 1],
|
||||
),
|
||||
(
|
||||
"two outputs share location 0",
|
||||
"layout(location = 0, component = 0) out uint low;\n\
|
||||
layout(location = 0, component = 1) out uvec3 high;\n\
|
||||
void main() { low = 3u; high = uvec3(4u, 5u, 6u); }",
|
||||
[3, 4, 5, 6],
|
||||
),
|
||||
(
|
||||
"the component 1 output declared first",
|
||||
"layout(location = 0, component = 1) out uvec2 middle;\n\
|
||||
layout(location = 0) out uint low;\n\
|
||||
void main() { low = 11u; middle = uvec2(12u, 13u); }",
|
||||
[11, 12, 13, 1],
|
||||
),
|
||||
] {
|
||||
let bytes = draw(&device, &queue, fragment, wgpu::TextureFormat::Rgba32Uint);
|
||||
let actual: [u32; 4] = bytemuck::cast(bytes);
|
||||
eprintln!("{name}: {actual:?}");
|
||||
assert_eq!(actual, expected, "{name}");
|
||||
}
|
||||
let bytes = draw(
|
||||
&device,
|
||||
&queue,
|
||||
"layout(location = 0, component = 0) out float red;\n\
|
||||
layout(location = 0, component = 1) out vec3 rest;\n\
|
||||
void main() { red = 0.0; rest = vec3(1.0, 0.0, 1.0); }",
|
||||
wgpu::TextureFormat::Rgba32Float,
|
||||
);
|
||||
let actual: [f32; 4] = bytemuck::cast(bytes);
|
||||
eprintln!("float outputs share location 0: {actual:?}");
|
||||
assert_eq!(actual, [0.0, 1.0, 0.0, 1.0]);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
//! `textureMatrix` in a gbuffers program (#9080), through the translated program on a host GPU.
|
||||
//! Expected values: our probe packs K-texmat3 and K-texmat2 in Iris 1.11.7 read the identity in
|
||||
//! the terrain vertex stage and zero in the terrain fragment stage.
|
||||
|
||||
use horizon_shaders::{GbuffersInputs, Macros, Pack, Target, preprocess, translate_gbuffers};
|
||||
|
||||
/// Draws one pixel with `vertex` and `fragment` as `gbuffers_terrain`; returns its RGBA8.
|
||||
fn pixel(device: &wgpu::Device, queue: &wgpu::Queue, vertex: &str, fragment: &str) -> [u8; 4] {
|
||||
use std::io::Write;
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (path, source) in [
|
||||
("shaders/gbuffers_terrain.vsh", vertex),
|
||||
("shaders/gbuffers_terrain.fsh", fragment),
|
||||
] {
|
||||
zip.start_file(path, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
}
|
||||
let pack =
|
||||
Pack::from_zip_bytes("texture-matrix.zip", zip.finish().unwrap().into_inner()).unwrap();
|
||||
let macros = Macros::standard();
|
||||
let program = translate_gbuffers(
|
||||
&preprocess(&pack, "gbuffers_terrain.vsh", ¯os),
|
||||
&preprocess(&pack, "gbuffers_terrain.fsh", ¯os),
|
||||
Target::Buffers,
|
||||
&GbuffersInputs::default(),
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("{e:?}"));
|
||||
assert!(!program.interface.draw, "no per-draw block in this probe");
|
||||
use wgpu::util::DeviceExt;
|
||||
// The frame's uniforms are all zero: `textureMatrix` must not come from them.
|
||||
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: &vec![0; program.interface.uniform_size as usize],
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let module = |module| {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
|
||||
})
|
||||
};
|
||||
let (vs, fs) = (module(program.vertex), module(program.fragment));
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &vs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &fs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: uniform.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = texture.create_view(&Default::default());
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 256,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
encoder.copy_texture_to_buffer(
|
||||
texture.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &read,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
texture.size(),
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |result| result.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let mapped = read.slice(..).get_mapped_range().unwrap();
|
||||
[mapped[0], mapped[1], mapped[2], mapped[3]]
|
||||
}
|
||||
|
||||
/// A full-screen triangle; `extra` declares and sets more.
|
||||
fn vertex(extra: &str, set: &str) -> String {
|
||||
format!(
|
||||
"#version 460 core\n{extra}\nvoid main() {{\n\
|
||||
vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);\n\
|
||||
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);\n{set}\n}}\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// Four values `v` as v / 4 + 0.5 (K-texmat3's encoding): 0 is 128, 1 is 191.
|
||||
const ENCODE: &str = "color = vec4(e) / 4.0 + 0.5;";
|
||||
|
||||
fn assert_close(actual: [u8; 4], expected: [u8; 4], what: &str) {
|
||||
assert!(
|
||||
actual.iter().zip(expected).all(|(a, e)| a.abs_diff(e) <= 1),
|
||||
"{what}: {actual:?}, expected {expected:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a host GPU; run with --ignored --nocapture"]
|
||||
fn texture_matrix_is_the_identity_in_the_vertex_stage_and_zero_in_the_fragment_stage() {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
|
||||
eprintln!("Texture matrix GPU: {:?}", adapter.get_info().name);
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let fragment_in = "#version 460 core\nflat in vec4 e;\nlayout(location = 0) out vec4 color;\n\
|
||||
void main() { ";
|
||||
// The vertex stage hands elements [0][0], [1][1], [3][0], [3][3] on (K-texmat3).
|
||||
let elements = pixel(
|
||||
&device,
|
||||
&queue,
|
||||
&vertex(
|
||||
"uniform mat4 textureMatrix;\nflat out vec4 e;",
|
||||
"e = vec4(textureMatrix[0][0], textureMatrix[1][1], textureMatrix[3][0], textureMatrix[3][3]);",
|
||||
),
|
||||
&format!("{fragment_in}{ENCODE} }}\n"),
|
||||
);
|
||||
assert_close(elements, [191, 191, 128, 191], "vertex-stage elements");
|
||||
// A UV through it is the UV (K-texmat's `mat2(m) * uv + m[3].xy`).
|
||||
let uv = pixel(
|
||||
&device,
|
||||
&queue,
|
||||
&vertex(
|
||||
"uniform mat4 textureMatrix;\nflat out vec4 e;",
|
||||
"vec2 uv = vec2(0.25, 0.75);\n\
|
||||
e = vec4(mat2(textureMatrix) * uv + textureMatrix[3].xy, (textureMatrix * vec4(uv, 0.0, 1.0)).xy);",
|
||||
),
|
||||
"#version 460 core\nflat in vec4 e;\nlayout(location = 0) out vec4 color;\n\
|
||||
void main() { color = e; }\n",
|
||||
);
|
||||
assert_close(uv, [64, 191, 64, 191], "transformed UV");
|
||||
// The fragment stage's own declaration reads zero, with or without the vertex stage's (K-texmat2).
|
||||
for vertex_declares in [false, true] {
|
||||
let v = if vertex_declares {
|
||||
vertex(
|
||||
"uniform mat4 textureMatrix;\nflat out vec4 e;",
|
||||
"e = textureMatrix[0];",
|
||||
)
|
||||
} else {
|
||||
vertex("flat out vec4 e;", "e = vec4(0.0);")
|
||||
};
|
||||
let f = "#version 460 core\nuniform mat4 textureMatrix;\nflat in vec4 e;\n\
|
||||
layout(location = 0) out vec4 color;\nvoid main() {\n\
|
||||
vec4 m = vec4(textureMatrix[0][0], textureMatrix[1][1], textureMatrix[3][0], textureMatrix[3][3]);\n\
|
||||
color = m / 4.0 + 0.5 + e * 0.0;\n}\n";
|
||||
assert_close(
|
||||
pixel(&device, &queue, &v, f),
|
||||
[128, 128, 128, 128],
|
||||
&format!("fragment stage (vertex declares: {vertex_declares})"),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,14 +10,11 @@
|
||||
"horizon.options.move_deadzone": "Bewegingsdooiesone",
|
||||
"horizon.options.look_deadzone": "Kykdooiesone",
|
||||
"horizon.options.button_glyphs": "Knoppie Glyphs",
|
||||
"horizon.options.menu_navigation": "Kieslysnavigasie",
|
||||
"horizon.options.on_screen_keyboard": "Sleutelbord op die skerm",
|
||||
"horizon.options.chat_delay": "Chat Vertraging",
|
||||
"horizon.options.lightbar.health": "Gesondheid",
|
||||
"horizon.options.lightbar.dimension": "Dimensie",
|
||||
"horizon.options.glyphs.auto": "Outo",
|
||||
"horizon.options.menu_focus": "Fokus",
|
||||
"horizon.options.menu_pointer": "Wyser",
|
||||
"horizon.options.keyboard.auto": "Outo",
|
||||
"horizon.options.unavailable": "Hierdie kenmerk is nog nie in Horizon beskikbaar nie.",
|
||||
"horizon.options.search": "Soek instellings...",
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
"horizon.options.move_deadzone": "المنطقة الميتة للحركة",
|
||||
"horizon.options.look_deadzone": "المنطقة الميتة للنظر",
|
||||
"horizon.options.button_glyphs": "الحروف الرسومية للأزرار",
|
||||
"horizon.options.menu_navigation": "التنقل في القائمة",
|
||||
"horizon.options.on_screen_keyboard": "لوحة المفاتيح على الشاشة",
|
||||
"horizon.options.chat_delay": "تأخير الدردشة",
|
||||
"horizon.options.lightbar.health": "الصحة",
|
||||
@@ -19,8 +18,6 @@
|
||||
"horizon.options.glyphs.xbox": "اكس بوكس",
|
||||
"horizon.options.glyphs.playstation": "بلاي ستيشن",
|
||||
"horizon.options.glyphs.nintendo": "نينتندو",
|
||||
"horizon.options.menu_focus": "التركيز",
|
||||
"horizon.options.menu_pointer": "المؤشر",
|
||||
"horizon.options.keyboard.auto": "تلقائي",
|
||||
"horizon.options.keyboard.steam": "البخار",
|
||||
"horizon.options.unavailable": "هذه الميزة غير متوفرة بعد في Horizon.",
|
||||
|
||||
@@ -10,14 +10,11 @@
|
||||
"horizon.options.move_deadzone": "Hərəkət ölü zonası",
|
||||
"horizon.options.look_deadzone": "Baxış ölü zonası",
|
||||
"horizon.options.button_glyphs": "Düymə Qlifləri",
|
||||
"horizon.options.menu_navigation": "Menyu Naviqasiyası",
|
||||
"horizon.options.on_screen_keyboard": "Ekran Klaviaturası",
|
||||
"horizon.options.chat_delay": "Söhbət Gecikməsi",
|
||||
"horizon.options.lightbar.health": "Sağlamlıq",
|
||||
"horizon.options.lightbar.dimension": "Ölçü",
|
||||
"horizon.options.glyphs.auto": "Avtomatik",
|
||||
"horizon.options.menu_focus": "Fokus",
|
||||
"horizon.options.menu_pointer": "Göstərici",
|
||||
"horizon.options.keyboard.auto": "Avtomatik",
|
||||
"horizon.options.unavailable": "Bu funksiya hələ Horizon ərazisində mövcud deyil.",
|
||||
"horizon.options.search": "Axtarış parametrləri...",
|
||||
|
||||
@@ -11,14 +11,11 @@
|
||||
"horizon.options.move_deadzone": "Хәрәкәт үле зонаһы",
|
||||
"horizon.options.look_deadzone": "Ҡараш үле зонаһы",
|
||||
"horizon.options.button_glyphs": "Төймә глифтары",
|
||||
"horizon.options.menu_navigation": "Навигация меню",
|
||||
"horizon.options.on_screen_keyboard": "Экрандағы клавиатура",
|
||||
"horizon.options.chat_delay": "Чат тотҡарланыуы",
|
||||
"horizon.options.lightbar.health": "Һаулыҡ",
|
||||
"horizon.options.lightbar.dimension": "Үлсәм",
|
||||
"horizon.options.glyphs.auto": "Авто",
|
||||
"horizon.options.menu_focus": "Фокус",
|
||||
"horizon.options.menu_pointer": "Күрһәткес",
|
||||
"horizon.options.keyboard.auto": "Авто",
|
||||
"horizon.options.unavailable": "Был функция Horizon-ла әлегә юҡ.",
|
||||
"horizon.options.search": "Эҙләү параметрҙары...",
|
||||
|
||||
@@ -10,14 +10,11 @@
|
||||
"horizon.options.move_deadzone": "Мёртвая зона руху",
|
||||
"horizon.options.look_deadzone": "Мёртвая зона агляду",
|
||||
"horizon.options.button_glyphs": "Гліфы кнопак",
|
||||
"horizon.options.menu_navigation": "Навігацыя па меню",
|
||||
"horizon.options.on_screen_keyboard": "Экранная клавіятура",
|
||||
"horizon.options.chat_delay": "Затрымка чата",
|
||||
"horizon.options.lightbar.health": "здароўе",
|
||||
"horizon.options.lightbar.dimension": "Вымярэнне",
|
||||
"horizon.options.glyphs.auto": "Аўто",
|
||||
"horizon.options.menu_focus": "Фокус",
|
||||
"horizon.options.menu_pointer": "Паказальнік",
|
||||
"horizon.options.keyboard.auto": "Аўто",
|
||||
"horizon.options.unavailable": "Гэтая функцыя яшчэ недаступная ў Horizon.",
|
||||
"horizon.options.search": "Налады пошуку...",
|
||||
|
||||
@@ -10,14 +10,11 @@
|
||||
"horizon.options.move_deadzone": "Мъртва зона на движението",
|
||||
"horizon.options.look_deadzone": "Мъртва зона на погледа",
|
||||
"horizon.options.button_glyphs": "Глифове на бутони",
|
||||
"horizon.options.menu_navigation": "Навигация в менюто",
|
||||
"horizon.options.on_screen_keyboard": "Екранна клавиатура",
|
||||
"horizon.options.chat_delay": "Забавяне на чата",
|
||||
"horizon.options.lightbar.health": "здраве",
|
||||
"horizon.options.lightbar.dimension": "Измерение",
|
||||
"horizon.options.glyphs.auto": "Авто",
|
||||
"horizon.options.menu_focus": "Фокус",
|
||||
"horizon.options.menu_pointer": "показалец",
|
||||
"horizon.options.keyboard.auto": "Авто",
|
||||
"horizon.options.keyboard.steam": "Пара",
|
||||
"horizon.options.unavailable": "Тази функция все още не е налична в Horizon.",
|
||||
|
||||
@@ -11,14 +11,11 @@
|
||||
"horizon.options.move_deadzone": "Takad marv ar fiñval",
|
||||
"horizon.options.look_deadzone": "Takad marv ar sell",
|
||||
"horizon.options.button_glyphs": "Glifoù bouton",
|
||||
"horizon.options.menu_navigation": "Merdeiñ dre ar meni",
|
||||
"horizon.options.on_screen_keyboard": "Klavier war ar skramm",
|
||||
"horizon.options.chat_delay": "Dale flapañ",
|
||||
"horizon.options.lightbar.health": "Yec'hed",
|
||||
"horizon.options.lightbar.dimension": "Ment",
|
||||
"horizon.options.glyphs.auto": "Emgefreek",
|
||||
"horizon.options.menu_focus": "Lakaat ar gaoz",
|
||||
"horizon.options.menu_pointer": "Poent",
|
||||
"horizon.options.keyboard.auto": "Emgefreek",
|
||||
"horizon.options.unavailable": "N'eo ket hegerz an elfenn-mañ c'hoazh e Horizon.",
|
||||
"horizon.options.search": "Arventennoù klask...",
|
||||
|
||||
@@ -10,13 +10,11 @@
|
||||
"horizon.options.move_deadzone": "Zona morta del moviment",
|
||||
"horizon.options.look_deadzone": "Zona morta de la vista",
|
||||
"horizon.options.button_glyphs": "Glifos de botons",
|
||||
"horizon.options.menu_navigation": "Navegació del menú",
|
||||
"horizon.options.on_screen_keyboard": "Teclat en pantalla",
|
||||
"horizon.options.chat_delay": "Retard de xat",
|
||||
"horizon.options.lightbar.health": "Salut",
|
||||
"horizon.options.lightbar.dimension": "Dimensió",
|
||||
"horizon.options.glyphs.auto": "Automàtic",
|
||||
"horizon.options.menu_pointer": "Apuntador",
|
||||
"horizon.options.keyboard.auto": "Automàtic",
|
||||
"horizon.options.unavailable": "Aquesta funció encara no està disponible a Horizon.",
|
||||
"horizon.options.search": "Configuració de cerca...",
|
||||
|
||||
@@ -10,13 +10,10 @@
|
||||
"horizon.options.move_deadzone": "Mrtvá zóna pohybu",
|
||||
"horizon.options.look_deadzone": "Mrtvá zóna pohledu",
|
||||
"horizon.options.button_glyphs": "Glyfy tlačítek",
|
||||
"horizon.options.menu_navigation": "Navigace v menu",
|
||||
"horizon.options.on_screen_keyboard": "Klávesnice na obrazovce",
|
||||
"horizon.options.chat_delay": "Zpoždění chatu",
|
||||
"horizon.options.lightbar.health": "Zdraví",
|
||||
"horizon.options.lightbar.dimension": "Dimenze",
|
||||
"horizon.options.menu_focus": "Zaměřte se",
|
||||
"horizon.options.menu_pointer": "Ukazatel",
|
||||
"horizon.options.keyboard.horizon": "Horizont",
|
||||
"horizon.options.keyboard.steam": "Pára",
|
||||
"horizon.options.unavailable": "Tato funkce zatím není v Horizon k dispozici.",
|
||||
|
||||
@@ -11,14 +11,11 @@
|
||||
"horizon.options.move_deadzone": "Куҫӑмӑн вилӗ зони",
|
||||
"horizon.options.look_deadzone": "Пӑхӑвӑн вилӗ зони",
|
||||
"horizon.options.button_glyphs": "Кнопка глифӗсем",
|
||||
"horizon.options.menu_navigation": "Меню навигацийӗ",
|
||||
"horizon.options.on_screen_keyboard": "Экран ҫинчи клавиатура",
|
||||
"horizon.options.chat_delay": "Чат кая юлни",
|
||||
"horizon.options.lightbar.health": "Сывлӑх",
|
||||
"horizon.options.lightbar.dimension": "Виҫе",
|
||||
"horizon.options.glyphs.auto": "Авто",
|
||||
"horizon.options.menu_focus": "Фокус",
|
||||
"horizon.options.menu_pointer": "Указатель",
|
||||
"horizon.options.keyboard.auto": "Авто",
|
||||
"horizon.options.unavailable": "Ку функци Horizon-ра хальлӗхе ҫук-ха.",
|
||||
"horizon.options.search": "Шырав настройкисем...",
|
||||
|
||||
@@ -10,13 +10,10 @@
|
||||
"horizon.options.move_deadzone": "Parth marw symud",
|
||||
"horizon.options.look_deadzone": "Parth marw edrych",
|
||||
"horizon.options.button_glyphs": "Glyffau Botwm",
|
||||
"horizon.options.menu_navigation": "Llywio Dewislen",
|
||||
"horizon.options.on_screen_keyboard": "Bysellfwrdd Ar-Sgrin",
|
||||
"horizon.options.chat_delay": "Oedi Sgwrsio",
|
||||
"horizon.options.lightbar.health": "Iechyd",
|
||||
"horizon.options.lightbar.dimension": "Dimensiwn",
|
||||
"horizon.options.menu_focus": "Ffocws",
|
||||
"horizon.options.menu_pointer": "Pwyntiwr",
|
||||
"horizon.options.unavailable": "Nid yw'r nodwedd hon ar gael eto yn Horizon.",
|
||||
"horizon.options.search": "Gosodiadau chwilio...",
|
||||
"horizon.pages.shader_packs": "Pecynnau Shader",
|
||||
|
||||
@@ -8,11 +8,9 @@
|
||||
"horizon.options.move_deadzone": "Dødzone for bevægelse",
|
||||
"horizon.options.look_deadzone": "Dødzone for kig",
|
||||
"horizon.options.button_glyphs": "Knap Glyfer",
|
||||
"horizon.options.menu_navigation": "Menu navigation",
|
||||
"horizon.options.on_screen_keyboard": "Skærmtastatur",
|
||||
"horizon.options.chat_delay": "Chatforsinkelse",
|
||||
"horizon.options.lightbar.health": "Sundhed",
|
||||
"horizon.options.menu_focus": "Fokus",
|
||||
"horizon.options.keyboard.steam": "Damp",
|
||||
"horizon.options.unavailable": "Denne funktion er endnu ikke tilgængelig i Horizon.",
|
||||
"horizon.options.search": "Søgeindstillinger...",
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"horizon.options.move_deadzone": "Totzone der Bewegung",
|
||||
"horizon.options.look_deadzone": "Totzone des Blicks",
|
||||
"horizon.options.button_glyphs": "Tastensymbole",
|
||||
"horizon.options.menu_navigation": "Menünavigation",
|
||||
"horizon.options.on_screen_keyboard": "Bildschirmtastatur",
|
||||
"horizon.options.chat_delay": "Chatverzögerung",
|
||||
"horizon.options.lightbar.health": "Gesundheit",
|
||||
@@ -20,8 +19,6 @@
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.menu_focus": "Fokus",
|
||||
"horizon.options.menu_pointer": "Zeiger",
|
||||
"horizon.options.keyboard.auto": "Auto",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
@@ -346,5 +343,31 @@
|
||||
"horizon.extras.cosmetics_auras.tooltip": "Auren und Spuren, die andere Spieler tragen. Standardmäßig aus.",
|
||||
"horizon.extras.cosmetics.shown": "Angezeigt",
|
||||
"horizon.extras.cosmetics.hidden": "Ausgeblendet",
|
||||
"horizon.extras.cosmetics.name_tags": "Nur Namensschilder"
|
||||
"horizon.extras.cosmetics.name_tags": "Nur Namensschilder",
|
||||
"horizon.moderation.duration.minute": "%s Minute",
|
||||
"horizon.moderation.duration.minutes": "%s Minuten",
|
||||
"horizon.moderation.duration.hour": "%s Stunde",
|
||||
"horizon.moderation.duration.hours": "%s Stunden",
|
||||
"horizon.moderation.duration.day": "%s Tag",
|
||||
"horizon.moderation.duration.days": "%s Tage",
|
||||
"horizon.moderation.reason": "%1$s: %2$s",
|
||||
"horizon.moderation.mutelist.empty": "Keine Spieler stummgeschaltet",
|
||||
"horizon.moderation.mutelist.count": "Stummgeschaltete Spieler: %s",
|
||||
"horizon.moderation.chat_filter": "dem Chatfilter",
|
||||
"horizon.moderation.until_unmuted": "bis zur Aufhebung der Stummschaltung",
|
||||
"horizon.moderation.remaining": "für weitere %s",
|
||||
"horizon.moderation.mutelist.entry": "%1$s wurde von %2$s %3$s stummgeschaltet",
|
||||
"horizon.moderation.invalid_duration": "%s ist keine Zeitdauer: 10m, 2h, 1d",
|
||||
"horizon.moderation.usage.mute": "/mute <Spieler> [Grund]",
|
||||
"horizon.moderation.usage.tempmute": "/tempmute <Spieler> <Dauer> [Grund]",
|
||||
"horizon.moderation.usage.unmute": "/unmute <Spieler>",
|
||||
"horizon.moderation.usage.mutelist": "/mutelist",
|
||||
"horizon.moderation.for_duration": "für %s",
|
||||
"horizon.moderation.muted": "%1$s wurde %2$s stummgeschaltet",
|
||||
"horizon.moderation.unmuted": "%s ist nicht mehr stummgeschaltet",
|
||||
"horizon.moderation.not_muted": "%s ist nicht stummgeschaltet",
|
||||
"horizon.tempban.usage": "/tempban <Spieler> <Dauer> [Grund]",
|
||||
"horizon.tempban.invalid_duration": "%s ist keine Zeitdauer: 30m, 12h, 7d",
|
||||
"horizon.tempban.dedicated_required": "Temporäre Sperren benötigen einen dedizierten Server",
|
||||
"horizon.tempban.success": "%1$s für %2$s, bis %3$s"
|
||||
}
|
||||
Loaded 100 of 611 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user