mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
824
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e5a2c74d2 | ||
|
|
2556a20c74 | ||
|
|
cc2bc8fb84 | ||
|
|
e9eb640ac0 | ||
|
|
0bb31c7d42 | ||
|
|
bf1fe4344d | ||
|
|
57cc476ac8 | ||
|
|
e058605ee6 | ||
|
|
bc807a6da1 | ||
|
|
b8f73142f9 | ||
|
|
00c25b5398 | ||
|
|
3e382789c5 | ||
|
|
68a348299e | ||
|
|
3e20d5c7e6 | ||
|
|
86fb2c8f82 | ||
|
|
f970ed281d | ||
|
|
ebc5806996 | ||
|
|
f6a8608beb | ||
|
|
6cafca9435 | ||
|
|
0caab26bc4 | ||
|
|
31d5f62db1 | ||
|
|
35a64d25c2 | ||
|
|
d8db492dca | ||
|
|
b2bda56ed3 | ||
|
|
e6f5a1ae7c | ||
|
|
cb14a7a1fb | ||
|
|
dd74d6b563 | ||
|
|
e3c831ab0d | ||
|
|
47cbc4dcb5 | ||
|
|
49f6ceb448 | ||
|
|
c08375d5be | ||
|
|
1b2812427b | ||
|
|
0b24d6d57b | ||
|
|
d029fb659e | ||
|
|
b3f74a5d42 | ||
|
|
dbe52ccb4c | ||
|
|
dc5a7553eb | ||
|
|
c43f3d26f4 | ||
|
|
c4f1b948db | ||
|
|
4a7e5d8f6f | ||
|
|
652c11e914 | ||
|
|
5c3a450532 | ||
|
|
be5494966e | ||
|
|
09cf77babd | ||
|
|
219c3dce3c | ||
|
|
c59a3d651d | ||
|
|
ccd8c0a3e4 | ||
|
|
c11e7ca6a3 | ||
|
|
02b0cb44fe | ||
|
|
b4aa021fc7 | ||
|
|
71f059c84b | ||
|
|
fc965ed486 | ||
|
|
f885164054 | ||
|
|
527b4f1b99 | ||
|
|
e58c8ff2f7 | ||
|
|
3076bade1e | ||
|
|
9fb793a432 | ||
|
|
bf4bfbd6fb | ||
|
|
00fb064655 | ||
|
|
10d2551439 | ||
|
|
435730a405 | ||
|
|
3d80f8991c | ||
|
|
fda7e48b33 | ||
|
|
de8dc9e86c | ||
|
|
cbbd4151c8 | ||
|
|
9891e04d64 | ||
|
|
ae3f35dc32 | ||
|
|
94d4b3a866 | ||
|
|
2b9fb6ac08 | ||
|
|
df0a98e064 | ||
|
|
987b517835 | ||
|
|
7ee99da411 | ||
|
|
f4f62f9224 | ||
|
|
0a63fbf6bd | ||
|
|
5cb5d3b41c | ||
|
|
1c6daa9085 | ||
|
|
3fdddfe733 | ||
|
|
ab59debc6c | ||
|
|
4ede938844 | ||
|
|
bbe3a66af7 | ||
|
|
2df2560f08 | ||
|
|
a2b65c9e4b | ||
|
|
cf8e9608f8 | ||
|
|
2e3a6576d6 | ||
|
|
20e28142a7 | ||
|
|
d56aa0216e | ||
|
|
e18d973c7d | ||
|
|
d3ae610d55 | ||
|
|
f58ef7dfae | ||
|
|
c594c860cf | ||
|
|
1929509ddd | ||
|
|
43062d7bbd | ||
|
|
f9fb435f94 | ||
|
|
cb77e8e745 | ||
|
|
d454f23ff6 | ||
|
|
07ad7d2abc | ||
|
|
d1ee2d879c | ||
|
|
a3d814ec3f | ||
|
|
d5273ce67f | ||
|
|
09a669a6c3 | ||
|
|
6470651ffe | ||
|
|
3370efe35e | ||
|
|
29ba9fbb09 | ||
|
|
155aebb56b | ||
|
|
bdc05a3853 | ||
|
|
4aca4d1d38 | ||
|
|
f837e61c67 | ||
|
|
7f0ea1224d | ||
|
|
7366f3f7aa | ||
|
|
10e4e4de5a | ||
|
|
01fa1b3e02 | ||
|
|
602923d8a7 | ||
|
|
e99e1018a3 | ||
|
|
667dd7902a | ||
|
|
d4b773dadf | ||
|
|
bd02c92df2 | ||
|
|
e8cbb4c13c | ||
|
|
2bce6d5b7d | ||
|
|
0d75e2e519 | ||
|
|
dfaa2864d5 | ||
|
|
d4f736d2bf | ||
|
|
337bb3ad1e | ||
|
|
0d8198d62d | ||
|
|
0454e97e32 | ||
|
|
ce465c5708 | ||
|
|
2de5ff4ef1 | ||
|
|
57b8346713 | ||
|
|
cbed0c07bd | ||
|
|
20c6194da6 | ||
|
|
8a3f5f6939 | ||
|
|
b3a291b0e2 | ||
|
|
f5be69df70 | ||
|
|
fc111a8fc0 | ||
|
|
5651a6af8d | ||
|
|
d073e1dc7e | ||
|
|
df3a7ffd73 | ||
|
|
fd88a545d9 | ||
|
|
351c82aded | ||
|
|
6d3917100d | ||
|
|
08508f5d86 | ||
|
|
873ff80cd9 | ||
|
|
1fad808164 | ||
|
|
33e2c32d35 | ||
|
|
491d9f5e25 | ||
|
|
78792c54ea | ||
|
|
dea101aafb | ||
|
|
0929294702 | ||
|
|
4aa12eb27f | ||
|
|
d3c7a961e8 | ||
|
|
d9a30e08b3 | ||
|
|
33b857768d | ||
|
|
6c5ee2d427 | ||
|
|
64688576ee | ||
|
|
c4c932d08a | ||
|
|
778bb6d932 | ||
|
|
553e69c479 | ||
|
|
8b1ab91f9f | ||
|
|
971b2d6dd5 | ||
|
|
3f4e6f0812 | ||
|
|
67788b979f | ||
|
|
383eea634d | ||
|
|
442ac7ab41 | ||
|
|
574d9b148e | ||
|
|
e440012293 | ||
|
|
d7896df7d8 | ||
|
|
194163c333 | ||
|
|
4bc8e359c2 | ||
|
|
f579f440f4 | ||
|
|
779ab7dd4c | ||
|
|
e4b2778edb | ||
|
|
10fcd81920 | ||
|
|
0d95220d49 | ||
|
|
b440c53cee | ||
|
|
23d6bc0766 | ||
|
|
610db0d288 | ||
|
|
2fb130b16c | ||
|
|
81e528f925 | ||
|
|
c33c3acf9b | ||
|
|
44b03616c1 | ||
|
|
5334979f60 | ||
|
|
adef15790d | ||
|
|
42f7a5af94 | ||
|
|
ada1b8b1e4 | ||
|
|
1a54e1cc56 | ||
|
|
133408899f | ||
|
|
1d20a9db1d | ||
|
|
752049907d | ||
|
|
6219424298 | ||
|
|
3a9559fe3c | ||
|
|
55487cb931 | ||
|
|
6d89cb0195 | ||
|
|
3740468b53 | ||
|
|
b12410ff52 | ||
|
|
ab3ed2d7e8 | ||
|
|
db34ce484d | ||
|
|
9cc9df16b4 | ||
|
|
f33fe927a9 | ||
|
|
f693be2363 | ||
|
|
3237499eab | ||
|
|
f10487540b | ||
|
|
d5e348e8e8 | ||
|
|
182fbc30e5 | ||
|
|
f57550ec8c | ||
|
|
ef56d07869 | ||
|
|
54afdf6735 | ||
|
|
cd593e15b9 | ||
|
|
0bf1609f2e | ||
|
|
9563b41599 | ||
|
|
ffba053a6a | ||
|
|
b0b5c25998 | ||
|
|
9de27c4e08 | ||
|
|
d7469b82c6 | ||
|
|
dd84588b53 | ||
|
|
b506983946 | ||
|
|
daa63d0fa7 | ||
|
|
a2f0a5baa8 | ||
|
|
8057c41c06 | ||
|
|
dd428288d1 | ||
|
|
78a634a191 | ||
|
|
3c0a4db876 | ||
|
|
8d93d68f1d | ||
|
|
cf2db82999 | ||
|
|
5c2a3cc945 | ||
|
|
b5d9d1aeb4 | ||
|
|
5dfae6950b | ||
|
|
8086d137e5 | ||
|
|
c3911b7608 | ||
|
|
e3e8af585e | ||
|
|
00ee928306 | ||
|
|
a755b863e0 | ||
|
|
026f3125f4 | ||
|
|
6ed2d97cda | ||
|
|
a651e476a3 | ||
|
|
b418ea15f0 | ||
|
|
69c11117a6 | ||
|
|
0cfd1d821c | ||
|
|
5fe8c6defa | ||
|
|
5b47e1f45b | ||
|
|
51946c9e45 | ||
|
|
66397a963e | ||
|
|
340dfd1d3e | ||
|
|
246f47f20d | ||
|
|
e431c279f4 | ||
|
|
06f842dc3b | ||
|
|
d7b307aed5 | ||
|
|
9feaec5cda | ||
|
|
fc3456815f | ||
|
|
3496fe24f7 | ||
|
|
0a0c2fd5f1 | ||
|
|
0187d1e47e | ||
|
|
cd5d7c5c15 | ||
|
|
f9cc7072ae | ||
|
|
4fbeab00ae | ||
|
|
88f4b82bfe | ||
|
|
3310fcafb9 | ||
|
|
64d470d9f7 | ||
|
|
12af237df8 | ||
|
|
20ba9cbff2 | ||
|
|
9d4d1596e3 | ||
|
|
9e80c04264 | ||
|
|
3ff9ae500b | ||
|
|
23c97e3d5a | ||
|
|
901a47524c | ||
|
|
6a7eebd2b8 | ||
|
|
4067d11777 | ||
|
|
336b0cd1a1 | ||
|
|
60d5ec63aa | ||
|
|
50f98ecb33 | ||
|
|
7a39319074 | ||
|
|
82309edd06 | ||
|
|
873ccc1ecf | ||
|
|
0bb6a370c3 | ||
|
|
989eb8a0be | ||
|
|
048f3b6e87 | ||
|
|
82f158ae47 | ||
|
|
2704394355 | ||
|
|
6e55b944e3 | ||
|
|
6fdeef19a7 | ||
|
|
65cfa74249 | ||
|
|
b4b5281ff8 | ||
|
|
205f2560b5 | ||
|
|
4a6267b9c2 | ||
|
|
56e5ef6f01 | ||
|
|
c9d49d4328 | ||
|
|
968a1b1475 | ||
|
|
c6dcaa6b46 | ||
|
|
7a319ed7bc | ||
|
|
5f75afdbd0 | ||
|
|
988167a17a | ||
|
|
eb94a3af37 | ||
|
|
5a28700eb4 | ||
|
|
e30335f719 | ||
|
|
3dc01cdcb4 | ||
|
|
53768a814b | ||
|
|
fe12948459 | ||
|
|
d02c07cde6 | ||
|
|
1446ced385 | ||
|
|
f30f3d8402 | ||
|
|
92967740a6 | ||
|
|
755d2215c5 | ||
|
|
c53bea9730 | ||
|
|
4e19829015 | ||
|
|
234a17bd63 | ||
|
|
f632e8c4af | ||
|
|
5cd3a00276 | ||
|
|
918e2a372d | ||
|
|
4673402d9b | ||
|
|
99fcc59bf6 | ||
|
|
f8bfa06b90 | ||
|
|
05a6f766e5 | ||
|
|
cccf1363dd | ||
|
|
d63d23327f | ||
|
|
c50a0813b3 | ||
|
|
e8b62addbe | ||
|
|
7482479b19 | ||
|
|
198c640a26 | ||
|
|
23edb9108b | ||
|
|
2ca605090c | ||
|
|
67eeb06049 | ||
|
|
b86f3f517f | ||
|
|
99b02b99c8 | ||
|
|
2e6f8a98fb | ||
|
|
f66168aa25 | ||
|
|
14d9cb5d8d | ||
|
|
4dc0a456dc | ||
|
|
185bc0b3e3 | ||
|
|
0bf9d01153 | ||
|
|
f81b809ae9 | ||
|
|
b3befb195b | ||
|
|
0df0b4d191 | ||
|
|
ff1dd85a1c | ||
|
|
cfc5c627bb | ||
|
|
bc22eb04e7 | ||
|
|
bcf163d11d | ||
|
|
b7e01926a2 | ||
|
|
6edff51558 | ||
|
|
3f2b714ed6 | ||
|
|
25f3bbd2ce | ||
|
|
c7a641587b | ||
|
|
1bd7d0a5c3 | ||
|
|
db483268db | ||
|
|
2d5237b3be | ||
|
|
01e150e30d | ||
|
|
fb37d6424f | ||
|
|
011bccf726 | ||
|
|
7aeddbcf44 | ||
|
|
adee3fcc26 | ||
|
|
1e03f61c98 | ||
|
|
3c6de1ba34 | ||
|
|
c305aac57a | ||
|
|
c9d06599c7 | ||
|
|
686537c322 | ||
|
|
4fb8955bf4 | ||
|
|
f681e4bbdc | ||
|
|
eb0d9e5805 | ||
|
|
6993333eaf | ||
|
|
1e74fa4647 | ||
|
|
d58f5ddcd1 | ||
|
|
a9065e3ee6 | ||
|
|
427ec651f9 | ||
|
|
e1cc1a1bbd | ||
|
|
de59ab97c5 | ||
|
|
e296a7ee38 | ||
|
|
da344a09d3 | ||
|
|
f21e244981 | ||
|
|
3303a42a48 | ||
|
|
60f0a35647 | ||
|
|
37dfdd4a98 | ||
|
|
f69e907e44 | ||
|
|
6b1e609afb | ||
|
|
a1fac8df39 | ||
|
|
a3718e2d8c | ||
|
|
c7b045112b | ||
|
|
d6b55b3c95 | ||
|
|
5937b56a66 | ||
|
|
d4565803b4 | ||
|
|
1e5c904d18 | ||
|
|
ba579191f9 | ||
|
|
50583c8b53 | ||
|
|
52219e4771 | ||
|
|
f252ef542d | ||
|
|
f798193428 | ||
|
|
d513df7b8f | ||
|
|
863a9eab0d | ||
|
|
e4e5f05b40 | ||
|
|
1e1d41e588 | ||
|
|
6e79752f94 | ||
|
|
4f48e48a43 | ||
|
|
df6f6c13bf | ||
|
|
f8cb56f3a2 | ||
|
|
7255d001cc | ||
|
|
4a50aaa08d | ||
|
|
3df30d4d84 | ||
|
|
eabcca7b27 | ||
|
|
f0a5634e53 | ||
|
|
ab908681fe | ||
|
|
ff3b1380f8 | ||
|
|
34e0a1295f | ||
|
|
cba1912a1f | ||
|
|
2341454826 | ||
|
|
ab13381576 | ||
|
|
742df9fb59 | ||
|
|
5b41c18eed | ||
|
|
11bdbd4712 | ||
|
|
1c100d6d77 | ||
|
|
dd289c8904 | ||
|
|
9a28fbe3c7 | ||
|
|
244a2c8155 | ||
|
|
2930023ff1 | ||
|
|
75974b885c | ||
|
|
0f5e1c8773 | ||
|
|
7d2786d072 | ||
|
|
a86ac72d59 | ||
|
|
74664db436 | ||
|
|
854042fa91 | ||
|
|
b72f0b52f8 | ||
|
|
2a396df108 | ||
|
|
415ef54a47 | ||
|
|
79aa093cc0 | ||
|
|
2b08d635d9 | ||
|
|
89ca25c574 | ||
|
|
2c012408c6 | ||
|
|
facacb2138 | ||
|
|
514d4b0125 | ||
|
|
a503ce42c9 | ||
|
|
dfc2b8b7c0 | ||
|
|
fcae565042 | ||
|
|
723fbc2c8d | ||
|
|
243c194bb7 | ||
|
|
20d6193c00 | ||
|
|
a8f55afe52 | ||
|
|
80b5aaf80d | ||
|
|
1b896eb3a6 | ||
|
|
7cf29b6238 | ||
|
|
ec627f27bb | ||
|
|
69be592cb2 | ||
|
|
75d84aaacd | ||
|
|
6aec3da419 | ||
|
|
56be7fdcb8 | ||
|
|
28e10cbf95 | ||
|
|
cba084f110 | ||
|
|
d27c2b4f8b | ||
|
|
9c554326ee | ||
|
|
89c7d58967 | ||
|
|
ae295952ce | ||
|
|
ed102e0592 | ||
|
|
8a99fa72df | ||
|
|
1dbad2a0f1 | ||
|
|
b6819c6d39 | ||
|
|
1910805793 | ||
|
|
71ba24b1bb | ||
|
|
f5b36c5bac | ||
|
|
a41747ac51 | ||
|
|
c34b27ef8a | ||
|
|
846fbbc98d | ||
|
|
683bd01ee8 | ||
|
|
711e912310 | ||
|
|
087e4ca3be | ||
|
|
34fe826383 | ||
|
|
10908a4f78 | ||
|
|
5520771857 | ||
|
|
b61bafb604 | ||
|
|
ce19187ac9 | ||
|
|
64ac231d51 | ||
|
|
5725650c07 | ||
|
|
dccfdc7b51 | ||
|
|
b7a976ff18 | ||
|
|
69ce4e4555 | ||
|
|
0f365f9404 | ||
|
|
9fbd459b63 | ||
|
|
3ed7d99b1e | ||
|
|
6743972c5e | ||
|
|
b499fcf45b | ||
|
|
da313872c5 | ||
|
|
23e3ae415e | ||
|
|
5327c34478 | ||
|
|
6f9d929bbd | ||
|
|
289ddc5017 | ||
|
|
3711915577 | ||
|
|
293444390f | ||
|
|
a15fb72e18 | ||
|
|
6286215b22 | ||
|
|
2dde8afb43 | ||
|
|
0e9f118caa | ||
|
|
3d8c14eb04 | ||
|
|
66359b4924 | ||
|
|
885f47a49a | ||
|
|
3c60503ad7 | ||
|
|
d00ded86c3 | ||
|
|
77758dad24 | ||
|
|
1153b64f46 | ||
|
|
5820d843e2 | ||
|
|
4ef3033c21 | ||
|
|
be156ad9fc | ||
|
|
90c7ae2071 | ||
|
|
cfc7278a48 | ||
|
|
90d851e5ed | ||
|
|
87aec930ad | ||
|
|
06bc6b3b59 | ||
|
|
4789fc28f6 | ||
|
|
9f7712f51b | ||
|
|
ee416aa977 | ||
|
|
e03324cd4e | ||
|
|
af63c75b86 | ||
|
|
2a53f80b1f | ||
|
|
1b0808bbdb | ||
|
|
88c4211d55 | ||
|
|
c2f5c37f3d | ||
|
|
4dbd6a9baf | ||
|
|
e6ab7980f3 | ||
|
|
097bf97b05 | ||
|
|
5ea187e227 | ||
|
|
d4aee3bca1 | ||
|
|
1aa8c36e6a | ||
|
|
5a7e0899eb | ||
|
|
849c73ce94 | ||
|
|
bdfa3e2b5c | ||
|
|
c86d0e4252 | ||
|
|
9d415f81e8 | ||
|
|
2584880942 | ||
|
|
b29ac4857f | ||
|
|
ce180fd93e | ||
|
|
d6b801c610 | ||
|
|
b456d148f2 | ||
|
|
41db622c34 | ||
|
|
fd93627b14 | ||
|
|
5a226d4585 | ||
|
|
c7d2b1199c | ||
|
|
05c7ec158f | ||
|
|
4fe32489bd | ||
|
|
2a2a749231 | ||
|
|
7673b45113 | ||
|
|
4625227d07 | ||
|
|
70fbc73c11 | ||
|
|
81c056227c | ||
|
|
6578a0abfd | ||
|
|
58866f96a0 | ||
|
|
d061ee0c99 | ||
|
|
52fc61258d | ||
|
|
3238c1c18f | ||
|
|
bbec3f1a4a | ||
|
|
e7d70fbe7c | ||
|
|
f4833621f9 | ||
|
|
7630a2abc3 | ||
|
|
a8ac6c8323 | ||
|
|
cf686de992 | ||
|
|
b773895f68 | ||
|
|
c74b542a6c | ||
|
|
cefa8d988c | ||
|
|
354e2e632d | ||
|
|
7e6f3d6bee | ||
|
|
53c51005f5 | ||
|
|
f8588d4738 | ||
|
|
7db8c6c7b6 | ||
|
|
07343d8312 | ||
|
|
08a64f07b7 | ||
|
|
65ae7147aa | ||
|
|
c19317af24 | ||
|
|
a5e220e097 | ||
|
|
616738141a | ||
|
|
4fd32d3906 | ||
|
|
aa9c7c3ce1 | ||
|
|
5ebf42f7bf | ||
|
|
7e06642fa2 | ||
|
|
700a9e9e60 | ||
|
|
b2d84a7f9e | ||
|
|
73e19ffba7 | ||
|
|
534c6ee114 | ||
|
|
89b6008f8b | ||
|
|
c77c94cb22 | ||
|
|
6def6595b5 | ||
|
|
7ee14b2df6 | ||
|
|
0d3eb998ae | ||
|
|
f3ae538469 | ||
|
|
dd69edfbca | ||
|
|
4690ae1cdb | ||
|
|
7221836b35 | ||
|
|
1ccdaacf6e | ||
|
|
b3c2b6dddb | ||
|
|
fa59636c5b | ||
|
|
74ae0056e8 | ||
|
|
2b4b7d06f3 | ||
|
|
ceb9787171 | ||
|
|
326a9f6241 | ||
|
|
26e6743fba | ||
|
|
45129f3116 | ||
|
|
ff41750d06 | ||
|
|
1b56d2d431 | ||
|
|
745fafeafe | ||
|
|
7a4638971e | ||
|
|
82de324266 | ||
|
|
f3959d23a9 | ||
|
|
411e7ee665 | ||
|
|
17e6fd55bf | ||
|
|
26103c104e | ||
|
|
10d8d84295 | ||
|
|
9a15e99e66 | ||
|
|
5777305264 | ||
|
|
3fcd220b56 | ||
|
|
b16454fd22 | ||
|
|
c30dc0febf | ||
|
|
783f2d31ca | ||
|
|
978591ea13 | ||
|
|
96ca047ac7 | ||
|
|
34a22fde39 | ||
|
|
a584067100 | ||
|
|
4d4de49de9 | ||
|
|
ee3f1787ff | ||
|
|
1f6a8bb3da | ||
|
|
9e09e3c4c1 | ||
|
|
7a7f5e7ba1 | ||
|
|
01ba87ebc2 | ||
|
|
cf60b9e15c | ||
|
|
09fac98139 | ||
|
|
aff1bcc05f | ||
|
|
ae5d7c78ae | ||
|
|
8512214d0e | ||
|
|
bfd6dc917d | ||
|
|
1a89bbb759 | ||
|
|
3e962c2dd1 | ||
|
|
a5b544fa16 | ||
|
|
8ba7410f91 | ||
|
|
ee27509165 | ||
|
|
ccbc2d1fe5 | ||
|
|
ea514b4c6b | ||
|
|
bf724b4919 | ||
|
|
27cd7982a3 | ||
|
|
67e3fadf72 | ||
|
|
b41a21a6bb | ||
|
|
244c866b50 | ||
|
|
9696cd39ea | ||
|
|
93ce3a81a7 | ||
|
|
db13dfa15c | ||
|
|
be512ea4e9 | ||
|
|
7ebed75520 | ||
|
|
5db3adb4e5 | ||
|
|
5c0086ceb0 | ||
|
|
0de0b1d1b7 | ||
|
|
ed15952d2e | ||
|
|
5057e98cd3 | ||
|
|
f3de190aaf | ||
|
|
7a9f20a33f | ||
|
|
8b728305b1 | ||
|
|
0ccddf82b8 | ||
|
|
382c9e7499 | ||
|
|
b70801e92a | ||
|
|
9d0ca26de9 | ||
|
|
f8a2d8b4f3 | ||
|
|
f9ef840898 | ||
|
|
20e3381dd6 | ||
|
|
367515af39 | ||
|
|
edccd5b58b | ||
|
|
a0f851b33e | ||
|
|
b4c7f5e422 | ||
|
|
53ba4b5a0a | ||
|
|
a5806d4e3c | ||
|
|
627e538c35 | ||
|
|
5328ecaf13 | ||
|
|
d76b622eae | ||
|
|
5e07ad2fc1 | ||
|
|
787b60eeae | ||
|
|
b91226cac9 | ||
|
|
89dfcf7563 | ||
|
|
72ab16d4dd | ||
|
|
4125d1ae76 | ||
|
|
bcbffc7f16 | ||
|
|
b11a97df5d | ||
|
|
3bd26833e1 | ||
|
|
7affb5b478 | ||
|
|
4d53099dbb | ||
|
|
14aa71000a | ||
|
|
2ca08a955f | ||
|
|
c866256981 | ||
|
|
2c4194b100 | ||
|
|
30268823b4 | ||
|
|
c26f5f2fc7 | ||
|
|
7458a6ca1a | ||
|
|
30715a97d9 | ||
|
|
7dda810ac9 | ||
|
|
6a91220891 | ||
|
|
9e88c7c344 | ||
|
|
fc53c6766e | ||
|
|
5228dba36e | ||
|
|
617a56e44d | ||
|
|
f345299ba2 | ||
|
|
f88f211fa4 | ||
|
|
9c34aa0193 | ||
|
|
2df26ddb98 | ||
|
|
a7d11428c2 | ||
|
|
6eed5914a9 | ||
|
|
6c435045c3 | ||
|
|
72614f85f2 | ||
|
|
bc1f50970f | ||
|
|
025794de83 | ||
|
|
dc2877fbfb | ||
|
|
a87bf9d4ca | ||
|
|
0f49685690 | ||
|
|
bf3a4dfbfa | ||
|
|
aefc95065c | ||
|
|
76b26c9803 | ||
|
|
c95422cbf1 | ||
|
|
3677c2bf45 | ||
|
|
a5c60577cf | ||
|
|
078c43479b | ||
|
|
5060a5bee3 | ||
|
|
21acaaa65f | ||
|
|
02be02992c | ||
|
|
234a37bb95 | ||
|
|
fd43c46207 | ||
|
|
8ecf339e5b | ||
|
|
6833b98494 | ||
|
|
eded4b24dd | ||
|
|
089d3a6d58 | ||
|
|
d4080d5d7f | ||
|
|
6fb7637cf1 | ||
|
|
f87882144e | ||
|
|
868069dea7 | ||
|
|
0a59f3e04c | ||
|
|
30a809f267 | ||
|
|
91a89466d9 | ||
|
|
d53fcde29a | ||
|
|
f983b543ab | ||
|
|
9fabb5b74c | ||
|
|
9cdbe813a6 | ||
|
|
81e64df8b2 | ||
|
|
27722b0578 | ||
|
|
77cb31ce19 | ||
|
|
df333744c6 | ||
|
|
587872b392 | ||
|
|
123bd3ff7a | ||
|
|
b40d434d4b | ||
|
|
6b6b552795 | ||
|
|
e560fdd4bc | ||
|
|
fa8f4bdd0c | ||
|
|
4eeb923824 | ||
|
|
c3b316607b | ||
|
|
096802ee36 | ||
|
|
5e553f703e | ||
|
|
13424686cb | ||
|
|
76e2ffdb87 | ||
|
|
bbb8ad3e13 | ||
|
|
3451589c31 | ||
|
|
e915bbc0ec | ||
|
|
7a88ad618a | ||
|
|
165eb0ad3a | ||
|
|
6df52fab75 | ||
|
|
5161c303b6 | ||
|
|
dd4b388f3e | ||
|
|
e18c1abd07 | ||
|
|
7cf0bceb8b | ||
|
|
ebec5efda8 | ||
|
|
96ffa46f2e | ||
|
|
509bf3c9e1 | ||
|
|
29c9854e60 | ||
|
|
1384ae37e7 | ||
|
|
12aa338862 | ||
|
|
e300bc82d5 | ||
|
|
bee5c7da72 | ||
|
|
4290465dbe | ||
|
|
3ef7e2e8f7 | ||
|
|
7ab188cf5a | ||
|
|
19327fd08f | ||
|
|
c6c3f3f96d | ||
|
|
e78f5366c7 | ||
|
|
5b4391fce3 | ||
|
|
efbf43422e | ||
|
|
4148b254a3 | ||
|
|
835f2b5222 | ||
|
|
afe44e4d77 | ||
|
|
d1df8b8996 | ||
|
|
d3b8e147b2 | ||
|
|
5eac2ed4e0 | ||
|
|
ead5f88ea3 | ||
|
|
5107acc191 | ||
|
|
b1a756ee59 | ||
|
|
0813600988 | ||
|
|
21a8f529c0 | ||
|
|
2b91e83a48 | ||
|
|
fb17636912 | ||
|
|
7a9207e1e2 | ||
|
|
16610216d4 | ||
|
|
07b7fc0ad8 | ||
|
|
e23cc14244 | ||
|
|
064cd6c99c | ||
|
|
1b1610facd | ||
|
|
eb6b666426 | ||
|
|
93b36334e2 | ||
|
|
2e16f1ffdd | ||
|
|
212d78cced | ||
|
|
0f8b7dbdd0 | ||
|
|
8195aa8b36 | ||
|
|
7e422bd881 | ||
|
|
eb4b64e1e4 | ||
|
|
842be48d6e | ||
|
|
e6ed585f5a | ||
|
|
99e71addf8 | ||
|
|
71c2161c73 | ||
|
|
fbe0014279 | ||
|
|
ddb317c771 | ||
|
|
aef97f1e43 | ||
|
|
3f3bdfa663 | ||
|
|
5482b975fb | ||
|
|
18a02fe574 | ||
|
|
8d8c5a8484 | ||
|
|
cb11c1d221 | ||
|
|
2aa6aa28cd | ||
|
|
df6e0f0547 | ||
|
|
a5bee31eb5 | ||
|
|
02122e2532 | ||
|
|
d4ff4aecef | ||
|
|
0c25ed9f23 | ||
|
|
b244d5354a | ||
|
|
46ba79697b | ||
|
|
807fd8b48a | ||
|
|
f6058569cb | ||
|
|
608545c23e | ||
|
|
bea7d2d0fb | ||
|
|
6f0f1da64e | ||
|
|
f4a28f2bd4 | ||
|
|
a2b6822754 | ||
|
|
73de5edebd | ||
|
|
d4fde1af7b | ||
|
|
352b2d08a8 | ||
|
|
e1908564c5 |
No files matched your search
@@ -4,3 +4,8 @@ docs/launcher-commands.md text eol=lf
|
||||
docs/horizon-ctl-commands.md text eol=lf
|
||||
crates/horizon-theme/tokens.css text eol=lf
|
||||
docs/api/*-reference.md text eol=lf
|
||||
# Generated by `cargo run -p xtask -- mod-api`; `--check` compares them byte for byte.
|
||||
crates/horizon-client/src/mod_api/generated.rs linguist-generated=true text eol=lf
|
||||
crates/horizon-client/src/mod_api/generated_host.rs linguist-generated=true text eol=lf
|
||||
crates/horizon-client/src/game/mod_api_host_generated.rs linguist-generated=true text eol=lf
|
||||
crates/horizon-client-render/src/gfx/native_render/generated.rs linguist-generated=true text eol=lf
|
||||
Generated
+4
@@ -1607,6 +1607,7 @@ dependencies = [
|
||||
name = "horizon-protocol"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"horizon-core",
|
||||
@@ -5119,6 +5120,9 @@ name = "xtask"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-theme",
|
||||
"quote",
|
||||
"serde_json",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -91,3 +91,27 @@ licensed under the MIT license:
|
||||
> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
> DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
## FusionExamples
|
||||
|
||||
The tile tables in `crates/horizon-optifine/src/fusion/layout.rs` were read off the connecting
|
||||
texture templates (`templates/template <layout>.png`) of
|
||||
[FusionExamples](https://github.com/SuperMartijn642/FusionExamples) (at `8c9c56e`), which is
|
||||
licensed under the MIT license:
|
||||
|
||||
> Copyright (c) 2024 Martijn van den Brand
|
||||
>
|
||||
> Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
||||
> and associated documentation files (the "Software"), to deal in the Software without
|
||||
> restriction, including without limitation the rights to use, copy, modify, merge, publish,
|
||||
> distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
|
||||
> Software is furnished to do so, subject to the following conditions:
|
||||
>
|
||||
> The above copyright notice and this permission notice shall be included in all copies or
|
||||
> substantial portions of the Software.
|
||||
>
|
||||
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
|
||||
> BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
> DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,758 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-01T22:55:52Z",
|
||||
"app": {
|
||||
"name": "horizon-client",
|
||||
"version": "0.1.0",
|
||||
"commit": "1bd24e4042"
|
||||
},
|
||||
"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": 16,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": true,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 113.978,
|
||||
"window": [
|
||||
1920,
|
||||
1080
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 1524.333,
|
||||
"stdev": 364.142,
|
||||
"min": 1112.127,
|
||||
"max": 1802.295,
|
||||
"ci95": 904.651
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 14001.774,
|
||||
"stdev": 8119.623,
|
||||
"min": 7466.904,
|
||||
"max": 23091.569,
|
||||
"ci95": 20171.889
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 8159.696,
|
||||
"stdev": 3897.327,
|
||||
"min": 5313.528,
|
||||
"max": 12601.663,
|
||||
"ci95": 9682.279
|
||||
},
|
||||
"chunks.village.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 25431.333,
|
||||
"stdev": 1389.728,
|
||||
"min": 24149.0,
|
||||
"max": 26908.0,
|
||||
"ci95": 3452.555
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 568.391,
|
||||
"stdev": 11.948,
|
||||
"min": 558.517,
|
||||
"max": 581.673,
|
||||
"ci95": 29.684
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 270.644,
|
||||
"stdev": 9.955,
|
||||
"min": 259.156,
|
||||
"max": 276.728,
|
||||
"ci95": 24.732
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 209.951,
|
||||
"stdev": 30.24,
|
||||
"min": 191.819,
|
||||
"max": 244.86,
|
||||
"ci95": 75.126
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 8879.707,
|
||||
"stdev": 1758.259,
|
||||
"min": 7205.686,
|
||||
"max": 10711.562,
|
||||
"ci95": 4368.11
|
||||
},
|
||||
"chunks.forest.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 29366.667,
|
||||
"stdev": 273.35,
|
||||
"min": 29053.0,
|
||||
"max": 29554.0,
|
||||
"ci95": 679.094
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 333.026,
|
||||
"stdev": 1.469,
|
||||
"min": 331.478,
|
||||
"max": 334.4,
|
||||
"ci95": 3.649
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 193.014,
|
||||
"stdev": 11.504,
|
||||
"min": 185.57,
|
||||
"max": 206.264,
|
||||
"ci95": 28.58
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 137.067,
|
||||
"stdev": 28.701,
|
||||
"min": 108.337,
|
||||
"max": 165.738,
|
||||
"ci95": 71.302
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 6399.435,
|
||||
"stdev": 2268.592,
|
||||
"min": 4346.111,
|
||||
"max": 8834.786,
|
||||
"ci95": 5635.949
|
||||
},
|
||||
"chunks.ocean.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 25436.0,
|
||||
"stdev": 813.154,
|
||||
"min": 24718.0,
|
||||
"max": 26319.0,
|
||||
"ci95": 2020.149
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 411.986,
|
||||
"stdev": 1.42,
|
||||
"min": 410.981,
|
||||
"max": 413.61,
|
||||
"ci95": 3.527
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 209.307,
|
||||
"stdev": 10.427,
|
||||
"min": 197.391,
|
||||
"max": 216.759,
|
||||
"ci95": 25.905
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 126.88,
|
||||
"stdev": 17.642,
|
||||
"min": 106.534,
|
||||
"max": 137.93,
|
||||
"ci95": 43.829
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 14672.976,
|
||||
"stdev": 9832.9,
|
||||
"min": 8230.723,
|
||||
"max": 25990.951,
|
||||
"ci95": 24428.25
|
||||
},
|
||||
"chunks.mountain.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 27715.0,
|
||||
"stdev": 1706.033,
|
||||
"min": 26572.0,
|
||||
"max": 29676.0,
|
||||
"ci95": 4238.362
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 357.149,
|
||||
"stdev": 2.391,
|
||||
"min": 355.009,
|
||||
"max": 359.73,
|
||||
"ci95": 5.941
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 199.658,
|
||||
"stdev": 12.172,
|
||||
"min": 187.504,
|
||||
"max": 211.847,
|
||||
"ci95": 30.238
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 154.465,
|
||||
"stdev": 13.484,
|
||||
"min": 144.923,
|
||||
"max": 169.891,
|
||||
"ci95": 33.498
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 1892.0,
|
||||
"stdev": 49.025,
|
||||
"min": 1835.406,
|
||||
"max": 1921.438,
|
||||
"ci95": 121.794
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1616.755,
|
||||
"stdev": 47.256,
|
||||
"min": 1564.734,
|
||||
"max": 1657.031,
|
||||
"ci95": 117.4
|
||||
},
|
||||
"memory.footprint_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 3083.815,
|
||||
"stdev": 25.002,
|
||||
"min": 3062.456,
|
||||
"max": 3111.315,
|
||||
"ci95": 62.113
|
||||
},
|
||||
"memory.footprint_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 2495.271,
|
||||
"stdev": 36.569,
|
||||
"min": 2453.284,
|
||||
"max": 2520.151,
|
||||
"ci95": 90.85
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 6981,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 581.673,
|
||||
"low_1pct_fps": 276.049,
|
||||
"low_01pct_fps": 191.819,
|
||||
"p50_ms": 1.655,
|
||||
"p95_ms": 2.267,
|
||||
"p99_ms": 3.277,
|
||||
"p999_ms": 4.174,
|
||||
"max_ms": 7.418,
|
||||
"cpu_p50_ms": 0.955,
|
||||
"gpu_p50_ms": 1.646,
|
||||
"histogram": [
|
||||
0,
|
||||
6445,
|
||||
353,
|
||||
176,
|
||||
3,
|
||||
3,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 6703,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 558.517,
|
||||
"low_1pct_fps": 276.728,
|
||||
"low_01pct_fps": 244.86,
|
||||
"p50_ms": 1.725,
|
||||
"p95_ms": 2.341,
|
||||
"p99_ms": 3.396,
|
||||
"p999_ms": 3.939,
|
||||
"max_ms": 4.185,
|
||||
"cpu_p50_ms": 0.767,
|
||||
"gpu_p50_ms": 1.708,
|
||||
"histogram": [
|
||||
17,
|
||||
6041,
|
||||
442,
|
||||
202,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 6780,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 564.982,
|
||||
"low_1pct_fps": 259.156,
|
||||
"low_01pct_fps": 193.174,
|
||||
"p50_ms": 1.707,
|
||||
"p95_ms": 2.311,
|
||||
"p99_ms": 3.407,
|
||||
"p999_ms": 4.984,
|
||||
"max_ms": 5.322,
|
||||
"cpu_p50_ms": 0.923,
|
||||
"gpu_p50_ms": 1.698,
|
||||
"histogram": [
|
||||
5,
|
||||
6118,
|
||||
480,
|
||||
163,
|
||||
8,
|
||||
6,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 3978,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 331.478,
|
||||
"low_1pct_fps": 187.208,
|
||||
"low_01pct_fps": 137.127,
|
||||
"p50_ms": 2.93,
|
||||
"p95_ms": 3.969,
|
||||
"p99_ms": 4.719,
|
||||
"p999_ms": 6.451,
|
||||
"max_ms": 8.835,
|
||||
"cpu_p50_ms": 0.857,
|
||||
"gpu_p50_ms": 2.92,
|
||||
"histogram": [
|
||||
0,
|
||||
29,
|
||||
2279,
|
||||
1541,
|
||||
109,
|
||||
14,
|
||||
4,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 3999,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 333.199,
|
||||
"low_1pct_fps": 185.57,
|
||||
"low_01pct_fps": 108.337,
|
||||
"p50_ms": 2.921,
|
||||
"p95_ms": 3.862,
|
||||
"p99_ms": 4.679,
|
||||
"p999_ms": 6.817,
|
||||
"max_ms": 11.683,
|
||||
"cpu_p50_ms": 0.828,
|
||||
"gpu_p50_ms": 2.903,
|
||||
"histogram": [
|
||||
3,
|
||||
16,
|
||||
2356,
|
||||
1511,
|
||||
97,
|
||||
12,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 4013,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 334.4,
|
||||
"low_1pct_fps": 206.264,
|
||||
"low_01pct_fps": 165.738,
|
||||
"p50_ms": 2.915,
|
||||
"p95_ms": 3.809,
|
||||
"p99_ms": 4.525,
|
||||
"p999_ms": 5.078,
|
||||
"max_ms": 8.228,
|
||||
"cpu_p50_ms": 0.861,
|
||||
"gpu_p50_ms": 2.899,
|
||||
"histogram": [
|
||||
1,
|
||||
18,
|
||||
2357,
|
||||
1553,
|
||||
77,
|
||||
5,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 4932,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 410.981,
|
||||
"low_1pct_fps": 216.759,
|
||||
"low_01pct_fps": 137.93,
|
||||
"p50_ms": 2.353,
|
||||
"p95_ms": 3.181,
|
||||
"p99_ms": 4.01,
|
||||
"p999_ms": 5.767,
|
||||
"max_ms": 9.713,
|
||||
"cpu_p50_ms": 0.888,
|
||||
"gpu_p50_ms": 2.343,
|
||||
"histogram": [
|
||||
0,
|
||||
163,
|
||||
4474,
|
||||
264,
|
||||
23,
|
||||
4,
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 4937,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 411.366,
|
||||
"low_1pct_fps": 213.772,
|
||||
"low_01pct_fps": 136.177,
|
||||
"p50_ms": 2.349,
|
||||
"p95_ms": 3.258,
|
||||
"p99_ms": 4.015,
|
||||
"p999_ms": 5.985,
|
||||
"max_ms": 8.539,
|
||||
"cpu_p50_ms": 0.871,
|
||||
"gpu_p50_ms": 2.339,
|
||||
"histogram": [
|
||||
0,
|
||||
202,
|
||||
4406,
|
||||
295,
|
||||
25,
|
||||
5,
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 4963,
|
||||
"seconds": 11.999,
|
||||
"avg_fps": 413.61,
|
||||
"low_1pct_fps": 197.391,
|
||||
"low_01pct_fps": 106.534,
|
||||
"p50_ms": 2.335,
|
||||
"p95_ms": 3.214,
|
||||
"p99_ms": 4.111,
|
||||
"p999_ms": 7.65,
|
||||
"max_ms": 11.399,
|
||||
"cpu_p50_ms": 0.878,
|
||||
"gpu_p50_ms": 2.323,
|
||||
"histogram": [
|
||||
2,
|
||||
228,
|
||||
4417,
|
||||
274,
|
||||
28,
|
||||
7,
|
||||
2,
|
||||
2,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 4317,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 359.73,
|
||||
"low_1pct_fps": 211.847,
|
||||
"low_01pct_fps": 169.891,
|
||||
"p50_ms": 2.7,
|
||||
"p95_ms": 3.753,
|
||||
"p99_ms": 4.366,
|
||||
"p999_ms": 5.041,
|
||||
"max_ms": 8.726,
|
||||
"cpu_p50_ms": 1.113,
|
||||
"gpu_p50_ms": 2.687,
|
||||
"histogram": [
|
||||
0,
|
||||
68,
|
||||
3445,
|
||||
718,
|
||||
80,
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 4281,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 356.707,
|
||||
"low_1pct_fps": 199.624,
|
||||
"low_01pct_fps": 144.923,
|
||||
"p50_ms": 2.712,
|
||||
"p95_ms": 3.806,
|
||||
"p99_ms": 4.535,
|
||||
"p999_ms": 5.453,
|
||||
"max_ms": 8.354,
|
||||
"cpu_p50_ms": 1.111,
|
||||
"gpu_p50_ms": 2.694,
|
||||
"histogram": [
|
||||
0,
|
||||
64,
|
||||
3353,
|
||||
717,
|
||||
138,
|
||||
5,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 4261,
|
||||
"seconds": 12.003,
|
||||
"avg_fps": 355.009,
|
||||
"low_1pct_fps": 187.504,
|
||||
"low_01pct_fps": 148.582,
|
||||
"p50_ms": 2.727,
|
||||
"p95_ms": 4.083,
|
||||
"p99_ms": 4.744,
|
||||
"p999_ms": 6.091,
|
||||
"max_ms": 7.442,
|
||||
"cpu_p50_ms": 1.126,
|
||||
"gpu_p50_ms": 2.711,
|
||||
"histogram": [
|
||||
0,
|
||||
155,
|
||||
3198,
|
||||
719,
|
||||
165,
|
||||
17,
|
||||
6,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "horizon-m4pro-1080p"
|
||||
}
|
||||
@@ -0,0 +1,758 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-01T23:17:23Z",
|
||||
"app": {
|
||||
"name": "horizon-client",
|
||||
"version": "0.1.0",
|
||||
"commit": "1bd24e4042"
|
||||
},
|
||||
"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": 28.236,
|
||||
"window": [
|
||||
1920,
|
||||
1080
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 2190.186,
|
||||
"stdev": 1780.667,
|
||||
"min": 906.481,
|
||||
"max": 4223.028,
|
||||
"ci95": 4423.779
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 14254.027,
|
||||
"stdev": 4604.903,
|
||||
"min": 10130.899,
|
||||
"max": 19223.339,
|
||||
"ci95": 11440.138
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 6509.117,
|
||||
"stdev": 1616.283,
|
||||
"min": 5457.214,
|
||||
"max": 8370.17,
|
||||
"ci95": 4015.394
|
||||
},
|
||||
"chunks.village.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 48522.333,
|
||||
"stdev": 1713.943,
|
||||
"min": 46732.0,
|
||||
"max": 50148.0,
|
||||
"ci95": 4258.014
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 326.473,
|
||||
"stdev": 6.811,
|
||||
"min": 319.588,
|
||||
"max": 333.208,
|
||||
"ci95": 16.921
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 152.497,
|
||||
"stdev": 34.142,
|
||||
"min": 116.575,
|
||||
"max": 184.525,
|
||||
"ci95": 84.82
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 122.567,
|
||||
"stdev": 33.914,
|
||||
"min": 87.235,
|
||||
"max": 154.858,
|
||||
"ci95": 84.254
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 10363.339,
|
||||
"stdev": 3122.178,
|
||||
"min": 6760.012,
|
||||
"max": 12265.121,
|
||||
"ci95": 7756.547
|
||||
},
|
||||
"chunks.forest.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 82591.667,
|
||||
"stdev": 1489.69,
|
||||
"min": 81053.0,
|
||||
"max": 84027.0,
|
||||
"ci95": 3700.895
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 165.414,
|
||||
"stdev": 4.368,
|
||||
"min": 162.757,
|
||||
"max": 170.455,
|
||||
"ci95": 10.851
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 103.992,
|
||||
"stdev": 20.41,
|
||||
"min": 83.527,
|
||||
"max": 124.346,
|
||||
"ci95": 50.705
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 94.785,
|
||||
"stdev": 24.344,
|
||||
"min": 69.796,
|
||||
"max": 118.428,
|
||||
"ci95": 60.478
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 10450.642,
|
||||
"stdev": 2599.832,
|
||||
"min": 7510.454,
|
||||
"max": 12445.71,
|
||||
"ci95": 6458.863
|
||||
},
|
||||
"chunks.ocean.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 76405.667,
|
||||
"stdev": 649.936,
|
||||
"min": 75681.0,
|
||||
"max": 76937.0,
|
||||
"ci95": 1614.662
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 210.179,
|
||||
"stdev": 5.279,
|
||||
"min": 205.559,
|
||||
"max": 215.932,
|
||||
"ci95": 13.114
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 128.777,
|
||||
"stdev": 30.982,
|
||||
"min": 93.017,
|
||||
"max": 147.571,
|
||||
"ci95": 76.97
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 115.916,
|
||||
"stdev": 39.398,
|
||||
"min": 70.443,
|
||||
"max": 139.838,
|
||||
"ci95": 97.879
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 17168.619,
|
||||
"stdev": 3613.606,
|
||||
"min": 13749.902,
|
||||
"max": 20949.796,
|
||||
"ci95": 8977.42
|
||||
},
|
||||
"chunks.mountain.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 91622.667,
|
||||
"stdev": 482.189,
|
||||
"min": 91089.0,
|
||||
"max": 92027.0,
|
||||
"ci95": 1197.921
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 190.088,
|
||||
"stdev": 2.175,
|
||||
"min": 187.78,
|
||||
"max": 192.099,
|
||||
"ci95": 5.403
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 108.92,
|
||||
"stdev": 14.244,
|
||||
"min": 92.641,
|
||||
"max": 119.094,
|
||||
"ci95": 35.388
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 91.132,
|
||||
"stdev": 12.82,
|
||||
"min": 76.343,
|
||||
"max": 99.09,
|
||||
"ci95": 31.849
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2536.536,
|
||||
"stdev": 40.31,
|
||||
"min": 2506.281,
|
||||
"max": 2582.297,
|
||||
"ci95": 100.144
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 2195.922,
|
||||
"stdev": 37.757,
|
||||
"min": 2167.078,
|
||||
"max": 2238.656,
|
||||
"ci95": 93.8
|
||||
},
|
||||
"memory.footprint_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 5559.837,
|
||||
"stdev": 47.18,
|
||||
"min": 5505.457,
|
||||
"max": 5589.863,
|
||||
"ci95": 117.211
|
||||
},
|
||||
"memory.footprint_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 4373.034,
|
||||
"stdev": 82.743,
|
||||
"min": 4311.909,
|
||||
"max": 4467.191,
|
||||
"ci95": 205.561
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 3920,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 326.623,
|
||||
"low_1pct_fps": 116.575,
|
||||
"low_01pct_fps": 87.235,
|
||||
"p50_ms": 2.938,
|
||||
"p95_ms": 4.356,
|
||||
"p99_ms": 7.397,
|
||||
"p999_ms": 9.497,
|
||||
"max_ms": 13.274,
|
||||
"cpu_p50_ms": 1.611,
|
||||
"gpu_p50_ms": 2.871,
|
||||
"histogram": [
|
||||
0,
|
||||
115,
|
||||
2297,
|
||||
1271,
|
||||
149,
|
||||
33,
|
||||
10,
|
||||
24,
|
||||
18,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 3835,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 319.588,
|
||||
"low_1pct_fps": 156.39,
|
||||
"low_01pct_fps": 125.608,
|
||||
"p50_ms": 3.04,
|
||||
"p95_ms": 4.322,
|
||||
"p99_ms": 5.265,
|
||||
"p999_ms": 7.732,
|
||||
"max_ms": 8.183,
|
||||
"cpu_p50_ms": 1.733,
|
||||
"gpu_p50_ms": 2.936,
|
||||
"histogram": [
|
||||
0,
|
||||
1,
|
||||
1610,
|
||||
2006,
|
||||
142,
|
||||
51,
|
||||
15,
|
||||
10,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 3999,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 333.208,
|
||||
"low_1pct_fps": 184.525,
|
||||
"low_01pct_fps": 154.858,
|
||||
"p50_ms": 2.9,
|
||||
"p95_ms": 4.01,
|
||||
"p99_ms": 4.955,
|
||||
"p999_ms": 6.193,
|
||||
"max_ms": 6.805,
|
||||
"cpu_p50_ms": 1.51,
|
||||
"gpu_p50_ms": 2.788,
|
||||
"histogram": [
|
||||
0,
|
||||
8,
|
||||
2499,
|
||||
1328,
|
||||
132,
|
||||
25,
|
||||
7,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 1957,
|
||||
"seconds": 12.004,
|
||||
"avg_fps": 163.03,
|
||||
"low_1pct_fps": 83.527,
|
||||
"low_01pct_fps": 69.796,
|
||||
"p50_ms": 6.011,
|
||||
"p95_ms": 8.032,
|
||||
"p99_ms": 10.903,
|
||||
"p999_ms": 14.322,
|
||||
"max_ms": 14.333,
|
||||
"cpu_p50_ms": 1.555,
|
||||
"gpu_p50_ms": 5.857,
|
||||
"histogram": [
|
||||
0,
|
||||
5,
|
||||
26,
|
||||
16,
|
||||
36,
|
||||
883,
|
||||
739,
|
||||
194,
|
||||
29,
|
||||
22,
|
||||
5,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 1954,
|
||||
"seconds": 12.006,
|
||||
"avg_fps": 162.757,
|
||||
"low_1pct_fps": 104.102,
|
||||
"low_01pct_fps": 96.13,
|
||||
"p50_ms": 5.98,
|
||||
"p95_ms": 7.665,
|
||||
"p99_ms": 9.049,
|
||||
"p999_ms": 10.323,
|
||||
"max_ms": 10.482,
|
||||
"cpu_p50_ms": 1.584,
|
||||
"gpu_p50_ms": 5.715,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
9,
|
||||
1028,
|
||||
737,
|
||||
130,
|
||||
46,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 2046,
|
||||
"seconds": 12.003,
|
||||
"avg_fps": 170.455,
|
||||
"low_1pct_fps": 124.346,
|
||||
"low_01pct_fps": 118.428,
|
||||
"p50_ms": 5.719,
|
||||
"p95_ms": 7.342,
|
||||
"p99_ms": 7.815,
|
||||
"p999_ms": 8.38,
|
||||
"max_ms": 8.506,
|
||||
"cpu_p50_ms": 1.384,
|
||||
"gpu_p50_ms": 5.572,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
26,
|
||||
1452,
|
||||
396,
|
||||
169,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 2467,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 205.559,
|
||||
"low_1pct_fps": 93.017,
|
||||
"low_01pct_fps": 70.443,
|
||||
"p50_ms": 4.708,
|
||||
"p95_ms": 6.6,
|
||||
"p99_ms": 9.432,
|
||||
"p999_ms": 13.566,
|
||||
"max_ms": 15.228,
|
||||
"cpu_p50_ms": 1.664,
|
||||
"gpu_p50_ms": 4.483,
|
||||
"histogram": [
|
||||
0,
|
||||
18,
|
||||
55,
|
||||
87,
|
||||
1811,
|
||||
290,
|
||||
143,
|
||||
27,
|
||||
20,
|
||||
13,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 2509,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 209.045,
|
||||
"low_1pct_fps": 145.742,
|
||||
"low_01pct_fps": 139.838,
|
||||
"p50_ms": 4.642,
|
||||
"p95_ms": 6.15,
|
||||
"p99_ms": 6.72,
|
||||
"p999_ms": 7.081,
|
||||
"max_ms": 7.254,
|
||||
"cpu_p50_ms": 1.74,
|
||||
"gpu_p50_ms": 4.376,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
78,
|
||||
2008,
|
||||
277,
|
||||
142,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 2591,
|
||||
"seconds": 11.999,
|
||||
"avg_fps": 215.932,
|
||||
"low_1pct_fps": 147.571,
|
||||
"low_01pct_fps": 137.466,
|
||||
"p50_ms": 4.466,
|
||||
"p95_ms": 5.947,
|
||||
"p99_ms": 6.512,
|
||||
"p999_ms": 7.262,
|
||||
"max_ms": 7.283,
|
||||
"cpu_p50_ms": 1.422,
|
||||
"gpu_p50_ms": 4.286,
|
||||
"histogram": [
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
111,
|
||||
2114,
|
||||
248,
|
||||
111,
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 2285,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 190.386,
|
||||
"low_1pct_fps": 92.641,
|
||||
"low_01pct_fps": 76.343,
|
||||
"p50_ms": 5.038,
|
||||
"p95_ms": 7.005,
|
||||
"p99_ms": 9.383,
|
||||
"p999_ms": 11.731,
|
||||
"max_ms": 15.266,
|
||||
"cpu_p50_ms": 2.239,
|
||||
"gpu_p50_ms": 4.915,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
54,
|
||||
45,
|
||||
916,
|
||||
985,
|
||||
168,
|
||||
78,
|
||||
22,
|
||||
15,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 2306,
|
||||
"seconds": 12.004,
|
||||
"avg_fps": 192.099,
|
||||
"low_1pct_fps": 119.094,
|
||||
"low_01pct_fps": 99.09,
|
||||
"p50_ms": 5.017,
|
||||
"p95_ms": 6.58,
|
||||
"p99_ms": 7.586,
|
||||
"p999_ms": 9.195,
|
||||
"max_ms": 11.428,
|
||||
"cpu_p50_ms": 2.261,
|
||||
"gpu_p50_ms": 4.823,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
3,
|
||||
12,
|
||||
1070,
|
||||
1003,
|
||||
165,
|
||||
43,
|
||||
9,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 2254,
|
||||
"seconds": 12.003,
|
||||
"avg_fps": 187.78,
|
||||
"low_1pct_fps": 115.026,
|
||||
"low_01pct_fps": 97.962,
|
||||
"p50_ms": 5.175,
|
||||
"p95_ms": 6.791,
|
||||
"p99_ms": 7.916,
|
||||
"p999_ms": 9.284,
|
||||
"max_ms": 11.986,
|
||||
"cpu_p50_ms": 2.158,
|
||||
"gpu_p50_ms": 4.942,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
17,
|
||||
774,
|
||||
1199,
|
||||
182,
|
||||
69,
|
||||
12,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "horizon-m4pro-1080p"
|
||||
}
|
||||
@@ -0,0 +1,758 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-01T22:55:52Z",
|
||||
"app": {
|
||||
"name": "horizon-client",
|
||||
"version": "0.1.0",
|
||||
"commit": "1bd24e4042"
|
||||
},
|
||||
"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": 8,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": true,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 73.292,
|
||||
"window": [
|
||||
1920,
|
||||
1080
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 7204.239,
|
||||
"stdev": 1586.64,
|
||||
"min": 5409.683,
|
||||
"max": 8421.051,
|
||||
"ci95": 3941.749
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 11407.51,
|
||||
"stdev": 2085.692,
|
||||
"min": 9027.004,
|
||||
"max": 12913.993,
|
||||
"ci95": 5181.563
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 5116.146,
|
||||
"stdev": 744.611,
|
||||
"min": 4548.096,
|
||||
"max": 5959.13,
|
||||
"ci95": 1849.865
|
||||
},
|
||||
"chunks.village.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 7220.0,
|
||||
"stdev": 137.292,
|
||||
"min": 7092.0,
|
||||
"max": 7365.0,
|
||||
"ci95": 341.079
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 540.213,
|
||||
"stdev": 182.263,
|
||||
"min": 432.911,
|
||||
"max": 750.659,
|
||||
"ci95": 452.803
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 195.561,
|
||||
"stdev": 75.888,
|
||||
"min": 141.859,
|
||||
"max": 282.38,
|
||||
"ci95": 188.533
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 134.559,
|
||||
"stdev": 74.125,
|
||||
"min": 87.663,
|
||||
"max": 220.016,
|
||||
"ci95": 184.153
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 7185.67,
|
||||
"stdev": 4093.192,
|
||||
"min": 4323.79,
|
||||
"max": 11874.133,
|
||||
"ci95": 10168.874
|
||||
},
|
||||
"chunks.forest.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 8302.667,
|
||||
"stdev": 538.894,
|
||||
"min": 7853.0,
|
||||
"max": 8900.0,
|
||||
"ci95": 1338.794
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 364.068,
|
||||
"stdev": 8.947,
|
||||
"min": 355.173,
|
||||
"max": 373.067,
|
||||
"ci95": 22.229
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 145.064,
|
||||
"stdev": 21.824,
|
||||
"min": 121.995,
|
||||
"max": 165.382,
|
||||
"ci95": 54.218
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 97.299,
|
||||
"stdev": 15.364,
|
||||
"min": 81.075,
|
||||
"max": 111.627,
|
||||
"ci95": 38.17
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 4944.497,
|
||||
"stdev": 1598.757,
|
||||
"min": 3816.976,
|
||||
"max": 6774.175,
|
||||
"ci95": 3971.854
|
||||
},
|
||||
"chunks.ocean.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 7041.0,
|
||||
"stdev": 276.949,
|
||||
"min": 6821.0,
|
||||
"max": 7352.0,
|
||||
"ci95": 688.036
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 410.281,
|
||||
"stdev": 35.314,
|
||||
"min": 370.881,
|
||||
"max": 439.08,
|
||||
"ci95": 87.731
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 161.057,
|
||||
"stdev": 15.769,
|
||||
"min": 144.003,
|
||||
"max": 175.109,
|
||||
"ci95": 39.175
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 111.852,
|
||||
"stdev": 16.415,
|
||||
"min": 93.045,
|
||||
"max": 123.299,
|
||||
"ci95": 40.78
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 8844.497,
|
||||
"stdev": 4291.678,
|
||||
"min": 5269.587,
|
||||
"max": 13604.075,
|
||||
"ci95": 10661.98
|
||||
},
|
||||
"chunks.mountain.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 7381.0,
|
||||
"stdev": 88.606,
|
||||
"min": 7290.0,
|
||||
"max": 7467.0,
|
||||
"ci95": 220.127
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 466.294,
|
||||
"stdev": 2.738,
|
||||
"min": 464.707,
|
||||
"max": 469.455,
|
||||
"ci95": 6.801
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 164.71,
|
||||
"stdev": 21.73,
|
||||
"min": 141.437,
|
||||
"max": 184.468,
|
||||
"ci95": 53.984
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 90.643,
|
||||
"stdev": 14.864,
|
||||
"min": 76.851,
|
||||
"max": 106.387,
|
||||
"ci95": 36.928
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 1536.969,
|
||||
"stdev": 19.205,
|
||||
"min": 1516.188,
|
||||
"max": 1554.063,
|
||||
"ci95": 47.711
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1379.422,
|
||||
"stdev": 9.712,
|
||||
"min": 1371.438,
|
||||
"max": 1390.234,
|
||||
"ci95": 24.129
|
||||
},
|
||||
"memory.footprint_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2093.701,
|
||||
"stdev": 13.714,
|
||||
"min": 2078.862,
|
||||
"max": 2105.909,
|
||||
"ci95": 34.07
|
||||
},
|
||||
"memory.footprint_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1883.495,
|
||||
"stdev": 10.346,
|
||||
"min": 1871.987,
|
||||
"max": 1892.026,
|
||||
"ci95": 25.702
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 9008,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 750.659,
|
||||
"low_1pct_fps": 282.38,
|
||||
"low_01pct_fps": 220.016,
|
||||
"p50_ms": 1.047,
|
||||
"p95_ms": 2.169,
|
||||
"p99_ms": 3.163,
|
||||
"p999_ms": 4.077,
|
||||
"max_ms": 5.462,
|
||||
"cpu_p50_ms": 0.447,
|
||||
"gpu_p50_ms": 1.021,
|
||||
"histogram": [
|
||||
3979,
|
||||
3877,
|
||||
1033,
|
||||
112,
|
||||
6,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 5245,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 437.07,
|
||||
"low_1pct_fps": 141.859,
|
||||
"low_01pct_fps": 87.663,
|
||||
"p50_ms": 1.822,
|
||||
"p95_ms": 4.165,
|
||||
"p99_ms": 5.399,
|
||||
"p999_ms": 10.052,
|
||||
"max_ms": 12.712,
|
||||
"cpu_p50_ms": 0.652,
|
||||
"gpu_p50_ms": 1.943,
|
||||
"histogram": [
|
||||
831,
|
||||
1968,
|
||||
606,
|
||||
1582,
|
||||
154,
|
||||
82,
|
||||
3,
|
||||
3,
|
||||
10,
|
||||
4,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 5195,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 432.911,
|
||||
"low_1pct_fps": 162.445,
|
||||
"low_01pct_fps": 95.997,
|
||||
"p50_ms": 1.806,
|
||||
"p95_ms": 4.202,
|
||||
"p99_ms": 5.259,
|
||||
"p999_ms": 7.591,
|
||||
"max_ms": 17.328,
|
||||
"cpu_p50_ms": 0.636,
|
||||
"gpu_p50_ms": 1.876,
|
||||
"histogram": [
|
||||
297,
|
||||
2502,
|
||||
650,
|
||||
1469,
|
||||
192,
|
||||
73,
|
||||
6,
|
||||
1,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 4262,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 355.173,
|
||||
"low_1pct_fps": 165.382,
|
||||
"low_01pct_fps": 111.627,
|
||||
"p50_ms": 2.917,
|
||||
"p95_ms": 3.854,
|
||||
"p99_ms": 5.022,
|
||||
"p999_ms": 7.808,
|
||||
"max_ms": 9.769,
|
||||
"cpu_p50_ms": 0.59,
|
||||
"gpu_p50_ms": 2.91,
|
||||
"histogram": [
|
||||
98,
|
||||
416,
|
||||
2506,
|
||||
1080,
|
||||
118,
|
||||
29,
|
||||
10,
|
||||
1,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 4477,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 373.067,
|
||||
"low_1pct_fps": 121.995,
|
||||
"low_01pct_fps": 81.075,
|
||||
"p50_ms": 2.243,
|
||||
"p95_ms": 4.687,
|
||||
"p99_ms": 5.835,
|
||||
"p999_ms": 11.831,
|
||||
"max_ms": 13.367,
|
||||
"cpu_p50_ms": 0.541,
|
||||
"gpu_p50_ms": 2.346,
|
||||
"histogram": [
|
||||
183,
|
||||
1975,
|
||||
392,
|
||||
887,
|
||||
903,
|
||||
99,
|
||||
12,
|
||||
6,
|
||||
11,
|
||||
6,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 4368,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 363.964,
|
||||
"low_1pct_fps": 147.816,
|
||||
"low_01pct_fps": 99.196,
|
||||
"p50_ms": 2.264,
|
||||
"p95_ms": 4.763,
|
||||
"p99_ms": 5.86,
|
||||
"p999_ms": 7.641,
|
||||
"max_ms": 11.427,
|
||||
"cpu_p50_ms": 0.562,
|
||||
"gpu_p50_ms": 2.203,
|
||||
"histogram": [
|
||||
73,
|
||||
1978,
|
||||
472,
|
||||
695,
|
||||
1004,
|
||||
104,
|
||||
35,
|
||||
3,
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 4451,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 370.881,
|
||||
"low_1pct_fps": 175.109,
|
||||
"low_01pct_fps": 119.212,
|
||||
"p50_ms": 2.858,
|
||||
"p95_ms": 4.279,
|
||||
"p99_ms": 5.045,
|
||||
"p999_ms": 6.057,
|
||||
"max_ms": 10.866,
|
||||
"cpu_p50_ms": 0.609,
|
||||
"gpu_p50_ms": 2.922,
|
||||
"histogram": [
|
||||
193,
|
||||
1245,
|
||||
964,
|
||||
1732,
|
||||
270,
|
||||
42,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 5269,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 439.08,
|
||||
"low_1pct_fps": 144.003,
|
||||
"low_01pct_fps": 93.045,
|
||||
"p50_ms": 1.767,
|
||||
"p95_ms": 4.188,
|
||||
"p99_ms": 5.191,
|
||||
"p999_ms": 10.375,
|
||||
"max_ms": 11.271,
|
||||
"cpu_p50_ms": 0.545,
|
||||
"gpu_p50_ms": 1.815,
|
||||
"histogram": [
|
||||
1239,
|
||||
1550,
|
||||
644,
|
||||
1555,
|
||||
201,
|
||||
59,
|
||||
1,
|
||||
4,
|
||||
10,
|
||||
6,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 5051,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 420.881,
|
||||
"low_1pct_fps": 164.058,
|
||||
"low_01pct_fps": 123.299,
|
||||
"p50_ms": 1.872,
|
||||
"p95_ms": 4.455,
|
||||
"p99_ms": 5.38,
|
||||
"p999_ms": 6.923,
|
||||
"max_ms": 11.189,
|
||||
"cpu_p50_ms": 0.548,
|
||||
"gpu_p50_ms": 1.874,
|
||||
"histogram": [
|
||||
519,
|
||||
2114,
|
||||
673,
|
||||
1086,
|
||||
577,
|
||||
65,
|
||||
13,
|
||||
3,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 5635,
|
||||
"seconds": 12.003,
|
||||
"avg_fps": 469.455,
|
||||
"low_1pct_fps": 184.468,
|
||||
"low_01pct_fps": 106.387,
|
||||
"p50_ms": 1.626,
|
||||
"p95_ms": 3.874,
|
||||
"p99_ms": 4.614,
|
||||
"p999_ms": 5.913,
|
||||
"max_ms": 18.185,
|
||||
"cpu_p50_ms": 0.564,
|
||||
"gpu_p50_ms": 1.604,
|
||||
"histogram": [
|
||||
1491,
|
||||
1599,
|
||||
704,
|
||||
1688,
|
||||
128,
|
||||
20,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 5578,
|
||||
"seconds": 12.003,
|
||||
"avg_fps": 464.72,
|
||||
"low_1pct_fps": 141.437,
|
||||
"low_01pct_fps": 76.851,
|
||||
"p50_ms": 1.587,
|
||||
"p95_ms": 4.127,
|
||||
"p99_ms": 5.085,
|
||||
"p999_ms": 10.138,
|
||||
"max_ms": 21.644,
|
||||
"cpu_p50_ms": 0.588,
|
||||
"gpu_p50_ms": 1.625,
|
||||
"histogram": [
|
||||
1321,
|
||||
1798,
|
||||
770,
|
||||
1453,
|
||||
173,
|
||||
41,
|
||||
2,
|
||||
5,
|
||||
7,
|
||||
6,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 5576,
|
||||
"seconds": 11.999,
|
||||
"avg_fps": 464.707,
|
||||
"low_1pct_fps": 168.226,
|
||||
"low_01pct_fps": 88.692,
|
||||
"p50_ms": 1.627,
|
||||
"p95_ms": 4.154,
|
||||
"p99_ms": 4.908,
|
||||
"p999_ms": 9.555,
|
||||
"max_ms": 14.784,
|
||||
"cpu_p50_ms": 0.595,
|
||||
"gpu_p50_ms": 1.64,
|
||||
"histogram": [
|
||||
1054,
|
||||
2049,
|
||||
837,
|
||||
1377,
|
||||
217,
|
||||
32,
|
||||
3,
|
||||
0,
|
||||
2,
|
||||
4,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "horizon-m4pro-1080p"
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
# Horizon main vs vanilla 26.3 on a loaded Mac mini M4 Pro (2026-11-01)
|
||||
|
||||
`tools/perf_bench.py` reports for Horizon main 1bd24e4042, vanilla 26.3 and vanilla with performance mods, render distances
|
||||
8, 16 and 32, three games each, every game from a fresh world. **These are not player numbers:** the Mac mini was running other
|
||||
work the whole time (1-minute load average 15-114 on 14 cores), and every game rendered without presenting. They are kept because
|
||||
they're the first Mac reports and show how each game behaves under heavy CPU contention. The reports are the
|
||||
`*-m4pro-1080p-rd*.json` files next to this page.
|
||||
|
||||
## Setup
|
||||
|
||||
- **Machine:** Mac mini, Apple M4 Pro (10 performance + 4 efficiency cores, 20-core GPU), 64 GB, macOS 27.0.1. Screen
|
||||
1920x1080; the session was locked, so every game ran `offscreen` (Horizon renders into a texture; vanilla draws into a
|
||||
window nobody sees).
|
||||
- **Horizon:** main 1bd24e4042, `cargo build --release -p horizon-client`, Metal.
|
||||
- **Vanilla:** the 26.3 client jar with Fabric Loader and the driver mod, Java 25 (Temurin), `-Xmx2G` with the launcher's G1
|
||||
flags, OpenGL 4.1 (Apple's Metal-backed driver).
|
||||
- **Vanilla + mods:** the same with Sodium mc26.3-0.9.2, Lithium mc26.3-0.26.2, FerriteCore 9.0.0, ImmediatelyFast 1.17.1 and
|
||||
Fabric API (`tools/benchmark-deps/mods.lock.json`).
|
||||
- **Order:** the games took turns inside each repeat (vanilla, mods, Horizon; then mods, Horizon, vanilla; then Horizon, vanilla,
|
||||
mods), one game at a time.
|
||||
|
||||
## Why these numbers aren't a fair comparison
|
||||
|
||||
- **Load.** Load before each game: RD 8 46-82, RD 16 43-114, RD 32 15-59 (the later RD 32 games ran at 15-30). Horizon raises its
|
||||
render and server threads to the user-interactive QoS class, and the JVM's threads stay at the default, so under this
|
||||
contention macOS runs Horizon's frame first. Vanilla's 1% lows of 2-15 FPS show that, more than how fast either game is.
|
||||
- **Resolution.** Vanilla's 1920x1080 window was shrunk to **1920x930** by the 1080p screen (menu and title bars); Horizon,
|
||||
offscreen, rendered 1920x1080. Vanilla drew 14% fewer pixels, which favours it in GPU-bound scenes. Because the recorded
|
||||
windows differ, `perf_bench.py --table` refuses to compare these reports. The tables below were made with the window check
|
||||
skipped. A rerun at a size that fits the screen (1600x900) would avoid it.
|
||||
|
||||
## Results
|
||||
|
||||
Mean of 3 games ± 95% confidence interval. FPS over the 12-second flight (average / 1% low / 0.1% low):
|
||||
|
||||
| RD | scene | Horizon main | Vanilla 26.3 | Vanilla + mods |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 8 | forest | 364 ±22 / 145 ±54 / 97 ±38 | 66 ±22 / 8 ±10 / 6 ±8 | 47 ±42 / 4 ±2 / 3 ±1 |
|
||||
| 8 | mountain | 466 ±7 / 165 ±54 / 91 ±37 | 46 ±37 / 5 ±7 / 4 ±6 | 67 ±35 / 8 ±5 / 4 ±4 |
|
||||
| 8 | ocean | 410 ±88 / 161 ±39 / 112 ±41 | 50 ±44 / 6 ±13 / 5 ±11 | 35 ±16 / 5 ±4 / 4 ±4 |
|
||||
| 8 | village | 540 ±453 / 196 ±189 / 135 ±184 | 42 ±16 / 4 ±3 / 3 ±0 | 33 ±46 / 4 ±6 / 3 ±4 |
|
||||
| 16 | forest | 333 ±4 / 193 ±29 / 137 ±71 | 23 ±9 / 2 ±1 / 2 ±1 | 62 ±94 / 10 ±21 / 7 ±14 |
|
||||
| 16 | mountain | 357 ±6 / 200 ±30 / 154 ±33 | 44 ±12 / 7 ±2 / 4 ±1 | 36 ±44 / 4 ±5 / 3 ±5 |
|
||||
| 16 | ocean | 412 ±4 / 209 ±26 / 127 ±44 | 51 ±16 / 6 ±8 / 4 ±7 | 75 ±159 / 13 ±33 / 7 ±16 |
|
||||
| 16 | village | 568 ±30 / 271 ±25 / 210 ±75 | 46 ±110 / 9 ±27 / 7 ±17 | 46 ±45 / 7 ±9 / 5 ±4 |
|
||||
| 32 | forest | 165 ±11 / 104 ±51 / 95 ±60 | 68 ±122 / 18 ±49 / 14 ±42 | 114 ±132 / 29 ±44 / 21 ±34 |
|
||||
| 32 | mountain | 190 ±5 / 109 ±35 / 91 ±32 | 29 ±19 / 3 ±2 / 2 ±3 | 57 ±49 / 7 ±2 / 3 ±3 |
|
||||
| 32 | ocean | 210 ±13 / 129 ±77 / 116 ±98 | 44 ±80 / 5 ±4 / 2 ±1 | 119 ±176 / 32 ±66 / 17 ±37 |
|
||||
| 32 | village | 326 ±17 / 152 ±85 / 123 ±84 | 81 ±99 / 15 ±20 / 11 ±11 | 92 ±28 / 13 ±6 / 9 ±3 |
|
||||
|
||||
Start to title / in-world, mean scene load, RSS steady / peak, physical footprint steady / peak (macOS `footprint`, which on Apple
|
||||
Silicon includes the game's GPU allocations), load average before the first game:
|
||||
|
||||
| RD | Horizon main | Vanilla 26.3 | Vanilla + mods |
|
||||
| --- | --- | --- | --- |
|
||||
| 8 | 7.2 / 11.4, 6.5, 1379 / 1537, 1883 / 2094, 73 | 49.9 / 89.1, 18.2, 1811 / 1877, 2635 / 2709, 66 | 56.3 / 97.7, 17.9, 1927 / 2019, 2396 / 2441, 74 |
|
||||
| 16 | 1.5 / 14.0, 9.5, 1617 / 1892, 2495 / 3084, 114 | 19.4 / 78.9, 33.3, 2431 / 2555, 3450 / 3713, 76 | 23.8 / 76.9, 27.1, 2355 / 2566, 3212 / 3431, 70 |
|
||||
| 32 | 2.2 / 14.3, 11.1, 2196 / 2537, 4373 / 5560, 28 | 20.6 / 124.2, 44.2, 2838 / 2958, 4265 / 4618, 59 | 13.5 / 63.2, 29.9, 3705 / 4078, 4647 / 5250, 37 |
|
||||
|
||||
## What it shows
|
||||
|
||||
- Horizon is GPU-bound on the M4 Pro in every scene at every distance: its median GPU time per frame is 1.6-2.5 ms at RD 8 and
|
||||
2.9-5.7 ms at RD 32, its render thread's CPU time 0.6-2.2 ms.
|
||||
- In the least-loaded RD 32 games (load 15-22), vanilla + mods matched Horizon's average FPS over the forest (162 vs 163-170) and
|
||||
the ocean (187 vs 209-216); Horizon's 1% lows were 2.4 times the modded client's there, and 15-17 times over the village and the
|
||||
mountain.
|
||||
- Horizon's resident memory is 1.2-1.7 times smaller than both clients'. Its physical footprint, GPU memory included, is level
|
||||
with vanilla's at RD 32 (4373 vs 4265 MB steady) and above it at peak (5560 vs 4618 MB).
|
||||
@@ -0,0 +1,707 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-01T22:55:52Z",
|
||||
"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": 16,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": true,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 75.854,
|
||||
"window": [
|
||||
1920,
|
||||
930
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 19419.646,
|
||||
"stdev": 10653.755,
|
||||
"min": 8240.178,
|
||||
"max": 29455.403,
|
||||
"ci95": 26467.531
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 78944.646,
|
||||
"stdev": 36737.011,
|
||||
"min": 51495.357,
|
||||
"max": 120678.403,
|
||||
"ci95": 91267.16
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 21947.683,
|
||||
"stdev": 10432.774,
|
||||
"min": 10427.451,
|
||||
"max": 30758.368,
|
||||
"ci95": 25918.54
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 45.524,
|
||||
"stdev": 44.352,
|
||||
"min": 15.749,
|
||||
"max": 96.497,
|
||||
"ci95": 110.185
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 9.141,
|
||||
"stdev": 10.812,
|
||||
"min": 1.86,
|
||||
"max": 21.565,
|
||||
"ci95": 26.862
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 6.727,
|
||||
"stdev": 6.902,
|
||||
"min": 1.785,
|
||||
"max": 14.612,
|
||||
"ci95": 17.146
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 39062.839,
|
||||
"stdev": 21806.234,
|
||||
"min": 15037.908,
|
||||
"max": 57603.235,
|
||||
"ci95": 54174.061
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 23.347,
|
||||
"stdev": 3.693,
|
||||
"min": 20.154,
|
||||
"max": 27.392,
|
||||
"ci95": 9.175
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 2.419,
|
||||
"stdev": 0.269,
|
||||
"min": 2.196,
|
||||
"max": 2.718,
|
||||
"ci95": 0.669
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 1.972,
|
||||
"stdev": 0.438,
|
||||
"min": 1.551,
|
||||
"max": 2.425,
|
||||
"ci95": 1.088
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 22097.713,
|
||||
"stdev": 4463.742,
|
||||
"min": 18053.319,
|
||||
"max": 26887.066,
|
||||
"ci95": 11089.445
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 51.279,
|
||||
"stdev": 6.309,
|
||||
"min": 44.786,
|
||||
"max": 57.386,
|
||||
"ci95": 15.673
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 6.15,
|
||||
"stdev": 3.128,
|
||||
"min": 3.444,
|
||||
"max": 9.574,
|
||||
"ci95": 7.77
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 4.161,
|
||||
"stdev": 2.739,
|
||||
"min": 2.487,
|
||||
"max": 7.321,
|
||||
"ci95": 6.803
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 50257.39,
|
||||
"stdev": 34798.619,
|
||||
"min": 25919.598,
|
||||
"max": 90115.595,
|
||||
"ci95": 86451.54
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 44.346,
|
||||
"stdev": 4.882,
|
||||
"min": 39.246,
|
||||
"max": 48.975,
|
||||
"ci95": 12.128
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 6.637,
|
||||
"stdev": 0.69,
|
||||
"min": 5.888,
|
||||
"max": 7.246,
|
||||
"ci95": 1.714
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 4.444,
|
||||
"stdev": 0.6,
|
||||
"min": 3.937,
|
||||
"max": 5.107,
|
||||
"ci95": 1.491
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2554.552,
|
||||
"stdev": 218.163,
|
||||
"min": 2328.859,
|
||||
"max": 2764.313,
|
||||
"ci95": 541.991
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 2430.854,
|
||||
"stdev": 230.654,
|
||||
"min": 2186.328,
|
||||
"max": 2644.531,
|
||||
"ci95": 573.023
|
||||
},
|
||||
"memory.footprint_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 3713.311,
|
||||
"stdev": 191.61,
|
||||
"min": 3512.331,
|
||||
"max": 3893.925,
|
||||
"ci95": 476.025
|
||||
},
|
||||
"memory.footprint_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 3450.061,
|
||||
"stdev": 76.233,
|
||||
"min": 3370.253,
|
||||
"max": 3522.128,
|
||||
"ci95": 189.388
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 189,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 15.749,
|
||||
"low_1pct_fps": 1.86,
|
||||
"low_01pct_fps": 1.785,
|
||||
"p50_ms": 22.219,
|
||||
"p95_ms": 254.854,
|
||||
"p99_ms": 515.134,
|
||||
"p999_ms": 560.079,
|
||||
"max_ms": 560.079,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
2,
|
||||
7,
|
||||
9,
|
||||
10,
|
||||
16,
|
||||
12,
|
||||
11,
|
||||
14,
|
||||
16,
|
||||
22,
|
||||
9,
|
||||
6,
|
||||
5,
|
||||
11,
|
||||
10,
|
||||
15,
|
||||
9,
|
||||
2,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 292,
|
||||
"seconds": 12.004,
|
||||
"avg_fps": 24.325,
|
||||
"low_1pct_fps": 3.998,
|
||||
"low_01pct_fps": 3.783,
|
||||
"p50_ms": 15.477,
|
||||
"p95_ms": 165.164,
|
||||
"p99_ms": 242.898,
|
||||
"p999_ms": 264.363,
|
||||
"max_ms": 264.363,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
26,
|
||||
20,
|
||||
32,
|
||||
20,
|
||||
20,
|
||||
17,
|
||||
12,
|
||||
12,
|
||||
10,
|
||||
17,
|
||||
14,
|
||||
20,
|
||||
14,
|
||||
16,
|
||||
16,
|
||||
21,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 1158,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 96.497,
|
||||
"low_1pct_fps": 21.565,
|
||||
"low_01pct_fps": 14.612,
|
||||
"p50_ms": 7.567,
|
||||
"p95_ms": 22.849,
|
||||
"p99_ms": 34.806,
|
||||
"p999_ms": 62.86,
|
||||
"max_ms": 74.011,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
38,
|
||||
452,
|
||||
158,
|
||||
102,
|
||||
94,
|
||||
125,
|
||||
68,
|
||||
34,
|
||||
42,
|
||||
30,
|
||||
7,
|
||||
4,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 271,
|
||||
"seconds": 12.047,
|
||||
"avg_fps": 22.496,
|
||||
"low_1pct_fps": 2.196,
|
||||
"low_01pct_fps": 1.551,
|
||||
"p50_ms": 18.263,
|
||||
"p95_ms": 189.284,
|
||||
"p99_ms": 317.823,
|
||||
"p999_ms": 644.675,
|
||||
"max_ms": 644.675,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
3,
|
||||
22,
|
||||
23,
|
||||
13,
|
||||
22,
|
||||
22,
|
||||
19,
|
||||
24,
|
||||
22,
|
||||
14,
|
||||
17,
|
||||
12,
|
||||
13,
|
||||
9,
|
||||
16,
|
||||
15,
|
||||
4,
|
||||
1,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 242,
|
||||
"seconds": 12.007,
|
||||
"avg_fps": 20.154,
|
||||
"low_1pct_fps": 2.343,
|
||||
"low_01pct_fps": 1.939,
|
||||
"p50_ms": 19.762,
|
||||
"p95_ms": 157.949,
|
||||
"p99_ms": 376.292,
|
||||
"p999_ms": 515.678,
|
||||
"max_ms": 515.678,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
13,
|
||||
17,
|
||||
16,
|
||||
26,
|
||||
19,
|
||||
11,
|
||||
9,
|
||||
10,
|
||||
11,
|
||||
9,
|
||||
16,
|
||||
17,
|
||||
12,
|
||||
21,
|
||||
19,
|
||||
9,
|
||||
6,
|
||||
1,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 331,
|
||||
"seconds": 12.084,
|
||||
"avg_fps": 27.392,
|
||||
"low_1pct_fps": 2.718,
|
||||
"low_01pct_fps": 2.425,
|
||||
"p50_ms": 13.0,
|
||||
"p95_ms": 162.797,
|
||||
"p99_ms": 325.827,
|
||||
"p999_ms": 412.349,
|
||||
"max_ms": 412.349,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
3,
|
||||
5,
|
||||
26,
|
||||
34,
|
||||
42,
|
||||
41,
|
||||
24,
|
||||
27,
|
||||
15,
|
||||
21,
|
||||
22,
|
||||
8,
|
||||
10,
|
||||
11,
|
||||
10,
|
||||
14,
|
||||
10,
|
||||
8,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 620,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 51.664,
|
||||
"low_1pct_fps": 3.444,
|
||||
"low_01pct_fps": 2.487,
|
||||
"p50_ms": 10.358,
|
||||
"p95_ms": 54.808,
|
||||
"p99_ms": 181.112,
|
||||
"p999_ms": 402.152,
|
||||
"max_ms": 402.152,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
30,
|
||||
79,
|
||||
95,
|
||||
86,
|
||||
90,
|
||||
60,
|
||||
54,
|
||||
28,
|
||||
28,
|
||||
19,
|
||||
9,
|
||||
8,
|
||||
8,
|
||||
6,
|
||||
8,
|
||||
7,
|
||||
5,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 689,
|
||||
"seconds": 12.006,
|
||||
"avg_fps": 57.386,
|
||||
"low_1pct_fps": 9.574,
|
||||
"low_01pct_fps": 7.321,
|
||||
"p50_ms": 12.76,
|
||||
"p95_ms": 43.737,
|
||||
"p99_ms": 69.357,
|
||||
"p999_ms": 136.592,
|
||||
"max_ms": 136.592,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
23,
|
||||
46,
|
||||
80,
|
||||
80,
|
||||
92,
|
||||
68,
|
||||
74,
|
||||
54,
|
||||
49,
|
||||
51,
|
||||
24,
|
||||
22,
|
||||
16,
|
||||
6,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 538,
|
||||
"seconds": 12.013,
|
||||
"avg_fps": 44.786,
|
||||
"low_1pct_fps": 5.431,
|
||||
"low_01pct_fps": 2.674,
|
||||
"p50_ms": 14.011,
|
||||
"p95_ms": 71.23,
|
||||
"p99_ms": 141.423,
|
||||
"p999_ms": 373.913,
|
||||
"max_ms": 373.913,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
8,
|
||||
38,
|
||||
84,
|
||||
83,
|
||||
53,
|
||||
62,
|
||||
52,
|
||||
54,
|
||||
29,
|
||||
12,
|
||||
15,
|
||||
13,
|
||||
22,
|
||||
10,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 588,
|
||||
"seconds": 12.006,
|
||||
"avg_fps": 48.975,
|
||||
"low_1pct_fps": 6.777,
|
||||
"low_01pct_fps": 3.937,
|
||||
"p50_ms": 12.678,
|
||||
"p95_ms": 64.221,
|
||||
"p99_ms": 108.513,
|
||||
"p999_ms": 254.008,
|
||||
"max_ms": 254.008,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
34,
|
||||
21,
|
||||
41,
|
||||
108,
|
||||
62,
|
||||
55,
|
||||
55,
|
||||
40,
|
||||
45,
|
||||
41,
|
||||
21,
|
||||
16,
|
||||
17,
|
||||
18,
|
||||
9,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 471,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 39.246,
|
||||
"low_1pct_fps": 5.888,
|
||||
"low_01pct_fps": 4.289,
|
||||
"p50_ms": 17.65,
|
||||
"p95_ms": 73.314,
|
||||
"p99_ms": 135.3,
|
||||
"p999_ms": 233.136,
|
||||
"max_ms": 233.136,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
3,
|
||||
12,
|
||||
22,
|
||||
29,
|
||||
36,
|
||||
40,
|
||||
43,
|
||||
38,
|
||||
41,
|
||||
46,
|
||||
62,
|
||||
29,
|
||||
20,
|
||||
19,
|
||||
22,
|
||||
5,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 538,
|
||||
"seconds": 12.004,
|
||||
"avg_fps": 44.818,
|
||||
"low_1pct_fps": 7.246,
|
||||
"low_01pct_fps": 5.107,
|
||||
"p50_ms": 15.959,
|
||||
"p95_ms": 63.485,
|
||||
"p99_ms": 109.639,
|
||||
"p999_ms": 195.815,
|
||||
"max_ms": 195.815,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
6,
|
||||
21,
|
||||
75,
|
||||
56,
|
||||
54,
|
||||
78,
|
||||
54,
|
||||
69,
|
||||
36,
|
||||
23,
|
||||
22,
|
||||
18,
|
||||
14,
|
||||
7,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-m4pro-1080p"
|
||||
}
|
||||
@@ -0,0 +1,707 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-01T23:17:23Z",
|
||||
"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": true,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 58.672,
|
||||
"window": [
|
||||
1920,
|
||||
930
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 20579.57,
|
||||
"stdev": 13060.035,
|
||||
"min": 10660.541,
|
||||
"max": 35376.471,
|
||||
"ci95": 32445.545
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 124180.237,
|
||||
"stdev": 102723.197,
|
||||
"min": 51510.541,
|
||||
"max": 241702.471,
|
||||
"ci95": 255199.163
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 23389.087,
|
||||
"stdev": 5416.249,
|
||||
"min": 18538.934,
|
||||
"max": 29233.632,
|
||||
"ci95": 13455.795
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 81.421,
|
||||
"stdev": 39.944,
|
||||
"min": 35.32,
|
||||
"max": 105.706,
|
||||
"ci95": 99.234
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 15.288,
|
||||
"stdev": 8.185,
|
||||
"min": 5.907,
|
||||
"max": 20.97,
|
||||
"ci95": 20.334
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 10.544,
|
||||
"stdev": 4.403,
|
||||
"min": 5.804,
|
||||
"max": 14.505,
|
||||
"ci95": 10.938
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 48382.741,
|
||||
"stdev": 32005.955,
|
||||
"min": 26161.003,
|
||||
"max": 85067.56,
|
||||
"ci95": 79513.616
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 67.82,
|
||||
"stdev": 49.16,
|
||||
"min": 12.722,
|
||||
"max": 107.193,
|
||||
"ci95": 122.129
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 17.769,
|
||||
"stdev": 19.765,
|
||||
"min": 2.609,
|
||||
"max": 40.124,
|
||||
"ci95": 49.103
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 13.876,
|
||||
"stdev": 16.851,
|
||||
"min": 2.308,
|
||||
"max": 33.21,
|
||||
"ci95": 41.864
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 37630.797,
|
||||
"stdev": 33094.177,
|
||||
"min": 14901.304,
|
||||
"max": 75599.155,
|
||||
"ci95": 82217.128
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 44.125,
|
||||
"stdev": 32.286,
|
||||
"min": 13.532,
|
||||
"max": 77.872,
|
||||
"ci95": 80.209
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 4.619,
|
||||
"stdev": 1.694,
|
||||
"min": 3.247,
|
||||
"max": 6.512,
|
||||
"ci95": 4.208
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 2.354,
|
||||
"stdev": 0.44,
|
||||
"min": 1.967,
|
||||
"max": 2.833,
|
||||
"ci95": 1.094
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 67242.203,
|
||||
"stdev": 17886.115,
|
||||
"min": 56354.348,
|
||||
"max": 87884.948,
|
||||
"ci95": 44435.159
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 28.745,
|
||||
"stdev": 7.77,
|
||||
"min": 20.237,
|
||||
"max": 35.466,
|
||||
"ci95": 19.304
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 3.013,
|
||||
"stdev": 0.849,
|
||||
"min": 2.273,
|
||||
"max": 3.94,
|
||||
"ci95": 2.109
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 2.377,
|
||||
"stdev": 1.031,
|
||||
"min": 1.246,
|
||||
"max": 3.263,
|
||||
"ci95": 2.561
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2958.083,
|
||||
"stdev": 22.425,
|
||||
"min": 2937.734,
|
||||
"max": 2982.125,
|
||||
"ci95": 55.71
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 2837.568,
|
||||
"stdev": 165.159,
|
||||
"min": 2648.188,
|
||||
"max": 2951.719,
|
||||
"ci95": 410.31
|
||||
},
|
||||
"memory.footprint_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 4618.337,
|
||||
"stdev": 95.349,
|
||||
"min": 4543.347,
|
||||
"max": 4725.644,
|
||||
"ci95": 236.878
|
||||
},
|
||||
"memory.footprint_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 4264.652,
|
||||
"stdev": 87.422,
|
||||
"min": 4173.589,
|
||||
"max": 4347.91,
|
||||
"ci95": 217.185
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 424,
|
||||
"seconds": 12.004,
|
||||
"avg_fps": 35.32,
|
||||
"low_1pct_fps": 5.907,
|
||||
"low_01pct_fps": 5.804,
|
||||
"p50_ms": 17.195,
|
||||
"p95_ms": 95.694,
|
||||
"p99_ms": 164.6,
|
||||
"p999_ms": 172.292,
|
||||
"max_ms": 172.292,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
5,
|
||||
19,
|
||||
39,
|
||||
45,
|
||||
44,
|
||||
52,
|
||||
42,
|
||||
41,
|
||||
38,
|
||||
21,
|
||||
20,
|
||||
21,
|
||||
19,
|
||||
12,
|
||||
6,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 1239,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 103.238,
|
||||
"low_1pct_fps": 20.97,
|
||||
"low_01pct_fps": 14.505,
|
||||
"p50_ms": 8.073,
|
||||
"p95_ms": 18.618,
|
||||
"p99_ms": 34.704,
|
||||
"p999_ms": 53.789,
|
||||
"max_ms": 84.091,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
275,
|
||||
412,
|
||||
260,
|
||||
117,
|
||||
60,
|
||||
35,
|
||||
25,
|
||||
23,
|
||||
14,
|
||||
7,
|
||||
5,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 1271,
|
||||
"seconds": 12.024,
|
||||
"avg_fps": 105.706,
|
||||
"low_1pct_fps": 18.988,
|
||||
"low_01pct_fps": 11.324,
|
||||
"p50_ms": 7.988,
|
||||
"p95_ms": 16.852,
|
||||
"p99_ms": 35.156,
|
||||
"p999_ms": 65.46,
|
||||
"max_ms": 111.155,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
3,
|
||||
220,
|
||||
500,
|
||||
318,
|
||||
101,
|
||||
43,
|
||||
21,
|
||||
15,
|
||||
19,
|
||||
17,
|
||||
4,
|
||||
4,
|
||||
5,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 154,
|
||||
"seconds": 12.105,
|
||||
"avg_fps": 12.722,
|
||||
"low_1pct_fps": 2.609,
|
||||
"low_01pct_fps": 2.308,
|
||||
"p50_ms": 54.156,
|
||||
"p95_ms": 216.738,
|
||||
"p99_ms": 333.238,
|
||||
"p999_ms": 433.205,
|
||||
"max_ms": 433.205,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
4,
|
||||
5,
|
||||
7,
|
||||
9,
|
||||
13,
|
||||
25,
|
||||
5,
|
||||
7,
|
||||
12,
|
||||
17,
|
||||
26,
|
||||
18,
|
||||
5,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 1287,
|
||||
"seconds": 12.006,
|
||||
"avg_fps": 107.193,
|
||||
"low_1pct_fps": 40.124,
|
||||
"low_01pct_fps": 33.21,
|
||||
"p50_ms": 8.355,
|
||||
"p95_ms": 14.243,
|
||||
"p99_ms": 22.791,
|
||||
"p999_ms": 29.023,
|
||||
"max_ms": 31.2,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
627,
|
||||
416,
|
||||
130,
|
||||
43,
|
||||
33,
|
||||
16,
|
||||
18,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 1003,
|
||||
"seconds": 12.005,
|
||||
"avg_fps": 83.546,
|
||||
"low_1pct_fps": 10.575,
|
||||
"low_01pct_fps": 6.111,
|
||||
"p50_ms": 9.556,
|
||||
"p95_ms": 22.218,
|
||||
"p99_ms": 53.64,
|
||||
"p999_ms": 143.879,
|
||||
"max_ms": 183.38,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
57,
|
||||
515,
|
||||
239,
|
||||
64,
|
||||
45,
|
||||
21,
|
||||
30,
|
||||
12,
|
||||
4,
|
||||
4,
|
||||
5,
|
||||
3,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 163,
|
||||
"seconds": 12.045,
|
||||
"avg_fps": 13.532,
|
||||
"low_1pct_fps": 3.247,
|
||||
"low_01pct_fps": 2.833,
|
||||
"p50_ms": 52.758,
|
||||
"p95_ms": 208.496,
|
||||
"p99_ms": 263.074,
|
||||
"p999_ms": 352.963,
|
||||
"max_ms": 352.963,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
4,
|
||||
5,
|
||||
5,
|
||||
14,
|
||||
23,
|
||||
10,
|
||||
12,
|
||||
17,
|
||||
30,
|
||||
18,
|
||||
20,
|
||||
2,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 951,
|
||||
"seconds": 12.212,
|
||||
"avg_fps": 77.872,
|
||||
"low_1pct_fps": 6.512,
|
||||
"low_01pct_fps": 1.967,
|
||||
"p50_ms": 8.895,
|
||||
"p95_ms": 31.377,
|
||||
"p99_ms": 72.053,
|
||||
"p999_ms": 508.284,
|
||||
"max_ms": 508.284,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
359,
|
||||
306,
|
||||
104,
|
||||
47,
|
||||
29,
|
||||
31,
|
||||
16,
|
||||
18,
|
||||
10,
|
||||
9,
|
||||
10,
|
||||
6,
|
||||
2,
|
||||
3,
|
||||
0,
|
||||
1,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 495,
|
||||
"seconds": 12.082,
|
||||
"avg_fps": 40.97,
|
||||
"low_1pct_fps": 4.097,
|
||||
"low_01pct_fps": 2.261,
|
||||
"p50_ms": 11.995,
|
||||
"p95_ms": 80.625,
|
||||
"p99_ms": 146.758,
|
||||
"p999_ms": 442.316,
|
||||
"max_ms": 442.316,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
3,
|
||||
80,
|
||||
109,
|
||||
56,
|
||||
31,
|
||||
37,
|
||||
24,
|
||||
30,
|
||||
28,
|
||||
17,
|
||||
17,
|
||||
27,
|
||||
24,
|
||||
8,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 243,
|
||||
"seconds": 12.008,
|
||||
"avg_fps": 20.237,
|
||||
"low_1pct_fps": 2.827,
|
||||
"low_01pct_fps": 2.623,
|
||||
"p50_ms": 21.487,
|
||||
"p95_ms": 177.814,
|
||||
"p99_ms": 315.716,
|
||||
"p999_ms": 381.289,
|
||||
"max_ms": 381.289,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
5,
|
||||
20,
|
||||
26,
|
||||
24,
|
||||
32,
|
||||
25,
|
||||
21,
|
||||
8,
|
||||
19,
|
||||
13,
|
||||
18,
|
||||
15,
|
||||
10,
|
||||
7,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 431,
|
||||
"seconds": 12.153,
|
||||
"avg_fps": 35.466,
|
||||
"low_1pct_fps": 2.273,
|
||||
"low_01pct_fps": 1.246,
|
||||
"p50_ms": 12.232,
|
||||
"p95_ms": 106.955,
|
||||
"p99_ms": 249.209,
|
||||
"p999_ms": 802.438,
|
||||
"max_ms": 802.438,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
66,
|
||||
137,
|
||||
62,
|
||||
41,
|
||||
21,
|
||||
23,
|
||||
20,
|
||||
3,
|
||||
14,
|
||||
11,
|
||||
11,
|
||||
10,
|
||||
8,
|
||||
2,
|
||||
2,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 367,
|
||||
"seconds": 12.02,
|
||||
"avg_fps": 30.533,
|
||||
"low_1pct_fps": 3.94,
|
||||
"low_01pct_fps": 3.263,
|
||||
"p50_ms": 21.354,
|
||||
"p95_ms": 104.182,
|
||||
"p99_ms": 218.448,
|
||||
"p999_ms": 306.483,
|
||||
"max_ms": 306.483,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
52,
|
||||
38,
|
||||
32,
|
||||
44,
|
||||
53,
|
||||
47,
|
||||
20,
|
||||
21,
|
||||
19,
|
||||
16,
|
||||
15,
|
||||
4,
|
||||
2,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-m4pro-1080p"
|
||||
}
|
||||
@@ -0,0 +1,707 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-01T22:55:52Z",
|
||||
"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": 8,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": true,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 66.477,
|
||||
"window": [
|
||||
1920,
|
||||
930
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 49895.61,
|
||||
"stdev": 37830.08,
|
||||
"min": 27410.441,
|
||||
"max": 93571.631,
|
||||
"ci95": 93982.713
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 89080.61,
|
||||
"stdev": 49418.222,
|
||||
"min": 59243.441,
|
||||
"max": 146123.631,
|
||||
"ci95": 122771.577
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 29280.957,
|
||||
"stdev": 15620.91,
|
||||
"min": 18681.538,
|
||||
"max": 47219.974,
|
||||
"ci95": 38807.625
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 42.245,
|
||||
"stdev": 6.241,
|
||||
"min": 36.416,
|
||||
"max": 48.83,
|
||||
"ci95": 15.506
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 4.137,
|
||||
"stdev": 1.2,
|
||||
"min": 3.03,
|
||||
"max": 5.412,
|
||||
"ci95": 2.981
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 2.891,
|
||||
"stdev": 0.196,
|
||||
"min": 2.777,
|
||||
"max": 3.118,
|
||||
"ci95": 0.488
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 16212.425,
|
||||
"stdev": 1584.155,
|
||||
"min": 14800.702,
|
||||
"max": 17925.672,
|
||||
"ci95": 3935.576
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 66.392,
|
||||
"stdev": 8.735,
|
||||
"min": 57.65,
|
||||
"max": 75.119,
|
||||
"ci95": 21.699
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 8.327,
|
||||
"stdev": 3.862,
|
||||
"min": 6.012,
|
||||
"max": 12.786,
|
||||
"ci95": 9.595
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 6.154,
|
||||
"stdev": 3.399,
|
||||
"min": 3.873,
|
||||
"max": 10.06,
|
||||
"ci95": 8.444
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 9054.345,
|
||||
"stdev": 1795.805,
|
||||
"min": 7429.882,
|
||||
"max": 10982.71,
|
||||
"ci95": 4461.386
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 49.891,
|
||||
"stdev": 17.901,
|
||||
"min": 29.637,
|
||||
"max": 63.591,
|
||||
"ci95": 44.472
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 6.144,
|
||||
"stdev": 5.262,
|
||||
"min": 2.948,
|
||||
"max": 12.218,
|
||||
"ci95": 13.074
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 5.254,
|
||||
"stdev": 4.377,
|
||||
"min": 2.603,
|
||||
"max": 10.306,
|
||||
"ci95": 10.874
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 18171.415,
|
||||
"stdev": 5234.921,
|
||||
"min": 12170.39,
|
||||
"max": 21800.551,
|
||||
"ci95": 13005.314
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 45.9,
|
||||
"stdev": 14.757,
|
||||
"min": 29.055,
|
||||
"max": 56.549,
|
||||
"ci95": 36.661
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 4.83,
|
||||
"stdev": 2.815,
|
||||
"min": 2.104,
|
||||
"max": 7.727,
|
||||
"ci95": 6.994
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 3.986,
|
||||
"stdev": 2.385,
|
||||
"min": 1.597,
|
||||
"max": 6.366,
|
||||
"ci95": 5.924
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 1877.276,
|
||||
"stdev": 149.19,
|
||||
"min": 1724.094,
|
||||
"max": 2022.125,
|
||||
"ci95": 370.639
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1810.693,
|
||||
"stdev": 157.376,
|
||||
"min": 1671.984,
|
||||
"max": 1981.719,
|
||||
"ci95": 390.975
|
||||
},
|
||||
"memory.footprint_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2708.649,
|
||||
"stdev": 200.476,
|
||||
"min": 2538.472,
|
||||
"max": 2929.644,
|
||||
"ci95": 498.051
|
||||
},
|
||||
"memory.footprint_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 2635.498,
|
||||
"stdev": 241.221,
|
||||
"min": 2402.909,
|
||||
"max": 2884.511,
|
||||
"ci95": 599.274
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 586,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 48.83,
|
||||
"low_1pct_fps": 3.03,
|
||||
"low_01pct_fps": 2.779,
|
||||
"p50_ms": 6.298,
|
||||
"p95_ms": 102.59,
|
||||
"p99_ms": 300.171,
|
||||
"p999_ms": 359.883,
|
||||
"max_ms": 359.883,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
30,
|
||||
105,
|
||||
67,
|
||||
75,
|
||||
58,
|
||||
68,
|
||||
43,
|
||||
24,
|
||||
14,
|
||||
15,
|
||||
8,
|
||||
4,
|
||||
8,
|
||||
4,
|
||||
7,
|
||||
8,
|
||||
18,
|
||||
11,
|
||||
11,
|
||||
8,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 498,
|
||||
"seconds": 12.003,
|
||||
"avg_fps": 41.489,
|
||||
"low_1pct_fps": 5.412,
|
||||
"low_01pct_fps": 3.118,
|
||||
"p50_ms": 14.331,
|
||||
"p95_ms": 81.666,
|
||||
"p99_ms": 133.706,
|
||||
"p999_ms": 320.73,
|
||||
"max_ms": 320.73,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
5,
|
||||
15,
|
||||
23,
|
||||
21,
|
||||
37,
|
||||
43,
|
||||
41,
|
||||
55,
|
||||
45,
|
||||
50,
|
||||
45,
|
||||
30,
|
||||
15,
|
||||
18,
|
||||
18,
|
||||
21,
|
||||
13,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 437,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 36.416,
|
||||
"low_1pct_fps": 3.97,
|
||||
"low_01pct_fps": 2.777,
|
||||
"p50_ms": 12.279,
|
||||
"p95_ms": 133.385,
|
||||
"p99_ms": 203.287,
|
||||
"p999_ms": 360.113,
|
||||
"max_ms": 360.113,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
9,
|
||||
28,
|
||||
21,
|
||||
19,
|
||||
41,
|
||||
52,
|
||||
41,
|
||||
31,
|
||||
33,
|
||||
27,
|
||||
25,
|
||||
27,
|
||||
13,
|
||||
13,
|
||||
17,
|
||||
12,
|
||||
14,
|
||||
12,
|
||||
2,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 797,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 66.407,
|
||||
"low_1pct_fps": 6.184,
|
||||
"low_01pct_fps": 4.528,
|
||||
"p50_ms": 7.208,
|
||||
"p95_ms": 64.408,
|
||||
"p99_ms": 126.005,
|
||||
"p999_ms": 220.846,
|
||||
"max_ms": 220.846,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
22,
|
||||
78,
|
||||
137,
|
||||
123,
|
||||
136,
|
||||
77,
|
||||
58,
|
||||
31,
|
||||
14,
|
||||
13,
|
||||
14,
|
||||
13,
|
||||
10,
|
||||
11,
|
||||
21,
|
||||
20,
|
||||
13,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 692,
|
||||
"seconds": 12.003,
|
||||
"avg_fps": 57.65,
|
||||
"low_1pct_fps": 6.012,
|
||||
"low_01pct_fps": 3.873,
|
||||
"p50_ms": 10.783,
|
||||
"p95_ms": 48.101,
|
||||
"p99_ms": 121.963,
|
||||
"p999_ms": 258.167,
|
||||
"max_ms": 258.167,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
13,
|
||||
52,
|
||||
52,
|
||||
41,
|
||||
62,
|
||||
83,
|
||||
88,
|
||||
50,
|
||||
46,
|
||||
40,
|
||||
50,
|
||||
43,
|
||||
20,
|
||||
23,
|
||||
9,
|
||||
11,
|
||||
6,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 902,
|
||||
"seconds": 12.008,
|
||||
"avg_fps": 75.119,
|
||||
"low_1pct_fps": 12.786,
|
||||
"low_01pct_fps": 10.06,
|
||||
"p50_ms": 9.096,
|
||||
"p95_ms": 38.051,
|
||||
"p99_ms": 67.18,
|
||||
"p999_ms": 99.405,
|
||||
"max_ms": 99.405,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
26,
|
||||
115,
|
||||
72,
|
||||
65,
|
||||
100,
|
||||
118,
|
||||
90,
|
||||
66,
|
||||
51,
|
||||
54,
|
||||
50,
|
||||
39,
|
||||
16,
|
||||
17,
|
||||
13,
|
||||
10,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 687,
|
||||
"seconds": 12.171,
|
||||
"avg_fps": 56.446,
|
||||
"low_1pct_fps": 3.267,
|
||||
"low_01pct_fps": 2.853,
|
||||
"p50_ms": 4.938,
|
||||
"p95_ms": 103.639,
|
||||
"p99_ms": 257.27,
|
||||
"p999_ms": 350.504,
|
||||
"max_ms": 350.504,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
28,
|
||||
199,
|
||||
125,
|
||||
99,
|
||||
51,
|
||||
35,
|
||||
32,
|
||||
18,
|
||||
5,
|
||||
7,
|
||||
11,
|
||||
10,
|
||||
11,
|
||||
6,
|
||||
2,
|
||||
4,
|
||||
7,
|
||||
14,
|
||||
15,
|
||||
8,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 764,
|
||||
"seconds": 12.014,
|
||||
"avg_fps": 63.591,
|
||||
"low_1pct_fps": 12.218,
|
||||
"low_01pct_fps": 10.306,
|
||||
"p50_ms": 11.549,
|
||||
"p95_ms": 45.214,
|
||||
"p99_ms": 67.493,
|
||||
"p999_ms": 97.034,
|
||||
"max_ms": 97.034,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
9,
|
||||
45,
|
||||
41,
|
||||
59,
|
||||
58,
|
||||
87,
|
||||
101,
|
||||
71,
|
||||
85,
|
||||
42,
|
||||
56,
|
||||
49,
|
||||
15,
|
||||
16,
|
||||
22,
|
||||
8,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 357,
|
||||
"seconds": 12.046,
|
||||
"avg_fps": 29.637,
|
||||
"low_1pct_fps": 2.948,
|
||||
"low_01pct_fps": 2.603,
|
||||
"p50_ms": 10.947,
|
||||
"p95_ms": 173.526,
|
||||
"p99_ms": 311.134,
|
||||
"p999_ms": 384.135,
|
||||
"max_ms": 384.135,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
11,
|
||||
47,
|
||||
31,
|
||||
12,
|
||||
34,
|
||||
32,
|
||||
23,
|
||||
15,
|
||||
26,
|
||||
21,
|
||||
14,
|
||||
15,
|
||||
7,
|
||||
6,
|
||||
16,
|
||||
18,
|
||||
6,
|
||||
15,
|
||||
8,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 355,
|
||||
"seconds": 12.218,
|
||||
"avg_fps": 29.055,
|
||||
"low_1pct_fps": 2.104,
|
||||
"low_01pct_fps": 1.597,
|
||||
"p50_ms": 7.766,
|
||||
"p95_ms": 145.251,
|
||||
"p99_ms": 368.51,
|
||||
"p999_ms": 626.205,
|
||||
"max_ms": 626.205,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
41,
|
||||
53,
|
||||
25,
|
||||
28,
|
||||
15,
|
||||
22,
|
||||
7,
|
||||
16,
|
||||
10,
|
||||
15,
|
||||
17,
|
||||
12,
|
||||
15,
|
||||
11,
|
||||
11,
|
||||
8,
|
||||
14,
|
||||
19,
|
||||
7,
|
||||
7,
|
||||
2,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 680,
|
||||
"seconds": 12.025,
|
||||
"avg_fps": 56.549,
|
||||
"low_1pct_fps": 7.727,
|
||||
"low_01pct_fps": 6.366,
|
||||
"p50_ms": 11.633,
|
||||
"p95_ms": 52.508,
|
||||
"p99_ms": 111.848,
|
||||
"p999_ms": 157.09,
|
||||
"max_ms": 157.09,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
32,
|
||||
38,
|
||||
43,
|
||||
49,
|
||||
71,
|
||||
55,
|
||||
55,
|
||||
54,
|
||||
57,
|
||||
53,
|
||||
41,
|
||||
54,
|
||||
21,
|
||||
19,
|
||||
17,
|
||||
12,
|
||||
7,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 632,
|
||||
"seconds": 12.132,
|
||||
"avg_fps": 52.095,
|
||||
"low_1pct_fps": 4.66,
|
||||
"low_01pct_fps": 3.995,
|
||||
"p50_ms": 8.601,
|
||||
"p95_ms": 78.611,
|
||||
"p99_ms": 172.405,
|
||||
"p999_ms": 250.342,
|
||||
"max_ms": 250.342,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
9,
|
||||
75,
|
||||
86,
|
||||
53,
|
||||
32,
|
||||
57,
|
||||
40,
|
||||
41,
|
||||
30,
|
||||
38,
|
||||
25,
|
||||
24,
|
||||
39,
|
||||
16,
|
||||
23,
|
||||
7,
|
||||
12,
|
||||
15,
|
||||
9,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-m4pro-1080p"
|
||||
}
|
||||
@@ -0,0 +1,723 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-01T22:55:52Z",
|
||||
"app": {
|
||||
"name": "minecraft",
|
||||
"version": "26.3",
|
||||
"mods": [
|
||||
{
|
||||
"name": "fabric-loader",
|
||||
"version": "0.19.5"
|
||||
},
|
||||
{
|
||||
"name": "sodium",
|
||||
"version": "mc26.3-0.9.2-fabric"
|
||||
},
|
||||
{
|
||||
"name": "lithium",
|
||||
"version": "mc26.3-0.26.2-fabric"
|
||||
},
|
||||
{
|
||||
"name": "ferrite-core",
|
||||
"version": "9.0.0-fabric"
|
||||
},
|
||||
{
|
||||
"name": "immediatelyfast",
|
||||
"version": "1.17.1+26.3-fabric"
|
||||
}
|
||||
]
|
||||
},
|
||||
"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": 16,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": true,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 69.878,
|
||||
"window": [
|
||||
1920,
|
||||
930
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 23843.039,
|
||||
"stdev": 7524.513,
|
||||
"min": 16643.766,
|
||||
"max": 31655.294,
|
||||
"ci95": 18693.436
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 76908.706,
|
||||
"stdev": 36719.804,
|
||||
"min": 45051.766,
|
||||
"max": 117069.294,
|
||||
"ci95": 91224.412
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 19608.485,
|
||||
"stdev": 9449.035,
|
||||
"min": 13510.149,
|
||||
"max": 30492.958,
|
||||
"ci95": 23474.6
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 46.159,
|
||||
"stdev": 17.954,
|
||||
"min": 31.413,
|
||||
"max": 66.152,
|
||||
"ci95": 44.604
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 6.544,
|
||||
"stdev": 3.452,
|
||||
"min": 3.225,
|
||||
"max": 10.116,
|
||||
"ci95": 8.577
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 4.689,
|
||||
"stdev": 1.808,
|
||||
"min": 2.647,
|
||||
"max": 6.085,
|
||||
"ci95": 4.491
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 23344.685,
|
||||
"stdev": 5493.145,
|
||||
"min": 17038.709,
|
||||
"max": 27089.821,
|
||||
"ci95": 13646.83
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 61.892,
|
||||
"stdev": 37.939,
|
||||
"min": 22.354,
|
||||
"max": 98.0,
|
||||
"ci95": 94.254
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 9.736,
|
||||
"stdev": 8.597,
|
||||
"min": 2.296,
|
||||
"max": 19.148,
|
||||
"ci95": 21.359
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 6.651,
|
||||
"stdev": 5.574,
|
||||
"min": 2.202,
|
||||
"max": 12.903,
|
||||
"ci95": 13.847
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 27267.052,
|
||||
"stdev": 21587.34,
|
||||
"min": 10640.097,
|
||||
"max": 51663.797,
|
||||
"ci95": 53630.253
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 75.493,
|
||||
"stdev": 63.885,
|
||||
"min": 25.975,
|
||||
"max": 147.604,
|
||||
"ci95": 158.711
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 13.396,
|
||||
"stdev": 13.366,
|
||||
"min": 4.634,
|
||||
"max": 28.78,
|
||||
"ci95": 33.205
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 7.356,
|
||||
"stdev": 6.609,
|
||||
"min": 3.26,
|
||||
"max": 14.98,
|
||||
"ci95": 16.418
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 38284.867,
|
||||
"stdev": 4237.4,
|
||||
"min": 33922.931,
|
||||
"max": 42385.65,
|
||||
"ci95": 10527.134
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 35.918,
|
||||
"stdev": 17.742,
|
||||
"min": 18.135,
|
||||
"max": 53.619,
|
||||
"ci95": 44.077
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 3.999,
|
||||
"stdev": 2.132,
|
||||
"min": 2.453,
|
||||
"max": 6.431,
|
||||
"ci95": 5.297
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 3.017,
|
||||
"stdev": 1.831,
|
||||
"min": 1.775,
|
||||
"max": 5.12,
|
||||
"ci95": 4.549
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2566.339,
|
||||
"stdev": 218.228,
|
||||
"min": 2339.094,
|
||||
"max": 2774.266,
|
||||
"ci95": 542.153
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 2354.852,
|
||||
"stdev": 240.105,
|
||||
"min": 2082.984,
|
||||
"max": 2537.867,
|
||||
"ci95": 596.503
|
||||
},
|
||||
"memory.footprint_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 3430.598,
|
||||
"stdev": 77.063,
|
||||
"min": 3345.395,
|
||||
"max": 3495.426,
|
||||
"ci95": 191.451
|
||||
},
|
||||
"memory.footprint_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 3211.931,
|
||||
"stdev": 99.984,
|
||||
"min": 3124.098,
|
||||
"max": 3320.738,
|
||||
"ci95": 248.394
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 377,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 31.413,
|
||||
"low_1pct_fps": 3.225,
|
||||
"low_01pct_fps": 2.647,
|
||||
"p50_ms": 15.145,
|
||||
"p95_ms": 128.716,
|
||||
"p99_ms": 276.685,
|
||||
"p999_ms": 377.749,
|
||||
"max_ms": 377.749,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
17,
|
||||
26,
|
||||
21,
|
||||
30,
|
||||
23,
|
||||
43,
|
||||
16,
|
||||
25,
|
||||
28,
|
||||
28,
|
||||
35,
|
||||
16,
|
||||
14,
|
||||
12,
|
||||
15,
|
||||
13,
|
||||
11,
|
||||
4,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 794,
|
||||
"seconds": 12.003,
|
||||
"avg_fps": 66.152,
|
||||
"low_1pct_fps": 10.116,
|
||||
"low_01pct_fps": 6.085,
|
||||
"p50_ms": 11.137,
|
||||
"p95_ms": 41.078,
|
||||
"p99_ms": 73.531,
|
||||
"p999_ms": 164.347,
|
||||
"max_ms": 164.347,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
20,
|
||||
57,
|
||||
65,
|
||||
52,
|
||||
70,
|
||||
82,
|
||||
88,
|
||||
80,
|
||||
67,
|
||||
53,
|
||||
48,
|
||||
52,
|
||||
18,
|
||||
20,
|
||||
10,
|
||||
10,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 491,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 40.912,
|
||||
"low_1pct_fps": 6.291,
|
||||
"low_01pct_fps": 5.336,
|
||||
"p50_ms": 15.962,
|
||||
"p95_ms": 71.604,
|
||||
"p99_ms": 137.056,
|
||||
"p999_ms": 187.401,
|
||||
"max_ms": 187.401,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
5,
|
||||
10,
|
||||
32,
|
||||
54,
|
||||
60,
|
||||
45,
|
||||
49,
|
||||
44,
|
||||
49,
|
||||
50,
|
||||
22,
|
||||
21,
|
||||
20,
|
||||
18,
|
||||
8,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 784,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 65.322,
|
||||
"low_1pct_fps": 7.763,
|
||||
"low_01pct_fps": 4.848,
|
||||
"p50_ms": 10.024,
|
||||
"p95_ms": 43.46,
|
||||
"p99_ms": 94.19,
|
||||
"p999_ms": 206.286,
|
||||
"max_ms": 206.286,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
17,
|
||||
40,
|
||||
69,
|
||||
80,
|
||||
95,
|
||||
88,
|
||||
82,
|
||||
53,
|
||||
58,
|
||||
39,
|
||||
49,
|
||||
52,
|
||||
21,
|
||||
15,
|
||||
14,
|
||||
7,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 1176,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 98.0,
|
||||
"low_1pct_fps": 19.148,
|
||||
"low_01pct_fps": 12.903,
|
||||
"p50_ms": 6.214,
|
||||
"p95_ms": 30.125,
|
||||
"p99_ms": 41.199,
|
||||
"p999_ms": 73.675,
|
||||
"max_ms": 81.328,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
3,
|
||||
179,
|
||||
205,
|
||||
175,
|
||||
115,
|
||||
111,
|
||||
79,
|
||||
39,
|
||||
21,
|
||||
25,
|
||||
34,
|
||||
79,
|
||||
77,
|
||||
20,
|
||||
10,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 271,
|
||||
"seconds": 12.123,
|
||||
"avg_fps": 22.354,
|
||||
"low_1pct_fps": 2.296,
|
||||
"low_01pct_fps": 2.202,
|
||||
"p50_ms": 14.989,
|
||||
"p95_ms": 214.282,
|
||||
"p99_ms": 404.5,
|
||||
"p999_ms": 454.134,
|
||||
"max_ms": 454.134,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
5,
|
||||
18,
|
||||
19,
|
||||
19,
|
||||
28,
|
||||
28,
|
||||
13,
|
||||
19,
|
||||
20,
|
||||
12,
|
||||
21,
|
||||
14,
|
||||
5,
|
||||
8,
|
||||
4,
|
||||
9,
|
||||
18,
|
||||
10,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 1772,
|
||||
"seconds": 12.005,
|
||||
"avg_fps": 147.604,
|
||||
"low_1pct_fps": 28.78,
|
||||
"low_01pct_fps": 14.98,
|
||||
"p50_ms": 5.328,
|
||||
"p95_ms": 15.261,
|
||||
"p99_ms": 25.961,
|
||||
"p999_ms": 58.586,
|
||||
"max_ms": 74.925,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
34,
|
||||
421,
|
||||
331,
|
||||
290,
|
||||
175,
|
||||
163,
|
||||
122,
|
||||
83,
|
||||
44,
|
||||
33,
|
||||
26,
|
||||
28,
|
||||
16,
|
||||
2,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 635,
|
||||
"seconds": 12.004,
|
||||
"avg_fps": 52.9,
|
||||
"low_1pct_fps": 6.774,
|
||||
"low_01pct_fps": 3.828,
|
||||
"p50_ms": 9.578,
|
||||
"p95_ms": 61.195,
|
||||
"p99_ms": 112.118,
|
||||
"p999_ms": 261.228,
|
||||
"max_ms": 261.228,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
38,
|
||||
49,
|
||||
64,
|
||||
63,
|
||||
63,
|
||||
51,
|
||||
60,
|
||||
27,
|
||||
29,
|
||||
27,
|
||||
17,
|
||||
37,
|
||||
28,
|
||||
30,
|
||||
27,
|
||||
15,
|
||||
8,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 312,
|
||||
"seconds": 12.011,
|
||||
"avg_fps": 25.975,
|
||||
"low_1pct_fps": 4.634,
|
||||
"low_01pct_fps": 3.26,
|
||||
"p50_ms": 24.629,
|
||||
"p95_ms": 115.493,
|
||||
"p99_ms": 169.827,
|
||||
"p999_ms": 306.737,
|
||||
"max_ms": 306.737,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
5,
|
||||
10,
|
||||
10,
|
||||
24,
|
||||
39,
|
||||
33,
|
||||
38,
|
||||
47,
|
||||
16,
|
||||
16,
|
||||
21,
|
||||
23,
|
||||
22,
|
||||
5,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 218,
|
||||
"seconds": 12.021,
|
||||
"avg_fps": 18.135,
|
||||
"low_1pct_fps": 2.453,
|
||||
"low_01pct_fps": 2.156,
|
||||
"p50_ms": 17.447,
|
||||
"p95_ms": 257.129,
|
||||
"p99_ms": 377.63,
|
||||
"p999_ms": 463.802,
|
||||
"max_ms": 463.802,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
7,
|
||||
13,
|
||||
15,
|
||||
18,
|
||||
11,
|
||||
9,
|
||||
7,
|
||||
16,
|
||||
8,
|
||||
3,
|
||||
11,
|
||||
10,
|
||||
14,
|
||||
7,
|
||||
5,
|
||||
16,
|
||||
8,
|
||||
15,
|
||||
12,
|
||||
13,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 653,
|
||||
"seconds": 12.179,
|
||||
"avg_fps": 53.619,
|
||||
"low_1pct_fps": 6.431,
|
||||
"low_01pct_fps": 5.12,
|
||||
"p50_ms": 8.427,
|
||||
"p95_ms": 58.968,
|
||||
"p99_ms": 141.423,
|
||||
"p999_ms": 195.302,
|
||||
"max_ms": 195.302,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
38,
|
||||
66,
|
||||
76,
|
||||
79,
|
||||
64,
|
||||
45,
|
||||
38,
|
||||
20,
|
||||
29,
|
||||
22,
|
||||
24,
|
||||
46,
|
||||
25,
|
||||
36,
|
||||
17,
|
||||
14,
|
||||
10,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 432,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 35.999,
|
||||
"low_1pct_fps": 3.112,
|
||||
"low_01pct_fps": 1.775,
|
||||
"p50_ms": 16.724,
|
||||
"p95_ms": 83.13,
|
||||
"p99_ms": 220.496,
|
||||
"p999_ms": 563.493,
|
||||
"max_ms": 563.493,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
7,
|
||||
14,
|
||||
17,
|
||||
23,
|
||||
35,
|
||||
35,
|
||||
26,
|
||||
24,
|
||||
33,
|
||||
34,
|
||||
43,
|
||||
43,
|
||||
23,
|
||||
27,
|
||||
20,
|
||||
15,
|
||||
5,
|
||||
4,
|
||||
3,
|
||||
1,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-mods-m4pro-1080p"
|
||||
}
|
||||
@@ -0,0 +1,723 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-01T23:17:23Z",
|
||||
"app": {
|
||||
"name": "minecraft",
|
||||
"version": "26.3",
|
||||
"mods": [
|
||||
{
|
||||
"name": "fabric-loader",
|
||||
"version": "0.19.5"
|
||||
},
|
||||
{
|
||||
"name": "sodium",
|
||||
"version": "mc26.3-0.9.2-fabric"
|
||||
},
|
||||
{
|
||||
"name": "lithium",
|
||||
"version": "mc26.3-0.26.2-fabric"
|
||||
},
|
||||
{
|
||||
"name": "ferrite-core",
|
||||
"version": "9.0.0-fabric"
|
||||
},
|
||||
{
|
||||
"name": "immediatelyfast",
|
||||
"version": "1.17.1+26.3-fabric"
|
||||
}
|
||||
]
|
||||
},
|
||||
"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": true,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 37.185,
|
||||
"window": [
|
||||
1920,
|
||||
930
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 13521.661,
|
||||
"stdev": 573.908,
|
||||
"min": 13085.942,
|
||||
"max": 14171.935,
|
||||
"ci95": 1425.783
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 63229.994,
|
||||
"stdev": 6185.388,
|
||||
"min": 56237.942,
|
||||
"max": 67987.935,
|
||||
"ci95": 15366.595
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 14807.746,
|
||||
"stdev": 2020.358,
|
||||
"min": 13414.414,
|
||||
"max": 17124.849,
|
||||
"ci95": 5019.253
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 91.753,
|
||||
"stdev": 11.452,
|
||||
"min": 78.555,
|
||||
"max": 99.067,
|
||||
"ci95": 28.451
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 13.105,
|
||||
"stdev": 2.514,
|
||||
"min": 10.354,
|
||||
"max": 15.282,
|
||||
"ci95": 6.244
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 9.203,
|
||||
"stdev": 1.382,
|
||||
"min": 7.689,
|
||||
"max": 10.396,
|
||||
"ci95": 3.432
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 24180.167,
|
||||
"stdev": 777.711,
|
||||
"min": 23290.973,
|
||||
"max": 24733.555,
|
||||
"ci95": 1932.098
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 113.871,
|
||||
"stdev": 53.305,
|
||||
"min": 56.542,
|
||||
"max": 161.938,
|
||||
"ci95": 132.428
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 28.748,
|
||||
"stdev": 17.633,
|
||||
"min": 12.823,
|
||||
"max": 47.698,
|
||||
"ci95": 43.807
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 20.551,
|
||||
"stdev": 13.547,
|
||||
"min": 8.937,
|
||||
"max": 35.433,
|
||||
"ci95": 33.655
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 19314.547,
|
||||
"stdev": 5853.258,
|
||||
"min": 15068.272,
|
||||
"max": 25991.528,
|
||||
"ci95": 14541.473
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 119.116,
|
||||
"stdev": 71.027,
|
||||
"min": 45.498,
|
||||
"max": 187.231,
|
||||
"ci95": 176.454
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 31.721,
|
||||
"stdev": 26.624,
|
||||
"min": 8.585,
|
||||
"max": 60.822,
|
||||
"ci95": 66.144
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 16.8,
|
||||
"stdev": 14.937,
|
||||
"min": 5.876,
|
||||
"max": 33.821,
|
||||
"ci95": 37.109
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 61290.415,
|
||||
"stdev": 15063.343,
|
||||
"min": 49453.256,
|
||||
"max": 78246.003,
|
||||
"ci95": 37422.439
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 56.905,
|
||||
"stdev": 19.674,
|
||||
"min": 37.807,
|
||||
"max": 77.109,
|
||||
"ci95": 48.878
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 7.384,
|
||||
"stdev": 0.62,
|
||||
"min": 6.67,
|
||||
"max": 7.784,
|
||||
"ci95": 1.539
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 3.48,
|
||||
"stdev": 1.306,
|
||||
"min": 2.526,
|
||||
"max": 4.968,
|
||||
"ci95": 3.244
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 4078.01,
|
||||
"stdev": 79.593,
|
||||
"min": 4020.391,
|
||||
"max": 4168.828,
|
||||
"ci95": 197.735
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 3705.104,
|
||||
"stdev": 149.415,
|
||||
"min": 3541.375,
|
||||
"max": 3834.078,
|
||||
"ci95": 371.196
|
||||
},
|
||||
"memory.footprint_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 5250.386,
|
||||
"stdev": 54.229,
|
||||
"min": 5200.662,
|
||||
"max": 5308.208,
|
||||
"ci95": 134.723
|
||||
},
|
||||
"memory.footprint_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 4647.172,
|
||||
"stdev": 29.179,
|
||||
"min": 4613.552,
|
||||
"max": 4665.911,
|
||||
"ci95": 72.492
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 1172,
|
||||
"seconds": 12.004,
|
||||
"avg_fps": 97.637,
|
||||
"low_1pct_fps": 15.282,
|
||||
"low_01pct_fps": 10.396,
|
||||
"p50_ms": 7.394,
|
||||
"p95_ms": 26.126,
|
||||
"p99_ms": 50.505,
|
||||
"p999_ms": 79.614,
|
||||
"max_ms": 112.764,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
24,
|
||||
111,
|
||||
198,
|
||||
193,
|
||||
170,
|
||||
129,
|
||||
109,
|
||||
59,
|
||||
42,
|
||||
43,
|
||||
23,
|
||||
38,
|
||||
10,
|
||||
10,
|
||||
7,
|
||||
5,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 1189,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 99.067,
|
||||
"low_1pct_fps": 13.678,
|
||||
"low_01pct_fps": 9.523,
|
||||
"p50_ms": 7.061,
|
||||
"p95_ms": 28.029,
|
||||
"p99_ms": 58.166,
|
||||
"p999_ms": 103.326,
|
||||
"max_ms": 106.682,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
59,
|
||||
132,
|
||||
223,
|
||||
172,
|
||||
171,
|
||||
107,
|
||||
90,
|
||||
59,
|
||||
39,
|
||||
36,
|
||||
28,
|
||||
35,
|
||||
14,
|
||||
9,
|
||||
9,
|
||||
4,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 943,
|
||||
"seconds": 12.004,
|
||||
"avg_fps": 78.555,
|
||||
"low_1pct_fps": 10.354,
|
||||
"low_01pct_fps": 7.689,
|
||||
"p50_ms": 8.817,
|
||||
"p95_ms": 31.62,
|
||||
"p99_ms": 70.58,
|
||||
"p999_ms": 130.049,
|
||||
"max_ms": 130.049,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
8,
|
||||
34,
|
||||
97,
|
||||
118,
|
||||
168,
|
||||
126,
|
||||
106,
|
||||
69,
|
||||
58,
|
||||
49,
|
||||
36,
|
||||
32,
|
||||
8,
|
||||
9,
|
||||
13,
|
||||
8,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 679,
|
||||
"seconds": 12.009,
|
||||
"avg_fps": 56.542,
|
||||
"low_1pct_fps": 12.823,
|
||||
"low_01pct_fps": 8.937,
|
||||
"p50_ms": 14.344,
|
||||
"p95_ms": 42.09,
|
||||
"p99_ms": 65.756,
|
||||
"p999_ms": 111.897,
|
||||
"max_ms": 111.897,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
33,
|
||||
50,
|
||||
62,
|
||||
68,
|
||||
60,
|
||||
48,
|
||||
67,
|
||||
84,
|
||||
76,
|
||||
64,
|
||||
27,
|
||||
18,
|
||||
14,
|
||||
5,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 1478,
|
||||
"seconds": 12.003,
|
||||
"avg_fps": 123.133,
|
||||
"low_1pct_fps": 25.723,
|
||||
"low_01pct_fps": 17.282,
|
||||
"p50_ms": 6.388,
|
||||
"p95_ms": 17.972,
|
||||
"p99_ms": 29.029,
|
||||
"p999_ms": 57.659,
|
||||
"max_ms": 58.069,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
3,
|
||||
336,
|
||||
298,
|
||||
222,
|
||||
186,
|
||||
144,
|
||||
77,
|
||||
70,
|
||||
57,
|
||||
39,
|
||||
23,
|
||||
13,
|
||||
5,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 1944,
|
||||
"seconds": 12.005,
|
||||
"avg_fps": 161.938,
|
||||
"low_1pct_fps": 47.698,
|
||||
"low_01pct_fps": 35.433,
|
||||
"p50_ms": 5.168,
|
||||
"p95_ms": 12.12,
|
||||
"p99_ms": 17.337,
|
||||
"p999_ms": 26.48,
|
||||
"max_ms": 29.965,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
18,
|
||||
793,
|
||||
527,
|
||||
243,
|
||||
125,
|
||||
91,
|
||||
47,
|
||||
41,
|
||||
34,
|
||||
15,
|
||||
7,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 548,
|
||||
"seconds": 12.044,
|
||||
"avg_fps": 45.498,
|
||||
"low_1pct_fps": 8.585,
|
||||
"low_01pct_fps": 5.876,
|
||||
"p50_ms": 15.416,
|
||||
"p95_ms": 62.523,
|
||||
"p99_ms": 98.757,
|
||||
"p999_ms": 170.17,
|
||||
"max_ms": 170.17,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
15,
|
||||
26,
|
||||
52,
|
||||
53,
|
||||
58,
|
||||
37,
|
||||
56,
|
||||
39,
|
||||
58,
|
||||
60,
|
||||
27,
|
||||
24,
|
||||
18,
|
||||
21,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 1496,
|
||||
"seconds": 12.005,
|
||||
"avg_fps": 124.619,
|
||||
"low_1pct_fps": 25.756,
|
||||
"low_01pct_fps": 10.702,
|
||||
"p50_ms": 6.649,
|
||||
"p95_ms": 15.751,
|
||||
"p99_ms": 26.208,
|
||||
"p999_ms": 76.546,
|
||||
"max_ms": 110.33,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
15,
|
||||
250,
|
||||
299,
|
||||
266,
|
||||
216,
|
||||
163,
|
||||
115,
|
||||
68,
|
||||
40,
|
||||
32,
|
||||
13,
|
||||
14,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 2247,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 187.231,
|
||||
"low_1pct_fps": 60.822,
|
||||
"low_01pct_fps": 33.821,
|
||||
"p50_ms": 4.73,
|
||||
"p95_ms": 8.497,
|
||||
"p99_ms": 12.315,
|
||||
"p999_ms": 21.724,
|
||||
"max_ms": 42.26,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
347,
|
||||
987,
|
||||
453,
|
||||
211,
|
||||
122,
|
||||
67,
|
||||
32,
|
||||
15,
|
||||
6,
|
||||
4,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 670,
|
||||
"seconds": 12.007,
|
||||
"avg_fps": 55.799,
|
||||
"low_1pct_fps": 7.784,
|
||||
"low_01pct_fps": 2.526,
|
||||
"p50_ms": 12.887,
|
||||
"p95_ms": 45.129,
|
||||
"p99_ms": 76.315,
|
||||
"p999_ms": 395.821,
|
||||
"max_ms": 395.821,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
16,
|
||||
54,
|
||||
79,
|
||||
75,
|
||||
81,
|
||||
68,
|
||||
60,
|
||||
53,
|
||||
70,
|
||||
54,
|
||||
17,
|
||||
16,
|
||||
9,
|
||||
17,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 455,
|
||||
"seconds": 12.035,
|
||||
"avg_fps": 37.807,
|
||||
"low_1pct_fps": 6.67,
|
||||
"low_01pct_fps": 4.968,
|
||||
"p50_ms": 15.986,
|
||||
"p95_ms": 82.012,
|
||||
"p99_ms": 117.987,
|
||||
"p999_ms": 201.279,
|
||||
"max_ms": 201.279,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
3,
|
||||
27,
|
||||
37,
|
||||
47,
|
||||
49,
|
||||
37,
|
||||
39,
|
||||
31,
|
||||
39,
|
||||
34,
|
||||
20,
|
||||
24,
|
||||
30,
|
||||
26,
|
||||
10,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 933,
|
||||
"seconds": 12.1,
|
||||
"avg_fps": 77.109,
|
||||
"low_1pct_fps": 7.697,
|
||||
"low_01pct_fps": 2.946,
|
||||
"p50_ms": 8.593,
|
||||
"p95_ms": 34.696,
|
||||
"p99_ms": 79.266,
|
||||
"p999_ms": 339.486,
|
||||
"max_ms": 339.486,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
45,
|
||||
176,
|
||||
209,
|
||||
196,
|
||||
95,
|
||||
45,
|
||||
43,
|
||||
26,
|
||||
19,
|
||||
30,
|
||||
9,
|
||||
9,
|
||||
14,
|
||||
13,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-mods-m4pro-1080p"
|
||||
}
|
||||
@@ -0,0 +1,723 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-01T22:55:52Z",
|
||||
"app": {
|
||||
"name": "minecraft",
|
||||
"version": "26.3",
|
||||
"mods": [
|
||||
{
|
||||
"name": "fabric-loader",
|
||||
"version": "0.19.5"
|
||||
},
|
||||
{
|
||||
"name": "sodium",
|
||||
"version": "mc26.3-0.9.2-fabric"
|
||||
},
|
||||
{
|
||||
"name": "lithium",
|
||||
"version": "mc26.3-0.26.2-fabric"
|
||||
},
|
||||
{
|
||||
"name": "ferrite-core",
|
||||
"version": "9.0.0-fabric"
|
||||
},
|
||||
{
|
||||
"name": "immediatelyfast",
|
||||
"version": "1.17.1+26.3-fabric"
|
||||
}
|
||||
]
|
||||
},
|
||||
"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": 8,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": true,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 74.321,
|
||||
"window": [
|
||||
1920,
|
||||
930
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 56311.461,
|
||||
"stdev": 26982.658,
|
||||
"min": 36031.835,
|
||||
"max": 86935.815,
|
||||
"ci95": 67034.048
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 97731.794,
|
||||
"stdev": 21691.925,
|
||||
"min": 72951.835,
|
||||
"max": 113284.815,
|
||||
"ci95": 53890.078
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 13954.335,
|
||||
"stdev": 10146.301,
|
||||
"min": 7679.006,
|
||||
"max": 25660.115,
|
||||
"ci95": 25206.843
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 33.239,
|
||||
"stdev": 18.526,
|
||||
"min": 12.03,
|
||||
"max": 46.264,
|
||||
"ci95": 46.026
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 3.772,
|
||||
"stdev": 2.399,
|
||||
"min": 1.734,
|
||||
"max": 6.416,
|
||||
"ci95": 5.96
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 2.866,
|
||||
"stdev": 1.762,
|
||||
"min": 1.433,
|
||||
"max": 4.834,
|
||||
"ci95": 4.379
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 19803.961,
|
||||
"stdev": 14910.114,
|
||||
"min": 8220.752,
|
||||
"max": 36626.588,
|
||||
"ci95": 37041.766
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 47.303,
|
||||
"stdev": 17.044,
|
||||
"min": 32.698,
|
||||
"max": 66.03,
|
||||
"ci95": 42.343
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 4.365,
|
||||
"stdev": 0.709,
|
||||
"min": 3.658,
|
||||
"max": 5.076,
|
||||
"ci95": 1.761
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 3.128,
|
||||
"stdev": 0.564,
|
||||
"min": 2.651,
|
||||
"max": 3.751,
|
||||
"ci95": 1.402
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 18639.188,
|
||||
"stdev": 8912.103,
|
||||
"min": 11960.198,
|
||||
"max": 28758.72,
|
||||
"ci95": 22140.678
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 34.908,
|
||||
"stdev": 6.372,
|
||||
"min": 29.271,
|
||||
"max": 41.822,
|
||||
"ci95": 15.831
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 4.614,
|
||||
"stdev": 1.558,
|
||||
"min": 2.986,
|
||||
"max": 6.09,
|
||||
"ci95": 3.869
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 4.06,
|
||||
"stdev": 1.662,
|
||||
"min": 2.224,
|
||||
"max": 5.463,
|
||||
"ci95": 4.13
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 19191.558,
|
||||
"stdev": 2902.338,
|
||||
"min": 15845.729,
|
||||
"max": 21030.7,
|
||||
"ci95": 7210.388
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 67.004,
|
||||
"stdev": 13.923,
|
||||
"min": 51.98,
|
||||
"max": 79.471,
|
||||
"ci95": 34.589
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 7.862,
|
||||
"stdev": 1.987,
|
||||
"min": 6.339,
|
||||
"max": 10.109,
|
||||
"ci95": 4.936
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 3.993,
|
||||
"stdev": 1.679,
|
||||
"min": 2.06,
|
||||
"max": 5.088,
|
||||
"ci95": 4.172
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2019.089,
|
||||
"stdev": 53.398,
|
||||
"min": 1960.188,
|
||||
"max": 2064.328,
|
||||
"ci95": 132.657
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1927.195,
|
||||
"stdev": 108.077,
|
||||
"min": 1813.609,
|
||||
"max": 2028.758,
|
||||
"ci95": 268.5
|
||||
},
|
||||
"memory.footprint_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2440.727,
|
||||
"stdev": 72.508,
|
||||
"min": 2375.394,
|
||||
"max": 2518.738,
|
||||
"ci95": 180.134
|
||||
},
|
||||
"memory.footprint_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 2395.53,
|
||||
"stdev": 71.597,
|
||||
"min": 2318.504,
|
||||
"max": 2460.05,
|
||||
"ci95": 177.872
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 146,
|
||||
"seconds": 12.136,
|
||||
"avg_fps": 12.03,
|
||||
"low_1pct_fps": 1.734,
|
||||
"low_01pct_fps": 1.433,
|
||||
"p50_ms": 28.465,
|
||||
"p95_ms": 339.625,
|
||||
"p99_ms": 455.663,
|
||||
"p999_ms": 697.859,
|
||||
"max_ms": 697.859,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
4,
|
||||
9,
|
||||
9,
|
||||
5,
|
||||
5,
|
||||
8,
|
||||
6,
|
||||
5,
|
||||
5,
|
||||
1,
|
||||
10,
|
||||
9,
|
||||
6,
|
||||
11,
|
||||
10,
|
||||
5,
|
||||
8,
|
||||
15,
|
||||
13,
|
||||
1,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 556,
|
||||
"seconds": 12.018,
|
||||
"avg_fps": 46.264,
|
||||
"low_1pct_fps": 6.416,
|
||||
"low_01pct_fps": 4.834,
|
||||
"p50_ms": 13.72,
|
||||
"p95_ms": 71.774,
|
||||
"p99_ms": 120.078,
|
||||
"p999_ms": 206.87,
|
||||
"max_ms": 206.87,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
6,
|
||||
5,
|
||||
16,
|
||||
22,
|
||||
57,
|
||||
55,
|
||||
64,
|
||||
62,
|
||||
62,
|
||||
50,
|
||||
38,
|
||||
29,
|
||||
19,
|
||||
23,
|
||||
16,
|
||||
21,
|
||||
9,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 498,
|
||||
"seconds": 12.022,
|
||||
"avg_fps": 41.423,
|
||||
"low_1pct_fps": 3.166,
|
||||
"low_01pct_fps": 2.331,
|
||||
"p50_ms": 7.7,
|
||||
"p95_ms": 114.025,
|
||||
"p99_ms": 246.232,
|
||||
"p999_ms": 428.98,
|
||||
"max_ms": 428.98,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
15,
|
||||
63,
|
||||
55,
|
||||
58,
|
||||
33,
|
||||
38,
|
||||
31,
|
||||
22,
|
||||
23,
|
||||
17,
|
||||
24,
|
||||
24,
|
||||
20,
|
||||
11,
|
||||
9,
|
||||
10,
|
||||
14,
|
||||
15,
|
||||
12,
|
||||
4,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 521,
|
||||
"seconds": 12.065,
|
||||
"avg_fps": 43.182,
|
||||
"low_1pct_fps": 4.362,
|
||||
"low_01pct_fps": 3.751,
|
||||
"p50_ms": 8.121,
|
||||
"p95_ms": 110.621,
|
||||
"p99_ms": 191.925,
|
||||
"p999_ms": 266.575,
|
||||
"max_ms": 266.575,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
63,
|
||||
27,
|
||||
49,
|
||||
61,
|
||||
67,
|
||||
39,
|
||||
45,
|
||||
27,
|
||||
17,
|
||||
13,
|
||||
17,
|
||||
7,
|
||||
11,
|
||||
13,
|
||||
11,
|
||||
18,
|
||||
22,
|
||||
11,
|
||||
2,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 793,
|
||||
"seconds": 12.01,
|
||||
"avg_fps": 66.03,
|
||||
"low_1pct_fps": 5.076,
|
||||
"low_01pct_fps": 2.651,
|
||||
"p50_ms": 8.218,
|
||||
"p95_ms": 44.767,
|
||||
"p99_ms": 143.139,
|
||||
"p999_ms": 377.207,
|
||||
"max_ms": 377.207,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
60,
|
||||
106,
|
||||
55,
|
||||
58,
|
||||
127,
|
||||
97,
|
||||
64,
|
||||
41,
|
||||
28,
|
||||
32,
|
||||
31,
|
||||
39,
|
||||
12,
|
||||
7,
|
||||
8,
|
||||
14,
|
||||
9,
|
||||
4,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 395,
|
||||
"seconds": 12.08,
|
||||
"avg_fps": 32.698,
|
||||
"low_1pct_fps": 3.658,
|
||||
"low_01pct_fps": 2.983,
|
||||
"p50_ms": 10.283,
|
||||
"p95_ms": 171.818,
|
||||
"p99_ms": 231.998,
|
||||
"p999_ms": 335.242,
|
||||
"max_ms": 335.242,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
6,
|
||||
19,
|
||||
13,
|
||||
27,
|
||||
42,
|
||||
31,
|
||||
53,
|
||||
36,
|
||||
22,
|
||||
25,
|
||||
20,
|
||||
19,
|
||||
10,
|
||||
8,
|
||||
7,
|
||||
6,
|
||||
11,
|
||||
15,
|
||||
23,
|
||||
2,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 405,
|
||||
"seconds": 12.043,
|
||||
"avg_fps": 33.63,
|
||||
"low_1pct_fps": 4.765,
|
||||
"low_01pct_fps": 4.492,
|
||||
"p50_ms": 14.686,
|
||||
"p95_ms": 115.893,
|
||||
"p99_ms": 196.822,
|
||||
"p999_ms": 222.615,
|
||||
"max_ms": 222.615,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
5,
|
||||
6,
|
||||
16,
|
||||
18,
|
||||
49,
|
||||
42,
|
||||
36,
|
||||
22,
|
||||
30,
|
||||
35,
|
||||
32,
|
||||
23,
|
||||
10,
|
||||
10,
|
||||
22,
|
||||
22,
|
||||
16,
|
||||
11,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 502,
|
||||
"seconds": 12.003,
|
||||
"avg_fps": 41.822,
|
||||
"low_1pct_fps": 6.09,
|
||||
"low_01pct_fps": 5.463,
|
||||
"p50_ms": 15.264,
|
||||
"p95_ms": 76.822,
|
||||
"p99_ms": 144.7,
|
||||
"p999_ms": 183.045,
|
||||
"max_ms": 183.045,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
9,
|
||||
14,
|
||||
12,
|
||||
13,
|
||||
47,
|
||||
43,
|
||||
52,
|
||||
41,
|
||||
40,
|
||||
52,
|
||||
40,
|
||||
50,
|
||||
15,
|
||||
26,
|
||||
17,
|
||||
17,
|
||||
9,
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 353,
|
||||
"seconds": 12.06,
|
||||
"avg_fps": 29.271,
|
||||
"low_1pct_fps": 2.986,
|
||||
"low_01pct_fps": 2.224,
|
||||
"p50_ms": 10.806,
|
||||
"p95_ms": 150.281,
|
||||
"p99_ms": 268.607,
|
||||
"p999_ms": 449.56,
|
||||
"max_ms": 449.56,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
29,
|
||||
16,
|
||||
24,
|
||||
36,
|
||||
35,
|
||||
23,
|
||||
31,
|
||||
13,
|
||||
16,
|
||||
12,
|
||||
18,
|
||||
14,
|
||||
9,
|
||||
8,
|
||||
16,
|
||||
18,
|
||||
17,
|
||||
12,
|
||||
6,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 624,
|
||||
"seconds": 12.005,
|
||||
"avg_fps": 51.98,
|
||||
"low_1pct_fps": 7.137,
|
||||
"low_01pct_fps": 5.088,
|
||||
"p50_ms": 12.033,
|
||||
"p95_ms": 75.22,
|
||||
"p99_ms": 115.345,
|
||||
"p999_ms": 196.553,
|
||||
"max_ms": 196.553,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
29,
|
||||
48,
|
||||
34,
|
||||
38,
|
||||
40,
|
||||
61,
|
||||
56,
|
||||
50,
|
||||
57,
|
||||
54,
|
||||
41,
|
||||
33,
|
||||
13,
|
||||
15,
|
||||
15,
|
||||
28,
|
||||
6,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 835,
|
||||
"seconds": 12.004,
|
||||
"avg_fps": 69.562,
|
||||
"low_1pct_fps": 6.339,
|
||||
"low_01pct_fps": 2.06,
|
||||
"p50_ms": 9.818,
|
||||
"p95_ms": 37.917,
|
||||
"p99_ms": 81.491,
|
||||
"p999_ms": 485.481,
|
||||
"max_ms": 485.481,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
6,
|
||||
62,
|
||||
63,
|
||||
59,
|
||||
75,
|
||||
84,
|
||||
75,
|
||||
94,
|
||||
59,
|
||||
77,
|
||||
55,
|
||||
31,
|
||||
36,
|
||||
27,
|
||||
10,
|
||||
10,
|
||||
5,
|
||||
5,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 954,
|
||||
"seconds": 12.004,
|
||||
"avg_fps": 79.471,
|
||||
"low_1pct_fps": 10.109,
|
||||
"low_01pct_fps": 4.832,
|
||||
"p50_ms": 7.91,
|
||||
"p95_ms": 38.672,
|
||||
"p99_ms": 79.104,
|
||||
"p999_ms": 206.935,
|
||||
"max_ms": 206.935,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
13,
|
||||
55,
|
||||
70,
|
||||
136,
|
||||
119,
|
||||
109,
|
||||
93,
|
||||
98,
|
||||
52,
|
||||
49,
|
||||
48,
|
||||
21,
|
||||
30,
|
||||
17,
|
||||
14,
|
||||
11,
|
||||
17,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-mods-m4pro-1080p"
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use crate::{
|
||||
json::Result,
|
||||
math::{Mat4, quaternion_xyz},
|
||||
model::{Direction, DisplayTransform, GuiLight, ItemTransform, Material},
|
||||
resolve::ModelLibrary,
|
||||
resolve::{ModelLibrary, ResolvedCuboidModel},
|
||||
};
|
||||
use std::{collections::BTreeSet, f32::consts::PI};
|
||||
|
||||
@@ -110,6 +110,15 @@ pub fn bake_item_model(
|
||||
let model = library
|
||||
.resolve(name)
|
||||
.or_else(|_| library.resolve(crate::resolve::MISSING_MODEL))?;
|
||||
bake_resolved(&model, sprites)
|
||||
}
|
||||
|
||||
/// Bake an already resolved model, e.g. one from
|
||||
/// [`ModelLibrary::resolve_with_textures`].
|
||||
pub fn bake_resolved(
|
||||
model: &ResolvedCuboidModel,
|
||||
sprites: &dyn SpriteLookup,
|
||||
) -> Result<BakedItemModel> {
|
||||
let known = |sprite: &str| sprite == MISSING_SPRITE || sprites.mask(sprite).is_some();
|
||||
let material = |m: &Material| {
|
||||
if known(&m.sprite) {
|
||||
@@ -143,7 +152,7 @@ pub fn bake_item_model(
|
||||
quads
|
||||
} else {
|
||||
let mut quads =
|
||||
bake_block_model(&model, crate::bake::BlockModelTransform::new([0; 3], false))?;
|
||||
bake_block_model(model, crate::bake::BlockModelTransform::new([0; 3], false))?;
|
||||
for q in &mut quads {
|
||||
q.material = material(&q.material);
|
||||
}
|
||||
@@ -298,3 +307,87 @@ fn extrude_layer(mask: &SpriteMask, material: &Material, layer: i32) -> Result<V
|
||||
}
|
||||
Ok(quads)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::{CuboidModel, TextureSlot};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// The stick: 2x2 with one transparent pixel. `item/cit`: one opaque pixel, so a bake
|
||||
/// shows whose mask it extruded.
|
||||
struct Sprites;
|
||||
impl SpriteLookup for Sprites {
|
||||
fn mask(&self, sprite: &str) -> Option<SpriteMask> {
|
||||
match sprite {
|
||||
"minecraft:item/stick" => Some(SpriteMask {
|
||||
width: 2,
|
||||
height: 2,
|
||||
frames: vec![vec![true, false, false, false]],
|
||||
}),
|
||||
"horizon_cit:item/cit" => Some(SpriteMask {
|
||||
width: 1,
|
||||
height: 1,
|
||||
frames: vec![vec![false]],
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
fn library() -> ModelLibrary {
|
||||
ModelLibrary::with_item_generator(BTreeMap::from(
|
||||
[
|
||||
("item/generated", r#"{"parent":"builtin/generated","display":{"gui":{"scale":[2,2,2]}}}"#),
|
||||
("item/stick", r#"{"parent":"item/generated","textures":{"layer0":"item/stick"}}"#),
|
||||
("horizon:loop", r#"{"parent":"horizon:loop2"}"#),
|
||||
("horizon:loop2", r#"{"parent":"horizon:loop"}"#),
|
||||
("block/slab", r##"{"textures":{"particle":"#side","side":"block/absent"},"elements":[{"from":[0,0,0],"to":[16,8,16],"faces":{"up":{"texture":"#side"},"down":{"texture":"#side","cullface":"down"}}}]}"##),
|
||||
]
|
||||
.map(|(name, json)| (name.to_owned(), CuboidModel::from_json(json).unwrap())),
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn baking_by_name_falls_back_to_the_missing_model() {
|
||||
let library = library();
|
||||
// Front, back, and the 8 unit edges around the L of 3 opaque pixels.
|
||||
let stick = bake_item_model(&library, &Sprites, "item/stick").unwrap();
|
||||
assert_eq!(stick.quads.len(), 10);
|
||||
// An unknown sprite bakes as the missing sprite.
|
||||
let slab = bake_item_model(&library, &Sprites, "block/slab").unwrap();
|
||||
assert_eq!(slab.quads.len(), 2);
|
||||
assert_eq!(slab.particle.sprite, MISSING_SPRITE);
|
||||
// A cyclic chain, an error to `resolve`, bakes as the missing cube.
|
||||
let cyclic = bake_item_model(&library, &Sprites, "horizon:loop").unwrap();
|
||||
assert_eq!(cyclic.quads.len(), 6);
|
||||
assert!(
|
||||
cyclic
|
||||
.quads
|
||||
.iter()
|
||||
.all(|q| q.material.sprite == MISSING_SPRITE)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn replaced_layer_bakes_with_its_own_sprite() {
|
||||
let library = library();
|
||||
let child = BTreeMap::from([(
|
||||
"layer0".to_owned(),
|
||||
TextureSlot::Material(Material {
|
||||
sprite: "horizon_cit:item/cit".to_owned(),
|
||||
force_translucent: false,
|
||||
}),
|
||||
)]);
|
||||
let model = library.resolve_with_textures("item/stick", &child).unwrap();
|
||||
let baked = bake_resolved(&model, &Sprites).unwrap();
|
||||
// The CIT sprite's own mask, one opaque pixel: front, back and 4 edges.
|
||||
assert_eq!(baked.quads.len(), 6);
|
||||
assert!(
|
||||
baked
|
||||
.quads
|
||||
.iter()
|
||||
.all(|q| q.material.sprite == "horizon_cit:item/cit")
|
||||
);
|
||||
assert_eq!(baked.particle.sprite, "horizon_cit:item/cit");
|
||||
assert_eq!(baked.transforms[5].as_ref().unwrap().scale, [2.0; 3]);
|
||||
}
|
||||
}
|
||||
@@ -102,10 +102,74 @@ impl ModelLibrary {
|
||||
missing,
|
||||
})
|
||||
}
|
||||
/// This library plus `extra` models; an `extra` model replaces a
|
||||
/// same-named one, but not the built-in missing and generated models.
|
||||
/// Every existing model is shared, not copied.
|
||||
pub fn overlaid(&self, extra: BTreeMap<String, CuboidModel>) -> Result<Self> {
|
||||
let mut models = self.models.clone();
|
||||
let mut added = BTreeSet::new();
|
||||
for (name, model) in extra {
|
||||
let name = identifier(&name)?;
|
||||
if !added.insert(name.clone()) {
|
||||
return Err(error("duplicate normalized model identifier"));
|
||||
}
|
||||
// The built-ins win, as `from_models` keeps `missing` and
|
||||
// `with_item_generator` keeps `generated`.
|
||||
if name != MISSING_MODEL && name != GENERATED_MODEL {
|
||||
models.insert(name, Arc::new(model));
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
models,
|
||||
missing: Arc::clone(&self.missing),
|
||||
})
|
||||
}
|
||||
/// Follow the complete parent chain. A missing resource uses the built-in
|
||||
/// model; a cyclic chain is rejected, as native discovery excludes it.
|
||||
/// The returned model can outlive this library without copying its elements.
|
||||
pub fn resolve(&self, name: &str) -> Result<ResolvedCuboidModel> {
|
||||
self.resolve_chain(name, None)
|
||||
}
|
||||
/// Whether `name` and every parent it reaches are models of this library,
|
||||
/// with no cycle: `resolve` puts the missing model in for any other.
|
||||
pub fn resolves(&self, name: &str) -> bool {
|
||||
let Ok(mut name) = identifier(name) else {
|
||||
return false;
|
||||
};
|
||||
let mut visited = BTreeSet::new();
|
||||
loop {
|
||||
let Some(model) = self.models.get(&name) else {
|
||||
return false;
|
||||
};
|
||||
if !visited.insert(name.clone()) {
|
||||
return false;
|
||||
}
|
||||
match &model.parent {
|
||||
Some(parent) => name = parent.clone(),
|
||||
None => return true,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `name`'s resolved model with a virtual child in front of it that has
|
||||
/// only `textures`: the child's slots win, and `#references` anywhere in
|
||||
/// the chain re-bind through them (so `builtin/generated`'s
|
||||
/// `particle: #layer0` follows a replaced `layer0`). The child's slots are
|
||||
/// taken as given: sprites namespaced, references without their `#`, as
|
||||
/// the JSON decoder leaves them. A cyclic chain is an
|
||||
/// error, as for `resolve`: a caller wanting the missing model falls
|
||||
/// back to it.
|
||||
pub fn resolve_with_textures(
|
||||
&self,
|
||||
name: &str,
|
||||
child: &BTreeMap<String, TextureSlot>,
|
||||
) -> Result<ResolvedCuboidModel> {
|
||||
self.resolve_chain(name, Some(child))
|
||||
}
|
||||
fn resolve_chain(
|
||||
&self,
|
||||
name: &str,
|
||||
child: Option<&BTreeMap<String, TextureSlot>>,
|
||||
) -> Result<ResolvedCuboidModel> {
|
||||
let mut name = identifier(name)?;
|
||||
let mut visited = BTreeSet::new();
|
||||
let mut chain = Vec::new();
|
||||
@@ -156,6 +220,9 @@ impl ModelLibrary {
|
||||
for model in chain.into_iter().rev() {
|
||||
slots.extend(model.textures.iter().map(|(k, v)| (k.clone(), v)));
|
||||
}
|
||||
if let Some(child) = child {
|
||||
slots.extend(child.iter().map(|(k, v)| (k.clone(), v)));
|
||||
}
|
||||
let mut textures = BTreeMap::<String, Material>::new();
|
||||
let mut unresolved_textures = BTreeSet::<String>::new();
|
||||
for start in slots.keys() {
|
||||
@@ -274,6 +341,195 @@ mod tests {
|
||||
}
|
||||
);
|
||||
}
|
||||
fn library(models: &[(&str, &str)]) -> ModelLibrary {
|
||||
ModelLibrary::with_item_generator(
|
||||
models
|
||||
.iter()
|
||||
.map(|(name, json)| (name.to_string(), CuboidModel::from_json(json).unwrap()))
|
||||
.collect(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
const GENERATED: &str = r#"{"parent":"builtin/generated","display":{"ground":{"translation":[0,2,0],"scale":[0.5,0.5,0.5]}}}"#;
|
||||
const CIT: &str = "horizon_cit:minecraft/optifine/cit/x";
|
||||
#[test]
|
||||
fn child_textures_replace_slots_and_rebind_the_generated_particle() {
|
||||
let library = library(&[
|
||||
("item/generated", GENERATED),
|
||||
(
|
||||
"item/stick",
|
||||
r#"{"parent":"item/generated","textures":{"layer0":"item/stick"}}"#,
|
||||
),
|
||||
]);
|
||||
let child = BTreeMap::from([(
|
||||
"layer0".to_owned(),
|
||||
TextureSlot::Material(Material {
|
||||
sprite: CIT.to_owned(),
|
||||
force_translucent: false,
|
||||
}),
|
||||
)]);
|
||||
let model = library.resolve_with_textures("item/stick", &child).unwrap();
|
||||
assert!(model.uses_item_generator());
|
||||
assert_eq!(model.material("layer0").unwrap().unwrap().sprite, CIT);
|
||||
assert_eq!(model.material("particle").unwrap().unwrap().sprite, CIT);
|
||||
assert_eq!(
|
||||
model.display.contexts[6],
|
||||
DisplayTransform::Explicit(Some(ItemTransform {
|
||||
rotation: [0.0; 3],
|
||||
translation: [0.0, 0.125, 0.0],
|
||||
scale: [0.5; 3],
|
||||
}))
|
||||
);
|
||||
// The library itself is untouched.
|
||||
let plain = library.resolve("item/stick").unwrap();
|
||||
assert_eq!(
|
||||
plain.material("particle").unwrap().unwrap().sprite,
|
||||
"minecraft:item/stick"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn overlaid_models_inherit_from_the_library_and_share_the_rest() {
|
||||
let base = library(&[
|
||||
("item/generated", GENERATED),
|
||||
(
|
||||
"item/handheld",
|
||||
r#"{"parent":"item/generated","display":{"thirdperson_righthand":{"rotation":[0,-90,55]}}}"#,
|
||||
),
|
||||
(
|
||||
"item/stick",
|
||||
r#"{"parent":"item/generated","textures":{"layer0":"item/stick"}}"#,
|
||||
),
|
||||
(
|
||||
"item/apple",
|
||||
r#"{"parent":"item/generated","textures":{"layer0":"item/apple"}}"#,
|
||||
),
|
||||
]);
|
||||
let cit = format!(r#"{{"parent":"item/handheld","textures":{{"layer0":"{CIT}"}}}}"#);
|
||||
let over = base
|
||||
.overlaid(BTreeMap::from([
|
||||
(CIT.to_owned(), CuboidModel::from_json(&cit).unwrap()),
|
||||
(
|
||||
"item/stick".to_owned(),
|
||||
CuboidModel::from_json(
|
||||
r#"{"parent":"item/generated","textures":{"layer0":"item/bone"}}"#,
|
||||
)
|
||||
.unwrap(),
|
||||
),
|
||||
]))
|
||||
.unwrap();
|
||||
let model = over.resolve(CIT).unwrap();
|
||||
assert!(model.uses_item_generator());
|
||||
assert_eq!(model.material("particle").unwrap().unwrap().sprite, CIT);
|
||||
let DisplayTransform::Explicit(Some(right)) = &model.display.contexts[1] else {
|
||||
panic!("handheld's right-hand transform is inherited");
|
||||
};
|
||||
assert_eq!(right.rotation, [0.0, -90.0, 55.0]);
|
||||
let sprite = |l: &ModelLibrary| {
|
||||
l.resolve("item/stick").unwrap().textures["layer0"]
|
||||
.sprite
|
||||
.clone()
|
||||
};
|
||||
assert_eq!(sprite(&over), "minecraft:item/bone");
|
||||
assert_eq!(sprite(&base), "minecraft:item/stick");
|
||||
assert!(!base.resolve(CIT).unwrap().uses_item_generator());
|
||||
assert!(Arc::ptr_eq(
|
||||
&base.models["minecraft:item/apple"],
|
||||
&over.models["minecraft:item/apple"]
|
||||
));
|
||||
}
|
||||
fn material(sprite: &str) -> TextureSlot {
|
||||
TextureSlot::Material(Material {
|
||||
sprite: sprite.to_owned(),
|
||||
force_translucent: false,
|
||||
})
|
||||
}
|
||||
#[test]
|
||||
fn child_slots_take_part_in_reference_chains() {
|
||||
let library = library(&[
|
||||
(
|
||||
"item/two",
|
||||
r##"{"textures":{"layer0":"#tex","layer1":"item/b","tex":"item/a"}}"##,
|
||||
),
|
||||
("horizon:loop", r#"{"parent":"horizon:loop2"}"#),
|
||||
("horizon:loop2", r#"{"parent":"horizon:loop"}"#),
|
||||
]);
|
||||
let child = BTreeMap::from([
|
||||
// A base alias binds through a slot only the child sets...
|
||||
("tex".to_owned(), material(CIT)),
|
||||
// ...and a child alias through the base's slots.
|
||||
(
|
||||
"particle".to_owned(),
|
||||
TextureSlot::Reference("layer1".to_owned()),
|
||||
),
|
||||
// A child alias to itself resolves to nothing, without looping.
|
||||
("self".to_owned(), TextureSlot::Reference("self".to_owned())),
|
||||
]);
|
||||
let model = library.resolve_with_textures("item/two", &child).unwrap();
|
||||
assert_eq!(model.material("layer0").unwrap().unwrap().sprite, CIT);
|
||||
assert_eq!(
|
||||
model.material("particle").unwrap().unwrap().sprite,
|
||||
"minecraft:item/b"
|
||||
);
|
||||
assert_eq!(model.material("self").unwrap(), None);
|
||||
assert!(model.unresolved_textures.contains("self"));
|
||||
// An absent base is the missing cube under the child; a cyclic one an error.
|
||||
let absent = library
|
||||
.resolve_with_textures("item/absent", &child)
|
||||
.unwrap();
|
||||
assert_eq!(absent.elements().len(), 1);
|
||||
assert!(
|
||||
library
|
||||
.resolve_with_textures("horizon:loop", &child)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn an_overlay_replaces_parents_but_not_the_built_ins_and_refuses_twins() {
|
||||
let base = library(&[
|
||||
("item/generated", GENERATED),
|
||||
("item/handheld", r#"{"parent":"item/generated"}"#),
|
||||
(
|
||||
"item/sword",
|
||||
r#"{"parent":"item/handheld","textures":{"layer0":"item/sword"}}"#,
|
||||
),
|
||||
]);
|
||||
let model = |json: &str| CuboidModel::from_json(json).unwrap();
|
||||
let over = base
|
||||
.overlaid(BTreeMap::from([
|
||||
(
|
||||
"item/handheld".to_owned(),
|
||||
model(
|
||||
r#"{"parent":"item/generated","display":{"head":{"rotation":[0,45,0]}}}"#,
|
||||
),
|
||||
),
|
||||
// Neither built-in can be replaced.
|
||||
("builtin/missing".to_owned(), model(r#"{"elements":[]}"#)),
|
||||
("builtin/generated".to_owned(), model("{}")),
|
||||
]))
|
||||
.unwrap();
|
||||
// The base's sword sees the overlay's parent; the base library does not.
|
||||
let head = |l: &ModelLibrary| l.resolve("item/sword").unwrap().display.contexts[4].clone();
|
||||
let DisplayTransform::Explicit(Some(rotated)) = head(&over) else {
|
||||
panic!("the replaced parent's head transform");
|
||||
};
|
||||
assert_eq!(rotated.rotation, [0.0, 45.0, 0.0]);
|
||||
assert_ne!(head(&base), head(&over));
|
||||
// The generator kept: its `particle: #layer0`, which `{}` would drop.
|
||||
let sword = over.resolve("item/sword").unwrap();
|
||||
assert_eq!(
|
||||
sword.material("particle").unwrap().unwrap().sprite,
|
||||
"minecraft:item/sword"
|
||||
);
|
||||
// The missing cube kept, looked up by its own name, not as a fallback.
|
||||
let missing = over.resolve("builtin/missing").unwrap();
|
||||
assert_eq!(missing.elements().len(), 1);
|
||||
// Two spellings of one id.
|
||||
let twins = BTreeMap::from([
|
||||
("item/x".to_owned(), model("{}")),
|
||||
("minecraft:item/x".to_owned(), model("{}")),
|
||||
]);
|
||||
assert!(base.overlaid(twins).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn absent_parents_fall_back_but_cycles_invalidate_descendants() {
|
||||
let a = CuboidModel::from_json(r#"{"parent":"horizon:b"}"#).unwrap();
|
||||
@@ -293,4 +549,23 @@ mod tests {
|
||||
"minecraft:missingno"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn a_model_resolves_only_with_its_whole_parent_chain() {
|
||||
let library = library(&[
|
||||
("item/generated", GENERATED),
|
||||
(
|
||||
"item/stick",
|
||||
r#"{"parent":"item/generated","textures":{"layer0":"item/stick"}}"#,
|
||||
),
|
||||
("item/orphan", r#"{"parent":"item/gone"}"#),
|
||||
("item/a", r#"{"parent":"item/b"}"#),
|
||||
("item/b", r#"{"parent":"item/a"}"#),
|
||||
]);
|
||||
assert!(library.resolves("item/stick"));
|
||||
assert!(library.resolves("minecraft:item/stick"));
|
||||
assert!(!library.resolves("item/absent"));
|
||||
assert!(!library.resolves("item/orphan"));
|
||||
assert!(!library.resolves("item/a"));
|
||||
assert!(!library.resolves("Item/Stick"));
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
//! from `pack.mcmeta` `language` sections, and a process-wide current
|
||||
//! language used by every screen.
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::{BTreeMap, HashMap},
|
||||
sync::{
|
||||
Arc, RwLock,
|
||||
@@ -34,6 +35,15 @@ pub struct Language {
|
||||
pub const DEFAULT: &str = "en_us";
|
||||
|
||||
static CURRENT: RwLock<Option<Arc<Language>>> = RwLock::new(None);
|
||||
/// A language that only the calling thread sees, and the keys it was asked for and did not have
|
||||
/// (`scoped`).
|
||||
struct Scope {
|
||||
language: Arc<Language>,
|
||||
missed: Vec<String>,
|
||||
}
|
||||
thread_local! {
|
||||
static SCOPE: RefCell<Option<Scope>> = const { RefCell::new(None) };
|
||||
}
|
||||
/// The current language's `bidirectional`, read for every drawn string.
|
||||
static BIDIRECTIONAL: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
@@ -254,8 +264,50 @@ pub fn set(language: Language) {
|
||||
*CURRENT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(language));
|
||||
}
|
||||
pub fn current() -> Option<Arc<Language>> {
|
||||
if let Some(language) = SCOPE.with(|s| s.borrow().as_ref().map(|s| s.language.clone())) {
|
||||
return Some(language);
|
||||
}
|
||||
CURRENT.read().unwrap_or_else(|e| e.into_inner()).clone()
|
||||
}
|
||||
/// Runs `f` with `language` as the current language of this thread only, and returns what it
|
||||
/// returned with the keys `get` and `get_or` looked up and `language` did not have, in the order
|
||||
/// asked and without repeats. Nothing is set process-wide, so a test can draw a screen in another
|
||||
/// language while the tests beside it read theirs. Scopes nest: the inner one wins, and the outer
|
||||
/// one's misses do not include the inner one's. For tests: `bidirectional` stays the process-wide
|
||||
/// language's.
|
||||
pub fn scoped<R>(language: Language, f: impl FnOnce() -> R) -> (R, Vec<String>) {
|
||||
/// Puts the outer scope back, also when `f` panics.
|
||||
struct Restore(Option<Scope>);
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
SCOPE.with(|s| *s.borrow_mut() = self.0.take());
|
||||
}
|
||||
}
|
||||
let outer = SCOPE.with(|s| {
|
||||
s.borrow_mut().replace(Scope {
|
||||
language: Arc::new(language),
|
||||
missed: Vec::new(),
|
||||
})
|
||||
});
|
||||
let _restore = Restore(outer);
|
||||
let result = f();
|
||||
let missed = SCOPE.with(|s| {
|
||||
s.borrow_mut()
|
||||
.as_mut()
|
||||
.map(|s| std::mem::take(&mut s.missed))
|
||||
});
|
||||
(result, missed.unwrap_or_default())
|
||||
}
|
||||
/// `key` was asked for in a `scoped` language that has no text for it.
|
||||
fn missed(key: &str) {
|
||||
SCOPE.with(|s| {
|
||||
if let Some(scope) = s.borrow_mut().as_mut()
|
||||
&& !scope.missed.iter().any(|m| m == key)
|
||||
{
|
||||
scope.missed.push(key.to_owned());
|
||||
}
|
||||
});
|
||||
}
|
||||
/// `ClientLanguage.defaultRightToLeft` of the current language.
|
||||
pub fn bidirectional() -> bool {
|
||||
BIDIRECTIONAL.load(Ordering::Relaxed)
|
||||
@@ -267,13 +319,19 @@ pub fn code() -> String {
|
||||
pub fn get(key: &str) -> String {
|
||||
current()
|
||||
.and_then(|l| l.get(key).map(str::to_owned))
|
||||
.unwrap_or_else(|| key.to_owned())
|
||||
.unwrap_or_else(|| {
|
||||
missed(key);
|
||||
key.to_owned()
|
||||
})
|
||||
}
|
||||
/// The translation, or `fallback` when the key is missing.
|
||||
pub fn get_or(key: &str, fallback: &str) -> String {
|
||||
current()
|
||||
.and_then(|l| l.get(key).map(str::to_owned))
|
||||
.unwrap_or_else(|| fallback.to_owned())
|
||||
.unwrap_or_else(|| {
|
||||
missed(key);
|
||||
fallback.to_owned()
|
||||
})
|
||||
}
|
||||
pub fn has(key: &str) -> bool {
|
||||
current().is_some_and(|l| l.get(key).is_some())
|
||||
@@ -350,6 +408,52 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scoped_language_is_the_calling_threads_and_reports_what_it_lacks() {
|
||||
let empty = Mem(HashMap::new());
|
||||
let outside = code();
|
||||
let ((german, english_only, fallback, elsewhere), missed) =
|
||||
scoped(Language::load(&[&empty], "de_de"), || {
|
||||
(
|
||||
get("horizon.osk.close"),
|
||||
get("horizon.no_such_key"),
|
||||
get_or("horizon.no_fallback", "English"),
|
||||
// Another thread keeps the process-wide language.
|
||||
std::thread::spawn(code).join().unwrap(),
|
||||
)
|
||||
});
|
||||
assert_eq!(german, "Schließen");
|
||||
assert_eq!(english_only, "horizon.no_such_key");
|
||||
assert_eq!(fallback, "English");
|
||||
assert_eq!(elsewhere, outside);
|
||||
assert_eq!(missed, ["horizon.no_such_key", "horizon.no_fallback"]);
|
||||
// The scope ends with its closure.
|
||||
assert_eq!(code(), outside);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scopes_nest_and_end_when_their_closure_panics() {
|
||||
let empty = Mem(HashMap::new());
|
||||
let outside = code();
|
||||
let (inner, missed) = scoped(Language::load(&[&empty], "fr_fr"), || {
|
||||
let (inner, inner_missed) = scoped(Language::load(&[&empty], "pl_pl"), || {
|
||||
(code(), get("horizon.inner_miss"))
|
||||
});
|
||||
assert_eq!(inner_missed, ["horizon.inner_miss"]);
|
||||
assert_eq!(code(), "fr_fr");
|
||||
inner.0
|
||||
});
|
||||
assert_eq!(inner, "pl_pl");
|
||||
assert!(missed.is_empty());
|
||||
let panicked = std::panic::catch_unwind(|| {
|
||||
scoped(Language::load(&[&Mem(HashMap::new())], "de_de"), || {
|
||||
panic!("a test failed inside a scope")
|
||||
})
|
||||
});
|
||||
assert!(panicked.is_err());
|
||||
assert_eq!(code(), outside);
|
||||
}
|
||||
|
||||
/// `crates/horizon-client/assets/horizon/lang`, where Horizon's own strings live.
|
||||
fn bundled_dir() -> std::path::PathBuf {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
|
||||
@@ -213,6 +213,48 @@ impl Registries {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Only what [`Self::fill_from_export`] takes from an export, to keep
|
||||
/// in place of the export itself (a third of its memory).
|
||||
pub fn from_export(r: &serde_json::Value) -> Self {
|
||||
let mut out = Self::default();
|
||||
out.fill_from_export(r);
|
||||
out
|
||||
}
|
||||
/// [`Self::fill_from_export`] from an export already read with
|
||||
/// [`Self::from_export`]: each field the game left empty, each registry
|
||||
/// it did not list.
|
||||
pub fn fill_from_exported(&mut self, e: &Self) {
|
||||
let fill = |field: &mut Vec<String>, from: &Vec<String>| {
|
||||
if field.is_empty() {
|
||||
field.clone_from(from);
|
||||
}
|
||||
};
|
||||
fill(&mut self.effects, &e.effects);
|
||||
fill(&mut self.enchantments, &e.enchantments);
|
||||
fill(&mut self.attributes, &e.attributes);
|
||||
fill(&mut self.damage_types, &e.damage_types);
|
||||
fill(&mut self.templates, &e.templates);
|
||||
fill(&mut self.persistent_components, &e.persistent_components);
|
||||
fill(&mut self.learnable_recipes, &e.learnable_recipes);
|
||||
for (name, ids) in &e.by_registry {
|
||||
if !self.by_registry.contains_key(name) {
|
||||
self.by_registry.insert(name.clone(), ids.clone());
|
||||
}
|
||||
}
|
||||
if self.block_entities.is_empty() {
|
||||
self.block_entities.clone_from(&e.block_entities);
|
||||
}
|
||||
if self.block_tag_members.is_empty() {
|
||||
self.block_tag_members.clone_from(&e.block_tag_members);
|
||||
}
|
||||
if self.clock_time_markers.is_empty() {
|
||||
self.clock_time_markers.clone_from(&e.clock_time_markers);
|
||||
}
|
||||
if self.advancement_criteria.is_empty() {
|
||||
self.advancement_criteria
|
||||
.clone_from(&e.advancement_criteria);
|
||||
}
|
||||
}
|
||||
/// The element ids of a registry (sorted).
|
||||
pub fn ids(&self, registry: &str) -> &[String] {
|
||||
if let Some(r) = self.by_registry.get(registry) {
|
||||
|
||||
@@ -51,6 +51,10 @@ impl InteractionInput {
|
||||
pub fn press(&mut self, button: Button) {
|
||||
self.clicks[button as usize] += 1;
|
||||
}
|
||||
/// Whether a click waits for the next tick.
|
||||
pub fn has_clicks(&self) -> bool {
|
||||
self.clicks.iter().any(|&c| c > 0)
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, world: &mut impl InteractionWorld, attack_down: bool, use_down: bool) {
|
||||
let [attack, use_item, pick] = std::mem::take(&mut self.clicks);
|
||||
|
||||
@@ -207,6 +207,20 @@ impl ToggleKeys {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// `KeyMapping.resetToggleKeys` (a new `LocalPlayer`): a sneak and a sprint toggled on in one
|
||||
/// world are off in the next.
|
||||
#[test]
|
||||
fn resetting_the_toggle_keys_puts_every_mapping_up() {
|
||||
let mut keys = ToggleKeys::default();
|
||||
keys.key(0, KeyAction::Press, false, true);
|
||||
keys.key(1, KeyAction::Press, false, true);
|
||||
assert!(keys.is_down(0) && keys.is_down(1));
|
||||
for key in &mut keys.0 {
|
||||
key.reset();
|
||||
}
|
||||
assert!(!keys.is_down(0) && !keys.is_down(1));
|
||||
}
|
||||
|
||||
use super::*;
|
||||
#[test]
|
||||
fn toggle_flips_on_press_and_ignores_release() {
|
||||
|
||||
@@ -73,4 +73,4 @@ workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
horizon-items = { path = "../horizon-items", features = ["test-support"] }
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "glsl-out", "msl-out", "spv-out"] }
|
||||
@@ -93,6 +93,8 @@ pub struct RenderAssets {
|
||||
pub model_gallery: Vec<Value>,
|
||||
pub animations: Vec<crate::sprites::SpriteAnimation>,
|
||||
pub sprite_layers: std::collections::BTreeMap<String, u32>,
|
||||
/// OptiFine's emissive block overlays: base sprite and overlay sprite names.
|
||||
pub emissive: Vec<(String, String)>,
|
||||
pub translucent_layers: Vec<bool>,
|
||||
/// Each layer's `MipmapGenerator` settings.
|
||||
pub sprite_mips: Vec<crate::mipmap::Settings>,
|
||||
@@ -242,6 +244,7 @@ impl RenderAssets {
|
||||
for stage in 0..10 {
|
||||
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
|
||||
}
|
||||
let mut emissive = Vec::new();
|
||||
let connected = if draws {
|
||||
crate::native_emissive::load(root, &mut sprites);
|
||||
// A side's sprite, for rules that connect by tile: the first quad facing it.
|
||||
@@ -270,6 +273,11 @@ impl RenderAssets {
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
// After the connected tiles: a tile identical to a block shares its layer, and the
|
||||
// overlays skip shared layers.
|
||||
if draws {
|
||||
emissive = crate::native_emissive::load_optifine(root, &mut sprites);
|
||||
}
|
||||
sprites.save_disk_cache();
|
||||
if draws {
|
||||
sprites.finish();
|
||||
@@ -289,6 +297,7 @@ impl RenderAssets {
|
||||
model_gallery: models.states,
|
||||
animations: sprites.animations,
|
||||
sprite_layers: sprites.layers,
|
||||
emissive,
|
||||
translucent_layers: sprites.translucent,
|
||||
sprite_mips: sprites.mips,
|
||||
pbr_normals: sprites.normals,
|
||||
|
||||
@@ -208,10 +208,26 @@ pub fn list_textures(root: &Path) -> Vec<String> {
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
/// The file of a texture identifier.
|
||||
/// The file of a texture identifier. A Custom Item Textures id (`cit_assets`) names its
|
||||
/// resource path, wherever the folder: `directory` is not used for it. An id whose path would
|
||||
/// leave the root (a pack's model may write any) names a file that is never there.
|
||||
pub fn texture_path(root: &Path, texture: &str, directory: &str) -> PathBuf {
|
||||
// No file can have a NUL in its name, so this one is never there.
|
||||
const NOWHERE: &str = "assets/minecraft/textures/horizon/\0refused.png";
|
||||
if texture.starts_with("horizon_cit:") {
|
||||
return root.join(
|
||||
crate::cit_assets::resource(texture, "png")
|
||||
.as_deref()
|
||||
.unwrap_or(NOWHERE),
|
||||
);
|
||||
}
|
||||
let (namespace, path) = texture.split_once(':').unwrap_or(("minecraft", texture));
|
||||
root.join(format!("assets/{namespace}/{directory}/{path}.png"))
|
||||
let rel = format!("assets/{namespace}/{directory}/{path}.png");
|
||||
root.join(if horizon_optifine::paths::valid(&rel) {
|
||||
&rel
|
||||
} else {
|
||||
NOWHERE
|
||||
})
|
||||
}
|
||||
|
||||
/// `SpriteSourceList.list`: every sprite id of the atlas and its origin.
|
||||
|
||||
@@ -46,6 +46,7 @@ pub enum Ticking {
|
||||
#[derive(Default)]
|
||||
pub struct BlockEntities {
|
||||
map: HashMap<Pos, BlockEntity>,
|
||||
attachments: HashMap<Pos, horizon_world::attachments::Attachments>,
|
||||
/// `Level.blockEntityTickers`: positions and the slot each was given.
|
||||
tickers: Arc<Vec<(Pos, u32)>>,
|
||||
/// `LevelChunk.tickersInLevel`: the live slot of each ticking position.
|
||||
@@ -84,6 +85,18 @@ pub struct BlockEntities {
|
||||
/// which leaves out a mod block whose template has none. One taken out
|
||||
/// (`take`) stays listed until `put` or `remove`.
|
||||
mods: FxHashMap<(i32, i32), FxHashSet<Pos>>,
|
||||
/// The positions of the block entities that draw a bounding box (structure and test
|
||||
/// instance blocks, `shows_box`), so a frame looks at them without scanning the map.
|
||||
/// Like `mods`, one taken out stays listed until `put` or `remove`.
|
||||
boxes: FxHashSet<Pos>,
|
||||
}
|
||||
|
||||
/// A structure or test instance block: the block entities whose box the frame may draw.
|
||||
fn shows_box(entity: &BlockEntity) -> bool {
|
||||
matches!(
|
||||
entity,
|
||||
BlockEntity::StructureBlock(_) | BlockEntity::TestInstanceBlock(_)
|
||||
)
|
||||
}
|
||||
|
||||
fn chunk_of((x, _, z): Pos) -> (i32, i32) {
|
||||
@@ -220,11 +233,53 @@ impl BlockEntities {
|
||||
self.pending.remove(pos);
|
||||
}
|
||||
|
||||
/// Attachment map for a live block entity.
|
||||
pub fn attachments_mut(
|
||||
&mut self,
|
||||
pos: Pos,
|
||||
) -> Option<&mut horizon_world::attachments::Attachments> {
|
||||
if !self.map.contains_key(&pos) && !self.out.contains(&pos) {
|
||||
return None;
|
||||
}
|
||||
Some(self.attachments.entry(pos).or_default())
|
||||
}
|
||||
/// Attachment maps in a loaded chunk, for a save snapshot.
|
||||
pub fn chunk_attachments(
|
||||
&self,
|
||||
x: i32,
|
||||
z: i32,
|
||||
) -> Vec<(Pos, horizon_world::attachments::Attachments)> {
|
||||
self.attachments
|
||||
.iter()
|
||||
.filter(|(p, _)| p.0 >> 4 == x && p.2 >> 4 == z)
|
||||
.map(|(p, data)| (*p, data.clone()))
|
||||
.collect()
|
||||
}
|
||||
/// Restore a loaded block entity's attachment map.
|
||||
pub fn restore_attachments(&mut self, pos: Pos, data: horizon_world::attachments::Attachments) {
|
||||
if self.map.contains_key(&pos) {
|
||||
self.attachments.insert(pos, data);
|
||||
}
|
||||
}
|
||||
/// Whether any mod block entity is in the map: until one is, a block change
|
||||
/// never looks one up (`LevelAccess::has_mod_block_entity`).
|
||||
pub fn has_mods(&self) -> bool {
|
||||
!self.mods.is_empty()
|
||||
}
|
||||
/// The structure and test instance blocks (a frame draws their boxes), in no order.
|
||||
pub fn boxes(&self) -> impl Iterator<Item = (&Pos, &BlockEntity)> {
|
||||
self.boxes
|
||||
.iter()
|
||||
.filter_map(|pos| Some((pos, self.map.get(pos)?)))
|
||||
}
|
||||
/// Keeps `pos` listed in `boxes` exactly while `entity` shows a box.
|
||||
fn note_box(&mut self, pos: Pos, entity: Option<&BlockEntity>) {
|
||||
if entity.is_some_and(shows_box) {
|
||||
self.boxes.insert(pos);
|
||||
} else {
|
||||
self.boxes.remove(&pos);
|
||||
}
|
||||
}
|
||||
/// The mod block entities within `radius` blocks of `x`, `z` (by chunk, so
|
||||
/// far ones are skipped whole; the caller checks each distance).
|
||||
pub fn mods_near(&self, x: f64, z: f64, radius: f64) -> impl Iterator<Item = &Pos> {
|
||||
@@ -248,12 +303,28 @@ impl BlockEntities {
|
||||
}
|
||||
/// `LevelChunk.setBlockEntity` (+ `addAndRegisterBlockEntity`): a new
|
||||
/// block entity may get a slot; one replacing another keeps its slot.
|
||||
/// One of the same type rewrites it in place (a jukebox taking a disc,
|
||||
/// `/data merge block`), where vanilla keeps the object, so its
|
||||
/// attachments stay; any other replacement starts them over.
|
||||
pub fn insert(&mut self, pos: Pos, entity: BlockEntity) -> Option<BlockEntity> {
|
||||
let new_is_mod = matches!(entity, BlockEntity::Mod(_));
|
||||
let same_type = |old: &BlockEntity| match (old, &entity) {
|
||||
(BlockEntity::Mod(old), BlockEntity::Mod(new)) => old.id == new.id,
|
||||
(old, new) => std::mem::discriminant(old) == std::mem::discriminant(new),
|
||||
};
|
||||
if !self.map.get(&pos).is_some_and(same_type) {
|
||||
self.attachments.remove(&pos);
|
||||
}
|
||||
let new_box = shows_box(&entity);
|
||||
let old = self.map.insert(pos, entity);
|
||||
if old.is_none() {
|
||||
self.add(pos);
|
||||
}
|
||||
if new_box {
|
||||
self.boxes.insert(pos);
|
||||
} else if old.as_ref().is_some_and(shows_box) {
|
||||
self.boxes.remove(&pos);
|
||||
}
|
||||
if new_is_mod {
|
||||
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
|
||||
} else if matches!(old, Some(BlockEntity::Mod(_))) {
|
||||
@@ -275,15 +346,20 @@ impl BlockEntities {
|
||||
if absent && matches!(entity, BlockEntity::Mod(_)) {
|
||||
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
|
||||
}
|
||||
if absent && shows_box(entity) {
|
||||
self.boxes.insert(pos);
|
||||
}
|
||||
entity
|
||||
}
|
||||
/// `LevelChunk.removeBlockEntity`: the block entity and its slot.
|
||||
pub fn remove(&mut self, pos: &Pos) -> Option<BlockEntity> {
|
||||
self.attachments.remove(pos);
|
||||
self.cancel(pos);
|
||||
let removed = self.map.remove(pos);
|
||||
if matches!(removed, Some(BlockEntity::Mod(_))) {
|
||||
forget_mod(&mut self.mods, *pos);
|
||||
}
|
||||
self.boxes.remove(pos);
|
||||
removed
|
||||
}
|
||||
/// The block entity at `pos`, out of the map for a while: its slot stays
|
||||
@@ -301,6 +377,7 @@ impl BlockEntities {
|
||||
if matches!(entity, BlockEntity::Mod(_)) {
|
||||
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
|
||||
}
|
||||
self.note_box(pos, Some(&entity));
|
||||
if self.map.insert(pos, entity).is_none()
|
||||
&& !self.live.contains_key(&pos)
|
||||
&& !self.pending.contains_key(&pos)
|
||||
@@ -311,21 +388,30 @@ impl BlockEntities {
|
||||
/// Removes the block entities `keep` refuses, and their slots
|
||||
/// (`clearAllBlockEntities` of unloaded chunks).
|
||||
pub fn retain(&mut self, mut keep: impl FnMut(&Pos, &mut BlockEntity) -> bool) {
|
||||
let (live, pending, freed) = (&mut self.live, &mut self.pending, &mut self.freed);
|
||||
let mods = &mut self.mods;
|
||||
let (live, pending, freed, attachments) = (
|
||||
&mut self.live,
|
||||
&mut self.pending,
|
||||
&mut self.freed,
|
||||
&mut self.attachments,
|
||||
);
|
||||
let (mods, boxes) = (&mut self.mods, &mut self.boxes);
|
||||
self.map.retain(|p, e| {
|
||||
let kept = keep(p, e);
|
||||
if !kept {
|
||||
attachments.remove(p);
|
||||
*freed |= live.remove(p).is_some();
|
||||
pending.remove(p);
|
||||
forget_mod(mods, *p);
|
||||
boxes.remove(p);
|
||||
}
|
||||
kept
|
||||
});
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.attachments.clear();
|
||||
self.map.clear();
|
||||
self.mods.clear();
|
||||
self.boxes.clear();
|
||||
self.tickers = Arc::default();
|
||||
self.live.clear();
|
||||
self.added.clear();
|
||||
@@ -342,7 +428,9 @@ impl BlockEntities {
|
||||
pub fn load_chunk(&mut self, entities: Vec<(Pos, BlockEntity)>) {
|
||||
let order = chunk_map_order(entities.iter().map(|(p, _)| *p));
|
||||
for (p, e) in entities {
|
||||
self.attachments.remove(&p);
|
||||
let new_is_mod = matches!(e, BlockEntity::Mod(_));
|
||||
self.note_box(p, Some(&e));
|
||||
let old = self.map.insert(p, e);
|
||||
if old.is_some() {
|
||||
self.replaced.insert(p);
|
||||
@@ -476,6 +564,52 @@ mod tests {
|
||||
b.clear();
|
||||
assert!(near(&b, 200.0, 0.0, 8.0).is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn attachments_follow_block_entity_lifetime() {
|
||||
use horizon_world::attachments::AttachmentType;
|
||||
let ty = AttachmentType {
|
||||
namespace: "test",
|
||||
name: "value",
|
||||
default: || 0,
|
||||
codec: None,
|
||||
copy_on_death: false,
|
||||
};
|
||||
let p = (1, 64, 2);
|
||||
let mut entities = level();
|
||||
assert!(entities.attachments_mut(p).is_none());
|
||||
entities.insert(p, hopper());
|
||||
entities.attachments_mut(p).unwrap().set(&ty, 4).unwrap();
|
||||
let held = entities.take(&p).unwrap();
|
||||
entities.put(p, held);
|
||||
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(4));
|
||||
entities.insert(p, hopper());
|
||||
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(4));
|
||||
let machine = |id: &str| {
|
||||
BlockEntity::Mod(Box::new(
|
||||
crate::chunk::block_entity::ModBlockEntityFields {
|
||||
id: id.into(),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
))
|
||||
};
|
||||
entities.insert(p, machine("test:a"));
|
||||
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(0));
|
||||
entities.attachments_mut(p).unwrap().set(&ty, 4).unwrap();
|
||||
entities.insert(p, machine("test:a"));
|
||||
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(4));
|
||||
entities.insert(p, machine("test:b"));
|
||||
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(0));
|
||||
entities.attachments_mut(p).unwrap().set(&ty, 4).unwrap();
|
||||
entities.remove(&p);
|
||||
entities.insert(p, hopper());
|
||||
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(0));
|
||||
entities.attachments_mut(p).unwrap().set(&ty, 5).unwrap();
|
||||
entities.retain(|_, _| false);
|
||||
entities.insert(p, hopper());
|
||||
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slots_follow_vanilla_lifecycle() {
|
||||
let mut b = level();
|
||||
@@ -560,4 +694,43 @@ mod tests {
|
||||
b.state_changed(p, 2, 1);
|
||||
assert_eq!(order(&mut b, &s), [q, p]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boxes_list_exactly_the_structure_and_test_blocks() {
|
||||
let test_block = || BlockEntity::TestInstanceBlock(Box::default());
|
||||
let listed = |b: &BlockEntities| {
|
||||
let mut v: Vec<Pos> = b.boxes().map(|(p, _)| *p).collect();
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
let mut b = level();
|
||||
let (p, q, r) = ((0, 0, 0), (1, 0, 0), (2, 0, 0));
|
||||
b.insert(p, test_block());
|
||||
b.insert(q, hopper());
|
||||
assert_eq!(listed(&b), [p]);
|
||||
// Replaced by another kind, and back.
|
||||
b.insert(p, hopper());
|
||||
assert_eq!(listed(&b), []);
|
||||
b.insert(q, test_block());
|
||||
b.get_or_insert_with(r, test_block);
|
||||
assert_eq!(listed(&b), [q, r]);
|
||||
// Taken out: not shown until put back; put back as another kind: gone.
|
||||
let taken = b.take(&q).unwrap();
|
||||
assert_eq!(listed(&b), [r]);
|
||||
b.put(q, taken);
|
||||
assert_eq!(listed(&b), [q, r]);
|
||||
let taken = b.take(&r).unwrap();
|
||||
drop(taken);
|
||||
b.put(r, hopper());
|
||||
assert_eq!(listed(&b), [q]);
|
||||
b.remove(&q);
|
||||
assert_eq!(listed(&b), []);
|
||||
b.load_chunk(vec![(p, test_block()), (q, hopper())]);
|
||||
assert_eq!(listed(&b), [p]);
|
||||
b.retain(|pos, _| *pos != p);
|
||||
assert_eq!(listed(&b), []);
|
||||
b.insert(r, test_block());
|
||||
b.clear();
|
||||
assert!(b.boxes.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,630 @@
|
||||
//! OptiFine custom entity models over the block entities' models (`block_entity_render`'s
|
||||
//! `MODELS`): `chest.jem`, `shulker_box.jem`, `banner.jem`, `bell.jem`, `head_zombie.jem`... (the
|
||||
//! "Block entity name" table of OptiFine's `cem_model.txt`, and Entity Model Features' one-file
|
||||
//! `banner`), found and read as the mobs' are ([`horizon_optifine::cem::files`]). Each entry
|
||||
//! replaces or joins the boxes of the vanilla part it names and takes its `scale`; animations
|
||||
//! don't run yet. A `.jem`'s texture paints the models it reshapes instead of vanilla's when the
|
||||
//! atlas has it.
|
||||
//!
|
||||
//! An entry's part is OptiFine's name for it, else the vanilla part's own name. Trapped and
|
||||
//! ender chests take only their own file, and copper chests (no CEM name) keep vanilla's chest.
|
||||
//! Lectern and enchanting-table books take theirs, else `book`. Signs, hanging signs and beds are
|
||||
//! block models here, so their files are not read; copper golem statues have no CEM name.
|
||||
|
||||
use crate::block_entity_render::ModelPart;
|
||||
use horizon_optifine::cem::{
|
||||
files::{atlas_name, jem_files, load_jem},
|
||||
model::{Bone, EntityModel},
|
||||
};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap, HashSet},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
/// One model the renderer draws, the CEM names that reshape it, best first, and OptiFine's
|
||||
/// names for its vanilla parts.
|
||||
struct Target {
|
||||
key: &'static str,
|
||||
/// The vanilla model it starts from: `key` itself, or the one a block the renderer tells
|
||||
/// apart shares (a trapped chest's is the chest's).
|
||||
base: &'static str,
|
||||
names: &'static [&'static str],
|
||||
parts: &'static [(&'static str, &'static str)],
|
||||
}
|
||||
|
||||
const CHEST: &[(&str, &str)] = &[
|
||||
("root", "root"),
|
||||
("base", "bottom"),
|
||||
("lid", "lid"),
|
||||
("knob", "lock"),
|
||||
];
|
||||
const SHULKER: &[(&str, &str)] = &[("root", "root"), ("base", "base"), ("lid", "lid")];
|
||||
const BANNER: &[(&str, &str)] = &[("stand", "pole"), ("top", "bar")];
|
||||
const FLAG: &[(&str, &str)] = &[("slate", "flag")];
|
||||
const HEAD: &[(&str, &str)] = &[("root", "root"), ("head", "head")];
|
||||
const DRAGON: &[(&str, &str)] = &[("root", "root"), ("head", "head"), ("jaw", "jaw")];
|
||||
const PIGLIN: &[(&str, &str)] = &[
|
||||
("root", "root"),
|
||||
("head", "head"),
|
||||
("left_ear", "left_ear"),
|
||||
("right_ear", "right_ear"),
|
||||
];
|
||||
const BELL: &[(&str, &str)] = &[
|
||||
("root", "root"),
|
||||
("body", "bell_body"),
|
||||
("base", "bell_base"),
|
||||
];
|
||||
const BOOK: &[(&str, &str)] = &[
|
||||
("root", "root"),
|
||||
("cover_right", "right_lid"),
|
||||
("cover_left", "left_lid"),
|
||||
("pages_right", "right_pages"),
|
||||
("pages_left", "left_pages"),
|
||||
("flipping_page_right", "flip_page1"),
|
||||
("flipping_page_left", "flip_page2"),
|
||||
("book_spine", "seam"),
|
||||
];
|
||||
const POT_BASE: &[(&str, &str)] = &[("neck", "neck"), ("top", "top"), ("bottom", "bottom")];
|
||||
const POT_SIDES: &[(&str, &str)] = &[
|
||||
("front", "front"),
|
||||
("back", "back"),
|
||||
("left", "left"),
|
||||
("right", "right"),
|
||||
];
|
||||
|
||||
const fn model(
|
||||
key: &'static str,
|
||||
names: &'static [&'static str],
|
||||
parts: &'static [(&'static str, &'static str)],
|
||||
) -> Target {
|
||||
Target {
|
||||
key,
|
||||
base: key,
|
||||
names,
|
||||
parts,
|
||||
}
|
||||
}
|
||||
const fn copy(
|
||||
key: &'static str,
|
||||
base: &'static str,
|
||||
names: &'static [&'static str],
|
||||
parts: &'static [(&'static str, &'static str)],
|
||||
) -> Target {
|
||||
Target {
|
||||
key,
|
||||
base,
|
||||
names,
|
||||
parts,
|
||||
}
|
||||
}
|
||||
|
||||
/// In order: a copy comes after its base.
|
||||
const TARGETS: &[Target] = &[
|
||||
model("chest", &["chest"], CHEST),
|
||||
model("double_chest_left", &["chest_left"], CHEST),
|
||||
model("double_chest_right", &["chest_right"], CHEST),
|
||||
copy("trapped_chest", "chest", &["trapped_chest"], CHEST),
|
||||
copy(
|
||||
"trapped_chest_left",
|
||||
"double_chest_left",
|
||||
&["trapped_chest_left"],
|
||||
CHEST,
|
||||
),
|
||||
copy(
|
||||
"trapped_chest_right",
|
||||
"double_chest_right",
|
||||
&["trapped_chest_right"],
|
||||
CHEST,
|
||||
),
|
||||
copy("ender_chest", "chest", &["ender_chest"], CHEST),
|
||||
model("shulker_box", &["shulker_box"], SHULKER),
|
||||
model("standing_banner", &["banner_base", "banner"], BANNER),
|
||||
model("wall_banner", &["banner_base", "banner"], BANNER),
|
||||
model("standing_banner_flag", &["banner_flag", "banner"], FLAG),
|
||||
model("wall_banner_flag", &["banner_flag", "banner"], FLAG),
|
||||
model("skeleton_skull", &["head_skeleton"], HEAD),
|
||||
model("wither_skeleton_skull", &["head_wither_skeleton"], HEAD),
|
||||
model("zombie_head", &["head_zombie"], HEAD),
|
||||
model("creeper_head", &["head_creeper"], HEAD),
|
||||
model("dragon_skull", &["head_dragon"], DRAGON),
|
||||
model("piglin_head", &["head_piglin"], PIGLIN),
|
||||
model("player_head", &["head_player"], HEAD),
|
||||
model("bell", &["bell"], BELL),
|
||||
model("book", &["enchanting_book", "book"], BOOK),
|
||||
copy("lectern_book", "book", &["lectern_book", "book"], BOOK),
|
||||
model("conduit_shell", &["conduit"], &[("base", "shell")]),
|
||||
model("conduit_cage", &["conduit"], &[("cage", "shell")]),
|
||||
model("conduit_wind", &["conduit"], &[("wind", "wind")]),
|
||||
model("conduit_eye", &["conduit"], &[("eye", "eye")]),
|
||||
model("decorated_pot_base", &["decorated_pot"], POT_BASE),
|
||||
model("decorated_pot_sides", &["decorated_pot"], POT_SIDES),
|
||||
];
|
||||
|
||||
/// Every model, reshaped or vanilla's, and the atlas names of the `.jem` textures by model.
|
||||
pub type Reshaped = (
|
||||
HashMap<&'static str, ModelPart>,
|
||||
HashMap<&'static str, String>,
|
||||
);
|
||||
|
||||
/// The models the packs under `root` reshape, over `vanilla`'s; None when no block entity has
|
||||
/// a `.jem` there.
|
||||
pub fn reshape(root: &Path, vanilla: &HashMap<&'static str, ModelPart>) -> Option<Reshaped> {
|
||||
let (reshaped, unused) = reshape_all(root, vanilla)?;
|
||||
for (path, part) in unused {
|
||||
log::warn!("Horizon: custom entity model {path}: no part {part}");
|
||||
}
|
||||
Some(reshaped)
|
||||
}
|
||||
|
||||
/// [`reshape`]'s models, and each file's entries whose part none of the models it reshapes has.
|
||||
fn reshape_all(
|
||||
root: &Path,
|
||||
vanilla: &HashMap<&'static str, ModelPart>,
|
||||
) -> Option<(Reshaped, Vec<(String, String)>)> {
|
||||
let files = jem_files(root);
|
||||
let named = |t: &Target| t.names.iter().any(|n| files.contains_key(*n));
|
||||
if !TARGETS.iter().any(named) {
|
||||
return None;
|
||||
}
|
||||
let mut models = vanilla.clone();
|
||||
let mut textures = HashMap::new();
|
||||
let mut reshaped = HashSet::new();
|
||||
// Each file parsed once; which of its entries found a part in some model.
|
||||
let mut parsed: HashMap<&str, Option<EntityModel>> = HashMap::new();
|
||||
let mut used: BTreeMap<&str, Vec<bool>> = BTreeMap::new();
|
||||
for t in TARGETS {
|
||||
let Some(model) = vanilla.get(t.base) else {
|
||||
continue;
|
||||
};
|
||||
let found = t
|
||||
.names
|
||||
.iter()
|
||||
.flat_map(|name| files.get(*name).into_iter().flatten())
|
||||
.map(String::as_str)
|
||||
.find(|&path| {
|
||||
parsed
|
||||
.entry(path)
|
||||
.or_insert_with(|| load_jem(root, path))
|
||||
.is_some()
|
||||
});
|
||||
let Some(path) = found else {
|
||||
// A copy of a reshaped base that has no file of its own stays vanilla.
|
||||
if t.key != t.base && reshaped.contains(t.base) {
|
||||
models.insert(t.key, model.clone());
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let Some(Some(jem)) = parsed.get(path) else {
|
||||
continue;
|
||||
};
|
||||
let mut out = model.clone();
|
||||
let hits = apply(&mut out, jem, t.parts);
|
||||
// A file shared by several models (the conduit's, the pot's) paints only the models
|
||||
// it reshapes: vanilla geometry keeps its own texture's layout.
|
||||
let reshapes = hits.iter().any(|hit| *hit);
|
||||
let marks = used
|
||||
.entry(path)
|
||||
.or_insert_with(|| vec![false; jem.parts.len()]);
|
||||
for (mark, hit) in marks.iter_mut().zip(hits) {
|
||||
*mark |= hit;
|
||||
}
|
||||
models.insert(t.key, out);
|
||||
reshaped.insert(t.key);
|
||||
if reshapes && let Some(texture) = jem.texture.as_deref().and_then(atlas_name) {
|
||||
textures.insert(t.key, texture);
|
||||
}
|
||||
}
|
||||
let mut unused = Vec::new();
|
||||
for (path, marks) in &used {
|
||||
let Some(Some(jem)) = parsed.get(path) else {
|
||||
continue;
|
||||
};
|
||||
for (p, _) in jem.parts.iter().zip(marks).filter(|(_, hit)| !**hit) {
|
||||
unused.push(((*path).to_owned(), p.part.clone()));
|
||||
}
|
||||
}
|
||||
Some(((models, textures), unused))
|
||||
}
|
||||
|
||||
/// `jem`'s entries on `model`, OptiFine's part names read through `parts`, else as the vanilla
|
||||
/// part's own name: a replacing entry clears its part's boxes, and each joins the part as a child
|
||||
/// `cem#<n>`. Which entries found their part.
|
||||
fn apply(model: &mut ModelPart, jem: &EntityModel, parts: &[(&str, &str)]) -> Vec<bool> {
|
||||
let mut count = 0;
|
||||
let mut hits = Vec::with_capacity(jem.parts.len());
|
||||
for p in &jem.parts {
|
||||
let mapped = parts.iter().find(|(of, _)| *of == p.part).map(|&(_, v)| v);
|
||||
let target = match mapped {
|
||||
Some(vanilla) if find_mut(model, vanilla).is_some() => vanilla,
|
||||
_ => p.part.as_str(),
|
||||
};
|
||||
let Some(node) = find_mut(model, target) else {
|
||||
hits.push(false);
|
||||
continue;
|
||||
};
|
||||
if !p.attach {
|
||||
node.polygons.clear();
|
||||
}
|
||||
let (name, mut bone) = part(&p.bone, &mut count);
|
||||
bone.pose[6..9].fill(p.scale);
|
||||
node.children.push((name, bone));
|
||||
hits.push(true);
|
||||
}
|
||||
hits
|
||||
}
|
||||
|
||||
/// The model itself for `root`, else its first part named `name`, depth first.
|
||||
fn find_mut<'a>(model: &'a mut ModelPart, name: &str) -> Option<&'a mut ModelPart> {
|
||||
fn child<'a>(part: &'a mut ModelPart, name: &str) -> Option<&'a mut ModelPart> {
|
||||
part.children.iter_mut().find_map(|(n, c)| {
|
||||
if n.as_str() == name {
|
||||
Some(c)
|
||||
} else {
|
||||
child(c, name)
|
||||
}
|
||||
})
|
||||
}
|
||||
if name == "root" {
|
||||
return Some(model);
|
||||
}
|
||||
child(model, name)
|
||||
}
|
||||
|
||||
/// A baked bone as a model part, named `cem#<n>`: unique in the model, so no vanilla pose
|
||||
/// change by name reaches it.
|
||||
fn part(bone: &Bone, count: &mut usize) -> (String, ModelPart) {
|
||||
let name = format!("cem#{count}");
|
||||
*count += 1;
|
||||
let [x, y, z] = bone.pivot;
|
||||
let [rx, ry, rz] = bone.rotation;
|
||||
let out = ModelPart {
|
||||
pose: [x, y, z, rx, ry, rz, 1.0, 1.0, 1.0],
|
||||
polygons: bone.quads.iter().map(|q| q.vertices).collect(),
|
||||
children: bone.children.iter().map(|c| part(c, count)).collect(),
|
||||
};
|
||||
(name, out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use std::{collections::BTreeSet, fs};
|
||||
|
||||
/// A part with one face and `children`.
|
||||
pub(crate) fn plane(children: Vec<(&str, ModelPart)>) -> ModelPart {
|
||||
ModelPart {
|
||||
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
|
||||
polygons: vec![[[0.0; 5]; 4]],
|
||||
children: children
|
||||
.into_iter()
|
||||
.map(|(n, c)| (n.to_owned(), c))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
pub(crate) fn chest() -> ModelPart {
|
||||
let mut root = plane(vec![
|
||||
("bottom", plane(vec![])),
|
||||
("lid", plane(vec![])),
|
||||
("lock", plane(vec![])),
|
||||
]);
|
||||
root.polygons.clear();
|
||||
root
|
||||
}
|
||||
pub(crate) const BOX: &str =
|
||||
r#""boxes": [{"coordinates": [0, 0, 0, 1, 1, 1], "textureOffset": [0, 0]}]"#;
|
||||
/// A fresh pack folder named for `test`, holding `files` under `assets/minecraft/`.
|
||||
pub(crate) fn pack(test: &str, files: &[(&str, String)]) -> std::path::PathBuf {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-be-cem-{test}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
for (path, text) in files {
|
||||
let path = root.join("assets/minecraft").join(path);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, text).unwrap();
|
||||
}
|
||||
root
|
||||
}
|
||||
/// A `.jem` replacing each of `parts` with one box.
|
||||
fn jem(parts: &[&str]) -> String {
|
||||
let entries: Vec<_> = parts
|
||||
.iter()
|
||||
.map(|p| format!(r#"{{"part": "{p}", {BOX}}}"#))
|
||||
.collect();
|
||||
format!(r#"{{"models": [{}]}}"#, entries.join(", "))
|
||||
}
|
||||
/// A `.jem` replacing each of `parts` with a box of its own: the `i`th is `i + 1` pixels
|
||||
/// deep, so a part that took another entry's box shows.
|
||||
fn jem_sized(parts: &[&str]) -> String {
|
||||
let entries: Vec<_> = parts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| {
|
||||
let depth = i + 1;
|
||||
format!(
|
||||
r#"{{"part": "{p}", "boxes": [{{"coordinates": [0, 0, 0, 1, 1, {depth}], "textureOffset": [0, 0]}}]}}"#
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
format!(r#"{{"models": [{}]}}"#, entries.join(", "))
|
||||
}
|
||||
/// How deep (z) the custom box under `model`'s part `name` is.
|
||||
fn depth(model: &ModelPart, name: &str) -> f32 {
|
||||
let part = model.child(name).unwrap();
|
||||
let zs = part
|
||||
.children
|
||||
.iter()
|
||||
.flat_map(|(_, c)| c.polygons.iter().flatten().map(|v| v[2]));
|
||||
let (lo, hi) = zs.fold((f32::MAX, f32::MIN), |(lo, hi), z| (lo.min(z), hi.max(z)));
|
||||
hi - lo
|
||||
}
|
||||
/// Whether `model`'s part `name` was replaced by one custom box: its own face gone, and one
|
||||
/// child of a box's six faces.
|
||||
fn replaced(model: &ModelPart, name: &str) -> bool {
|
||||
let part = model.child(name).unwrap();
|
||||
match part.children.as_slice() {
|
||||
[(_, only)] => part.polygons.is_empty() && only.polygons.len() == 6,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_optifine_part_name_lands_on_its_vanilla_part() {
|
||||
// File, OptiFine's part, the model it reshapes, vanilla's part there.
|
||||
let cases = [
|
||||
("chest", "base", "chest", "bottom"),
|
||||
("chest", "lid", "chest", "lid"),
|
||||
("chest", "knob", "chest", "lock"),
|
||||
("chest_left", "base", "double_chest_left", "bottom"),
|
||||
("chest_left", "lid", "double_chest_left", "lid"),
|
||||
("chest_left", "knob", "double_chest_left", "lock"),
|
||||
("chest_right", "base", "double_chest_right", "bottom"),
|
||||
("chest_right", "lid", "double_chest_right", "lid"),
|
||||
("chest_right", "knob", "double_chest_right", "lock"),
|
||||
("trapped_chest", "base", "trapped_chest", "bottom"),
|
||||
("trapped_chest", "knob", "trapped_chest", "lock"),
|
||||
("trapped_chest_left", "base", "trapped_chest_left", "bottom"),
|
||||
("trapped_chest_left", "knob", "trapped_chest_left", "lock"),
|
||||
(
|
||||
"trapped_chest_right",
|
||||
"base",
|
||||
"trapped_chest_right",
|
||||
"bottom",
|
||||
),
|
||||
("trapped_chest_right", "knob", "trapped_chest_right", "lock"),
|
||||
("ender_chest", "base", "ender_chest", "bottom"),
|
||||
("ender_chest", "knob", "ender_chest", "lock"),
|
||||
("shulker_box", "base", "shulker_box", "base"),
|
||||
("shulker_box", "lid", "shulker_box", "lid"),
|
||||
("banner", "stand", "standing_banner", "pole"),
|
||||
("banner", "top", "standing_banner", "bar"),
|
||||
("banner", "top", "wall_banner", "bar"),
|
||||
("banner", "slate", "standing_banner_flag", "flag"),
|
||||
("banner", "slate", "wall_banner_flag", "flag"),
|
||||
("head_skeleton", "head", "skeleton_skull", "head"),
|
||||
(
|
||||
"head_wither_skeleton",
|
||||
"head",
|
||||
"wither_skeleton_skull",
|
||||
"head",
|
||||
),
|
||||
("head_zombie", "head", "zombie_head", "head"),
|
||||
("head_creeper", "head", "creeper_head", "head"),
|
||||
("head_dragon", "head", "dragon_skull", "head"),
|
||||
("head_dragon", "jaw", "dragon_skull", "jaw"),
|
||||
("head_piglin", "head", "piglin_head", "head"),
|
||||
("head_piglin", "left_ear", "piglin_head", "left_ear"),
|
||||
("head_piglin", "right_ear", "piglin_head", "right_ear"),
|
||||
("head_player", "head", "player_head", "head"),
|
||||
("bell", "body", "bell", "bell_body"),
|
||||
("bell", "base", "bell", "bell_base"),
|
||||
("enchanting_book", "cover_right", "book", "right_lid"),
|
||||
("enchanting_book", "cover_left", "book", "left_lid"),
|
||||
("enchanting_book", "pages_right", "book", "right_pages"),
|
||||
("enchanting_book", "pages_left", "book", "left_pages"),
|
||||
(
|
||||
"enchanting_book",
|
||||
"flipping_page_right",
|
||||
"book",
|
||||
"flip_page1",
|
||||
),
|
||||
(
|
||||
"enchanting_book",
|
||||
"flipping_page_left",
|
||||
"book",
|
||||
"flip_page2",
|
||||
),
|
||||
("enchanting_book", "book_spine", "book", "seam"),
|
||||
("lectern_book", "cover_right", "lectern_book", "right_lid"),
|
||||
("lectern_book", "pages_left", "lectern_book", "left_pages"),
|
||||
(
|
||||
"lectern_book",
|
||||
"flipping_page_right",
|
||||
"lectern_book",
|
||||
"flip_page1",
|
||||
),
|
||||
("lectern_book", "book_spine", "lectern_book", "seam"),
|
||||
("conduit", "base", "conduit_shell", "shell"),
|
||||
("conduit", "cage", "conduit_cage", "shell"),
|
||||
("conduit", "wind", "conduit_wind", "wind"),
|
||||
("conduit", "eye", "conduit_eye", "eye"),
|
||||
("decorated_pot", "neck", "decorated_pot_base", "neck"),
|
||||
("decorated_pot", "top", "decorated_pot_base", "top"),
|
||||
("decorated_pot", "bottom", "decorated_pot_base", "bottom"),
|
||||
("decorated_pot", "front", "decorated_pot_sides", "front"),
|
||||
("decorated_pot", "back", "decorated_pot_sides", "back"),
|
||||
("decorated_pot", "left", "decorated_pot_sides", "left"),
|
||||
("decorated_pot", "right", "decorated_pot_sides", "right"),
|
||||
];
|
||||
// One file per name with each of its parts, and a stub of each model holding its parts
|
||||
// (chest models all of theirs, so a misplaced entry shows).
|
||||
let mut files: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
|
||||
let mut trees: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
|
||||
for (file, part, key, vanilla) in cases {
|
||||
files.entry(file).or_default().insert(part);
|
||||
trees.entry(key).or_default().insert(vanilla);
|
||||
}
|
||||
for key in ["chest", "double_chest_left", "double_chest_right"] {
|
||||
trees
|
||||
.entry(key)
|
||||
.or_default()
|
||||
.extend(["bottom", "lid", "lock"]);
|
||||
}
|
||||
let files_by_name = files.clone();
|
||||
let files: Vec<_> = files
|
||||
.iter()
|
||||
.map(|(file, parts)| {
|
||||
let parts: Vec<_> = parts.iter().copied().collect();
|
||||
(format!("optifine/cem/{file}.jem"), jem_sized(&parts))
|
||||
})
|
||||
.collect();
|
||||
let files: Vec<_> = files.iter().map(|(p, t)| (p.as_str(), t.clone())).collect();
|
||||
let root = pack("parts", &files);
|
||||
let vanilla: HashMap<&'static str, ModelPart> = trees
|
||||
.iter()
|
||||
.map(|(key, parts)| {
|
||||
let parts = parts.iter().map(|p| (*p, plane(vec![]))).collect();
|
||||
(*key, plane(parts))
|
||||
})
|
||||
.collect();
|
||||
let result = reshape_all(&root, &vanilla);
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
let ((models, _), unused) = result.unwrap();
|
||||
for (file, part, key, vanilla) in cases {
|
||||
assert!(
|
||||
replaced(&models[key], vanilla),
|
||||
"{file}.jem {part}: {key} {vanilla}"
|
||||
);
|
||||
// The box is this entry's own: swapped names (left and right, body and base) show.
|
||||
let own = files_by_name[file].iter().position(|p| *p == part).unwrap() + 1;
|
||||
assert_eq!(
|
||||
depth(&models[key], vanilla),
|
||||
own as f32,
|
||||
"{file}.jem {part}: {key} {vanilla}"
|
||||
);
|
||||
}
|
||||
assert_eq!(unused, Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chest_jem_reshapes_the_chest_alone_and_warns_of_entries_without_a_part() {
|
||||
let root = pack(
|
||||
"chest",
|
||||
&[(
|
||||
"optifine/cem/chest.jem",
|
||||
format!(
|
||||
r#"{{"texture": "textures/entity/chest/fancy.png", "models": [
|
||||
{{"part": "lid", {BOX}}},
|
||||
{{"part": "knob", "attach": true, "scale": 2, {BOX}}},
|
||||
{{"part": "bottom", {BOX}}},
|
||||
{{"part": "tail", {BOX}}}]}}"#
|
||||
),
|
||||
)],
|
||||
);
|
||||
let vanilla: HashMap<_, _> = [("chest", chest()), ("double_chest_left", chest())].into();
|
||||
let result = reshape_all(&root, &vanilla);
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
let ((models, textures), unused) = result.unwrap();
|
||||
let chest = &models["chest"];
|
||||
// Replaced: the lid's own face goes and the custom box (6 faces) joins it.
|
||||
assert!(replaced(chest, "lid"));
|
||||
// `bottom` is no OptiFine name but vanilla's own: it reaches the part as well.
|
||||
assert!(replaced(chest, "bottom"));
|
||||
// Attached: the lock keeps its face beside the scaled box.
|
||||
let lock = chest.child("lock").unwrap();
|
||||
assert_eq!((lock.polygons.len(), lock.children.len()), (1, 1));
|
||||
assert_eq!(lock.children[0].1.pose[6..], [2.0, 2.0, 2.0]);
|
||||
// `tail` names no chest part: the one warning.
|
||||
assert_eq!(
|
||||
unused,
|
||||
[(
|
||||
"assets/minecraft/optifine/cem/chest.jem".to_owned(),
|
||||
"tail".to_owned()
|
||||
)]
|
||||
);
|
||||
// The double chest has no file, nor do trapped and ender chests: vanilla's own, and
|
||||
// only the chest takes the texture.
|
||||
for key in ["double_chest_left", "trapped_chest", "ender_chest"] {
|
||||
let lid = models[key].child("lid").unwrap();
|
||||
assert_eq!((lid.polygons.len(), lid.children.len()), (1, 0), "{key}");
|
||||
}
|
||||
assert_eq!(
|
||||
textures,
|
||||
HashMap::from([("chest", "entity/chest/fancy".to_owned())])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn banners_split_one_file_and_books_fall_back() {
|
||||
let root = pack(
|
||||
"banner",
|
||||
&[
|
||||
("optifine/cem/banner.jem", jem(&["stand", "top", "slate"])),
|
||||
("emf/cem/enchanting_book.jem", jem(&["cover_left"])),
|
||||
],
|
||||
);
|
||||
let banner = plane(vec![("pole", plane(vec![])), ("bar", plane(vec![]))]);
|
||||
let wall = plane(vec![("bar", plane(vec![]))]);
|
||||
let flag = plane(vec![("flag", plane(vec![]))]);
|
||||
let book = plane(vec![("left_lid", plane(vec![]))]);
|
||||
let vanilla: HashMap<_, _> = [
|
||||
("standing_banner", banner),
|
||||
("wall_banner", wall),
|
||||
("wall_banner_flag", flag),
|
||||
("book", book),
|
||||
]
|
||||
.into();
|
||||
let result = reshape_all(&root, &vanilla);
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
let ((models, textures), unused) = result.unwrap();
|
||||
assert!(replaced(&models["standing_banner"], "pole"));
|
||||
assert!(replaced(&models["standing_banner"], "bar"));
|
||||
assert!(replaced(&models["wall_banner"], "bar"));
|
||||
assert!(replaced(&models["wall_banner_flag"], "flag"));
|
||||
assert!(replaced(&models["book"], "left_lid"));
|
||||
// The lectern's book has no file of its own nor `book.jem`: vanilla's.
|
||||
let lectern = models["lectern_book"].child("left_lid").unwrap();
|
||||
assert_eq!((lectern.polygons.len(), lectern.children.len()), (1, 0));
|
||||
assert!(textures.is_empty());
|
||||
// Each entry found its part in one of the models: no warning.
|
||||
assert_eq!(unused, Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn book_jem_reshapes_the_lectern_book_without_its_own_file() {
|
||||
let root = pack("book", &[("optifine/cem/book.jem", jem(&["cover_left"]))]);
|
||||
let vanilla: HashMap<_, _> = [("book", plane(vec![("left_lid", plane(vec![]))]))].into();
|
||||
let result = reshape(&root, &vanilla);
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
let (models, _) = result.unwrap();
|
||||
assert!(replaced(&models["book"], "left_lid"));
|
||||
assert!(replaced(&models["lectern_book"], "left_lid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_file_paints_only_the_models_it_reshapes() {
|
||||
// The conduit's file reshapes its eye alone: the shell, cage and wind keep their
|
||||
// vanilla geometry and so their own textures.
|
||||
let file = format!(
|
||||
r#"{{"texture": "textures/entity/conduit/custom.png", "models": [{{"part": "eye", {BOX}}}]}}"#
|
||||
);
|
||||
let root = pack("conduit", &[("optifine/cem/conduit.jem", file)]);
|
||||
let vanilla: HashMap<_, _> = [
|
||||
"conduit_shell",
|
||||
"conduit_cage",
|
||||
"conduit_wind",
|
||||
"conduit_eye",
|
||||
]
|
||||
.map(|key| {
|
||||
let part = if key == "conduit_eye" { "eye" } else { "shell" };
|
||||
(key, plane(vec![(part, plane(vec![]))]))
|
||||
})
|
||||
.into();
|
||||
let result = reshape(&root, &vanilla);
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
let (models, textures) = result.unwrap();
|
||||
assert!(replaced(&models["conduit_eye"], "eye"));
|
||||
let painted: BTreeSet<_> = textures.keys().copied().collect();
|
||||
assert_eq!(painted, BTreeSet::from(["conduit_eye"]));
|
||||
assert_eq!(textures["conduit_eye"], "entity/conduit/custom");
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,12 @@ use horizon_world::{
|
||||
registry::{BlockRegistry, BlockStateId},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, f32::consts::PI, path::Path, sync::Mutex};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
f32::consts::PI,
|
||||
path::Path,
|
||||
sync::{Arc, Mutex, PoisonError, RwLock},
|
||||
};
|
||||
|
||||
/// One baked `ModelPart`: initial pose, textured polygons (pixel units,
|
||||
/// normalized UVs) and named children.
|
||||
@@ -120,7 +125,12 @@ pub struct Paint {
|
||||
pub tint: u32,
|
||||
pub light: u32,
|
||||
pub lights: (Vec3, Vec3),
|
||||
/// The texture's OptiFine emissive overlay (`_e`): drawn again over it at full
|
||||
/// brightness, with the same shading.
|
||||
pub glow: Option<Rect>,
|
||||
}
|
||||
/// Full sky and block light, in [`vertex_light`]'s form.
|
||||
const FULL_BRIGHT: u32 = 240 | (240 << 8);
|
||||
/// `ModelPart.render`: translate by pose/16, rotate ZYX, scale; children inherit.
|
||||
/// `adjust` may change a part's pose by name (`setupAnim`); `skip` hides parts.
|
||||
pub fn draw_part(
|
||||
@@ -142,6 +152,7 @@ pub fn draw_part(
|
||||
tint,
|
||||
light,
|
||||
lights,
|
||||
glow,
|
||||
} = paint;
|
||||
for poly in &part.polygons {
|
||||
let pos = poly.map(|v| m.transform_point3(Vec3::new(v[0], v[1], v[2]) / 16.0));
|
||||
@@ -155,17 +166,19 @@ pub fn draw_part(
|
||||
normal
|
||||
};
|
||||
let color = pack_color(tint, 255, diffuse(normal, lights));
|
||||
for (v, position) in poly.iter().zip(pos) {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: position.to_array(),
|
||||
uv: [r.u0 + (r.u1 - r.u0) * v[3], r.v0 + (r.v1 - r.v0) * v[4]],
|
||||
layer: 0,
|
||||
color,
|
||||
light,
|
||||
for (r, light) in std::iter::once((r, light)).chain(glow.map(|g| (g, FULL_BRIGHT))) {
|
||||
for (v, position) in poly.iter().zip(pos) {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: position.to_array(),
|
||||
uv: [r.u0 + (r.u1 - r.u0) * v[3], r.v0 + (r.v1 - r.v0) * v[4]],
|
||||
layer: 0,
|
||||
color,
|
||||
light,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (child_name, child) in &part.children {
|
||||
@@ -771,6 +784,8 @@ pub struct Scene<'a> {
|
||||
pub max_y: i32,
|
||||
pub world: &'a World,
|
||||
pub sprites: &'a HashMap<String, Rect>,
|
||||
/// The packs' OptiFine emissive overlays of the sprites.
|
||||
pub emissive: &'a horizon_optifine::emissive::Emissive,
|
||||
pub nether: bool,
|
||||
/// Chest lid openness (`ChestLidController.getOpenNess`) at a position.
|
||||
pub lid: &'a dyn Fn([i32; 3]) -> f32,
|
||||
@@ -792,16 +807,102 @@ pub struct Scene<'a> {
|
||||
/// Empty by default (a dedicated server): no state has a block entity model.
|
||||
#[derive(Default)]
|
||||
pub struct BlockEntityRenderer {
|
||||
models: HashMap<&'static str, ModelPart>,
|
||||
vanilla: Arc<BlockEntityShapes>,
|
||||
/// What draws: `vanilla`, or the packs' custom entity models over it.
|
||||
shapes: RwLock<Arc<BlockEntityShapes>>,
|
||||
kinds: Vec<Kind>,
|
||||
// Closed chest geometry: dimension lighting, chest part, horizontal facing.
|
||||
closed_chests: [[[Vec<Vertex>; 4]; 3]; 2],
|
||||
/// Item id to its `PROVIDES_POTTERY_PATTERN` side texture (`Sheets.DECORATED_POT_MAPPER`).
|
||||
pot_patterns: HashMap<String, String>,
|
||||
/// `Block.shouldRenderFace` for End gateway faces (its occlusion pair cache needs a lock).
|
||||
visibility: Option<Mutex<FaceVisibility>>,
|
||||
sign_texts: Mutex<SignTexts>,
|
||||
}
|
||||
/// The block-entity models and what is baked from them: vanilla's, or the packs' custom entity
|
||||
/// models over them ([`crate::block_entity_cem`]).
|
||||
#[derive(Default)]
|
||||
pub struct BlockEntityShapes {
|
||||
models: HashMap<&'static str, ModelPart>,
|
||||
/// Closed chest geometry by chest model: dimension lighting, horizontal facing.
|
||||
closed_chests: HashMap<&'static str, [[Vec<Vertex>; 4]; 2]>,
|
||||
/// A `.jem`'s texture (atlas name) by model, drawn instead of vanilla's.
|
||||
textures: HashMap<&'static str, String>,
|
||||
}
|
||||
/// The chest models, each baked with its lid closed.
|
||||
const CHEST_MODELS: [&str; 7] = [
|
||||
"chest",
|
||||
"double_chest_left",
|
||||
"double_chest_right",
|
||||
"trapped_chest",
|
||||
"trapped_chest_left",
|
||||
"trapped_chest_right",
|
||||
"ender_chest",
|
||||
];
|
||||
impl BlockEntityShapes {
|
||||
fn new(
|
||||
models: HashMap<&'static str, ModelPart>,
|
||||
textures: HashMap<&'static str, String>,
|
||||
) -> Self {
|
||||
let closed_chests = CHEST_MODELS
|
||||
.iter()
|
||||
.filter_map(|key| Some((*key, closed_chest(models.get(key)?))))
|
||||
.collect();
|
||||
Self {
|
||||
models,
|
||||
closed_chests,
|
||||
textures,
|
||||
}
|
||||
}
|
||||
/// The texture model `key` draws with: its `.jem`'s when the atlas has it, else `vanilla`.
|
||||
fn texture<'a>(
|
||||
&'a self,
|
||||
key: &str,
|
||||
vanilla: &'a str,
|
||||
sprites: &HashMap<String, Rect>,
|
||||
) -> &'a str {
|
||||
match self.textures.get(key) {
|
||||
Some(texture) if sprites.contains_key(texture) => texture,
|
||||
_ => vanilla,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A chest model with its lid closed, for each dimension lighting and horizontal facing, its UVs
|
||||
/// over the whole texture.
|
||||
fn closed_chest(model: &ModelPart) -> [[Vec<Vertex>; 4]; 2] {
|
||||
[false, true].map(|nether| {
|
||||
[0.0f32, 90.0, 180.0, 270.0].map(|y_rot| {
|
||||
let mut vertices = Vec::new();
|
||||
draw_part(
|
||||
model,
|
||||
"root",
|
||||
around(
|
||||
Mat4::from_rotation_y((-y_rot).to_radians()),
|
||||
Vec3::new(0.5, 0.0, 0.5),
|
||||
),
|
||||
&|name, p| {
|
||||
if name == "lid" || name == "lock" {
|
||||
p[3] = crate::block_entity_anim::chest_lid_x_rot(0.0);
|
||||
}
|
||||
},
|
||||
Paint {
|
||||
sprite: Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 16,
|
||||
h: 16,
|
||||
},
|
||||
tint: 0xFFFFFF,
|
||||
light: 0,
|
||||
lights: diffuse_lights(nether),
|
||||
glow: None,
|
||||
},
|
||||
&mut vertices,
|
||||
);
|
||||
vertices
|
||||
})
|
||||
})
|
||||
}
|
||||
/// Laid-out sign text by position. `AbstractSignRenderer` lays the text out
|
||||
/// again each frame, glyph by glyph; its vertices only change with what is
|
||||
/// in the key, so they are kept relative to the block and moved to the camera.
|
||||
@@ -884,6 +985,8 @@ impl BlockEntityRenderer {
|
||||
.collect();
|
||||
let mut renderer = Self::new(models, registry);
|
||||
renderer.visibility = Some(Mutex::new(visibility));
|
||||
// The packs' custom entity models (`.jem` files), before anything draws.
|
||||
renderer.set_shapes(renderer.shapes_for(root));
|
||||
// Sherd items: provides_pottery_pattern -> decorated_pot_pattern asset id.
|
||||
let items = match horizon_world::item_defaults::read(root) {
|
||||
Ok(items) => items,
|
||||
@@ -910,43 +1013,7 @@ impl BlockEntityRenderer {
|
||||
Ok(renderer)
|
||||
}
|
||||
fn new(models: HashMap<&'static str, ModelPart>, registry: &BlockRegistry) -> Self {
|
||||
let closed_chests = [false, true].map(|nether| {
|
||||
["chest", "double_chest_left", "double_chest_right"].map(|name| {
|
||||
[0.0f32, 90.0, 180.0, 270.0].map(|y_rot| {
|
||||
let mut vertices = Vec::new();
|
||||
if let Some(model) = models.get(name) {
|
||||
draw_part(
|
||||
model,
|
||||
"root",
|
||||
around(
|
||||
Mat4::from_rotation_y((-y_rot).to_radians()),
|
||||
Vec3::new(0.5, 0.0, 0.5),
|
||||
),
|
||||
&|name, p| {
|
||||
if name == "lid" || name == "lock" {
|
||||
p[3] = crate::block_entity_anim::chest_lid_x_rot(0.0);
|
||||
}
|
||||
},
|
||||
Paint {
|
||||
sprite: Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 16,
|
||||
h: 16,
|
||||
},
|
||||
tint: 0xFFFFFF,
|
||||
light: 0,
|
||||
lights: diffuse_lights(nether),
|
||||
},
|
||||
&mut vertices,
|
||||
);
|
||||
}
|
||||
vertices
|
||||
})
|
||||
})
|
||||
});
|
||||
let vanilla = Arc::new(BlockEntityShapes::new(models, HashMap::new()));
|
||||
let kinds = (0..registry.len() as u32)
|
||||
.map(|id| {
|
||||
registry.state(BlockStateId(id)).map_or(Kind::None, |s| {
|
||||
@@ -959,14 +1026,31 @@ impl BlockEntityRenderer {
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
models,
|
||||
shapes: RwLock::new(vanilla.clone()),
|
||||
vanilla,
|
||||
kinds,
|
||||
closed_chests,
|
||||
pot_patterns: HashMap::new(),
|
||||
visibility: None,
|
||||
sign_texts: Mutex::default(),
|
||||
}
|
||||
}
|
||||
/// The models the packs under `root` give, their `.jem` files over vanilla's, for
|
||||
/// [`Self::set_shapes`] (a pack reload): vanilla's own, shared, without any.
|
||||
pub fn shapes_for(&self, root: &Path) -> Arc<BlockEntityShapes> {
|
||||
match crate::block_entity_cem::reshape(root, &self.vanilla.models) {
|
||||
Some((models, textures)) => Arc::new(BlockEntityShapes::new(models, textures)),
|
||||
None => self.vanilla.clone(),
|
||||
}
|
||||
}
|
||||
pub fn set_shapes(&self, shapes: Arc<BlockEntityShapes>) {
|
||||
*self.shapes.write().unwrap_or_else(PoisonError::into_inner) = shapes;
|
||||
}
|
||||
fn shapes(&self) -> Arc<BlockEntityShapes> {
|
||||
self.shapes
|
||||
.read()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.clone()
|
||||
}
|
||||
fn kind(&self, state: u32) -> &Kind {
|
||||
self.kinds.get(state as usize).unwrap_or(&Kind::None)
|
||||
}
|
||||
@@ -1260,6 +1344,7 @@ impl BlockEntityRenderer {
|
||||
world_chests: bool,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let shapes = self.shapes();
|
||||
let sprite = |name: &str| scene.sprites.get(name).copied();
|
||||
let paint = |name: &str, tint: u32, packed: u8| {
|
||||
let rect = if crumbling {
|
||||
@@ -1279,6 +1364,9 @@ impl BlockEntityRenderer {
|
||||
tint,
|
||||
light: vertex_light(packed),
|
||||
lights,
|
||||
glow: (!crumbling)
|
||||
.then(|| scene.emissive.overlay(name).and_then(sprite))
|
||||
.flatten(),
|
||||
})
|
||||
};
|
||||
let none = |_: &str, _: &mut [f32; 9]| {};
|
||||
@@ -1349,12 +1437,34 @@ impl BlockEntityRenderer {
|
||||
} else {
|
||||
*texture
|
||||
};
|
||||
let model = match part {
|
||||
ChestPart::Single => "chest",
|
||||
ChestPart::Left => "double_chest_left",
|
||||
ChestPart::Right => "double_chest_right",
|
||||
// Trapped and ender chests have models of their own when a `.jem` reshapes
|
||||
// them; copper chests have no CEM name and draw vanilla's chest.
|
||||
let shapes: &BlockEntityShapes =
|
||||
if matches!(block.as_str(), "chest" | "trapped_chest" | "ender_chest") {
|
||||
&shapes
|
||||
} else {
|
||||
&self.vanilla
|
||||
};
|
||||
let (own, chest) = match (block.as_str(), part) {
|
||||
("trapped_chest", ChestPart::Single) => ("trapped_chest", "chest"),
|
||||
("trapped_chest", ChestPart::Left) => {
|
||||
("trapped_chest_left", "double_chest_left")
|
||||
}
|
||||
("trapped_chest", ChestPart::Right) => {
|
||||
("trapped_chest_right", "double_chest_right")
|
||||
}
|
||||
("ender_chest", _) => ("ender_chest", "chest"),
|
||||
(_, ChestPart::Single) => ("chest", "chest"),
|
||||
(_, ChestPart::Left) => ("double_chest_left", "double_chest_left"),
|
||||
(_, ChestPart::Right) => ("double_chest_right", "double_chest_right"),
|
||||
};
|
||||
let Some(model) = self.models.get(model) else {
|
||||
let key = if shapes.models.contains_key(own) {
|
||||
own
|
||||
} else {
|
||||
chest
|
||||
};
|
||||
let texture = shapes.texture(key, texture, scene.sprites);
|
||||
let Some(model) = shapes.models.get(key) else {
|
||||
return;
|
||||
};
|
||||
let (open, other) = self.chest_openness(
|
||||
@@ -1370,19 +1480,25 @@ impl BlockEntityRenderer {
|
||||
packed = (packed >> 4).max(l >> 4) << 4 | (packed & 15).max(l & 15);
|
||||
}
|
||||
if world_chests && open == 0.0 {
|
||||
if let Some(paint) = paint(texture, 0xFFFFFF, packed) {
|
||||
let vertices = &self.closed_chests[usize::from(scene.nether)]
|
||||
[*part as usize][*y_rot as usize / 90];
|
||||
if let (Some(paint), Some(closed)) = (
|
||||
paint(texture, 0xFFFFFF, packed),
|
||||
shapes.closed_chests.get(key),
|
||||
) {
|
||||
let vertices = &closed[usize::from(scene.nether)][*y_rot as usize / 90];
|
||||
let origin = base.w_axis.truncate();
|
||||
out.extend(vertices.iter().map(|v| Vertex {
|
||||
pos: (Vec3::from_array(v.pos) + origin).to_array(),
|
||||
uv: [
|
||||
paint.sprite.u0 + (paint.sprite.u1 - paint.sprite.u0) * v.uv[0],
|
||||
paint.sprite.v0 + (paint.sprite.v1 - paint.sprite.v0) * v.uv[1],
|
||||
],
|
||||
light: paint.light,
|
||||
..*v
|
||||
}));
|
||||
let passes = std::iter::once((paint.sprite, paint.light))
|
||||
.chain(paint.glow.map(|g| (g, FULL_BRIGHT)));
|
||||
for (r, light) in passes {
|
||||
out.extend(vertices.iter().map(|v| Vertex {
|
||||
pos: (Vec3::from_array(v.pos) + origin).to_array(),
|
||||
uv: [
|
||||
r.u0 + (r.u1 - r.u0) * v.uv[0],
|
||||
r.v0 + (r.v1 - r.v0) * v.uv[1],
|
||||
],
|
||||
light,
|
||||
..*v
|
||||
}));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1403,8 +1519,12 @@ impl BlockEntityRenderer {
|
||||
}
|
||||
Kind::Shulker { texture, facing } => {
|
||||
let (Some(model), Some(paint)) = (
|
||||
self.models.get("shulker_box"),
|
||||
paint(texture, 0xFFFFFF, packed),
|
||||
shapes.models.get("shulker_box"),
|
||||
paint(
|
||||
shapes.texture("shulker_box", texture, scene.sprites),
|
||||
0xFFFFFF,
|
||||
packed,
|
||||
),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
@@ -1428,12 +1548,13 @@ impl BlockEntityRenderer {
|
||||
draw_part(model, "root", m, &lid, paint, out);
|
||||
}
|
||||
Kind::Banner { color, wall, angle } => {
|
||||
let (main, flag) = if *wall {
|
||||
let (main_key, flag_key) = if *wall {
|
||||
("wall_banner", "wall_banner_flag")
|
||||
} else {
|
||||
("standing_banner", "standing_banner_flag")
|
||||
};
|
||||
let (Some(main), Some(flag)) = (self.models.get(main), self.models.get(flag))
|
||||
let (Some(main), Some(flag)) =
|
||||
(shapes.models.get(main_key), shapes.models.get(flag_key))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
@@ -1454,8 +1575,13 @@ impl BlockEntityRenderer {
|
||||
p[3] = (-0.0125 + 0.01 * (2.0 * PI * phase).mcos()) * PI;
|
||||
}
|
||||
};
|
||||
if let Some(paint) = paint("entity/banner/banner_base", 0xFFFFFF, packed) {
|
||||
let banner_base = "entity/banner/banner_base";
|
||||
let main_texture = shapes.texture(main_key, banner_base, scene.sprites);
|
||||
if let Some(paint) = paint(main_texture, 0xFFFFFF, packed) {
|
||||
draw_part(main, "root", m, &none, paint, out);
|
||||
}
|
||||
let flag_texture = shapes.texture(flag_key, banner_base, scene.sprites);
|
||||
if let Some(paint) = paint(flag_texture, 0xFFFFFF, packed) {
|
||||
draw_part(flag, "root", m, &wave, paint, out);
|
||||
}
|
||||
// Pattern passes must not multiply the crumbling decal.
|
||||
@@ -1497,9 +1623,12 @@ impl BlockEntityRenderer {
|
||||
None
|
||||
};
|
||||
let has_profile = skin.is_some();
|
||||
let texture = skin.as_deref().unwrap_or(texture);
|
||||
// A `.jem` texture stands in for the default skin, not a profile's.
|
||||
let texture = skin
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| shapes.texture(model, texture, scene.sprites));
|
||||
let (Some(model), Some(paint)) =
|
||||
(self.models.get(model), paint(texture, 0xFFFFFF, packed))
|
||||
(shapes.models.get(model), paint(texture, 0xFFFFFF, packed))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
@@ -1541,8 +1670,12 @@ impl BlockEntityRenderer {
|
||||
}
|
||||
Kind::Bell => {
|
||||
let (Some(model), Some(paint)) = (
|
||||
self.models.get("bell"),
|
||||
paint("entity/bell/bell_body", 0xFFFFFF, packed),
|
||||
shapes.models.get("bell"),
|
||||
paint(
|
||||
shapes.texture("bell", "entity/bell/bell_body", scene.sprites),
|
||||
0xFFFFFF,
|
||||
packed,
|
||||
),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
@@ -1570,8 +1703,8 @@ impl BlockEntityRenderer {
|
||||
}
|
||||
Kind::DecoratedPot { y_rot } => {
|
||||
let (Some(base_model), Some(sides)) = (
|
||||
self.models.get("decorated_pot_base"),
|
||||
self.models.get("decorated_pot_sides"),
|
||||
shapes.models.get("decorated_pot_base"),
|
||||
shapes.models.get("decorated_pot_sides"),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
@@ -1599,17 +1732,20 @@ impl BlockEntityRenderer {
|
||||
}
|
||||
}
|
||||
let first = out.len();
|
||||
if let Some(paint) =
|
||||
paint("entity/decorated_pot/decorated_pot_base", 0xFFFFFF, packed)
|
||||
{
|
||||
let pot_base = "entity/decorated_pot/decorated_pot_base";
|
||||
let pot_base = shapes.texture("decorated_pot_base", pot_base, scene.sprites);
|
||||
if let Some(paint) = paint(pot_base, 0xFFFFFF, packed) {
|
||||
draw_part(base_model, "root", m, &none, paint, out);
|
||||
}
|
||||
// PotDecorations (back, left, right, front); items without a pattern use the blank side.
|
||||
// PotDecorations (back, left, right, front); items without a pattern use the blank
|
||||
// side (a `.jem`'s texture, if any).
|
||||
let blank = "entity/decorated_pot/decorated_pot_side";
|
||||
let blank = shapes.texture("decorated_pot_sides", blank, scene.sprites);
|
||||
for (side, item) in ["back", "left", "right", "front"].iter().zip(&a.sherds) {
|
||||
let texture = item
|
||||
.as_ref()
|
||||
.and_then(|i| self.pot_patterns.get(i))
|
||||
.map_or("entity/decorated_pot/decorated_pot_side", String::as_str);
|
||||
.map_or(blank, String::as_str);
|
||||
if let (Some(part), Some(paint)) =
|
||||
(sides.child(side), paint(texture, 0xFFFFFF, packed))
|
||||
{
|
||||
@@ -1659,11 +1795,14 @@ impl BlockEntityRenderer {
|
||||
let (ticks, active, hunting, raw_rotation) = (scene.anim)(pos).conduit;
|
||||
// ConduitRenderer.extractRenderState.
|
||||
let rotation = (raw_rotation + if active { scene.partial } else { 0.0 }) * -0.0375;
|
||||
let tex = |name: &str| paint(name, 0xFFFFFF, packed);
|
||||
let tex = |key: &str, name: &str| {
|
||||
paint(shapes.texture(key, name, scene.sprites), 0xFFFFFF, packed)
|
||||
};
|
||||
if !active {
|
||||
let (Some(model), Some(paint)) =
|
||||
(self.models.get("conduit_shell"), tex("entity/conduit/base"))
|
||||
else {
|
||||
let (Some(model), Some(paint)) = (
|
||||
shapes.models.get("conduit_shell"),
|
||||
tex("conduit_shell", "entity/conduit/base"),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let m = base
|
||||
@@ -1678,9 +1817,10 @@ impl BlockEntityRenderer {
|
||||
let hh = (anim_time * 0.1).msin() / 2.0 + 0.5;
|
||||
let hh = hh * hh + hh;
|
||||
let lift = Vec3::new(0.5, 0.3 + hh * 0.2, 0.5);
|
||||
if let (Some(model), Some(paint)) =
|
||||
(self.models.get("conduit_cage"), tex("entity/conduit/cage"))
|
||||
{
|
||||
if let (Some(model), Some(paint)) = (
|
||||
shapes.models.get("conduit_cage"),
|
||||
tex("conduit_cage", "entity/conduit/cage"),
|
||||
) {
|
||||
let axis = Vec3::new(0.5, 1.0, 0.5).normalize();
|
||||
let m = base
|
||||
* Mat4::from_translation(lift)
|
||||
@@ -1696,7 +1836,9 @@ impl BlockEntityRenderer {
|
||||
} else {
|
||||
"entity/conduit/wind"
|
||||
};
|
||||
if let (Some(model), Some(paint)) = (self.models.get("conduit_wind"), tex(wind)) {
|
||||
if let (Some(model), Some(paint)) =
|
||||
(shapes.models.get("conduit_wind"), tex("conduit_wind", wind))
|
||||
{
|
||||
let turn = match phase {
|
||||
1 => Mat4::from_rotation_x(PI / 2.0),
|
||||
2 => Mat4::from_rotation_z(PI / 2.0),
|
||||
@@ -1714,7 +1856,9 @@ impl BlockEntityRenderer {
|
||||
} else {
|
||||
"entity/conduit/closed_eye"
|
||||
};
|
||||
if let (Some(model), Some(paint)) = (self.models.get("conduit_eye"), tex(eye)) {
|
||||
if let (Some(model), Some(paint)) =
|
||||
(shapes.models.get("conduit_eye"), tex("conduit_eye", eye))
|
||||
{
|
||||
let m = base
|
||||
* Mat4::from_translation(lift)
|
||||
* Mat4::from_scale(Vec3::splat(0.5))
|
||||
@@ -1725,9 +1869,14 @@ impl BlockEntityRenderer {
|
||||
}
|
||||
}
|
||||
Kind::EnchantingTable => {
|
||||
let book = "entity/enchantment/enchanting_table_book";
|
||||
let (Some(model), Some(paint)) = (
|
||||
self.models.get("book"),
|
||||
paint("entity/enchantment/enchanting_table_book", 0xFFFFFF, packed),
|
||||
shapes.models.get("book"),
|
||||
paint(
|
||||
shapes.texture("book", book, scene.sprites),
|
||||
0xFFFFFF,
|
||||
packed,
|
||||
),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
@@ -1746,9 +1895,16 @@ impl BlockEntityRenderer {
|
||||
draw_part(model, "root", m, &book_pose(openness, ff1, ff2), paint, out);
|
||||
}
|
||||
Kind::Lectern { y_rot } => {
|
||||
// Its own `.jem`'s book, else the enchanting table's.
|
||||
let key = if shapes.models.contains_key("lectern_book") {
|
||||
"lectern_book"
|
||||
} else {
|
||||
"book"
|
||||
};
|
||||
let book = "entity/enchantment/enchanting_table_book";
|
||||
let (Some(model), Some(paint)) = (
|
||||
self.models.get("book"),
|
||||
paint("entity/enchantment/enchanting_table_book", 0xFFFFFF, packed),
|
||||
shapes.models.get(key),
|
||||
paint(shapes.texture(key, book, scene.sprites), 0xFFFFFF, packed),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
@@ -1779,7 +1935,7 @@ impl BlockEntityRenderer {
|
||||
_ => "copper_golem",
|
||||
};
|
||||
let (Some(model), Some(paint)) =
|
||||
(self.models.get(key), paint(texture, 0xFFFFFF, packed))
|
||||
(shapes.models.get(key), paint(texture, 0xFFFFFF, packed))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
@@ -2106,6 +2262,7 @@ mod tests {
|
||||
max_y: 320,
|
||||
world: &world,
|
||||
sprites: &base_sprites,
|
||||
emissive: &Default::default(),
|
||||
nether: false,
|
||||
lid: &|_| 0.0,
|
||||
sign: &|_| None,
|
||||
@@ -2305,6 +2462,7 @@ mod tests {
|
||||
tint: 0xFFFFFF,
|
||||
light: 0,
|
||||
lights: diffuse_lights(false),
|
||||
glow: None,
|
||||
},
|
||||
&mut out,
|
||||
);
|
||||
@@ -2313,6 +2471,55 @@ mod tests {
|
||||
assert_eq!(out[2].uv, [0.5, 0.5]);
|
||||
}
|
||||
|
||||
/// An OptiFine emissive overlay draws each polygon again with its own sprite at full
|
||||
/// brightness, shaded alike.
|
||||
#[test]
|
||||
fn a_glow_draws_the_part_again_at_full_brightness() {
|
||||
let part = ModelPart {
|
||||
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
|
||||
polygons: vec![[
|
||||
[0.0, 0.0, 0.0, 0.0, 0.0],
|
||||
[16.0, 0.0, 0.0, 1.0, 0.0],
|
||||
[16.0, 0.0, 16.0, 1.0, 1.0],
|
||||
[0.0, 0.0, 16.0, 0.0, 1.0],
|
||||
]],
|
||||
children: vec![],
|
||||
};
|
||||
let rect = |u0, v0| Rect {
|
||||
u0,
|
||||
v0,
|
||||
u1: u0 + 0.5,
|
||||
v1: v0 + 0.5,
|
||||
w: 1,
|
||||
h: 1,
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
let light = vertex_light(0x52);
|
||||
draw_part(
|
||||
&part,
|
||||
"root",
|
||||
Mat4::IDENTITY,
|
||||
&|_, _| {},
|
||||
Paint {
|
||||
sprite: rect(0.0, 0.0),
|
||||
tint: 0xFFFFFF,
|
||||
light,
|
||||
lights: diffuse_lights(false),
|
||||
glow: Some(rect(0.5, 0.5)),
|
||||
},
|
||||
&mut out,
|
||||
);
|
||||
assert_eq!(out.len(), 8);
|
||||
let (base, glow) = out.split_at(4);
|
||||
assert!(base.iter().all(|v| v.light == light));
|
||||
assert!(glow.iter().all(|v| v.light == 240 | (240 << 8)));
|
||||
assert_eq!(glow[2].uv, [1.0, 1.0]);
|
||||
assert_eq!(base[2].uv, [0.5, 0.5]);
|
||||
for (b, g) in base.iter().zip(glow) {
|
||||
assert_eq!((b.pos, b.color), (g.pos, g.color));
|
||||
}
|
||||
}
|
||||
|
||||
/// TextFeatureRenderer.renderText: an outlined call's text is drawn over
|
||||
/// its outline (Font.DisplayMode.POLYGON_OFFSET), never level with it,
|
||||
/// and plain text stays on the sign's plane.
|
||||
@@ -2341,6 +2548,7 @@ mod tests {
|
||||
max_y: 320,
|
||||
world: &world,
|
||||
sprites: &sprites,
|
||||
emissive: &Default::default(),
|
||||
nether: false,
|
||||
lid: &|_| 0.0,
|
||||
sign: &|_| None,
|
||||
@@ -2379,7 +2587,8 @@ mod tests {
|
||||
#[test]
|
||||
fn cached_sign_text_matches_and_follows_changes() {
|
||||
let renderer = BlockEntityRenderer {
|
||||
models: HashMap::new(),
|
||||
vanilla: Default::default(),
|
||||
shapes: Default::default(),
|
||||
kinds: vec![
|
||||
Kind::Sign {
|
||||
hanging: false,
|
||||
@@ -2392,7 +2601,6 @@ mod tests {
|
||||
angle: 90.0,
|
||||
},
|
||||
],
|
||||
closed_chests: Default::default(),
|
||||
pot_patterns: HashMap::new(),
|
||||
visibility: None,
|
||||
sign_texts: Mutex::default(),
|
||||
@@ -2445,6 +2653,7 @@ mod tests {
|
||||
max_y: 320,
|
||||
world: &world,
|
||||
sprites: &sprites,
|
||||
emissive: &Default::default(),
|
||||
nether: false,
|
||||
lid: &|_| 0.0,
|
||||
sign: &first_sign,
|
||||
@@ -2553,6 +2762,7 @@ mod tests {
|
||||
max_y: 320,
|
||||
world: &world,
|
||||
sprites: &sprites,
|
||||
emissive: &Default::default(),
|
||||
nether: false,
|
||||
lid: &|_| 0.0,
|
||||
sign: &|_| None,
|
||||
@@ -2574,4 +2784,131 @@ mod tests {
|
||||
"entitySolid culls the pot's back faces"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chest_jem_reshapes_the_closed_chest_and_paints_its_texture() {
|
||||
use crate::block_entity_cem::tests::{BOX, chest, pack};
|
||||
let vanilla = Arc::new(BlockEntityShapes::new(
|
||||
HashMap::from([("chest", chest())]),
|
||||
HashMap::new(),
|
||||
));
|
||||
let renderer = BlockEntityRenderer {
|
||||
shapes: RwLock::new(vanilla.clone()),
|
||||
vanilla,
|
||||
kinds: Vec::new(),
|
||||
pot_patterns: HashMap::new(),
|
||||
visibility: None,
|
||||
sign_texts: Mutex::default(),
|
||||
};
|
||||
// No block entity's `.jem` (a mob's only): vanilla's shapes, not a copy.
|
||||
let empty = pack("render-none", &[("optifine/cem/zombie.jem", "{}".into())]);
|
||||
assert!(Arc::ptr_eq(&renderer.shapes_for(&empty), &renderer.vanilla));
|
||||
let root = pack(
|
||||
"render-chest",
|
||||
&[(
|
||||
"optifine/cem/chest.jem",
|
||||
format!(
|
||||
r#"{{"texture": "textures/entity/chest/fancy.png",
|
||||
"models": [{{"part": "lid", {BOX}}}]}}"#
|
||||
),
|
||||
)],
|
||||
);
|
||||
renderer.set_shapes(renderer.shapes_for(&root));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let _ = std::fs::remove_dir_all(&empty);
|
||||
let rect = |u0: f32| Rect {
|
||||
u0,
|
||||
v0: 0.0,
|
||||
u1: u0 + 0.2,
|
||||
v1: 0.2,
|
||||
w: 16,
|
||||
h: 16,
|
||||
};
|
||||
let sprites: HashMap<String, Rect> = [
|
||||
("entity/chest/normal", rect(0.0)),
|
||||
("entity/chest/ender", rect(0.2)),
|
||||
("entity/chest/fancy", rect(0.4)),
|
||||
("entity/chest/copper", rect(0.6)),
|
||||
]
|
||||
.map(|(name, r)| (name.to_owned(), r))
|
||||
.into();
|
||||
let world = World::new(BlockStateId(0), 1);
|
||||
let scene = Scene {
|
||||
camera: DVec3::ZERO,
|
||||
orientation: Quat::IDENTITY,
|
||||
anim: &|_| BlockAnim::default(),
|
||||
texts: None,
|
||||
partial: 0.0,
|
||||
game_time: 0,
|
||||
max_y: 320,
|
||||
world: &world,
|
||||
sprites: &sprites,
|
||||
emissive: &Default::default(),
|
||||
nether: false,
|
||||
lid: &|_| 0.0,
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyphs: 0,
|
||||
};
|
||||
// A closed single chest of `block` drawn by `renderer`: positions and UVs.
|
||||
let draw = |renderer: &BlockEntityRenderer, block: &str, texture: &'static str| {
|
||||
let kind = Kind::Chest {
|
||||
block: block.into(),
|
||||
texture,
|
||||
part: ChestPart::Single,
|
||||
y_rot: 0.0,
|
||||
double_with: None,
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
renderer.render_one(
|
||||
&scene,
|
||||
[0; 3],
|
||||
&kind,
|
||||
Mat4::IDENTITY,
|
||||
0xF0,
|
||||
diffuse_lights(false),
|
||||
false,
|
||||
true,
|
||||
&mut out,
|
||||
);
|
||||
out.iter().map(|v| (v.pos, v.uv)).collect::<Vec<_>>()
|
||||
};
|
||||
// The chest's three faces become the bottom's, the lock's and the custom lid's six, in
|
||||
// the `.jem`'s texture. Ender and copper chests have no file of their own: vanilla's
|
||||
// three faces in their own textures. (Trapped chests: `block_entity_cem`'s tests; their
|
||||
// texture turns festive in late December.)
|
||||
for (block, texture, u0, faces) in [
|
||||
("chest", "entity/chest/normal", 0.4, 8),
|
||||
("ender_chest", "entity/chest/ender", 0.2, 3),
|
||||
("copper_chest", "entity/chest/copper", 0.6, 3),
|
||||
] {
|
||||
let out = draw(&renderer, block, texture);
|
||||
assert_eq!(out.len(), faces * 4, "{block}");
|
||||
assert!(
|
||||
out.iter().all(|(_, uv)| (u0..=u0 + 0.2).contains(&uv[0])),
|
||||
"{block}"
|
||||
);
|
||||
}
|
||||
// A copper chest draws exactly what it draws without any pack.
|
||||
let plain = BlockEntityRenderer {
|
||||
shapes: RwLock::new(renderer.vanilla.clone()),
|
||||
vanilla: renderer.vanilla.clone(),
|
||||
kinds: Vec::new(),
|
||||
pot_patterns: HashMap::new(),
|
||||
visibility: None,
|
||||
sign_texts: Mutex::default(),
|
||||
};
|
||||
assert_eq!(
|
||||
draw(&renderer, "copper_chest", "entity/chest/copper"),
|
||||
draw(&plain, "copper_chest", "entity/chest/copper")
|
||||
);
|
||||
// The atlas has no such texture: vanilla's.
|
||||
let shapes = renderer.shapes();
|
||||
assert_eq!(
|
||||
shapes.texture("chest", "entity/chest/normal", &HashMap::new()),
|
||||
"entity/chest/normal"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ use crate::{
|
||||
};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashSet, fs, path::Path};
|
||||
use std::{collections::HashSet, path::Path};
|
||||
|
||||
pub use horizon_client_core::potions::{
|
||||
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, all_effects,
|
||||
@@ -158,6 +158,28 @@ pub struct Brewing {
|
||||
/// Items accepted in the bottle slots (`PotionIngredient.isPotionInput`).
|
||||
pub inputs: HashSet<String>,
|
||||
}
|
||||
/// The files right in `recipe/brewing/`, parsed, by id: taken from the whole
|
||||
/// recipe folder's read when another table holds it (the start loads them
|
||||
/// together), else read alone. A file that doesn't parse is left out.
|
||||
fn brewing_sources(root: &Path) -> Vec<(String, Value)> {
|
||||
let recipe = root.join("data/minecraft/recipe");
|
||||
let (folder, prefix) = match crate::json_folder::held(&recipe) {
|
||||
Some(folder) => (folder, "brewing/"),
|
||||
None => match crate::json_folder::read(&recipe.join("brewing")) {
|
||||
Ok(folder) => (folder, ""),
|
||||
Err(_) => return Vec::new(),
|
||||
},
|
||||
};
|
||||
folder
|
||||
.files
|
||||
.iter()
|
||||
.filter_map(|f| {
|
||||
let name = f.name.strip_prefix(prefix).filter(|n| !n.contains('/'))?;
|
||||
let v = serde_json::from_slice::<Value>(f.bytes().ok()?).ok()?;
|
||||
Some((format!("minecraft:brewing/{name}"), v))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
impl Brewing {
|
||||
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Self {
|
||||
// NeoForge's load conditions: a file whose don't hold is silently
|
||||
@@ -172,23 +194,7 @@ impl Brewing {
|
||||
let conditions_hold = |v: &Value| {
|
||||
horizon_world::conditions::conditions_hold(v, &|id: &str| mods.iter().any(|m| m == id))
|
||||
};
|
||||
let mut sources: Vec<(String, Value)> =
|
||||
fs::read_dir(root.join("data/minecraft/recipe/brewing"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let bytes = fs::read(e.path()).ok()?;
|
||||
let v = serde_json::from_slice::<Value>(&bytes).ok()?;
|
||||
Some((
|
||||
format!(
|
||||
"minecraft:brewing/{}",
|
||||
e.file_name().to_string_lossy().strip_suffix(".json")?
|
||||
),
|
||||
v,
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
let mut sources: Vec<(String, Value)> = brewing_sources(root);
|
||||
// The world's packs' brewing recipes over the game's own: the top
|
||||
// pack's file for each id replaces it (`SimpleJsonResourceReloadListener`).
|
||||
for (id, files) in overlay.kind("recipe") {
|
||||
|
||||
@@ -419,6 +419,31 @@ pub enum CameraFluid {
|
||||
PowderSnow,
|
||||
}
|
||||
|
||||
/// `Camera.NearPlane`: the near plane's centre and its half extents toward
|
||||
/// the camera's left and up, relative to the camera.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct NearPlane {
|
||||
pub forward: [f64; 3],
|
||||
pub left: [f64; 3],
|
||||
pub up: [f64; 3],
|
||||
}
|
||||
impl NearPlane {
|
||||
/// `forward`, `getTopLeft`, `getTopRight`, `getBottomLeft`, `getBottomRight`:
|
||||
/// the points `Camera.getFluidInCamera` looks for lava and powder snow at.
|
||||
pub fn points(&self) -> [[f64; 3]; 5] {
|
||||
let corner = |up: f64, left: f64| -> [f64; 3] {
|
||||
std::array::from_fn(|i| self.forward[i] + self.up[i] * up + self.left[i] * left)
|
||||
};
|
||||
[
|
||||
self.forward,
|
||||
corner(1.0, 1.0),
|
||||
corner(1.0, -1.0),
|
||||
corner(-1.0, 1.0),
|
||||
corner(-1.0, -1.0),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// `Camera`: the eye-height and field-of-view easing and the view placement.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Camera {
|
||||
@@ -481,6 +506,19 @@ impl Camera {
|
||||
self.up = self.rotation.transform([0.0, 1.0, 0.0]);
|
||||
self.left = self.rotation.transform([-1.0, 0.0, 0.0]);
|
||||
}
|
||||
/// `Camera.getNearPlane(fov)` for a `width` x `height` window, at the
|
||||
/// projection's `zNear` of 0.05.
|
||||
pub fn near_plane(&self, fov: f32, width: f32, height: f32) -> NearPlane {
|
||||
let z_near = f64::from(0.05f32);
|
||||
let aspect = f64::from(width) / f64::from(height);
|
||||
let plane_height = horizon_core::jmath::tan(f64::from(fov * DEG_TO_RAD) / 2.0) * z_near;
|
||||
let scale = |v: [f32; 3], by: f64| v.map(|c| f64::from(c) * by);
|
||||
NearPlane {
|
||||
forward: scale(self.forwards, z_near),
|
||||
left: scale(self.left, plane_height * aspect),
|
||||
up: scale(self.up, plane_height),
|
||||
}
|
||||
}
|
||||
/// `Camera.move(forwards, up, right)`.
|
||||
pub fn move_by(&mut self, forwards: f32, up: f32, right: f32) {
|
||||
let offset = self.rotation.transform([right, up, -forwards]);
|
||||
|
||||
@@ -644,6 +644,10 @@ impl RolledLoot {
|
||||
/// Save data loaded with a chunk that Horizon keeps but does not simulate.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ChunkExtras {
|
||||
/// Server-owned mod data attached to this chunk.
|
||||
pub attachments: horizon_world::attachments::Attachments,
|
||||
/// Attachment maps of the chunk's block entities, on a save snapshot.
|
||||
pub block_entity_attachments: Vec<((i32, i32, i32), horizon_world::attachments::Attachments)>,
|
||||
/// Chunk root keys written back as loaded (structures, PostProcessing,
|
||||
/// InhabitedTime, blending data, ...).
|
||||
pub root: Compound,
|
||||
|
||||
@@ -8,7 +8,13 @@ use horizon_world::{
|
||||
level::sculk::SculkSpreader,
|
||||
nbt::{Compound, Tag},
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
/// An entity's UUID as its four ints (`UUIDUtil.CODEC`).
|
||||
pub type Uuid = [i32; 4];
|
||||
@@ -94,18 +100,98 @@ pub enum BlockEntity {
|
||||
/// `TestInstanceBlockEntity` (`test_instance_block.rs`).
|
||||
TestInstanceBlock(Box<crate::test_instance_block::TestInstanceBlock>),
|
||||
}
|
||||
/// A mod's block entity: its type id (`mymod:machine`), its saved
|
||||
/// A mod's block entity, read and changed through its fields
|
||||
/// (`ModBlockEntityFields`) as a struct's: `entity.nbt`. Each change goes
|
||||
/// through `DerefMut` and gives it a new `revision`, so a client tells
|
||||
/// whether what it kept of it (`BlockEntityDraw::keep`) is still right
|
||||
/// without comparing the whole entity, and no write can skip that.
|
||||
pub struct ModBlockEntity {
|
||||
fields: ModBlockEntityFields,
|
||||
revision: ModRevision,
|
||||
}
|
||||
/// What a `ModBlockEntity` holds: its type id (`mymod:machine`), its saved
|
||||
/// compound without `id`, `x`, `y`, `z` and `Items`, its item slots
|
||||
/// (`Items`) when its block has a container (`ModContainer`), and the data
|
||||
/// slots its menu shows (`ContainerData`: a furnace menu's flame and arrow),
|
||||
/// which the mod sets and nothing saves.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ModBlockEntity {
|
||||
pub struct ModBlockEntityFields {
|
||||
pub id: String,
|
||||
pub nbt: Compound,
|
||||
pub items: Vec<Option<ItemStack>>,
|
||||
pub data: Vec<i32>,
|
||||
}
|
||||
/// Which contents a `ModBlockEntity` has had: one per entity made (a clone
|
||||
/// is another), counted on by each change. Equal revisions, equal contents.
|
||||
/// Neither saved nor compared.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ModRevision {
|
||||
born: u64,
|
||||
changes: u64,
|
||||
}
|
||||
static MOD_BLOCK_ENTITIES_MADE: AtomicU64 = AtomicU64::new(0);
|
||||
impl ModBlockEntity {
|
||||
pub fn new(fields: ModBlockEntityFields) -> Self {
|
||||
Self {
|
||||
fields,
|
||||
revision: ModRevision {
|
||||
born: MOD_BLOCK_ENTITIES_MADE.fetch_add(1, Ordering::Relaxed),
|
||||
changes: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
pub fn revision(&self) -> ModRevision {
|
||||
self.revision
|
||||
}
|
||||
/// A client's copy loads the tag the server sent (`loadWithComponents`
|
||||
/// in `ClientPacketListener.handleBlockEntityData`): `tag`'s fields
|
||||
/// replace its own of the same names. One it already has as sent is
|
||||
/// left alone, so a tag sent again unchanged keeps its revision.
|
||||
pub fn merge_update_tag(&mut self, tag: &Compound) {
|
||||
for (k, v) in tag {
|
||||
if self.nbt.get(k) != Some(v) {
|
||||
self.nbt.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl From<ModBlockEntityFields> for ModBlockEntity {
|
||||
fn from(fields: ModBlockEntityFields) -> Self {
|
||||
Self::new(fields)
|
||||
}
|
||||
}
|
||||
impl Default for ModBlockEntity {
|
||||
fn default() -> Self {
|
||||
Self::new(ModBlockEntityFields::default())
|
||||
}
|
||||
}
|
||||
impl Clone for ModBlockEntity {
|
||||
fn clone(&self) -> Self {
|
||||
Self::new(self.fields.clone())
|
||||
}
|
||||
}
|
||||
impl PartialEq for ModBlockEntity {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.fields == other.fields
|
||||
}
|
||||
}
|
||||
impl std::fmt::Debug for ModBlockEntity {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.fields.fmt(f)
|
||||
}
|
||||
}
|
||||
impl std::ops::Deref for ModBlockEntity {
|
||||
type Target = ModBlockEntityFields;
|
||||
fn deref(&self) -> &ModBlockEntityFields {
|
||||
&self.fields
|
||||
}
|
||||
}
|
||||
impl std::ops::DerefMut for ModBlockEntity {
|
||||
fn deref_mut(&mut self) -> &mut ModBlockEntityFields {
|
||||
self.revision.changes += 1;
|
||||
&mut self.fields
|
||||
}
|
||||
}
|
||||
/// The container of a mod block's block entity, as a `WorldlyContainer`
|
||||
/// says it: its slot count, the slots each face reaches
|
||||
/// (`getSlotsForFace`, by `Direction`: down, up, north, south, west, east),
|
||||
@@ -605,3 +691,64 @@ pub struct CommandBlock {
|
||||
pub auto: bool,
|
||||
pub condition_met: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_mod_block_entitys_revision_moves_with_each_change_and_only_then() {
|
||||
let mut machine = ModBlockEntity::new(ModBlockEntityFields {
|
||||
id: "mymod:machine".into(),
|
||||
data: vec![0],
|
||||
..Default::default()
|
||||
});
|
||||
let made = machine.revision();
|
||||
// Reads leave it.
|
||||
assert_eq!(machine.data[0] + machine.nbt.len() as i32, 0);
|
||||
assert_eq!(machine.revision(), made);
|
||||
// Every way to change a field moves it.
|
||||
let mut seen = vec![made];
|
||||
let changes: [fn(&mut ModBlockEntity); 4] = [
|
||||
|m| m.data[0] = 1,
|
||||
|m| _ = m.nbt.insert("progress".into(), Tag::Int(3)),
|
||||
|m| m.items.push(None),
|
||||
|m| m.id.push('s'),
|
||||
];
|
||||
for change in changes {
|
||||
change(&mut machine);
|
||||
assert!(!seen.contains(&machine.revision()));
|
||||
seen.push(machine.revision());
|
||||
}
|
||||
// Another entity, or a clone, never shares one, even with equal
|
||||
// contents; equality is the contents' alone.
|
||||
let copy = machine.clone();
|
||||
assert_eq!(copy, machine);
|
||||
assert!(!seen.contains(©.revision()));
|
||||
let fresh = ModBlockEntity::default();
|
||||
assert_ne!(fresh.revision(), ModBlockEntity::default().revision());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_update_tag_sent_again_unchanged_keeps_the_revision() {
|
||||
let mut machine = ModBlockEntity::default();
|
||||
let tag = Compound::from([
|
||||
("progress".into(), Tag::Int(3)),
|
||||
("fuel".into(), Tag::Int(9)),
|
||||
]);
|
||||
machine.merge_update_tag(&tag);
|
||||
let merged = machine.revision();
|
||||
machine.merge_update_tag(&tag);
|
||||
assert_eq!(machine.revision(), merged);
|
||||
// One field changed: replaced, the other kept, the revision moved.
|
||||
machine.merge_update_tag(&Compound::from([("progress".into(), Tag::Int(4))]));
|
||||
assert_ne!(machine.revision(), merged);
|
||||
assert_eq!(
|
||||
machine.nbt,
|
||||
Compound::from([
|
||||
("progress".into(), Tag::Int(4)),
|
||||
("fuel".into(), Tag::Int(9)),
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
pub use horizon_optifine::cit::{Hand, Kind, Rule};
|
||||
|
||||
use crate::cit_glint::{Global, StackGlint};
|
||||
use horizon_client_core::component::name_text;
|
||||
use horizon_client_core::scoreboard::comp_text;
|
||||
use horizon_items::item_data::{canon_tag, encode_components};
|
||||
@@ -16,7 +17,7 @@ use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Every rule under the root's `optifine/cit/`, `mcpatcher/cit/` and CIT Resewn's
|
||||
/// `citresewn/cit/` folders, the one chosen first when several match first (`Rule::priority`).
|
||||
@@ -27,13 +28,8 @@ pub fn load_rules(root: &Path) -> Vec<Rule> {
|
||||
.into_iter()
|
||||
.filter(|path| !path.ends_with("/cit.properties"))
|
||||
.filter_map(|path| {
|
||||
// Java reads `.properties` as Latin-1: a pack saved that way still loads.
|
||||
let bytes = fs::read(root.join(&path)).ok()?;
|
||||
let text = String::from_utf8(bytes)
|
||||
.unwrap_or_else(|e| e.into_bytes().iter().map(|&b| char::from(b)).collect());
|
||||
let rule = Rule::parse(&path, &Properties::parse_equals_first(&text), |p| {
|
||||
root.join(p).is_file()
|
||||
});
|
||||
let props = read_properties(&root.join(&path))?;
|
||||
let rule = Rule::parse(&path, &props, |p| root.join(p).is_file());
|
||||
if rule.is_none() {
|
||||
log::warn!("custom item textures: {path} is not a rule Horizon can read");
|
||||
}
|
||||
@@ -44,10 +40,22 @@ pub fn load_rules(root: &Path) -> Vec<Rule> {
|
||||
rules
|
||||
}
|
||||
|
||||
/// A `.properties` file as CIT Resewn reads them (`Properties::parse_equals_first`): UTF-8,
|
||||
/// else Latin-1 as Java reads them, so a pack saved that way still loads. `None` when it can't
|
||||
/// be read.
|
||||
pub fn read_properties(path: &Path) -> Option<Properties> {
|
||||
let bytes = fs::read(path).ok()?;
|
||||
let text = String::from_utf8(bytes)
|
||||
.unwrap_or_else(|e| e.into_bytes().iter().map(|&b| char::from(b)).collect());
|
||||
Some(Properties::parse_equals_first(&text))
|
||||
}
|
||||
|
||||
/// The rules with an index by item and a cache of what each stack matched.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct CitRules {
|
||||
pub rules: Vec<Rule>,
|
||||
/// The pack's `cit.properties`.
|
||||
pub global: Global,
|
||||
/// Per kind (`Kind as usize`), the rules naming each item, in priority order.
|
||||
by_item: [HashMap<String, Vec<u32>>; 4],
|
||||
/// Enchantment rules without `items`, in priority order.
|
||||
@@ -56,6 +64,7 @@ pub struct CitRules {
|
||||
literals: Vec<Vec<Prepared>>,
|
||||
selected: Mutex<HashMap<u64, Option<u32>>>,
|
||||
enchanted: Mutex<HashMap<u64, Vec<u32>>>,
|
||||
glints: Mutex<HashMap<u64, Arc<StackGlint>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
@@ -70,7 +79,10 @@ const CACHE_LIMIT: usize = 4096;
|
||||
impl CitRules {
|
||||
/// The enabled packs' rules under the overlay resource root.
|
||||
pub fn load(root: &Path) -> Self {
|
||||
Self::new(load_rules(root))
|
||||
Self {
|
||||
global: crate::cit_glint::load_global(root),
|
||||
..Self::new(load_rules(root))
|
||||
}
|
||||
}
|
||||
|
||||
/// `rules` in priority order, the winner first.
|
||||
@@ -106,6 +118,19 @@ impl CitRules {
|
||||
self.rules.is_empty()
|
||||
}
|
||||
|
||||
/// Leave out the Enchantment rules `keep` refuses (those without a drawable texture): they
|
||||
/// match no stack, so vanilla's glint and the other rules of their layer stay.
|
||||
pub fn keep_glints(&mut self, keep: impl Fn(usize) -> bool) {
|
||||
self.any_item.retain(|&i| keep(i as usize));
|
||||
let named = &mut self.by_item[Kind::Enchantment as usize];
|
||||
for list in named.values_mut() {
|
||||
list.retain(|&i| keep(i as usize));
|
||||
}
|
||||
named.retain(|_, list| !list.is_empty());
|
||||
lock(&self.enchanted).clear();
|
||||
lock(&self.glints).clear();
|
||||
}
|
||||
|
||||
/// The winning rule of `kind` (Item, Armor or Elytra) for this stack, as an index into `rules`.
|
||||
pub fn select(
|
||||
&self,
|
||||
@@ -123,13 +148,14 @@ impl CitRules {
|
||||
let found = candidates
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|&i| self.matches(i, stack, Some(hand), max_damage, comps.as_ref()));
|
||||
.find(|&i| self.matches(i, stack, hand, max_damage, comps.as_ref()));
|
||||
remember(&self.selected, key, found);
|
||||
found.map(|i| i as usize)
|
||||
}
|
||||
|
||||
/// Every matching Enchantment rule, in priority order (layers are the glint PR's business).
|
||||
pub fn enchantments(&self, stack: &ItemStack, max_damage: u32) -> Vec<usize> {
|
||||
/// Every matching Enchantment rule for the stack drawn in `hand` (`Hand::Main` for one in a
|
||||
/// GUI), in priority order.
|
||||
pub fn enchantments(&self, stack: &ItemStack, hand: Hand, max_damage: u32) -> Vec<usize> {
|
||||
let named = self.by_item[Kind::Enchantment as usize]
|
||||
.get(&*stack.item)
|
||||
.map_or(&[][..], Vec::as_slice);
|
||||
@@ -138,7 +164,7 @@ impl CitRules {
|
||||
}
|
||||
let comps = encode_components(stack);
|
||||
let key = cache_key(
|
||||
(1, Kind::Enchantment, Hand::Any),
|
||||
(1, Kind::Enchantment, hand),
|
||||
stack,
|
||||
max_damage,
|
||||
comps.as_ref(),
|
||||
@@ -147,24 +173,62 @@ impl CitRules {
|
||||
let found = hit.unwrap_or_else(|| {
|
||||
let mut all: Vec<u32> = named.iter().chain(&self.any_item).copied().collect();
|
||||
all.sort_unstable();
|
||||
all.retain(|&i| self.matches(i, stack, None, max_damage, comps.as_ref()));
|
||||
all.retain(|&i| self.matches(i, stack, hand, max_damage, comps.as_ref()));
|
||||
remember(&self.enchanted, key, all.clone());
|
||||
all
|
||||
});
|
||||
found.into_iter().map(|i| i as usize).collect()
|
||||
}
|
||||
|
||||
/// Whether rule `i` matches; `hand` None skips the hand test (enchantment glints).
|
||||
/// The glint layers this stack draws in `hand` (`Hand::Main` in a GUI) and whether
|
||||
/// vanilla's still does (`StackGlint`), worked out once per stack and hand; `None` when the
|
||||
/// pack changes no glint. `foil` is whether the stack draws a glint at all
|
||||
/// (`ItemStack::has_foil`): one that doesn't gets no layers.
|
||||
pub fn glint(
|
||||
&self,
|
||||
stack: &ItemStack,
|
||||
hand: Hand,
|
||||
max_damage: u32,
|
||||
foil: bool,
|
||||
) -> Option<Arc<StackGlint>> {
|
||||
let no_rules =
|
||||
self.any_item.is_empty() && self.by_item[Kind::Enchantment as usize].is_empty();
|
||||
// Before the cache, which `foil` is not part of.
|
||||
if !foil || (no_rules && self.global.use_glint) {
|
||||
return None;
|
||||
}
|
||||
let comps = encode_components(stack);
|
||||
let key = cache_key(
|
||||
(2, Kind::Enchantment, hand),
|
||||
stack,
|
||||
max_damage,
|
||||
comps.as_ref(),
|
||||
);
|
||||
if let Some(hit) = lock(&self.glints).get(&key) {
|
||||
return Some(hit.clone());
|
||||
}
|
||||
let matched = self.enchantments(stack, hand, max_damage);
|
||||
let glint = Arc::new(StackGlint::new(
|
||||
&self.rules,
|
||||
&matched,
|
||||
stack.crafting_enchantments(),
|
||||
&self.global,
|
||||
));
|
||||
remember(&self.glints, key, glint.clone());
|
||||
Some(glint)
|
||||
}
|
||||
|
||||
/// Whether rule `i` matches the stack drawn in `hand`.
|
||||
fn matches(
|
||||
&self,
|
||||
i: u32,
|
||||
stack: &ItemStack,
|
||||
hand: Option<Hand>,
|
||||
hand: Hand,
|
||||
max_damage: u32,
|
||||
comps: Option<&Tag>,
|
||||
) -> bool {
|
||||
let rule = &self.rules[i as usize];
|
||||
if hand.is_some_and(|h| rule.hand != Hand::Any && rule.hand != h) {
|
||||
if rule.hand != Hand::Any && rule.hand != hand {
|
||||
return false;
|
||||
}
|
||||
let count = i32::try_from(stack.count).unwrap_or(i32::MAX);
|
||||
@@ -214,7 +278,7 @@ fn remember<V>(cache: &Mutex<HashMap<u64, V>>, key: u64, value: V) {
|
||||
cache.insert(key, value);
|
||||
}
|
||||
|
||||
/// What a cached answer depends on; `query` tells `select` from `enchantments`.
|
||||
/// What a cached answer depends on; `query` tells `select`, `enchantments` and `glint` apart.
|
||||
fn cache_key(query: (u8, Kind, Hand), stack: &ItemStack, max: u32, comps: Option<&Tag>) -> u64 {
|
||||
let mut h = DefaultHasher::new();
|
||||
(query.0, query.1 as u8, query.2 as u8, &*stack.item).hash(&mut h);
|
||||
@@ -646,7 +710,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(item(&cit, enchanted("stone", &mixed)), Some("any3"));
|
||||
let glints = |stack: ItemStack| -> Vec<&str> {
|
||||
let found = cit.enchantments(&stack, 0).into_iter();
|
||||
let found = cit.enchantments(&stack, Hand::Main, 0).into_iter();
|
||||
found.map(|i| stem(&cit, Some(i)).unwrap()).collect()
|
||||
};
|
||||
let sharp = [("sharpness", 1)];
|
||||
@@ -659,4 +723,127 @@ mod tests {
|
||||
// Glints are never an item's texture.
|
||||
assert_eq!(item(&cit, enchanted("diamond_sword", &sharp)), None);
|
||||
}
|
||||
|
||||
/// `CitRules::glint` in the main hand, with the stack's own `has_foil` as callers pass it.
|
||||
fn glint(cit: &CitRules, stack: &ItemStack, item_glint: bool) -> Option<Arc<StackGlint>> {
|
||||
cit.glint(stack, Hand::Main, 0, stack.has_foil(item_glint))
|
||||
}
|
||||
|
||||
/// `stack` with its `enchantment_glint_override` set to `on`.
|
||||
fn overridden(mut stack: ItemStack, on: bool) -> ItemStack {
|
||||
let flag = (
|
||||
"minecraft:enchantment_glint_override".to_owned(),
|
||||
Tag::Byte(on.into()),
|
||||
);
|
||||
stack.data_mut().other.push(flag);
|
||||
stack
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stack_glint_reads_the_book_and_the_pack_file() {
|
||||
let mut cit = rules(&[
|
||||
("sharp", "type=enchantment\nenchantments=sharpness"),
|
||||
(
|
||||
"keep",
|
||||
"type=enchantment\nlayer=1\nuseGlint=true\nenchantments=mending",
|
||||
),
|
||||
]);
|
||||
// An enchanted book's glint follows its stored enchantments.
|
||||
let mut book = new("enchanted_book");
|
||||
book.data_mut().stored_enchantments = vec![
|
||||
(Arc::from("minecraft:sharpness"), 3),
|
||||
(Arc::from("minecraft:mending"), 1),
|
||||
];
|
||||
let book = glint(&cit, &book, true).unwrap();
|
||||
let weights: Vec<_> = book.layers.iter().map(|l| l.weight).collect();
|
||||
assert_eq!(weights, [0.75, 0.25]);
|
||||
assert!(book.vanilla);
|
||||
let plain = glint(&cit, &enchanted("stick", &[("sharpness", 1)]), false).unwrap();
|
||||
assert_eq!(plain.layers.len(), 1);
|
||||
assert!(!plain.vanilla);
|
||||
// No enchantment rules: nothing changes, unless the pack turns vanilla's glint off.
|
||||
let mut bare = rules(&[("stick", "items=stick")]);
|
||||
assert!(glint(&bare, &overridden(new("stick"), true), false).is_none());
|
||||
bare.global.use_glint = false;
|
||||
assert!(
|
||||
!glint(&bare, &overridden(new("stick"), true), false)
|
||||
.unwrap()
|
||||
.vanilla
|
||||
);
|
||||
cit.global.use_glint = false;
|
||||
assert!(
|
||||
glint(&cit, &overridden(new("stick"), true), false)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_stack_with_a_glint_gets_layers() {
|
||||
let cit = rules(&[("any", "type=enchantment")]);
|
||||
assert!(glint(&cit, &new("stick"), false).is_none());
|
||||
// The override turns an enchanted stack's glint off, and an unenchanted one's on.
|
||||
let off = overridden(enchanted("stick", &[("sharpness", 1)]), false);
|
||||
assert!(glint(&cit, &off, false).is_none());
|
||||
let on = glint(&cit, &overridden(new("stick"), true), false).unwrap();
|
||||
let weights: Vec<_> = on.layers.iter().map(|l| l.weight).collect();
|
||||
assert_eq!(
|
||||
weights,
|
||||
[1.0],
|
||||
"nothing to share: the rule at full strength"
|
||||
);
|
||||
// The same stack, asked again without its glint, is not answered from the cache.
|
||||
assert!(
|
||||
cit.glint(&overridden(new("stick"), true), Hand::Main, 0, false)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rule_left_out_of_the_glints_matches_nothing() {
|
||||
let mut cit = rules(&[
|
||||
("broken", "type=enchantment\nitems=diamond_sword\nweight=1"),
|
||||
("shown", "type=enchantment"),
|
||||
]);
|
||||
let sword = enchanted("diamond_sword", &[("sharpness", 1)]);
|
||||
let layers = |cit: &CitRules| -> Vec<String> {
|
||||
let glint = glint(cit, &sword, false).unwrap();
|
||||
let stems = glint
|
||||
.layers
|
||||
.iter()
|
||||
.map(|l| stem(cit, Some(l.rule)).unwrap());
|
||||
stems.map(str::to_owned).collect()
|
||||
};
|
||||
// The heavier rule wins the layer, until its texture is missing.
|
||||
assert_eq!(layers(&cit), ["broken"]);
|
||||
let broken = (0..cit.rules.len())
|
||||
.find(|&i| stem(&cit, Some(i)) == Some("broken"))
|
||||
.unwrap();
|
||||
cit.keep_glints(|i| i != broken);
|
||||
assert_eq!(layers(&cit), ["shown"]);
|
||||
assert_eq!(cit.enchantments(&sword, Hand::Main, 0).len(), 1);
|
||||
// None left: vanilla's glint stays.
|
||||
cit.keep_glints(|_| false);
|
||||
assert!(glint(&cit, &sword, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_off_hand_layer_draws_only_in_the_off_hand() {
|
||||
let cit = rules(&[
|
||||
("left", "type=enchantment\nlayer=1\nhand=off"),
|
||||
("both", "type=enchantment"),
|
||||
]);
|
||||
let sword = enchanted("diamond_sword", &[("sharpness", 1)]);
|
||||
let layers = |hand| -> Vec<&str> {
|
||||
let glint = cit.glint(&sword, hand, 0, true).unwrap();
|
||||
glint
|
||||
.layers
|
||||
.iter()
|
||||
.map(|l| stem(&cit, Some(l.rule)).unwrap())
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(layers(Hand::Off), ["both", "left"]);
|
||||
// Asked in this order, a cache keyed without the hand would answer as for the off hand.
|
||||
assert_eq!(layers(Hand::Main), ["both"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
//! Custom Item Textures for worn armour and elytra (`type=armor`, `type=elytra`). An armour rule
|
||||
//! names its textures after the armour texture files of before 1.21.2
|
||||
//! (`textures/models/armor/diamond_layer_1.png`): `texture.diamond_layer_1`,
|
||||
//! `texture.leather_layer_2_overlay`..., with `texture` for any layer it does not name. 26.3's
|
||||
//! equipment assets name the same layers `entity/equipment/humanoid/diamond` (layer 1: helmet,
|
||||
//! chestplate, boots) and `entity/equipment/humanoid_leggings/diamond` (layer 2), so the layers
|
||||
//! are mapped back to the old names here. An elytra rule's `texture` replaces the wings'.
|
||||
|
||||
use crate::cit_assets::{rule_sprites, sprite_id};
|
||||
use horizon_optifine::cit::{Kind, Rule};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// The pre-1.21.2 armour texture name of an equipment asset's layer: `asset` (`minecraft:gold`
|
||||
/// or `gold`) on the leggings layer (`humanoid_leggings`) or the others (`humanoid`), the dye
|
||||
/// overlay (`leather_overlay`) or the main texture.
|
||||
pub fn armor_layer_name(asset: &str, leggings: bool, overlay: bool) -> String {
|
||||
let path = asset.rsplit_once(':').map_or(asset, |(_, p)| p);
|
||||
// The equipment assets renamed from their old textures; the rest kept their names.
|
||||
let old = match path {
|
||||
"turtle_scute" => "turtle",
|
||||
other => other,
|
||||
};
|
||||
let layer = if leggings { 2 } else { 1 };
|
||||
let suffix = if overlay { "_overlay" } else { "" };
|
||||
format!("{old}_layer_{layer}{suffix}")
|
||||
}
|
||||
|
||||
/// The texture (a resource path) `rule` draws on the layer named `layer`: its `texture.<layer>`,
|
||||
/// else its `texture`.
|
||||
pub fn armor_texture<'a>(rule: &'a Rule, layer: &str) -> Option<&'a str> {
|
||||
rule.textures
|
||||
.iter()
|
||||
.find(|(name, _)| name == layer)
|
||||
.map(|(_, path)| path.as_str())
|
||||
.or(rule.texture.as_deref())
|
||||
}
|
||||
|
||||
/// The atlas name of a CIT texture: its `cit_assets` id, without `minecraft:` as the atlas packs
|
||||
/// vanilla's sprites.
|
||||
fn atlas_name(resource: &str) -> Option<String> {
|
||||
let id = sprite_id(resource)?;
|
||||
Some(match id.strip_prefix("minecraft:") {
|
||||
Some(name) => name.to_owned(),
|
||||
None => id,
|
||||
})
|
||||
}
|
||||
|
||||
/// The atlas sprite `rule` puts on the layer of equipment asset `asset` (see
|
||||
/// [`armor_layer_name`]); `None` keeps the vanilla texture.
|
||||
pub fn layer_sprite(rule: &Rule, asset: &str, leggings: bool, overlay: bool) -> Option<String> {
|
||||
atlas_name(armor_texture(
|
||||
rule,
|
||||
&armor_layer_name(asset, leggings, overlay),
|
||||
)?)
|
||||
}
|
||||
|
||||
/// The atlas sprite an elytra rule puts on the wings.
|
||||
pub fn elytra_sprite(rule: &Rule) -> Option<String> {
|
||||
atlas_name(armor_texture(rule, "elytra")?)
|
||||
}
|
||||
|
||||
/// The atlas sprite the one CIT armour rule `rule` puts on a worn armour layer: the layer
|
||||
/// `vanilla` (an atlas name, `…_overlay` for the dye overlay) of equipment asset `asset` in
|
||||
/// equipment layer `kind` (`humanoid_leggings` for the leggings). `None` draws `vanilla`, as does
|
||||
/// a name the atlas lacks; nothing is allocated without a rule.
|
||||
pub fn worn_layer_sprite(
|
||||
rule: Option<&Rule>,
|
||||
asset: &str,
|
||||
kind: &str,
|
||||
vanilla: &str,
|
||||
) -> Option<String> {
|
||||
let leggings = kind == "humanoid_leggings";
|
||||
let overlay = vanilla.ends_with("_overlay");
|
||||
layer_sprite(rule?, asset, leggings, overlay)
|
||||
}
|
||||
|
||||
/// The CIT elytra rule's wings sprite. On `None`, or a name the atlas lacks, the caller draws the
|
||||
/// cape, else the equipment asset's own wings.
|
||||
pub fn wings_sprite(rule: Option<&Rule>) -> Option<String> {
|
||||
elytra_sprite(rule?)
|
||||
}
|
||||
|
||||
/// What a worn layer draws, looked up with `get` (the atlas): the rule's sprite
|
||||
/// ([`worn_layer_sprite`]), else `vanilla` when there is no rule or the atlas lacks its texture.
|
||||
pub fn worn_layer<R>(
|
||||
rule: Option<&Rule>,
|
||||
asset: &str,
|
||||
kind: &str,
|
||||
vanilla: &str,
|
||||
get: impl Fn(&str) -> Option<R>,
|
||||
) -> Option<R> {
|
||||
worn_layer_sprite(rule, asset, kind, vanilla)
|
||||
.and_then(|name| get(&name))
|
||||
.or_else(|| get(vanilla))
|
||||
}
|
||||
|
||||
/// What worn wings draw, looked up with `get`: the rule's sprite, else the cape; `None` leaves
|
||||
/// them to the equipment asset's own wings.
|
||||
pub fn wings<R>(
|
||||
rule: Option<&Rule>,
|
||||
cape: Option<&str>,
|
||||
get: impl Fn(&str) -> Option<R>,
|
||||
) -> Option<R> {
|
||||
wings_sprite(rule)
|
||||
.and_then(|name| get(&name))
|
||||
.or_else(|| cape.and_then(&get))
|
||||
}
|
||||
|
||||
/// Sprite ids the armour and elytra rules draw (see [`rule_sprites`]).
|
||||
pub fn armor_sprites(rules: &[Rule]) -> BTreeSet<String> {
|
||||
rule_sprites(rules, |kind| matches!(kind, Kind::Armor | Kind::Elytra))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_optifine::Properties;
|
||||
|
||||
fn rule(path: &str, text: &str) -> Rule {
|
||||
Rule::parse(path, &Properties::parse_equals_first(text), |_| true).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_take_their_old_texture_names() {
|
||||
assert_eq!(
|
||||
armor_layer_name("minecraft:diamond", false, false),
|
||||
"diamond_layer_1"
|
||||
);
|
||||
assert_eq!(armor_layer_name("diamond", true, false), "diamond_layer_2");
|
||||
assert_eq!(
|
||||
armor_layer_name("minecraft:leather", false, true),
|
||||
"leather_layer_1_overlay"
|
||||
);
|
||||
assert_eq!(
|
||||
armor_layer_name("minecraft:leather", true, true),
|
||||
"leather_layer_2_overlay"
|
||||
);
|
||||
assert_eq!(
|
||||
armor_layer_name("minecraft:gold", false, false),
|
||||
"gold_layer_1"
|
||||
);
|
||||
assert_eq!(
|
||||
armor_layer_name("minecraft:chainmail", true, false),
|
||||
"chainmail_layer_2"
|
||||
);
|
||||
assert_eq!(
|
||||
armor_layer_name("minecraft:turtle_scute", false, false),
|
||||
"turtle_layer_1"
|
||||
);
|
||||
assert_eq!(
|
||||
armor_layer_name("minecraft:netherite", true, false),
|
||||
"netherite_layer_2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_layer_wins_over_the_generic_texture() {
|
||||
let r = rule(
|
||||
"assets/minecraft/optifine/cit/armor/blue.properties",
|
||||
"type=armor\nitems=diamond_chestplate diamond_leggings\ntexture=generic\n\
|
||||
texture.diamond_layer_2=blue_legs\n",
|
||||
);
|
||||
let dir = "assets/minecraft/optifine/cit/armor";
|
||||
assert_eq!(
|
||||
armor_texture(&r, "diamond_layer_2"),
|
||||
Some(format!("{dir}/blue_legs.png").as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
armor_texture(&r, "diamond_layer_1"),
|
||||
Some(format!("{dir}/generic.png").as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
layer_sprite(&r, "minecraft:diamond", true, false).as_deref(),
|
||||
Some("horizon_cit:minecraft/optifine/cit/armor/blue_legs")
|
||||
);
|
||||
assert_eq!(
|
||||
layer_sprite(&r, "minecraft:diamond", false, false).as_deref(),
|
||||
Some("horizon_cit:minecraft/optifine/cit/armor/generic")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_generic_texture_other_layers_stay_vanilla() {
|
||||
let r = rule(
|
||||
"assets/minecraft/optifine/cit/a.properties",
|
||||
"type=armor\nitems=leather_helmet\ntexture.leather_layer_1=dyed\n",
|
||||
);
|
||||
assert_eq!(
|
||||
layer_sprite(&r, "minecraft:leather", false, false).as_deref(),
|
||||
Some("horizon_cit:minecraft/optifine/cit/dyed")
|
||||
);
|
||||
assert_eq!(layer_sprite(&r, "minecraft:leather", false, true), None);
|
||||
assert_eq!(layer_sprite(&r, "minecraft:leather", true, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vanilla_texture_path_keeps_its_atlas_name() {
|
||||
let r = rule(
|
||||
"assets/minecraft/optifine/cit/wings.properties",
|
||||
"type=elytra\ntexture=minecraft:textures/entity/wings/dragon.png\n",
|
||||
);
|
||||
assert_eq!(elytra_sprite(&r).as_deref(), Some("entity/wings/dragon"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worn_layers_pick_the_rules_texture_for_their_layer() {
|
||||
let r = rule(
|
||||
"assets/minecraft/optifine/cit/dye.properties",
|
||||
"type=armor\nitems=leather_chestplate leather_leggings\n\
|
||||
texture.leather_layer_1=body\ntexture.leather_layer_1_overlay=body_dye\n\
|
||||
texture.leather_layer_2=legs\n",
|
||||
);
|
||||
let cit = |p: &str| Some(format!("horizon_cit:minecraft/optifine/cit/{p}"));
|
||||
let pick = |kind: &str, vanilla: &str| {
|
||||
worn_layer_sprite(Some(&r), "minecraft:leather", kind, vanilla)
|
||||
};
|
||||
let body = "entity/equipment/humanoid/leather";
|
||||
assert_eq!(pick("humanoid", body), cit("body"));
|
||||
assert_eq!(
|
||||
pick("humanoid", "entity/equipment/humanoid/leather_overlay"),
|
||||
cit("body_dye")
|
||||
);
|
||||
assert_eq!(
|
||||
pick(
|
||||
"humanoid_leggings",
|
||||
"entity/equipment/humanoid_leggings/leather"
|
||||
),
|
||||
cit("legs")
|
||||
);
|
||||
// No `texture.leather_layer_2_overlay` and no `texture`: the overlay stays vanilla.
|
||||
let legs_dye = "entity/equipment/humanoid_leggings/leather_overlay";
|
||||
assert_eq!(pick("humanoid_leggings", legs_dye), None);
|
||||
// No rule draws vanilla's.
|
||||
assert_eq!(
|
||||
worn_layer_sprite(None, "minecraft:leather", "humanoid", body),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wings_take_the_rules_texture() {
|
||||
let r = rule(
|
||||
"assets/minecraft/optifine/cit/wings.properties",
|
||||
"type=elytra\ntexture=wings\n",
|
||||
);
|
||||
assert_eq!(
|
||||
wings_sprite(Some(&r)).as_deref(),
|
||||
Some("horizon_cit:minecraft/optifine/cit/wings")
|
||||
);
|
||||
assert_eq!(wings_sprite(None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_armor_and_elytra_rules_reach_the_atlas() {
|
||||
let rules = [
|
||||
rule(
|
||||
"assets/minecraft/optifine/cit/sword.properties",
|
||||
"items=diamond_sword\ntexture=sword\n",
|
||||
),
|
||||
rule(
|
||||
"assets/minecraft/optifine/cit/plate.properties",
|
||||
"type=armor\nitems=iron_chestplate iron_leggings\ntexture=plate\n\
|
||||
texture.iron_layer_2=legs\n",
|
||||
),
|
||||
rule(
|
||||
"assets/minecraft/optifine/cit/wings.properties",
|
||||
"type=elytra\ntexture=wings\n",
|
||||
),
|
||||
rule(
|
||||
"assets/minecraft/optifine/cit/glint.properties",
|
||||
"type=enchantment\nenchantments=sharpness\ntexture=glint\n",
|
||||
),
|
||||
];
|
||||
let got: Vec<String> = armor_sprites(&rules).into_iter().collect();
|
||||
assert_eq!(
|
||||
got,
|
||||
[
|
||||
"horizon_cit:minecraft/optifine/cit/legs",
|
||||
"horizon_cit:minecraft/optifine/cit/plate",
|
||||
"horizon_cit:minecraft/optifine/cit/wings",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// An atlas lookup that knows `names`.
|
||||
fn atlas(names: &[&str]) -> impl Fn(&str) -> Option<String> {
|
||||
let names: Vec<String> = names.iter().map(|n| (*n).to_owned()).collect();
|
||||
move |s| names.iter().any(|n| n == s).then(|| s.to_owned())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_atlas_lookup_falls_back_to_vanilla_and_the_cape() {
|
||||
let iron = rule(
|
||||
"assets/minecraft/optifine/cit/iron.properties",
|
||||
"type=armor\nitems=iron_chestplate\ntexture.iron_layer_1=plate",
|
||||
);
|
||||
let plate = "horizon_cit:minecraft/optifine/cit/plate";
|
||||
let vanilla = "entity/equipment/humanoid/iron";
|
||||
let layer = |rule: Option<&Rule>, names: &[&str]| {
|
||||
worn_layer(rule, "minecraft:iron", "humanoid", vanilla, atlas(names))
|
||||
};
|
||||
assert_eq!(
|
||||
layer(Some(&iron), &[plate, vanilla]).as_deref(),
|
||||
Some(plate)
|
||||
);
|
||||
// The rule's texture didn't reach the atlas, or there's no rule: vanilla's layer.
|
||||
assert_eq!(layer(Some(&iron), &[vanilla]).as_deref(), Some(vanilla));
|
||||
assert_eq!(layer(None, &[plate, vanilla]).as_deref(), Some(vanilla));
|
||||
let elytra = rule(
|
||||
"assets/minecraft/optifine/cit/w.properties",
|
||||
"type=elytra\ntexture=wings",
|
||||
);
|
||||
let w = "horizon_cit:minecraft/optifine/cit/wings";
|
||||
let wing = |rule: Option<&Rule>, names: &[&str]| wings(rule, Some("cape/x"), atlas(names));
|
||||
assert_eq!(wing(Some(&elytra), &[w, "cape/x"]).as_deref(), Some(w));
|
||||
assert_eq!(wing(Some(&elytra), &["cape/x"]).as_deref(), Some("cape/x"));
|
||||
assert_eq!(wings(None, None, atlas(&[w])), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
//! The ids Custom Item Textures' files take in Horizon's atlas and model library. A CIT rule
|
||||
//! names its textures and models by resource path, mostly under `optifine/cit/` where vanilla
|
||||
//! never looks: those take the reserved namespace [`NAMESPACE`], their path the resource path's
|
||||
//! (`assets/minecraft/optifine/cit/swords/flame.png` is
|
||||
//! `horizon_cit:minecraft/optifine/cit/swords/flame`). A file already under `textures/` (or
|
||||
//! `models/`) keeps its vanilla id, so it is not packed twice.
|
||||
|
||||
use horizon_assets::model::{CuboidModel, TextureSlot};
|
||||
use horizon_optifine::cit::{Kind, Rule};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// The namespace of CIT files outside `textures/` and `models/`.
|
||||
pub const NAMESPACE: &str = "horizon_cit";
|
||||
|
||||
/// `<namespace>:<path>`, if both keep to the identifier rules (lower-case letters, digits,
|
||||
/// `_.-`, and `/` in the path). CIT Resewn refuses the other paths too.
|
||||
fn identifier(namespace: &str, path: &str) -> Option<String> {
|
||||
if namespace.is_empty() || path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
horizon_client_core::identifier::parse_identifier(&format!("{namespace}:{path}"))
|
||||
}
|
||||
|
||||
/// The id of a resource path ending in `.<ext>`: `assets/<ns>/<folder>/<p>` is `<ns>:<p>`, any
|
||||
/// other `assets/<rest>` is `horizon_cit:<rest>`.
|
||||
fn id(resource: &str, folder: &str, ext: &str) -> Option<String> {
|
||||
let rest = resource
|
||||
.strip_prefix("assets/")?
|
||||
.strip_suffix(ext)?
|
||||
.strip_suffix('.')?;
|
||||
if let Some((namespace, path)) = rest.split_once('/')
|
||||
&& let Some(path) = path.strip_prefix(folder).and_then(|p| p.strip_prefix('/'))
|
||||
{
|
||||
// A pack's own `assets/horizon_cit/` would name another file than its id maps back to.
|
||||
return (namespace != NAMESPACE)
|
||||
.then(|| identifier(namespace, path))
|
||||
.flatten();
|
||||
}
|
||||
identifier(NAMESPACE, rest)
|
||||
}
|
||||
|
||||
/// The atlas sprite id of a `.png` resource path.
|
||||
pub fn sprite_id(resource: &str) -> Option<String> {
|
||||
id(resource, "textures", "png")
|
||||
}
|
||||
|
||||
/// The model id of a `.json` resource path.
|
||||
pub fn model_id(resource: &str) -> Option<String> {
|
||||
id(resource, "models", "json")
|
||||
}
|
||||
|
||||
/// The resource path of a `horizon_cit` id, with the extension `ext`; `None` for other ids,
|
||||
/// and for one that would leave the resource root (a pack model may write any id).
|
||||
pub fn resource(id: &str, ext: &str) -> Option<String> {
|
||||
let rest = id.strip_prefix(NAMESPACE)?.strip_prefix(':')?;
|
||||
horizon_optifine::paths::valid(rest).then(|| format!("assets/{rest}.{ext}"))
|
||||
}
|
||||
|
||||
/// Sprite ids the rules whose kind passes `keep` draw: their `texture` and `texture.<name>`
|
||||
/// values. A path no id can name is skipped with a warning.
|
||||
pub fn rule_sprites(rules: &[Rule], keep: impl Fn(Kind) -> bool) -> BTreeSet<String> {
|
||||
let mut out = BTreeSet::new();
|
||||
for rule in rules.iter().filter(|r| keep(r.kind)) {
|
||||
let named = rule.textures.iter().map(|(_, path)| path);
|
||||
for path in rule.texture.iter().chain(named) {
|
||||
match sprite_id(path) {
|
||||
Some(id) => {
|
||||
out.insert(id);
|
||||
}
|
||||
None => log::warn!(
|
||||
"custom item textures: {} names {path}, which is not a valid texture id",
|
||||
rule.path
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Sprite ids the item rules draw (`Kind::Item`, see [`rule_sprites`]). Textures named by CIT
|
||||
/// JSON models are [`model_sprites`].
|
||||
pub fn item_sprites(rules: &[Rule]) -> BTreeSet<String> {
|
||||
rule_sprites(rules, |kind| kind == Kind::Item)
|
||||
}
|
||||
|
||||
/// The resource path a model value `./x` (or `./../x`, `../x`) names beside the file at `file`,
|
||||
/// ending in `.<ext>` (added unless the value has it). `None` for other values and for paths
|
||||
/// that would leave the root.
|
||||
fn relative(file: &str, value: &str, ext: &str) -> Option<String> {
|
||||
let mut rest = value
|
||||
.strip_prefix("./")
|
||||
.or_else(|| value.starts_with("../").then_some(value))?;
|
||||
let mut dir = file.rsplit_once('/')?.0;
|
||||
while let Some(up) = rest.strip_prefix("../") {
|
||||
dir = dir.rsplit_once('/')?.0;
|
||||
rest = up;
|
||||
}
|
||||
let rest = rest
|
||||
.strip_suffix(ext)
|
||||
.and_then(|r| r.strip_suffix('.'))
|
||||
.unwrap_or(rest);
|
||||
let path = format!("{dir}/{rest}.{ext}");
|
||||
horizon_optifine::paths::valid(&path).then_some(path)
|
||||
}
|
||||
|
||||
/// The id a relative value of the model at `file` names, as `CuboidModel::from_json` read it
|
||||
/// (`./x` is `minecraft:./x` there), with the resource path of the file it names.
|
||||
fn rebase(file: &str, id: &str, ext: &str) -> Option<(String, String)> {
|
||||
let path = relative(file, id.strip_prefix("minecraft:")?, ext)?;
|
||||
let id = if ext == "png" {
|
||||
sprite_id(&path)
|
||||
} else {
|
||||
model_id(&path)
|
||||
}?;
|
||||
Some((id, path))
|
||||
}
|
||||
|
||||
/// Parents followed from a rule's model before the rest are left to the library.
|
||||
const MAX_PARENTS: usize = 16;
|
||||
|
||||
/// The CIT JSON models the item rules name (`model`, `model.<name>`) under their [`model_id`]s,
|
||||
/// with the parents they reach by relative path. Their `./x` texture and parent values become
|
||||
/// the ids of the files beside them. Only `horizon_cit` models: the others are in the model
|
||||
/// library already. A file that does not read or parse is left out, and a rule's layer that
|
||||
/// names it (or a model whose parents reach it) keeps its vanilla model.
|
||||
pub fn load_models(root: &Path, rules: &[Rule]) -> BTreeMap<String, CuboidModel> {
|
||||
let mut out = BTreeMap::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
for rule in rules.iter().filter(|r| r.kind == Kind::Item) {
|
||||
let named = rule.models.iter().map(|(_, path)| path);
|
||||
for path in rule.model.iter().chain(named) {
|
||||
load_model(root, path, 0, &mut seen, &mut out);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn load_model(
|
||||
root: &Path,
|
||||
resource: &str,
|
||||
depth: usize,
|
||||
seen: &mut BTreeSet<String>,
|
||||
out: &mut BTreeMap<String, CuboidModel>,
|
||||
) {
|
||||
let Some(id) = model_id(resource) else {
|
||||
log::warn!("custom item textures: {resource} is not a valid model id");
|
||||
return;
|
||||
};
|
||||
let ours = id
|
||||
.strip_prefix(NAMESPACE)
|
||||
.is_some_and(|r| r.starts_with(':'));
|
||||
if !ours || depth > MAX_PARENTS || !seen.insert(id.clone()) {
|
||||
return;
|
||||
}
|
||||
let model = fs::read_to_string(root.join(resource))
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|text| CuboidModel::from_json(&text).map_err(|e| e.to_string()));
|
||||
let mut model = match model {
|
||||
Ok(model) => model,
|
||||
Err(e) => {
|
||||
log::warn!("custom item textures: skipped model {resource}: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
for slot in model.textures.values_mut() {
|
||||
if let TextureSlot::Material(m) = slot
|
||||
&& let Some((id, _)) = rebase(resource, &m.sprite, "png")
|
||||
{
|
||||
m.sprite = id;
|
||||
}
|
||||
}
|
||||
let parent = model
|
||||
.parent
|
||||
.as_deref()
|
||||
.and_then(|p| rebase(resource, p, "json"));
|
||||
if let Some((id, path)) = parent {
|
||||
model.parent = Some(id);
|
||||
load_model(root, &path, depth + 1, seen, out);
|
||||
}
|
||||
out.insert(id, model);
|
||||
}
|
||||
|
||||
/// The `horizon_cit` sprites `models` name, which the atlas packs beside [`item_sprites`].
|
||||
pub fn model_sprites(models: &BTreeMap<String, CuboidModel>) -> BTreeSet<String> {
|
||||
let prefix = format!("{NAMESPACE}:");
|
||||
models
|
||||
.values()
|
||||
.flat_map(|m| m.textures.values())
|
||||
.filter_map(|slot| match slot {
|
||||
TextureSlot::Material(m) if m.sprite.starts_with(&prefix) => Some(m.sprite.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::atlas_sources::texture_path;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn cit_paths_take_the_reserved_namespace_and_come_back() {
|
||||
let png = "assets/minecraft/optifine/cit/swords/flame.png";
|
||||
let id = sprite_id(png).unwrap();
|
||||
assert_eq!(id, "horizon_cit:minecraft/optifine/cit/swords/flame");
|
||||
assert_eq!(resource(&id, "png").as_deref(), Some(png));
|
||||
let json = "assets/minecraft/optifine/cit/swords/flame.json";
|
||||
let id = model_id(json).unwrap();
|
||||
assert_eq!(id, "horizon_cit:minecraft/optifine/cit/swords/flame");
|
||||
assert_eq!(resource(&id, "json").as_deref(), Some(json));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_under_textures_and_models_keep_their_vanilla_ids() {
|
||||
let id = sprite_id("assets/minecraft/textures/item/stick.png");
|
||||
assert_eq!(id.as_deref(), Some("minecraft:item/stick"));
|
||||
let id = model_id("assets/gems/models/item/ruby.json");
|
||||
assert_eq!(id.as_deref(), Some("gems:item/ruby"));
|
||||
// A model id is not a resource of this module's own.
|
||||
assert_eq!(resource("minecraft:item/stick", "png"), None);
|
||||
// `textures` as a folder name elsewhere is still a CIT path.
|
||||
let id = sprite_id("assets/minecraft/optifine/cit/textures/a.png");
|
||||
assert_eq!(
|
||||
id.as_deref(),
|
||||
Some("horizon_cit:minecraft/optifine/cit/textures/a")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_no_identifier_can_name_are_refused() {
|
||||
for path in [
|
||||
"assets/minecraft/optifine/cit/Flame.png",
|
||||
"assets/minecraft/optifine/cit/fire sword.png",
|
||||
"assets/minecraft/textures/item/Stick.png",
|
||||
"assets/minecraft/optifine/cit/a.json",
|
||||
"optifine/cit/a.png",
|
||||
] {
|
||||
assert_eq!(sprite_id(path), None, "{path}");
|
||||
}
|
||||
assert_eq!(model_id("assets/minecraft/optifine/cit/A.json"), None);
|
||||
assert_eq!(
|
||||
sprite_id("assets//textures/item/x.png"),
|
||||
None,
|
||||
"no namespace"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_reserved_namespace_names_only_files_inside_the_root() {
|
||||
// A pack's own `horizon_cit` namespace would map back to another file.
|
||||
assert_eq!(sprite_id("assets/horizon_cit/textures/item/x.png"), None);
|
||||
// A model may write any id: only ones inside the root name a file.
|
||||
for id in [
|
||||
"horizon_cit:../../etc/x",
|
||||
"horizon_cit:a/../../x",
|
||||
"horizon_cit:a\\..\\x",
|
||||
] {
|
||||
assert_eq!(resource(id, "png"), None, "{id}");
|
||||
// Nor does the texture's file: refused, not read from elsewhere.
|
||||
let file = texture_path(Path::new("/r"), id, "textures");
|
||||
assert!(file.starts_with("/r/assets") && !file.to_string_lossy().contains(".."));
|
||||
}
|
||||
let escape = texture_path(Path::new("/r"), "minecraft:../../../x", "textures");
|
||||
assert!(!escape.to_string_lossy().contains(".."), "{escape:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_id_forms_find_their_file() {
|
||||
let root = Path::new("/r");
|
||||
assert_eq!(
|
||||
texture_path(root, "horizon_cit:minecraft/optifine/cit/a", "textures"),
|
||||
root.join("assets/minecraft/optifine/cit/a.png")
|
||||
);
|
||||
assert_eq!(
|
||||
texture_path(root, "minecraft:item/stick", "textures"),
|
||||
root.join("assets/minecraft/textures/item/stick.png")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rule_models_load_with_their_relative_textures_and_parents() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-cit-models-{}", std::process::id()));
|
||||
let cit = root.join("assets/minecraft/optifine/cit");
|
||||
fs::create_dir_all(cit.join("swords")).unwrap();
|
||||
let write = |rel: &str, text: &str| fs::write(cit.join(rel), text).unwrap();
|
||||
write(
|
||||
"swords/flame.json",
|
||||
r#"{"parent":"./../base","textures":{"layer0":"./flame","layer1":"item/stick"}}"#,
|
||||
);
|
||||
write(
|
||||
"base.json",
|
||||
r#"{"parent":"item/handheld","textures":{"layer2":"./glow.png"}}"#,
|
||||
);
|
||||
let props =
|
||||
horizon_optifine::Properties::parse_equals_first("items=stick\nmodel=./swords/flame");
|
||||
let rule = Rule::parse("assets/minecraft/optifine/cit/a.properties", &props, |p| {
|
||||
root.join(p).is_file()
|
||||
})
|
||||
.unwrap();
|
||||
let models = load_models(&root, &[rule]);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let cit = |p: &str| format!("horizon_cit:minecraft/optifine/cit/{p}");
|
||||
assert_eq!(
|
||||
models.keys().cloned().collect::<Vec<_>>(),
|
||||
[cit("base"), cit("swords/flame")]
|
||||
);
|
||||
let flame = &models[&cit("swords/flame")];
|
||||
assert_eq!(flame.parent.as_deref(), Some(cit("base").as_str()));
|
||||
assert_eq!(
|
||||
models[&cit("base")].parent.as_deref(),
|
||||
Some("minecraft:item/handheld")
|
||||
);
|
||||
assert_eq!(
|
||||
model_sprites(&models).into_iter().collect::<Vec<_>>(),
|
||||
[cit("glow"), cit("swords/flame")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_values_stay_in_the_root() {
|
||||
let file = "assets/minecraft/optifine/cit/a/m.json";
|
||||
let at = |v: &str| relative(file, v, "png");
|
||||
assert_eq!(
|
||||
at("./x").as_deref(),
|
||||
Some("assets/minecraft/optifine/cit/a/x.png")
|
||||
);
|
||||
assert_eq!(
|
||||
at("./../x.png").as_deref(),
|
||||
Some("assets/minecraft/optifine/cit/x.png")
|
||||
);
|
||||
assert_eq!(
|
||||
at("../../x").as_deref(),
|
||||
Some("assets/minecraft/optifine/x.png")
|
||||
);
|
||||
assert_eq!(at("item/x"), None);
|
||||
assert_eq!(at("./../../../../../x"), None);
|
||||
assert_eq!(at("./a/../x"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
//! Custom Item Textures' enchantment glints (`type=enchantment`): which of a stack's matching
|
||||
//! rules draw, in which order and how strongly, and whether vanilla's glint still draws under
|
||||
//! them. The pack's `cit.properties` (`Global`) says how layers share an item.
|
||||
//!
|
||||
//! Written from OptiFine's `cit.properties` doc and CIT Resewn's (MIT) `TypeEnchantment` of
|
||||
//! before its 1.21.11 port, which implemented `method`, `cap` and `fade`: the sums and the
|
||||
//! cycle's fading follow it, except where noted.
|
||||
|
||||
pub use horizon_optifine::cit::{Global, Method};
|
||||
|
||||
use crate::cit::read_properties;
|
||||
use horizon_optifine::cit::Rule;
|
||||
use std::path::Path;
|
||||
|
||||
/// The pack's global `cit.properties` under the overlay resource root: the first of
|
||||
/// `Global::PATHS` that exists, else the defaults.
|
||||
pub fn load_global(root: &Path) -> Global {
|
||||
Global::PATHS
|
||||
.iter()
|
||||
.find_map(|path| read_properties(&root.join(path)))
|
||||
.map(|props| Global::parse(&props))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// One glint layer a stack draws.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct GlintLayer {
|
||||
/// The rule, an index into `CitRules::rules`.
|
||||
pub rule: usize,
|
||||
/// Its `average` or `layered` share, 1 under `none` and `cycle`.
|
||||
pub weight: f32,
|
||||
/// Its `duration`, for `cycle`.
|
||||
duration: f32,
|
||||
}
|
||||
|
||||
/// What a stack's glint is made of, worked out when the stack changes.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct StackGlint {
|
||||
/// Bottom first (`layer` ascending).
|
||||
pub layers: Vec<GlintLayer>,
|
||||
/// Whether vanilla's glint draws (on a stack that has one) under the layers.
|
||||
pub vanilla: bool,
|
||||
/// `fade` when the layers take turns (`method=cycle`).
|
||||
cycle_fade: Option<f32>,
|
||||
}
|
||||
|
||||
impl StackGlint {
|
||||
/// The layers among `matched` (the stack's matching enchantment rules, in priority order,
|
||||
/// as `CitRules::enchantments` gives them) that draw, for a stack with `enchantments`
|
||||
/// (`ItemStack::crafting_enchantments`).
|
||||
pub fn new(
|
||||
rules: &[Rule],
|
||||
matched: &[usize],
|
||||
enchantments: &[(impl AsRef<str>, u32)],
|
||||
global: &Global,
|
||||
) -> Self {
|
||||
// The highest priority rule of each layer.
|
||||
let mut layers: Vec<usize> = Vec::new();
|
||||
for &i in matched {
|
||||
let layer = rules[i].glint.layer;
|
||||
if !layers.iter().any(|&j| rules[j].glint.layer == layer) {
|
||||
layers.push(i);
|
||||
}
|
||||
}
|
||||
layers.sort_by_key(|&i| rules[i].glint.layer);
|
||||
let cycle = global.method == Method::Cycle;
|
||||
// OptiFine's doc: `cap` is for `average` and `layered`, and keeps the topmost layers.
|
||||
// `none`, which OptiFine lacks, is capped too, as CIT Resewn caps every method (it keeps
|
||||
// the bottom layers, though, and caps cycles as well).
|
||||
if let Some(cap) = global.cap.filter(|_| !cycle) {
|
||||
let cap = usize::try_from(cap).unwrap_or(usize::MAX);
|
||||
layers = layers.split_off(layers.len().saturating_sub(cap));
|
||||
}
|
||||
// CIT Resewn: any layer left asking for vanilla's glint keeps it, and the global
|
||||
// `useGlint=false` removes it everywhere, as OptiFine's doc says too. `cap=0` leaves no
|
||||
// custom layer, and vanilla's glint only if the pack's `useGlint` allows it.
|
||||
let vanilla = global.use_glint
|
||||
&& (layers.is_empty() || layers.iter().any(|&i| rules[i].glint.use_glint));
|
||||
// CIT Resewn: shares are against every enchantment of the stack, not only those the
|
||||
// layers name.
|
||||
let levels = enchantments.iter().map(|(_, level)| *level as f32);
|
||||
let total = match global.method {
|
||||
Method::Average => levels.sum::<f32>(),
|
||||
Method::Layered => levels.fold(0.0, f32::max),
|
||||
Method::None | Method::Cycle => 0.0,
|
||||
};
|
||||
let shared = matches!(global.method, Method::Average | Method::Layered);
|
||||
let layers = layers
|
||||
.into_iter()
|
||||
.map(|i| {
|
||||
let rule = &rules[i];
|
||||
let named = &rule.enchantments;
|
||||
let weight = if !shared || named.is_empty() {
|
||||
1.0
|
||||
} else {
|
||||
// The highest level among the enchantments the rule names whose level also
|
||||
// passes its `enchantmentLevels` (CIT Resewn takes the first named one the
|
||||
// stack has, whatever its level).
|
||||
let in_range = |level: u32| {
|
||||
let level = i32::try_from(level).unwrap_or(i32::MAX);
|
||||
rule.enchantment_levels
|
||||
.as_ref()
|
||||
.is_none_or(|r| r.contains(level))
|
||||
};
|
||||
let level = enchantments
|
||||
.iter()
|
||||
.filter(|(id, level)| {
|
||||
let id: &str = id.as_ref();
|
||||
named.iter().any(|n| n == id) && in_range(*level)
|
||||
})
|
||||
.map(|(_, level)| *level as f32)
|
||||
.fold(0.0, f32::max);
|
||||
if total > 0.0 { level / total } else { 0.0 }
|
||||
};
|
||||
GlintLayer {
|
||||
rule: i,
|
||||
weight,
|
||||
duration: rule.glint.duration,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
layers,
|
||||
vanilla,
|
||||
cycle_fade: cycle.then_some(global.fade),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.layers.is_empty()
|
||||
}
|
||||
|
||||
/// Layer `index`'s intensity `seconds` into the game: its weight, and under `method=cycle`
|
||||
/// its turn.
|
||||
pub fn intensity(&self, index: usize, seconds: f64) -> f32 {
|
||||
let layer = self.layers[index];
|
||||
match self.cycle_fade {
|
||||
None => layer.weight,
|
||||
Some(fade) => cycle_intensity(&self.layers, fade, index, seconds),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// CIT Resewn's `Loops.statelessFadingLoop`: each layer shows alone for its `duration`, then
|
||||
/// fades into the next over `fade` seconds, the last into the first. With no time at all
|
||||
/// (every `duration` and `fade` 0) the bottom layer shows alone.
|
||||
fn cycle_intensity(layers: &[GlintLayer], fade: f32, index: usize, seconds: f64) -> f32 {
|
||||
let n = layers.len();
|
||||
let fade = f64::from(fade);
|
||||
let period: f64 = layers.iter().map(|l| f64::from(l.duration) + fade).sum();
|
||||
if n == 1 || period <= 0.0 {
|
||||
return if index == 0 { 1.0 } else { 0.0 };
|
||||
}
|
||||
let mut t = seconds.rem_euclid(period);
|
||||
for (i, layer) in layers.iter().enumerate() {
|
||||
let duration = f64::from(layer.duration);
|
||||
if t < duration {
|
||||
return if i == index { 1.0 } else { 0.0 };
|
||||
}
|
||||
t -= duration;
|
||||
// Defensive: the last fade also takes anything rounding might leave of the period
|
||||
// (none seen), ending fully on the first layer.
|
||||
if t < fade || i + 1 == n {
|
||||
let into = if fade > 0.0 {
|
||||
(t / fade).min(1.0) as f32
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
return if i == index {
|
||||
1.0 - into
|
||||
} else if (i + 1) % n == index {
|
||||
into
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
t -= fade;
|
||||
}
|
||||
// Not reached, as the last layer returns above; no layer rather than a panic.
|
||||
0.0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_optifine::Properties;
|
||||
|
||||
const DIR: &str = "assets/minecraft/optifine/cit";
|
||||
|
||||
/// An enchantment rule named `name` with `text` after `type` and `texture`.
|
||||
fn rule(name: &str, text: &str) -> Rule {
|
||||
let text = format!("type=enchantment\ntexture=glint\n{text}");
|
||||
let path = format!("{DIR}/{name}.properties");
|
||||
Rule::parse(&path, &Properties::parse_equals_first(&text), |_| true).unwrap()
|
||||
}
|
||||
|
||||
fn global(text: &str) -> Global {
|
||||
Global::parse(&Properties::parse_equals_first(text))
|
||||
}
|
||||
|
||||
fn rules_of(layers: &StackGlint) -> Vec<usize> {
|
||||
layers.layers.iter().map(|l| l.rule).collect()
|
||||
}
|
||||
|
||||
const SWORD: [(&str, u32); 2] = [("minecraft:sharpness", 3), ("minecraft:unbreaking", 1)];
|
||||
|
||||
#[test]
|
||||
fn one_rule_per_layer_bottom_first() {
|
||||
let rules = [
|
||||
rule("a", "layer=2"),
|
||||
rule("b", "layer=0"),
|
||||
rule("c", "layer=2"),
|
||||
rule("d", "layer=-1"),
|
||||
];
|
||||
// Priority order a, b, c, d: c loses layer 2 to a.
|
||||
let g = StackGlint::new(&rules, &[0, 1, 2, 3], &SWORD, &Global::default());
|
||||
assert_eq!(rules_of(&g), [3, 1, 0]);
|
||||
let g = StackGlint::new(&rules, &[2, 0], &SWORD, &Global::default());
|
||||
assert_eq!(rules_of(&g), [2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn average_and_layered_shares() {
|
||||
let rules = [
|
||||
rule("sharp", "enchantments=sharpness smite"),
|
||||
rule("unb", "layer=1\nenchantments=unbreaking"),
|
||||
rule("any", "layer=2"),
|
||||
rule("fire", "layer=3\nenchantments=fire_aspect"),
|
||||
];
|
||||
let weights = |method: &str| {
|
||||
let g = StackGlint::new(&rules, &[0, 1, 2, 3], &SWORD, &global(method));
|
||||
g.layers.iter().map(|l| l.weight).collect::<Vec<_>>()
|
||||
};
|
||||
// Sharpness III and Unbreaking I: 3 / 4 and 1 / 4; against the highest, 3 / 3 and 1 / 3.
|
||||
// A rule naming no enchantment is at full strength, one the stack lacks at none.
|
||||
assert_eq!(weights("method=average"), [0.75, 0.25, 1.0, 0.0]);
|
||||
assert_eq!(weights("method=layered"), [1.0, 1.0 / 3.0, 1.0, 0.0]);
|
||||
assert_eq!(weights("method=cycle"), [1.0; 4]);
|
||||
assert_eq!(weights("method=none"), [1.0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_level_is_one_the_rules_levels_allow() {
|
||||
let rules = [rule(
|
||||
"holy",
|
||||
"enchantments=sharpness smite\nenchantmentLevels=1-2",
|
||||
)];
|
||||
// Sharpness V is out of range, so Smite I sets the share: 1 / 5 of the highest level.
|
||||
let stack = [("minecraft:sharpness", 5), ("minecraft:smite", 1)];
|
||||
let g = StackGlint::new(&rules, &[0], &stack, &global("method=layered"));
|
||||
assert_eq!(g.layers[0].weight, 1.0 / 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cap_keeps_the_topmost_layers() {
|
||||
let rules = [
|
||||
rule("a", "layer=0"),
|
||||
rule("b", "layer=1"),
|
||||
rule("c", "layer=2"),
|
||||
];
|
||||
let g = StackGlint::new(&rules, &[0, 1, 2], &SWORD, &global("cap=2"));
|
||||
assert_eq!(rules_of(&g), [1, 2]);
|
||||
let g = StackGlint::new(&rules, &[0, 1, 2], &SWORD, &global("cap=0"));
|
||||
assert!(g.is_empty());
|
||||
let g = StackGlint::new(&rules, &[0, 1, 2], &SWORD, &global("cap=1\nmethod=none"));
|
||||
assert_eq!(rules_of(&g), [2]);
|
||||
// Cycles show every layer in turn.
|
||||
let g = StackGlint::new(&rules, &[0, 1, 2], &SWORD, &global("cap=1\nmethod=cycle"));
|
||||
assert_eq!(rules_of(&g), [0, 1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vanilla_glint() {
|
||||
let plain = rule("plain", "");
|
||||
let keep = rule("keep", "layer=1\nuseGlint=true");
|
||||
let rules = [plain, keep];
|
||||
let vanilla = |matched: &[usize], text: &str| {
|
||||
StackGlint::new(&rules, matched, &SWORD, &global(text)).vanilla
|
||||
};
|
||||
assert!(vanilla(&[], ""), "no rule: vanilla's glint");
|
||||
assert!(!vanilla(&[0], ""), "a rule replaces it");
|
||||
assert!(vanilla(&[0, 1], ""), "unless one keeps it");
|
||||
assert!(!vanilla(&[], "useGlint=false"));
|
||||
assert!(!vanilla(&[1], "useGlint=false"), "the pack's word is final");
|
||||
// Only the layers `cap` keeps count.
|
||||
let rules = [rule("keep", "useGlint=true"), rule("top", "layer=1")];
|
||||
let g = StackGlint::new(&rules, &[0, 1], &SWORD, &global("cap=1"));
|
||||
assert!(!g.vanilla);
|
||||
let g = StackGlint::new(&rules, &[0, 1], &SWORD, &global("cap=0"));
|
||||
assert!(g.is_empty() && g.vanilla);
|
||||
let g = StackGlint::new(&rules, &[0, 1], &SWORD, &global("cap=0\nuseGlint=false"));
|
||||
assert!(!g.vanilla);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycles_take_turns_and_fade() {
|
||||
let rules = [
|
||||
rule("a", "duration=2"),
|
||||
rule("b", "layer=1\nduration=1"),
|
||||
rule("c", "layer=2\nduration=1"),
|
||||
];
|
||||
let g = StackGlint::new(
|
||||
&rules,
|
||||
&[0, 1, 2],
|
||||
&SWORD,
|
||||
&global("method=cycle\nfade=0.5"),
|
||||
);
|
||||
// a 0-2, a→b 2-2.5, b 2.5-3.5, b→c 3.5-4, c 4-5, c→a 5-5.5, then again.
|
||||
let at = |t: f64| [0, 1, 2].map(|i| g.intensity(i, t));
|
||||
assert_eq!(at(1.0), [1.0, 0.0, 0.0]);
|
||||
assert_eq!(at(2.125), [0.75, 0.25, 0.0]);
|
||||
assert_eq!(at(3.0), [0.0, 1.0, 0.0]);
|
||||
assert_eq!(at(3.75), [0.0, 0.5, 0.5]);
|
||||
assert_eq!(at(4.5), [0.0, 0.0, 1.0]);
|
||||
assert_eq!(at(5.25), [0.5, 0.0, 0.5]);
|
||||
assert_eq!(at(5.5 + 3.0), [0.0, 1.0, 0.0]);
|
||||
// One layer always shows.
|
||||
let one = StackGlint::new(&rules, &[1], &SWORD, &global("method=cycle"));
|
||||
assert_eq!(one.intensity(0, 0.7), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cycle_always_shows_a_whole_layer() {
|
||||
let rules = [
|
||||
rule("a", "duration=0.1"),
|
||||
rule("b", "layer=1\nduration=0.7"),
|
||||
rule("c", "layer=2\nduration=0.2"),
|
||||
];
|
||||
let g = StackGlint::new(
|
||||
&rules,
|
||||
&[0, 1, 2],
|
||||
&SWORD,
|
||||
&global("method=cycle\nfade=0.3"),
|
||||
);
|
||||
// Every millisecond of the first two seconds, and just under a period, the last fade's
|
||||
// end. (Rounding has not been seen to leave time past it; the last fade's clamp is
|
||||
// defensive.)
|
||||
let period: f64 = [0.1f32, 0.7, 0.2]
|
||||
.map(|d| f64::from(d) + f64::from(0.3f32))
|
||||
.iter()
|
||||
.sum();
|
||||
let last = f64::from_bits(period.to_bits() - 1);
|
||||
for t in (0..2000_i32)
|
||||
.map(|i| f64::from(i) * 0.001)
|
||||
.chain([last, period + last])
|
||||
{
|
||||
let sum: f32 = (0..3).map(|i| g.intensity(i, t)).sum();
|
||||
assert!((sum - 1.0).abs() < 1e-5, "{sum} at {t}");
|
||||
}
|
||||
// No time at all: the bottom layer alone, never NaN.
|
||||
let still = [rule("a", "duration=0"), rule("b", "layer=1\nduration=0")];
|
||||
let g = StackGlint::new(&still, &[0, 1], &SWORD, &global("method=cycle\nfade=0"));
|
||||
for t in [0.0, 0.5, 7.25] {
|
||||
assert_eq!([0, 1].map(|i| g.intensity(i, t)), [1.0, 0.0]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_global_file_wins() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-cit-glint-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
assert_eq!(load_global(&root), Global::default());
|
||||
let write = |path: &str, text: &[u8]| {
|
||||
let file = root.join(path);
|
||||
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
std::fs::write(file, text).unwrap();
|
||||
};
|
||||
// Saved as Latin-1 (`é` is not UTF-8 alone), in the OptiFine doc's `cit/` folder.
|
||||
write(
|
||||
"assets/minecraft/optifine/cit/cit.properties",
|
||||
b"# caf\xE9\nmethod=cycle",
|
||||
);
|
||||
assert_eq!(load_global(&root).method, Method::Cycle);
|
||||
write("assets/minecraft/mcpatcher/cit.properties", b"method=none");
|
||||
assert_eq!(load_global(&root).method, Method::Cycle);
|
||||
write(
|
||||
"assets/minecraft/optifine/cit.properties",
|
||||
b"method=layered",
|
||||
);
|
||||
assert_eq!(load_global(&root).method, Method::Layered);
|
||||
write("assets/minecraft/citresewn/cit.properties", b"cap=1");
|
||||
let g = load_global(&root);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
assert_eq!((g.method, g.cap), (Method::Average, Some(1)));
|
||||
}
|
||||
}
|
||||
@@ -41,9 +41,35 @@ pub enum Pick {
|
||||
Quarters([TileLayer; 4]),
|
||||
}
|
||||
|
||||
/// A tile drawn over a face, after its own (`overlay*` methods).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Overlay {
|
||||
pub layer: u16,
|
||||
/// `layer=translucent`: drawn in the translucent pass, else with an alpha cutout.
|
||||
pub translucent: bool,
|
||||
/// `tintIndex` and the state whose tint it is (`tintBlock`, else the face's block).
|
||||
pub tint: Option<(i32, Option<u32>)>,
|
||||
}
|
||||
|
||||
struct LoadedRule {
|
||||
rule: Rule,
|
||||
tiles: Vec<TileLayer>,
|
||||
overlay: OverlayJoin,
|
||||
}
|
||||
|
||||
/// What an overlay rule joins: `connectBlocks` as state ids and `connectTiles` as sprite
|
||||
/// layers (sorted), and its tint.
|
||||
#[derive(Default)]
|
||||
struct OverlayJoin {
|
||||
states: Vec<u32>,
|
||||
tiles: Vec<u16>,
|
||||
tint: Option<(i32, Option<u32>)>,
|
||||
}
|
||||
|
||||
impl OverlayJoin {
|
||||
fn any(&self) -> bool {
|
||||
!self.states.is_empty() || !self.tiles.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
@@ -53,8 +79,15 @@ struct StateInfo {
|
||||
axis: Option<Axis>,
|
||||
/// `half=upper`: `linked` random tiles follow the lower half.
|
||||
upper: bool,
|
||||
/// A pane's arms (`north`, `east`, `south`, `west` true), as bits of [`ARMS`].
|
||||
arms: u8,
|
||||
/// Rules name the state or one of its sides' sprites ([`ConnectedTextures::stacked_pane_hides`]).
|
||||
has_rules: bool,
|
||||
}
|
||||
|
||||
/// A pane's arm properties, in [`StateInfo::arms`]' bit order.
|
||||
const ARMS: [&str; 4] = ["north", "east", "south", "west"];
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ConnectedTextures {
|
||||
rules: Vec<LoadedRule>,
|
||||
@@ -98,9 +131,8 @@ impl ConnectedTextures {
|
||||
mut face_layer: impl FnMut(BlockStateId, Face) -> Option<u32>,
|
||||
) -> Self {
|
||||
let mut rules = ctm::load(root);
|
||||
// Rules no face can use load no tiles: overlays (not drawn yet), and custom names (section
|
||||
// meshes see no block entity).
|
||||
rules.retain(|r| !r.method.is_overlay() && r.name.is_none());
|
||||
// Rules with a custom name load no tiles: section meshes see no block entity.
|
||||
rules.retain(|r| r.name.is_none());
|
||||
// Rule indices are `u16`.
|
||||
rules.truncate(usize::from(u16::MAX));
|
||||
if rules.is_empty() {
|
||||
@@ -141,7 +173,11 @@ impl ConnectedTextures {
|
||||
})
|
||||
.collect::<crate::Result<Vec<_>>>();
|
||||
match tiles {
|
||||
Ok(tiles) => Some(LoadedRule { rule, tiles }),
|
||||
Ok(tiles) => Some(LoadedRule {
|
||||
rule,
|
||||
tiles,
|
||||
overlay: OverlayJoin::default(),
|
||||
}),
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: connected textures {}: a tile: {e}", rule.path);
|
||||
None
|
||||
@@ -155,7 +191,11 @@ impl ConnectedTextures {
|
||||
.map(|s| (s.name.as_str(), &s.properties))
|
||||
});
|
||||
let mut out = Self::build(loaded, states, |name| sprites.layers.get(name).copied());
|
||||
if out.rules.iter().any(|r| r.rule.connect == Connect::Tile) {
|
||||
if out
|
||||
.rules
|
||||
.iter()
|
||||
.any(|r| r.rule.connect == Connect::Tile || !r.overlay.tiles.is_empty())
|
||||
{
|
||||
out.face_layers = (0..registry.len() as u32)
|
||||
.map(|id| {
|
||||
Face::ALL
|
||||
@@ -163,6 +203,23 @@ impl ConnectedTextures {
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
// Panes whose sprites tile rules name (`matchTiles=glass`) are connected too, by a rule
|
||||
// that can draw on them: here, one naming no block (a rule naming the pane is in its
|
||||
// own list already).
|
||||
for id in (0..out.states.len()).filter(|_| !out.by_layer.is_empty()) {
|
||||
let pane = registry
|
||||
.state(BlockStateId(id as u32))
|
||||
.is_some_and(|s| s.name.ends_with("glass_pane"));
|
||||
if pane && !out.states[id].has_rules {
|
||||
out.states[id].has_rules = Face::ALL.into_iter().any(|f| {
|
||||
face_layer(BlockStateId(id as u32), f).is_some_and(|l| {
|
||||
out.list(&out.by_layer, l as usize)
|
||||
.iter()
|
||||
.any(|&i| out.rules[usize::from(i)].rule.match_blocks.is_empty())
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -177,6 +234,48 @@ impl ConnectedTextures {
|
||||
rules,
|
||||
..Self::default()
|
||||
};
|
||||
let states: Vec<_> = states.collect();
|
||||
// Each block's states, for `connectBlocks` and `tintBlock`.
|
||||
let mut block_states: HashMap<&str, Vec<u32>> = HashMap::new();
|
||||
if out.rules.iter().any(|r| r.rule.method.is_overlay()) {
|
||||
for (id, state) in states.iter().enumerate() {
|
||||
if let Some((name, _)) = state {
|
||||
block_states.entry(name).or_default().push(id as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
for r in out.rules.iter_mut().filter(|r| r.rule.method.is_overlay()) {
|
||||
let rule = &r.rule;
|
||||
// `connectBlocks` and `connectTiles` are "only for method overlay".
|
||||
if rule.method == Method::Overlay {
|
||||
for m in &rule.connect_blocks {
|
||||
let candidates = block_states
|
||||
.get(m.id.as_str())
|
||||
.map_or(&[][..], Vec::as_slice);
|
||||
r.overlay.states.extend(candidates.iter().filter(|&&id| {
|
||||
states[id as usize].is_some_and(|(name, props)| {
|
||||
m.matches(name, |k| props.get(k).map(String::as_str))
|
||||
})
|
||||
}));
|
||||
}
|
||||
r.overlay.states.sort_unstable();
|
||||
r.overlay.states.dedup();
|
||||
r.overlay.tiles = rule
|
||||
.connect_tiles
|
||||
.iter()
|
||||
.filter_map(|t| layer(&sprite_name(t)).map(|l| l as u16))
|
||||
.collect();
|
||||
r.overlay.tiles.sort_unstable();
|
||||
}
|
||||
// A `tintBlock` the game doesn't have tints nothing.
|
||||
let tint = match rule.tint_block.as_deref() {
|
||||
None => Some(None),
|
||||
Some(block) => block_states.get(block).map(|ids| Some(ids[0])),
|
||||
};
|
||||
r.overlay.tint = tint
|
||||
.filter(|_| rule.tint_index >= 0)
|
||||
.map(|b| (rule.tint_index, b));
|
||||
}
|
||||
let mut blocks = HashMap::new();
|
||||
let mut by_layer: Vec<Vec<u16>> = Vec::new();
|
||||
// Block rules by the block each entry names, so a state tests only its own.
|
||||
@@ -224,6 +323,13 @@ impl ConnectedTextures {
|
||||
_ => None,
|
||||
},
|
||||
upper: prop("half") == Some("upper"),
|
||||
arms: ARMS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|&(_, arm)| prop(arm) == Some("true"))
|
||||
.fold(0, |bits, (i, _)| bits | 1 << i),
|
||||
// Set below, with the state's rule list.
|
||||
has_rules: false,
|
||||
});
|
||||
list.clear();
|
||||
if let Some(candidates) = by_block.get(name) {
|
||||
@@ -233,6 +339,7 @@ impl ConnectedTextures {
|
||||
.filter(|&&i| out.rules[usize::from(i)].rule.matches_block(name, prop)),
|
||||
);
|
||||
}
|
||||
out.states.last_mut().expect("pushed above").has_rules = !list.is_empty();
|
||||
let range = push_list(&mut out.lists, &list);
|
||||
out.by_state.push(range);
|
||||
}
|
||||
@@ -243,6 +350,35 @@ impl ConnectedTextures {
|
||||
self.rules.is_empty()
|
||||
}
|
||||
|
||||
/// Whether a pane's top or bottom strip, centred at `centre` (block-local x, z), is hidden by
|
||||
/// the pane `other` stacked on it (Continuity's "Glass Pane Culling Fix"): only for a pane
|
||||
/// with connected-texture rules, under or over the same block, where `other` has the same
|
||||
/// part (the post, or the arm toward the strip).
|
||||
pub fn stacked_pane_hides(&self, state: u32, other: u32, [x, z]: [f32; 2]) -> bool {
|
||||
let (Some(own), Some(theirs)) = (
|
||||
self.states.get(state as usize),
|
||||
self.states.get(other as usize),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
if own.block != theirs.block || !own.has_rules {
|
||||
return false;
|
||||
}
|
||||
let (dx, dz) = (x - 0.5, z - 0.5);
|
||||
// The post is 2 pixels wide; an arm runs from it to the block's edge.
|
||||
if dx.abs() <= 1.0 / 16.0 && dz.abs() <= 1.0 / 16.0 {
|
||||
return true;
|
||||
}
|
||||
let arm = if dz.abs() > dx.abs() {
|
||||
if dz < 0.0 { 0 } else { 2 }
|
||||
} else if dx > 0.0 {
|
||||
1
|
||||
} else {
|
||||
3
|
||||
};
|
||||
theirs.arms & 1 << arm != 0
|
||||
}
|
||||
|
||||
fn list(&self, ranges: &[(u32, u32)], i: usize) -> &[u16] {
|
||||
ranges.get(i).map_or(&[], |&(start, len)| {
|
||||
&self.lists[start as usize..(start + len) as usize]
|
||||
@@ -251,7 +387,8 @@ impl ConnectedTextures {
|
||||
|
||||
/// What the `face` of the block `state` at `pos` draws, for a quad with sprite `layer`.
|
||||
/// `neighbour` answers the state at an offset from the block; `biome` its biome, asked only
|
||||
/// by rules that test one.
|
||||
/// by rules that test one; `texture` the quad's own texture directions, asked only by rules
|
||||
/// with `orient=texture`.
|
||||
pub fn pick<'b>(
|
||||
&self,
|
||||
state: u32,
|
||||
@@ -260,6 +397,8 @@ impl ConnectedTextures {
|
||||
pos: [i32; 3],
|
||||
neighbour: impl Fn([i32; 3]) -> u32,
|
||||
biome: impl Fn() -> Option<&'b str>,
|
||||
texture: impl Fn() -> Option<Axes>,
|
||||
mut overlay: impl FnMut(Overlay),
|
||||
) -> Pick {
|
||||
let (mut by_state, mut by_layer) = (
|
||||
self.list(&self.by_state, state as usize),
|
||||
@@ -290,14 +429,65 @@ impl ConnectedTextures {
|
||||
let r = &self.rules[usize::from(i)];
|
||||
if (block || r.rule.match_blocks.is_empty())
|
||||
&& (tile || r.rule.match_tiles.is_empty())
|
||||
&& let Some(pick) = self.apply(r, state, layer, face, pos, &neighbour, &biome)
|
||||
&& let Some(pick) = self.apply(
|
||||
r,
|
||||
state,
|
||||
layer,
|
||||
face,
|
||||
pos,
|
||||
&neighbour,
|
||||
&biome,
|
||||
&texture,
|
||||
&mut overlay,
|
||||
)
|
||||
{
|
||||
// "Tiles output by CTM rules can also be matched by another rule": once, by the
|
||||
// tile rules naming the picked tile (and this block, if they name blocks).
|
||||
// A tile on the face's own sprite (`tiles=stone`) is no new tile.
|
||||
let Pick::Tile(TileLayer::Layer { layer: picked, .. }) = pick else {
|
||||
return pick;
|
||||
};
|
||||
if picked == layer {
|
||||
return pick;
|
||||
}
|
||||
let own = self.list(&self.by_state, state as usize);
|
||||
// Rules tried on the face's sprite already (those before this one) are not tried
|
||||
// again: an overlay would draw twice.
|
||||
let first = self.list(&self.by_layer, layer as usize);
|
||||
let again = self
|
||||
.list(&self.by_layer, picked as usize)
|
||||
.iter()
|
||||
.map(|&j| (j, &self.rules[usize::from(j)]))
|
||||
.filter(|&(j, r)| {
|
||||
j != i
|
||||
&& !(j < i && first.contains(&j))
|
||||
&& (r.rule.match_blocks.is_empty() || own.contains(&j))
|
||||
});
|
||||
for (_, r) in again {
|
||||
// Joins by tile still compare the face's own sprite with its neighbours'.
|
||||
match self.apply(
|
||||
r,
|
||||
state,
|
||||
layer,
|
||||
face,
|
||||
pos,
|
||||
&neighbour,
|
||||
&biome,
|
||||
&texture,
|
||||
&mut overlay,
|
||||
) {
|
||||
Some(Pick::Keep) => return pick,
|
||||
Some(chained) => return chained,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
return pick;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One rule on a face: `None` when it doesn't apply or picks `<skip>`.
|
||||
/// One rule on a face: `None` when it doesn't apply, picks `<skip>` or draws an overlay
|
||||
/// (handed to `overlay`: the rules after it still choose the face's own sprite).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn apply<'b>(
|
||||
&self,
|
||||
@@ -308,6 +498,8 @@ impl ConnectedTextures {
|
||||
pos: [i32; 3],
|
||||
neighbour: &impl Fn([i32; 3]) -> u32,
|
||||
biome: &impl Fn() -> Option<&'b str>,
|
||||
texture: &impl Fn() -> Option<Axes>,
|
||||
overlay: &mut impl FnMut(Overlay),
|
||||
) -> Option<Pick> {
|
||||
let rule = &r.rule;
|
||||
// `Rule::applies_at`, with the biome looked up only for rules that test one. Rules with
|
||||
@@ -323,8 +515,17 @@ impl ConnectedTextures {
|
||||
return None;
|
||||
}
|
||||
let info = self.states.get(state as usize).copied().unwrap_or_default();
|
||||
let join = &r.overlay;
|
||||
let joins = |offset: [i32; 3]| {
|
||||
let other = neighbour(offset);
|
||||
if join.any() {
|
||||
// An overlay joins the blocks it is drawn beside.
|
||||
return join.states.binary_search(&other).is_ok()
|
||||
|| self
|
||||
.face_layers
|
||||
.get(other as usize)
|
||||
.is_some_and(|f| join.tiles.binary_search(&f[face as usize]).is_ok());
|
||||
}
|
||||
match rule.connect {
|
||||
Connect::Block => {
|
||||
self.states.get(other as usize).map(|s| s.block) == Some(info.block)
|
||||
@@ -338,12 +539,46 @@ impl ConnectedTextures {
|
||||
};
|
||||
let axes = |face| match (rule.orient, info.axis) {
|
||||
(Orient::StateAxis, Some(axis)) => Axes::column(face, axis),
|
||||
// `orient=texture` follows the quad's own UVs; vanilla cubes are what it is used on.
|
||||
// The quad's own texture directions, whatever face `symmetry` stands it for.
|
||||
// Not for `overlay`, which Continuity's `orient` leaves out: its 17 tiles always face
|
||||
// the standard way.
|
||||
(Orient::Texture, _) if rule.method != Method::Overlay => {
|
||||
texture().unwrap_or(Axes::cube(face))
|
||||
}
|
||||
_ => Axes::cube(face),
|
||||
};
|
||||
let mask = || geometry::neighbours(axes(face), face.normal(), rule.inner_seams, joins);
|
||||
let tile = |i: usize| r.tiles.get(i).copied().unwrap_or(TileLayer::Default);
|
||||
let index = match rule.method {
|
||||
let mut draw = |t: TileLayer| {
|
||||
if let TileLayer::Layer { layer, .. } = t {
|
||||
overlay(Overlay {
|
||||
layer,
|
||||
translucent: rule.layer == ctm::Layer::Translucent,
|
||||
tint: join.tint,
|
||||
});
|
||||
}
|
||||
};
|
||||
// An overlay picks its tile as the method it is the overlay of.
|
||||
let method = match rule.method {
|
||||
Method::Overlay => {
|
||||
// The 17 transition tiles, toward the blocks it names (none: it draws nothing).
|
||||
if join.any() {
|
||||
let mask = geometry::neighbours(axes(face), face.normal(), false, joins);
|
||||
geometry::overlay_tiles(mask).for_each(|i| draw(tile(i)));
|
||||
}
|
||||
return None;
|
||||
}
|
||||
Method::OverlayCtm => Method::Ctm,
|
||||
Method::OverlayRandom => Method::Random,
|
||||
Method::OverlayRepeat => Method::Repeat,
|
||||
Method::OverlayFixed => Method::Fixed,
|
||||
Method::OverlayHorizontal => Method::Horizontal,
|
||||
Method::OverlayVertical => Method::Vertical,
|
||||
Method::OverlayHorizontalVertical => Method::HorizontalVertical,
|
||||
Method::OverlayVerticalHorizontal => Method::VerticalHorizontal,
|
||||
m => m,
|
||||
};
|
||||
let index = match method {
|
||||
Method::Ctm => geometry::ctm_index(mask()),
|
||||
Method::CtmCompact => {
|
||||
let mask = mask();
|
||||
@@ -388,9 +623,13 @@ impl ConnectedTextures {
|
||||
let face = geometry::symmetric_face(face, rule.symmetry);
|
||||
geometry::repeat_index(pos, axes(face), rule.width, rule.height)
|
||||
}
|
||||
// Overlays are not loaded yet.
|
||||
// Every overlay was mapped to its method above.
|
||||
_ => return None,
|
||||
};
|
||||
if rule.method.is_overlay() {
|
||||
draw(tile(index));
|
||||
return None;
|
||||
}
|
||||
match tile(index) {
|
||||
TileLayer::Skip => None,
|
||||
TileLayer::Default => Some(Pick::Keep),
|
||||
@@ -423,6 +662,7 @@ mod tests {
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(n, rule)| LoadedRule {
|
||||
overlay: OverlayJoin::default(),
|
||||
tiles: rule
|
||||
.tiles
|
||||
.iter()
|
||||
@@ -443,7 +683,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// State 0 air, 1 glass, 2 stone, 3 and 4 oak logs (axis y, x), 5 and 6 a door (lower,
|
||||
/// upper); the stone sprite is layer 7.
|
||||
/// upper); the stone sprite is layer 7, glass 3.
|
||||
fn world(files: &[(&str, &str)]) -> ConnectedTextures {
|
||||
let props = |pairs: &[(&str, &str)]| -> BlockProperties {
|
||||
pairs
|
||||
@@ -463,7 +703,15 @@ mod tests {
|
||||
ConnectedTextures::build(
|
||||
rules(files),
|
||||
states.iter().map(|(n, p)| Some((*n, p))),
|
||||
|name| (name == "minecraft:block/stone").then_some(7),
|
||||
|name| match name {
|
||||
"minecraft:block/stone" => Some(7),
|
||||
"minecraft:block/glass" => Some(3),
|
||||
// The tile `b0` of a rule second in order (`a_picked_tile_can_be_picked_again`).
|
||||
"assets/minecraft/optifine/ctm/b0.png" => Some(2000),
|
||||
// And `a0` of the first rule.
|
||||
"assets/minecraft/optifine/ctm/a0.png" => Some(1000),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -484,7 +732,7 @@ mod tests {
|
||||
blocks: &[([i32; 3], u32)],
|
||||
) -> Pick {
|
||||
let at = |o: [i32; 3]| blocks.iter().find(|(p, _)| *p == o).map_or(0, |b| b.1);
|
||||
ctm.pick(state, layer, face, [0, 64, 0], at, || None)
|
||||
ctm.pick(state, layer, face, [0, 64, 0], at, || None, || None, |_| {})
|
||||
}
|
||||
|
||||
const GLASS: &str = "matchBlocks=glass\nmethod=ctm\ntiles=0-46";
|
||||
@@ -607,8 +855,26 @@ mod tests {
|
||||
)]);
|
||||
let mut tiles = std::collections::HashSet::new();
|
||||
for x in 0..20 {
|
||||
let lower = ctm.pick(5, 3, Face::North, [x, 64, 0], |_| 0, || None);
|
||||
let upper = ctm.pick(6, 3, Face::North, [x, 65, 0], |_| 0, || None);
|
||||
let lower = ctm.pick(
|
||||
5,
|
||||
3,
|
||||
Face::North,
|
||||
[x, 64, 0],
|
||||
|_| 0,
|
||||
|| None,
|
||||
|| None,
|
||||
|_| {},
|
||||
);
|
||||
let upper = ctm.pick(
|
||||
6,
|
||||
3,
|
||||
Face::North,
|
||||
[x, 65, 0],
|
||||
|_| 0,
|
||||
|| None,
|
||||
|| None,
|
||||
|_| {},
|
||||
);
|
||||
assert_eq!(lower, upper, "{x}");
|
||||
tiles.insert(format!("{lower:?}"));
|
||||
}
|
||||
@@ -621,8 +887,18 @@ mod tests {
|
||||
"glass.properties",
|
||||
"matchBlocks=glass\nmethod=fixed\ntiles=0\nheights=0-63\nbiomes=desert",
|
||||
)]);
|
||||
let at =
|
||||
|y, biome: &'static str| ctm.pick(1, 3, Face::Up, [0, y, 0], |_| 0, || Some(biome));
|
||||
let at = |y, biome: &'static str| {
|
||||
ctm.pick(
|
||||
1,
|
||||
3,
|
||||
Face::Up,
|
||||
[0, y, 0],
|
||||
|_| 0,
|
||||
|| Some(biome),
|
||||
|| None,
|
||||
|_| {},
|
||||
)
|
||||
};
|
||||
assert_eq!(at(10, "minecraft:desert"), tile(1000));
|
||||
assert_eq!(at(64, "minecraft:desert"), Pick::Keep);
|
||||
assert_eq!(at(10, "minecraft:plains"), Pick::Keep);
|
||||
@@ -656,7 +932,7 @@ mod tests {
|
||||
"glass.properties",
|
||||
"matchBlocks=glass\nmethod=repeat\nwidth=2\nheight=2\ntiles=0-3",
|
||||
)]);
|
||||
let at = |pos| ctm.pick(1, 3, Face::North, pos, |_| 0, || None);
|
||||
let at = |pos| ctm.pick(1, 3, Face::North, pos, |_| 0, || None, || None, |_| {});
|
||||
assert_eq!(at([0, 64, 0]), tile(1000));
|
||||
// The north face's right is west; rows run down.
|
||||
assert_eq!(at([-1, 64, 0]), tile(1001));
|
||||
@@ -721,6 +997,277 @@ mod tests {
|
||||
assert_eq!(pick_in(&top, 1, 3, Face::Up, &[]), tile(2000));
|
||||
}
|
||||
|
||||
/// The overlays `pick` hands out, with what it picks for the face itself.
|
||||
fn overlays_in(
|
||||
ctm: &ConnectedTextures,
|
||||
state: u32,
|
||||
face: Face,
|
||||
blocks: &[([i32; 3], u32)],
|
||||
) -> (Vec<u16>, Pick) {
|
||||
let at = |o: [i32; 3]| blocks.iter().find(|(p, _)| *p == o).map_or(0, |b| b.1);
|
||||
let mut out = Vec::new();
|
||||
let pick = ctm.pick(
|
||||
state,
|
||||
3,
|
||||
face,
|
||||
[0, 64, 0],
|
||||
at,
|
||||
|| None,
|
||||
|| None,
|
||||
|o| out.push(o.layer),
|
||||
);
|
||||
(out, pick)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlays_draw_toward_the_blocks_they_name() {
|
||||
// Stone gets glass's edge where glass is beside it; then a fixed tile for the stone.
|
||||
let ctm = world(&[
|
||||
(
|
||||
"a_overlay.properties",
|
||||
"matchBlocks=stone\nmethod=overlay\nconnectBlocks=glass\ntiles=0-16\ntintIndex=0\ntintBlock=oak_log",
|
||||
),
|
||||
("b.properties", "matchBlocks=stone\nmethod=fixed\ntiles=0"),
|
||||
]);
|
||||
// Nothing beside: no overlay, and b draws.
|
||||
assert_eq!(overlays_in(&ctm, 2, Face::Up, &[]), (vec![], tile(2000)));
|
||||
// Glass to the north of a top face (its texture's up): tile 15. Glass east and west
|
||||
// too: one tile for the three sides, 13.
|
||||
assert_eq!(overlays_in(&ctm, 2, Face::Up, &[([0, 0, -1], 1)]).0, [1015]);
|
||||
let three = [([0, 0, -1], 1), ([1, 0, 0], 1), ([-1, 0, 0], 1)];
|
||||
assert_eq!(overlays_in(&ctm, 2, Face::Up, &three).0, [1013]);
|
||||
// Glass only at the north-east corner: the corner tile, 14.
|
||||
assert_eq!(overlays_in(&ctm, 2, Face::Up, &[([1, 0, -1], 1)]).0, [1014]);
|
||||
// Stone beside stone draws nothing.
|
||||
assert_eq!(
|
||||
overlays_in(&ctm, 2, Face::Up, &[([0, 0, -1], 2)]).0,
|
||||
[0u16; 0]
|
||||
);
|
||||
let mut tint = None;
|
||||
ctm.pick(
|
||||
2,
|
||||
3,
|
||||
Face::Up,
|
||||
[0, 64, 0],
|
||||
|o| if o == [0, 0, -1] { 1 } else { 0 },
|
||||
|| None,
|
||||
|| None,
|
||||
|o| {
|
||||
tint = Some((o.tint, o.translucent));
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
tint,
|
||||
Some((Some((0, Some(3))), false)),
|
||||
"oak_log's first state is 3"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_joins_layers_and_tints() {
|
||||
let mut ctm = world(&[
|
||||
(
|
||||
"a.properties",
|
||||
"matchBlocks=stone\nmethod=overlay\nconnectTiles=glass\ntiles=0-16\nlayer=translucent\ntintIndex=0\ntintBlock=no_such_block",
|
||||
),
|
||||
(
|
||||
"b.properties",
|
||||
"matchBlocks=glass\nmethod=overlay_ctm\nconnectBlocks=stone\ntiles=0-46",
|
||||
),
|
||||
]);
|
||||
// connectTiles joins neighbours showing a named sprite on the same side: the door to the
|
||||
// north shows glass (sprite 3) on top.
|
||||
let mut faces = vec![[9u16; 6]; 7];
|
||||
faces[5][Face::Up as usize] = 3;
|
||||
ctm.face_layers = faces;
|
||||
let mut got = Vec::new();
|
||||
let door_north = |o: [i32; 3]| if o == [0, 0, -1] { 5 } else { 0 };
|
||||
ctm.pick(
|
||||
2,
|
||||
7,
|
||||
Face::Up,
|
||||
[0, 64, 0],
|
||||
door_north,
|
||||
|| None,
|
||||
|| None,
|
||||
|o| got.push(o),
|
||||
);
|
||||
assert_eq!(
|
||||
got,
|
||||
[Overlay {
|
||||
layer: 1015,
|
||||
translucent: true,
|
||||
// A tintBlock the game lacks tints nothing.
|
||||
tint: None,
|
||||
}]
|
||||
);
|
||||
// connectBlocks is only for `overlay`: overlay_ctm joins glass by `connect`, not stone.
|
||||
let (layers, _) = overlays_in(&ctm, 1, Face::North, &[([-1, 0, 0], 2)]);
|
||||
assert_eq!(layers, [2000]);
|
||||
let (layers, _) = overlays_in(&ctm, 1, Face::North, &[([-1, 0, 0], 1)]);
|
||||
assert_eq!(layers, [2001]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_methods_pick_like_their_own() {
|
||||
let ctm = world(&[
|
||||
(
|
||||
"a.properties",
|
||||
"matchBlocks=glass\nmethod=overlay_ctm\ntiles=0-46\nlayer=translucent",
|
||||
),
|
||||
(
|
||||
"b.properties",
|
||||
"matchBlocks=glass\nmethod=overlay_fixed\ntiles=<skip>",
|
||||
),
|
||||
(
|
||||
"c.properties",
|
||||
"matchBlocks=glass\nmethod=overlay_fixed\ntiles=0",
|
||||
),
|
||||
]);
|
||||
// overlay_ctm joins glass (connect=block): the left end of a row, tile 1; b's <skip>
|
||||
// draws nothing; c draws; the face keeps its own sprite.
|
||||
let (layers, pick) = overlays_in(&ctm, 1, Face::North, &[([-1, 0, 0], 1)]);
|
||||
assert_eq!((layers, pick), (vec![1001, 3000], Pick::Keep));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picked_tile_can_be_picked_again() {
|
||||
// b (rule 1) gives glass its tile b0, layer 2000; t (rule 0, a tile rule, tried first)
|
||||
// names that tile, so it draws in b0's place.
|
||||
let chain = |t: &str| {
|
||||
world(&[
|
||||
("b.properties", "matchBlocks=glass\nmethod=fixed\ntiles=b0"),
|
||||
("t.properties", t),
|
||||
])
|
||||
};
|
||||
let ctm = chain("matchTiles=optifine/ctm/b0.png\nmethod=fixed\ntiles=0");
|
||||
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(1000));
|
||||
// A chained rule keeping the tile (<default>) leaves b's.
|
||||
let ctm = chain("matchTiles=optifine/ctm/b0.png\nmethod=fixed\ntiles=<default>");
|
||||
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
|
||||
// One that names another block doesn't chain on glass.
|
||||
let ctm = chain("matchTiles=optifine/ctm/b0.png\nmatchBlocks=stone\nmethod=fixed\ntiles=0");
|
||||
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
|
||||
// An overlay tried on the face's own sprite and on the picked tile draws once.
|
||||
let ctm = chain("matchTiles=glass optifine/ctm/b0.png\nmethod=overlay_fixed\ntiles=0");
|
||||
let (overlays, pick) = overlays_in(&ctm, 1, Face::Up, &[]);
|
||||
assert_eq!((overlays, pick), (vec![1000], tile(2000)));
|
||||
// A rule after the one that picked, naming both the face's sprite and the tile, wasn't
|
||||
// tried: it chains.
|
||||
let ctm = world(&[
|
||||
("a.properties", "matchTiles=glass\nmethod=fixed\ntiles=a0"),
|
||||
(
|
||||
"c.properties",
|
||||
"matchTiles=glass optifine/ctm/a0.png\nmethod=fixed\ntiles=0",
|
||||
),
|
||||
]);
|
||||
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orient_texture_follows_the_quads_uvs() {
|
||||
let rule = |orient: &str| {
|
||||
world(&[(
|
||||
"glass.properties",
|
||||
&*format!("matchBlocks=glass\nmethod=horizontal\ntiles=0-3\norient={orient}"),
|
||||
)])
|
||||
};
|
||||
// A mirrored north face (a vanilla pane's): its texture's right is east.
|
||||
let mirrored = || {
|
||||
Some(Axes {
|
||||
right: [1, 0, 0],
|
||||
up: [0, 1, 0],
|
||||
})
|
||||
};
|
||||
let east = |o: [i32; 3]| if o == [1, 0, 0] { 1 } else { 0 };
|
||||
let at = |ctm: &ConnectedTextures| {
|
||||
ctm.pick(
|
||||
1,
|
||||
3,
|
||||
Face::North,
|
||||
[0, 64, 0],
|
||||
east,
|
||||
|| None,
|
||||
mirrored,
|
||||
|_| {},
|
||||
)
|
||||
};
|
||||
// Glass to the east is to the texture's right: the row's left end.
|
||||
assert_eq!(at(&rule("texture")), tile(1000));
|
||||
// By the cube's directions it is to the left: the row's right end.
|
||||
assert_eq!(at(&rule("none")), tile(1002));
|
||||
// A plain overlay keeps the standard directions: glass north of a top face is above it
|
||||
// (tile 15) whatever the quad's texture says.
|
||||
let overlay = world(&[(
|
||||
"a.properties",
|
||||
"matchBlocks=stone\nmethod=overlay\nconnectBlocks=glass\ntiles=0-16\norient=texture",
|
||||
)]);
|
||||
let turned = || {
|
||||
Some(Axes {
|
||||
right: [0, 0, 1],
|
||||
up: [1, 0, 0],
|
||||
})
|
||||
};
|
||||
let north = |o: [i32; 3]| if o == [0, 0, -1] { 1 } else { 0 };
|
||||
let mut got = Vec::new();
|
||||
overlay.pick(
|
||||
2,
|
||||
7,
|
||||
Face::Up,
|
||||
[0, 64, 0],
|
||||
north,
|
||||
|| None,
|
||||
turned,
|
||||
|o| got.push(o.layer),
|
||||
);
|
||||
assert_eq!(got, [1015]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacked_panes_hide_the_strips_they_share() {
|
||||
let props = |pairs: &[(&str, &str)]| -> BlockProperties {
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect()
|
||||
};
|
||||
// 0 a pane with north and east arms, 1 the same pane with only a north arm, 2 another
|
||||
// pane block, 3 a pane no rule names.
|
||||
let states = [
|
||||
(
|
||||
"minecraft:glass_pane",
|
||||
props(&[("north", "true"), ("east", "true")]),
|
||||
),
|
||||
("minecraft:glass_pane", props(&[("north", "true")])),
|
||||
(
|
||||
"minecraft:white_stained_glass_pane",
|
||||
props(&[("north", "true")]),
|
||||
),
|
||||
("minecraft:iron_bars", props(&[("north", "true")])),
|
||||
];
|
||||
let ctm = ConnectedTextures::build(
|
||||
rules(&[(
|
||||
"pane.properties",
|
||||
"matchBlocks=glass_pane white_stained_glass_pane\nmethod=ctm\ntiles=0-46",
|
||||
)]),
|
||||
states.iter().map(|(n, p)| Some((*n, p))),
|
||||
|_| None,
|
||||
);
|
||||
let (post, north, east) = ([0.5, 0.5], [0.5, 0.2], [0.8, 0.5]);
|
||||
assert!(ctm.stacked_pane_hides(0, 1, post));
|
||||
assert!(ctm.stacked_pane_hides(0, 1, north));
|
||||
// The pane above has no east arm: the strip shows; one with it hides it.
|
||||
assert!(!ctm.stacked_pane_hides(0, 1, east));
|
||||
assert!(ctm.stacked_pane_hides(1, 0, east));
|
||||
let (south, west) = ([0.5, 0.8], [0.2, 0.5]);
|
||||
assert!(!ctm.stacked_pane_hides(1, 0, south));
|
||||
assert!(!ctm.stacked_pane_hides(1, 0, west));
|
||||
assert!(ctm.stacked_pane_hides(1, 0, north));
|
||||
// Another block, or a pane without rules, hides nothing.
|
||||
assert!(!ctm.stacked_pane_hides(1, 2, post));
|
||||
assert!(!ctm.stacked_pane_hides(3, 3, post));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprite_names() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
//! from the data-driven `context_int_provider`/`context_float_provider` files.
|
||||
pub use crate::chunk::block_entity::{
|
||||
Banner, Beacon, BlockEntity, Brushable, Campfire, ChiseledBookshelf, Crafter, CreakingHeart,
|
||||
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, MOD_MENUS, ModBlockEntity, ModContainer,
|
||||
ModEnergyBuffer, ModFluidTanks, PotentSulfur, Shelf, Skull, SkullSkin,
|
||||
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, MOD_MENUS, ModBlockEntity,
|
||||
ModBlockEntityFields, ModContainer, ModEnergyBuffer, ModFluidTanks, ModRevision, PotentSulfur,
|
||||
Shelf, Skull, SkullSkin,
|
||||
};
|
||||
use crate::items::ItemStack;
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -13,7 +13,8 @@ use std::collections::HashMap;
|
||||
|
||||
/// Lights the shaders read: `Globals.dynamic_lights` in gfx.rs, terrain.wgsl and entity.wgsl.
|
||||
pub const MAX: usize = 16;
|
||||
/// Camera-relative position and radius; RGB intensity in a second vec4.
|
||||
/// Camera-relative position and radius; RGB intensity in a second vec4. The shaders read it as a flat
|
||||
/// `array<vec4<f32>, 32>` (nested arrays need ARB_arrays_of_arrays, absent from GL below 4.3).
|
||||
pub type Colored = [[f32; 4]; 2];
|
||||
|
||||
/// Nearest validated native visual lights; never enters gameplay lighting.
|
||||
@@ -75,6 +76,12 @@ impl Luminance {
|
||||
self.0.get(item).copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The HDR tier of an item that glows, as its block's (`mesher::hdr_tier`), 0 for the rest.
|
||||
pub fn hdr_tier(&self, item: &str) -> u8 {
|
||||
let name = item.strip_prefix("minecraft:").unwrap_or(item);
|
||||
crate::mesher::hdr_tier(name, self.of(item)) & !crate::mesher::WHOLE
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn from_pairs(pairs: &[(&str, u8)]) -> Self {
|
||||
Self(pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect())
|
||||
@@ -118,6 +125,23 @@ pub fn level_at(pos: [f32; 3], lights: &[[f32; 4]]) -> f32 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn items_shine_as_their_blocks() {
|
||||
use crate::mesher::{BRIGHT, INCANDESCENT};
|
||||
let lum = Luminance::from_pairs(&[
|
||||
("minecraft:torch", 14),
|
||||
("minecraft:glowstone", 15),
|
||||
("minecraft:lava_bucket", 15),
|
||||
("minecraft:campfire", 15),
|
||||
]);
|
||||
assert_eq!(lum.hdr_tier("minecraft:torch"), INCANDESCENT);
|
||||
assert_eq!(lum.hdr_tier("minecraft:glowstone"), BRIGHT);
|
||||
assert_eq!(lum.hdr_tier("minecraft:lava_bucket"), INCANDESCENT);
|
||||
// Items carry no sprite thresholds: only the tier, not "all over".
|
||||
assert_eq!(lum.hdr_tier("minecraft:campfire"), BRIGHT);
|
||||
assert_eq!(lum.hdr_tier("minecraft:apple"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colored_sources_are_camera_relative_and_bounded() {
|
||||
let lights = select_colored(
|
||||
|
||||
@@ -22,12 +22,18 @@ struct Globals {
|
||||
outline_params: vec4<f32>,
|
||||
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
|
||||
dynamic_lights: array<vec4<f32>, 16>,
|
||||
colored_lights: array<array<vec4<f32>, 2>, 16>,
|
||||
colored_lights: array<vec4<f32>, 32>,
|
||||
colored_count: vec4<u32>,
|
||||
emissive_layers: array<vec4<u32>, 32>,
|
||||
emissive_layers: array<vec4<u32>, 128>,
|
||||
emissive_count: vec4<u32>,
|
||||
sunrise_fan: array<vec4<f32>, 32>,
|
||||
filtering: vec4<f32>,
|
||||
// HDR output's tier windows (hdr.rs `Hdr::windows`): x..w low to very bright,
|
||||
// [0] where the lift starts, [1] where it is full.
|
||||
hdr_windows: array<vec4<f32>, 2>,
|
||||
// HDR lighting: x how strongly the sky lights the world now (`Sky::sky_light_factor`),
|
||||
// y what the sky and fog get: the same, but none under water.
|
||||
hdr_light: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||
@group(0) @binding(1) var atlas: texture_2d<f32>;
|
||||
@@ -58,13 +64,16 @@ struct Out {
|
||||
@location(9) light_color: vec4<f32>,
|
||||
@location(10) @interpolate(flat) weather_rect: vec4<f32>,
|
||||
// The `GLINT_TEXTURING` sample for overlay 7 (item foil), or the model UV
|
||||
// `ARMOR_ENTITY_GLINT_TEXTURING` scrolls for overlay 36 (armour).
|
||||
// `ARMOR_ENTITY_GLINT_TEXTURING` scrolls for overlay 36 (armour), or the
|
||||
// dissolve mask's atlas UV for overlay 38.
|
||||
@location(11) glint_uv: vec2<f32>,
|
||||
// 1 drops the quad's back-facing pixels (Vertex::normal w = 1), like a culling render type.
|
||||
@location(12) @interpolate(flat) cull: u32,
|
||||
// HDR tier of a light-giving particle (mesher::hdr_tier), from the overlay's
|
||||
// high bits (particles.rs): fs_hdr_mark marks the frame with it.
|
||||
@location(13) @interpolate(flat) tier: u32,
|
||||
// HDR lighting (`hdr_scene`), 0 for what isn't lit.
|
||||
@location(14) scene: f32,
|
||||
}
|
||||
// Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed.
|
||||
fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
|
||||
@@ -97,12 +106,22 @@ fn dynamic_block_light(pos: vec3<f32>) -> f32 {
|
||||
fn native_colored_light(pos: vec3<f32>) -> vec3<f32> {
|
||||
var rgb = vec3(0.0);
|
||||
for (var i = 0u; i < min(globals.colored_count.x, 16u); i++) {
|
||||
let at = globals.colored_lights[i][0];
|
||||
let color = globals.colored_lights[i][1];
|
||||
let at = globals.colored_lights[i * 2u];
|
||||
let color = globals.colored_lights[i * 2u + 1u];
|
||||
rgb += color.rgb * max(0.0, 1.0 - distance(pos, at.xyz) / at.w) * color.a;
|
||||
}
|
||||
return min(rgb, vec3(1.0));
|
||||
}
|
||||
// HDR lighting: how far an entity's light carries it above paper white, 0 to 1, as terrain's.
|
||||
fn hdr_scene(block: f32, sky: f32) -> f32 {
|
||||
let b = smoothstep(11.0, 15.0, block);
|
||||
let s = smoothstep(11.0, 15.0, sky) * globals.hdr_light.x;
|
||||
return max(b, s);
|
||||
}
|
||||
// The frame's alpha for it, between 1 (none) and the light sources' marks below 0.75.
|
||||
fn hdr_scene_alpha(scene: f32) -> f32 {
|
||||
return 1.0 - 0.24 * scene;
|
||||
}
|
||||
fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
overlay: u32, color: vec4<f32>, light: vec4<u32>, normal: vec4<f32>) -> Out {
|
||||
var out: Out;
|
||||
@@ -116,6 +135,7 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
let unlit = overlay == 2u || overlay == 4u || overlay == 6u || overlay == 9u || overlay == 10u
|
||||
|| overlay == 11u || overlay == 12u;
|
||||
out.light_color = select(lm, vec4(1.0), unlit);
|
||||
out.scene = select(hdr_scene(l.x / 16.0, l.y / 16.0), 0.0, unlit);
|
||||
if globals.colored_count.x > 0u && !unlit {
|
||||
out.light_color = vec4(min(out.light_color.rgb + native_colored_light(position), vec3(1.0)), out.light_color.a);
|
||||
}
|
||||
@@ -252,7 +272,15 @@ fn entity_color(in: Out, front: bool) -> vec4<f32> {
|
||||
// Each crack's stage sprite origin comes in its light bytes (breaking::stage_light).
|
||||
uv = crumbling_uv(in.uv, in.wrap, globals.crumbling_sprite.zw, textureDimensions(atlas));
|
||||
}
|
||||
var color = textureSample(atlas, atlas_sampler, uv) * select(in.back_color, in.color, front);
|
||||
let texel = textureSample(atlas, atlas_sampler, uv);
|
||||
var tint = select(in.back_color, in.color, front);
|
||||
if in.overlay == 38u {
|
||||
// RenderPipelines.ENTITY_CUTOUT_DISSOLVE (entity.fsh's DISSOLVE): gone where the vertex
|
||||
// alpha is below the mask's (its atlas UV in glint_uv), the rest drawn opaque.
|
||||
if tint.a < textureSampleLevel(atlas, atlas_sampler, in.glint_uv, 0.0).a { discard; }
|
||||
tint.a = 1.0;
|
||||
}
|
||||
var color = texel * tint;
|
||||
if in.overlay == 9u || in.overlay == 10u {
|
||||
// rendertype_end_portal: end_sky and PORTAL_LAYERS (15 for portals, 16
|
||||
// for gateways) layers of end_portal.png projected from the screen.
|
||||
@@ -315,8 +343,34 @@ fn shadow_color(in: Out) -> vec4<f32> {
|
||||
// Beams wrap their v in the sprite as fs_entity does: sample where they draw.
|
||||
var uv = in.uv;
|
||||
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
|
||||
if in.tier == 0u || textureSample(atlas, atlas_sampler, uv).a < 0.1 { discard; }
|
||||
return vec4(0.0, 0.0, 0.0, 0.7 * f32(4u - min(in.tier, 4u)) / 4.0);
|
||||
let texel = textureSample(atlas, atlas_sampler, uv);
|
||||
if texel.a < 0.1 { discard; }
|
||||
// Fog hides a light source and the light on a surface as it hides everything else.
|
||||
let fogged = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
|
||||
if in.tier == 0u {
|
||||
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(mix(in.scene, globals.hdr_light.y, fogged)));
|
||||
}
|
||||
return vec4(0.0, 0.0, 0.0, hdr_mark(in.tier, brightness(texel.rgb * in.color.rgb) * (1.0 - fogged)));
|
||||
}
|
||||
fn brightness(c: vec3<f32>) -> f32 {
|
||||
let l = pow(max(c, vec3(0.0)), vec3(2.2));
|
||||
return 0.5 * (dot(l, vec3(0.2126, 0.7152, 0.0722)) + max(l.r, max(l.g, l.b)));
|
||||
}
|
||||
// HDR output's mark (hdr.wgsl): the frame's alpha below 0.75, a band of 0.175 per
|
||||
// tier (1 low .. 4 very bright, from 0.525 down to 0) and in it how much the pixel
|
||||
// lifts, from its own brightness `y` before lighting through the tier's window.
|
||||
fn hdr_mark(tier: u32, y: f32) -> f32 {
|
||||
let t = clamp(tier, 1u, 4u) - 1u;
|
||||
let lift = smoothstep(globals.hdr_windows[0][t], globals.hdr_windows[1][t], y);
|
||||
return 0.175 * f32(3u - t) + 0.165 * (1.0 - lift);
|
||||
}
|
||||
|
||||
// The hand in HDR output (gfx.rs "HDR hand marks"): where it drew, what the world kept there
|
||||
// no longer shines; fs_hdr_mark then marks the light sources it holds.
|
||||
@fragment fn fs_hdr_hand_unmark(in: Out) -> @location(0) vec4<f32> {
|
||||
if textureSample(atlas, atlas_sampler, in.uv).a < 0.1 { discard; }
|
||||
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(in.scene));
|
||||
}
|
||||
|
||||
fn celestial_color(in: Out) -> vec4<f32> {
|
||||
@@ -334,19 +388,21 @@ fn celestial_color(in: Out) -> vec4<f32> {
|
||||
}
|
||||
|
||||
// The sky's celestial pass (sun, moon, the End sky): fs_celestial's light,
|
||||
// premultiplied for an additive (one, one) blend, and as alpha how much of it
|
||||
// the texel adds, which the blend takes off the frame's alpha. HDR output
|
||||
// (hdr.wgsl) lifts only where that left it low: the sun's and moon's discs, not
|
||||
// the dark rest of their quads or the dim End sky.
|
||||
// premultiplied for an additive (one, one) blend. Where a texel adds light, its
|
||||
// alpha leaves the sky's 1 at HDR output's mark (hdr_mark) for the brightest
|
||||
// tier, so the sun's and moon's discs shine; the dark rest of their quads and
|
||||
// the dim End sky stay unmarked.
|
||||
@fragment fn fs_sky_celestial(in: Out) -> @location(0) vec4<f32> {
|
||||
let color = celestial_color(in);
|
||||
let added = color.rgb * color.a;
|
||||
// The sky under the disc keeps alpha 1 less this: 0 for the brightest tier
|
||||
// (the sun), terrain.wgsl's mark for the lower ones (the moon). Tier 0 is
|
||||
// geometry that sets none (the End sky, the End flash): it stays brightest.
|
||||
let tier = select(4u, min(in.tier, 4u), in.tier > 0u);
|
||||
let mark = 1.0 - 0.7 * f32(4u - tier) / 4.0;
|
||||
return vec4(added, mark * smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
|
||||
// The sun at the brightest tier, the moon at its own (in.tier); geometry that sets
|
||||
// none (the End sky, the End flash) stays brightest.
|
||||
let tier = select(in.tier, 4u, in.tier == 0u);
|
||||
let y = brightness(added);
|
||||
// Only what's past the tier's window takes the mark: a soft edge keeps the sky's light,
|
||||
// or it would come out darker than the sky around it.
|
||||
if y < globals.hdr_windows[1][clamp(tier, 1u, 4u) - 1u] { return vec4(added, 0.0); }
|
||||
return vec4(added, 1.0 - hdr_mark(tier, y));
|
||||
}
|
||||
|
||||
// RenderPipelines.ENERGY_SWIRL (entity.fsh with EMISSIVE and NO_OVERLAY): the sprite, scrolled by
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use crate::{
|
||||
biome::Biomes,
|
||||
blocks::{BlockFacts, Fluid},
|
||||
camera::NearPlane,
|
||||
environment::Sky,
|
||||
world::World,
|
||||
};
|
||||
@@ -135,9 +136,24 @@ fn camera_fluid_height(world: &World, facts: &BlockFacts, b: [i32; 3]) -> Option
|
||||
};
|
||||
Some((s.fluid, height))
|
||||
}
|
||||
/// `Camera.getFluidInCamera` (near-plane points reduced to the camera and
|
||||
/// the point just in front of it).
|
||||
pub fn fog_type(world: &World, facts: &BlockFacts, camera: [f64; 3], forward: [f64; 3]) -> FogType {
|
||||
/// `Camera.getFluidInCamera`: water at the camera, then lava or powder snow
|
||||
/// at the near plane's centre and corners.
|
||||
pub fn fog_type(world: &World, facts: &BlockFacts, camera: [f64; 3], near: &NearPlane) -> FogType {
|
||||
camera_fog_type(
|
||||
camera,
|
||||
near,
|
||||
|b| camera_fluid_height(world, facts, b),
|
||||
|b| &*facts.get(world.block(b[0], b[1], b[2])).name == "minecraft:powder_snow",
|
||||
)
|
||||
}
|
||||
/// `fog_type` over a block lookup: the fluid and its camera height, and
|
||||
/// whether the block is powder snow.
|
||||
fn camera_fog_type(
|
||||
camera: [f64; 3],
|
||||
near: &NearPlane,
|
||||
fluid: impl Fn([i32; 3]) -> Option<(Fluid, f64)>,
|
||||
powder_snow: impl Fn([i32; 3]) -> bool,
|
||||
) -> FogType {
|
||||
let block = |p: [f64; 3]| {
|
||||
[
|
||||
p[0].floor() as i32,
|
||||
@@ -146,28 +162,19 @@ pub fn fog_type(world: &World, facts: &BlockFacts, camera: [f64; 3], forward: [f
|
||||
]
|
||||
};
|
||||
let b = block(camera);
|
||||
if let Some((Fluid::Water, h)) = camera_fluid_height(world, facts, b)
|
||||
if let Some((Fluid::Water, h)) = fluid(b)
|
||||
&& camera[1] < f64::from(b[1]) + h
|
||||
{
|
||||
return FogType::Water;
|
||||
}
|
||||
for p in [
|
||||
[
|
||||
camera[0] + forward[0] * 0.05,
|
||||
camera[1] + forward[1] * 0.05,
|
||||
camera[2] + forward[2] * 0.05,
|
||||
],
|
||||
camera,
|
||||
] {
|
||||
for point in near.points() {
|
||||
let p: [f64; 3] = std::array::from_fn(|i| camera[i] + point[i]);
|
||||
let b = block(p);
|
||||
match camera_fluid_height(world, facts, b) {
|
||||
match fluid(b) {
|
||||
Some((Fluid::Lava, h)) if p[1] <= f64::from(b[1]) + h => return FogType::Lava,
|
||||
Some((Fluid::Lava, _)) => {}
|
||||
_ => {
|
||||
if &*facts.get(world.block(b[0], b[1], b[2])).name == "minecraft:powder_snow" {
|
||||
return FogType::PowderSnow;
|
||||
}
|
||||
}
|
||||
_ if powder_snow(b) => return FogType::PowderSnow,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
FogType::Atmospheric
|
||||
@@ -448,6 +455,23 @@ mod tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn lava_fog_starts_when_a_near_plane_corner_dips_under_the_surface() {
|
||||
// Flowing lava (amount 4) on y 64: the camera sees its surface at 64 + 4/9.
|
||||
let surface = 64.0 + 4.0 / 9.0;
|
||||
let fluid = |b: [i32; 3]| (b[1] == 64).then_some((Fluid::Lava, 4.0 / 9.0));
|
||||
let fog = |y: f64, pitch: f32| {
|
||||
let mut camera = crate::camera::Camera::default();
|
||||
camera.set_rotation(0.0, pitch);
|
||||
let near = camera.near_plane(70.0, 1600.0, 900.0);
|
||||
camera_fog_type([0.5, y, 0.5], &near, fluid, |_| false)
|
||||
};
|
||||
// Looking level, the near plane reaches tan(35 degrees) * 0.05 = 0.035 below the eye.
|
||||
assert_eq!(fog(surface + 0.02, 0.0), FogType::Lava);
|
||||
assert_eq!(fog(surface + 0.05, 0.0), FogType::Atmospheric);
|
||||
// The eye itself is not tested: looking up, the whole plane is above it.
|
||||
assert_eq!(fog(surface - 0.01, -90.0), FogType::Atmospheric);
|
||||
}
|
||||
#[test]
|
||||
fn boss_world_fog_closes_in() {
|
||||
// AtmosphericFogEnvironment.setupFog under a createWorldFog boss bar.
|
||||
let sky = Sky {
|
||||
|
||||
@@ -947,6 +947,16 @@ impl Font {
|
||||
.sum::<f32>()
|
||||
.ceil()
|
||||
}
|
||||
/// `Font.width(FormattedCharSequence)` of a sequence a component built
|
||||
/// with `FormattedCharSequence.forward`: every character is a glyph, a
|
||||
/// `§` included (`StringDecomposer.iterate`, not `iterateFormatted`).
|
||||
pub fn width_drawn(&self, t: &Text) -> f32 {
|
||||
crate::bidi::visual(t)
|
||||
.chars()
|
||||
.map(|(c, s)| self.advance(c, s))
|
||||
.sum::<f32>()
|
||||
.ceil()
|
||||
}
|
||||
/// `StringSplitter.splitLines`: each line and whether it continues a
|
||||
/// wrapped one. A line breaks at a `\n`, or once a character overflows
|
||||
/// `max_width` (never the first visible one) at the last space so far, or
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -735,7 +735,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! with pages up to `LARGE_PAGE_BYTES`, and their sorted indices too
|
||||
//! (`Arena::translucent_indices`): their back-to-front list then stays in one
|
||||
//! page of each and draws in a few multi-draws.
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, VecDeque};
|
||||
|
||||
/// Ranges are rounded up to this many vertices to limit fragmentation, and
|
||||
/// start at multiples of it (`flat` names sections by these blocks).
|
||||
@@ -100,6 +100,9 @@ impl Page {
|
||||
/// An arena with this many bytes in free ranges, at least half of its pages,
|
||||
/// has a page worth emptying (`Arena::sparsest`).
|
||||
const COMPACT_MIN_FREE: u64 = 128 << 20;
|
||||
/// A page of at least this many bytes shrinks (`Arena::oversized`) when a page
|
||||
/// of its ranges in use halves it or is `PAGE_BYTES` smaller.
|
||||
const SHRINK_MIN_BYTES: u64 = 64 << 20;
|
||||
|
||||
/// Terrain vertices in pages of at most `page_limit` vertices (devices cap
|
||||
/// buffer sizes): a section's vertices sit in one page.
|
||||
@@ -139,10 +142,10 @@ impl Arena {
|
||||
Self::with_usage(device, 4, capacity, usage, PAGE_BYTES)
|
||||
}
|
||||
/// Translucent quads' vertices, which no shader reads as storage: pages
|
||||
/// up to `LARGE_PAGE_BYTES`. The section arena's compaction never runs on
|
||||
/// it (`Renderer::compact_arena`): a page this large has no others to
|
||||
/// empty into, and the compaction moves sections' `page` ranges, not their
|
||||
/// `translucent_at`.
|
||||
/// up to `LARGE_PAGE_BYTES`. A page this large has no others to empty into
|
||||
/// (`sparsest`): once the view has settled, one far larger than its ranges
|
||||
/// in use moves into a new page of their size (`oversized`, `open_page`;
|
||||
/// `Renderer::shrink_translucent`).
|
||||
pub fn translucent(device: &wgpu::Device, stride: u64, capacity: u64) -> Self {
|
||||
let usage = wgpu::BufferUsages::VERTEX;
|
||||
Self::with_usage(device, stride, capacity, usage, LARGE_PAGE_BYTES)
|
||||
@@ -241,7 +244,7 @@ impl Arena {
|
||||
})
|
||||
}
|
||||
/// The rounded length a request of `vertices` occupies.
|
||||
fn span(vertices: u64) -> u64 {
|
||||
pub fn span(vertices: u64) -> u64 {
|
||||
vertices.max(1).next_multiple_of(GRAIN)
|
||||
}
|
||||
/// Bytes of every page's buffer.
|
||||
@@ -278,6 +281,15 @@ impl Arena {
|
||||
.expect("live arena page")
|
||||
.buffer
|
||||
}
|
||||
/// Bytes of one vertex (or index).
|
||||
pub fn stride(&self) -> u64 {
|
||||
self.stride
|
||||
}
|
||||
/// Vertices in `page`'s ranges in use (as `span` rounds them).
|
||||
pub fn used(&self, page: u32) -> u64 {
|
||||
let page = self.pages[page as usize].as_ref().expect("live arena page");
|
||||
page.capacity - page.free_vertices()
|
||||
}
|
||||
/// A range of `vertices`: (page, first vertex). Grows the newest page
|
||||
/// (copying its contents on the GPU, in `encoder`) or starts a new one
|
||||
/// when nothing free is large enough; `None` when no memory is left for
|
||||
@@ -402,6 +414,49 @@ impl Arena {
|
||||
.min()
|
||||
.map(|(_, _, i)| i)
|
||||
}
|
||||
/// A page far larger than its ranges in use, and the capacity a page of them
|
||||
/// gets (`grown`: twice theirs, or `PAGE_BYTES` more; at least the first
|
||||
/// page's): one of `SHRINK_MIN_BYTES` or more, with ranges in use (an empty
|
||||
/// one is `trim`'s), that this halves or makes `PAGE_BYTES` smaller. In
|
||||
/// system memory too (`open_page` makes the new one in video memory). None
|
||||
/// while a page is closing; the page it leads to is not oversized until its
|
||||
/// ranges change.
|
||||
pub fn oversized(&self) -> Option<(u32, u64)> {
|
||||
if self.closing.is_some() {
|
||||
return None;
|
||||
}
|
||||
let saving = PAGE_BYTES / self.stride;
|
||||
self.pages.iter().enumerate().find_map(|(i, p)| {
|
||||
let p = p.as_ref()?;
|
||||
let used = p.capacity - p.free_vertices();
|
||||
let capacity = self
|
||||
.grown(used, 0)
|
||||
.max(self.initial_capacity)
|
||||
.next_multiple_of(GRAIN)
|
||||
.min(self.page_limit);
|
||||
let smaller = 2 * capacity <= p.capacity || capacity + saving <= p.capacity;
|
||||
(p.capacity * self.stride >= SHRINK_MIN_BYTES && used > 0 && smaller)
|
||||
.then_some((i as u32, capacity))
|
||||
})
|
||||
}
|
||||
/// A new, empty page of `capacity` vertices in video memory, made the newest
|
||||
/// (the one that grows), for what `Renderer::shrink_translucent` moves off an
|
||||
/// `oversized` page (`alloc_moved`) and for new ranges meanwhile. False, and
|
||||
/// nothing changes, when video memory has no room for it: in system memory it
|
||||
/// would slow every frame that draws it.
|
||||
pub fn open_page(&mut self, device: &wgpu::Device, capacity: u64) -> bool {
|
||||
let Some((buffer, host)) = self.create(device, capacity, false) else {
|
||||
return false;
|
||||
};
|
||||
let free = FreeList::new(capacity);
|
||||
self.pages.push(Some(Page {
|
||||
buffer,
|
||||
capacity,
|
||||
free,
|
||||
host,
|
||||
}));
|
||||
true
|
||||
}
|
||||
/// Stops placing vertices in `page` until `reopen`.
|
||||
pub fn close(&mut self, page: u32) {
|
||||
self.closing = Some(page);
|
||||
@@ -490,14 +545,17 @@ impl Arena {
|
||||
old.free.give(previous, capacity - previous);
|
||||
true
|
||||
}
|
||||
/// Reclaim after an upload batch, so replacing a section can reuse its range.
|
||||
pub fn trim(&mut self) {
|
||||
/// Reclaim after an upload batch, so replacing a section can reuse its range. Gives back
|
||||
/// the buffers of the pages it emptied, for a [`Graveyard`].
|
||||
pub fn trim(&mut self) -> Vec<wgpu::Buffer> {
|
||||
let mut released = Vec::new();
|
||||
for page in &mut self.pages {
|
||||
if let Some(p) = page
|
||||
&& p.free.0.len() == 1
|
||||
&& p.free.0.get(&0) == Some(&p.capacity)
|
||||
if page
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.free.0.len() == 1 && p.free.0.get(&0) == Some(&p.capacity))
|
||||
&& let Some(p) = page.take()
|
||||
{
|
||||
*page = None;
|
||||
released.push(p.buffer);
|
||||
}
|
||||
}
|
||||
if self.closing.is_some_and(|page| !self.has_page(page)) {
|
||||
@@ -507,13 +565,67 @@ impl Arena {
|
||||
while self.pages.last().is_some_and(Option::is_none) {
|
||||
self.pages.pop();
|
||||
}
|
||||
released
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.pages.clear();
|
||||
/// Drops every page; gives back their buffers, for a [`Graveyard`].
|
||||
pub fn clear(&mut self) -> Vec<wgpu::Buffer> {
|
||||
self.closing = None;
|
||||
self.pages.drain(..).flatten().map(|p| p.buffer).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Frames a released page waits before it is freed: by then the GPU is done with the frames that
|
||||
/// used it, and the bind groups naming it (flat draws' page tables) are gone, so the freeing
|
||||
/// thread holds its last reference.
|
||||
const GRAVE_FRAMES: usize = 4;
|
||||
|
||||
/// Buffers of released pages, freed [`GRAVE_FRAMES`] frames later on a thread of their own
|
||||
/// ([`free_later`]): giving a page of up to 256 MB back to the driver on the render thread took
|
||||
/// that frame 8-14 ms of CPU (Mesa's lavapipe), when flight or a teleport emptied a page.
|
||||
pub struct Graveyard<T> {
|
||||
graves: VecDeque<Vec<T>>,
|
||||
}
|
||||
|
||||
impl<T> Default for Graveyard<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
graves: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Graveyard<T> {
|
||||
/// Released this frame.
|
||||
pub fn add(&mut self, items: impl IntoIterator<Item = T>) {
|
||||
if self.graves.is_empty() {
|
||||
self.graves.push_back(Vec::new());
|
||||
}
|
||||
if let Some(grave) = self.graves.back_mut() {
|
||||
grave.extend(items);
|
||||
}
|
||||
}
|
||||
/// A frame went by: what was released `GRAVE_FRAMES` frames ago, to free.
|
||||
pub fn advance(&mut self) -> Vec<T> {
|
||||
self.graves.push_back(Vec::new());
|
||||
if self.graves.len() > GRAVE_FRAMES {
|
||||
self.graves.pop_front().unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops `items` on a short-lived thread of its own, as `chunk::free_columns` does for
|
||||
/// unloaded chunks; should the thread not start, they drop here.
|
||||
pub fn free_later<T: Send + 'static>(items: Vec<T>) {
|
||||
if items.is_empty() {
|
||||
return;
|
||||
}
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("Buffer freeing".into())
|
||||
.spawn(move || drop(items));
|
||||
}
|
||||
|
||||
/// `create_buffer` with an out-of-memory failure returned instead of raised
|
||||
/// (raised errors are fatal).
|
||||
fn try_create(device: &wgpu::Device, size: u64, usage: wgpu::BufferUsages) -> Option<wgpu::Buffer> {
|
||||
@@ -532,13 +644,31 @@ fn try_create(device: &wgpu::Device, size: u64, usage: wgpu::BufferUsages) -> Op
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A released page is handed back for freeing `GRAVE_FRAMES` frames later, in order.
|
||||
#[test]
|
||||
fn graveyard_frees_after_its_frames() {
|
||||
let mut graves = Graveyard::default();
|
||||
graves.add([1, 2]);
|
||||
let mut freed = Vec::new();
|
||||
for frame in 0..GRAVE_FRAMES + 2 {
|
||||
if frame == 1 {
|
||||
graves.add([3]);
|
||||
}
|
||||
freed.push(graves.advance());
|
||||
}
|
||||
let mut expected = vec![Vec::new(); GRAVE_FRAMES + 2];
|
||||
expected[GRAVE_FRAMES - 1] = vec![1, 2];
|
||||
expected[GRAVE_FRAMES] = vec![3];
|
||||
assert_eq!(freed, expected);
|
||||
}
|
||||
|
||||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
device_with(wgpu::Features::empty())
|
||||
}
|
||||
|
||||
/// A device with those of `features` the adapter has.
|
||||
fn device_with(features: wgpu::Features) -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
eprintln!("arena GPU regression: {}", adapter.get_info().name);
|
||||
let required_features = adapter.features() & features;
|
||||
@@ -552,7 +682,7 @@ mod tests {
|
||||
/// A device with the adapter's own limits, as the renderer opens it: page
|
||||
/// caps past the default 256 MiB buffer limit show there.
|
||||
fn device_at_limits() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
required_limits: adapter.limits(),
|
||||
@@ -690,7 +820,7 @@ mod tests {
|
||||
/// `translucent_at` along.
|
||||
#[test]
|
||||
fn compaction_moves_what_sections_hold_in_the_section_arena() {
|
||||
use super::super::{SectionGpu, move_off_page};
|
||||
use super::super::{Part, SectionGpu, move_off_page};
|
||||
let Some((device, queue)) = device() else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
@@ -706,6 +836,7 @@ mod tests {
|
||||
sort: super::super::translucency::SortState::new(Vec::new()),
|
||||
sorted_indices: None,
|
||||
flat_id: None,
|
||||
list_id: None,
|
||||
fading: None,
|
||||
leaving: None,
|
||||
animated_layers: Box::new([]),
|
||||
@@ -756,14 +887,15 @@ mod tests {
|
||||
arena.close(0);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let meshes = sections.iter_mut();
|
||||
let mut budget = u64::MAX;
|
||||
assert!(move_off_page(
|
||||
&mut arena,
|
||||
&mut encoder,
|
||||
None,
|
||||
meshes,
|
||||
0,
|
||||
split,
|
||||
u64::MAX
|
||||
Part::Section { split },
|
||||
&mut budget
|
||||
));
|
||||
queue.submit([encoder.finish()]);
|
||||
let word =
|
||||
@@ -954,6 +1086,185 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A large page far larger than its ranges in use moves into a new page of their
|
||||
/// size (`begin_shrink`) over frames, through `move_off_page`: a budget a call,
|
||||
/// its first range whatever its size, data and all, while new ranges, writes and
|
||||
/// releases go on around the moves in one encoder, as the renderer records them.
|
||||
/// The other range of each mesh stays; the emptied page takes no new range until
|
||||
/// `trim` returns it. Translucent vertices and sorted indices alike.
|
||||
#[test]
|
||||
fn a_large_page_moves_into_one_of_the_size_its_ranges_get() {
|
||||
use super::super::{Part, SectionGpu, begin_shrink, move_off_page};
|
||||
// Where system memory could take the new page, it must not.
|
||||
let Some((device, queue)) = device_with(wgpu::Features::MAPPABLE_PRIMARY_BUFFERS) else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
};
|
||||
for (mut arena, part) in [
|
||||
(Arena::translucent(&device, 24, GRAIN), Part::Translucent),
|
||||
(Arena::translucent_indices(&device, GRAIN), Part::Indices),
|
||||
] {
|
||||
let indices = matches!(part, Part::Indices);
|
||||
let stride = arena.stride;
|
||||
// 64 quads a mesh: their vertices, or their sorted indices.
|
||||
let len: u64 = if indices { 64 * 6 } else { 64 * 4 };
|
||||
let span = len.next_multiple_of(GRAIN);
|
||||
// Its range here at `at`; the other one names a page that is not there.
|
||||
let mesh = |at: (u32, u64), solid: u32, translucent: u32| SectionGpu {
|
||||
page: 0,
|
||||
start: 0,
|
||||
translucent_at: if indices { (7, 7) } else { at },
|
||||
solid,
|
||||
sides: crate::mesher::Sides::unsorted(solid),
|
||||
translucent,
|
||||
sort: super::super::translucency::SortState::new(Vec::new()),
|
||||
sorted_indices: (translucent > 0).then_some(if indices { at } else { (7, 7) }),
|
||||
flat_id: None,
|
||||
list_id: None,
|
||||
fading: None,
|
||||
leaving: None,
|
||||
animated_layers: Box::new([]),
|
||||
};
|
||||
let mut uploads = super::super::upload::Uploads::new(&device);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
// As the renderer writes: through a belt, in the encoder's order.
|
||||
let mut write = |encoder: &mut _, buffer: &wgpu::Buffer, at: u64, value: u32| {
|
||||
uploads.buffer(encoder, buffer, at * stride, &value.to_le_bytes())
|
||||
};
|
||||
// Sixteen meshes with their number at both ends of their range, and one
|
||||
// of solid quads alone (nothing here).
|
||||
let mut meshes: Vec<(u32, SectionGpu)> = (0..16)
|
||||
.map(|i| {
|
||||
let at = arena.alloc(&device, &mut encoder, len).unwrap();
|
||||
write(&mut encoder, arena.buffer(at.0), at.1, i);
|
||||
write(&mut encoder, arena.buffer(at.0), at.1 + len - 1, i);
|
||||
(i, mesh(at, 0, 64))
|
||||
})
|
||||
.collect();
|
||||
meshes.push((99, mesh((0, 0), 64, 0)));
|
||||
// Grown past `SHRINK_MIN_BYTES` in the same encoder (the writes before
|
||||
// reach the grown buffer), then mostly freed, as after a teleport.
|
||||
let big = (64u64 << 20).div_ceil(stride);
|
||||
let at = arena.alloc(&device, &mut encoder, big).unwrap();
|
||||
arena.release(at.0, at.1, big);
|
||||
for (_, s) in meshes.extract_if(.., |(i, _)| ![0, 3, 6, 9, 12, 99].contains(i)) {
|
||||
let (page, start, n) = part.of(&s).unwrap();
|
||||
arena.release(page, start, n);
|
||||
}
|
||||
// No video memory for the new page: nothing changes, nothing spills.
|
||||
arena.video_memory_full = true;
|
||||
let held = meshes.iter_mut().map(|(_, s)| s);
|
||||
assert_eq!(begin_shrink(&mut arena, &device, held, part), Some(false));
|
||||
assert_eq!((arena.closing(), arena.pages.len()), (None, 1));
|
||||
assert!(!arena.spilled);
|
||||
arena.video_memory_full = false;
|
||||
let held = meshes.iter_mut().map(|(_, s)| s);
|
||||
assert_eq!(begin_shrink(&mut arena, &device, held, part), Some(true));
|
||||
assert_eq!(arena.closing(), Some(0));
|
||||
// Twice the five meshes' ranges.
|
||||
assert_eq!(arena.pages[1].as_ref().unwrap().capacity, 10 * span);
|
||||
// New ranges go to the new page meanwhile.
|
||||
let upload = arena.alloc(&device, &mut encoder, GRAIN).unwrap();
|
||||
assert_eq!(upload.0, 1);
|
||||
write(&mut encoder, arena.buffer(1), upload.1, 77);
|
||||
// Moves what page 0 holds within `budget`: how many meshes page 1 has then.
|
||||
let moved = |arena: &mut Arena,
|
||||
encoder: &mut _,
|
||||
meshes: &mut [(u32, SectionGpu)],
|
||||
budget: &mut u64| {
|
||||
let held = meshes.iter_mut().map(|(_, s)| s);
|
||||
assert!(move_off_page(arena, encoder, None, held, 0, part, budget));
|
||||
let on = |s: &SectionGpu| part.of(s).is_some_and(|r| r.0 == 1);
|
||||
meshes.iter().filter(|(_, s)| on(s)).count()
|
||||
};
|
||||
let mut budget = 2 * len * stride;
|
||||
let on = moved(&mut arena, &mut encoder, &mut meshes, &mut budget);
|
||||
assert_eq!((on, budget), (2, 0));
|
||||
// Out of budget, a call still moves its first range.
|
||||
let on = moved(&mut arena, &mut encoder, &mut meshes, &mut budget);
|
||||
assert_eq!(on, 3);
|
||||
// The sorter writes where #0 is now; a mesh still on the old page goes.
|
||||
let (page, start, _) = part.of(&meshes[0].1).unwrap();
|
||||
write(&mut encoder, arena.buffer(page), start, 100);
|
||||
let gone = meshes
|
||||
.iter()
|
||||
.position(|(_, s)| part.of(s).is_some_and(|r| r.0 == 0));
|
||||
let (page, start, n) = part.of(&meshes.remove(gone.unwrap()).1).unwrap();
|
||||
arena.release(page, start, n);
|
||||
budget = u64::MAX;
|
||||
let on = moved(&mut arena, &mut encoder, &mut meshes, &mut budget);
|
||||
assert_eq!(on, 4);
|
||||
// Emptied, it stays closed until `trim`.
|
||||
assert_eq!((arena.used(0), arena.closing()), (0, Some(0)));
|
||||
uploads.finish(&encoder);
|
||||
queue.submit([encoder.finish()]);
|
||||
let word = |page, at: u64| read(&device, &queue, arena.buffer(page), at * stride);
|
||||
for (i, s) in &meshes {
|
||||
let Some((page, start, n)) = part.of(s) else {
|
||||
// Solid quads alone: as it was.
|
||||
let at = if indices { (7, 7) } else { (0, 0) };
|
||||
assert_eq!((*i, s.translucent_at, s.sorted_indices), (99, at, None));
|
||||
continue;
|
||||
};
|
||||
assert_eq!(page, 1);
|
||||
assert_eq!(word(page, start), if *i == 0 { 100 } else { *i });
|
||||
assert_eq!(word(page, start + n - 1), *i);
|
||||
// Its other range stays where it was.
|
||||
assert!(s.translucent_at == (7, 7) || s.sorted_indices == Some((7, 7)));
|
||||
}
|
||||
assert_eq!(word(upload.0, upload.1), 77);
|
||||
arena.trim();
|
||||
assert!(!arena.has_page(0));
|
||||
assert_eq!(arena.closing(), None);
|
||||
assert_eq!(arena.bytes(), 10 * span * stride);
|
||||
}
|
||||
}
|
||||
|
||||
/// Large pages shrink when a page of their ranges in use (what `grown` makes of
|
||||
/// them, at least the first page's size) halves them or is `PAGE_BYTES`
|
||||
/// smaller: at the smallest page that shrinks and at the largest.
|
||||
#[test]
|
||||
fn large_pages_shrink_when_that_halves_them_or_saves_a_page() {
|
||||
let Some((device, _)) = device() else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
};
|
||||
let mut arena = Arena::translucent(&device, 24, 1 << 18);
|
||||
arena.page_limit = LARGE_PAGE_BYTES / 24 / GRAIN * GRAIN;
|
||||
let (limit, step) = (arena.page_limit, PAGE_BYTES / 24);
|
||||
// `oversized` looks at the counts only: a small buffer stands in.
|
||||
let buffer = try_create(&device, 4, wgpu::BufferUsages::VERTEX).unwrap();
|
||||
let mut shrinks = |capacity: u64, used: u64, host: bool| {
|
||||
let free = FreeList(BTreeMap::from([(used, capacity - used)]));
|
||||
let buffer = buffer.clone();
|
||||
arena.pages = vec![Some(Page {
|
||||
buffer,
|
||||
capacity,
|
||||
free,
|
||||
host,
|
||||
})];
|
||||
arena.oversized().map(|(_, to)| to)
|
||||
};
|
||||
let least = (64u64 << 20).div_ceil(24).next_multiple_of(GRAIN);
|
||||
let quarter = least / 4 / GRAIN * GRAIN;
|
||||
assert_eq!(shrinks(least, quarter, false), Some(2 * quarter));
|
||||
assert_eq!(shrinks(least, quarter + GRAIN, false), None);
|
||||
let used = (limit - 2 * step) / GRAIN * GRAIN;
|
||||
let target = (used + step).next_multiple_of(GRAIN);
|
||||
assert_eq!(shrinks(limit, used, false), Some(target));
|
||||
assert_eq!(shrinks(limit, used + GRAIN, false), None);
|
||||
// The page it leads to, holding the same, is not oversized.
|
||||
assert_eq!(shrinks(target, used, false), None);
|
||||
// Not below the first page's size; smaller pages and empty ones stay.
|
||||
assert_eq!(shrinks(least, GRAIN, false), Some(1 << 18));
|
||||
assert_eq!(shrinks(least - GRAIN, GRAIN, false), None);
|
||||
assert_eq!(shrinks(least, 0, false), None);
|
||||
// One in system memory goes back to video memory; none while one closes.
|
||||
assert_eq!(shrinks(least, quarter, true), Some(2 * quarter));
|
||||
arena.closing = Some(0);
|
||||
assert_eq!(arena.oversized(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_an_arena_releases_buffers_and_can_allocate_again() {
|
||||
let Some((device, queue)) = device() else {
|
||||
|
||||
@@ -4,7 +4,7 @@ use wgpu::util::DeviceExt;
|
||||
#[test]
|
||||
#[ignore = "requires a Vulkan/Metal/DX12 adapter"]
|
||||
fn crack_samples_stay_aligned_between_stages() {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter =
|
||||
pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default()))
|
||||
.unwrap();
|
||||
|
||||
@@ -37,7 +37,7 @@ fn geometry_matches_pre_residency_vertex_snapshots() {
|
||||
}
|
||||
|
||||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
eprintln!("End sky test adapter: {:?}", adapter.get_info());
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
|
||||
@@ -22,6 +22,10 @@ const MATERIALS: usize = 10;
|
||||
pub const OIT_ENTITY: u32 = 1 << 24;
|
||||
/// `RenderPipelines.DRAGON_RAYS`: untextured colour, additive with depth writes.
|
||||
pub const DRAGON_RAYS_OVERLAY: u32 = 37;
|
||||
/// `RenderPipelines.ENTITY_CUTOUT_DISSOLVE` (the dying dragon): a pixel is dropped where the
|
||||
/// vertex alpha is below the mask's, whose atlas UV rides in `Vertex::glint_uv`; the rest is
|
||||
/// drawn opaque. No overlay.
|
||||
pub const DISSOLVE_OVERLAY: u32 = 38;
|
||||
/// Tags quads as drawn by a render type vanilla gives an `OitPipelineSet`
|
||||
/// (`OIT_ENTITY`): improved transparency draws them in its stages.
|
||||
pub fn mark_oit(quads: &mut [Vertex]) {
|
||||
|
||||
@@ -173,7 +173,8 @@ impl EntityOutline {
|
||||
}
|
||||
|
||||
/// Uploads this frame's silhouettes (`LevelRenderer.currentFrameRendersEntityOutline`
|
||||
/// when there are any) and sizes the targets like the window.
|
||||
/// when there are any) and sizes the targets like the window. A frame with
|
||||
/// none gives the targets' video memory back until an entity glows again.
|
||||
pub(super) fn prepare(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
@@ -184,6 +185,7 @@ impl EntityOutline {
|
||||
) {
|
||||
self.quads = (silhouettes.len() / 4) as u32;
|
||||
if self.quads == 0 {
|
||||
self.targets = None;
|
||||
return;
|
||||
}
|
||||
let vertices = silhouettes.len() as u64;
|
||||
|
||||
@@ -42,6 +42,8 @@ struct Plan {
|
||||
solid_calls: usize,
|
||||
/// Where the shadow camera's solid and translucent calls begin.
|
||||
shadow_calls: [usize; 2],
|
||||
/// Where Metal's opaque pass ends and its cutout pass begins in phase 1's calls.
|
||||
pass_calls: [usize; 2],
|
||||
total: u64,
|
||||
}
|
||||
const QUADS: u32 = u32::MAX;
|
||||
@@ -107,6 +109,9 @@ pub struct Flat {
|
||||
pub calls: Vec<Call>,
|
||||
pub phase1_calls: usize,
|
||||
pub solid_calls: usize,
|
||||
/// Metal's two solid passes over phase 1: the opaque one draws
|
||||
/// `calls[..pass_calls[0]]`, the cutout one `calls[pass_calls[1]..phase1_calls]`.
|
||||
pub pass_calls: [usize; 2],
|
||||
/// Where a shader pack's shadow camera's solid and translucent calls
|
||||
/// begin (`calls.len()` without them): the camera's translucent calls
|
||||
/// end at the first.
|
||||
@@ -216,7 +221,9 @@ fn buffer(
|
||||
/// apart. A phase 2 range names its draw in phase 2 and its call in phase 2
|
||||
/// instead of a destination: the call's room holds all of its draws. Draws
|
||||
/// from `shadow[0]` (solid) and `shadow[1]` (translucent) on are a shader
|
||||
/// pack's shadow camera's, with calls of their own.
|
||||
/// pack's shadow camera's, with calls of their own. Phase 1's draws before
|
||||
/// `passes[0]` and from `passes[1]` on, the two solid passes' on Metal, too.
|
||||
/// The list starts at index `base` (the GPU-made lists' rooms come first: `gpu_lists`).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn plan(
|
||||
draws: &[[u32; 5]],
|
||||
@@ -224,17 +231,20 @@ fn plan(
|
||||
phase1_draws: usize,
|
||||
solid_draws: usize,
|
||||
shadow: [usize; 2],
|
||||
passes: [usize; 2],
|
||||
align: u64,
|
||||
calls: &mut Vec<Call>,
|
||||
ranges: &mut Vec<Range>,
|
||||
segments: &mut Vec<(Source, u64, u64)>,
|
||||
base: u64,
|
||||
) -> Plan {
|
||||
calls.clear();
|
||||
ranges.clear();
|
||||
segments.clear();
|
||||
let (mut phase1_calls, mut solid_calls) = (None, None);
|
||||
let mut shadow_calls = [None; 2];
|
||||
let mut total = 0u64;
|
||||
let mut pass_calls = [None; 2];
|
||||
let mut total = base;
|
||||
let mut by_source: Vec<(Source, Vec<Range>)> = Vec::new();
|
||||
for (i, (d, &(page, sorted))) in draws.iter().zip(pages).enumerate() {
|
||||
if i == phase1_draws {
|
||||
@@ -248,7 +258,13 @@ fn plan(
|
||||
shadow_calls[k].get_or_insert(calls.len());
|
||||
}
|
||||
}
|
||||
let starts = i == phase1_draws || i == solid_draws || shadow.contains(&i);
|
||||
for (k, &from) in passes.iter().enumerate() {
|
||||
if i == from {
|
||||
pass_calls[k].get_or_insert(calls.len());
|
||||
}
|
||||
}
|
||||
let starts =
|
||||
i == phase1_draws || i == solid_draws || shadow.contains(&i) || passes.contains(&i);
|
||||
match calls.last_mut() {
|
||||
Some(last) if last.page == page && !starts => last.count += d[0],
|
||||
_ => calls.push(Call {
|
||||
@@ -283,10 +299,12 @@ fn plan(
|
||||
ranges.resize(padded as usize, [0; 4]);
|
||||
}
|
||||
let solid_calls = solid_calls.unwrap_or(calls.len());
|
||||
let phase1_calls = phase1_calls.unwrap_or(calls.len()).min(solid_calls);
|
||||
Plan {
|
||||
phase1_calls: phase1_calls.unwrap_or(calls.len()).min(solid_calls),
|
||||
phase1_calls,
|
||||
solid_calls,
|
||||
shadow_calls: shadow_calls.map(|c| c.unwrap_or(calls.len())),
|
||||
pass_calls: pass_calls.map(|c| c.unwrap_or(calls.len()).min(phase1_calls)),
|
||||
total,
|
||||
}
|
||||
}
|
||||
@@ -438,6 +456,7 @@ impl Flat {
|
||||
calls: Vec::new(),
|
||||
phase1_calls: 0,
|
||||
solid_calls: 0,
|
||||
pass_calls: [0; 2],
|
||||
shadow_calls: [0; 2],
|
||||
min_sections,
|
||||
range_align: (u64::from(device.limits().min_storage_buffer_offset_alignment) / 16)
|
||||
@@ -519,9 +538,12 @@ impl Flat {
|
||||
phase1_draws: usize,
|
||||
solid_draws: usize,
|
||||
shadow: [usize; 2],
|
||||
passes: [usize; 2],
|
||||
phase2: Option<&wgpu::Buffer>,
|
||||
origins: impl Iterator<Item = (u32, [f32; 4])>,
|
||||
sorted_page: impl Fn(u32) -> wgpu::Buffer,
|
||||
mut lists: Option<&mut super::gpu_lists::GpuLists>,
|
||||
eye: Option<[f32; 3]>,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) -> bool {
|
||||
self.phase2_group = None;
|
||||
@@ -559,10 +581,13 @@ impl Flat {
|
||||
bytemuck::cast_slice(&self.origin_data),
|
||||
);
|
||||
}
|
||||
// The GPU-made lists' rooms come first in the index list.
|
||||
let room = lists.as_mut().map_or(0, |l| l.rooms());
|
||||
let Plan {
|
||||
phase1_calls,
|
||||
solid_calls,
|
||||
shadow_calls,
|
||||
pass_calls,
|
||||
total,
|
||||
} = plan(
|
||||
draws,
|
||||
@@ -570,19 +595,23 @@ impl Flat {
|
||||
phase1_draws,
|
||||
solid_draws,
|
||||
shadow,
|
||||
passes,
|
||||
self.range_align,
|
||||
&mut self.calls,
|
||||
&mut self.range_data,
|
||||
&mut self.segments,
|
||||
room,
|
||||
);
|
||||
self.phase1_calls = phase1_calls;
|
||||
self.solid_calls = solid_calls;
|
||||
self.shadow_calls = shadow_calls;
|
||||
self.pass_calls = pass_calls;
|
||||
if total == 0 || total * 4 > device.limits().max_storage_buffer_binding_size {
|
||||
self.calls.clear();
|
||||
self.phase1_calls = 0;
|
||||
self.solid_calls = 0;
|
||||
self.shadow_calls = [0; 2];
|
||||
self.pass_calls = [0; 2];
|
||||
return false;
|
||||
}
|
||||
if self.range_data.len() as u64 > self.range_capacity {
|
||||
@@ -634,6 +663,9 @@ impl Flat {
|
||||
bytemuck::cast_slice(&self.phase2_call_data),
|
||||
);
|
||||
}
|
||||
if let Some(lists) = &mut lists {
|
||||
lists.prepare(device, encoder, uploads, &self.indices, eye);
|
||||
}
|
||||
let ranges_at = |first: u64, count: u64| {
|
||||
wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||
buffer: &self.ranges,
|
||||
@@ -703,13 +735,17 @@ impl Flat {
|
||||
label: Some("Flatten section draws"),
|
||||
timestamp_writes: timing.and_then(|t| t.compute_writes("Flatten")),
|
||||
});
|
||||
if let Some(lists) = &lists {
|
||||
lists.dispatch(&mut pass);
|
||||
}
|
||||
pass.set_pipeline(&self.flatten);
|
||||
for (group, count) in &groups {
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
|
||||
}
|
||||
drop(pass);
|
||||
for page in self.calls.iter().map(|c| c.page) {
|
||||
let list_pages = lists.iter().flat_map(|l| l.calls().iter().map(|c| c.page));
|
||||
for page in self.calls.iter().map(|c| c.page).chain(list_pages) {
|
||||
let table = self.pages[page as usize]
|
||||
.as_mut()
|
||||
.expect("a drawn page has its block table");
|
||||
@@ -750,6 +786,27 @@ impl Flat {
|
||||
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
|
||||
}
|
||||
|
||||
/// Draws pass `group`'s calls of `lists` (`gpu_lists::Out::Indices`, prepared with
|
||||
/// this frame's `prepare`) with a flat pipeline set.
|
||||
pub fn draw_lists<'a>(
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
lists: &super::gpu_lists::GpuLists,
|
||||
group: u8,
|
||||
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
|
||||
) {
|
||||
if !lists.prepared() {
|
||||
return;
|
||||
}
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
for (k, call) in lists.calls().iter().enumerate() {
|
||||
if call.group == group {
|
||||
self.bind_page(pass, call.page, &vertices);
|
||||
lists.draw_indexed(pass, k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The camera's translucent calls.
|
||||
pub fn translucent_calls(&self) -> &[Call] {
|
||||
&self.calls[self.solid_calls..self.shadow_calls[0]]
|
||||
@@ -870,10 +927,12 @@ mod tests {
|
||||
solid,
|
||||
solid,
|
||||
[draws.len(); 2],
|
||||
[draws.len(), 0],
|
||||
4,
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
0,
|
||||
);
|
||||
assert_eq!(total, 6 * 13);
|
||||
assert_eq!(phase1_calls, solid_calls);
|
||||
@@ -904,6 +963,40 @@ mod tests {
|
||||
assert_eq!(ranges.len(), 12);
|
||||
}
|
||||
|
||||
/// Metal's opaque pass draws the lists before `passes[0]`, its cutout
|
||||
/// pass those from `passes[1]`: each bound starts a call of its own,
|
||||
/// though its page goes on.
|
||||
#[test]
|
||||
fn plan_splits_calls_where_the_solid_passes_end_and_begin() {
|
||||
let (draws, pages, solid) = sample();
|
||||
let (mut calls, mut ranges, mut segments) = (Vec::new(), Vec::new(), Vec::new());
|
||||
let plan = plan(
|
||||
&draws,
|
||||
&pages,
|
||||
solid,
|
||||
solid,
|
||||
[draws.len(); 2],
|
||||
[3, 1],
|
||||
4,
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
0,
|
||||
);
|
||||
assert_eq!((plan.pass_calls, plan.solid_calls), ([3, 1], 4));
|
||||
let call = |page, first, count| Call { page, first, count };
|
||||
assert_eq!(
|
||||
calls,
|
||||
[
|
||||
call(0, 0, 18),
|
||||
call(0, 18, 12),
|
||||
call(1, 30, 6),
|
||||
call(0, 36, 24),
|
||||
call(0, 60, 18)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// A shader pack's shadow draws follow the camera's, in calls of their
|
||||
/// own even from the same page: solid ones, then translucent ones.
|
||||
#[test]
|
||||
@@ -919,10 +1012,12 @@ mod tests {
|
||||
solid,
|
||||
solid,
|
||||
[camera, camera + 2],
|
||||
[camera, 0],
|
||||
4,
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
0,
|
||||
);
|
||||
assert_eq!((plan.solid_calls, plan.shadow_calls), (3, [4, 5]));
|
||||
let call = |page, first, count| Call { page, first, count };
|
||||
@@ -946,10 +1041,12 @@ mod tests {
|
||||
2,
|
||||
solid,
|
||||
[draws.len(); 2],
|
||||
[draws.len(), 0],
|
||||
4,
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
0,
|
||||
);
|
||||
assert_eq!(
|
||||
plan,
|
||||
@@ -957,6 +1054,7 @@ mod tests {
|
||||
phase1_calls: 1,
|
||||
solid_calls: 3,
|
||||
shadow_calls: [4, 4],
|
||||
pass_calls: [1, 0],
|
||||
total: 6 * 13
|
||||
}
|
||||
);
|
||||
@@ -982,7 +1080,7 @@ mod tests {
|
||||
/// of the draws the test saw, in order, and their calls count them.
|
||||
#[test]
|
||||
fn flatten_writes_the_indices_of_the_per_section_draws() {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
@@ -1061,10 +1159,12 @@ mod tests {
|
||||
phase1,
|
||||
solid,
|
||||
[draws.len(); 2],
|
||||
[draws.len(), 0],
|
||||
align,
|
||||
&mut calls,
|
||||
&mut ranges,
|
||||
&mut segments,
|
||||
0,
|
||||
);
|
||||
// Phase 2's draws as the test leaves them: instance counts of 0,
|
||||
// 1 where it saw the section.
|
||||
|
||||
@@ -470,7 +470,7 @@ pub(super) mod tests {
|
||||
/// A device with WebGPU's default limits (what every GPU wgpu runs on has), or `None`
|
||||
/// without a GPU.
|
||||
pub fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,639 @@
|
||||
//! Phase 1's solid section draws made on the GPU (gpu_lists.wgsl).
|
||||
//!
|
||||
//! Each frame the render thread walked every section phase 1 draws through
|
||||
//! `mesher::Sides::visible_from` to find the runs of its quads that face the eye,
|
||||
//! and pushed one draw per run. Here it pushes one record per section instead:
|
||||
//! its row (where its quads start and how `Sides` sorted them, uploaded with its
|
||||
//! mesh), the call each of its parts draws in and its camera-relative origin. The
|
||||
//! GPU finds the same runs and writes their indices, per call (one per pass
|
||||
//! group and arena page), into the flat draws' index list, drawn with
|
||||
//! `draw_indexed_indirect`. Calls take room for all the quads of their sections'
|
||||
//! parts; the GPU counts the indices it wrote.
|
||||
//!
|
||||
//! Solid draws write depth and are tested against it, so their order within a
|
||||
//! call does not change the picture.
|
||||
use crate::mesher::{Part, Sides};
|
||||
|
||||
/// Words per row: first vertex, `Sides::gpu_row`, padding.
|
||||
const ROW: usize = 24;
|
||||
/// Most workgroups along one dimension of a dispatch.
|
||||
const GROUP_LIMIT: u32 = 65535;
|
||||
|
||||
/// A part of a section in a record: `Part` as gpu_lists.wgsl names it.
|
||||
fn part_code(part: Part) -> u32 {
|
||||
match part {
|
||||
Part::All => 0,
|
||||
Part::Opaque => 1,
|
||||
Part::Cutout => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// One of the frame's calls: what pass `group` draws of arena `page`, from
|
||||
/// index `first` with room for `count`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) struct ListCall {
|
||||
pub group: u8,
|
||||
pub page: u32,
|
||||
pub first: u32,
|
||||
pub count: u32,
|
||||
}
|
||||
|
||||
pub(super) struct GpuLists {
|
||||
pipeline: wgpu::ComputePipeline,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
/// Per id: its first vertex and `Sides::gpu_row` (`ROW` words).
|
||||
rows: wgpu::Buffer,
|
||||
row_capacity: u64,
|
||||
free_ids: Vec<u32>,
|
||||
next_id: u32,
|
||||
/// The frame's sections: id, the call and part of each of its parts, its
|
||||
/// number of parts; then its origin.
|
||||
records: Vec<[u32; 8]>,
|
||||
record_buffer: wgpu::Buffer,
|
||||
record_capacity: u64,
|
||||
calls: Vec<ListCall>,
|
||||
/// `calls`' index by page and group in the band of distance the last
|
||||
/// section listed is in (`u32::MAX`: none yet).
|
||||
call_of: Vec<u32>,
|
||||
band: u32,
|
||||
call_data: Vec<[u32; 5]>,
|
||||
/// Per call `DrawIndexedIndirectArgs`; the GPU adds what it writes to the count.
|
||||
call_buffer: wgpu::Buffer,
|
||||
call_capacity: u64,
|
||||
view: wgpu::Buffer,
|
||||
group: Option<(wgpu::BindGroup, u32)>,
|
||||
/// The indices the frame's calls take, and the most they may.
|
||||
room: u64,
|
||||
cap: u64,
|
||||
}
|
||||
|
||||
fn buffer(
|
||||
device: &wgpu::Device,
|
||||
label: &str,
|
||||
size: u64,
|
||||
usage: wgpu::BufferUsages,
|
||||
) -> wgpu::Buffer {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some(label),
|
||||
size,
|
||||
// COPY_SRC: rows move to a larger buffer; tests read calls back.
|
||||
usage: usage | wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn entry(binding: u32, ty: wgpu::BufferBindingType) -> wgpu::BindGroupLayoutEntry {
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
}
|
||||
}
|
||||
|
||||
impl GpuLists {
|
||||
pub fn new(device: &wgpu::Device) -> Self {
|
||||
let read = wgpu::BufferBindingType::Storage { read_only: true };
|
||||
let write = wgpu::BufferBindingType::Storage { read_only: false };
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Section lists"),
|
||||
entries: &[
|
||||
entry(0, read),
|
||||
entry(1, read),
|
||||
entry(2, wgpu::BufferBindingType::Uniform),
|
||||
entry(3, write),
|
||||
entry(4, write),
|
||||
],
|
||||
});
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Section lists"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("gpu_lists.wgsl").into()),
|
||||
});
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("Section lists"),
|
||||
layout: Some(
|
||||
&device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Section lists"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
}),
|
||||
),
|
||||
module: &module,
|
||||
entry_point: Some("flatten_sides"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let storage = wgpu::BufferUsages::STORAGE;
|
||||
let indirect = wgpu::BufferUsages::INDIRECT | storage;
|
||||
Self {
|
||||
pipeline,
|
||||
layout,
|
||||
rows: buffer(device, "Section rows", ROW as u64 * 4, storage),
|
||||
row_capacity: 1,
|
||||
free_ids: Vec::new(),
|
||||
next_id: 0,
|
||||
records: Vec::new(),
|
||||
record_buffer: buffer(device, "Sections listed", 32, storage),
|
||||
record_capacity: 1,
|
||||
calls: Vec::new(),
|
||||
call_of: Vec::new(),
|
||||
band: 0,
|
||||
call_data: Vec::new(),
|
||||
call_buffer: buffer(device, "Section list calls", 20, indirect),
|
||||
call_capacity: 1,
|
||||
view: buffer(
|
||||
device,
|
||||
"Section lists' eye",
|
||||
16,
|
||||
wgpu::BufferUsages::UNIFORM,
|
||||
),
|
||||
group: None,
|
||||
room: 0,
|
||||
cap: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A mesh uploaded from vertex `start` of its page, its solid quads sorted as
|
||||
/// `sides` says: its id, whose row is written through `uploads` in `encoder`.
|
||||
pub fn add(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
uploads: &mut super::upload::Uploads,
|
||||
start: u32,
|
||||
sides: &Sides,
|
||||
) -> u32 {
|
||||
let id = self.free_ids.pop().unwrap_or_else(|| {
|
||||
self.next_id += 1;
|
||||
self.next_id - 1
|
||||
});
|
||||
self.set(device, encoder, uploads, id, start, sides);
|
||||
id
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, id: u32) {
|
||||
self.free_ids.push(id);
|
||||
}
|
||||
|
||||
/// Mesh `id` now starts at vertex `start` (moved to another page).
|
||||
pub fn set(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
uploads: &mut super::upload::Uploads,
|
||||
id: u32,
|
||||
start: u32,
|
||||
sides: &Sides,
|
||||
) {
|
||||
let need = u64::from(id) + 1;
|
||||
if need > self.row_capacity {
|
||||
let capacity = need.next_power_of_two().max(1024);
|
||||
let rows = buffer(
|
||||
device,
|
||||
"Section rows",
|
||||
capacity * ROW as u64 * 4,
|
||||
wgpu::BufferUsages::STORAGE,
|
||||
);
|
||||
// Earlier rows: uploads record their copies in `encoder`, so this one follows them.
|
||||
encoder.copy_buffer_to_buffer(
|
||||
&self.rows,
|
||||
0,
|
||||
&rows,
|
||||
0,
|
||||
self.row_capacity * ROW as u64 * 4,
|
||||
);
|
||||
self.rows = rows;
|
||||
self.row_capacity = capacity;
|
||||
}
|
||||
let mut row = [0u32; ROW];
|
||||
row[0] = start;
|
||||
row[1..23].copy_from_slice(&sides.gpu_row());
|
||||
uploads.buffer(
|
||||
encoder,
|
||||
&self.rows,
|
||||
u64::from(id) * ROW as u64 * 4,
|
||||
bytemuck::cast_slice(&row),
|
||||
);
|
||||
}
|
||||
|
||||
/// Starts the frame's lists.
|
||||
pub fn clear(&mut self, device: &wgpu::Device) {
|
||||
self.records.clear();
|
||||
self.calls.clear();
|
||||
self.call_of.fill(u32::MAX);
|
||||
self.band = 0;
|
||||
self.room = 0;
|
||||
self.group = None;
|
||||
// A quarter of the binding they share with the CPU's draws.
|
||||
self.cap = device.limits().max_storage_buffer_binding_size / 16;
|
||||
}
|
||||
|
||||
/// Section `id`, in `page`, at `origin` from the camera and `distance`
|
||||
/// (squared, as bits) from it: each of `parts` (pass group, part) draws the
|
||||
/// quads of the part that face the eye. False, listing nothing, when the
|
||||
/// frame's lists hold all the room they may: the caller draws it.
|
||||
///
|
||||
/// Sections come nearest first. Each band of distance gets calls of its own,
|
||||
/// drawn in that order, as the CPU's lists keep them (`page_runs`): within
|
||||
/// a call the GPU writes sections in no set order, and cutout quads, which
|
||||
/// tile GPUs can't hide before shading, then draw mostly front to back.
|
||||
pub fn list(
|
||||
&mut self,
|
||||
id: u32,
|
||||
page: u32,
|
||||
distance: u64,
|
||||
sides: &Sides,
|
||||
parts: &[(u8, Part)],
|
||||
origin: [f32; 3],
|
||||
) -> bool {
|
||||
let parts = &parts[..parts.len().min(2)];
|
||||
let mut counts = [0; 2];
|
||||
for (count, &(_, part)) in counts.iter_mut().zip(parts) {
|
||||
*count = sides.part_quads(part) * 6;
|
||||
}
|
||||
let room = u64::from(counts[0]) + u64::from(counts[1]);
|
||||
if self.room + room > self.cap {
|
||||
return false;
|
||||
}
|
||||
self.room += room;
|
||||
let band = super::page_runs::band(distance) as u32;
|
||||
if band != self.band {
|
||||
self.band = band;
|
||||
self.call_of.fill(u32::MAX);
|
||||
}
|
||||
let mut record = [id, 0, 0, parts.len() as u32, 0, 0, 0, 0];
|
||||
for (k, &(group, part)) in parts.iter().enumerate() {
|
||||
let key = page as usize * 2 + usize::from(group.min(1));
|
||||
if self.call_of.len() <= key {
|
||||
self.call_of.resize(key + 1, u32::MAX);
|
||||
}
|
||||
if self.call_of[key] == u32::MAX {
|
||||
self.call_of[key] = self.calls.len() as u32;
|
||||
self.calls.push(ListCall {
|
||||
group,
|
||||
page,
|
||||
first: 0,
|
||||
count: 0,
|
||||
});
|
||||
}
|
||||
let call = self.call_of[key];
|
||||
self.calls[call as usize].count += counts[k];
|
||||
record[1 + k] = call | part_code(part) << 28;
|
||||
}
|
||||
for (to, o) in record[4..7].iter_mut().zip(origin) {
|
||||
*to = o.to_bits();
|
||||
}
|
||||
self.records.push(record);
|
||||
true
|
||||
}
|
||||
|
||||
/// The sections listed this frame.
|
||||
pub fn listed(&self) -> usize {
|
||||
self.records.len()
|
||||
}
|
||||
|
||||
/// The frame's calls, with their rooms once laid out (`rooms`).
|
||||
pub fn calls(&self) -> &[ListCall] {
|
||||
&self.calls
|
||||
}
|
||||
|
||||
/// Lays the calls' rooms out one after the other from the start of the
|
||||
/// index list: the indices they take.
|
||||
pub fn rooms(&mut self) -> u64 {
|
||||
let mut room = 0u64;
|
||||
for call in &mut self.calls {
|
||||
call.first = room as u32;
|
||||
room += u64::from(call.count);
|
||||
}
|
||||
room
|
||||
}
|
||||
|
||||
/// Stages the frame's sections, calls and eye (camera-relative; none: every
|
||||
/// quad faces it), and binds them for `dispatch` writing into `indices`.
|
||||
/// After `rooms`.
|
||||
pub fn prepare(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
uploads: &mut super::upload::Uploads,
|
||||
indices: &wgpu::Buffer,
|
||||
eye: Option<[f32; 3]>,
|
||||
) {
|
||||
self.group = None;
|
||||
let records = self.records.len() as u64;
|
||||
if records == 0 {
|
||||
return;
|
||||
}
|
||||
if records > self.record_capacity {
|
||||
self.record_capacity = records.next_power_of_two();
|
||||
self.record_buffer = buffer(
|
||||
device,
|
||||
"Sections listed",
|
||||
self.record_capacity * 32,
|
||||
wgpu::BufferUsages::STORAGE,
|
||||
);
|
||||
}
|
||||
uploads.buffer(
|
||||
encoder,
|
||||
&self.record_buffer,
|
||||
0,
|
||||
bytemuck::cast_slice(&self.records),
|
||||
);
|
||||
self.call_data.clear();
|
||||
self.call_data
|
||||
.extend(self.calls.iter().map(|c| [0, 1, c.first, 0, 0]));
|
||||
let calls = self.call_data.len() as u64;
|
||||
let indirect = wgpu::BufferUsages::INDIRECT | wgpu::BufferUsages::STORAGE;
|
||||
if calls > self.call_capacity {
|
||||
self.call_capacity = calls.next_power_of_two();
|
||||
self.call_buffer = buffer(
|
||||
device,
|
||||
"Section list calls",
|
||||
self.call_capacity * 20,
|
||||
indirect,
|
||||
);
|
||||
}
|
||||
uploads.buffer(
|
||||
encoder,
|
||||
&self.call_buffer,
|
||||
0,
|
||||
bytemuck::cast_slice(&self.call_data),
|
||||
);
|
||||
let [x, y, z] = eye.unwrap_or_default();
|
||||
let view = [x, y, z, if eye.is_some() { 1.0 } else { 0.0 }];
|
||||
uploads.buffer(encoder, &self.view, 0, bytemuck::cast_slice(&view));
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Section lists"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||
buffer: &self.record_buffer,
|
||||
offset: 0,
|
||||
size: wgpu::BufferSize::new(records * 32),
|
||||
}),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: self.rows.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: self.view.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: self.call_buffer.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: indices.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
self.group = Some((group, records as u32));
|
||||
}
|
||||
|
||||
/// Writes the frame's lists (after `prepare`).
|
||||
pub fn dispatch(&self, pass: &mut wgpu::ComputePass<'_>) {
|
||||
let Some((group, records)) = &self.group else {
|
||||
return;
|
||||
};
|
||||
let groups = *records;
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.dispatch_workgroups(groups.min(GROUP_LIMIT), groups.div_ceil(GROUP_LIMIT), 1);
|
||||
}
|
||||
|
||||
/// Whether `dispatch` writes this frame's lists.
|
||||
pub fn prepared(&self) -> bool {
|
||||
self.group.is_some()
|
||||
}
|
||||
|
||||
/// Draws call `k` with a pipeline, its page's vertices and the index list bound.
|
||||
pub fn draw_indexed(&self, pass: &mut wgpu::RenderPass<'_>, k: usize) {
|
||||
pass.draw_indexed_indirect(&self.call_buffer, k as u64 * 20);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mesher::{Part, Sides};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
struct Section {
|
||||
sides: Sides,
|
||||
origin: [f32; 3],
|
||||
start: u32,
|
||||
page: u32,
|
||||
parts: &'static [(u8, Part)],
|
||||
}
|
||||
|
||||
/// Random sorts: some parts empty, grouped or not, split or not, planes in
|
||||
/// and out of a section, each listed in one of the three ways it can be.
|
||||
fn sections(count: usize) -> Vec<Section> {
|
||||
let mut seed = 0x2545_f491_4f6c_dd1du64;
|
||||
let mut next = |n: u32| {
|
||||
seed = seed
|
||||
.wrapping_mul(6_364_136_223_846_793_005)
|
||||
.wrapping_add(1_442_695_040_888_963_407);
|
||||
((seed >> 33) % u64::from(n)) as u32
|
||||
};
|
||||
(0..count)
|
||||
.map(|_| {
|
||||
let mut ends = [0; 14];
|
||||
let mut end = 0;
|
||||
for e in &mut ends {
|
||||
if next(3) > 0 {
|
||||
end += next(40);
|
||||
}
|
||||
*e = end;
|
||||
}
|
||||
let planes = std::array::from_fn(|_| next(17) as f32 + next(16) as f32 / 16.0);
|
||||
Section {
|
||||
sides: Sides::from_parts(ends, next(2) == 0, next(4) > 0, planes),
|
||||
origin: [0, 1, 2].map(|_| next(129) as f32 - 64.0),
|
||||
start: next(10_000) * 4,
|
||||
page: next(3),
|
||||
parts: match next(3) {
|
||||
0 => &[(0, Part::All)],
|
||||
1 => &[(0, Part::Opaque), (1, Part::Cutout)],
|
||||
_ => &[(1, Part::All)],
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The runs the CPU finds for a section's part, as `Gfx` drew them.
|
||||
fn cpu_runs(s: &Section, part: Part, eye: Option<[f32; 3]>) -> Vec<(u32, u32)> {
|
||||
let mut runs = Vec::new();
|
||||
let draw = |first, quads| runs.push((first, quads));
|
||||
match eye {
|
||||
Some(eye) => {
|
||||
let eye = [0, 1, 2].map(|a| eye[a] - s.origin[a]);
|
||||
s.sides.visible_from(eye, eye[1], part, draw);
|
||||
}
|
||||
None => s.sides.every(part, draw),
|
||||
}
|
||||
runs
|
||||
}
|
||||
|
||||
/// Lists `sections` from each of a few eyes; per eye, the lists, the
|
||||
/// calls' words and the indices written.
|
||||
fn run(sections: &[Section]) -> Option<Vec<(Option<[f32; 3]>, GpuLists, Vec<u32>, Vec<u32>)>> {
|
||||
let (device, queue) = device()?;
|
||||
let mut results = Vec::new();
|
||||
for eye in [
|
||||
Some([3.5f32, 70.25, -12.0]),
|
||||
Some([-40.0, -3.0, 50.5]),
|
||||
None,
|
||||
] {
|
||||
let mut lists = GpuLists::new(&device);
|
||||
lists.clear(&device);
|
||||
let mut rows = vec![0u32; sections.len() * ROW];
|
||||
for (id, s) in sections.iter().enumerate() {
|
||||
rows[id * ROW] = s.start;
|
||||
rows[id * ROW + 1..id * ROW + 23].copy_from_slice(&s.sides.gpu_row());
|
||||
// Nearest first, over several bands.
|
||||
let distance = (id as f64 * 2.0).powi(2).to_bits();
|
||||
assert!(lists.list(id as u32, s.page, distance, &s.sides, s.parts, s.origin));
|
||||
}
|
||||
let room = lists.rooms();
|
||||
let init = |contents: &[u8], usage| {
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents,
|
||||
usage,
|
||||
})
|
||||
};
|
||||
let storage = wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC;
|
||||
lists.rows = init(bytemuck::cast_slice(&rows), storage);
|
||||
let indices = init(&vec![0xffu8; room as usize * 4], storage);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let mut uploads = super::super::upload::Uploads::new(&device);
|
||||
lists.prepare(&device, &mut encoder, &mut uploads, &indices, eye);
|
||||
uploads.finish(&encoder);
|
||||
{
|
||||
let mut pass = encoder.begin_compute_pass(&Default::default());
|
||||
lists.dispatch(&mut pass);
|
||||
}
|
||||
queue.submit([encoder.finish()]);
|
||||
let read_back = |buffer: &wgpu::Buffer, words: u64| -> Vec<u32> {
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: words * 4,
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
encoder.copy_buffer_to_buffer(buffer, 0, &read, 0, words * 4);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
bytemuck::cast_slice(&read.slice(..).get_mapped_range().unwrap()).to_vec()
|
||||
};
|
||||
let calls = read_back(&lists.call_buffer, lists.calls.len() as u64 * 5);
|
||||
let words = read_back(&indices, room);
|
||||
results.push((eye, lists, calls, words));
|
||||
}
|
||||
Some(results)
|
||||
}
|
||||
|
||||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return None;
|
||||
};
|
||||
let device = pollster::block_on(adapter.request_device(&Default::default())).ok();
|
||||
if device.is_none() {
|
||||
eprintln!("no GPU device: skipped");
|
||||
}
|
||||
device
|
||||
}
|
||||
|
||||
/// The call section `i`'s `k`th part draws in, as listed.
|
||||
fn call_of(lists: &GpuLists, i: usize, k: usize) -> usize {
|
||||
(lists.records[i][1 + k] & 0x0fff_ffff) as usize
|
||||
}
|
||||
|
||||
/// Each band of distance and page gets a pass's calls of its own, in the
|
||||
/// order sections come (nearest first), and sections of one share them.
|
||||
#[test]
|
||||
fn calls_go_by_band_then_page_nearest_first() {
|
||||
let Some((device, _)) = device() else {
|
||||
return;
|
||||
};
|
||||
let mut lists = GpuLists::new(&device);
|
||||
lists.clear(&device);
|
||||
let sides = Sides::unsorted(4);
|
||||
let parts = &[(0, Part::Opaque), (1, Part::Cutout)];
|
||||
for (page, blocks) in [(1, 10.0), (0, 20.0), (1, 30.0), (1, 100.0), (0, 130.0)] {
|
||||
let distance = f64::powi(blocks, 2).to_bits();
|
||||
assert!(lists.list(0, page, distance, &sides, parts, [0.0; 3]));
|
||||
}
|
||||
let calls: Vec<_> = lists
|
||||
.calls()
|
||||
.iter()
|
||||
.map(|c| (c.group, c.page, c.count))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
calls,
|
||||
[
|
||||
(0, 1, 48),
|
||||
(1, 1, 48),
|
||||
(0, 0, 24),
|
||||
(1, 0, 24),
|
||||
(0, 1, 24),
|
||||
(1, 1, 24),
|
||||
(0, 0, 24),
|
||||
(1, 0, 24)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// `flatten_sides` writes, in each call's room, the indices of exactly the
|
||||
/// runs `Sides::visible_from` finds for each listed section's parts (in
|
||||
/// some order: workgroups take room as they come), and counts them.
|
||||
#[test]
|
||||
fn flatten_sides_writes_the_runs_the_cpu_finds() {
|
||||
let sections = sections(200);
|
||||
let Some(results) = run(§ions) else {
|
||||
return;
|
||||
};
|
||||
for (eye, lists, calls, out) in results {
|
||||
let mut expected = vec![Vec::new(); lists.calls.len()];
|
||||
for (i, s) in sections.iter().enumerate() {
|
||||
for (k, &(_, part)) in s.parts.iter().enumerate() {
|
||||
let call = call_of(&lists, i, k);
|
||||
for (first, quads) in cpu_runs(s, part, eye) {
|
||||
for k in 0..quads * 6 {
|
||||
let corner = [0, 1, 2, 0, 2, 3][(k % 6) as usize];
|
||||
expected[call].push(s.start + first * 4 + (k / 6) * 4 + corner);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (k, call) in lists.calls.iter().enumerate() {
|
||||
let count = calls[k * 5] as usize;
|
||||
assert_eq!(
|
||||
count,
|
||||
expected[k].len(),
|
||||
"call {k}'s index count, eye {eye:?}"
|
||||
);
|
||||
assert_eq!(calls[k * 5 + 2], call.first);
|
||||
let mut got = out[call.first as usize..call.first as usize + count].to_vec();
|
||||
got.sort_unstable();
|
||||
expected[k].sort_unstable();
|
||||
assert_eq!(got, expected[k], "call {k}'s indices, eye {eye:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Phase 1's solid section draws made on the GPU (gfx/gpu_lists.rs): per section
|
||||
// listed, the runs of its quads that face the eye, found as
|
||||
// `mesher::Sides::visible_from` finds them on the CPU, written into the call
|
||||
// each of its parts draws in (one per pass group and arena page) after taking
|
||||
// room there: their indices, in the flat draws' index list, as `flatten_seen`
|
||||
// does for occlusion culling's phase 2. Solid draws write depth and are tested
|
||||
// against it, so their order within a call does not change the picture.
|
||||
struct View {
|
||||
// The eye relative to the camera, and 1.0 when there is one (`sides_on`);
|
||||
// without, every bucket is drawn (`Sides::every`).
|
||||
eye: vec3<f32>,
|
||||
has_eye: f32,
|
||||
}
|
||||
// Per section listed: its row's id; for each of its parts (up to two) the call
|
||||
// it draws in, with the part (`PART_*`) in the top bits; its number of parts.
|
||||
// Then its origin relative to the camera.
|
||||
struct Record {
|
||||
words: vec4<u32>,
|
||||
origin: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<storage, read> records: array<Record>;
|
||||
// Per id, `ROW` words: its first vertex in its page, then `Sides::gpu_row`
|
||||
// (14 ends, 7 planes' bits, flags).
|
||||
@group(0) @binding(1) var<storage, read> rows: array<u32>;
|
||||
@group(0) @binding(2) var<uniform> view: View;
|
||||
// Per call `DrawIndexedIndirectArgs`: its index count starts at 0, its first
|
||||
// index is where its room starts.
|
||||
@group(0) @binding(3) var<storage, read_write> calls: array<atomic<u32>>;
|
||||
// The flat draws' index list.
|
||||
@group(0) @binding(4) var<storage, read_write> out: array<u32>;
|
||||
|
||||
const ROW: u32 = 24u;
|
||||
const LANES: u32 = 64u;
|
||||
const PART_ALL: u32 = 0u;
|
||||
const PART_OPAQUE: u32 = 1u;
|
||||
const PART_CUTOUT: u32 = 2u;
|
||||
const CALL_MASK: u32 = 0x0fffffffu;
|
||||
// `sides::MARGIN`.
|
||||
const MARGIN: f32 = 0.0625;
|
||||
|
||||
// Index `k` of a solid draw: quad k / 6's vertices 0 1 2 0 2 3.
|
||||
fn quad(k: u32) -> u32 {
|
||||
let c = k % 6u;
|
||||
return (k / 6u) * 4u + select(select(c - 2u, 0u, c == 3u), c, c < 3u);
|
||||
}
|
||||
|
||||
fn plane(row: u32, bucket: u32) -> f32 {
|
||||
return bitcast<f32>(rows[row + 15u + bucket]);
|
||||
}
|
||||
|
||||
// `Sides::visible_from`'s `seen`, buckets in mesh order: +X, +Z, other, +Y, -Z, -X, -Y.
|
||||
fn seen(row: u32, bucket: u32, eye: vec3<f32>) -> bool {
|
||||
var faces = true;
|
||||
if view.has_eye != 0.0 {
|
||||
switch bucket {
|
||||
case 0u: { faces = eye.x > plane(row, 0u) - MARGIN; }
|
||||
case 1u: { faces = eye.z > plane(row, 1u) - MARGIN; }
|
||||
case 3u: { faces = eye.y > plane(row, 3u) - MARGIN; }
|
||||
case 4u: { faces = eye.z < plane(row, 4u) + MARGIN; }
|
||||
case 5u: { faces = eye.x < plane(row, 5u) + MARGIN; }
|
||||
case 6u: { faces = eye.y < plane(row, 6u) + MARGIN; }
|
||||
default: {}
|
||||
}
|
||||
}
|
||||
return faces;
|
||||
}
|
||||
|
||||
// A part's runs (first quad, quads): at most 7, as a run ends at a part left
|
||||
// out and there are 14.
|
||||
struct Runs {
|
||||
n: u32,
|
||||
quads: u32,
|
||||
runs: array<vec2<u32>, 7>,
|
||||
}
|
||||
|
||||
// `Sides::runs` for `part` of the mesh at `row`, seen from `eye` (relative to
|
||||
// the section): empty parts do not break a run.
|
||||
fn part_runs(row: u32, part: u32, eye: vec3<f32>) -> Runs {
|
||||
var r: Runs;
|
||||
let grouped = (rows[row + 22u] & 1u) != 0u;
|
||||
let split = (rows[row + 22u] & 2u) != 0u;
|
||||
var start = 0u;
|
||||
var open = false;
|
||||
var first = 0u;
|
||||
for (var n = 0u; n < 14u; n++) {
|
||||
let end = rows[row + 1u + n];
|
||||
if end == start {
|
||||
continue;
|
||||
}
|
||||
var bucket: u32;
|
||||
var cutout: bool;
|
||||
if grouped {
|
||||
bucket = n % 7u;
|
||||
cutout = n >= 7u;
|
||||
} else {
|
||||
bucket = n / 2u;
|
||||
cutout = n % 2u == 1u;
|
||||
}
|
||||
let wanted = part == PART_ALL || !split
|
||||
|| (part == PART_OPAQUE && !cutout) || (part == PART_CUTOUT && cutout);
|
||||
let take = wanted && seen(row, bucket, eye);
|
||||
if take && !open {
|
||||
open = true;
|
||||
first = start;
|
||||
} else if !take && open {
|
||||
r.runs[r.n] = vec2(first, start - first);
|
||||
r.quads += start - first;
|
||||
r.n++;
|
||||
open = false;
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
if open {
|
||||
r.runs[r.n] = vec2(first, start - first);
|
||||
r.quads += start - first;
|
||||
r.n++;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
fn record_index(group: vec3<u32>) -> u32 {
|
||||
return group.y * 65535u + group.x;
|
||||
}
|
||||
|
||||
// The eye relative to `record`'s section.
|
||||
fn eye_of(record: Record) -> vec3<f32> {
|
||||
return view.eye - record.origin.xyz;
|
||||
}
|
||||
|
||||
fn part_word(record: Record, p: u32) -> u32 {
|
||||
return select(record.words.z, record.words.y, p == 0u);
|
||||
}
|
||||
|
||||
// Lane 0's runs per part, and where each part's indices start.
|
||||
var<workgroup> found: array<Runs, 2>;
|
||||
var<workgroup> at: array<u32, 2>;
|
||||
|
||||
// One workgroup per record: lane 0 finds the runs and takes room for their
|
||||
// indices, then every lane writes them.
|
||||
@compute @workgroup_size(64)
|
||||
fn flatten_sides(@builtin(workgroup_id) group: vec3<u32>,
|
||||
@builtin(local_invocation_index) lane: u32) {
|
||||
let r = record_index(group);
|
||||
if r >= arrayLength(&records) {
|
||||
return;
|
||||
}
|
||||
let record = records[r];
|
||||
let row = record.words.x * ROW;
|
||||
let parts = record.words.w;
|
||||
if lane == 0u {
|
||||
let eye = eye_of(record);
|
||||
for (var p = 0u; p < parts; p++) {
|
||||
let word = part_word(record, p);
|
||||
let runs = part_runs(row, word >> 28u, eye);
|
||||
let call = (word & CALL_MASK) * 5u;
|
||||
var begin = 0u;
|
||||
if runs.quads > 0u {
|
||||
begin = atomicLoad(&calls[call + 2u]) + atomicAdd(&calls[call], runs.quads * 6u);
|
||||
}
|
||||
found[p] = runs;
|
||||
at[p] = begin;
|
||||
}
|
||||
}
|
||||
workgroupBarrier();
|
||||
let base = rows[row];
|
||||
for (var p = 0u; p < parts; p++) {
|
||||
var to = at[p];
|
||||
for (var k = 0u; k < found[p].n; k++) {
|
||||
let run = found[p].runs[k];
|
||||
let first = base + run.x * 4u;
|
||||
let count = run.y * 6u;
|
||||
for (var i = lane; i < count; i += LANES) {
|
||||
out[to + i] = first + quad(i);
|
||||
}
|
||||
to += count;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,8 @@ struct Targets {
|
||||
}
|
||||
|
||||
/// How one tier of light sources (`mesher::hdr_tier`) shines: `share` of the headroom for
|
||||
/// its brightest pixels, lifted from brightness `start` (none) to `full` (all of it).
|
||||
/// its brightest pixels, lifted from brightness `start` (none) to `full` (all of it), the
|
||||
/// texel's own brightness before lighting.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Tier {
|
||||
pub share: f32,
|
||||
@@ -148,6 +149,9 @@ pub struct Hdr {
|
||||
pub ui: f32,
|
||||
/// Which pixels of a light source shine (`Masks`).
|
||||
pub masks: Masks,
|
||||
/// 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,
|
||||
/// Calibration's test picture while it runs.
|
||||
pub calibration: Option<Calibration>,
|
||||
}
|
||||
@@ -268,7 +272,7 @@ impl Hdr {
|
||||
let params = || {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon HDR parameters"),
|
||||
size: 96,
|
||||
size: 64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
@@ -292,6 +296,7 @@ impl Hdr {
|
||||
tiers: TIERS,
|
||||
ui: 1.0,
|
||||
masks: MASKS,
|
||||
lighting: true,
|
||||
calibration: None,
|
||||
}
|
||||
}
|
||||
@@ -319,7 +324,10 @@ impl Hdr {
|
||||
"Horizon HDR frame",
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let world = texture("Horizon HDR world", wgpu::TextureUsages::COPY_DST);
|
||||
let world = texture(
|
||||
"Horizon HDR world",
|
||||
wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
);
|
||||
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Horizon HDR bindings"),
|
||||
@@ -437,7 +445,19 @@ impl Hdr {
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
/// The world as `keep_world` kept it: where the hand's marks go (its alpha, which HDR output
|
||||
/// reads for it too), and what tints the world rather than covering it.
|
||||
pub fn hand_light_view(&self) -> Option<wgpu::TextureView> {
|
||||
let t = self.targets.as_ref()?;
|
||||
Some(t.world.create_view(&Default::default()))
|
||||
}
|
||||
|
||||
/// Writes the finished frame to the swapchain image `target`.
|
||||
/// The tiers' windows for the shaders that mark the frame (`Globals::hdr_windows`):
|
||||
/// where each starts, then where each is full.
|
||||
pub fn windows(&self) -> [[f32; 4]; 2] {
|
||||
[self.tiers.map(|t| t.start), self.tiers.map(|t| t.full)]
|
||||
}
|
||||
pub fn present(
|
||||
&self,
|
||||
queue: &wgpu::Queue,
|
||||
@@ -510,10 +530,10 @@ impl Hdr {
|
||||
}
|
||||
|
||||
/// hdr.wgsl's `Params`.
|
||||
fn params(&self, world: bool, pq: bool) -> [f32; 24] {
|
||||
fn params(&self, world: bool, pq: bool) -> [f32; 16] {
|
||||
let paper = self.paper_white.max(1.0);
|
||||
let c = self.calibration;
|
||||
let mut params = [0.0; 24];
|
||||
let mut params = [0.0; 16];
|
||||
params[..5].copy_from_slice(&[
|
||||
paper / 80.0,
|
||||
(self.peak / paper).max(1.0),
|
||||
@@ -523,14 +543,17 @@ impl Hdr {
|
||||
]);
|
||||
params[5] = self.black.clamp(0.0, paper);
|
||||
params[6] = self.ui.clamp(0.25, 1.0);
|
||||
params[7] = if self.lighting {
|
||||
scene_gain(self.peak, self.paper_white)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
for (i, t) in self.tiers.iter().enumerate() {
|
||||
params[8 + i] = t.share;
|
||||
params[12 + i] = t.start;
|
||||
params[16 + i] = t.full;
|
||||
}
|
||||
params[20] = c.map_or(0.0, |c| c.step as f32);
|
||||
params[21] = c.map_or(0.0, |c| c.nits);
|
||||
params[22] = c.map_or(0.0, |c| c.band);
|
||||
params[12] = c.map_or(0.0, |c| c.step as f32);
|
||||
params[13] = c.map_or(0.0, |c| c.nits);
|
||||
params[14] = c.map_or(0.0, |c| c.band);
|
||||
params
|
||||
}
|
||||
|
||||
@@ -604,6 +627,12 @@ fn logo_mask(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
|
||||
.create_view(&Default::default())
|
||||
}
|
||||
|
||||
/// What the strongest light multiplies a surface by, in HDR lighting: 4, which looked right
|
||||
/// across biomes; less only where there is little headroom to ease into (`hdr.wgsl`).
|
||||
fn scene_gain(peak: f32, paper_white: f32) -> f32 {
|
||||
(peak / paper_white.max(1.0) * 1.75).clamp(1.0, 4.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Output, pick};
|
||||
@@ -656,3 +685,18 @@ mod tests {
|
||||
assert_eq!(pick(&caps(&[(F::Bgra8UnormSrgb, S::SRGB)]), None), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod lighting_tests {
|
||||
use super::scene_gain;
|
||||
|
||||
#[test]
|
||||
fn the_lighting_scales_with_the_headroom() {
|
||||
// Enough headroom: the full strength, whatever the paper white.
|
||||
assert_eq!(scene_gain(1000.0, 430.0), 4.0);
|
||||
assert_eq!(scene_gain(1060.0, 203.0), 4.0);
|
||||
// Little headroom, and none.
|
||||
assert_eq!(scene_gain(600.0, 400.0), 2.625);
|
||||
assert_eq!(scene_gain(200.0, 480.0), 1.0);
|
||||
}
|
||||
}
|
||||
@@ -217,7 +217,7 @@ fn long_flash_sequence_keeps_only_active_seeds() {
|
||||
#[ignore = "requires GPU; run separately with --ignored --exact"]
|
||||
fn retained_gpu_draw_matches_transient_additive_draw() {
|
||||
use bytemuck::Zeroable;
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
@@ -530,7 +530,7 @@ fn white_uv_is_inside_the_white_sprite() {
|
||||
#[test]
|
||||
#[ignore = "requires GPU; run separately with --ignored --exact"]
|
||||
fn bolt_through_the_atlas_white_sprite_is_fully_drawn() {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
|
||||
@@ -276,6 +276,10 @@ impl Renderer {
|
||||
/// once all are in, fades them in over the tiles they replace.
|
||||
pub fn upload_lods(&mut self, budget: Duration) {
|
||||
let start = Instant::now();
|
||||
if self.lod_pending.is_some() {
|
||||
// Far tiles fill the arenas too: large translucent pages wait for them.
|
||||
self.shrink_gate.wait(start, super::SHRINK_SETTLE);
|
||||
}
|
||||
self.release_left(start);
|
||||
let faded: Vec<_> = self
|
||||
.lods
|
||||
|
||||
@@ -39,6 +39,8 @@ struct HorizonMaterialOut {
|
||||
@location(8) @interpolate(flat) face: u32,
|
||||
@location(9) @interpolate(flat) fluid_quad: u32,
|
||||
@location(10) @interpolate(flat) emission: f32,
|
||||
// terrain.wgsl's `emission`, for HDR output's mark.
|
||||
@location(11) @interpolate(flat) hdr_code: u32,
|
||||
}
|
||||
fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
|
||||
light: vec2<u32>, origin: vec3<f32>) -> HorizonMaterialOut {
|
||||
@@ -67,7 +69,9 @@ fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color
|
||||
out.fluid_quad = u32(fluid);
|
||||
// The tier (0 none, 1 low, 2 incandescent, 3 bright, 4 very bright) as how strongly the
|
||||
// face shines: a redstone torch 7/15, a torch 14/15, glowstone 1.
|
||||
let tier = 15u - (flags & 15u);
|
||||
out.hdr_code = 15u - (flags & 15u);
|
||||
// Codes above 4 are the same tiers, shining all over rather than in their bright parts.
|
||||
let tier = select(out.hdr_code, out.hdr_code - 4u, out.hdr_code > 4u);
|
||||
out.emission = select(min(f32(tier) * 7.0 / 15.0, 1.0), 0.0, tier == 0u);
|
||||
return out;
|
||||
}
|
||||
@@ -145,7 +149,8 @@ fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32
|
||||
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
|
||||
translucent, horizon_sprite_tags(in.layer & 0x7fffu),
|
||||
horizon_sprite_material(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
|
||||
in.face, fluid, in.emission, normal, tangent)).color;
|
||||
in.face, fluid, in.emission, normal, tangent,
|
||||
horizon_terrain_native_colored_light(in.position))).color;
|
||||
}
|
||||
var<private> horizon_material_face_normal: vec3<f32>;
|
||||
struct HorizonMaterialSolid {
|
||||
@@ -154,15 +159,33 @@ struct HorizonMaterialSolid {
|
||||
// stage's `surface_at` compares with the final depth.
|
||||
@location(1) surface: vec4<f32>,
|
||||
}
|
||||
// What `fs_solid` leaves in the frame's alpha for HDR output, from the sprite before the pack's
|
||||
// material: the pack decides the colour, Horizon still knows which pixels are light sources.
|
||||
fn horizon_material_mark(in: HorizonMaterialOut, texel: vec4<f32>) -> f32 {
|
||||
if in.hdr_code > 0u {
|
||||
let whole = in.hdr_code > 4u;
|
||||
let tier = select(in.hdr_code, in.hdr_code - 4u, whole);
|
||||
let l = in.layer & 0x7fffu;
|
||||
let t = textureLoad(horizon_terrain_emission_thresholds,
|
||||
vec2(i32(l % horizon_terrain_SPRITE_SPLIT), i32(l / horizon_terrain_SPRITE_SPLIT)), 0);
|
||||
let y = horizon_terrain_brightness(texel.rgb);
|
||||
if whole || y >= t.r {
|
||||
return horizon_terrain_hdr_mark(tier, y);
|
||||
}
|
||||
}
|
||||
return 1.0;
|
||||
}
|
||||
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> HorizonMaterialSolid {
|
||||
let color = horizon_material_color(in, false);
|
||||
let texel = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
|
||||
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
|
||||
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
|
||||
if horizon_surface.w == 0.0 {
|
||||
set_surface(horizon_material_face_normal, 0.0, 0.0);
|
||||
}
|
||||
var out: HorizonMaterialSolid;
|
||||
out.color = horizon_material_fogged(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
|
||||
let mark = horizon_material_mark(in, texel);
|
||||
out.color = horizon_material_fogged(vec4(color.rgb, mark), in.spherical, in.cylindrical);
|
||||
out.surface = vec4(horizon_surface.xyz, in.clip.z);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ pub struct NativePack {
|
||||
pub weather: Option<wgpu::RenderPipeline>,
|
||||
/// The draw of the shadow map the stages read, if the pack asks for one.
|
||||
pub shadow: Option<NativeShadow>,
|
||||
/// `[[particle_lights]]`.
|
||||
pub particle_lights: Vec<horizon_shader_api::manifest::ParticleLight>,
|
||||
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
|
||||
pub timing: Option<PassTiming>,
|
||||
}
|
||||
@@ -133,6 +135,7 @@ impl NativePack {
|
||||
labels.extend(post.labels().into_iter().map(str::to_owned));
|
||||
}
|
||||
let pack = Self {
|
||||
particle_lights: report.manifest.particle_lights.clone(),
|
||||
frame,
|
||||
sky,
|
||||
material,
|
||||
|
||||
@@ -1294,7 +1294,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
@@ -6,7 +6,23 @@
|
||||
use super::Vertex;
|
||||
use super::pack_draw::EntitySpan;
|
||||
use glam::{DVec3, Mat4};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{
|
||||
OnceLock,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
mod generated;
|
||||
pub use generated::{RenderHooks, RenderNeedsEvent, WorldDrawnEvent};
|
||||
pub use wgpu;
|
||||
|
||||
static RENDERERS: OnceLock<Box<[&'static dyn RenderHooks]>> = OnceLock::new();
|
||||
|
||||
/// Install trait mod renderers in dispatch order, once per process.
|
||||
pub fn install(
|
||||
renderers: Box<[&'static dyn RenderHooks]>,
|
||||
) -> Result<(), Box<[&'static dyn RenderHooks]>> {
|
||||
RENDERERS.set(renderers)
|
||||
}
|
||||
|
||||
static MODS_BUILT_IN: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
@@ -18,12 +34,16 @@ pub fn set_mods_built_in(on: bool) {
|
||||
/// Whether [`native_world_drawn`] may draw ([`set_mods_built_in`]).
|
||||
pub fn world_drawn_hooked() -> bool {
|
||||
MODS_BUILT_IN.load(Ordering::Relaxed)
|
||||
|| RENDERERS
|
||||
.get()
|
||||
.is_some_and(|renderers| !renderers.is_empty())
|
||||
}
|
||||
|
||||
/// What a mod can draw with once the world is drawn: after the sky, terrain, entities,
|
||||
/// particles, weather and the world post pass, before the first-person hand, the HUD and the
|
||||
/// screens. `color` holds the world's picture (copy it out before reading it: it is the target
|
||||
/// drawn into), `depth` the world's depth, reversed (0 is the sky).
|
||||
#[non_exhaustive]
|
||||
pub struct WorldDrawn<'a> {
|
||||
pub device: &'a wgpu::Device,
|
||||
pub queue: &'a wgpu::Queue,
|
||||
@@ -59,6 +79,7 @@ pub struct WorldDrawn<'a> {
|
||||
/// What the mods need from a frame that the game doesn't make for itself. Each mod sets what it
|
||||
/// needs and leaves the rest, so several can ask.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub struct RenderNeeds {
|
||||
/// Record `Frame::entity_spans` without a shader pack: entity runs get a stable id from
|
||||
/// their type's name, block entity runs their block state.
|
||||
@@ -70,13 +91,13 @@ pub struct RenderNeeds {
|
||||
/// The world is drawn (see [`WorldDrawn`]). Not called on the title screen's panorama.
|
||||
#[inline(never)]
|
||||
pub fn native_world_drawn(world: &mut WorldDrawn<'_>) {
|
||||
let _ = world;
|
||||
generated::dispatch_world_drawn(RENDERERS.get().map_or(&[], |renderers| renderers), world);
|
||||
}
|
||||
|
||||
/// Asked as a frame is built and drawn: set what your mod needs.
|
||||
#[inline(never)]
|
||||
pub fn native_render_needs(needs: &mut RenderNeeds) {
|
||||
let _ = needs;
|
||||
generated::dispatch_render_needs(RENDERERS.get().map_or(&[], |renderers| renderers), needs);
|
||||
}
|
||||
|
||||
/// What the mods need this frame ([`native_render_needs`]).
|
||||
@@ -85,3 +106,34 @@ pub fn render_needs() -> RenderNeeds {
|
||||
native_render_needs(&mut needs);
|
||||
needs
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct Depth;
|
||||
impl RenderHooks for Depth {
|
||||
fn render_needs(&self, event: &mut RenderNeedsEvent<'_>) {
|
||||
event.needs.world_depth = true;
|
||||
}
|
||||
}
|
||||
struct Spans;
|
||||
impl RenderHooks for Spans {
|
||||
fn render_needs(&self, event: &mut RenderNeedsEvent<'_>) {
|
||||
event.needs.entity_spans = event.needs.world_depth;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_accumulate_in_mod_order() {
|
||||
let mut needs = RenderNeeds::default();
|
||||
generated::dispatch_render_needs(&[&Depth, &Spans], &mut needs);
|
||||
assert_eq!(
|
||||
needs,
|
||||
RenderNeeds {
|
||||
world_depth: true,
|
||||
entity_spans: true
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// @generated by cargo run -p xtask -- mod-api; do not edit.
|
||||
use super::*;
|
||||
pub trait RenderHooks: Send + Sync {
|
||||
/// The world is drawn (see [`WorldDrawn`]). Not called on the title screen's panorama.
|
||||
fn world_drawn(&self, event: &mut WorldDrawnEvent<'_, '_>) {
|
||||
let _ = event;
|
||||
}
|
||||
/// Asked as a frame is built and drawn: set what your mod needs.
|
||||
///
|
||||
/// Every mod gets `event.needs` in order and may add, change or remove needs.
|
||||
fn render_needs(&self, event: &mut RenderNeedsEvent<'_>) {
|
||||
let _ = event;
|
||||
}
|
||||
}
|
||||
/// What [`RenderHooks::world_drawn`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
pub struct WorldDrawnEvent<'a, 'b> {
|
||||
pub world: &'a mut WorldDrawn<'b>,
|
||||
}
|
||||
pub fn dispatch_world_drawn(mods: &[&dyn RenderHooks], world: &mut WorldDrawn<'_>) {
|
||||
let mut event = WorldDrawnEvent { world };
|
||||
for mod_hook in mods {
|
||||
mod_hook.world_drawn(&mut event);
|
||||
}
|
||||
}
|
||||
/// What [`RenderHooks::render_needs`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
pub struct RenderNeedsEvent<'a> {
|
||||
pub needs: &'a mut RenderNeeds,
|
||||
}
|
||||
pub fn dispatch_render_needs(mods: &[&dyn RenderHooks], needs: &mut RenderNeeds) {
|
||||
let mut event = RenderNeedsEvent { needs };
|
||||
for mod_hook in mods {
|
||||
mod_hook.render_needs(&mut event);
|
||||
}
|
||||
}
|
||||
@@ -211,7 +211,7 @@ mod tests {
|
||||
/// vanilla's pixels, one that inverts them draws the inverse (and stays opaque).
|
||||
#[test]
|
||||
fn a_pack_gets_vanilla_s_colour_and_draws_opaque() {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -244,7 +244,7 @@ mod tests {
|
||||
/// Two arrays of 2x2 sprites, one layer split; each layer a colour of its own.
|
||||
#[test]
|
||||
fn layers_land_in_their_cells() {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -319,6 +319,7 @@ impl PackEntities {
|
||||
entities: &[Vertex],
|
||||
spans: &[EntitySpan],
|
||||
drawn: [bool; PROGRAMS],
|
||||
crack: ([f32; 4], [u32; 2]),
|
||||
shadow: Casters,
|
||||
rest: &mut Vec<Vertex>,
|
||||
) {
|
||||
@@ -330,7 +331,9 @@ impl PackEntities {
|
||||
rest.extend_from_slice(&entities[..first.min(entities.len())]);
|
||||
for (span, range) in runs(entities.len(), spans) {
|
||||
let run = &entities[range];
|
||||
let program = program_of(&span).filter(|_| plain(run));
|
||||
let program = program_of(&span).filter(|&p| {
|
||||
plain(run) || (p == DESTROY && run.iter().all(|v| matches!(v.layer, 11 | 12)))
|
||||
});
|
||||
let pack = program.is_some_and(|p| drawn[p]) && !span.hidden;
|
||||
let casts = program.is_some_and(|p| shadow.cast(&span, p));
|
||||
if !pack && !span.hidden {
|
||||
@@ -341,6 +344,9 @@ impl PackEntities {
|
||||
};
|
||||
let start = self.scratch.len() as u32;
|
||||
self.scratch.extend_from_slice(run);
|
||||
if program == DESTROY {
|
||||
project_cracks(&mut self.scratch[start as usize..], crack.0, crack.1);
|
||||
}
|
||||
let (entity_id, block_id) = match program {
|
||||
BLOCK => (0, span.id),
|
||||
_ => (span.id, 0),
|
||||
@@ -419,7 +425,8 @@ pub const ENTITY: usize = 0;
|
||||
pub const BLOCK: usize = 1;
|
||||
pub const TRANSLUCENT: usize = 2;
|
||||
pub const PARTICLE: usize = 3;
|
||||
pub const PROGRAMS: usize = 4;
|
||||
pub const DESTROY: usize = 4;
|
||||
pub const PROGRAMS: usize = 5;
|
||||
|
||||
/// Which program draws `span`'s run, given which of them (or their fallbacks) the pack has;
|
||||
/// `None`: Horizon draws it.
|
||||
@@ -440,6 +447,8 @@ pub fn program_of(span: &EntitySpan) -> Option<usize> {
|
||||
BLOCK
|
||||
} else if span.stage == render_stage("PARTICLES") {
|
||||
PARTICLE
|
||||
} else if span.stage == render_stage("DESTROY") {
|
||||
DESTROY
|
||||
} else {
|
||||
return None;
|
||||
})
|
||||
@@ -461,6 +470,16 @@ pub fn runs(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn destroy_stage_uses_the_pack_only_when_a_program_is_available() {
|
||||
let destroy = EntitySpan {
|
||||
stage: render_stage("DESTROY"),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(drawn_by_pack(&destroy, [true; PROGRAMS]).is_some());
|
||||
assert_eq!(drawn_by_pack(&destroy, [false; PROGRAMS]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_split_the_list_between_pack_and_horizon() {
|
||||
let v = |layer| Vertex {
|
||||
@@ -483,15 +502,21 @@ mod tests {
|
||||
let all = [true; PROGRAMS];
|
||||
assert_eq!(drawn_by_pack(&spans[0], all), Some(ENTITY));
|
||||
assert_eq!(drawn_by_pack(&spans[1], all), Some(PARTICLE));
|
||||
assert_eq!(drawn_by_pack(&spans[1], [true, true, true, false]), None);
|
||||
assert_eq!(drawn_by_pack(&spans[2], [true, false, true, true]), None);
|
||||
assert_eq!(
|
||||
drawn_by_pack(&spans[1], [true, true, true, false, true]),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
drawn_by_pack(&spans[2], [true, false, true, true, true]),
|
||||
None
|
||||
);
|
||||
assert_eq!(drawn_by_pack(&spans[2], all), Some(BLOCK));
|
||||
let shadow = EntitySpan {
|
||||
translucent: true,
|
||||
..spans[0]
|
||||
};
|
||||
assert_eq!(drawn_by_pack(&shadow, all), Some(TRANSLUCENT));
|
||||
assert_eq!(drawn_by_pack(&span(0, "DESTROY", 0), all), None);
|
||||
assert_eq!(drawn_by_pack(&span(0, "DESTROY", 0), all), Some(DESTROY));
|
||||
// A beacon beam's quads stay Horizon's.
|
||||
assert!(plain(&[v(0), v(0), v(0), v(0), v(1)]));
|
||||
assert!(!plain(&[v(0), v(0), v(0), v(0), v(6)]));
|
||||
@@ -523,3 +548,53 @@ mod tests {
|
||||
assert!(!some.cast(&spans[2], BLOCK) && some.cast(&lit, BLOCK));
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode the destroy-stage origin stored in the light bytes and project one continuous
|
||||
/// sprite over each model face. Pack geometry uses ordinary atlas UVs, not Horizon's overlay codes.
|
||||
fn project_cracks(vertices: &mut [Vertex], sprite: [f32; 4], atlas: [u32; 2]) {
|
||||
let dims = [atlas[0].max(1) as f32, atlas[1].max(1) as f32];
|
||||
for quad in vertices.as_chunks_mut::<4>().0 {
|
||||
let origin = [
|
||||
(quad[0].light & 65535) as f32 / 65535.0,
|
||||
(quad[0].light >> 16) as f32 / 65535.0,
|
||||
];
|
||||
let origin = [0, 1].map(|i| (origin[i] * dims[i]).round() / dims[i]);
|
||||
let min = [0, 1]
|
||||
.map(|i| (quad.iter().map(|v| v.uv[i]).fold(f32::INFINITY, f32::min) + 0.0001).floor());
|
||||
for v in quad {
|
||||
v.uv =
|
||||
[0, 1].map(|i| origin[i] + (v.uv[i] - min[i]).clamp(0.001, 0.999) * sprite[i + 2]);
|
||||
v.layer = 0;
|
||||
v.light = 0x0000_f0f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod crack_projection_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn crack_uvs_keep_a_full_sprite_and_decode_each_stage() {
|
||||
let mut vertices: Vec<Vertex> = [[-1.0, -1.0], [-1.0, 0.0], [0.0, 0.0], [0.0, -1.0]]
|
||||
.into_iter()
|
||||
.map(|uv| Vertex {
|
||||
uv,
|
||||
layer: 11,
|
||||
light: crate::breaking::stage_light([0.25, 0.5]),
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
})
|
||||
.collect();
|
||||
project_cracks(
|
||||
&mut vertices,
|
||||
[0.0, 0.0, 16.0 / 4096.0, 16.0 / 9216.0],
|
||||
[4096, 9216],
|
||||
);
|
||||
assert!(
|
||||
vertices
|
||||
.iter()
|
||||
.all(|v| v.layer == 0 && v.uv[0] >= 0.25 && v.uv[0] < 0.25 + 16.0 / 4096.0)
|
||||
);
|
||||
assert!((vertices[2].uv[0] - vertices[0].uv[0]) > 15.0 / 4096.0);
|
||||
assert!((vertices[1].uv[1] - vertices[0].uv[1]) > 15.0 / 9216.0);
|
||||
}
|
||||
}
|
||||
@@ -408,6 +408,7 @@ pub(super) struct World<'a> {
|
||||
pub block_ids: &'a [i32],
|
||||
/// The atlas of the sun and moon's sprites.
|
||||
pub celestial: Option<wgpu::TextureView>,
|
||||
pub gui_size: [u32; 2],
|
||||
/// Set 1 of the clouds' draw: their offset and palette.
|
||||
pub clouds: Option<&'a wgpu::BindGroupLayout>,
|
||||
/// The game can hand its rain and snow to the pack.
|
||||
@@ -510,6 +511,7 @@ pub struct PackPost {
|
||||
/// with the page buffer it was made for.
|
||||
block_ids: Option<(wgpu::BindGroupLayout, 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`).
|
||||
pub timing: Option<super::pack_timing::PassTiming>,
|
||||
/// `depthtex0` at the screen's centre, read back for `centerDepthSmooth`.
|
||||
@@ -1341,11 +1343,16 @@ fn build_pass(
|
||||
} else if let Some(&buffer) = p.buffers.get(l) {
|
||||
let format = formats[buffer as usize];
|
||||
let mut state = target(format);
|
||||
if let (Some(t), Some(b)) = (&mut state, p.blend)
|
||||
&& format
|
||||
.guaranteed_format_features(features)
|
||||
.flags
|
||||
.contains(wgpu::TextureFormatFeatureFlags::BLENDABLE)
|
||||
if let (Some(t), Some(b)) = (
|
||||
&mut state,
|
||||
p.buffer_blends
|
||||
.iter()
|
||||
.find(|(n, _)| *n == buffer)
|
||||
.map_or(p.blend, |(_, b)| *b),
|
||||
) && format
|
||||
.guaranteed_format_features(features)
|
||||
.flags
|
||||
.contains(wgpu::TextureFormatFeatureFlags::BLENDABLE)
|
||||
{
|
||||
t.blend = Some(blend_state(b));
|
||||
}
|
||||
@@ -1433,7 +1440,15 @@ fn build_pass(
|
||||
depth_write_enabled: Some(g.depth_write),
|
||||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
bias: if p.name == "gbuffers_damagedblock" {
|
||||
wgpu::DepthBiasState {
|
||||
constant: -10,
|
||||
slope_scale: -1.0,
|
||||
clamp: 0.0,
|
||||
}
|
||||
} else {
|
||||
Default::default()
|
||||
},
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
@@ -1893,8 +1908,15 @@ impl PackPost {
|
||||
post.block,
|
||||
post.entities_translucent,
|
||||
post.particles,
|
||||
post.damaged_block,
|
||||
];
|
||||
let late = [
|
||||
false,
|
||||
false,
|
||||
post.translucent_late,
|
||||
post.particles_late,
|
||||
false,
|
||||
];
|
||||
let late = [false, false, post.translucent_late, post.particles_late];
|
||||
let shadow_only = (post.shadow_only_player, post.shadow_only_lit_blocks);
|
||||
let shadow_programs = [post.shadow_entities, post.shadow_block];
|
||||
let entity_vertices = (programs.iter().chain(&shadow_programs))
|
||||
@@ -1910,8 +1932,21 @@ impl PackPost {
|
||||
cull: false,
|
||||
});
|
||||
let entity_passes = programs.map(|p| {
|
||||
p.zip(entity_geometry.as_ref())
|
||||
.and_then(|(p, g)| build(p, Some(g)))
|
||||
p.zip(entity_geometry.as_ref()).and_then(|(p, g)| {
|
||||
if p.name == "gbuffers_damagedblock" {
|
||||
let decal = Geometry {
|
||||
buffers: g.buffers,
|
||||
table: g.table,
|
||||
depth_write: false,
|
||||
atlas: g.atlas.clone(),
|
||||
ids: g.ids,
|
||||
cull: false,
|
||||
};
|
||||
build(p, Some(&decal))
|
||||
} else {
|
||||
build(p, Some(g))
|
||||
}
|
||||
})
|
||||
});
|
||||
let shadow_entities = shadow_programs.map(|p| {
|
||||
p.zip(entity_geometry.as_ref())
|
||||
@@ -2243,6 +2278,7 @@ impl PackPost {
|
||||
draws,
|
||||
block_ids,
|
||||
page_groups: HashMap::new(),
|
||||
gui_size: world.gui_size,
|
||||
timing: None,
|
||||
center_depth: None,
|
||||
custom,
|
||||
@@ -2868,6 +2904,7 @@ impl PackPost {
|
||||
queue: &wgpu::Queue,
|
||||
entities: &[super::Vertex],
|
||||
spans: &[super::pack_draw::EntitySpan],
|
||||
crumbling: [f32; 4],
|
||||
rest: &mut Vec<super::Vertex>,
|
||||
) {
|
||||
let shadow = self.shadow_casters();
|
||||
@@ -2884,6 +2921,7 @@ impl PackPost {
|
||||
entities,
|
||||
spans,
|
||||
drawn,
|
||||
(crumbling, self.gui_size),
|
||||
shadow,
|
||||
rest,
|
||||
);
|
||||
@@ -3974,7 +4012,7 @@ mod tests {
|
||||
/// made. Such a panic skips the pass, as a validation error does, and the device goes on.
|
||||
#[test]
|
||||
fn a_shader_compiler_panic_skips_the_pass() {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -404,9 +404,9 @@ impl FrameUniforms {
|
||||
frame.depth_far,
|
||||
);
|
||||
let camera = frame.camera.as_vec3();
|
||||
let first_frame = self.previous.is_none();
|
||||
let (previous_exact, previous_view, previous_projection) =
|
||||
self.previous
|
||||
.unwrap_or((glam::DVec3::ZERO, Mat4::ZERO, Mat4::ZERO));
|
||||
self.previous.unwrap_or((frame.camera, view, projection));
|
||||
let previous_camera = previous_exact.as_vec3();
|
||||
self.previous = Some((frame.camera, view, projection));
|
||||
// The camera's whole blocks and the rest, exact however far from the origin.
|
||||
@@ -436,6 +436,10 @@ impl FrameUniforms {
|
||||
};
|
||||
self.wetness = smooth(self.wetness, inputs.rain, half_life / 20.0, dt);
|
||||
let eye = inputs.eye_light.map(|l| (l * 16) as f32);
|
||||
if first_frame {
|
||||
self.wetness = inputs.rain;
|
||||
self.eye_smooth = eye;
|
||||
}
|
||||
for (smoothed, target) in self.eye_smooth.iter_mut().zip(eye) {
|
||||
*smoothed = smooth(*smoothed, target, 10.0 / 20.0, dt);
|
||||
}
|
||||
@@ -849,3 +853,147 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod initial_history_tests {
|
||||
use super::*;
|
||||
use crate::hud::HudBatch;
|
||||
fn float(u: &FrameUniforms, name: &str) -> [f32; 16] {
|
||||
match u.values[index(name).unwrap()] {
|
||||
Value::F(v) => v,
|
||||
_ => panic!("float uniform expected"),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn first_history_is_current_then_tracks_the_actual_previous_frame() {
|
||||
let hud = HudBatch::new([800, 450]);
|
||||
let mut f = frame(&hud);
|
||||
let mut u = FrameUniforms::default();
|
||||
f.camera = DVec3::new(1024.25, -3.5, 11.75);
|
||||
f.yaw = 21.0;
|
||||
f.pitch = -16.0;
|
||||
u.update(&f, &PackInputs::default(), [800, 450]);
|
||||
assert_eq!(
|
||||
float(&u, "previousCameraPosition"),
|
||||
float(&u, "cameraPosition")
|
||||
);
|
||||
for (prior, current) in [
|
||||
("gbufferPreviousProjection", "gbufferProjection"),
|
||||
("gbufferPreviousModelView", "gbufferModelView"),
|
||||
] {
|
||||
assert_eq!(float(&u, prior), float(&u, current));
|
||||
assert!(float(&u, prior).iter().any(|v| *v != 0.0));
|
||||
}
|
||||
let camera = float(&u, "cameraPosition");
|
||||
let view = float(&u, "gbufferModelView");
|
||||
let projection = float(&u, "gbufferProjection");
|
||||
f.camera = DVec3::new(-100.0, 20.0, 40.0);
|
||||
f.yaw = 90.0;
|
||||
f.pitch = 50.0;
|
||||
f.fov_degrees = 100.0;
|
||||
u.update(&f, &PackInputs::default(), [1000, 500]);
|
||||
assert_eq!(float(&u, "previousCameraPosition"), camera);
|
||||
assert_eq!(float(&u, "gbufferPreviousModelView"), view);
|
||||
assert_eq!(float(&u, "gbufferPreviousProjection"), projection);
|
||||
assert_ne!(
|
||||
float(&u, "previousCameraPosition"),
|
||||
float(&u, "cameraPosition")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn environmental_smoothing_starts_at_the_first_inputs_without_resetting_each_frame() {
|
||||
let hud = HudBatch::new([800, 450]);
|
||||
let f = frame(&hud);
|
||||
let mut u = FrameUniforms::default();
|
||||
let mut input = PackInputs {
|
||||
rain: 0.75,
|
||||
eye_light: [3, 14],
|
||||
..Default::default()
|
||||
};
|
||||
u.update(&f, &input, [800, 450]);
|
||||
assert_eq!(float(&u, "wetness")[0], 0.75);
|
||||
match u.values[index("eyeBrightnessSmooth").unwrap()] {
|
||||
Value::I(v) => assert_eq!(&v[..2], &[48, 224]),
|
||||
_ => panic!("integer uniform expected"),
|
||||
}
|
||||
input.rain = 0.0;
|
||||
input.eye_light = [0, 0];
|
||||
u.update(&f, &input, [800, 450]);
|
||||
assert!(float(&u, "wetness")[0] > 0.0);
|
||||
assert!(u.eye_smooth.iter().all(|v| *v > 0.0));
|
||||
}
|
||||
fn frame(hud: &HudBatch) -> Frame<'_> {
|
||||
Frame {
|
||||
eye: glam::DVec3::ZERO,
|
||||
eye_rotation: [0.0; 2],
|
||||
first_person: true,
|
||||
shadow_scene_revision: (0, 0),
|
||||
shadow_cache: false,
|
||||
water_plane: None,
|
||||
weather: &[],
|
||||
weather_time: crate::weather::Time::new(0, 0.0),
|
||||
camera: glam::DVec3::new(0.5, 100.0, 0.5),
|
||||
yaw: 0.0,
|
||||
pitch: 10.0,
|
||||
fov_degrees: 85.0,
|
||||
bob: glam::Mat4::IDENTITY,
|
||||
spin: glam::Mat4::IDENTITY,
|
||||
sky: crate::environment::Sky::default(),
|
||||
distance: 12.0,
|
||||
detail_distance: 12.0,
|
||||
depth_far: 1000.0,
|
||||
underwater: false,
|
||||
cave_culling: false,
|
||||
title: true,
|
||||
sky_blocked: false,
|
||||
skybox: false,
|
||||
end_sky: false,
|
||||
end_sky_sprite: None,
|
||||
environment_fog: None,
|
||||
water_fog: [0.0; 3],
|
||||
fog: None,
|
||||
dark_disc: false,
|
||||
outline: Vec::new(),
|
||||
outline_after_terrain: false,
|
||||
outline_high_contrast: false,
|
||||
debug_lines: &[],
|
||||
debug_lines_on_top: &[],
|
||||
debug_crosshair: &[],
|
||||
hud,
|
||||
glint_alpha: 0.75,
|
||||
glint_ticks: 0.0,
|
||||
nether_lighting: false,
|
||||
entities: &[],
|
||||
entity_motion: &[],
|
||||
colored_lights: &[],
|
||||
emissive_layers: &[],
|
||||
shadow_only: &[],
|
||||
entity_spans: &[],
|
||||
glowing: &[],
|
||||
dynamic_lights: &[],
|
||||
clouds: None,
|
||||
lightning: Default::default(),
|
||||
additive: &[],
|
||||
see_through: &[],
|
||||
celestial: &[],
|
||||
stars: None,
|
||||
hand: &[],
|
||||
hand_fov_degrees: 70.0,
|
||||
screen: &[],
|
||||
pack: Default::default(),
|
||||
border: None,
|
||||
game_time: 0.0,
|
||||
shader_game_time: 0.0,
|
||||
end_portal_sprites: [[0.0; 4]; 2],
|
||||
crumbling_sprite: [0.0; 4],
|
||||
ticks: 0,
|
||||
darkness: (0.0, 0.0),
|
||||
night_vision: 0.0,
|
||||
block_flicker: 0.0,
|
||||
boss_darken: 0.0,
|
||||
menu_blur: None,
|
||||
chunk_fade: 0.0,
|
||||
post_effects: crate::post_chain::Request::none(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@
|
||||
|
||||
/// The width of a band, in blocks.
|
||||
const BAND: f64 = 64.0;
|
||||
/// Where a run's group sits in its sort key, above band and page.
|
||||
const GROUP_SHIFT: u32 = 62;
|
||||
|
||||
/// One section's draws: `start..start + len` of the list.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -27,6 +29,11 @@ pub(super) struct Run {
|
||||
pub tag: [u32; 3],
|
||||
}
|
||||
|
||||
/// The band of a section `distance` from the camera (squared, as bits).
|
||||
pub(super) fn band(distance: u64) -> u64 {
|
||||
(f64::from_bits(distance).sqrt() / BAND) as u64
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct PageRuns {
|
||||
runs: Vec<Run>,
|
||||
@@ -41,10 +48,24 @@ impl PageRuns {
|
||||
/// The draws `start..start + len` of a section `distance` from the
|
||||
/// camera (squared, as bits) in arena `page`; runs come nearest first.
|
||||
pub fn push(&mut self, distance: u64, page: u32, start: usize, len: usize, tag: [u32; 3]) {
|
||||
self.push_in(0, distance, page, start, len, tag);
|
||||
}
|
||||
|
||||
/// As `push`, in `group` (0 to 3): regrouping puts each group's runs
|
||||
/// before the next group's, then bands and pages within it.
|
||||
pub fn push_in(
|
||||
&mut self,
|
||||
group: u8,
|
||||
distance: u64,
|
||||
page: u32,
|
||||
start: usize,
|
||||
len: usize,
|
||||
tag: [u32; 3],
|
||||
) {
|
||||
if len > 0 {
|
||||
let band = (f64::from_bits(distance).sqrt() / BAND) as u64;
|
||||
let band = band(distance);
|
||||
self.runs.push(Run {
|
||||
key: band << 32 | u64::from(page),
|
||||
key: u64::from(group) << GROUP_SHIFT | band << 32 | u64::from(page),
|
||||
start: start as u32,
|
||||
len: len as u32,
|
||||
tag,
|
||||
@@ -61,7 +82,8 @@ impl PageRuns {
|
||||
pages: &mut [(u32, Option<u32>)],
|
||||
base: usize,
|
||||
) -> &[Run] {
|
||||
let page = |r: &Run| r.key as u32;
|
||||
// Group and page: one of each needs no reordering.
|
||||
let page = |r: &Run| r.key & (3 << GROUP_SHIFT | u64::from(u32::MAX));
|
||||
if self.runs.windows(2).all(|w| page(&w[0]) == page(&w[1])) {
|
||||
return &self.runs;
|
||||
}
|
||||
@@ -157,6 +179,24 @@ mod tests {
|
||||
assert_eq!(order, [0, 1, 2]);
|
||||
}
|
||||
|
||||
/// Metal's two solid passes draw a prefix and a suffix of the list:
|
||||
/// groups come whole and in order, whatever their distance and page.
|
||||
#[test]
|
||||
fn groups_come_before_bands_and_pages() {
|
||||
let mut grouped = PageRuns::default();
|
||||
let mut draws: Vec<_> = (0..5).map(|n| [n, 0, 0, 0, 0]).collect();
|
||||
let mut pages = vec![(0, None); 5];
|
||||
for (n, (group, distance)) in [(2, 10.0), (0, 300.0), (1, 20.0), (0, 30.0), (2, 5.0)]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
grouped.push_in(group, bits(distance), 0, n, 1, [0; 3]);
|
||||
}
|
||||
grouped.regroup(&mut draws, &mut pages, 0);
|
||||
let order: Vec<_> = draws.iter().map(|d| d[0]).collect();
|
||||
assert_eq!(order, [3, 1, 2, 0, 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_page_and_an_offset_list() {
|
||||
let (mut grouped, mut draws, mut pages) = list(&[(10.0, 3, 2), (90.0, 3, 1)]);
|
||||
|
||||
@@ -1,36 +1,64 @@
|
||||
//! Vanilla 26.3's CubeMapTexture and CubeMap, separate from the pixel-filtered GUI atlas.
|
||||
use crate::{Result, hud::decode};
|
||||
use crate::{Result, hud::decode_bytes};
|
||||
use glam::Mat4;
|
||||
use std::path::Path;
|
||||
use std::{
|
||||
path::Path,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
/// Native cube order: +X, -X, +Y, -Y, +Z, -Z (CubeMapTexture.SUFFIXES).
|
||||
const FACE_ORDER: [u8; 6] = [1, 3, 5, 4, 0, 2];
|
||||
/// Where the cube's faces live in a resource root.
|
||||
const BACKGROUND: &str = "assets/minecraft/textures/gui/title/background";
|
||||
|
||||
/// The title screen's six faces. The cube is on the GPU only while the title
|
||||
/// screen shows, and its texels are decoded from the faces' files each time it
|
||||
/// is made: kept decoded, they held 24 MiB through the whole game.
|
||||
#[derive(Debug)]
|
||||
pub struct Faces {
|
||||
pub size: u32,
|
||||
pub pixels: Vec<u8>,
|
||||
/// The faces' PNG files in cube order; none for a one-colour cube.
|
||||
files: Vec<Vec<u8>>,
|
||||
/// The texel of a one-colour cube.
|
||||
colour: [u8; 4],
|
||||
/// The texels decoded while loading, which the first cube takes.
|
||||
decoded: Mutex<Option<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl Faces {
|
||||
pub fn load(root: &Path) -> Self {
|
||||
Self::try_load(root).unwrap_or_else(|error| {
|
||||
log::warn!("Horizon: title panorama unavailable ({error})");
|
||||
Self {
|
||||
size: 1,
|
||||
pixels: [96, 110, 111, 255].repeat(6),
|
||||
}
|
||||
/// The title artwork of `root` (the packs' overlay) over `defaults` (the
|
||||
/// game's own resources). `CubeMapTexture` registers each face with the
|
||||
/// resource manager, which takes the topmost pack that has it: a pack that
|
||||
/// leaves a face alone keeps vanilla's, and only a cube no layer completes
|
||||
/// falls back to the grey placeholder that an installation without the
|
||||
/// imported panorama shows.
|
||||
pub fn load(root: &Path, defaults: &Path) -> Self {
|
||||
Self::try_load(root, defaults).unwrap_or_else(|error| {
|
||||
log::warn!(
|
||||
"Horizon: no title panorama ({error}); prepare the game again in the launcher \
|
||||
(or run horizon-prepare panorama)"
|
||||
);
|
||||
Self::colour([96, 110, 111, 255])
|
||||
})
|
||||
}
|
||||
|
||||
fn try_load(root: &Path) -> Result<Self> {
|
||||
let background = root.join("assets/minecraft/textures/gui/title/background");
|
||||
/// A one-texel cube of one colour.
|
||||
pub fn colour(colour: [u8; 4]) -> Self {
|
||||
Self {
|
||||
size: 1,
|
||||
files: Vec::new(),
|
||||
colour,
|
||||
decoded: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn try_load(root: &Path, defaults: &Path) -> Result<Self> {
|
||||
let mut size = 0;
|
||||
let mut pixels = Vec::new();
|
||||
let mut files = Vec::with_capacity(FACE_ORDER.len());
|
||||
let mut texels = Vec::new();
|
||||
for face in FACE_ORDER {
|
||||
let path = background.join(format!("panorama_{face}.png"));
|
||||
let (w, h, data) = decode(&path)?;
|
||||
let (path, file, (w, h, data)) = load_face(root, defaults, face)?;
|
||||
if w != h || (size != 0 && w != size) {
|
||||
return Err(format!(
|
||||
"{}: panorama faces must be equally sized squares",
|
||||
@@ -41,11 +69,104 @@ impl Faces {
|
||||
size = w;
|
||||
// CubeMapTexture copies each original image with flipY=true. Keep every texel.
|
||||
for row in data.chunks_exact((w * 4) as usize).rev() {
|
||||
pixels.extend_from_slice(row);
|
||||
texels.extend_from_slice(row);
|
||||
}
|
||||
files.push(file);
|
||||
}
|
||||
Ok(Self { size, pixels })
|
||||
// Decoded once here to check them, and kept for the first cube.
|
||||
Ok(Self {
|
||||
size,
|
||||
files,
|
||||
colour: [0; 4],
|
||||
decoded: Mutex::new(Some(texels)),
|
||||
})
|
||||
}
|
||||
|
||||
/// The cube's texels, layer after layer.
|
||||
pub fn texels(&self) -> Vec<u8> {
|
||||
if self.files.is_empty() {
|
||||
return self.colour.repeat(6);
|
||||
}
|
||||
let loaded = self
|
||||
.decoded
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.take();
|
||||
loaded
|
||||
.or_else(|| decode_faces(&self.files).ok().map(|(_, texels)| texels))
|
||||
.unwrap_or_else(|| vec![0; (self.size * self.size * 4 * 6) as usize])
|
||||
}
|
||||
}
|
||||
|
||||
/// The faces' files decoded, side by side, into one array of six layers of
|
||||
/// equal squares.
|
||||
fn decode_faces(files: &[Vec<u8>]) -> Result<(u32, Vec<u8>)> {
|
||||
let decoded: Vec<_> = std::thread::scope(|scope| {
|
||||
let threads: Vec<_> = files
|
||||
.iter()
|
||||
.map(|file| scope.spawn(|| decode_bytes(file).map_err(|e| e.to_string())))
|
||||
.collect();
|
||||
threads
|
||||
.into_iter()
|
||||
.map(|t| t.join().unwrap_or_else(|p| std::panic::resume_unwind(p)))
|
||||
.collect()
|
||||
});
|
||||
let mut size = 0;
|
||||
let mut texels = Vec::new();
|
||||
for (face, image) in FACE_ORDER.iter().zip(decoded) {
|
||||
let (w, h, data) = image?;
|
||||
if w != h || (size != 0 && w != size) {
|
||||
return Err(format!(
|
||||
"panorama_{face}.png: panorama faces must be equally sized squares"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if size == 0 {
|
||||
size = w;
|
||||
texels.reserve_exact((w * w * 4) as usize * files.len());
|
||||
}
|
||||
// CubeMapTexture copies each original image with flipY=true. Keep every texel.
|
||||
for row in data.chunks_exact((w * 4) as usize).rev() {
|
||||
texels.extend_from_slice(row);
|
||||
}
|
||||
}
|
||||
Ok((size, texels))
|
||||
}
|
||||
|
||||
/// `panorama_{face}.png` from the first layer that has one, with the path it
|
||||
/// came from, its file and its decoded image. Reading a face that no layer
|
||||
/// provides fails naming every path tried, so a report of a missing panorama
|
||||
/// says where the game looked.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn load_face(
|
||||
root: &Path,
|
||||
defaults: &Path,
|
||||
face: u8,
|
||||
) -> Result<(std::path::PathBuf, Vec<u8>, (u32, u32, Vec<u8>))> {
|
||||
let name = format!("panorama_{face}.png");
|
||||
let mut tried: Vec<(std::path::PathBuf, String)> = Vec::new();
|
||||
for layer in [root, defaults] {
|
||||
let path = layer.join(BACKGROUND).join(&name);
|
||||
// A vanilla-only installation loads one root twice.
|
||||
if tried.first().is_some_and(|(seen, _)| *seen == path) {
|
||||
continue;
|
||||
}
|
||||
let read = std::fs::read(&path)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|file| {
|
||||
let image = decode_bytes(&file).map_err(|e| e.to_string())?;
|
||||
Ok((file, image))
|
||||
});
|
||||
match read {
|
||||
Ok((file, image)) => return Ok((path, file, image)),
|
||||
Err(error) => tried.push((path, error)),
|
||||
}
|
||||
}
|
||||
let tried: Vec<String> = tried
|
||||
.iter()
|
||||
.map(|(path, error)| format!("{}: {error}", path.display()))
|
||||
.collect();
|
||||
Err(tried.join("; ").into())
|
||||
}
|
||||
|
||||
fn projection(width: u32, height: u32, yaw: f32, pitch: f32) -> Mat4 {
|
||||
@@ -62,40 +183,20 @@ fn projection(width: u32, height: u32, yaw: f32, pitch: f32) -> Mat4 {
|
||||
|
||||
pub(super) struct Panorama {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
group: wgpu::BindGroup,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
sampler: wgpu::Sampler,
|
||||
uniform: wgpu::Buffer,
|
||||
faces: Arc<Faces>,
|
||||
/// The cube and its bindings, while the title screen shows.
|
||||
cube: Option<wgpu::BindGroup>,
|
||||
}
|
||||
|
||||
impl Panorama {
|
||||
pub(super) fn new(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
format: wgpu::TextureFormat,
|
||||
faces: &Faces,
|
||||
faces: Arc<Faces>,
|
||||
) -> Self {
|
||||
let texture = device.create_texture_with_data(
|
||||
queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: Some("Title panorama cube"),
|
||||
size: wgpu::Extent3d {
|
||||
width: faces.size,
|
||||
height: faces.size,
|
||||
depth_or_array_layers: 6,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
&faces.pixels,
|
||||
);
|
||||
let view = texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
dimension: Some(wgpu::TextureViewDimension::Cube),
|
||||
..Default::default()
|
||||
});
|
||||
// CubeMapTexture's blur=true metadata: linear minification and magnification, no mips.
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Title panorama linear filtering"),
|
||||
@@ -140,24 +241,6 @@ impl Panorama {
|
||||
},
|
||||
],
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Title panorama resources"),
|
||||
layout: &layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: uniform.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(&view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::Sampler(&sampler),
|
||||
},
|
||||
],
|
||||
});
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Title panorama shader"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("panorama.wgsl").into()),
|
||||
@@ -203,13 +286,18 @@ impl Panorama {
|
||||
});
|
||||
Self {
|
||||
pipeline,
|
||||
group,
|
||||
layout,
|
||||
sampler,
|
||||
uniform,
|
||||
faces,
|
||||
cube: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ready a title screen frame: the cube, made if it is not on the GPU.
|
||||
pub(super) fn prepare(
|
||||
&self,
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
width: u32,
|
||||
height: u32,
|
||||
@@ -221,11 +309,68 @@ impl Panorama {
|
||||
0,
|
||||
bytemuck::cast_slice(&projection(width, height, yaw, pitch).to_cols_array()),
|
||||
);
|
||||
if self.cube.is_none() {
|
||||
self.cube = Some(self.cube(device, queue));
|
||||
}
|
||||
}
|
||||
|
||||
/// Give the cube's video memory back: no frame draws it until the title
|
||||
/// screen shows again.
|
||||
pub(super) fn release(&mut self) {
|
||||
self.cube = None;
|
||||
}
|
||||
|
||||
fn cube(&self, device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::BindGroup {
|
||||
let texture = device.create_texture_with_data(
|
||||
queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: Some("Title panorama cube"),
|
||||
size: wgpu::Extent3d {
|
||||
width: self.faces.size,
|
||||
height: self.faces.size,
|
||||
depth_or_array_layers: 6,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
&self.faces.texels(),
|
||||
);
|
||||
let view = texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
dimension: Some(wgpu::TextureViewDimension::Cube),
|
||||
..Default::default()
|
||||
});
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Title panorama resources"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: self.uniform.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(&view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/// Draw the cube `prepare` made for this frame.
|
||||
pub(super) fn draw(&self, pass: &mut wgpu::RenderPass<'_>) {
|
||||
let Some(cube) = &self.cube else {
|
||||
return;
|
||||
};
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.set_bind_group(0, cube, &[]);
|
||||
pass.draw(0..36, 0..1);
|
||||
}
|
||||
}
|
||||
@@ -253,9 +398,18 @@ mod tests {
|
||||
sync::atomic::{AtomicU32, Ordering},
|
||||
};
|
||||
|
||||
/// One resource root: a pack overlay, or the game's own resources.
|
||||
struct Fixture(std::path::PathBuf);
|
||||
impl Fixture {
|
||||
fn new(size: u32) -> Self {
|
||||
let fixture = Self::bare();
|
||||
for face in 0..6 {
|
||||
fixture.write(face, size, size);
|
||||
}
|
||||
fixture
|
||||
}
|
||||
/// A root with the faces' folder and none of the faces.
|
||||
fn bare() -> Self {
|
||||
static NEXT: AtomicU32 = AtomicU32::new(0);
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"horizon-panorama-{}-{}",
|
||||
@@ -264,9 +418,6 @@ mod tests {
|
||||
));
|
||||
let fixture = Self(root);
|
||||
fs::create_dir_all(fixture.background()).unwrap();
|
||||
for face in 0..6 {
|
||||
fixture.write(face, size, size);
|
||||
}
|
||||
fixture
|
||||
}
|
||||
fn background(&self) -> std::path::PathBuf {
|
||||
@@ -309,16 +460,19 @@ mod tests {
|
||||
#[test]
|
||||
fn original_resolution_texels_and_vanilla_cube_orientation_are_preserved() {
|
||||
let f = Fixture::new(1024);
|
||||
let faces = Faces::try_load(&f.0).unwrap();
|
||||
let faces = Faces::try_load(&f.0, &f.0).unwrap();
|
||||
assert_eq!(faces.size, 1024);
|
||||
assert_eq!(faces.pixels.len(), 6 * 1024 * 1024 * 4);
|
||||
// The texels decoded while loading, then decoded again for the next cube.
|
||||
let (loaded, again) = (faces.texels(), faces.texels());
|
||||
assert_eq!(loaded, again);
|
||||
assert_eq!(again.len(), 6 * 1024 * 1024 * 4);
|
||||
// CubeMapTexture's actual layer suffixes, with each source row flipped vertically.
|
||||
for (layer, source_face) in [1, 3, 5, 4, 0, 2].into_iter().enumerate() {
|
||||
for y in 0..1024 {
|
||||
for x in 0..1024 {
|
||||
let at = ((layer * 1024 + y) * 1024 + x) * 4;
|
||||
assert_eq!(
|
||||
faces.pixels[at..at + 4],
|
||||
again[at..at + 4],
|
||||
[source_face, (x % 251) as u8, ((1023 - y) % 253) as u8, 255]
|
||||
);
|
||||
}
|
||||
@@ -329,21 +483,57 @@ mod tests {
|
||||
#[test]
|
||||
fn placeholders_load_and_incomplete_or_mismatched_cubes_fall_back() {
|
||||
let f = Fixture::new(1);
|
||||
assert_eq!(Faces::try_load(&f.0).unwrap().size, 1);
|
||||
assert_eq!(Faces::try_load(&f.0, &f.0).unwrap().size, 1);
|
||||
f.write(5, 2, 1);
|
||||
assert!(
|
||||
Faces::try_load(&f.0)
|
||||
Faces::try_load(&f.0, &f.0)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("equally sized squares")
|
||||
);
|
||||
let fallback = Faces::load(&f.0);
|
||||
assert_eq!(fallback.pixels.len(), 24);
|
||||
let fallback = Faces::load(&f.0, &f.0);
|
||||
assert_eq!(fallback.texels(), [96, 110, 111, 255].repeat(6));
|
||||
f.write(5, 2, 2);
|
||||
assert!(Faces::try_load(&f.0).is_err());
|
||||
assert!(Faces::try_load(&f.0, &f.0).is_err());
|
||||
fs::remove_file(f.background().join("panorama_5.png")).unwrap();
|
||||
assert!(Faces::try_load(&f.0).is_err());
|
||||
assert_eq!(Faces::load(&f.0).size, 1);
|
||||
assert!(Faces::try_load(&f.0, &f.0).is_err());
|
||||
assert_eq!(Faces::load(&f.0, &f.0).size, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pack_without_a_face_keeps_vanillas_and_its_own_face_wins() {
|
||||
let vanilla = Fixture::new(4);
|
||||
// A pack that replaces one face of the cube: the other five are
|
||||
// still the game's, as the bottom of the pack stack provides them.
|
||||
let pack = Fixture::bare();
|
||||
pack.write(2, 4, 4);
|
||||
let faces = Faces::try_load(&pack.0, &vanilla.0).unwrap();
|
||||
assert_eq!(faces.size, 4);
|
||||
let texels = faces.texels();
|
||||
assert_eq!(texels.len(), 6 * 4 * 4 * 4);
|
||||
// Each face's red channel is its index (`face` above), and the cube
|
||||
// holds them in FACE_ORDER: layer 5 is face 2, the pack's, and layer 4
|
||||
// is face 0, which only the game's resources have.
|
||||
let red = |layer: usize, texel: usize| texels[(layer * 16 + texel) * 4];
|
||||
assert_eq!(red(5, 0), 2);
|
||||
assert_eq!(red(4, 0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_cube_names_every_path_it_tried() {
|
||||
let vanilla = Fixture::bare();
|
||||
let pack = Fixture::bare();
|
||||
let error = Faces::try_load(&pack.0, &vanilla.0)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
for root in [&pack, &vanilla] {
|
||||
let path = root.background().join("panorama_1.png");
|
||||
assert!(error.contains(&path.display().to_string()), "{error}");
|
||||
}
|
||||
// No layer completes the cube: the one-pixel grey placeholder, and the
|
||||
// window still has a title.
|
||||
let fallback = Faces::load(&pack.0, &vanilla.0);
|
||||
assert_eq!((fallback.size, fallback.texels().len()), (1, 24));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -85,7 +85,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
//! Pipelines kept between the renderers built on one device. The start builds
|
||||
//! three (the boot screen's, the title artwork's, the world's) and a resource
|
||||
//! reload or a graphics option builds another; each compiled every shader
|
||||
//! module and pipeline again, though most come out identical (the boot and
|
||||
//! title renderers' entirely, the world's entity pipelines). On a driver that
|
||||
//! compiles slowly (a cold shader cache, llvmpipe, a weak CPU) that was seconds
|
||||
//! of the main thread before the title showed.
|
||||
//!
|
||||
//! An object is kept under its descriptor's `Debug` text: wgpu prints the
|
||||
//! labels, sources, constants, formats and states, and the handles a descriptor
|
||||
//! names (shader modules, layouts) by their ids, so a descriptor that names a
|
||||
//! kept handle matches only the same handle. Kept objects stay alive, so their
|
||||
//! ids are never reused while they are keys.
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Mutex, PoisonError},
|
||||
};
|
||||
|
||||
/// One map of kept objects with the generation each was last asked for in.
|
||||
struct Kept<T> {
|
||||
map: Mutex<HashMap<String, (T, u32)>>,
|
||||
}
|
||||
impl<T> Default for Kept<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
map: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<T: Clone> Kept<T> {
|
||||
/// The object kept under `key`, else `make`'s, kept. The lock is not
|
||||
/// held while `make` compiles.
|
||||
fn get(&self, key: String, generation: u32, make: impl FnOnce() -> T) -> T {
|
||||
if let Some((object, used)) = self
|
||||
.map
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.get_mut(&key)
|
||||
{
|
||||
*used = generation;
|
||||
return object.clone();
|
||||
}
|
||||
let object = make();
|
||||
self.map
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.insert(key, (object.clone(), generation));
|
||||
object
|
||||
}
|
||||
fn keep_only(&self, generation: u32) {
|
||||
self.map
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.retain(|_, (_, used)| *used == generation);
|
||||
}
|
||||
fn len(&self) -> usize {
|
||||
self.map
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// The shader modules, layouts and pipelines of the device's renderers.
|
||||
#[derive(Default)]
|
||||
pub struct Reuse {
|
||||
generation: std::sync::atomic::AtomicU32,
|
||||
shaders: Kept<wgpu::ShaderModule>,
|
||||
bind_group_layouts: Kept<wgpu::BindGroupLayout>,
|
||||
pipeline_layouts: Kept<wgpu::PipelineLayout>,
|
||||
render: Kept<wgpu::RenderPipeline>,
|
||||
}
|
||||
|
||||
impl Reuse {
|
||||
/// A renderer starts building: what it asks for is kept, the rest is let
|
||||
/// go by [`Reuse::finish`].
|
||||
pub fn begin(&self) -> u32 {
|
||||
self.generation
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
.wrapping_add(1)
|
||||
}
|
||||
/// The renderer of `generation` is built: objects only older renderers
|
||||
/// used are dropped here (the renderers still alive hold their own).
|
||||
pub fn finish(&self, generation: u32) {
|
||||
self.shaders.keep_only(generation);
|
||||
self.bind_group_layouts.keep_only(generation);
|
||||
self.pipeline_layouts.keep_only(generation);
|
||||
self.render.keep_only(generation);
|
||||
}
|
||||
/// How many objects are kept.
|
||||
pub fn len(&self) -> usize {
|
||||
self.shaders.len()
|
||||
+ self.bind_group_layouts.len()
|
||||
+ self.pipeline_layouts.len()
|
||||
+ self.render.len()
|
||||
}
|
||||
fn current(&self) -> u32 {
|
||||
self.generation.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
/// `device`'s `create_*` calls through this cache.
|
||||
pub fn on<'a>(&'a self, device: &'a wgpu::Device) -> On<'a> {
|
||||
On {
|
||||
reuse: self,
|
||||
device,
|
||||
generation: self.current(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A device whose shader modules, layouts and render pipelines come from a
|
||||
/// [`Reuse`] when one with the same descriptor is kept.
|
||||
pub struct On<'a> {
|
||||
reuse: &'a Reuse,
|
||||
device: &'a wgpu::Device,
|
||||
generation: u32,
|
||||
}
|
||||
impl On<'_> {
|
||||
pub fn create_shader_module(
|
||||
&self,
|
||||
descriptor: wgpu::ShaderModuleDescriptor<'_>,
|
||||
) -> wgpu::ShaderModule {
|
||||
let key = format!("{descriptor:?}");
|
||||
self.reuse.shaders.get(key, self.generation, || {
|
||||
self.device.create_shader_module(descriptor)
|
||||
})
|
||||
}
|
||||
pub fn create_bind_group_layout(
|
||||
&self,
|
||||
descriptor: &wgpu::BindGroupLayoutDescriptor<'_>,
|
||||
) -> wgpu::BindGroupLayout {
|
||||
let key = format!("{descriptor:?}");
|
||||
self.reuse.bind_group_layouts.get(key, self.generation, || {
|
||||
self.device.create_bind_group_layout(descriptor)
|
||||
})
|
||||
}
|
||||
pub fn create_pipeline_layout(
|
||||
&self,
|
||||
descriptor: &wgpu::PipelineLayoutDescriptor<'_>,
|
||||
) -> wgpu::PipelineLayout {
|
||||
let key = format!("{descriptor:?}");
|
||||
self.reuse.pipeline_layouts.get(key, self.generation, || {
|
||||
self.device.create_pipeline_layout(descriptor)
|
||||
})
|
||||
}
|
||||
pub fn create_render_pipeline(
|
||||
&self,
|
||||
descriptor: &wgpu::RenderPipelineDescriptor<'_>,
|
||||
) -> wgpu::RenderPipeline {
|
||||
let key = format!("{descriptor:?}");
|
||||
self.reuse.render.get(key, self.generation, || {
|
||||
self.device.create_render_pipeline(descriptor)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Kept;
|
||||
|
||||
/// A key asked for again gets the kept object without making one; what
|
||||
/// the latest generation did not ask for is let go when it finishes.
|
||||
#[test]
|
||||
fn kept_objects_are_shared_until_a_build_stops_asking() {
|
||||
let kept = Kept::<u32>::default();
|
||||
let made = std::cell::Cell::new(0);
|
||||
let make = |value| {
|
||||
made.set(made.get() + 1);
|
||||
value
|
||||
};
|
||||
assert_eq!(kept.get("a".into(), 1, || make(10)), 10);
|
||||
assert_eq!(kept.get("b".into(), 1, || make(20)), 20);
|
||||
kept.keep_only(1);
|
||||
assert_eq!(kept.len(), 2);
|
||||
// The next build asks for "a" only.
|
||||
assert_eq!(kept.get("a".into(), 2, || make(11)), 10);
|
||||
kept.keep_only(2);
|
||||
assert_eq!(kept.len(), 1);
|
||||
assert_eq!(kept.get("b".into(), 3, || make(21)), 21);
|
||||
assert_eq!(made.get(), 3);
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,9 @@
|
||||
//! ([`Deref`]) first waits for that frame to be submitted, so writes, copies and
|
||||
//! submissions keep the order they had on one thread.
|
||||
use std::ops::Deref;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Mutex, mpsc};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Whether frames go to the submit thread, unless `HORIZON_SUBMIT_THREAD` says (`0` off, `1` on):
|
||||
/// on Linux, where it was measured faster (+32% FPS in a CPU-bound view on Vulkan, #5779). On
|
||||
@@ -50,6 +51,9 @@ pub struct Queue {
|
||||
/// A frame went to the submit thread and has not been waited for.
|
||||
pending: AtomicBool,
|
||||
landing: Mutex<Option<Landing>>,
|
||||
/// Nanoseconds the window's thread waited for the submit thread since the last
|
||||
/// [`take_waited`](Self::take_waited): the GPU or the display holding a frame back, not work.
|
||||
waited: AtomicU64,
|
||||
}
|
||||
|
||||
impl Queue {
|
||||
@@ -60,6 +64,7 @@ impl Queue {
|
||||
thread: None,
|
||||
pending: AtomicBool::new(false),
|
||||
landing: Mutex::new(None),
|
||||
waited: AtomicU64::new(0),
|
||||
};
|
||||
if !threaded {
|
||||
return this;
|
||||
@@ -130,15 +135,24 @@ impl Queue {
|
||||
return;
|
||||
}
|
||||
if let Some(l) = landing.as_mut() {
|
||||
let started = Instant::now();
|
||||
// A thread that died (a wgpu panic on it) panics here, as it would have in place.
|
||||
let submission = l
|
||||
.done
|
||||
.recv()
|
||||
.expect("Horizon renderer: the submit thread stopped");
|
||||
l.landed = Some(submission);
|
||||
let nanos = u64::try_from(started.elapsed().as_nanos()).unwrap_or(u64::MAX);
|
||||
self.waited.fetch_add(nanos, Ordering::Relaxed);
|
||||
}
|
||||
self.pending.store(false, Ordering::Release);
|
||||
}
|
||||
/// How long the window's thread waited for the submit thread since the last call: in a
|
||||
/// GPU-bound view, the previous frame's submission and present hold up every use of the
|
||||
/// queue (uploads in the frame's first half too), and that is waiting, not the frame's work.
|
||||
pub fn take_waited(&self) -> Duration {
|
||||
Duration::from_nanos(self.waited.swap(0, Ordering::Relaxed))
|
||||
}
|
||||
/// The submission of the frame [`submit_later`](Self::submit_later) took, once it is
|
||||
/// made; `None` if it was taken already.
|
||||
pub fn landed(&self) -> Option<wgpu::SubmissionIndex> {
|
||||
@@ -172,7 +186,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
//! The GPU tests' `wgpu::Instance`.
|
||||
|
||||
/// A new instance for one GPU test. On Windows it opens Vulkan and DirectX 12,
|
||||
/// not OpenGL: WGL instances made and dropped from many test threads at once
|
||||
/// deadlocked NVIDIA's driver, and the render tests hung most runs (#5238).
|
||||
/// The game runs there on Vulkan or DirectX 12 unless neither works.
|
||||
/// `WGPU_BACKEND` and the other `WGPU_*` variables still apply, as in the game.
|
||||
pub(crate) fn instance() -> wgpu::Instance {
|
||||
let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle();
|
||||
if cfg!(windows) {
|
||||
descriptor.backends = wgpu::Backends::PRIMARY;
|
||||
}
|
||||
wgpu::Instance::new(descriptor.with_env())
|
||||
}
|
||||
@@ -30,6 +30,8 @@ const DONE: u8 = 2;
|
||||
/// Resolved and on its way back.
|
||||
const PENDING: u8 = 3;
|
||||
const MAPPED: u8 = 4;
|
||||
/// Readback failed; stop timing rather than reuse an invalid buffer.
|
||||
const FAILED: u8 = 5;
|
||||
|
||||
struct Slot {
|
||||
buffer: wgpu::Buffer,
|
||||
@@ -131,6 +133,14 @@ impl GpuTimer {
|
||||
})
|
||||
}
|
||||
pub fn begin(&mut self, encoder: &mut wgpu::CommandEncoder) {
|
||||
if self
|
||||
.slots
|
||||
.iter()
|
||||
.any(|slot| slot.state.load(Ordering::Acquire) == FAILED)
|
||||
{
|
||||
self.current = None;
|
||||
return;
|
||||
}
|
||||
if self.markers.is_some() {
|
||||
for slot in 0..SLOTS {
|
||||
if self.slots[slot].state.load(Ordering::Acquire) == DONE {
|
||||
@@ -216,7 +226,7 @@ impl GpuTimer {
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, move |result| {
|
||||
state.store(
|
||||
if result.is_ok() { MAPPED } else { FREE },
|
||||
if result.is_ok() { MAPPED } else { FAILED },
|
||||
Ordering::Release,
|
||||
);
|
||||
});
|
||||
@@ -233,13 +243,21 @@ impl GpuTimer {
|
||||
}
|
||||
/// GPU milliseconds of frames whose timestamps have arrived, oldest first.
|
||||
pub fn collect(&mut self, device: &wgpu::Device, out: &mut Vec<f32>) {
|
||||
let _ = device.poll(wgpu::PollType::Poll);
|
||||
if device.poll(wgpu::PollType::Poll).is_err() {
|
||||
return;
|
||||
}
|
||||
for i in 0..SLOTS {
|
||||
let slot = &self.slots[(self.next + i) % SLOTS];
|
||||
if slot.state.load(Ordering::Acquire) != MAPPED {
|
||||
continue;
|
||||
}
|
||||
if let Ok(data) = slot.buffer.slice(..).get_mapped_range() {
|
||||
{
|
||||
let Ok(data) = slot.buffer.slice(..).get_mapped_range() else {
|
||||
// Device loss can destroy a buffer after its mapping succeeded.
|
||||
// Unmapping it would turn the failed readback into a panic.
|
||||
slot.state.store(FAILED, Ordering::Release);
|
||||
continue;
|
||||
};
|
||||
// Read unaligned: the mapping isn't guaranteed to be 8-byte aligned.
|
||||
let stamps: [u64; 2] = bytemuck::pod_read_unaligned(&data[..16]);
|
||||
// A start marker that ran late (rare on Metal) leaves the frame untimed.
|
||||
@@ -285,3 +303,74 @@ pub(super) fn end_pipeline(
|
||||
cache: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn mapped_timer() -> Option<(wgpu::Device, wgpu::Queue, GpuTimer)> {
|
||||
let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle();
|
||||
// WGL can hang while creating a test instance on Windows (#5238).
|
||||
descriptor.backends = wgpu::Backends::PRIMARY;
|
||||
let instance = wgpu::Instance::new(descriptor);
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
if !adapter.features().contains(wgpu::Features::TIMESTAMP_QUERY) {
|
||||
return None;
|
||||
}
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
required_features: GpuTimer::features(&adapter),
|
||||
..Default::default()
|
||||
}))
|
||||
.unwrap();
|
||||
let mut timer = GpuTimer::new(&device, &queue).unwrap();
|
||||
queue.write_buffer(
|
||||
&timer.slots[0].buffer,
|
||||
0,
|
||||
bytemuck::cast_slice(&[10_u64, 20]),
|
||||
);
|
||||
queue.submit([]);
|
||||
timer.resolved.push(0);
|
||||
timer.submitted(&queue);
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
assert_eq!(timer.slots[0].state.load(Ordering::Acquire), MAPPED);
|
||||
Some((device, queue, timer))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_readback_releases_slot() {
|
||||
let Some((device, _queue, mut timer)) = mapped_timer() else {
|
||||
eprintln!("Skipping GPU timer test: no timestamp-capable adapter");
|
||||
return;
|
||||
};
|
||||
let mut samples = Vec::new();
|
||||
timer.collect(&device, &mut samples);
|
||||
assert_eq!(
|
||||
samples,
|
||||
vec![(10.0 * f64::from(timer.period_ns) / 1e6) as f32]
|
||||
);
|
||||
assert_eq!(timer.slots[0].state.load(Ordering::Acquire), FREE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destroyed_readback_stops_timing_without_unmapping() {
|
||||
let Some((device, queue, mut timer)) = mapped_timer() else {
|
||||
eprintln!("Skipping GPU timer test: no timestamp-capable adapter");
|
||||
return;
|
||||
};
|
||||
// Reproduce a buffer destroyed after its successful map callback (#6320).
|
||||
timer.slots[0].buffer.destroy();
|
||||
let mut samples = Vec::new();
|
||||
timer.collect(&device, &mut samples);
|
||||
assert!(samples.is_empty());
|
||||
assert_eq!(timer.slots[0].state.load(Ordering::Acquire), FAILED);
|
||||
// Later frames must not resolve or copy into the failed readback buffer.
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
timer.begin(&mut encoder);
|
||||
assert!(timer.current.is_none());
|
||||
assert!(timer.resolved.is_empty());
|
||||
queue.submit([encoder.finish()]);
|
||||
timer.submitted(&queue);
|
||||
timer.collect(&device, &mut samples);
|
||||
assert!(samples.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -125,7 +125,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
@@ -361,7 +361,7 @@ mod tests {
|
||||
#[test]
|
||||
#[ignore = "requires a Vulkan/Metal/DX12 adapter"]
|
||||
fn weather_shader_uvs_match_legacy_bits() {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter =
|
||||
pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default()))
|
||||
.unwrap();
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
use glam::{DMat4, DVec3, Mat4, Vec2};
|
||||
|
||||
/// The least a world is scaled to when memory runs out.
|
||||
const MIN_SCALE: f32 = 0.25;
|
||||
/// The TAA history format: 8 bits would band as frames accumulate.
|
||||
const HISTORY_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
|
||||
@@ -322,12 +324,65 @@ impl Post {
|
||||
self.target = None;
|
||||
return None;
|
||||
}
|
||||
let (size, output) = self.sizes(window);
|
||||
let history_size = self.taa_on.then_some(output);
|
||||
if self
|
||||
.target
|
||||
// The old target goes first: a slider drag would otherwise hold the old and the new
|
||||
// targets together, and the sum is what runs a small GPU out of memory.
|
||||
loop {
|
||||
let (size, output) = self.sizes(window);
|
||||
let history_size = self.taa_on.then_some(output);
|
||||
if self
|
||||
.target
|
||||
.as_ref()
|
||||
.is_some_and(|t| t.size == size && t.taa.as_ref().map(|h| h.size) == history_size)
|
||||
{
|
||||
break;
|
||||
}
|
||||
self.target = None;
|
||||
// Errors raised are fatal, so catch an out-of-memory one and scale down. A texture
|
||||
// that failed is invalid, so its view fails validation too: catch that as well.
|
||||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let memory = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
|
||||
let target = self.build_target(device, size, history_size);
|
||||
let error = pollster::block_on(memory.pop()).or(pollster::block_on(validation.pop()));
|
||||
if error.is_none() {
|
||||
// New textures: no history yet.
|
||||
self.taa_this_frame.jitter[2] = 1.0;
|
||||
self.target = Some(target);
|
||||
break;
|
||||
}
|
||||
drop(target);
|
||||
if self.scale <= MIN_SCALE {
|
||||
log::warn!(
|
||||
"Horizon renderer: no video memory for the scaled world at {size:?} ({}); drawing at the window's size",
|
||||
error.map_or_else(String::new, |e| e.to_string())
|
||||
);
|
||||
self.scale = 1.0;
|
||||
self.target = None;
|
||||
return None;
|
||||
}
|
||||
let reduced = (self.scale * 0.75).max(MIN_SCALE);
|
||||
log::warn!(
|
||||
"Horizon renderer: out of video memory for the scaled world at {size:?}; resolution scale {:.2} -> {reduced:.2} ({})",
|
||||
self.scale,
|
||||
error.map_or_else(String::new, |e| e.to_string())
|
||||
);
|
||||
self.scale = reduced;
|
||||
if self.scale == 1.0 && !self.antialias && !self.taa_on && !always {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
self.target
|
||||
.as_ref()
|
||||
.is_none_or(|t| t.size != size || t.taa.as_ref().map(|h| h.size) != history_size)
|
||||
.map(|t| (t.color.clone(), t.depth.clone()))
|
||||
}
|
||||
|
||||
/// The world's colour and depth targets at `size`, and TAA's history when `history_size`
|
||||
/// is set.
|
||||
fn build_target(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
size: [u32; 2],
|
||||
history_size: Option<[u32; 2]>,
|
||||
) -> WorldTarget {
|
||||
{
|
||||
let make_tex = |label, size: [u32; 2], format, usage| {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
@@ -403,19 +458,14 @@ impl Post {
|
||||
show,
|
||||
})
|
||||
});
|
||||
// New textures: no history yet.
|
||||
self.taa_this_frame.jitter[2] = 1.0;
|
||||
self.target = Some(WorldTarget {
|
||||
WorldTarget {
|
||||
size,
|
||||
color,
|
||||
depth,
|
||||
group,
|
||||
taa,
|
||||
});
|
||||
}
|
||||
}
|
||||
self.target
|
||||
.as_ref()
|
||||
.map(|t| (t.color.clone(), t.depth.clone()))
|
||||
}
|
||||
|
||||
/// The size of the world's own target, when it has one.
|
||||
@@ -568,8 +618,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn test_device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance =
|
||||
wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle().with_env());
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
@@ -19,20 +19,28 @@ pub const HDR_MARKER: &str = "horizon-hdr-pending";
|
||||
pub struct Reported {
|
||||
pub paper_white: u32,
|
||||
pub peak: u32,
|
||||
/// The display's darkest black, in hundredths of a nit.
|
||||
pub black: u32,
|
||||
}
|
||||
|
||||
static REPORTED: [AtomicU32; 2] = [AtomicU32::new(0), AtomicU32::new(0)];
|
||||
static REPORTED: [AtomicU32; 3] = [AtomicU32::new(0), AtomicU32::new(0), AtomicU32::new(0)];
|
||||
|
||||
/// Sets what the system reports (the renderer, when it opens its surface).
|
||||
pub fn report(reported: Option<Reported>) {
|
||||
let (paper_white, peak) = reported.map_or((0, 0), |r| (r.paper_white, r.peak));
|
||||
let (paper_white, peak, black) =
|
||||
reported.map_or((0, 0, 0), |r| (r.paper_white, r.peak, r.black));
|
||||
REPORTED[0].store(paper_white, Ordering::Relaxed);
|
||||
REPORTED[1].store(peak, Ordering::Relaxed);
|
||||
REPORTED[2].store(black, Ordering::Relaxed);
|
||||
}
|
||||
/// What the system reports, if it does.
|
||||
pub fn reported() -> Option<Reported> {
|
||||
let [paper_white, peak] = REPORTED.each_ref().map(|n| n.load(Ordering::Relaxed));
|
||||
(peak > 0).then_some(Reported { paper_white, peak })
|
||||
let [paper_white, peak, black] = REPORTED.each_ref().map(|n| n.load(Ordering::Relaxed));
|
||||
(peak > 0).then_some(Reported {
|
||||
paper_white,
|
||||
peak,
|
||||
black,
|
||||
})
|
||||
}
|
||||
|
||||
/// Simple-level presets. `Custom` leaves every option as the player set it.
|
||||
@@ -456,7 +464,7 @@ static TRANSLUCENT_BATCHING_OFFERED: std::sync::atomic::AtomicBool =
|
||||
/// Whether the device can batch translucent quads (`Graphics::translucent_batching`): set when
|
||||
/// the renderer starts; not without indirect multi-draw or on OpenGL, which loops over a
|
||||
/// multi-draw's entries on the CPU, nor where vanilla frames draw flat (they read the section
|
||||
/// arena's translucent quads).
|
||||
/// arena's translucent quads). Where it can't, the settings grey its switch out.
|
||||
pub fn offer_translucent_batching(on: bool) {
|
||||
TRANSLUCENT_BATCHING_OFFERED.store(on, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
@@ -582,8 +590,9 @@ pub struct Graphics {
|
||||
/// HDR peak brightness the world's highlights reach, in nits; 0 is Auto:
|
||||
/// what the system reports for the display ([`Graphics::peak_nits`]).
|
||||
pub hdr_peak: u32,
|
||||
/// HDR black level: the display's darkest black, in hundredths of a nit (0 for
|
||||
/// an OLED's true black). Shadows are lifted onto it, and the HDR metadata says it.
|
||||
/// HDR black level: the display's darkest black, in hundredths of a nit; 0 is Auto:
|
||||
/// what the system reports, else true black. Shadows are lifted onto it, and the HDR
|
||||
/// metadata says it.
|
||||
pub hdr_black_level: u32,
|
||||
/// HDR brightness of the HUD and menus, in percent of paper white (25..=100); the
|
||||
/// first-person hand stays at the world's.
|
||||
@@ -601,9 +610,11 @@ pub struct Graphics {
|
||||
/// The graphics API (Windows only; see `GraphicsApi`).
|
||||
pub api: GraphicsApi,
|
||||
/// Translucent quads in vertex buffers of their own (`gfx`'s translucent arena), so their
|
||||
/// back-to-front draws go out in a few calls, for a little more video memory. Off by
|
||||
/// default for now; read when a renderer is made (start, resource-pack reload), where it is
|
||||
/// offered (`translucent_batching_offered`).
|
||||
/// back-to-front draws go out in a few calls, for a little more video memory. On by
|
||||
/// default (Translucent Batching, Advanced graphics); read when a renderer is made (start,
|
||||
/// resource-pack reload), where it is offered (`translucent_batching_offered`). Its key is
|
||||
/// `batchTranslucent`: `translucentBatching`, which every save wrote as false while it was
|
||||
/// off by default and had no switch, stays ignored.
|
||||
pub translucent_batching: bool,
|
||||
/// The shader pack picked in `shaderpacks/` (`shader_pack_list`); empty: none. Its key is
|
||||
/// not `shaderPack`, which versions before the clean-room rewrite saved and which stays
|
||||
@@ -646,7 +657,7 @@ impl Default for Graphics {
|
||||
battery: false,
|
||||
shadow_cache: false,
|
||||
api: GraphicsApi::Auto,
|
||||
translucent_batching: false,
|
||||
translucent_batching: true,
|
||||
shader_pack: String::new(),
|
||||
}
|
||||
}
|
||||
@@ -739,7 +750,7 @@ impl Graphics {
|
||||
"hdrKey" if value.starts_with("key.") => g.hdr_key = value.to_owned(),
|
||||
"batteryMode" => g.battery = flag(g.battery),
|
||||
"shadowCache" => g.shadow_cache = flag(g.shadow_cache),
|
||||
"translucentBatching" => g.translucent_batching = flag(g.translucent_batching),
|
||||
"batchTranslucent" => g.translucent_batching = flag(g.translucent_batching),
|
||||
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
|
||||
"pickedShaderPack" => g.shader_pack = value.to_owned(),
|
||||
"overlay" => {
|
||||
@@ -756,7 +767,7 @@ impl Graphics {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nleafCulling:{}\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:{}\ntranslucentBatching:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nleafCulling:{}\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:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
@@ -828,6 +839,14 @@ impl Graphics {
|
||||
n => n,
|
||||
}
|
||||
}
|
||||
/// The black level, in nits: the player's, or on Auto what the system reports, else 0.
|
||||
pub fn black_level_nits(&self) -> f32 {
|
||||
let hundredths = match self.hdr_black_level {
|
||||
0 => reported().map_or(0, |r| r.black),
|
||||
n => n,
|
||||
};
|
||||
hundredths as f32 / 100.0
|
||||
}
|
||||
/// Worker threads for chunk generation and meshing.
|
||||
pub fn worker_threads(&self) -> usize {
|
||||
if self.workers > 0 {
|
||||
@@ -948,7 +967,7 @@ mod tests {
|
||||
battery: true,
|
||||
shadow_cache: true,
|
||||
api: GraphicsApi::Dx12,
|
||||
translucent_batching: true,
|
||||
translucent_batching: false,
|
||||
shader_pack: "Golden Hour".into(),
|
||||
};
|
||||
assert_eq!(Graphics::parse(&g.serialize()), g);
|
||||
@@ -968,8 +987,12 @@ mod tests {
|
||||
EntityCullingMode::Off
|
||||
);
|
||||
assert_eq!(Graphics::parse(""), Graphics::default());
|
||||
// Translucent Batching is opt-in for now: only the file turns it on.
|
||||
assert!(!Graphics::default().translucent_batching);
|
||||
// Translucent Batching is on, in files from before it too; only false turns it off,
|
||||
// and only under its key: the old one held the false every save wrote while it was
|
||||
// off by default, not a choice.
|
||||
assert!(Graphics::default().translucent_batching);
|
||||
assert!(!Graphics::parse("batchTranslucent:false\n").translucent_batching);
|
||||
assert!(Graphics::parse("translucentBatching:false\n").translucent_batching);
|
||||
// 0 is Auto; anything else is a peak the slider reaches.
|
||||
assert_eq!(Graphics::parse("hdrPeak:0\n").hdr_peak, 0);
|
||||
assert_eq!(Graphics::parse("hdrPeak:50\n").hdr_peak, 200);
|
||||
|
||||
@@ -15,7 +15,7 @@ pub enum Cost {
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
/// Turning it on makes frames faster (culling).
|
||||
/// Turning it on makes frames faster (culling, caching, batching).
|
||||
Saves,
|
||||
}
|
||||
impl Cost {
|
||||
|
||||
@@ -61,6 +61,7 @@ pub enum H {
|
||||
BatteryMode,
|
||||
ShadowCache,
|
||||
GraphicsApi,
|
||||
TranslucentBatching,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -357,6 +358,11 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
None,
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.",
|
||||
),
|
||||
h(
|
||||
H::TranslucentBatching,
|
||||
Saves,
|
||||
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.",
|
||||
),
|
||||
h(
|
||||
H::UploadBudget,
|
||||
Low,
|
||||
@@ -370,11 +376,13 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
),
|
||||
],
|
||||
};
|
||||
// Only Windows and Linux have more than one graphics API.
|
||||
// Only Windows and Linux have more than one graphics API, and translucent batching
|
||||
// (Metal draws flat).
|
||||
if cfg!(target_os = "macos") {
|
||||
rows.retain(
|
||||
|row| !matches!(row, Row::Setting(s) if s.item == Item::Horizon(H::GraphicsApi)),
|
||||
);
|
||||
rows.retain(|row| {
|
||||
!matches!(row, Row::Setting(s)
|
||||
if matches!(s.item, Item::Horizon(H::GraphicsApi | H::TranslucentBatching)))
|
||||
});
|
||||
}
|
||||
rows
|
||||
}
|
||||
@@ -598,24 +606,21 @@ impl H {
|
||||
H::HdrPaperWhite => match g.hdr_paper_white {
|
||||
0 => labelled(
|
||||
"Paper White",
|
||||
lang::tr("Auto (%s nits)", &[&g.paper_white_nits()]),
|
||||
lang::tr("Auto (%s)", &[&g.paper_white_nits()]),
|
||||
),
|
||||
n => labelled("Paper White", lang::tr("%s nits", &[&n])),
|
||||
n => labelled("Paper White", n.to_string()),
|
||||
},
|
||||
H::HdrPeak => match g.hdr_peak {
|
||||
0 => labelled(
|
||||
"Peak Brightness",
|
||||
lang::tr("Auto (%s nits)", &[&g.peak_nits()]),
|
||||
),
|
||||
n => labelled("Peak Brightness", lang::tr("%s nits", &[&n])),
|
||||
0 => labelled("Peak Brightness", lang::tr("Auto (%s)", &[&g.peak_nits()])),
|
||||
n => labelled("Peak Brightness", n.to_string()),
|
||||
},
|
||||
H::HdrBlackLevel => labelled(
|
||||
"Black Level",
|
||||
lang::tr(
|
||||
"%s nits",
|
||||
&[&format!("{:.2}", g.hdr_black_level as f32 / 100.0)],
|
||||
H::HdrBlackLevel => match g.hdr_black_level {
|
||||
0 => labelled(
|
||||
"Black Level",
|
||||
lang::tr("Auto (%s)", &[&format!("{:.2}", g.black_level_nits())]),
|
||||
),
|
||||
),
|
||||
n => labelled("Black Level", format!("{:.2}", n as f32 / 100.0)),
|
||||
},
|
||||
H::HdrUiBrightness => labelled("UI Brightness", format!("{}%", g.hdr_ui_brightness)),
|
||||
H::HdrKey => {
|
||||
Text::translate("Shortcut: %s", &[crate::keybinds::display_name(&g.hdr_key)])
|
||||
@@ -623,6 +628,12 @@ impl H {
|
||||
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
|
||||
H::ShadowCache => labelled("Shadow Cache", on_off(g.shadow_cache)),
|
||||
H::GraphicsApi => labelled("Graphics API", g.api.label()),
|
||||
// Greyed out where the device can't (`Item::blocked`): Off, as it is there,
|
||||
// while the saved value waits for a device that can.
|
||||
H::TranslucentBatching => labelled(
|
||||
"Translucent Batching",
|
||||
on_off(g.translucent_batching && crate::graphics::translucent_batching_offered()),
|
||||
),
|
||||
H::Overlay => labelled(
|
||||
"Performance Overlay",
|
||||
match g.overlay {
|
||||
@@ -718,6 +729,7 @@ impl H {
|
||||
H::BatteryMode => g.battery = !g.battery,
|
||||
H::ShadowCache => g.shadow_cache = !g.shadow_cache,
|
||||
H::GraphicsApi => g.api = step(GraphicsApi::choices(), g.api, back),
|
||||
H::TranslucentBatching => g.translucent_batching = !g.translucent_batching,
|
||||
H::Pacing => {
|
||||
g.pacing = match g.pacing {
|
||||
Pacing::Efficient => Pacing::Precise,
|
||||
@@ -748,7 +760,8 @@ impl Item {
|
||||
/// the display offers HDR output, whatever `horizon-graphics.txt` says (a copied game
|
||||
/// folder, a display swapped for an SDR one: the renderer leaves HDR off there, and
|
||||
/// the saved values wait for an HDR display), and its sliders do something only
|
||||
/// while it is on.
|
||||
/// while it is on; Translucent Batching switches only where the device can batch
|
||||
/// (`graphics::translucent_batching_offered`), its saved value waiting for one that can.
|
||||
fn blocked(self, s: &Settings) -> Option<&'static str> {
|
||||
if !self.available() {
|
||||
return Some("This feature is not yet available in Horizon.");
|
||||
@@ -764,6 +777,11 @@ impl Item {
|
||||
| H::HdrPaperWhite
|
||||
| H::HdrUiBrightness,
|
||||
) if !s.horizon.hdr => Some("Turn HDR on to use it."),
|
||||
Item::Horizon(H::TranslucentBatching)
|
||||
if !crate::graphics::translucent_batching_offered() =>
|
||||
{
|
||||
Some("Your GPU or graphics API doesn't support it.")
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -780,6 +798,7 @@ impl Item {
|
||||
Item::Horizon(H::Hdr) => g.hdr,
|
||||
Item::Horizon(H::BatteryMode) => g.battery,
|
||||
Item::Horizon(H::ShadowCache) => g.shadow_cache,
|
||||
Item::Horizon(H::TranslucentBatching) => g.translucent_batching,
|
||||
Item::Vanilla(Opt::ViewBobbing) => s.view_bobbing,
|
||||
Item::Vanilla(Opt::Vsync) => s.vsync,
|
||||
Item::Vanilla(Opt::Fullscreen) => s.fullscreen,
|
||||
@@ -843,6 +862,7 @@ impl Item {
|
||||
| H::BatteryMode
|
||||
| H::ShadowCache
|
||||
| H::GraphicsApi
|
||||
| H::TranslucentBatching
|
||||
) | Item::Vanilla(
|
||||
Opt::GuiScale
|
||||
| Opt::Fullscreen
|
||||
@@ -1468,6 +1488,12 @@ impl GraphicsUi {
|
||||
&& let Some(target) = self.focus
|
||||
{
|
||||
match target {
|
||||
// A click focuses the greyed row; Enter there does nothing, as the click didn't.
|
||||
Target::Item(item @ Item::Horizon(H::TranslucentBatching))
|
||||
if item.blocked(s).is_some() =>
|
||||
{
|
||||
return (Vec::new(), true);
|
||||
}
|
||||
Target::Item(item) => {
|
||||
if item.slider(s).is_some() {
|
||||
self.can_change_value = !self.can_change_value;
|
||||
@@ -1986,6 +2012,66 @@ mod tests {
|
||||
assert_eq!(item.label(&s).plain(), "Entity Culling: Asynchronous");
|
||||
}
|
||||
|
||||
/// Translucent Batching: a switch on Advanced (not Simple), on by default, no part of a
|
||||
/// preset, and greyed out, reading Off, where the device can't batch.
|
||||
#[test]
|
||||
fn translucent_batching_is_an_advanced_switch_where_the_device_can() {
|
||||
use winit::keyboard::KeyCode as K;
|
||||
let item = Item::Horizon(H::TranslucentBatching);
|
||||
let has = |level| {
|
||||
rows(level)
|
||||
.iter()
|
||||
.any(|r| matches!(r, Row::Setting(x) if x.item == item))
|
||||
};
|
||||
if cfg!(target_os = "macos") {
|
||||
// Metal draws flat: never offered, so not shown.
|
||||
assert!(!has(Level::Advanced));
|
||||
return;
|
||||
}
|
||||
assert!(has(Level::Advanced) && !has(Level::Simple));
|
||||
let mut s = Settings::default();
|
||||
s.horizon.preset = Preset::Balanced;
|
||||
assert_eq!(item.switch(&s), Some(true));
|
||||
let mut ui = GraphicsUi::default();
|
||||
let size = (480.0, 2000.0);
|
||||
let (widgets, _, _) = ui.layout(Level::Advanced, &s, size.0, size.1);
|
||||
let w = widgets
|
||||
.iter()
|
||||
.find(|w| w.target == Target::Item(item))
|
||||
.unwrap();
|
||||
let point = (w.x + 1.0, w.y + 1.0);
|
||||
let click = |ui: &mut GraphicsUi, s: &mut Settings| {
|
||||
ui.click(Level::Advanced, s, point, size, [480, 2000], false, false)
|
||||
};
|
||||
// Where the device can't, the saved On reads Off, and neither a click nor Enter
|
||||
// after it (the click focuses the row) does anything: no sound, no change, no save.
|
||||
crate::graphics::offer_translucent_batching(false);
|
||||
assert!(item.blocked(&s).is_some());
|
||||
assert_eq!(item.label(&s).plain(), "Translucent Batching: Off");
|
||||
assert!(click(&mut ui, &mut s).is_empty());
|
||||
let enter = ui.key_modifiers(Level::Advanced, &mut s, K::Enter, false, size, [480, 2000]);
|
||||
assert!(enter.0.is_empty());
|
||||
assert!(s.horizon.translucent_batching);
|
||||
// Where it can, a click switches it and keeps the preset.
|
||||
crate::graphics::offer_translucent_batching(true);
|
||||
assert_eq!(item.label(&s).plain(), "Translucent Batching: On");
|
||||
assert_eq!(
|
||||
click(&mut ui, &mut s),
|
||||
[Action::ButtonClick, Action::Changed]
|
||||
);
|
||||
assert!(!s.horizon.translucent_batching);
|
||||
assert_eq!(item.label(&s).plain(), "Translucent Batching: Off");
|
||||
assert_eq!(s.horizon.preset, Preset::Balanced);
|
||||
// No preset sets it.
|
||||
for on in [false, true] {
|
||||
s.horizon.translucent_batching = on;
|
||||
for preset in Preset::ALL {
|
||||
crate::graphics::apply_preset(preset, &mut s);
|
||||
assert_eq!(s.horizon.translucent_batching, on, "{preset:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphics_slider_keyboard_navigation_and_selection_toggle_parity() {
|
||||
use winit::keyboard::KeyCode as K;
|
||||
@@ -2140,4 +2226,112 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Horizon's graphics captions are English sentences that the language table keys by
|
||||
/// themselves, so a caption, description or value that is not in `en_us.json` shows in
|
||||
/// English in every language. Every text the three pages show, with each setting cycled
|
||||
/// through its values (#1927).
|
||||
#[test]
|
||||
fn every_graphics_text_is_in_the_language_table() {
|
||||
let (_, missed) = lang::scoped(lang::Language::load(&[], "de_de"), || {
|
||||
for hdr in [false, true] {
|
||||
let mut s = Settings::default();
|
||||
s.horizon.hdr = hdr;
|
||||
s.horizon.taa = true;
|
||||
for level in [Level::Simple, Level::Advanced, Level::Hdr] {
|
||||
for _ in 0..8 {
|
||||
for row in extra_rows(level, &s) {
|
||||
match row {
|
||||
Row::Header(name) => {
|
||||
lang::get(name);
|
||||
}
|
||||
Row::Setting(row) => {
|
||||
row.item.label(&s);
|
||||
let text = description(row.item, &s, row.text);
|
||||
lang::get(text);
|
||||
lang::get(row.cost.label());
|
||||
if let Some(why) = row.item.blocked(&s) {
|
||||
lang::get(why);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for row in extra_rows(level, &s) {
|
||||
if let Row::Setting(Setting {
|
||||
item: Item::Horizon(h),
|
||||
..
|
||||
}) = row
|
||||
{
|
||||
h.cycle(&mut s, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for button in ["Advanced...", "Simple...", "Done"] {
|
||||
lang::get(button);
|
||||
}
|
||||
});
|
||||
// `labelled` also looks up values that are already words in the language ("An") or
|
||||
// numbers with a unit ("12 chunks", "Auto (8)"); those are not keys, and "FSR 3.1" is
|
||||
// a name.
|
||||
let missed: Vec<_> = missed
|
||||
.into_iter()
|
||||
.filter(|m| !m.starts_with(|c: char| c.is_ascii_digit()) && !m.starts_with("Auto ("))
|
||||
.filter(|m| !["An", "Aus", "FSR 3.1", "key.keyboard.unknown"].contains(&m.as_str()))
|
||||
.collect();
|
||||
assert_eq!(missed, Vec::<String>::new());
|
||||
}
|
||||
/// Leaf Culling, Frame Generation and Shadow Cache came with English captions only (#1927).
|
||||
#[test]
|
||||
fn newer_graphics_settings_are_translated() {
|
||||
let mut s = Settings::default();
|
||||
s.horizon.leaf_culling = LeafCullingMode::Hollow;
|
||||
s.horizon.entity_culling = EntityCullingMode::Asynchronous;
|
||||
for (code, leaf, entity, leaf_text) in [
|
||||
(
|
||||
"de_de",
|
||||
"Laub-Culling: Hohl",
|
||||
"Objekt-Culling: Asynchron",
|
||||
"Lässt Flächen zwischen Blättern weg: schneller, aber Lücken sind durchsichtig.",
|
||||
),
|
||||
(
|
||||
"pl_pl",
|
||||
"Pomijanie liści: Puste",
|
||||
"Pomijanie bytów: Asynchroniczne",
|
||||
"Pomija ściany między liśćmi: szybciej, ale luki są przezroczyste.",
|
||||
),
|
||||
(
|
||||
"uk_ua",
|
||||
"Відсікання листя: Порожнє",
|
||||
"Відсік. сутностей: Асинхронно",
|
||||
"Прибирає грані між листям: швидше, але прогалини прозорі.",
|
||||
),
|
||||
(
|
||||
"zh_cn",
|
||||
"树叶剔除: 镂空",
|
||||
"实体剔除: 异步",
|
||||
"省略树叶之间的面:更快,但缝隙会透视。",
|
||||
),
|
||||
(
|
||||
"fr_fr",
|
||||
"Occlusion feuillage: Creux",
|
||||
"Occlusion des entités: Asynchrone",
|
||||
"Supprime les faces entre feuilles : plus rapide, mais on voit à travers les trous.",
|
||||
),
|
||||
] {
|
||||
let (shown, _) = lang::scoped(lang::Language::load(&[], code), || {
|
||||
let item = |h| Item::Horizon(h);
|
||||
(
|
||||
item(H::LeafCulling).label(&s).plain(),
|
||||
item(H::EntityCulling).label(&s).plain(),
|
||||
lang::get(description(item(H::LeafCulling), &s, "")),
|
||||
)
|
||||
});
|
||||
assert_eq!(
|
||||
shown,
|
||||
(leaf.into(), entity.into(), leaf_text.into()),
|
||||
"{code}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,12 +20,11 @@ struct Params {
|
||||
black: f32,
|
||||
// The HUD and menus' brightness as a share of paper white.
|
||||
ui: f32,
|
||||
// HDR lighting: what the strongest light multiplies a surface by, 1 for none.
|
||||
scene: f32,
|
||||
// Per tier, low to very bright (hdr.rs `Tier`): the share of the headroom
|
||||
// the brightest pixels get, and the brightness where the lift starts and
|
||||
// where it is full.
|
||||
// its fully lifted pixels get.
|
||||
share: vec4<f32>,
|
||||
start: vec4<f32>,
|
||||
full: vec4<f32>,
|
||||
// HDR calibration (hdr_calibration.rs): x its step (0 none, 1 black level,
|
||||
// 2 peak, 3 paper white), y the slider in nits, z where its controls start,
|
||||
// a share of the height.
|
||||
@@ -40,9 +39,11 @@ struct Params {
|
||||
// near-white spots (glowstone) and leaves the rest (lava, a jack o'lantern's
|
||||
// face) nearly as it was. Each tier's pixels brighter than its `full` get its
|
||||
// `share` of the headroom, dimmer ones less down to none at its `start`
|
||||
// (lava's crust, a lantern's metal), and no channel goes past the peak.
|
||||
// Brightness is the mean of luminance and the brightest channel, so saturated
|
||||
// light counts as bright.
|
||||
// (lava's crust, a lantern's metal), and no channel goes past the peak. That
|
||||
// brightness is the texel's own, before lighting: the shaders that mark the
|
||||
// frame (hdr_mark) work it out, so a light source shines the same at any light
|
||||
// level and on every face. Brightness is the mean of luminance and the
|
||||
// brightest channel, so saturated light counts as bright.
|
||||
|
||||
// Whether `c` still shows the world pixel `w`: the same, or darkened evenly
|
||||
// across its channels, as the vignette and other screen-wide tints darken it.
|
||||
@@ -54,7 +55,7 @@ fn shows_world(c: vec3<f32>, w: vec3<f32>) -> bool {
|
||||
return true;
|
||||
}
|
||||
let f = (c.r + c.g + c.b) / max(w.r + w.g + w.b, 1.0 / 255.0);
|
||||
return f <= 1.01 && f > 0.5 && all(abs(c - f * w) <= 0.03 * w + 1.0 / 255.0);
|
||||
return f <= 1.01 && f > 0.02 && all(abs(c - f * w) <= 0.03 * w + 1.0 / 255.0);
|
||||
}
|
||||
|
||||
// Linear BT.709 to BT.2020 (ITU-R BT.2087), columns first.
|
||||
@@ -157,24 +158,38 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
|
||||
// 1/255 (Hdr::mark_hand: drawn at the world's brightness), else the HUD or a
|
||||
// menu, by how much.
|
||||
let covered = f.a > 0.0;
|
||||
let ui = select(f.a, 0.0, f.a < 1.5 / 255.0);
|
||||
let hand = covered && f.a < 1.5 / 255.0;
|
||||
let ui = select(f.a, 0.0, hand);
|
||||
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
|
||||
var light = pow(max(c, vec3(0.0)), vec3(2.2));
|
||||
let w = textureLoad(world, at, 0);
|
||||
// The world's alpha is below 0.75 on light sources, the sun and the moon
|
||||
// (terrain.wgsl, the celestial pipeline): only those shine, by the tier it
|
||||
// holds, the lower the brighter, and only where nothing was drawn over them
|
||||
// since: grey text over the white sun still looks like the sun darkened, but
|
||||
// it raised the frame's alpha.
|
||||
// (terrain.wgsl, entity.wgsl): only those shine, and only where nothing was
|
||||
// drawn over them since: grey text over the white sun still looks like the sun
|
||||
// darkened, but it raised the frame's alpha. Its band of 0.175 is the tier, the
|
||||
// lower the brighter, and where in the band how much (hdr_mark). The hand
|
||||
// writes its own alpha there (Hdr::hand_light_view): 1, or a light source it holds.
|
||||
var lifted = false;
|
||||
if params.world > 0.5 && params.peak > 1.0 && w.a < 0.75 && !covered
|
||||
&& shows_world(c, w.rgb) {
|
||||
// Brightness of the world pixel, before the vignette darkens the frame
|
||||
// toward its edges: a light source shines the same anywhere on screen.
|
||||
let wl = pow(max(w.rgb, vec3(0.0)), vec3(2.2));
|
||||
let y = 0.5 * (dot(wl, vec3(0.2126, 0.7152, 0.0722)) + max(wl.r, max(wl.g, wl.b)));
|
||||
let tier = u32(clamp(round(4.0 - 4.0 * w.a / 0.7), 1.0, 4.0)) - 1u;
|
||||
let lift = smoothstep(params.start[tier], params.full[tier], y);
|
||||
let shows = params.world > 0.5 && params.peak > 1.0
|
||||
&& (hand || (!covered && shows_world(c, w.rgb)));
|
||||
// HDR lighting: alpha between 0.76 and 1 is how much of it the surface gets. Vanilla's
|
||||
// light is albedo times lightmap, so this is the lightmap going past 1. It eases into the
|
||||
// peak from 60% of it, so bright textures keep their detail.
|
||||
if shows && w.a >= 0.75 {
|
||||
let top = max(light.r, max(light.g, light.b));
|
||||
let gain = 1.0 + (params.scene - 1.0) * (1.0 - w.a) / 0.24;
|
||||
let v = top * gain;
|
||||
let knee = max(0.6 * params.peak, 1.0);
|
||||
var out = v;
|
||||
if v > knee && params.peak > knee {
|
||||
out = knee + (params.peak - knee) * (1.0 - exp(-(v - knee) / (params.peak - knee)));
|
||||
}
|
||||
light *= select(gain, out / max(top, 1e-4), v > knee);
|
||||
}
|
||||
if shows && w.a < 0.75 {
|
||||
let band = u32(clamp(floor((w.a + 0.005) / 0.175), 0.0, 3.0));
|
||||
let tier = 3u - band;
|
||||
let lift = 1.0 - clamp((w.a - 0.175 * f32(band)) / 0.165, 0.0, 1.0);
|
||||
if lift > 0.0 {
|
||||
let top = max(light.r, max(light.g, light.b));
|
||||
let gain = min(1.0 + (params.peak - 1.0) * params.share[tier] * lift, params.peak / max(top, 1e-4));
|
||||
|
||||
@@ -49,7 +49,7 @@ pub struct Calibration {
|
||||
values: [f32; 3],
|
||||
/// Which values were Auto (peak, paper white): saved as Auto again unless the
|
||||
/// player moved them.
|
||||
auto: [Option<f32>; 2],
|
||||
auto: [Option<f32>; 3],
|
||||
dragging: bool,
|
||||
}
|
||||
|
||||
@@ -125,11 +125,12 @@ impl Calibration {
|
||||
Self {
|
||||
step: 0,
|
||||
values: [
|
||||
g.hdr_black_level as f32 / 100.0,
|
||||
g.black_level_nits(),
|
||||
g.peak_nits() as f32,
|
||||
g.paper_white_nits() as f32,
|
||||
],
|
||||
auto: [
|
||||
(g.hdr_black_level == 0).then_some(g.black_level_nits()),
|
||||
(g.hdr_peak == 0).then_some(g.peak_nits() as f32),
|
||||
(g.hdr_paper_white == 0).then_some(g.paper_white_nits() as f32),
|
||||
],
|
||||
@@ -146,15 +147,11 @@ impl Calibration {
|
||||
fn label(&self) -> Text {
|
||||
let v = self.values[self.step];
|
||||
let step = self.current();
|
||||
let nits = match step {
|
||||
let value = match step {
|
||||
Step::Black => format!("{v:.2}"),
|
||||
_ => format!("{v:.0}"),
|
||||
};
|
||||
Text::literal(format!(
|
||||
"{}: {}",
|
||||
step.name(),
|
||||
lang::tr("%s nits", &[&nits])
|
||||
))
|
||||
Text::literal(format!("{}: {}", step.name(), value))
|
||||
}
|
||||
/// The step, its nits and where the controls start (a share of the height), for
|
||||
/// HDR output's test picture.
|
||||
@@ -284,11 +281,12 @@ impl Calibration {
|
||||
/// Writes what the player landed on into Horizon's settings.
|
||||
pub fn save(&self, s: &mut Settings) {
|
||||
let g = &mut s.horizon;
|
||||
g.hdr_black_level = (self.values[0] * 100.0).round() as u32;
|
||||
let [peak, paper_white] = [1, 2].map(|i| match self.auto[i - 1] {
|
||||
let scale = [100.0, 1.0, 1.0];
|
||||
let [black, peak, paper_white] = [0, 1, 2].map(|i| match self.auto[i] {
|
||||
Some(auto) if auto == self.values[i] => 0,
|
||||
_ => self.values[i].round() as u32,
|
||||
_ => (self.values[i] * scale[i]).round() as u32,
|
||||
});
|
||||
g.hdr_black_level = black;
|
||||
g.hdr_peak = peak;
|
||||
g.hdr_paper_white = paper_white;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use std::{
|
||||
};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
pub mod cit_glint;
|
||||
mod pack_text;
|
||||
|
||||
/// A sprite's atlas region, and its size in GUI pixels: the size of the
|
||||
@@ -85,6 +86,10 @@ pub struct HudAtlas {
|
||||
pub text_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
|
||||
/// The packs' OptiFine random entity textures, whose variants this atlas packs.
|
||||
pub random_textures: horizon_optifine::random_textures::RandomTextures,
|
||||
/// Their emissive overlays (`_e`), packed here too.
|
||||
pub emissive: std::sync::Arc<horizon_optifine::emissive::Emissive>,
|
||||
/// ETF's blinking frames (`_blink`), packed here too.
|
||||
pub blinks: horizon_optifine::blink::Blinks,
|
||||
white: Rect,
|
||||
}
|
||||
/// Atlas rows reserved for glyphs rasterized at run time.
|
||||
@@ -137,6 +142,34 @@ pub fn decode_bytes(data: &[u8]) -> Result<(u32, u32, Vec<u8>)> {
|
||||
}
|
||||
Ok((info.width, info.height, rgba))
|
||||
}
|
||||
/// The most texels of OptiFine random entity variants the entity atlas packs: 2048 rows of
|
||||
/// its 4096, which keeps the default atlas (4096x5120) under the 8192 rows many GPUs take.
|
||||
const RANDOM_TEXELS: u64 = 4096 * 2048;
|
||||
/// The variant images (and `overlays`) the atlas packs: every variant before any overlay, by
|
||||
/// name, while their sizes add up to at most `budget` texels. A full budget drops glows before
|
||||
/// looks.
|
||||
fn budgeted<'a>(
|
||||
files: impl Iterator<Item = &'a PathBuf>,
|
||||
overlays: &HashSet<PathBuf>,
|
||||
budget: u64,
|
||||
) -> HashSet<PathBuf> {
|
||||
let mut files: Vec<&PathBuf> = files.collect();
|
||||
files.sort_by_key(|p| (overlays.contains(*p), *p));
|
||||
within_budget(&files, budget)
|
||||
}
|
||||
/// The `files` (in order) whose sizes add up to at most `budget` texels.
|
||||
fn within_budget(files: &[&PathBuf], budget: u64) -> HashSet<PathBuf> {
|
||||
let mut texels = 0u64;
|
||||
files
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
let (w, h) = png_size(p).unwrap_or((0, 0));
|
||||
texels += u64::from(w) * u64::from(h);
|
||||
texels <= budget
|
||||
})
|
||||
.map(|p| (*p).clone())
|
||||
.collect()
|
||||
}
|
||||
/// A PNG's width and height, from its header alone.
|
||||
fn png_size(path: &Path) -> Option<(u32, u32)> {
|
||||
use std::io::Read;
|
||||
@@ -215,6 +248,126 @@ fn wrap_border((name, w, h, data): &PackedImage) -> PackedImage {
|
||||
}
|
||||
(name.clone(), bw, bh, out)
|
||||
}
|
||||
/// The textures the enabled packs' Custom Item Textures rules draw, beyond those already packed:
|
||||
/// item rules' textures and their JSON models', armour and elytra rules' textures, under their
|
||||
/// `cit_assets` ids, with their flags and whether they animate. An animated strip keeps its
|
||||
/// first frame; a still texture is packed whole. None without `optifine/cit` rules.
|
||||
fn cit_sprites(
|
||||
root: &Path,
|
||||
packed: &[PackedImage],
|
||||
) -> Vec<(PackedImage, Option<TextureFlags>, bool)> {
|
||||
let have: HashSet<&str> = packed.iter().map(|i| i.0.as_str()).collect();
|
||||
let rules = crate::cit::load_rules(root);
|
||||
let mut ids = crate::cit_assets::item_sprites(&rules);
|
||||
if !rules.is_empty() {
|
||||
let models = crate::cit_assets::load_models(root, &rules);
|
||||
ids.extend(crate::cit_assets::model_sprites(&models));
|
||||
ids.extend(crate::cit_armor::armor_sprites(&rules));
|
||||
}
|
||||
let ids: Vec<String> = ids
|
||||
.into_iter()
|
||||
// Vanilla's sprites are packed without their namespace, as `sprite_name` looks them up;
|
||||
// Horizon's own (`horizon/white`, the run-time slots) are added after, and stay its own.
|
||||
.filter(|id| {
|
||||
let name = id.strip_prefix("minecraft:").unwrap_or(id);
|
||||
!have.contains(name) && !name.starts_with("horizon/")
|
||||
})
|
||||
.collect();
|
||||
// No more than the atlas could ever hold, by their headers, so a pack naming thousands of
|
||||
// big textures is cut short before they are decoded, not after. Each is charged what it
|
||||
// can be packed as (a strip's frame, or a still image whole), 1024x512 at most since
|
||||
// `cit_sprite` refuses more, with a gutter;
|
||||
// the glyph rows and the shelves' waste (a tenth) are kept aside.
|
||||
let side = u64::from(MAX_ATLAS_SIZE);
|
||||
let used: u64 = packed
|
||||
.iter()
|
||||
.map(|i| u64::from(i.1 + 1) * u64::from(i.2 + 1))
|
||||
.sum();
|
||||
let mut left = (side * side * 9 / 10).saturating_sub(side * u64::from(GLYPH_ROWS) + used);
|
||||
let before = ids.len();
|
||||
let ids: Vec<String> = ids
|
||||
.into_iter()
|
||||
.filter(|id| {
|
||||
let path = crate::atlas_sources::texture_path(root, id, "textures");
|
||||
let Some((w, h)) = png_size(&path) else {
|
||||
return true;
|
||||
};
|
||||
let (w, h) = (u64::from(w), u64::from(h));
|
||||
if w * h > 1024 * 512 * 64 {
|
||||
// Refused by `cit_sprite` without decoding: costs nothing.
|
||||
return true;
|
||||
}
|
||||
// A strip's frame is at most its width squared; a still image is all of it.
|
||||
let frame = (w * h).min(w * w).min(1024 * 512) + w + h + 1;
|
||||
match left.checked_sub(frame) {
|
||||
Some(rest) => {
|
||||
left = rest;
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if ids.len() < before {
|
||||
log::warn!(
|
||||
"left out {} custom item textures: more than the atlas holds",
|
||||
before - ids.len()
|
||||
);
|
||||
}
|
||||
// Decoded on the threads `pack` decodes the rest on: a big pack names thousands.
|
||||
let threads = std::thread::available_parallelism().map_or(1, |n| n.get());
|
||||
let chunk = ids.len().div_ceil(threads).max(1);
|
||||
std::thread::scope(|scope| {
|
||||
let workers: Vec<_> = ids
|
||||
.chunks(chunk)
|
||||
.map(|ids| scope.spawn(|| ids.iter().filter_map(|id| cit_sprite(root, id)).collect()))
|
||||
.collect();
|
||||
workers
|
||||
.into_iter()
|
||||
.flat_map(|w| {
|
||||
w.join().unwrap_or_else(|_| {
|
||||
log::warn!("a custom item texture decoder panicked; its textures are left out");
|
||||
Vec::new()
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
/// One of `cit_sprites`, or `None` (with a warning) when it does not load or is too big.
|
||||
fn cit_sprite(root: &Path, id: &str) -> Option<(PackedImage, Option<TextureFlags>, bool)> {
|
||||
let name = id.strip_prefix("minecraft:").unwrap_or(id);
|
||||
let path = crate::atlas_sources::texture_path(root, id, "textures");
|
||||
// The limit `pack` keeps every other texture to, for a frame; and from the header first,
|
||||
// so an image bigger than 64 such frames is never decoded.
|
||||
let fits = |w: u32, h: u32| w <= 1024 && h <= 512;
|
||||
if let Some((w, h)) = png_size(&path)
|
||||
&& u64::from(w) * u64::from(h) > 1024 * 512 * 64
|
||||
{
|
||||
log::warn!("skipped custom item texture {}: {w}x{h}", path.display());
|
||||
return None;
|
||||
}
|
||||
let image = match decode(&path) {
|
||||
Ok(image) => image,
|
||||
Err(e) => {
|
||||
log::warn!("skipped custom item texture {}: {e}", path.display());
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let mcmeta = fs::read_to_string(path.with_extension("png.mcmeta")).ok();
|
||||
let animation = animation_of(mcmeta.as_deref());
|
||||
// Only an animation is cut to its first frame: a still CIT texture is drawn whole, and
|
||||
// armour layers are twice as wide as they are tall.
|
||||
let (w, h, pixels) = match &animation {
|
||||
Some(animation) => first_frame(image, Some(animation)),
|
||||
None => image,
|
||||
};
|
||||
if !fits(w, h) {
|
||||
log::warn!("skipped custom item texture {}: {w}x{h}", path.display());
|
||||
return None;
|
||||
}
|
||||
let flags = mcmeta.as_deref().and_then(TextureFlags::from_mcmeta);
|
||||
Some(((name.to_owned(), w, h, pixels), flags, animation.is_some()))
|
||||
}
|
||||
/// Folders of `assets/minecraft/textures/` that [`HudAtlas::pack`] walks for
|
||||
/// the full atlas. A pack may add others through `atlases/blocks.json`.
|
||||
const WALKED_TEXTURE_FOLDERS: &[&str] = &[
|
||||
@@ -490,6 +643,27 @@ impl HudAtlas {
|
||||
white,
|
||||
text_shader: None,
|
||||
random_textures: Default::default(),
|
||||
emissive: Default::default(),
|
||||
blinks: Default::default(),
|
||||
}
|
||||
}
|
||||
/// `layout`, but when `extras` (Custom Item Textures) make the images more than `max`
|
||||
/// holds, without them: they are left out, not the packs.
|
||||
fn layout_leaving_out(
|
||||
images: &mut Vec<PackedImage>,
|
||||
animated: &mut Vec<String>,
|
||||
extras: &HashSet<String>,
|
||||
max: u32,
|
||||
) -> Result<(u32, Vec<(u32, u32)>, u32)> {
|
||||
match Self::layout(images, max) {
|
||||
Err(e) if !extras.is_empty() => {
|
||||
images.retain(|i| !extras.contains(&i.0));
|
||||
animated.retain(|n| !extras.contains(n));
|
||||
let layout = Self::layout(images, max)?;
|
||||
log::warn!("left out the custom item textures: {e}");
|
||||
Ok(layout)
|
||||
}
|
||||
layout => layout,
|
||||
}
|
||||
}
|
||||
/// Shelf placements of `images` (tallest first) in the narrowest atlas
|
||||
@@ -498,25 +672,21 @@ impl HudAtlas {
|
||||
/// packs fill a 4096-wide atlas past what GPUs allow (Faithful 64x needs
|
||||
/// 32768 rows); vanilla's stitcher fails the reload when its atlases don't
|
||||
/// fit, and the packs are then turned off (`Minecraft.clearResourcePacksOnError`).
|
||||
/// A width where the plain shelves (each image after the last) leave too
|
||||
/// much unused is tried again with each image in the first shelf with
|
||||
/// room: a pack of thousands of mixed sizes (server packs with custom
|
||||
/// items) fits where the shelves' ragged ends would not.
|
||||
fn layout(images: &[PackedImage], max: u32) -> Result<(u32, Vec<(u32, u32)>, u32)> {
|
||||
let mut width = 4096.min(max);
|
||||
loop {
|
||||
let mut placements = Vec::with_capacity(images.len());
|
||||
let (mut x, mut y, mut row) = (0u32, 0u32, 0u32);
|
||||
for (_, w, h, _) in images {
|
||||
if x + w + 1 > width {
|
||||
x = 0;
|
||||
y += row + 1;
|
||||
row = 0;
|
||||
}
|
||||
placements.push((x, y));
|
||||
x += w + 1;
|
||||
row = row.max(*h);
|
||||
}
|
||||
let glyph_region = y + row + 1;
|
||||
let wide_enough = images.iter().all(|i| i.1 < width);
|
||||
if wide_enough && glyph_region + GLYPH_ROWS <= max {
|
||||
return Ok((width, placements, glyph_region));
|
||||
if wide_enough {
|
||||
for first_fit in [false, true] {
|
||||
let (placements, glyph_region) = Self::shelves(images, width, first_fit);
|
||||
if glyph_region + GLYPH_ROWS <= max {
|
||||
return Ok((width, placements, glyph_region));
|
||||
}
|
||||
}
|
||||
}
|
||||
if width >= max {
|
||||
let texels: u64 = images.iter().map(|i| u64::from(i.1) * u64::from(i.2)).sum();
|
||||
@@ -529,6 +699,33 @@ impl HudAtlas {
|
||||
width = (width * 2).min(max);
|
||||
}
|
||||
}
|
||||
/// `layout`'s shelves at one width: the placements and the first row below
|
||||
/// them. `first_fit` puts an image in the first shelf that is tall enough
|
||||
/// and has room, instead of only the last one.
|
||||
fn shelves(images: &[PackedImage], width: u32, first_fit: bool) -> (Vec<(u32, u32)>, u32) {
|
||||
// Each shelf: its top row, its height (its first image's) and the next free column.
|
||||
let mut shelves: Vec<(u32, u32, u32)> = Vec::new();
|
||||
let mut placements = Vec::with_capacity(images.len());
|
||||
for (_, w, h, _) in images {
|
||||
let open = if first_fit {
|
||||
shelves.iter().position(|s| s.1 >= *h && s.2 + w < width)
|
||||
} else {
|
||||
shelves
|
||||
.len()
|
||||
.checked_sub(1)
|
||||
.filter(|&i| shelves[i].1 >= *h && shelves[i].2 + w < width)
|
||||
};
|
||||
let i = open.unwrap_or_else(|| {
|
||||
let top = shelves.last().map_or(0, |s| s.0 + s.1 + 1);
|
||||
shelves.push((top, *h, 0));
|
||||
shelves.len() - 1
|
||||
});
|
||||
placements.push((shelves[i].2, shelves[i].0));
|
||||
shelves[i].2 += w + 1;
|
||||
}
|
||||
let glyph_region = shelves.last().map_or(1, |s| s.0 + s.1 + 1);
|
||||
(placements, glyph_region)
|
||||
}
|
||||
/// Pack the textures under these folders of `textures/`, and for the
|
||||
/// `full` atlas the generated sprites and the run-time slots too.
|
||||
fn pack(root: &Path, defaults: &Path, folders: &[&str], full: bool) -> Result<Self> {
|
||||
@@ -585,15 +782,66 @@ impl HudAtlas {
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
let blinks = if folders.contains(&"entity") {
|
||||
horizon_optifine::blink::Blinks::scan(root, &random_textures)
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
let emissive = std::sync::Arc::new(if folders.contains(&"entity") {
|
||||
let mut e = horizon_optifine::emissive::Emissive::scan(root, &random_textures);
|
||||
e.pair_beside(root, blinks.files());
|
||||
e
|
||||
} else {
|
||||
Default::default()
|
||||
});
|
||||
let mut named: HashMap<PathBuf, String> = HashMap::new();
|
||||
for (file, name) in random_textures.files() {
|
||||
let extra = random_textures
|
||||
.files()
|
||||
.chain(emissive.files())
|
||||
.chain(blinks.files());
|
||||
for (file, name) in extra {
|
||||
named.insert(root.join(file), name.to_owned());
|
||||
}
|
||||
if !named.is_empty() {
|
||||
// A pack's variants can't grow the shared atlas past what a GPU takes: every variant
|
||||
// (those beside their texture too) counts, in name order, and those past the budget
|
||||
// stay out (their mobs wear the texture itself), not the whole pack.
|
||||
// Overlays and blink frames only after every variant.
|
||||
let overlays: HashSet<PathBuf> = emissive
|
||||
.files()
|
||||
.chain(blinks.files())
|
||||
.map(|(f, _)| root.join(f))
|
||||
.collect();
|
||||
let kept = budgeted(named.keys(), &overlays, RANDOM_TEXELS);
|
||||
if kept.len() < named.len() {
|
||||
log::warn!(
|
||||
"{} random entity textures left out of the atlas: past its budget",
|
||||
named.len() - kept.len()
|
||||
);
|
||||
}
|
||||
// A dropped variant beside its texture takes its walked overlay and blink frames
|
||||
// (and theirs) with it: nothing would draw them.
|
||||
let png = format!("{}.png", emissive.suffix);
|
||||
let png = png.as_str();
|
||||
let orphans: HashSet<PathBuf> = named
|
||||
.keys()
|
||||
.filter(|p| !kept.contains(*p))
|
||||
.filter_map(|p| p.to_str()?.strip_suffix(".png").map(str::to_owned))
|
||||
.flat_map(|stem| {
|
||||
["", "_blink", "_blink2"]
|
||||
.into_iter()
|
||||
.flat_map(move |frame| {
|
||||
[format!("{stem}{frame}.png"), format!("{stem}{frame}{png}")]
|
||||
})
|
||||
})
|
||||
.map(PathBuf::from)
|
||||
.filter(|p| !named.contains_key(p))
|
||||
.collect();
|
||||
files.retain(|p| (!named.contains_key(p) || kept.contains(p)) && !orphans.contains(p));
|
||||
// Those beside their texture are walked already.
|
||||
let walked: HashSet<&PathBuf> = files.iter().collect();
|
||||
let mut extra: Vec<PathBuf> = named
|
||||
.keys()
|
||||
let mut extra: Vec<PathBuf> = kept
|
||||
.iter()
|
||||
.filter(|p| !walked.contains(p))
|
||||
.cloned()
|
||||
.collect();
|
||||
@@ -682,10 +930,21 @@ impl HudAtlas {
|
||||
}
|
||||
images.push(image);
|
||||
}
|
||||
let mut cit_names = HashSet::new();
|
||||
if full {
|
||||
let (more, more_animated) = atlas_sprites(root, &images);
|
||||
images.extend(more);
|
||||
animated.extend(more_animated);
|
||||
for (image, image_flags, is_animated) in cit_sprites(root, &images) {
|
||||
cit_names.insert(image.0.clone());
|
||||
if let Some(f) = image_flags {
|
||||
flags.insert(image.0.clone(), f);
|
||||
}
|
||||
if is_animated {
|
||||
animated.push(image.0.clone());
|
||||
}
|
||||
images.push(image);
|
||||
}
|
||||
}
|
||||
// The title screen's "JAVA EDITION" banner reads "RUST EDITION".
|
||||
if let Some(edition) = images
|
||||
@@ -720,7 +979,8 @@ impl HudAtlas {
|
||||
}
|
||||
}
|
||||
images.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1)));
|
||||
let (width, placements, glyph_region) = Self::layout(&images, MAX_ATLAS_SIZE)?;
|
||||
let (width, placements, glyph_region) =
|
||||
Self::layout_leaving_out(&mut images, &mut animated, &cit_names, MAX_ATLAS_SIZE)?;
|
||||
// A multiple of 1024 rather than a power of two: the default resources
|
||||
// need 4096x5120 (8192 rows before), Faithful 32x 4096x12288 (16384).
|
||||
let height = (glyph_region + GLYPH_ROWS).next_multiple_of(1024);
|
||||
@@ -815,6 +1075,8 @@ impl HudAtlas {
|
||||
glint_armor,
|
||||
text_shader: None,
|
||||
random_textures,
|
||||
emissive,
|
||||
blinks,
|
||||
font,
|
||||
white,
|
||||
})
|
||||
@@ -924,6 +1186,9 @@ pub enum Blend {
|
||||
/// `RenderPipelines.END_PORTAL` as a GUI fill: the End portal's layers
|
||||
/// projected from the screen (`HudBatch::end_portal` sets the textures).
|
||||
EndPortal,
|
||||
/// An OptiFine CIT glint layer (`cit_glint`), blended as entry `n` of
|
||||
/// `GlintTextures::blends` says.
|
||||
CitGlint(u8),
|
||||
}
|
||||
/// What a `Blend::EndPortal` fill samples: the atlas rects (u0, v0, width,
|
||||
/// height) of `end_sky.png` and `end_portal.png`, and the `GameTime` uniform.
|
||||
@@ -1368,7 +1633,7 @@ impl HudBatch {
|
||||
right: 0.0,
|
||||
bottom: 8.0,
|
||||
advance: 8.0,
|
||||
offset: 0.0,
|
||||
offset: 1.0,
|
||||
}
|
||||
} else {
|
||||
font.glyph_styled(ch, style)
|
||||
@@ -1430,12 +1695,6 @@ impl HudBatch {
|
||||
object,
|
||||
) in &glyphs
|
||||
{
|
||||
if let Some(object) = object {
|
||||
if !is_shadow {
|
||||
self.draw_inline_object(atlas, object, *gx, y, alpha);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let shadow_color = if let Some(argb) = custom_shadow {
|
||||
let text_alpha = (c >> 24) as u8 as u16;
|
||||
let shadow_alpha = (argb >> 24) as u8 as u16;
|
||||
@@ -1456,6 +1715,18 @@ impl HudBatch {
|
||||
(0.0, *c)
|
||||
};
|
||||
let (px, py) = (*gx + dx, y + dx);
|
||||
if let Some(object) = object {
|
||||
// `PlainTextRenderable.render`: sprite glyphs use the same
|
||||
// shadow colour and offset as text, with the front at SHADOW_DEPTH.
|
||||
let start = self.vertices.len();
|
||||
self.draw_inline_object(atlas, object, px, py - 1.0, col);
|
||||
if world && has_shadow && !is_shadow {
|
||||
for v in &mut self.vertices[start..] {
|
||||
v.depth = 0.03;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let shear = |v: f32| if *italic { 1.0 - 0.25 * v } else { 0.0 };
|
||||
let thick = if *bold { 0.1 } else { 0.0 };
|
||||
let draw = |batch: &mut Self, ox: f32| match g.rect {
|
||||
@@ -1533,10 +1804,9 @@ impl HudBatch {
|
||||
object: &crate::text::InlineObject,
|
||||
x: f32,
|
||||
y: f32,
|
||||
alpha: u8,
|
||||
tint: u32,
|
||||
) {
|
||||
use crate::text::InlineObject;
|
||||
let tint = rgba(255, 255, 255, alpha);
|
||||
match object {
|
||||
InlineObject::Player { profile, hat, .. } => {
|
||||
let id = crate::skins::parse_profile(profile).id;
|
||||
@@ -1653,7 +1923,11 @@ pub struct HudRenderer {
|
||||
depth_pipeline: wgpu::RenderPipeline,
|
||||
vignette_pipeline: wgpu::RenderPipeline,
|
||||
end_portal_pipeline: wgpu::RenderPipeline,
|
||||
/// The layout of `group`, which the CIT glint pipelines share.
|
||||
layout: wgpu::BindGroupLayout,
|
||||
group: wgpu::BindGroup,
|
||||
/// The pack's CIT enchantment glints, when it has any.
|
||||
cit_glint: Option<cit_glint::CitGlintPass>,
|
||||
uniform: wgpu::Buffer,
|
||||
buffer: wgpu::Buffer,
|
||||
capacity: u64,
|
||||
@@ -1886,7 +2160,9 @@ impl HudRenderer {
|
||||
depth_pipeline,
|
||||
vignette_pipeline,
|
||||
end_portal_pipeline,
|
||||
layout,
|
||||
group,
|
||||
cit_glint: None,
|
||||
uniform,
|
||||
buffer,
|
||||
capacity,
|
||||
@@ -1896,6 +2172,18 @@ impl HudRenderer {
|
||||
zoom: 1.0,
|
||||
})
|
||||
}
|
||||
/// Draw the CIT glint layers of `textures` (the pack's enchantment rules); `None` or no
|
||||
/// textures draws none.
|
||||
pub fn set_cit_glints(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
textures: Option<&cit_glint::GlintTextures>,
|
||||
) {
|
||||
self.cit_glint = textures.and_then(|t| {
|
||||
cit_glint::CitGlintPass::new(device, queue, self.format, &self.layout, t)
|
||||
});
|
||||
}
|
||||
/// Draw the GUI's text with `shader` (`HudAtlas::text_shader`), or natively.
|
||||
pub fn set_text_shader(
|
||||
&mut self,
|
||||
@@ -2131,6 +2419,12 @@ impl HudRenderer {
|
||||
Blend::Depth(_) => &self.depth_pipeline,
|
||||
Blend::Vignette => &self.vignette_pipeline,
|
||||
Blend::EndPortal => &self.end_portal_pipeline,
|
||||
Blend::CitGlint(blend) => {
|
||||
if let Some(cit) = &self.cit_glint {
|
||||
cit.draw(&mut pass, *blend, range.clone());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match &self.pack_text {
|
||||
Some(pack_text) if *blend == Blend::Alpha => {
|
||||
@@ -2320,6 +2614,106 @@ mod tests {
|
||||
assert!(!atlas.sprites.contains_key("gems:item/ruby"));
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
/// OptiFine random entity variants outside `textures/` pack under the names
|
||||
/// `RandomTextures` gives them; one beside its texture packs once, under its own.
|
||||
#[test]
|
||||
fn random_entity_variants_pack_under_their_scan_names() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-atlas-random-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let random = root.join("assets/minecraft/optifine/random/entity/pig");
|
||||
fs::create_dir_all(&random).unwrap();
|
||||
write_strip(&random.join("pig2.png"), 64, 64, &[[0, 200, 0, 255]], None);
|
||||
let beside = root.join("assets/minecraft/textures/entity/cat");
|
||||
fs::create_dir_all(&beside).unwrap();
|
||||
fs::write(beside.join("cat.properties"), "textures.1=2").unwrap();
|
||||
write_strip(&beside.join("cat.png"), 64, 32, &[[1, 1, 1, 255]], None);
|
||||
write_strip(&beside.join("cat2.png"), 64, 32, &[[0, 0, 200, 255]], None);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let rect = atlas.sprites["minecraft:optifine/random/entity/pig/pig2"];
|
||||
assert_eq!(texel_at(&atlas, rect, 0, 0), [0, 200, 0, 255]);
|
||||
let rect = atlas.sprites["entity/cat/cat2"];
|
||||
assert_eq!(texel_at(&atlas, rect, 0, 0), [0, 0, 200, 255]);
|
||||
assert!(atlas.random_textures.get("entity/pig/pig").is_some());
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
/// A random variant's emissive overlay packs beside the variant, under its name.
|
||||
#[test]
|
||||
fn variant_overlays_pack_under_their_names() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-atlas-overlay-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let random = root.join("assets/minecraft/optifine/random/entity/zombie");
|
||||
fs::create_dir_all(&random).unwrap();
|
||||
write_strip(
|
||||
&random.join("zombie3.png"),
|
||||
64,
|
||||
64,
|
||||
&[[0, 200, 0, 255]],
|
||||
None,
|
||||
);
|
||||
write_strip(
|
||||
&random.join("zombie3_e.png"),
|
||||
64,
|
||||
64,
|
||||
&[[200, 0, 0, 255]],
|
||||
None,
|
||||
);
|
||||
// A variant's blink frame, with its own overlay.
|
||||
write_strip(
|
||||
&random.join("zombie3_blink.png"),
|
||||
64,
|
||||
64,
|
||||
&[[0, 0, 200, 255]],
|
||||
None,
|
||||
);
|
||||
write_strip(
|
||||
&random.join("zombie3_blink_e.png"),
|
||||
64,
|
||||
64,
|
||||
&[[9, 9, 9, 255]],
|
||||
None,
|
||||
);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let rect = atlas.sprites["minecraft:optifine/random/entity/zombie/zombie3_blink_e"];
|
||||
assert_eq!(texel_at(&atlas, rect, 0, 0), [9, 9, 9, 255]);
|
||||
let rect = atlas.sprites["minecraft:optifine/random/entity/zombie/zombie3_e"];
|
||||
assert_eq!(texel_at(&atlas, rect, 0, 0), [200, 0, 0, 255]);
|
||||
assert_eq!(
|
||||
atlas
|
||||
.emissive
|
||||
.overlay("minecraft:optifine/random/entity/zombie/zombie3"),
|
||||
Some("minecraft:optifine/random/entity/zombie/zombie3_e")
|
||||
);
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
/// Variants past the budget, in name order, stay out of the atlas.
|
||||
#[test]
|
||||
fn random_variants_stop_at_their_budget() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-atlas-budget-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let files: Vec<PathBuf> = ["a2.png", "a3.png", "a4.png"]
|
||||
.iter()
|
||||
.map(|n| dir.join(n))
|
||||
.collect();
|
||||
for f in &files {
|
||||
write_strip(f, 16, 16, &[[1, 2, 3, 255]], None);
|
||||
}
|
||||
let refs: Vec<&PathBuf> = files.iter().collect();
|
||||
// 256 texels each: 600 holds two.
|
||||
let kept = within_budget(&refs, 600);
|
||||
assert_eq!(kept, files[..2].iter().cloned().collect());
|
||||
assert_eq!(within_budget(&refs, 768).len(), 3);
|
||||
// a2's overlay sorts before a3 by name, but every variant comes first.
|
||||
let overlay = dir.join("a2_e.png");
|
||||
write_strip(&overlay, 16, 16, &[[1, 2, 3, 255]], None);
|
||||
let overlays: HashSet<PathBuf> = [overlay.clone()].into();
|
||||
let all = [&files[0], &overlay, &files[1]];
|
||||
let kept = budgeted(all.into_iter(), &overlays, 600);
|
||||
assert_eq!(kept, [files[0].clone(), files[1].clone()].into());
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn content_mod_effect_icons_pack_under_their_namespace() {
|
||||
let root =
|
||||
@@ -2464,6 +2858,232 @@ mod tests {
|
||||
.expect("the namespaced sprite animates");
|
||||
assert_eq!(animation.frame_count(), 2);
|
||||
}
|
||||
/// A resource root with one plain item texture and these CIT files (relative path, contents).
|
||||
fn cit_root(name: &str, files: &[(&str, &str)]) -> PathBuf {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-atlas-cit-{name}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let item = root.join("assets/minecraft/textures/item");
|
||||
fs::create_dir_all(&item).unwrap();
|
||||
write_strip(&item.join("stick.png"), 16, 16, &[[90, 60, 30, 255]], None);
|
||||
let cit = root.join("assets/minecraft/optifine/cit");
|
||||
fs::create_dir_all(&cit).unwrap();
|
||||
for (rel, text) in files {
|
||||
fs::write(cit.join(rel), text).unwrap();
|
||||
}
|
||||
root
|
||||
}
|
||||
#[test]
|
||||
fn a_cit_item_rules_texture_is_packed_under_its_id() {
|
||||
let root = cit_root("plain", &[("a.properties", "items=stick\ntexture=a")]);
|
||||
let cit = root.join("assets/minecraft/optifine/cit");
|
||||
write_strip(&cit.join("a.png"), 2, 2, &[[10, 200, 30, 255]], None);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let rect = atlas.sprites["horizon_cit:minecraft/optifine/cit/a"];
|
||||
assert_eq!(rect.texels((atlas.width, atlas.height)), (2, 2));
|
||||
assert_eq!(texel_at(&atlas, rect, 1, 1), [10, 200, 30, 255]);
|
||||
}
|
||||
#[test]
|
||||
fn a_cit_models_relative_texture_is_packed() {
|
||||
let root = cit_root(
|
||||
"model",
|
||||
&[
|
||||
("a.properties", "items=stick\nmodel=sword"),
|
||||
(
|
||||
"sword.json",
|
||||
r#"{"parent":"item/generated","textures":{"layer0":"./blade"}}"#,
|
||||
),
|
||||
],
|
||||
);
|
||||
let cit = root.join("assets/minecraft/optifine/cit");
|
||||
write_strip(&cit.join("blade.png"), 2, 2, &[[40, 50, 60, 255]], None);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let rect = atlas.sprites["horizon_cit:minecraft/optifine/cit/blade"];
|
||||
assert_eq!(texel_at(&atlas, rect, 1, 1), [40, 50, 60, 255]);
|
||||
}
|
||||
#[test]
|
||||
fn a_still_cit_texture_is_packed_whole() {
|
||||
// Not cut to a square as an unmarked strip would be: armour layers are 2:1.
|
||||
let root = cit_root("still", &[("a.properties", "items=stick\ntexture=a")]);
|
||||
let cit = root.join("assets/minecraft/optifine/cit");
|
||||
write_strip(&cit.join("a.png"), 4, 2, &[[7, 8, 9, 255]], None);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let rect = atlas.sprites["horizon_cit:minecraft/optifine/cit/a"];
|
||||
assert_eq!(rect.texels((atlas.width, atlas.height)), (4, 2));
|
||||
}
|
||||
#[test]
|
||||
fn an_animated_cit_texture_packs_one_frame_and_animates() {
|
||||
let root = cit_root("anim", &[("a.properties", "items=stick\ntexture=a")]);
|
||||
let cit = root.join("assets/minecraft/optifine/cit");
|
||||
write_strip(
|
||||
&cit.join("a.png"),
|
||||
16,
|
||||
48,
|
||||
&[[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255]],
|
||||
Some(r#"{"animation": {"frametime": 2}}"#),
|
||||
);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let rect = atlas.sprites["horizon_cit:minecraft/optifine/cit/a"];
|
||||
assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 16));
|
||||
assert_eq!(texel_at(&atlas, rect, 0, 0), [255, 0, 0, 255]);
|
||||
let animations: Vec<_> = atlas
|
||||
.sprite_animations
|
||||
.iter()
|
||||
.filter(|(r, _)| r.u0 == rect.u0 && r.v0 == rect.v0)
|
||||
.collect();
|
||||
assert_eq!(animations.len(), 1);
|
||||
assert_eq!(animations[0].1.frame_count(), 3);
|
||||
}
|
||||
#[test]
|
||||
fn an_armor_rules_texture_is_packed_too() {
|
||||
let root = cit_root(
|
||||
"armor",
|
||||
&[(
|
||||
"a.properties",
|
||||
"type=armor\nitems=iron_chestplate\ntexture.iron_layer_1=a",
|
||||
)],
|
||||
);
|
||||
let cit = root.join("assets/minecraft/optifine/cit");
|
||||
write_strip(&cit.join("a.png"), 64, 32, &[[1, 2, 3, 255]], None);
|
||||
let rules = crate::cit::load_rules(&root);
|
||||
assert_eq!(rules.len(), 1);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
// Worn armour draws from the same atlas, under the name the layer looks up.
|
||||
let rect = atlas.sprites["horizon_cit:minecraft/optifine/cit/a"];
|
||||
assert_eq!(rect.texels((atlas.width, atlas.height)), (64, 32));
|
||||
let layer = crate::cit_armor::layer_sprite(&rules[0], "minecraft:iron", false, false);
|
||||
assert!(atlas.sprites.contains_key(&layer.unwrap()));
|
||||
}
|
||||
#[test]
|
||||
fn named_cit_textures_pack_once_and_bad_ones_are_skipped() {
|
||||
let root = cit_root(
|
||||
"mixed",
|
||||
&[
|
||||
// Vanilla's own texture: already packed, under its usual name.
|
||||
(
|
||||
"v.properties",
|
||||
"items=stick
|
||||
texture=item/stick",
|
||||
),
|
||||
(
|
||||
"sub.properties",
|
||||
"items=bow
|
||||
texture.bow_pulling_0=b",
|
||||
),
|
||||
(
|
||||
"broken.properties",
|
||||
"items=apple
|
||||
texture=broken",
|
||||
),
|
||||
(
|
||||
"wide.properties",
|
||||
"items=egg
|
||||
texture=wide",
|
||||
),
|
||||
],
|
||||
);
|
||||
let cit = root.join("assets/minecraft/optifine/cit");
|
||||
write_strip(&cit.join("b.png"), 2, 2, &[[5, 6, 7, 255]], None);
|
||||
fs::write(cit.join("broken.png"), b"not a png").unwrap();
|
||||
// A still image past the 1024x512 limit.
|
||||
write_strip(&cit.join("wide.png"), 1040, 1040, &[[1, 1, 1, 255]], None);
|
||||
// Vanilla's stick is already packed when the CIT pass runs.
|
||||
let stick = ("item/stick".to_owned(), 16, 16, vec![0; 16 * 16 * 4]);
|
||||
let added = cit_sprites(&root, &[stick]);
|
||||
let names: Vec<_> = added.iter().map(|(image, _, _)| image.0.as_str()).collect();
|
||||
assert_eq!(names, ["horizon_cit:minecraft/optifine/cit/b"]);
|
||||
// And the atlas still builds around the broken and the too-wide texture.
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
assert!(
|
||||
atlas
|
||||
.sprites
|
||||
.contains_key("horizon_cit:minecraft/optifine/cit/b")
|
||||
);
|
||||
assert!(atlas.sprites.contains_key("item/stick"));
|
||||
}
|
||||
#[test]
|
||||
fn one_oversized_texture_leaves_the_others_in() {
|
||||
// `a` sorts first and claims more than any atlas; `b` is small and must still pack.
|
||||
let root = cit_root(
|
||||
"oversized",
|
||||
&[
|
||||
("a.properties", "items=stick\ntexture=a"),
|
||||
("b.properties", "items=apple\ntexture=b"),
|
||||
],
|
||||
);
|
||||
let cit = root.join("assets/minecraft/optifine/cit");
|
||||
// Only the header is read for `a`: a PNG signature and an IHDR of 20000x20000.
|
||||
let mut header = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR".to_vec();
|
||||
header.extend(20000u32.to_be_bytes());
|
||||
header.extend(20000u32.to_be_bytes());
|
||||
fs::write(cit.join("a.png"), header).unwrap();
|
||||
write_strip(&cit.join("b.png"), 2, 2, &[[4, 5, 6, 255]], None);
|
||||
let added = cit_sprites(&root, &[]);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let names: Vec<_> = added.iter().map(|(i, _, _)| i.0.as_str()).collect();
|
||||
assert_eq!(names, ["horizon_cit:minecraft/optifine/cit/b"]);
|
||||
}
|
||||
#[test]
|
||||
fn a_wide_grid_animation_packs_its_frame() {
|
||||
// 1104 wide is past the limit for a still texture, not for a row of 16x16 frames.
|
||||
let root = cit_root("grid", &[("a.properties", "items=stick\ntexture=a")]);
|
||||
let cit = root.join("assets/minecraft/optifine/cit");
|
||||
write_strip(&cit.join("a.png"), 1104, 16, &[[9, 9, 9, 255]], None);
|
||||
let mcmeta = r#"{"animation":{"width":16,"height":16}}"#;
|
||||
fs::write(cit.join("a.png.mcmeta"), mcmeta).unwrap();
|
||||
let added = cit_sprites(&root, &[]);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let sizes: Vec<_> = added.iter().map(|(i, _, _)| (i.1, i.2)).collect();
|
||||
assert_eq!(sizes, [(16, 16)]);
|
||||
}
|
||||
#[test]
|
||||
fn too_many_custom_item_textures_leave_them_out() {
|
||||
// A 1024 atlas holds the stick and the glyph rows, not an image 1100 wide.
|
||||
let image = |name: &str, side: u32| {
|
||||
let pixels = vec![0; (side * side * 4) as usize];
|
||||
(name.to_owned(), side, side, pixels)
|
||||
};
|
||||
let mut images = vec![image("item/stick", 16), image("horizon_cit:big", 1100)];
|
||||
let mut animated = vec!["horizon_cit:big".to_owned()];
|
||||
let extras = HashSet::from(["horizon_cit:big".to_owned()]);
|
||||
assert!(HudAtlas::layout(&images, 1024).is_err());
|
||||
HudAtlas::layout_leaving_out(&mut images, &mut animated, &extras, 1024).unwrap();
|
||||
let names: Vec<_> = images.iter().map(|i| i.0.as_str()).collect();
|
||||
assert_eq!(names, ["item/stick"]);
|
||||
assert!(animated.is_empty());
|
||||
// Without extras to leave out, it fails as before.
|
||||
let mut alone = vec![image("item/big", 1100)];
|
||||
let none = HashSet::new();
|
||||
assert!(HudAtlas::layout_leaving_out(&mut alone, &mut animated, &none, 1024).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn a_root_without_cit_rules_packs_the_same_sprites() {
|
||||
let root = cit_root("none", &[("a.properties", "items=stick\ntexture=a")]);
|
||||
let cit = root.join("assets/minecraft/optifine/cit");
|
||||
write_strip(&cit.join("a.png"), 2, 2, &[[10, 200, 30, 255]], None);
|
||||
let with: HashSet<String> = HudAtlas::build(&root)
|
||||
.unwrap()
|
||||
.sprites
|
||||
.into_keys()
|
||||
.collect();
|
||||
fs::remove_dir_all(root.join("assets/minecraft/optifine")).unwrap();
|
||||
let without: HashSet<String> = HudAtlas::build(&root)
|
||||
.unwrap()
|
||||
.sprites
|
||||
.into_keys()
|
||||
.collect();
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
assert!(without.is_subset(&with));
|
||||
let added: Vec<_> = with.difference(&without).collect();
|
||||
assert_eq!(added, ["horizon_cit:minecraft/optifine/cit/a"]);
|
||||
assert!(without.contains("item/stick"));
|
||||
}
|
||||
/// `count` images of `w x h`; layout reads only their sizes.
|
||||
fn sized(count: usize, w: u32, h: u32) -> Vec<PackedImage> {
|
||||
(0..count)
|
||||
@@ -2565,6 +3185,19 @@ mod tests {
|
||||
assert_inside(&images, width, &placements, free);
|
||||
}
|
||||
#[test]
|
||||
fn mixed_sizes_fill_the_ends_of_the_shelves() {
|
||||
// 15 images 9000 wide leave 7383 of a 16384 shelf unused each; the
|
||||
// 4000-wide ones (same height) come after them, and on shelves of their
|
||||
// own (4 to a shelf) the atlas is 19 shelves tall: past 16384 rows.
|
||||
// Each in a gap, it is 15.
|
||||
let mut images = sized(15, 9000, 1000);
|
||||
images.extend(sized(15, 4000, 1000));
|
||||
let (width, placements, free) = HudAtlas::layout(&images, 16384).unwrap();
|
||||
assert_eq!(width, 16384);
|
||||
assert!(free + GLYPH_ROWS <= 16384);
|
||||
assert_inside(&images, width, &placements, free);
|
||||
}
|
||||
#[test]
|
||||
fn textures_that_cannot_fit_are_an_error_not_a_gpu_panic() {
|
||||
let error = HudAtlas::layout(&sized(300, 1024, 1024), MAX_ATLAS_SIZE).unwrap_err();
|
||||
assert!(error.to_string().contains("do not fit a 16384x16384 atlas"));
|
||||
@@ -2725,6 +3358,13 @@ mod tests {
|
||||
batch.text_styled(&atlas, &named, 10.0, 10.0, u32::MAX, false);
|
||||
let skin = atlas.sprites["entity/player/wide/steve"];
|
||||
assert_eq!(batch.vertices[0].uv[0], skin.u0 + (skin.u1 - skin.u0) / 8.0);
|
||||
batch.vertices.clear();
|
||||
batch.text_styled(&atlas, &named, 10.0, 10.0, u32::MAX, true);
|
||||
assert_eq!(batch.vertices.len(), 24); // shadow and front, each with face and hat
|
||||
assert_eq!(batch.vertices[0].pos, [11.0, 10.0]);
|
||||
assert_eq!(batch.vertices[0].color, rgba(63, 63, 63, 255));
|
||||
assert_eq!(batch.vertices[12].pos, [10.0, 9.0]);
|
||||
assert_eq!(batch.vertices[12].color, u32::MAX);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,35 @@ struct Out {
|
||||
if c.a < 0.004 { discard; }
|
||||
return c;
|
||||
}
|
||||
// OptiFine CIT glint layers (hud/cit_glint.rs): every rule's texture in one array, the slot in
|
||||
// the vertex colour's red and the layer's intensity in its alpha. Masked by the item's texture
|
||||
// as the vanilla glint is, then blended by the pipeline the rule's `blend` asks for.
|
||||
struct CitSlot {
|
||||
rgba: vec4<f32>,
|
||||
// x: linear filtering (`blur`).
|
||||
blur: vec4<f32>,
|
||||
}
|
||||
struct CitSlots {
|
||||
slots: array<CitSlot, 256>,
|
||||
}
|
||||
@group(1) @binding(0) var cit_tex: texture_2d_array<f32>;
|
||||
@group(1) @binding(1) var cit_blur: sampler;
|
||||
@group(1) @binding(2) var cit_nearest: sampler;
|
||||
@group(1) @binding(3) var<uniform> cit: CitSlots;
|
||||
@fragment fn fs_cit_glint(in: Out) -> @location(0) vec4<f32> {
|
||||
let mask = textureSample(atlas, atlas_sampler, in.uv).a;
|
||||
let index = u32(round(in.color.r * 255.0));
|
||||
let slot = cit.slots[index];
|
||||
let blurred = textureSampleLevel(cit_tex, cit_blur, in.glint_uv, index, 0.0);
|
||||
let sharp = textureSampleLevel(cit_tex, cit_nearest, in.glint_uv, index, 0.0);
|
||||
if mask < 0.1 { discard; }
|
||||
// The layer's colour times its intensity (the glint strength option included) in alpha,
|
||||
// cut where vanilla's glint shader cuts, which CIT Resewn draws through.
|
||||
var t = select(sharp, blurred, slot.blur.x > 0.5) * slot.rgba;
|
||||
t.a *= in.color.a;
|
||||
if t.a < 0.1 { discard; }
|
||||
return t;
|
||||
}
|
||||
@fragment fn fs_crosshair(in: Out) -> @location(0) vec4<f32> {
|
||||
let c = textureSample(atlas, atlas_sampler, in.uv) * in.color;
|
||||
if c.a == 0.0 { discard; }
|
||||
|
||||
@@ -0,0 +1,563 @@
|
||||
//! OptiFine CIT enchantment glints on GUI items (`type=enchantment`, see `crate::cit_glint` for
|
||||
//! which layers a stack draws): every rule's texture in one texture array, each layer an extra
|
||||
//! pass over the item's quads with its rule's blend. A layer's vertices carry its texture slot
|
||||
//! (red) and its intensity (alpha) in their colour; its scrolled UV is worked out on the CPU, as
|
||||
//! vanilla's glint's is. Without enchantment rules nothing here is made.
|
||||
|
||||
use super::HudVertex;
|
||||
use horizon_optifine::cit::{Blend, Factor, Kind, Rule};
|
||||
use std::path::Path;
|
||||
|
||||
/// Slots the shader's table holds; rules past it draw no layer (and match no stack).
|
||||
pub const MAX_SLOTS: usize = 256;
|
||||
/// The largest side the shared texture size takes: bigger glints are scaled down to it.
|
||||
const MAX_SIDE: u32 = 1024;
|
||||
/// The most the texture array takes, in bytes: past it the shared size is halved.
|
||||
const MAX_BYTES: u64 = 64 << 20;
|
||||
|
||||
/// One slot's constants.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Slot {
|
||||
/// The rule's `r`, `g`, `b`, `a`.
|
||||
pub rgba: [f32; 4],
|
||||
/// Linear filtering (`blur`), else nearest.
|
||||
pub blur: bool,
|
||||
/// An index into `GlintTextures::blends`.
|
||||
pub blend: u8,
|
||||
}
|
||||
|
||||
/// The enchantment rules' textures, resized to one size.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct GlintTextures {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// One `width` x `height` RGBA image per slot, slot after slot. Kept after the upload: every
|
||||
/// split-screen player's renderer builds its own array from it.
|
||||
pub pixels: Vec<u8>,
|
||||
/// Per rule (an index into `CitRules::rules`): its slot; `None` for other kinds of rules,
|
||||
/// textures that did not load and rules past `MAX_SLOTS`.
|
||||
pub slot_of: Vec<Option<u8>>,
|
||||
pub slots: Vec<Slot>,
|
||||
/// The blends the slots use, once each: the HUD makes a pipeline per entry.
|
||||
pub blends: Vec<Option<Blend>>,
|
||||
}
|
||||
|
||||
impl GlintTextures {
|
||||
/// The textures of `rules` under the overlay resource root.
|
||||
pub fn load(root: &Path, rules: &[Rule]) -> Self {
|
||||
let mut images = Vec::new();
|
||||
let mut past = 0;
|
||||
for (i, rule) in rules.iter().enumerate() {
|
||||
let Some(path) = rule.texture.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
if rule.kind != Kind::Enchantment {
|
||||
continue;
|
||||
}
|
||||
if images.len() == MAX_SLOTS {
|
||||
past += 1;
|
||||
continue;
|
||||
}
|
||||
match super::decode(&root.join(path)) {
|
||||
Ok(image) => images.push((i, image)),
|
||||
Err(e) => log::warn!("custom item textures: glint {path} left out: {e}"),
|
||||
}
|
||||
}
|
||||
if past > 0 {
|
||||
log::warn!(
|
||||
"custom item textures: {past} glint rules past the first {MAX_SLOTS} left out"
|
||||
);
|
||||
}
|
||||
Self::new(rules, images, MAX_BYTES)
|
||||
}
|
||||
|
||||
/// `images`: (rule, (width, height, RGBA)), in at most `budget` bytes.
|
||||
fn new(rules: &[Rule], images: Vec<(usize, (u32, u32, Vec<u8>))>, budget: u64) -> Self {
|
||||
let sizes = images.iter().map(|(_, (w, h, _))| (*w, *h));
|
||||
let Some((width, height)) = shared_size(sizes, images.len(), budget) else {
|
||||
return Self::default();
|
||||
};
|
||||
let mut out = Self {
|
||||
width,
|
||||
height,
|
||||
slot_of: vec![None; rules.len()],
|
||||
..Self::default()
|
||||
};
|
||||
for (rule, (w, h, rgba)) in images {
|
||||
let glint = &rules[rule].glint;
|
||||
out.pixels
|
||||
.extend(resize(&rgba, (w, h), (width, height), glint.blur));
|
||||
let blend = match out.blends.iter().position(|b| *b == glint.blend) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
out.blends.push(glint.blend);
|
||||
out.blends.len() - 1
|
||||
}
|
||||
};
|
||||
out.slot_of[rule] = Some(out.slots.len() as u8);
|
||||
out.slots.push(Slot {
|
||||
rgba: glint.rgba,
|
||||
blur: glint.blur,
|
||||
blend: blend as u8,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.slots.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// The size every slot takes: the largest width and height of `sizes`, each at most
|
||||
/// `MAX_SIDE`, then halved until `layers` RGBA images of it fit in `budget` bytes.
|
||||
fn shared_size(
|
||||
sizes: impl Iterator<Item = (u32, u32)>,
|
||||
layers: usize,
|
||||
budget: u64,
|
||||
) -> Option<(u32, u32)> {
|
||||
let (mut w, mut h) = sizes.fold(None, |max: Option<(u32, u32)>, (w, h)| {
|
||||
let (w, h) = (w.clamp(1, MAX_SIDE), h.clamp(1, MAX_SIDE));
|
||||
Some(max.map_or((w, h), |(mw, mh)| (mw.max(w), mh.max(h))))
|
||||
})?;
|
||||
let bytes = |w: u32, h: u32| u64::from(w) * u64::from(h) * 4 * layers as u64;
|
||||
while bytes(w, h) > budget && (w > 1 || h > 1) {
|
||||
(w, h) = ((w / 2).max(1), (h / 2).max(1));
|
||||
}
|
||||
Some((w, h))
|
||||
}
|
||||
|
||||
/// `rgba` (`from` texels) scaled to `to`: bilinearly for a `blur` glint, as the GPU would have
|
||||
/// filtered the texture at its own size, else to the nearest texel. Both wrap at the edges, as
|
||||
/// the glint repeats over the item.
|
||||
fn resize(rgba: &[u8], from: (u32, u32), to: (u32, u32), smooth: bool) -> Vec<u8> {
|
||||
if from == to {
|
||||
return rgba.to_vec();
|
||||
}
|
||||
let ((w, h), (to_w, to_h)) = (from, to);
|
||||
let texel = |x: i64, y: i64| {
|
||||
let (x, y) = (x.rem_euclid(i64::from(w)), y.rem_euclid(i64::from(h)));
|
||||
let at = (y as usize * w as usize + x as usize) * 4;
|
||||
&rgba[at..at + 4]
|
||||
};
|
||||
// Where texel `i` of `n` samples the source's `size` texels, in source texels.
|
||||
let centre = |i: u32, n: u32, size: u32| (f64::from(i) + 0.5) * f64::from(size) / f64::from(n);
|
||||
let mut out = Vec::with_capacity(to_w as usize * to_h as usize * 4);
|
||||
for y in 0..to_h {
|
||||
let sy = centre(y, to_h, h);
|
||||
for x in 0..to_w {
|
||||
let sx = centre(x, to_w, w);
|
||||
if !smooth {
|
||||
let pick = |i: u32, n: u32, size: u32| {
|
||||
(u64::from(i) * u64::from(size) / u64::from(n)) as i64
|
||||
};
|
||||
out.extend_from_slice(texel(pick(x, to_w, w), pick(y, to_h, h)));
|
||||
continue;
|
||||
}
|
||||
let (fx, fy) = (sx - 0.5, sy - 0.5);
|
||||
let (x0, y0) = (fx.floor() as i64, fy.floor() as i64);
|
||||
let (tx, ty) = (fx - fx.floor(), fy - fy.floor());
|
||||
let [a, b, c, d] = [
|
||||
texel(x0, y0),
|
||||
texel(x0 + 1, y0),
|
||||
texel(x0, y0 + 1),
|
||||
texel(x0 + 1, y0 + 1),
|
||||
];
|
||||
let lerp =
|
||||
|p: &[u8], q: &[u8], k: usize| f64::from(p[k]) * (1.0 - tx) + f64::from(q[k]) * tx;
|
||||
out.extend((0..4).map(|k| {
|
||||
let (top, bottom) = (lerp(a, b, k), lerp(c, d, k));
|
||||
(top * (1.0 - ty) + bottom * ty).round() as u8
|
||||
}));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// CIT Resewn's glint texturing for one layer: `translation(-x, y)`, `rotateZ(rotation)`, then
|
||||
/// `scale`, where `x` and `y` run through vanilla's 110000 and 30000 periods of the clock
|
||||
/// `millis * speed` (vanilla's own glint runs at `millis * glintSpeed * 8`). `rotation` is in
|
||||
/// degrees, as OptiFine's doc has it.
|
||||
pub fn layer_uv(model: [f32; 2], millis: f32, speed: f32, rotation: f32, scale: f32) -> [f32; 2] {
|
||||
let clock = f64::from(millis) * f64::from(speed);
|
||||
let x = (clock % 110_000.0 / 110_000.0) as f32;
|
||||
let y = (clock % 30_000.0 / 30_000.0) as f32;
|
||||
let (sin, cos) = rotation.to_radians().sin_cos();
|
||||
let (u, v) = (model[0] * scale, model[1] * scale);
|
||||
[u * cos - v * sin - x, u * sin + v * cos + y]
|
||||
}
|
||||
|
||||
/// The colour of a layer's vertices: its slot in red, its intensity in alpha.
|
||||
pub fn vertex_color(slot: u8, intensity: f32) -> u32 {
|
||||
let alpha = (intensity.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
super::rgba(slot, 0, 0, alpha)
|
||||
}
|
||||
|
||||
fn factor(f: Factor) -> wgpu::BlendFactor {
|
||||
use wgpu::BlendFactor as W;
|
||||
match f {
|
||||
Factor::Zero => W::Zero,
|
||||
Factor::One => W::One,
|
||||
Factor::SrcColor => W::Src,
|
||||
Factor::OneMinusSrcColor => W::OneMinusSrc,
|
||||
Factor::SrcAlpha => W::SrcAlpha,
|
||||
Factor::OneMinusSrcAlpha => W::OneMinusSrcAlpha,
|
||||
Factor::DstAlpha => W::DstAlpha,
|
||||
Factor::OneMinusDstAlpha => W::OneMinusDstAlpha,
|
||||
Factor::DstColor => W::Dst,
|
||||
Factor::OneMinusDstColor => W::OneMinusDst,
|
||||
Factor::SrcAlphaSaturate => W::SrcAlphaSaturated,
|
||||
}
|
||||
}
|
||||
|
||||
/// `glBlendFuncSeparate` with the rule's factors; `replace` draws unblended.
|
||||
pub fn blend_state(blend: Option<Blend>) -> wgpu::BlendState {
|
||||
let Some(b) = blend else {
|
||||
return wgpu::BlendState::REPLACE;
|
||||
};
|
||||
let component = |src, dst| wgpu::BlendComponent {
|
||||
src_factor: factor(src),
|
||||
dst_factor: factor(dst),
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
};
|
||||
wgpu::BlendState {
|
||||
color: component(b.src, b.dst),
|
||||
alpha: component(b.src_alpha, b.dst_alpha),
|
||||
}
|
||||
}
|
||||
|
||||
/// The GPU side: the texture array and slot table (bind group 1 of `fs_cit_glint`), and one
|
||||
/// pipeline per blend.
|
||||
pub(super) struct CitGlintPass {
|
||||
group: wgpu::BindGroup,
|
||||
pipelines: Vec<wgpu::RenderPipeline>,
|
||||
}
|
||||
|
||||
impl CitGlintPass {
|
||||
/// `None` without textures. `hud` is the HUD's bind group layout (group 0).
|
||||
pub(super) fn new(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
format: wgpu::TextureFormat,
|
||||
hud: &wgpu::BindGroupLayout,
|
||||
textures: &GlintTextures,
|
||||
) -> Option<Self> {
|
||||
use wgpu::util::DeviceExt;
|
||||
if textures.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// wgpu's OpenGL backend makes 1 layer a 2D texture and 6n a cube map
|
||||
// (`gfx::array_layers`): pad with empty layers, within the GPU's limit. Slots past the
|
||||
// layers kept sample the last one.
|
||||
let gl = device.adapter_info().backend == wgpu::Backend::Gl;
|
||||
let max = device.limits().max_texture_array_layers.max(1);
|
||||
let slots = textures.slots.len() as u32;
|
||||
let mut kept = slots.min(max);
|
||||
while kept > 1 && crate::gfx::array_layers(kept, gl) > max {
|
||||
kept -= 1;
|
||||
}
|
||||
if kept < slots {
|
||||
log::warn!("custom item textures: the GPU holds {kept} of {slots} glint textures");
|
||||
}
|
||||
let layers = crate::gfx::array_layers(kept, gl).min(max);
|
||||
let layer_bytes = textures.width as usize * textures.height as usize * 4;
|
||||
let mut data = std::borrow::Cow::Borrowed(&textures.pixels[..]);
|
||||
if layers != slots {
|
||||
let mut padded = textures.pixels[..layer_bytes * kept as usize].to_vec();
|
||||
padded.resize(layer_bytes * layers as usize, 0);
|
||||
data = std::borrow::Cow::Owned(padded);
|
||||
}
|
||||
let texture = device.create_texture_with_data(
|
||||
queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: Some("CIT glints"),
|
||||
size: wgpu::Extent3d {
|
||||
width: textures.width,
|
||||
height: textures.height,
|
||||
depth_or_array_layers: layers,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
&data,
|
||||
);
|
||||
let view = texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
dimension: Some(wgpu::TextureViewDimension::D2Array),
|
||||
..Default::default()
|
||||
});
|
||||
let sampler = |label, filter| {
|
||||
device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some(label),
|
||||
address_mode_u: wgpu::AddressMode::Repeat,
|
||||
address_mode_v: wgpu::AddressMode::Repeat,
|
||||
mag_filter: filter,
|
||||
min_filter: filter,
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
let linear = sampler("CIT glint blur", wgpu::FilterMode::Linear);
|
||||
let nearest = sampler("CIT glint", wgpu::FilterMode::Nearest);
|
||||
// hud.wgsl's `CitSlot`: rgba, then blur in x.
|
||||
let mut table = vec![[0.0f32; 8]; MAX_SLOTS];
|
||||
for (row, slot) in table.iter_mut().zip(&textures.slots) {
|
||||
row[..4].copy_from_slice(&slot.rgba);
|
||||
row[4] = if slot.blur { 1.0 } else { 0.0 };
|
||||
}
|
||||
let table = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("CIT glint slots"),
|
||||
contents: bytemuck::cast_slice(&table),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let fragment = wgpu::ShaderStages::FRAGMENT;
|
||||
let filtering = wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering);
|
||||
let entry = |binding, ty| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: fragment,
|
||||
ty,
|
||||
count: None,
|
||||
};
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("CIT glint bindings"),
|
||||
entries: &[
|
||||
entry(
|
||||
0,
|
||||
wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2Array,
|
||||
multisampled: false,
|
||||
},
|
||||
),
|
||||
entry(1, filtering),
|
||||
entry(2, filtering),
|
||||
entry(
|
||||
3,
|
||||
wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
),
|
||||
],
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("CIT glint resources"),
|
||||
layout: &layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::Sampler(&linear),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::Sampler(&nearest),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: table.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon HUD (CIT glints)"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("../hud.wgsl").into()),
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("CIT glint layout"),
|
||||
bind_group_layouts: &[Some(hud), Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let attributes =
|
||||
wgpu::vertex_attr_array![0=>Float32x2,1=>Float32x2,2=>Unorm8x4,3=>Float32,4=>Float32x2];
|
||||
let pipelines = textures
|
||||
.blends
|
||||
.iter()
|
||||
.map(|&blend| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("CIT glint"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<HudVertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &attributes,
|
||||
})],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_cit_glint"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: Some(blend_state(blend)),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Some(Self { group, pipelines })
|
||||
}
|
||||
|
||||
/// Draw `vertices` with blend `blend` (an index into `GlintTextures::blends`).
|
||||
pub(super) fn draw(
|
||||
&self,
|
||||
pass: &mut wgpu::RenderPass<'_>,
|
||||
blend: u8,
|
||||
vertices: std::ops::Range<u32>,
|
||||
) {
|
||||
if let Some(pipeline) = self.pipelines.get(usize::from(blend)) {
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(1, &self.group, &[]);
|
||||
pass.draw(vertices, 0..1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_optifine::Properties;
|
||||
use horizon_optifine::cit::parse_blend;
|
||||
|
||||
fn rule(name: &str, text: &str) -> Rule {
|
||||
let path = format!("assets/minecraft/optifine/cit/{name}.properties");
|
||||
let text = format!("texture=glint\n{text}");
|
||||
Rule::parse(&path, &Properties::parse_equals_first(&text), |_| true).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slots_share_one_size_and_their_blends() {
|
||||
let rules = [
|
||||
rule("a", "type=enchantment\nr=0.5"),
|
||||
rule("item", "items=stick"),
|
||||
rule("b", "type=enchantment\nblend=multiply\nblur=false"),
|
||||
rule("c", "type=enchantment"),
|
||||
];
|
||||
// A 1x1 red glint and a 2x2 one: the red texel fills 2x2.
|
||||
let red = vec![255, 0, 0, 255];
|
||||
let quad: Vec<u8> = (0..16).collect();
|
||||
let t = GlintTextures::new(
|
||||
&rules,
|
||||
vec![
|
||||
(0, (1, 1, red)),
|
||||
(2, (2, 2, quad.clone())),
|
||||
(3, (2, 2, quad.clone())),
|
||||
],
|
||||
MAX_BYTES,
|
||||
);
|
||||
assert_eq!((t.width, t.height), (2, 2));
|
||||
assert_eq!(t.slot_of, [Some(0), None, Some(1), Some(2)]);
|
||||
assert_eq!(&t.pixels[..16], [255, 0, 0, 255].repeat(4));
|
||||
assert_eq!(&t.pixels[16..32], &quad[..]);
|
||||
assert_eq!(
|
||||
t.blends,
|
||||
[
|
||||
parse_blend("add").unwrap(),
|
||||
parse_blend("multiply").unwrap()
|
||||
]
|
||||
);
|
||||
let blends: Vec<_> = t.slots.iter().map(|s| s.blend).collect();
|
||||
assert_eq!(blends, [0, 1, 0]);
|
||||
assert_eq!(t.slots[0].rgba, [0.5, 1.0, 1.0, 1.0]);
|
||||
assert!(!t.slots[1].blur && t.slots[2].blur);
|
||||
assert!(GlintTextures::new(&rules, Vec::new(), MAX_BYTES).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn big_glints_are_scaled_down_to_the_largest_side() {
|
||||
let rules = [rule("wide", "type=enchantment\nblur=false")];
|
||||
// 2048 x 1, texel x holding x in red and green: every other texel is kept.
|
||||
let wide: Vec<u8> = (0..2048u32)
|
||||
.flat_map(|x| [(x % 256) as u8, (x / 256) as u8, 0, 255])
|
||||
.collect();
|
||||
let t = GlintTextures::new(&rules, vec![(0, (2048, 1, wide))], MAX_BYTES);
|
||||
assert_eq!((t.width, t.height), (1024, 1));
|
||||
for x in [0u32, 1, 300, 1023] {
|
||||
let at = x as usize * 4;
|
||||
let source = 2 * x;
|
||||
let want = [(source % 256) as u8, (source / 256) as u8, 0, 255];
|
||||
assert_eq!(t.pixels[at..at + 4], want, "texel {x}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_array_is_halved_until_it_fits() {
|
||||
let sizes = |n: usize| std::iter::repeat_n((1024, 1024), n);
|
||||
// 16 layers of 1024 x 1024 RGBA are 64 MiB; a 17th halves both sides.
|
||||
assert_eq!(shared_size(sizes(16), 16, 64 << 20), Some((1024, 1024)));
|
||||
assert_eq!(shared_size(sizes(17), 17, 64 << 20), Some((512, 512)));
|
||||
// The largest of each side, never past MAX_SIDE.
|
||||
let mixed = [(16, 4096), (64, 32)].into_iter();
|
||||
assert_eq!(shared_size(mixed, 2, 64 << 20), Some((64, 1024)));
|
||||
assert_eq!(shared_size(std::iter::empty(), 0, 64 << 20), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blurred_glints_grow_smoothly_and_sharp_ones_by_texel() {
|
||||
// Black then white, doubled: the wrapping blend of the two, or each texel twice.
|
||||
let row = [0, 0, 0, 255, 255, 255, 255, 255];
|
||||
let red = |rgba: Vec<u8>| rgba.chunks(4).map(|t| t[0]).collect::<Vec<_>>();
|
||||
assert_eq!(red(resize(&row, (2, 1), (4, 1), true)), [64, 64, 191, 191]);
|
||||
assert_eq!(red(resize(&row, (2, 1), (4, 1), false)), [0, 0, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_vertex_carries_its_slot_and_intensity() {
|
||||
// Unorm8x4 reads the colour's bytes in memory order: r, g, b, a.
|
||||
assert_eq!(vertex_color(3, 0.5).to_le_bytes(), [3, 0, 0, 128]);
|
||||
assert_eq!(vertex_color(200, 2.0).to_le_bytes(), [200, 0, 0, 255]);
|
||||
assert_eq!(vertex_color(0, -1.0).to_le_bytes(), [0, 0, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_still_layer_is_scaled_and_turned() {
|
||||
let close =
|
||||
|a: [f32; 2], b: [f32; 2]| (a[0] - b[0]).abs() < 1e-5 && (a[1] - b[1]).abs() < 1e-5;
|
||||
// No speed: no scroll whatever the clock.
|
||||
assert!(close(
|
||||
layer_uv([0.25, 0.5], 12_345.0, 0.0, 0.0, 8.0),
|
||||
[2.0, 4.0]
|
||||
));
|
||||
// A quarter turn takes (1, 0) to (0, 1).
|
||||
assert!(close(layer_uv([1.0, 0.0], 0.0, 0.0, 90.0, 1.0), [0.0, 1.0]));
|
||||
// 1500 ms at speed 2: 3000 / 110000 back, 3000 / 30000 up.
|
||||
assert!(close(
|
||||
layer_uv([0.0, 0.0], 1500.0, 2.0, 0.0, 8.0),
|
||||
[-3.0 / 110.0, 0.1]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_blends_take_their_gl_factors() {
|
||||
let multiply = blend_state(parse_blend("multiply").unwrap());
|
||||
assert_eq!(multiply.color.src_factor, wgpu::BlendFactor::Dst);
|
||||
assert_eq!(
|
||||
multiply.color.dst_factor,
|
||||
wgpu::BlendFactor::OneMinusSrcAlpha
|
||||
);
|
||||
// The alpha factors default to GL_ZERO, GL_ONE: the target's alpha stays.
|
||||
assert_eq!(multiply.alpha.src_factor, wgpu::BlendFactor::Zero);
|
||||
assert_eq!(multiply.alpha.dst_factor, wgpu::BlendFactor::One);
|
||||
assert_eq!(
|
||||
blend_state(parse_blend("replace").unwrap()),
|
||||
wgpu::BlendState::REPLACE
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//! A data folder's files read from the disk once while a load holds them.
|
||||
//! Several tables are built from the same folder (the recipes: the crafting
|
||||
//! tables, the stations, the recipe book, brewing), and each used to list and
|
||||
//! read every file again: on a hard disk or under a virus scanner, each open
|
||||
//! costs. The first reader lists and reads the folder; later readers share
|
||||
//! those bytes for as long as one of them holds the [`Folder`], then it is
|
||||
//! gone (nothing is kept past the load).
|
||||
use std::{
|
||||
fs, io,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex, Weak},
|
||||
};
|
||||
|
||||
/// One file: its path inside the folder without `.json` (`a/b`, as an id has
|
||||
/// it), the file, and its bytes or the error reading it gave.
|
||||
pub struct File {
|
||||
pub name: String,
|
||||
pub path: PathBuf,
|
||||
bytes: Result<Box<[u8]>, (io::ErrorKind, String)>,
|
||||
}
|
||||
impl File {
|
||||
pub fn bytes(&self) -> io::Result<&[u8]> {
|
||||
self.bytes
|
||||
.as_deref()
|
||||
.map_err(|(kind, message)| io::Error::new(*kind, message.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Every `.json` file under a folder, its subfolders' too, in name order.
|
||||
pub struct Folder {
|
||||
pub files: Vec<File>,
|
||||
}
|
||||
|
||||
static OPEN: Mutex<Vec<(PathBuf, Weak<Folder>)>> = Mutex::new(Vec::new());
|
||||
|
||||
fn find(open: &[(PathBuf, Weak<Folder>)], dir: &Path) -> Option<Arc<Folder>> {
|
||||
open.iter()
|
||||
.find(|(d, _)| d == dir)
|
||||
.and_then(|(_, f)| f.upgrade())
|
||||
}
|
||||
|
||||
/// `dir`'s files if another reader holds them, without reading anything.
|
||||
pub fn held(dir: &Path) -> Option<Arc<Folder>> {
|
||||
find(&OPEN.lock().unwrap_or_else(|e| e.into_inner()), dir)
|
||||
}
|
||||
|
||||
/// The `.json` files under `dir`: shared with any other reader holding them,
|
||||
/// else listed and read now. An error when `dir` itself can't be listed.
|
||||
pub fn read(dir: &Path) -> io::Result<Arc<Folder>> {
|
||||
if let Some(folder) = held(dir) {
|
||||
return Ok(folder);
|
||||
}
|
||||
let files = list(dir)?
|
||||
.into_iter()
|
||||
.map(|(name, path)| {
|
||||
let bytes = fs::read(&path)
|
||||
.map(Vec::into_boxed_slice)
|
||||
.map_err(|e| (e.kind(), e.to_string()));
|
||||
File { name, path, bytes }
|
||||
})
|
||||
.collect();
|
||||
let folder = Arc::new(Folder { files });
|
||||
let mut open = OPEN.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// Another reader may have read it meanwhile: share one copy.
|
||||
if let Some(theirs) = find(&open, dir) {
|
||||
return Ok(theirs);
|
||||
}
|
||||
open.retain(|(_, f)| f.strong_count() > 0);
|
||||
open.push((dir.to_owned(), Arc::downgrade(&folder)));
|
||||
Ok(folder)
|
||||
}
|
||||
|
||||
/// The `.json` files under `dir` and its subfolders, in name order: each
|
||||
/// one's path inside the folder without the extension (`a/b`) and its file.
|
||||
/// An error when `dir` itself can't be listed; a subfolder or an entry that
|
||||
/// can't be is warned and skipped, as vanilla's `PathPackResources` keeps
|
||||
/// what it found.
|
||||
pub fn list(dir: &Path) -> io::Result<Vec<(String, PathBuf)>> {
|
||||
let mut files = Vec::new();
|
||||
let mut stack = vec![fs::read_dir(dir)?];
|
||||
while let Some(entries) = stack.pop() {
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: failed to list an entry of {}: {e}", dir.display());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
match fs::read_dir(&path) {
|
||||
Ok(list) => stack.push(list),
|
||||
Err(e) => log::warn!("Horizon: failed to list {}: {e}", path.display()),
|
||||
}
|
||||
} else if path.extension().is_some_and(|e| e == "json") {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut named: Vec<(String, PathBuf)> = files
|
||||
.into_iter()
|
||||
.map(|path| {
|
||||
let inside = path.strip_prefix(dir).unwrap_or(&path).with_extension("");
|
||||
let name: Vec<_> = inside.iter().map(|c| c.to_string_lossy()).collect();
|
||||
(name.join("/"), path)
|
||||
})
|
||||
.collect();
|
||||
// Vanilla lists resources by id, in a map keyed by it
|
||||
// (`FallbackResourceManager`): `crushing` before `crushing/iron`, where
|
||||
// a path's components put the subfolder's file first.
|
||||
named.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
Ok(named)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn folder(tag: &str) -> PathBuf {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("horizon-json-folder-{tag}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(dir.join("brewing")).unwrap();
|
||||
fs::write(dir.join("stick.json"), b"{\"a\":1}").unwrap();
|
||||
fs::write(dir.join("brewing/awkward.json"), b"{}").unwrap();
|
||||
fs::write(dir.join("notes.txt"), b"not json").unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readers_share_the_bytes_while_one_holds_them_and_read_again_after() {
|
||||
let dir = folder("share");
|
||||
let first = read(&dir).unwrap();
|
||||
let names: Vec<_> = first.files.iter().map(|f| f.name.as_str()).collect();
|
||||
assert_eq!(names, ["brewing/awkward", "stick"]);
|
||||
assert_eq!(first.files[1].bytes().unwrap(), b"{\"a\":1}");
|
||||
// Held: a change on the disk isn't seen, the copy read is shared.
|
||||
fs::write(dir.join("stick.json"), b"{\"a\":2}").unwrap();
|
||||
let second = read(&dir).unwrap();
|
||||
assert!(Arc::ptr_eq(&first, &second));
|
||||
drop((first, second));
|
||||
// Let go: the next reader reads the folder again.
|
||||
assert_eq!(read(&dir).unwrap().files[1].bytes().unwrap(), b"{\"a\":2}");
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_that_cannot_be_listed_is_an_error() {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("horizon-json-folder-none-{}", std::process::id()));
|
||||
assert!(read(&dir).is_err());
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ pub mod biome;
|
||||
pub mod block_edit;
|
||||
pub mod block_entities;
|
||||
pub mod block_entity_anim;
|
||||
pub mod block_entity_cem;
|
||||
pub mod block_entity_render;
|
||||
pub mod border_render;
|
||||
pub mod breaking;
|
||||
@@ -20,6 +21,9 @@ pub mod chat;
|
||||
pub mod chunk;
|
||||
pub mod chunk_map;
|
||||
pub mod cit;
|
||||
pub mod cit_armor;
|
||||
pub mod cit_assets;
|
||||
pub mod cit_glint;
|
||||
pub mod clouds;
|
||||
pub mod collisions;
|
||||
pub mod command_block;
|
||||
@@ -45,6 +49,7 @@ pub mod hud_overlays;
|
||||
pub mod inventory_effects;
|
||||
pub mod java_text;
|
||||
pub mod jigsaw_block;
|
||||
pub mod json_folder;
|
||||
pub mod level_loading;
|
||||
pub mod light;
|
||||
pub mod lod;
|
||||
|
||||
@@ -26,6 +26,11 @@ const MAX_SAVED: usize = 1024;
|
||||
/// (`Lod::observing`).
|
||||
pub const OBSERVE_AHEAD: usize = 8;
|
||||
const SAMPLE_JOBS: usize = 4;
|
||||
/// Sample jobs queued per sampling thread once the near terrain is in.
|
||||
/// Results are taken and jobs started once a frame: with four in flight, a
|
||||
/// tile taking milliseconds left the threads idle for most of a slow frame
|
||||
/// (2-3 fps on a CPU renderer, with far terrain a few tiles a frame).
|
||||
const QUEUED_PER_SAMPLER: usize = 32;
|
||||
/// Threads that sample far tiles beside the mesher. Sampling is the cold-load
|
||||
/// bottleneck (each task evaluates a shared density lattice for 256 columns),
|
||||
/// so medium machines use half their cores, not a quarter.
|
||||
@@ -898,8 +903,9 @@ struct Shared {
|
||||
/// On small machines, hold queued far meshing while near terrain loads.
|
||||
mesh_held: AtomicBool,
|
||||
}
|
||||
/// One background worker and at most two outstanding sample jobs. Only the
|
||||
/// current view's quadtree and a bounded set of visited summaries are retained.
|
||||
/// A background worker, sampling and meshing threads, and a bounded queue of
|
||||
/// sample jobs (`sample_jobs`). Only the current view's quadtree and a
|
||||
/// bounded set of visited summaries are retained.
|
||||
pub struct Lod {
|
||||
shared: Arc<Shared>,
|
||||
thread: Option<thread::JoinHandle<()>>,
|
||||
@@ -1193,12 +1199,17 @@ impl Lod {
|
||||
if worker.stop.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
if sampling.is_empty() {
|
||||
// Summaries and detail requests first: they
|
||||
// would otherwise wait behind a whole queue of
|
||||
// samples where no sampling threads take them.
|
||||
let other =
|
||||
jobs.iter().position(|job| !matches!(job, Job::Sample(_)));
|
||||
if let Some(index) = other {
|
||||
jobs.remove(index)
|
||||
} else if sampling.is_empty() {
|
||||
jobs.pop_front()
|
||||
} else {
|
||||
jobs.iter()
|
||||
.position(|job| !matches!(job, Job::Sample(_)))
|
||||
.and_then(|index| jobs.remove(index))
|
||||
None
|
||||
}
|
||||
};
|
||||
let Some(job) = job else { continue };
|
||||
@@ -1260,7 +1271,7 @@ impl Lod {
|
||||
detail_request_pending: false,
|
||||
detail_request_at: std::time::Instant::now(),
|
||||
small: cores <= 4,
|
||||
sample_jobs: SAMPLE_JOBS.max(sampling_threads(cores) + 1),
|
||||
sample_jobs: sampling_threads(cores) * QUEUED_PER_SAMPLER,
|
||||
near_loading: false,
|
||||
held_sample_at: std::time::Instant::now(),
|
||||
retry: FxHashMap::default(),
|
||||
@@ -1326,14 +1337,31 @@ impl Lod {
|
||||
}
|
||||
(desired, sampled)
|
||||
}
|
||||
/// Drawn columns that take their chunk's generated detail (trees,
|
||||
/// structures; `request_detail`), and how many of them have it.
|
||||
/// `refined_samples` counts the detail of every drawn tile instead.
|
||||
pub fn detail_progress(&self) -> (usize, usize) {
|
||||
self.active
|
||||
.iter()
|
||||
.filter(|key| self.wants_detail(key))
|
||||
.fold((0, 0), |(wanted, refined), key| {
|
||||
let mask = self.detail_masks.get(key).map_or(0, detail_count);
|
||||
(wanted + CELLS * CELLS, refined + mask)
|
||||
})
|
||||
}
|
||||
pub fn refined_samples(&self) -> usize {
|
||||
self.active
|
||||
.iter()
|
||||
.filter_map(|key| self.detail_masks.get(key))
|
||||
.flat_map(|mask| mask.iter())
|
||||
.map(|bits| bits.count_ones() as usize)
|
||||
.map(detail_count)
|
||||
.sum()
|
||||
}
|
||||
/// An active tile whose columns take generated chunks' detail.
|
||||
fn wants_detail(&self, key: &TileKey) -> bool {
|
||||
key.level <= DETAIL_LEVEL
|
||||
&& !self.plan.split.contains(key)
|
||||
&& !(key.level == 0 && self.covered.contains(&(key.x, key.z)))
|
||||
}
|
||||
pub fn removed(&mut self) -> Vec<TileKey> {
|
||||
std::mem::take(&mut self.removed)
|
||||
}
|
||||
@@ -1611,6 +1639,11 @@ impl Lod {
|
||||
pub fn observing(&self) -> usize {
|
||||
self.shared.observed.lock().unwrap().len()
|
||||
}
|
||||
/// Whether tiles new to the plan are being sampled: far terrain on its way,
|
||||
/// meshed as soon as it is.
|
||||
pub fn sampling(&self) -> bool {
|
||||
!self.pending.is_empty()
|
||||
}
|
||||
/// Keeps a visited chunk's summary, unless it is unchanged.
|
||||
fn finish_observed(&mut self, key: (i32, i32), columns: ReducedColumns) {
|
||||
if self
|
||||
@@ -1733,9 +1766,43 @@ impl Lod {
|
||||
// neighbours. Coarser tiles do not read visited columns (stride > 16),
|
||||
// so re-meshing every tile touching the chunk was pure waste.
|
||||
}
|
||||
/// Whether the near terrain is still loading: on small machines far
|
||||
/// terrain then holds back (`HELD_SAMPLE_STEP`).
|
||||
/// Whether the near terrain is still loading: far terrain then queues
|
||||
/// `SAMPLE_JOBS` at most, and on small machines holds back
|
||||
/// (`HELD_SAMPLE_STEP`).
|
||||
pub fn set_near_loading(&mut self, loading: bool) {
|
||||
if loading && !self.near_loading {
|
||||
// The queue filled while nothing loaded nearby; the near chunks
|
||||
// must not wait behind it. Larger machines keep its first
|
||||
// `SAMPLE_JOBS`, small ones none.
|
||||
let mut keep = if self.small { 0 } else { SAMPLE_JOBS };
|
||||
let dropped = drop_queued_samples(&self.shared, &mut self.pending, |_| {
|
||||
let kept = keep > 0;
|
||||
keep = keep.saturating_sub(1);
|
||||
kept
|
||||
});
|
||||
// Back in line where `wanted_tiles` put them: the near terrain can
|
||||
// start and stop loading every few frames, and rebuilding the whole
|
||||
// queue each time cost more than the samples it held back.
|
||||
let camera = self.plan.camera;
|
||||
self.wanted.extend(dropped.into_iter().map(|key| {
|
||||
let class = if self.plan.split.contains(&key) {
|
||||
BACKDROP
|
||||
} else if key.reach(camera) < self.plan.first {
|
||||
SEAM
|
||||
} else {
|
||||
REST
|
||||
};
|
||||
std::cmp::Reverse(Wanted {
|
||||
class,
|
||||
distance: i64::from(key.distance(camera)),
|
||||
x: key.x,
|
||||
z: key.z,
|
||||
level: key.level,
|
||||
})
|
||||
}));
|
||||
// A sample may still be running: the held pace starts from now.
|
||||
self.held_sample_at = std::time::Instant::now();
|
||||
}
|
||||
self.near_loading = loading;
|
||||
let held = self.held();
|
||||
if self.shared.mesh_held.swap(held, Ordering::Release) && !held {
|
||||
@@ -1764,10 +1831,7 @@ impl Lod {
|
||||
.active
|
||||
.iter()
|
||||
.filter(|key| {
|
||||
key.level <= DETAIL_LEVEL
|
||||
&& !self.plan.split.contains(key)
|
||||
&& !(key.level == 0 && self.covered.contains(&(key.x, key.z)))
|
||||
&& self.detail_masks.get(key) != Some(&[u64::MAX; 4])
|
||||
self.wants_detail(key) && self.detail_masks.get(key) != Some(&[u64::MAX; 4])
|
||||
})
|
||||
.copied()
|
||||
.collect();
|
||||
@@ -1876,6 +1940,11 @@ impl Lod {
|
||||
self.reduced_detail,
|
||||
);
|
||||
self.ready.retain(|key, _| self.plan.nodes.contains(key));
|
||||
// Queued tiles the view left are not sampled (a running one is
|
||||
// dropped when it finishes, `finish_sample`).
|
||||
drop_queued_samples(&self.shared, &mut self.pending, |key| {
|
||||
self.plan.nodes.contains(key)
|
||||
});
|
||||
self.detail_masks
|
||||
.retain(|key, _| self.plan.nodes.contains(key));
|
||||
// Complete resident columns need no duplicate noise work.
|
||||
@@ -2245,7 +2314,8 @@ impl Lod {
|
||||
self.pending.is_empty() && self.held_sample_at.elapsed() >= HELD_SAMPLE_STEP,
|
||||
)
|
||||
} else {
|
||||
// The extra sampling threads wait until the near terrain is in.
|
||||
// `SAMPLE_JOBS` while the near terrain loads, the full queue
|
||||
// (`QUEUED_PER_SAMPLER` a thread) once it is in.
|
||||
let slots = if self.near_loading {
|
||||
SAMPLE_JOBS
|
||||
} else {
|
||||
@@ -2467,6 +2537,28 @@ impl Drop for Lod {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The columns of a tile's `detail_masks` entry that have their detail.
|
||||
fn detail_count(mask: &[u64; 4]) -> usize {
|
||||
mask.iter().map(|bits| bits.count_ones() as usize).sum()
|
||||
}
|
||||
/// Removes the queued sample jobs `keep` rejects (in queue order, best
|
||||
/// first) and frees their slots; the tiles removed.
|
||||
fn drop_queued_samples(
|
||||
shared: &Shared,
|
||||
pending: &mut FxHashSet<TileKey>,
|
||||
mut keep: impl FnMut(&TileKey) -> bool,
|
||||
) -> Vec<TileKey> {
|
||||
let mut dropped = Vec::new();
|
||||
shared.jobs.lock().unwrap().retain(|job| match job {
|
||||
Job::Sample(key) if !keep(key) => {
|
||||
pending.remove(key);
|
||||
dropped.push(*key);
|
||||
false
|
||||
}
|
||||
_ => true,
|
||||
});
|
||||
dropped
|
||||
}
|
||||
/// Fine retiring stand-ins need actual column detail before `Plan::replaces`
|
||||
/// can release their normal meshes. The caller keeps the usual detail-request
|
||||
/// budget and waits for the replacement mesh before changing cutout.
|
||||
@@ -3595,6 +3687,100 @@ mod tests {
|
||||
assert_eq!(lod.shared.mesh_jobs.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_samples_follow_the_view_and_make_way_for_the_near_terrain() {
|
||||
let mut lod = Lod::scheduler_for_test();
|
||||
lod.small = true;
|
||||
lod.sample_jobs = 16;
|
||||
let queued = |lod: &Lod| -> Vec<TileKey> {
|
||||
let jobs = lod.shared.jobs.lock().unwrap();
|
||||
jobs.iter()
|
||||
.filter_map(|job| match job {
|
||||
Job::Sample(key) => Some(*key),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let observing = |lod: &Lod| {
|
||||
let jobs = lod.shared.jobs.lock().unwrap();
|
||||
jobs.iter().any(|job| matches!(job, Job::Observe((5, 5))))
|
||||
};
|
||||
let pending = |lod: &Lod| -> Vec<TileKey> {
|
||||
let mut keys: Vec<_> = lod.pending.iter().copied().collect();
|
||||
keys.sort();
|
||||
keys
|
||||
};
|
||||
let sorted = |mut keys: Vec<TileKey>| {
|
||||
keys.sort();
|
||||
keys
|
||||
};
|
||||
let none = || Some(FxHashSet::default());
|
||||
lod.update([8., 64., 8.], 2, 64, none());
|
||||
let before = queued(&lod);
|
||||
assert_eq!(before.len(), 16, "a full queue once the near terrain is in");
|
||||
lod.shared
|
||||
.jobs
|
||||
.lock()
|
||||
.unwrap()
|
||||
.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());
|
||||
let moved = queued(&lod);
|
||||
let kept: Vec<_> = before
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|key| lod.plan.nodes.contains(key))
|
||||
.collect();
|
||||
assert!(!kept.is_empty());
|
||||
assert_eq!(moved[..kept.len()], kept[..]);
|
||||
assert_eq!(moved.len(), 16);
|
||||
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());
|
||||
let after = queued(&lod);
|
||||
assert_eq!(after.len(), 16);
|
||||
assert!(after.iter().all(|key| !moved.contains(key)));
|
||||
assert!(after.iter().all(|key| lod.plan.nodes.contains(key)));
|
||||
assert_eq!(
|
||||
pending(&lod),
|
||||
sorted(after.clone()),
|
||||
"dropped jobs free their slots"
|
||||
);
|
||||
|
||||
// New chunks load nearby: the queue gives way at once, not after it
|
||||
// drained, and the dropped tiles come back, best first, once they are in.
|
||||
lod.set_near_loading(true);
|
||||
assert!(queued(&lod).is_empty());
|
||||
assert!(lod.pending.is_empty());
|
||||
assert!(observing(&lod));
|
||||
lod.set_near_loading(false);
|
||||
lod.update([100_000., 64., 100_000.], 2, 64, none());
|
||||
assert_eq!(queued(&lod), after);
|
||||
|
||||
// Larger machines keep the first `SAMPLE_JOBS` of the queue.
|
||||
lod.small = false;
|
||||
lod.set_near_loading(true);
|
||||
assert_eq!(queued(&lod), after[..SAMPLE_JOBS]);
|
||||
assert_eq!(pending(&lod), sorted(after[..SAMPLE_JOBS].to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_progress_counts_the_columns_that_take_generated_detail() {
|
||||
let mut lod = Lod::scheduler_for_test();
|
||||
let tile = |x, level| TileKey { x, z: 0, level };
|
||||
let (fine, covered, coarse, split) = (tile(0, 0), tile(1, 0), tile(1, 3), tile(1, 2));
|
||||
lod.active = [fine, covered, coarse, split].into_iter().collect();
|
||||
lod.covered.insert((1, 0));
|
||||
lod.plan.split.insert(split);
|
||||
lod.detail_masks.insert(fine, [u64::MAX, 1, 0, 0]);
|
||||
lod.detail_masks.insert(coarse, [u64::MAX; 4]);
|
||||
// Only the fine tile: the covered one is the near chunk's, the split
|
||||
// one stands in for finer tiles and the coarse one is past the detail.
|
||||
assert_eq!(lod.detail_progress(), (256, 65));
|
||||
}
|
||||
|
||||
/// Drives the scheduler by hand: every sample the new incremental path
|
||||
/// produces must select exactly the tiles `Plan::select` selects, after
|
||||
/// every single sample, and across camera moves and distance changes.
|
||||
|
||||
@@ -160,6 +160,11 @@ impl MenuBlur {
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
/// Give the targets' video memory back: no frame blurs until the next
|
||||
/// screen that does, which makes them again.
|
||||
pub fn release(&mut self) {
|
||||
self.targets.clear();
|
||||
}
|
||||
/// Blur `frame` (the swapchain texture) in place through `target`, its
|
||||
/// non-sRGB view, by `radius` pixels (`menuBackgroundBlurriness`).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -238,6 +243,90 @@ impl MenuBlur {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A frame of hard stripes, blurred, read back.
|
||||
fn blurred(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
blur: &mut MenuBlur,
|
||||
size: [u32; 2],
|
||||
) -> Vec<u8> {
|
||||
let format = wgpu::TextureFormat::Rgba8Unorm;
|
||||
let extent = wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
};
|
||||
let stripes: Vec<u8> = (0..size[0] * size[1])
|
||||
.flat_map(|i| if i % 4 < 2 { [255; 4] } else { [0, 0, 0, 255] })
|
||||
.collect();
|
||||
let frame = wgpu::util::DeviceExt::create_texture_with_data(
|
||||
device,
|
||||
queue,
|
||||
&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
|
||||
| wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
&stripes,
|
||||
);
|
||||
let out = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(size[0] * size[1] * 4),
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let view = frame.create_view(&Default::default());
|
||||
blur.apply(device, queue, &mut encoder, &frame, &view, size, 2.0, None);
|
||||
encoder.copy_texture_to_buffer(
|
||||
frame.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &out,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(size[0] * 4),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
extent,
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
out.slice(..).map_async(wgpu::MapMode::Read, |_| ());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
out.slice(..).get_mapped_range().unwrap().to_vec()
|
||||
}
|
||||
|
||||
/// Released between two screens, the blur makes its targets again and
|
||||
/// blurs the same.
|
||||
#[test]
|
||||
fn blurs_the_same_after_its_targets_were_released() {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
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 mut blur = MenuBlur::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb);
|
||||
let size = [64, 8];
|
||||
let first = blurred(&device, &queue, &mut blur, size);
|
||||
// The stripes are blurred into greys.
|
||||
assert!(first.chunks(4).any(|p| p[0] != 0 && p[0] != 255));
|
||||
blur.release();
|
||||
assert_eq!(blurred(&device, &queue, &mut blur, size), first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_blur_shader_validates() {
|
||||
let module = naga::front::wgsl::parse_str(include_str!("menu_blur.wgsl")).unwrap();
|
||||
|
||||
@@ -12,20 +12,21 @@ use crate::{
|
||||
assets::{Assets, RenderAssets},
|
||||
biome::Biomes,
|
||||
blocks::{BlockFacts, RenderShape},
|
||||
chunk::{Chunk, MIN_Y},
|
||||
chunk::{Chunk, Light, MIN_Y},
|
||||
graphics::LeafCullingMode,
|
||||
models::catalog::BakedFace,
|
||||
};
|
||||
use horizon_assets::model::Direction as ModelDirection;
|
||||
use horizon_core::{
|
||||
biome::BiomeZoom, geometry::Direction, position::BlockPos, random::position_seed,
|
||||
};
|
||||
use horizon_optifine::ctm::connect::Face as CtmFace;
|
||||
use horizon_optifine::ctm::connect::{Axes as CtmAxes, Face as CtmFace};
|
||||
use horizon_world::{
|
||||
block_render::FaceVisibility,
|
||||
fluid::{FluidKind, FluidState},
|
||||
registry::BlockStateId,
|
||||
};
|
||||
pub use sides::Sides;
|
||||
pub use sides::{Part, Sides, group_parts};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// An entity, item and effect vertex (the entity pipelines), with a face
|
||||
@@ -282,7 +283,7 @@ impl SectionMesh {
|
||||
pub const BOUNDS_PAD: f32 = 0.5;
|
||||
|
||||
/// Retain geometry capacity after an upload, but none of its old contents.
|
||||
pub(crate) fn clear(&mut self) {
|
||||
pub fn clear(&mut self) {
|
||||
self.solid.clear();
|
||||
self.translucent.clear();
|
||||
self.block_entities.clear();
|
||||
@@ -474,6 +475,9 @@ struct Info {
|
||||
overlay: bool,
|
||||
/// `LeavesBlock`: with opaque leaves the faces between two leaf blocks are skipped.
|
||||
leaves: bool,
|
||||
/// A glass pane: connected-texture tiles take the cube's UVs on it (OptiFine's pane models
|
||||
/// drop vanilla's mirrored faces).
|
||||
pane: bool,
|
||||
/// A full opaque cube: `RenderShape::Model`, `can_occlude`, `solid_render`
|
||||
/// and a full collision shape. The only block leaf culling treats as solid.
|
||||
opaque_cube: bool,
|
||||
@@ -501,6 +505,10 @@ pub struct MeshFacts {
|
||||
}
|
||||
pub struct MeshTables {
|
||||
info: Vec<Info>,
|
||||
/// `Info::solid` by state, packed apart: the opacity mask of a section
|
||||
/// reads 8,000 states, and a few bytes each stay in cache where `Info`
|
||||
/// does not.
|
||||
solid: Vec<bool>,
|
||||
pub visibility: FaceVisibility,
|
||||
water_still: u16,
|
||||
water_flow: u16,
|
||||
@@ -654,6 +662,7 @@ impl MeshTables {
|
||||
legacy_solid: s.legacy_solid,
|
||||
overlay: s.has_class("HalfTransparentBlock") || s.has_class("LeavesBlock"),
|
||||
leaves: s.has_class("LeavesBlock"),
|
||||
pane: s.has_class("IronBarsBlock") && name.ends_with("glass_pane"),
|
||||
opaque_cube: leaf_culling::opaque_cube(
|
||||
s.render_shape,
|
||||
s.can_occlude,
|
||||
@@ -668,6 +677,7 @@ impl MeshTables {
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
solid: info.iter().map(|i| i.solid).collect(),
|
||||
info,
|
||||
visibility,
|
||||
water_still: layer("minecraft:block/water_still")?,
|
||||
@@ -837,43 +847,66 @@ impl Padded {
|
||||
}
|
||||
fn fill(&mut self, region: &[[Option<&Chunk>; 3]; 3], sy: i32, own: &mut [u32; 4096]) {
|
||||
self.blocks.resize(P * P * P, 0);
|
||||
self.blocks.fill(0);
|
||||
self.light.resize(P * P * P, 0);
|
||||
let (blocks, light) = (&mut self.blocks, &mut self.light);
|
||||
let base_y = region[1][1].map_or(MIN_Y, Chunk::min_y) + sy * 16;
|
||||
// The section itself is unpacked in one pass; the border cells of the
|
||||
// neighbouring sections are read one by one.
|
||||
// The section itself is unpacked in one pass and copied row by row;
|
||||
// the border cells of the neighbouring sections are read one by one,
|
||||
// each run of them from a section found once.
|
||||
own.fill(0);
|
||||
let center = region[1][1].map(|c| &c.sections[sy as usize]);
|
||||
if let Some(section) = center {
|
||||
section.unpack(own);
|
||||
}
|
||||
// The x runs of a padded row by the chunk column they fall in.
|
||||
const RUNS: [(usize, i32, i32); 3] = [(0, -B, 0), (1, 0, 16), (2, 16, 16 + B)];
|
||||
for y in -B..16 + B {
|
||||
let wy = base_y + y;
|
||||
for z in -B..16 + B {
|
||||
for x in -B..16 + B {
|
||||
let chunk = region[((z + 16) >> 4) as usize][((x + 16) >> 4) as usize];
|
||||
let i = pi(x, y, z);
|
||||
if let Some(c) = chunk {
|
||||
blocks[i] =
|
||||
if (0..16).contains(&x) && (0..16).contains(&y) && (0..16).contains(&z)
|
||||
{
|
||||
own[(y << 8 | z << 4 | x) as usize]
|
||||
} else {
|
||||
c.get(x & 15, wy, z & 15)
|
||||
};
|
||||
light[i] = c.light(x & 15, wy, z & 15);
|
||||
} else if let Some(c) = region[1][1] {
|
||||
// A neighbour not loaded continues this chunk's edge
|
||||
// column. As lit air, the outermost chunks' water drew
|
||||
// a wall toward it (fluids show both sides), a light
|
||||
// sheet in the water where far terrain carries on.
|
||||
// Vanilla draws no chunk without its neighbours.
|
||||
let (ex, ez) = (x.clamp(0, 15), z.clamp(0, 15));
|
||||
blocks[i] = c.get(ex, wy, ez);
|
||||
light[i] = c.light(ex, wy, ez);
|
||||
let row = pi(-B, y, z);
|
||||
let rz = ((z + 16) >> 4) as usize;
|
||||
for (rx, x0, x1) in RUNS {
|
||||
let (start, end) = (row + (x0 + B) as usize, row + (x1 + B) as usize);
|
||||
let (blocks, light) = (&mut blocks[start..end], &mut light[start..end]);
|
||||
let Some(c) = region[rz][rx] else {
|
||||
match region[1][1] {
|
||||
// A neighbour not loaded continues this chunk's edge
|
||||
// column. As lit air, the outermost chunks' water drew
|
||||
// a wall toward it (fluids show both sides), a light
|
||||
// sheet in the water where far terrain carries on.
|
||||
// Vanilla draws no chunk without its neighbours.
|
||||
Some(c) => {
|
||||
let ez = z.clamp(0, 15);
|
||||
for (k, x) in (x0..x1).enumerate() {
|
||||
let ex = x.clamp(0, 15);
|
||||
blocks[k] = c.get(ex, wy, ez);
|
||||
light[k] = c.light(ex, wy, ez);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
blocks.fill(0);
|
||||
light.fill(0xF0);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
};
|
||||
// `Chunk::get` and `Chunk::light` with the section found once.
|
||||
let s = (wy - c.min_y()) >> 4;
|
||||
let Some(section) = c.sections.get(s as usize).filter(|_| s >= 0) else {
|
||||
blocks.fill(0);
|
||||
light.fill(if s < 0 { 0 } else { 0xF0 });
|
||||
continue;
|
||||
};
|
||||
let at = crate::chunk::index(x0, wy, z);
|
||||
if rx == 1 && rz == 1 && (0..16).contains(&y) {
|
||||
let own_row = (y << 8 | z << 4) as usize;
|
||||
blocks.copy_from_slice(&own[own_row..own_row + 16]);
|
||||
} else {
|
||||
light[i] = 0xF0;
|
||||
section.blocks.get_run(at, blocks);
|
||||
}
|
||||
match §ion.light {
|
||||
Light::Uniform(v) => light.fill(*v),
|
||||
Light::Full(all) => light.copy_from_slice(&all[at..at + light.len()]),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -884,11 +917,33 @@ impl Padded {
|
||||
/// geometry still owned by the renderer, and total about 150 KiB per worker
|
||||
/// (the leaf mask adds 8,000 cells, depths and a queue).
|
||||
#[derive(Default)]
|
||||
pub(crate) struct MeshScratch {
|
||||
pub struct MeshScratch {
|
||||
padded: Padded,
|
||||
own: Option<Box<[u32; 4096]>>,
|
||||
visibility: Vec<usize>,
|
||||
leaf: leaf_culling::LeafMask,
|
||||
/// Per padded row (`pi(-B, y, z) / P`), bit `x + B` set where the block
|
||||
/// is `isSolidRender`.
|
||||
opaque: Vec<u32>,
|
||||
}
|
||||
/// Fills `rows` with the padded section's opacity, one bit per block (`MeshScratch::opaque`).
|
||||
fn opaque_rows(pad: &Padded, solid: &[bool], rows: &mut Vec<u32>) {
|
||||
rows.clear();
|
||||
rows.extend(pad.blocks.as_chunks::<P>().0.iter().map(|row| {
|
||||
row.iter().enumerate().fold(0u32, |bits, (x, &id)| {
|
||||
bits | u32::from(solid[id as usize]) << x
|
||||
})
|
||||
}));
|
||||
}
|
||||
/// Whether the block at `(x, y, z)` (section-local) and its six neighbours
|
||||
/// are all `isSolidRender`: none of its faces can show.
|
||||
#[inline]
|
||||
fn hidden(rows: &[u32], x: i32, y: i32, z: i32) -> bool {
|
||||
let row = |y: i32, z: i32| rows[((y + B) as usize) * P + (z + B) as usize];
|
||||
let r = row(y, z);
|
||||
let around =
|
||||
r & r << 1 & r >> 1 & row(y - 1, z) & row(y + 1, z) & row(y, z - 1) & row(y, z + 1);
|
||||
around >> (x + B) & 1 != 0
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod leaf_culling_tests;
|
||||
@@ -985,6 +1040,12 @@ enum Resolver {
|
||||
/// layer plus the blend radius around it, and the blended colours per column.
|
||||
struct Layer {
|
||||
zoomed: Vec<u16>,
|
||||
/// Each resolver's colour of every cell of `zoomed`, filled on first use:
|
||||
/// a cell is in the blend square of up to `(2r+1)^2` columns.
|
||||
cells: [Vec<u32>; 4],
|
||||
/// `BiomeZoom::corner_jitter` of the quart cells `zoomed` reaches, by
|
||||
/// their offset from the first: each is shared by up to 16 cells of the layer.
|
||||
jitter: Vec<Option<[[f64; 3]; 8]>>,
|
||||
colors: [[u32; 256]; 4],
|
||||
}
|
||||
/// `ClientLevel.calculateBlockTint` behind a per-layer `BlockTintCache`: each
|
||||
@@ -1036,6 +1097,16 @@ impl<'a> Tints<'a> {
|
||||
[x, y, z]: [i32; 3],
|
||||
) -> u16 {
|
||||
let q = zoom.quart_at_block(BlockPos::new(cx * 16 + x, base_y + y, cz * 16 + z));
|
||||
Self::quart_biome(region, plains, [cx, cz], q)
|
||||
}
|
||||
/// The biome of quart cell `q` in the loaded chunks around chunk `(cx, cz)`, plains where
|
||||
/// there is none.
|
||||
fn quart_biome(
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
plains: u16,
|
||||
[cx, cz]: [i32; 2],
|
||||
q: [i32; 3],
|
||||
) -> u16 {
|
||||
let (rx, rz) = ((q[0] >> 2) - cx + 1, (q[2] >> 2) - cz + 1);
|
||||
if (0..3).contains(&rx) && (0..3).contains(&rz) {
|
||||
region[rz as usize][rx as usize].map_or(plains, |c| {
|
||||
@@ -1052,6 +1123,8 @@ impl<'a> Tints<'a> {
|
||||
let layer = self.layers[y as usize].get_or_insert_with(|| {
|
||||
Box::new(Layer {
|
||||
zoomed: vec![NO_BIOME; dim * dim],
|
||||
cells: Default::default(),
|
||||
jitter: Vec::new(),
|
||||
colors: [[UNSET; 256]; 4],
|
||||
})
|
||||
});
|
||||
@@ -1060,21 +1133,45 @@ impl<'a> Tints<'a> {
|
||||
if cached != UNSET {
|
||||
return cached;
|
||||
}
|
||||
let [cx, cz, _] = self.origin;
|
||||
let (bx, bz) = (cx * 16, cz * 16);
|
||||
let [cx, cz, base_y] = self.origin;
|
||||
let (bx, by, bz) = (cx * 16, base_y + y, cz * 16);
|
||||
let (mut red, mut green, mut blue) = (0, 0, 0);
|
||||
let cells = &mut layer.cells[resolver as usize];
|
||||
if cells.is_empty() {
|
||||
cells.resize(dim * dim, UNSET);
|
||||
}
|
||||
for dz in -r..=r {
|
||||
for dx in -r..=r {
|
||||
let cell = ((z + dz + r) as usize) * dim + (x + dx + r) as usize;
|
||||
let known = cells[cell];
|
||||
if known != UNSET {
|
||||
red += known >> 16 & 255;
|
||||
green += known >> 8 & 255;
|
||||
blue += known & 255;
|
||||
continue;
|
||||
}
|
||||
let mut biome = layer.zoomed[cell];
|
||||
if biome == NO_BIOME {
|
||||
biome = Self::biome_id(
|
||||
&self.zoom,
|
||||
self.region,
|
||||
self.plains,
|
||||
self.origin,
|
||||
[x + dx, y, z + dz],
|
||||
);
|
||||
// BiomeManager.getBiome over the loaded chunks (plains
|
||||
// where there is none): `quart_at_block`, its cell's
|
||||
// corner jitter mixed once per layer.
|
||||
let block = BlockPos::new(bx + x + dx, by, bz + z + dz);
|
||||
let base = [block.x, block.y, block.z].map(|v| v.wrapping_sub(2) >> 2);
|
||||
let first = [bx - r, bz - r].map(|v| v.wrapping_sub(2) >> 2);
|
||||
let span = dim / 4 + 2;
|
||||
let at = (base[2] - first[1]) as usize * span + (base[0] - first[0]) as usize;
|
||||
if layer.jitter.is_empty() {
|
||||
layer.jitter.resize(span * span, None);
|
||||
}
|
||||
let jitter =
|
||||
layer.jitter[at].get_or_insert_with(|| self.zoom.corner_jitter(base));
|
||||
let corner = BiomeZoom::select_corner(block, jitter);
|
||||
let q = [
|
||||
base[0].wrapping_add((corner >> 2 & 1) as i32),
|
||||
base[1].wrapping_add((corner >> 1 & 1) as i32),
|
||||
base[2].wrapping_add((corner & 1) as i32),
|
||||
];
|
||||
biome = Self::quart_biome(self.region, self.plains, [cx, cz], q);
|
||||
layer.zoomed[cell] = biome;
|
||||
}
|
||||
let c = match resolver {
|
||||
@@ -1086,6 +1183,7 @@ impl<'a> Tints<'a> {
|
||||
Resolver::Dry => self.biomes.get(biome).dry_foliage,
|
||||
Resolver::Water => self.biomes.get(biome).water,
|
||||
};
|
||||
cells[cell] = c;
|
||||
red += c >> 16 & 255;
|
||||
green += c >> 8 & 255;
|
||||
blue += c & 255;
|
||||
@@ -1139,7 +1237,9 @@ impl Mesher<'_> {
|
||||
&mut MeshScratch::default(),
|
||||
)
|
||||
}
|
||||
pub(crate) fn mesh_reusing(
|
||||
/// The meshing workers' path: `out`'s buffers and `scratch` are reused
|
||||
/// rather than allocated per section.
|
||||
pub fn mesh_reusing(
|
||||
&mut self,
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
sy: i32,
|
||||
@@ -1156,6 +1256,8 @@ impl Mesher<'_> {
|
||||
scratch.padded.fill(region, sy, own);
|
||||
let pad = &scratch.padded;
|
||||
out.visibility = visibility_reusing(pad, &self.tables.info, &mut scratch.visibility);
|
||||
opaque_rows(pad, &self.tables.solid, &mut scratch.opaque);
|
||||
let opaque = &scratch.opaque;
|
||||
let base_y = center.min_y() + sy * 16;
|
||||
if self.leaf_culling.is_interior() {
|
||||
scratch
|
||||
@@ -1188,12 +1290,7 @@ impl Mesher<'_> {
|
||||
out.lights
|
||||
.push(([center.x * 16 + x, base_y + y, center.z * 16 + z], id));
|
||||
}
|
||||
if state.solid
|
||||
&& Direction::ALL.iter().all(|d| {
|
||||
let [dx, dy, dz] = d.normal();
|
||||
info[pad.blocks[pi(x + dx, y + dy, z + dz)] as usize].solid
|
||||
})
|
||||
{
|
||||
if hidden(opaque, x, y, z) {
|
||||
continue;
|
||||
}
|
||||
if state.fluid.kind() != FluidKind::Empty {
|
||||
@@ -1251,11 +1348,34 @@ impl Mesher<'_> {
|
||||
)
|
||||
.unwrap_or(true);
|
||||
}
|
||||
let connected = &self.render.connected;
|
||||
for model in &models {
|
||||
for face in &model.faces {
|
||||
if face.quad.cull.is_some_and(|d| !visible[d as usize]) {
|
||||
continue;
|
||||
}
|
||||
// A pane's top and bottom strips between stacked panes, with
|
||||
// connected textures.
|
||||
if state.pane
|
||||
&& !connected.is_empty()
|
||||
&& let Some(dy) = match face.quad.facing {
|
||||
ModelDirection::Up => Some(1),
|
||||
ModelDirection::Down => Some(-1),
|
||||
_ => None,
|
||||
}
|
||||
{
|
||||
let p = &face.quad.positions;
|
||||
// Only on the block's top or bottom plane: a pack's own pane
|
||||
// model may have ledges between.
|
||||
let plane = if dy > 0 { 1.0 } else { 0.0 };
|
||||
let on_edge = p.iter().all(|v| (v[1] - plane).abs() < 1e-4);
|
||||
let centre =
|
||||
[0, 2].map(|k| p.iter().map(|v| v[k]).sum::<f32>() / 4.0);
|
||||
let other = pad.blocks[pi(x, y + dy, z)];
|
||||
if on_edge && connected.stacked_pane_hides(id, other, centre) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
self.face(
|
||||
pad,
|
||||
&mut tints,
|
||||
@@ -1333,8 +1453,11 @@ impl Mesher<'_> {
|
||||
([lighting::gray(directional); 4], [coords; 4])
|
||||
};
|
||||
let tint = Self::tint(state, q.tint_index, tints, pos);
|
||||
// OptiFine connected textures: another sprite, or one per quarter.
|
||||
// OptiFine connected textures: another sprite, or one per quarter, and the overlays
|
||||
// drawn over it (a few per face at most; more are dropped).
|
||||
let connected = &self.render.connected;
|
||||
let mut overlays = [None; 8];
|
||||
let mut count = 0;
|
||||
let pick = if connected.is_empty() {
|
||||
Pick::Keep
|
||||
} else {
|
||||
@@ -1346,27 +1469,54 @@ impl Mesher<'_> {
|
||||
[world.x, world.y, world.z],
|
||||
|[dx, dy, dz]| pad.blocks[pi(pos[0] + dx, pos[1] + dy, pos[2] + dz)],
|
||||
|| Some(tints.biome(pos[0], pos[1], pos[2])),
|
||||
// A pane's tiles are drawn with the cube's UVs (below), so that is its texture.
|
||||
|| {
|
||||
if state.pane {
|
||||
Some(CtmAxes::cube(CtmFace::ALL[q.facing as usize]))
|
||||
} else {
|
||||
CtmAxes::texture(&q.positions, &q.uv)
|
||||
}
|
||||
},
|
||||
|overlay| {
|
||||
if let Some(slot) = overlays.get_mut(count) {
|
||||
*slot = Some(overlay);
|
||||
count += 1;
|
||||
}
|
||||
},
|
||||
)
|
||||
};
|
||||
let mut corners = [(TerrainVertex::new([0.0; 3], [0.0; 2], 0, 0, 0), [0.0; 2]); 4];
|
||||
for (i, corner) in corners.iter_mut().enumerate() {
|
||||
let g = gray[i] as u32;
|
||||
let rgb = match tint {
|
||||
Some(t) => [16, 8, 0].map(|shift| g * (t >> shift & 255) / 255),
|
||||
None => [g; 3],
|
||||
};
|
||||
let p = q.positions[i];
|
||||
*corner = (
|
||||
TerrainVertex::new(
|
||||
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
|
||||
q.uv[i],
|
||||
0,
|
||||
emitting(pack_rgb(rgb), tier),
|
||||
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
|
||||
),
|
||||
q.uv[i],
|
||||
);
|
||||
}
|
||||
let vertices = |tint: Option<u32>, uv: [[f32; 2]; 4]| {
|
||||
std::array::from_fn::<_, 4, _>(|i| {
|
||||
let g = gray[i] as u32;
|
||||
let rgb = match tint {
|
||||
Some(t) => [16, 8, 0].map(|shift| g * (t >> shift & 255) / 255),
|
||||
None => [g; 3],
|
||||
};
|
||||
let p = q.positions[i];
|
||||
(
|
||||
TerrainVertex::new(
|
||||
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
|
||||
uv[i],
|
||||
0,
|
||||
emitting(pack_rgb(rgb), tier),
|
||||
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
|
||||
),
|
||||
uv[i],
|
||||
)
|
||||
})
|
||||
};
|
||||
// Vanilla's pane models mirror some faces' textures; OptiFine's don't, so a tile's borders
|
||||
// land where the neighbours are: on a pane, tiles take the cube's own UVs.
|
||||
let standard = || q.positions.map(|p| standard_uv(q.facing, p));
|
||||
let tiled = !matches!(pick, Pick::Keep);
|
||||
let corners = vertices(
|
||||
tint,
|
||||
if state.pane && tiled {
|
||||
standard()
|
||||
} else {
|
||||
q.uv
|
||||
},
|
||||
);
|
||||
// Opaque leaves (forceOpaque) and leaves a leaf-culling mode proved internal draw in the
|
||||
// solid layer whatever their sprite.
|
||||
let solid_leaves = !self.cutout_leaves && state.leaves || interior_leaf;
|
||||
@@ -1403,15 +1553,84 @@ impl Mesher<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Overlays: the face again, after it (equal depth passes), tinted as their rule says.
|
||||
// `layer=cutout` is an alpha cutout whatever the tile's alpha, but over a translucent
|
||||
// face it must be in the translucent pass too, or the face would draw over it.
|
||||
// Most faces have none: nothing below runs for them.
|
||||
if count == 0 {
|
||||
return;
|
||||
}
|
||||
let face_translucent = match pick {
|
||||
Pick::Keep => self.translucent(own),
|
||||
Pick::Tile(t) => self.translucent(tile(t)),
|
||||
Pick::Quarters(tiles) => tiles.into_iter().any(|t| self.translucent(tile(t))),
|
||||
};
|
||||
// Overlay tiles face the standard way too (Continuity: "overlay textures always have the
|
||||
// standard orientation"), whatever turns the model's own UVs (dirt's and sand's random
|
||||
// rotations).
|
||||
// Over a `ctm_compact` face, the same pieces: the same depths and sort centres.
|
||||
let cuts = match pick {
|
||||
Pick::Quarters(_) => cuts(&corners),
|
||||
_ => Cuts([0.0, 1.0, 1.0, 1.0], 2),
|
||||
};
|
||||
for overlay in overlays[..count].iter().flatten().copied() {
|
||||
let tint = overlay.tint.and_then(|(index, block)| {
|
||||
let info = block
|
||||
.and_then(|b| self.tables.info.get(b as usize))
|
||||
.unwrap_or(state);
|
||||
Self::tint(info, index, tints, pos)
|
||||
});
|
||||
let tile = TileLayer::Layer {
|
||||
layer: overlay.layer,
|
||||
translucent: overlay.translucent || face_translucent,
|
||||
opaque: false,
|
||||
};
|
||||
let whole = vertices(tint, standard());
|
||||
for s in cuts.windows(2) {
|
||||
for t in cuts.windows(2) {
|
||||
let piece = [(s[0], t[0]), (s[1], t[0]), (s[1], t[1]), (s[0], t[1])]
|
||||
.map(|(s, t)| bilinear(&whole, s, t).0);
|
||||
self.quad_as(out, false, tile, piece, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// One terrain quad with sprite `tile`, in the pass its alpha needs (`solid_leaves`: no
|
||||
/// alpha cutout whatever the sprite).
|
||||
fn quad(
|
||||
&self,
|
||||
out: &mut SectionMesh,
|
||||
solid_leaves: bool,
|
||||
tile: TileLayer,
|
||||
v: [TerrainVertex; 4],
|
||||
) {
|
||||
self.quad_as(out, solid_leaves, tile, v, true);
|
||||
}
|
||||
/// Whether [`Mesher::quad`] draws `tile` in the translucent pass.
|
||||
fn translucent(&self, tile: TileLayer) -> bool {
|
||||
match tile {
|
||||
TileLayer::Layer {
|
||||
layer, translucent, ..
|
||||
} => {
|
||||
translucent
|
||||
|| self
|
||||
.translucent_layers
|
||||
.get(usize::from(layer))
|
||||
.copied()
|
||||
.unwrap_or(false)
|
||||
}
|
||||
TileLayer::Skip | TileLayer::Default => false,
|
||||
}
|
||||
}
|
||||
/// [`Mesher::quad`], with the sprite's own translucency (`translucent_layers`) counted only
|
||||
/// when `by_sprite`: an overlay's pass is its rule's.
|
||||
fn quad_as(
|
||||
&self,
|
||||
out: &mut SectionMesh,
|
||||
solid_leaves: bool,
|
||||
tile: TileLayer,
|
||||
mut v: [TerrainVertex; 4],
|
||||
by_sprite: bool,
|
||||
) {
|
||||
let TileLayer::Layer {
|
||||
layer,
|
||||
@@ -1424,12 +1643,11 @@ impl Mesher<'_> {
|
||||
if self.tables.is_animated(layer) {
|
||||
out.note_sprite_layer(layer);
|
||||
}
|
||||
let translucent = translucent
|
||||
|| self
|
||||
.translucent_layers
|
||||
.get(usize::from(layer))
|
||||
.copied()
|
||||
.unwrap_or(false);
|
||||
let translucent = if by_sprite {
|
||||
self.translucent(tile)
|
||||
} else {
|
||||
translucent
|
||||
};
|
||||
// Quads over opaque texels draw in the solid layer: no alpha cutout (a dirt path's side
|
||||
// skips its clear top row). All four vertices share the layer.
|
||||
let layer = if opaque && !translucent || solid_leaves {
|
||||
@@ -1484,6 +1702,19 @@ fn cuts(c: &[(TerrainVertex, [f32; 2]); 4]) -> Cuts {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// The UV a vanilla cube's face gives the block-local point `p` (`BlockElement`'s default UVs),
|
||||
/// in sprite units: up is north on the top face, south on the bottom one, and up on the sides.
|
||||
fn standard_uv(facing: horizon_assets::model::Direction, [x, y, z]: [f32; 3]) -> [f32; 2] {
|
||||
use horizon_assets::model::Direction as D;
|
||||
match facing {
|
||||
D::Down => [x, 1.0 - z],
|
||||
D::Up => [x, z],
|
||||
D::North => [1.0 - x, 1.0 - y],
|
||||
D::South => [x, 1.0 - y],
|
||||
D::West => [z, 1.0 - y],
|
||||
D::East => [1.0 - z, 1.0 - y],
|
||||
}
|
||||
}
|
||||
/// The quarter of the sprite (top-left, top-right, bottom-left, bottom-right) a piece of a
|
||||
/// quad lies in, by its UV centre (v grows downward).
|
||||
fn quarter_of(piece: &[(TerrainVertex, [f32; 2]); 4]) -> usize {
|
||||
@@ -1594,4 +1825,17 @@ mod quarter_tests {
|
||||
[0, 1, 2, 3]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlays_take_the_cubes_own_uvs() {
|
||||
use horizon_assets::model::Direction as D;
|
||||
// A top face: the north-west corner is the sprite's top-left, so a tile's top edge is
|
||||
// north whatever the model's turn.
|
||||
assert_eq!(standard_uv(D::Up, [0.0, 1.0, 0.0]), [0.0, 0.0]);
|
||||
assert_eq!(standard_uv(D::Up, [1.0, 1.0, 1.0]), [1.0, 1.0]);
|
||||
// A north face seen from the north: its top-left is the east top corner.
|
||||
assert_eq!(standard_uv(D::North, [1.0, 1.0, 0.0]), [0.0, 0.0]);
|
||||
assert_eq!(standard_uv(D::East, [1.0, 0.0, 1.0]), [0.0, 1.0]);
|
||||
assert_eq!(standard_uv(D::Down, [0.0, 0.0, 1.0]), [0.0, 0.0]);
|
||||
}
|
||||
}
|
||||
@@ -322,6 +322,7 @@ mod tests {
|
||||
legacy_solid: false,
|
||||
overlay: leaves,
|
||||
leaves,
|
||||
pane: false,
|
||||
opaque_cube: opaque,
|
||||
blocks_flow: false,
|
||||
ice: false,
|
||||
|
||||
@@ -16,6 +16,15 @@
|
||||
//! each. Coplanar quads facing one way keep their model order, so an overlay
|
||||
//! on its base still draws in that order. Solid cutout quads are otherwise
|
||||
//! ordered from the outside toward the section interior to reduce overdraw.
|
||||
//!
|
||||
//! The opaque quads (`OPAQUE_LAYER`) and the cutout ones of each bucket are
|
||||
//! apart, so that each of Metal's two solid passes draws only its own quads
|
||||
//! (`Part`). Where the renderer draws them in two passes (`group_parts`), all
|
||||
//! the opaque buckets come first, then all the cutout ones, so each part is
|
||||
//! one or two runs; elsewhere each bucket holds its opaque quads, then its
|
||||
//! cutout ones, so one pass over all of them draws the runs it drew before.
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use super::{OPAQUE_LAYER, TerrainVertex};
|
||||
|
||||
/// The buckets in mesh order: +X, +Z, other, +Y, -Z, -X, -Y. Seen from
|
||||
@@ -44,12 +53,37 @@ enum Bucket {
|
||||
Other,
|
||||
}
|
||||
|
||||
/// Where each bucket of a mesh's solid quads ends, and the planes that tell
|
||||
/// whether it can face the eye.
|
||||
/// Whether meshes sorted from now on put every bucket's opaque quads before
|
||||
/// any cutout ones: set by the renderer when it draws them in two passes.
|
||||
static GROUPED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Sorts later meshes for two solid passes (`true`) or one.
|
||||
pub fn group_parts(on: bool) {
|
||||
GROUPED.store(on, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// The bucket and whether it holds cutout quads of the `n`th part of a
|
||||
/// mesh, in mesh order.
|
||||
fn slot(grouped: bool, n: usize) -> (usize, bool) {
|
||||
if grouped {
|
||||
(n % 7, n >= 7)
|
||||
} else {
|
||||
(n / 2, n % 2 == 1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where each part of each bucket of a mesh's solid quads ends, and the
|
||||
/// planes that tell whether a bucket can face the eye.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Sides {
|
||||
/// The solid quads up to the end of each bucket, in `BUCKETS` order.
|
||||
ends: [u32; 7],
|
||||
/// The solid quads up to the end of each part of each bucket, in mesh
|
||||
/// order (`slot`).
|
||||
ends: [u32; 14],
|
||||
/// Whether all the opaque parts come first (`group_parts`).
|
||||
grouped: bool,
|
||||
/// Whether opaque and cutout quads are apart; else either part is every
|
||||
/// quad.
|
||||
split: bool,
|
||||
/// Per bucket facing a positive direction, its lowest plane; facing a
|
||||
/// negative one, its highest (unused for the other bucket).
|
||||
planes: [f32; 7],
|
||||
@@ -59,43 +93,98 @@ impl Sides {
|
||||
/// `quads` solid quads in one bucket drawn from everywhere: for meshes
|
||||
/// that were not sorted.
|
||||
pub fn unsorted(quads: u32) -> Self {
|
||||
let mut ends = [0; 7];
|
||||
ends[OTHER..].fill(quads);
|
||||
let mut ends = [0; 14];
|
||||
ends[OTHER * 2..].fill(quads);
|
||||
Self {
|
||||
ends,
|
||||
grouped: false,
|
||||
split: false,
|
||||
planes: [0.0; 7],
|
||||
}
|
||||
}
|
||||
|
||||
/// 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[6]
|
||||
self.ends[13]
|
||||
}
|
||||
|
||||
/// Calls `draw(first, count)` for each run of quads an eye at `eye`
|
||||
/// (relative to the mesh's origin) can see the front of, nearest the
|
||||
/// start first. Empty buckets do not break a run.
|
||||
pub fn visible(&self, eye: [f32; 3], draw: impl FnMut(u32, u32)) {
|
||||
self.visible_from(eye, eye[1], draw);
|
||||
self.visible_from(eye, eye[1], Part::All, draw);
|
||||
}
|
||||
|
||||
/// `visible` for every eye from `eye` straight down to the height `low`: the camera and
|
||||
/// its water mirror's eye below the plane, which differ only in y.
|
||||
pub fn visible_from(&self, eye: [f32; 3], low: f32, mut draw: impl FnMut(u32, u32)) {
|
||||
/// its water mirror's eye below the plane, which differ only in y. Only the quads of
|
||||
/// `part`, but every quad of a mesh whose parts are not split.
|
||||
pub fn visible_from(&self, eye: [f32; 3], low: f32, part: Part, draw: impl FnMut(u32, u32)) {
|
||||
let seen = |i: usize| match BUCKETS[i] {
|
||||
Bucket::Side(axis, true) => eye[axis] > self.planes[i] - MARGIN,
|
||||
Bucket::Side(1, false) => low < self.planes[i] + MARGIN,
|
||||
Bucket::Side(axis, false) => eye[axis] < self.planes[i] + MARGIN,
|
||||
Bucket::Other => true,
|
||||
};
|
||||
self.runs(seen, part, draw);
|
||||
}
|
||||
|
||||
/// How many quads `part` draws facing every way (`every`).
|
||||
pub fn part_quads(&self, part: Part) -> u32 {
|
||||
let mut quads = 0;
|
||||
self.every(part, |_, count| quads += count);
|
||||
quads
|
||||
}
|
||||
|
||||
/// The facts `visible_from` reads, for the GPU to find the same runs
|
||||
/// (gpu_lists.wgsl): the parts' ends, the planes' bits,
|
||||
/// then 1 when grouped plus 2 when split.
|
||||
pub fn gpu_row(&self) -> [u32; 22] {
|
||||
let mut row = [0; 22];
|
||||
row[..14].copy_from_slice(&self.ends);
|
||||
for (to, plane) in row[14..21].iter_mut().zip(self.planes) {
|
||||
*to = plane.to_bits();
|
||||
}
|
||||
row[21] = u32::from(self.grouped) | u32::from(self.split) << 1;
|
||||
row
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
/// Calls `draw` for each run of `part`'s quads in the buckets `seen` keeps.
|
||||
fn runs(&self, seen: impl Fn(usize) -> bool, part: Part, mut draw: impl FnMut(u32, u32)) {
|
||||
let mut start = 0;
|
||||
let mut run: Option<u32> = None;
|
||||
for (i, bucket) in BUCKETS.iter().enumerate() {
|
||||
let end = self.ends[i];
|
||||
for (n, &end) in self.ends.iter().enumerate() {
|
||||
if end == start {
|
||||
continue;
|
||||
}
|
||||
let seen = match *bucket {
|
||||
Bucket::Side(axis, true) => eye[axis] > self.planes[i] - MARGIN,
|
||||
Bucket::Side(1, false) => low < self.planes[i] + MARGIN,
|
||||
Bucket::Side(axis, false) => eye[axis] < self.planes[i] + MARGIN,
|
||||
Bucket::Other => true,
|
||||
let (bucket, cutout) = slot(self.grouped, n);
|
||||
let wanted = match part {
|
||||
Part::All => true,
|
||||
_ if !self.split => true,
|
||||
Part::Opaque => !cutout,
|
||||
Part::Cutout => cutout,
|
||||
};
|
||||
match (seen, run) {
|
||||
match (wanted && seen(bucket), run) {
|
||||
(true, None) => run = Some(start),
|
||||
(false, Some(first)) => {
|
||||
draw(first, start - first);
|
||||
@@ -111,6 +200,15 @@ impl Sides {
|
||||
}
|
||||
}
|
||||
|
||||
/// The quads `Sides::visible_from` draws: all of them, or those of one of
|
||||
/// Metal's two solid passes (terrain.wgsl's `opaque_quad`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Part {
|
||||
All,
|
||||
Opaque,
|
||||
Cutout,
|
||||
}
|
||||
|
||||
/// The bucket of a quad: the axis plane it lies in and the side its
|
||||
/// triangles (corners 0, 1, 2 and 0, 2, 3, as the renderer draws them) face.
|
||||
fn bucket(quad: &[TerrainVertex; 4]) -> usize {
|
||||
@@ -139,18 +237,56 @@ fn bucket(quad: &[TerrainVertex; 4]) -> usize {
|
||||
OTHER
|
||||
}
|
||||
|
||||
/// Reorders `solid`'s quads by bucket. Solid cutout quads are also ordered
|
||||
/// front to back within each axis-facing bucket; stable sorting preserves the
|
||||
/// mesher's order for coplanar overlays.
|
||||
/// Reorders `solid`'s quads by bucket, opaque ones first in each. Solid
|
||||
/// cutout quads are also ordered front to back within each axis-facing
|
||||
/// bucket; stable sorting preserves the mesher's order for coplanar overlays.
|
||||
pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
|
||||
sort_parts(solid, GROUPED.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// `sort`, with all the opaque parts first when `grouped`.
|
||||
fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
|
||||
SORT_SCRATCH.with_borrow_mut(|scratch| sort_with(solid, grouped, scratch))
|
||||
}
|
||||
|
||||
/// `sort`'s buffers, kept by each meshing thread: a section's quads went
|
||||
/// through fresh vectors, and the sorted copy replaced the mesh's own
|
||||
/// (recycled) buffer.
|
||||
#[derive(Default)]
|
||||
struct SortScratch {
|
||||
slots: Vec<u8>,
|
||||
keys: Vec<u64>,
|
||||
sorted: Vec<[TerrainVertex; 4]>,
|
||||
}
|
||||
thread_local! {
|
||||
static SORT_SCRATCH: std::cell::RefCell<SortScratch> = Default::default();
|
||||
}
|
||||
|
||||
/// `f32::total_cmp`'s order as an unsigned integer's.
|
||||
fn total_order_key(v: f32) -> u32 {
|
||||
let bits = v.to_bits() as i32;
|
||||
(bits ^ (((bits >> 31) as u32) >> 1) as i32) as u32 ^ 0x8000_0000
|
||||
}
|
||||
|
||||
fn sort_with(solid: &mut Vec<TerrainVertex>, grouped: bool, scratch: &mut SortScratch) -> Sides {
|
||||
let (quads, rest) = solid.as_chunks::<4>();
|
||||
debug_assert!(rest.is_empty(), "solid vertices come in quads");
|
||||
let buckets: Vec<u8> = quads.iter().map(|q| bucket(q) as u8).collect();
|
||||
let has_cutout = quads.iter().any(|q| q[0].layer & OPAQUE_LAYER == 0);
|
||||
let cutout = |q: &[TerrainVertex; 4]| q[0].layer & OPAQUE_LAYER == 0;
|
||||
// Each quad's part, by its place in mesh order (`slot`).
|
||||
let position = |b: usize, cutout: bool| match grouped {
|
||||
true => b + 7 * usize::from(cutout),
|
||||
false => b * 2 + usize::from(cutout),
|
||||
};
|
||||
let slots = &mut scratch.slots;
|
||||
slots.clear();
|
||||
slots.extend(quads.iter().map(|q| position(bucket(q), cutout(q)) as u8));
|
||||
let has_cutout = quads.iter().any(cutout);
|
||||
let mut counts = [0u32; 7];
|
||||
let mut slot_counts = [0u32; 14];
|
||||
let mut planes = [0f32; 7];
|
||||
for (quad, &b) in quads.iter().zip(&buckets) {
|
||||
let b = usize::from(b);
|
||||
for (quad, &part) in quads.iter().zip(slots.iter()) {
|
||||
slot_counts[usize::from(part)] += 1;
|
||||
let b = slot(grouped, usize::from(part)).0;
|
||||
if let Bucket::Side(axis, positive) = BUCKETS[b] {
|
||||
let p = quad[0].pos[axis];
|
||||
planes[b] = match (counts[b], positive) {
|
||||
@@ -161,48 +297,122 @@ pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
|
||||
}
|
||||
counts[b] += 1;
|
||||
}
|
||||
let mut ends = [0u32; 7];
|
||||
let mut slot_ends = [0u32; 14];
|
||||
let mut total = 0;
|
||||
for (end, count) in ends.iter_mut().zip(counts) {
|
||||
for (end, count) in slot_ends.iter_mut().zip(slot_counts) {
|
||||
total += count;
|
||||
*end = total;
|
||||
}
|
||||
if has_cutout || counts.iter().filter(|&&c| c > 0).count() > 1 {
|
||||
let mut next: [u32; 7] = std::array::from_fn(|b| ends[b] - counts[b]);
|
||||
let mut sorted = vec![quads[0]; quads.len()];
|
||||
for (quad, &b) in quads.iter().zip(&buckets) {
|
||||
let slot = &mut next[usize::from(b)];
|
||||
sorted[*slot as usize] = *quad;
|
||||
*slot += 1;
|
||||
}
|
||||
if has_cutout {
|
||||
let mut start = 0;
|
||||
for (i, bucket) in BUCKETS.iter().enumerate() {
|
||||
let end = ends[i] as usize;
|
||||
let slice = &mut sorted[start..end];
|
||||
if let Bucket::Side(axis, positive) = *bucket {
|
||||
slice.sort_by(|a, b| {
|
||||
let a = a[0].pos[axis];
|
||||
let b = b[0].pos[axis];
|
||||
if positive {
|
||||
b.total_cmp(&a)
|
||||
} else {
|
||||
a.total_cmp(&b)
|
||||
// One integer key per quad: its part, then (cutout meshes, side
|
||||
// buckets) its plane from the outside in, then its index, which keeps
|
||||
// the order of equal quads as a stable sort does.
|
||||
let keys = &mut scratch.keys;
|
||||
keys.clear();
|
||||
keys.extend(
|
||||
quads
|
||||
.iter()
|
||||
.zip(slots.iter())
|
||||
.enumerate()
|
||||
.map(|(i, (quad, &part))| {
|
||||
let plane = match BUCKETS[slot(grouped, usize::from(part)).0] {
|
||||
Bucket::Side(axis, positive) if has_cutout => {
|
||||
let key = total_order_key(quad[0].pos[axis]);
|
||||
if positive { !key } else { key }
|
||||
}
|
||||
});
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
*solid = sorted.into_flattened();
|
||||
_ => 0,
|
||||
};
|
||||
u64::from(part) << 56 | u64::from(plane) << 24 | i as u64
|
||||
}),
|
||||
);
|
||||
debug_assert!(quads.len() < 1 << 24);
|
||||
keys.sort_unstable();
|
||||
let sorted = &mut scratch.sorted;
|
||||
sorted.clear();
|
||||
sorted.extend(keys.iter().map(|&k| quads[(k & 0xFF_FFFF) as usize]));
|
||||
solid.clear();
|
||||
solid.extend_from_slice(sorted.as_flattened());
|
||||
}
|
||||
Sides {
|
||||
ends: slot_ends,
|
||||
grouped,
|
||||
split: true,
|
||||
planes,
|
||||
}
|
||||
Sides { ends, planes }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The sort as it was before the integer keys: quads moved to their
|
||||
/// parts in order, then a stable sort by plane in each side bucket.
|
||||
fn reference(solid: &[TerrainVertex], grouped: bool) -> Vec<TerrainVertex> {
|
||||
let quads = solid.as_chunks::<4>().0;
|
||||
let has_cutout = quads.iter().any(|q| q[0].layer & OPAQUE_LAYER == 0);
|
||||
let mut sorted = Vec::new();
|
||||
for n in 0..14 {
|
||||
let (b, cutout) = slot(grouped, n);
|
||||
let mut part: Vec<[TerrainVertex; 4]> = quads
|
||||
.iter()
|
||||
.filter(|q| bucket(q) == b && (q[0].layer & OPAQUE_LAYER == 0) == cutout)
|
||||
.copied()
|
||||
.collect();
|
||||
if let (Bucket::Side(axis, positive), true) = (BUCKETS[b], has_cutout) {
|
||||
part.sort_by(|a, b| {
|
||||
let (a, b) = (a[0].pos[axis], b[0].pos[axis]);
|
||||
if positive {
|
||||
b.total_cmp(&a)
|
||||
} else {
|
||||
a.total_cmp(&b)
|
||||
}
|
||||
});
|
||||
}
|
||||
sorted.extend(part);
|
||||
}
|
||||
sorted.into_flattened()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_keys_sort_as_the_stable_sort_did() {
|
||||
let mut state = 0x2545_F491_4F6C_DD1Du64;
|
||||
let mut next = move || {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 7;
|
||||
state ^= state << 17;
|
||||
state
|
||||
};
|
||||
for round in 0..100 {
|
||||
let grouped = round % 2 == 1;
|
||||
let mut solid = Vec::new();
|
||||
for _ in 0..(next() % 300) {
|
||||
let origin = [0, 1, 2].map(|_| (next() % 34) as f32 / 2.0 - 0.5);
|
||||
// Some negative and signed-zero planes too.
|
||||
let origin = if next() % 9 == 0 {
|
||||
origin.map(|v| -v)
|
||||
} else {
|
||||
origin
|
||||
};
|
||||
let layer = if round % 3 == 0 {
|
||||
OPAQUE_LAYER
|
||||
} else {
|
||||
(next() % 2) as u16 * OPAQUE_LAYER
|
||||
};
|
||||
let faces = cube(origin, layer);
|
||||
let face = (next() % 6) as usize;
|
||||
solid.extend_from_slice(&faces[face * 4..face * 4 + 4]);
|
||||
}
|
||||
let expected = reference(&solid, grouped);
|
||||
let sides = sort_parts(&mut solid, grouped);
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<_, u8>(&solid),
|
||||
bytemuck::cast_slice::<_, u8>(&expected),
|
||||
"round {round}"
|
||||
);
|
||||
assert_eq!(sides.quads() as usize * 4, solid.len());
|
||||
}
|
||||
}
|
||||
|
||||
fn quad(corners: [[f32; 3]; 4], layer: u16) -> [TerrainVertex; 4] {
|
||||
corners.map(|p| TerrainVertex::new(p, [0.0; 2], layer, 0, 0))
|
||||
}
|
||||
@@ -225,6 +435,12 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Where each bucket ends, in a mesh sorted for one pass.
|
||||
fn bucket_ends(sides: &Sides) -> [u32; 7] {
|
||||
assert!(!sides.grouped);
|
||||
std::array::from_fn(|b| sides.ends[b * 2 + 1])
|
||||
}
|
||||
|
||||
fn runs(sides: &Sides, eye: [f32; 3]) -> Vec<(u32, u32)> {
|
||||
let mut out = Vec::new();
|
||||
sides.visible(eye, |first, count| out.push((first, count)));
|
||||
@@ -261,7 +477,7 @@ mod tests {
|
||||
v.layer = (i / 4) as u16;
|
||||
}
|
||||
let sides = sort(&mut solid);
|
||||
assert_eq!(sides.ends, [1, 2, 2, 3, 4, 5, 6]);
|
||||
assert_eq!(bucket_ends(&sides), [1, 2, 2, 3, 4, 5, 6]);
|
||||
let layers: Vec<u16> = solid.iter().step_by(4).map(|v| v.layer).collect();
|
||||
// +X, +Z, +Y, -Z, -X, -Y.
|
||||
assert_eq!(layers, [0, 4, 2, 5, 1, 3]);
|
||||
@@ -348,7 +564,7 @@ mod tests {
|
||||
let sides = sort(&mut solid);
|
||||
let both = |eye: [f32; 3], low: f32| {
|
||||
let mut out = Vec::new();
|
||||
sides.visible_from(eye, low, |first, count| out.push((first, count)));
|
||||
sides.visible_from(eye, low, Part::All, |first, count| out.push((first, count)));
|
||||
out
|
||||
};
|
||||
// Above the block: +X, +Z and +Y; with the mirror's eye below it, -Y too.
|
||||
@@ -360,6 +576,98 @@ mod tests {
|
||||
assert_eq!(both([-8.0, 8.0, -8.0], -8.0), [(2, 4)]);
|
||||
}
|
||||
|
||||
/// Opaque and cutout quads of each bucket split: each pass's runs hold
|
||||
/// exactly its own quads facing the eye, and all of them together the
|
||||
/// same runs as one pass.
|
||||
#[test]
|
||||
fn each_part_draws_its_own_quads_facing_the_eye() {
|
||||
// Two blocks: one opaque, one cutout, then an opaque one again, so
|
||||
// every bucket holds both kinds out of order.
|
||||
let mut solid = Vec::new();
|
||||
let mut id = 0u16;
|
||||
for (origin, opaque) in [
|
||||
([0.0, 0.0, 0.0], true),
|
||||
([4.0, 2.0, 6.0], false),
|
||||
([9.0, 5.0, 3.0], true),
|
||||
] {
|
||||
for q in cube(origin, 0).chunks(4) {
|
||||
let layer = id | if opaque { OPAQUE_LAYER } else { 0 };
|
||||
solid.extend(q.iter().map(|v| TerrainVertex { layer, ..*v }));
|
||||
id += 1;
|
||||
}
|
||||
}
|
||||
let unsorted = solid;
|
||||
for grouped in [false, true] {
|
||||
let mut solid = unsorted.clone();
|
||||
let sides = sort_parts(&mut solid, grouped);
|
||||
let layers: Vec<u16> = solid
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|q| q[0].layer)
|
||||
.collect();
|
||||
for eye in [[20.0, 30.0, -10.0], [-5.0, 1.0, 4.0], [5.0, 3.0, 7.0]] {
|
||||
let quads = |part| {
|
||||
let mut out = Vec::new();
|
||||
sides.visible_from(eye, eye[1], part, |first, count| {
|
||||
out.extend(
|
||||
layers[first as usize..(first + count) as usize]
|
||||
.iter()
|
||||
.copied(),
|
||||
)
|
||||
});
|
||||
out.sort_unstable();
|
||||
out
|
||||
};
|
||||
let mut facing = front_facing(&solid, eye);
|
||||
facing.sort_unstable();
|
||||
let opaque = quads(Part::Opaque);
|
||||
let cutout = quads(Part::Cutout);
|
||||
assert!(opaque.iter().all(|l| l & OPAQUE_LAYER != 0), "{eye:?}");
|
||||
assert!(cutout.iter().all(|l| l & OPAQUE_LAYER == 0), "{eye:?}");
|
||||
for l in &facing {
|
||||
let mine = if l & OPAQUE_LAYER != 0 {
|
||||
&opaque
|
||||
} else {
|
||||
&cutout
|
||||
};
|
||||
assert!(
|
||||
mine.contains(l),
|
||||
"{eye:?}: quad {l} facing the eye left out"
|
||||
);
|
||||
}
|
||||
let mut both = [opaque, cutout].concat();
|
||||
both.sort_unstable();
|
||||
assert_eq!(both, quads(Part::All), "{eye:?}");
|
||||
}
|
||||
// Every way: each part once, whole.
|
||||
let mut every = Vec::new();
|
||||
sides.every(Part::Opaque, |first, count| every.push((first, count)));
|
||||
sides.every(Part::Cutout, |first, count| every.push((first, count)));
|
||||
let total: u32 = every.iter().map(|r| r.1).sum();
|
||||
assert_eq!(total, sides.quads());
|
||||
|
||||
// Two passes: from above a corner, each part is one or two runs.
|
||||
if grouped {
|
||||
let mut count = 0;
|
||||
sides.visible_from([20.0, 30.0, -10.0], 30.0, Part::Opaque, |_, _| count += 1);
|
||||
assert_eq!(count, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A mesh that was not sorted has no parts: each pass draws all of it
|
||||
/// (the shader leaves out the other pass's quads).
|
||||
#[test]
|
||||
fn unsorted_meshes_draw_everything_in_each_part() {
|
||||
let sides = Sides::unsorted(9);
|
||||
for part in [Part::Opaque, Part::Cutout] {
|
||||
let mut out = Vec::new();
|
||||
sides.every(part, |first, count| out.push((first, count)));
|
||||
assert_eq!(out, [(0, 9)]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsorted_meshes_draw_everything() {
|
||||
let sides = Sides::unsorted(9);
|
||||
@@ -385,7 +693,7 @@ mod tests {
|
||||
.collect();
|
||||
let before = solid.clone();
|
||||
let sides = sort(&mut solid);
|
||||
assert_eq!(sides.ends, [3; 7]);
|
||||
assert_eq!(bucket_ends(&sides), [3; 7]);
|
||||
assert_eq!(
|
||||
solid.iter().map(|v| v.layer).collect::<Vec<_>>(),
|
||||
before.iter().map(|v| v.layer).collect::<Vec<_>>()
|
||||
|
||||
@@ -88,6 +88,7 @@ fn info(solid: bool) -> Info {
|
||||
legacy_solid: false,
|
||||
overlay: false,
|
||||
leaves: false,
|
||||
pane: false,
|
||||
opaque_cube: solid,
|
||||
blocks_flow: false,
|
||||
ice: false,
|
||||
|
||||
@@ -10,6 +10,29 @@ use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::Path;
|
||||
|
||||
/// The standalone models native mods asked for (`native_standalone_models`).
|
||||
static STANDALONE: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
|
||||
|
||||
/// Sets the models no blockstate names that every resource load bakes
|
||||
/// beside the block models ([`StateModels::standalone`]): once, at start,
|
||||
/// as vanilla's `ModelEvent.RegisterStandalone` is posted once per model
|
||||
/// discovery. Names are sorted and deduplicated; one without a namespace
|
||||
/// (`mymod:block/shaft_half`) is dropped. False when already set.
|
||||
pub fn register_standalone(mut names: Vec<String>) -> bool {
|
||||
names.retain(|n| {
|
||||
n.split_once(':')
|
||||
.is_some_and(|(ns, path)| !ns.is_empty() && !path.is_empty())
|
||||
});
|
||||
names.sort();
|
||||
names.dedup();
|
||||
STANDALONE.set(names).is_ok()
|
||||
}
|
||||
|
||||
/// What [`register_standalone`] set; none before.
|
||||
pub fn standalone() -> &'static [String] {
|
||||
STANDALONE.get().map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
type Placement = (&'static str, &'static [(&'static str, &'static str)]);
|
||||
const GALLERY: [Placement; 16] = [
|
||||
(
|
||||
@@ -76,8 +99,9 @@ pub(crate) fn prepare_with_models(
|
||||
.filter(|name| registry.default_state(name).is_some())
|
||||
.map(|name| name.strip_prefix("minecraft:").unwrap_or(name))
|
||||
.collect();
|
||||
let catalog =
|
||||
let mut catalog =
|
||||
StateModels::from_facts_with_models(root, registry, &blocks, sprites, &facts, models)?;
|
||||
catalog.bake_standalone(root, sprites, models, standalone())?;
|
||||
let mut placements = Vec::new();
|
||||
let mut states = Vec::new();
|
||||
for (index, (name, properties)) in GALLERY.iter().enumerate() {
|
||||
@@ -149,3 +173,23 @@ pub fn load_visibility(
|
||||
.collect::<std::result::Result<_, &str>>()?;
|
||||
Ok(FaceVisibility::new(&occlusion, &definitions)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn standalone_names_are_namespaced_sorted_and_set_once() {
|
||||
assert!(super::standalone().is_empty());
|
||||
let names = [
|
||||
"lanef:block/b",
|
||||
"nonamespace",
|
||||
":x",
|
||||
"lanef:",
|
||||
"lanef:block/a",
|
||||
"lanef:block/b",
|
||||
];
|
||||
assert!(super::register_standalone(names.map(str::to_owned).into()));
|
||||
assert_eq!(super::standalone(), ["lanef:block/a", "lanef:block/b"]);
|
||||
assert!(!super::register_standalone(vec!["other:block/c".into()]));
|
||||
assert_eq!(super::standalone(), ["lanef:block/a", "lanef:block/b"]);
|
||||
}
|
||||
}
|
||||
@@ -52,10 +52,55 @@ pub struct StateModels {
|
||||
states: Vec<Option<(usize, SelectedModels)>>,
|
||||
/// Every transformed model once, with the variant it was baked from.
|
||||
baked: Vec<(Variant, BakedModel)>,
|
||||
/// Standalone models by id: their index in `baked`.
|
||||
standalone: BTreeMap<String, u32>,
|
||||
}
|
||||
fn key(variant: &Variant) -> VariantKey {
|
||||
(variant.model.clone(), variant.rotations, variant.uv_lock)
|
||||
}
|
||||
/// Bakes model `name` of `library`, turned and UV-locked as a variant says.
|
||||
fn bake_variant(
|
||||
root: &Path,
|
||||
sprites: &mut Sprites,
|
||||
library: &horizon_assets::resolve::ModelLibrary,
|
||||
name: &str,
|
||||
turns: [u8; 3],
|
||||
lock: bool,
|
||||
) -> Result<BakedModel> {
|
||||
bake_resolved(root, sprites, &library.resolve(name)?, turns, lock)
|
||||
}
|
||||
/// Bakes a resolved `model`, turned and UV-locked as a variant says.
|
||||
fn bake_resolved(
|
||||
root: &Path,
|
||||
sprites: &mut Sprites,
|
||||
model: &horizon_assets::resolve::ResolvedCuboidModel,
|
||||
turns: [u8; 3],
|
||||
lock: bool,
|
||||
) -> Result<BakedModel> {
|
||||
let particle = model
|
||||
.material("particle")?
|
||||
.map(|material| sprites.insert(root, &material.sprite))
|
||||
.transpose()?;
|
||||
let faces: Vec<BakedFace> = bake_block_model(model, BlockModelTransform::new(turns, lock))?
|
||||
.into_iter()
|
||||
.map(|quad| {
|
||||
let layer = sprites.insert(root, &quad.material.sprite)?;
|
||||
Ok(BakedFace {
|
||||
translucent: quad.material.force_translucent || sprites.translucent[layer as usize],
|
||||
opaque: sprites.region_opaque(layer, &quad.uv),
|
||||
quad,
|
||||
layer,
|
||||
})
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
let cube = super::cube::cube_styles(&faces);
|
||||
Ok(BakedModel {
|
||||
ambient_occlusion: model.ambient_occlusion,
|
||||
particle,
|
||||
faces,
|
||||
cube,
|
||||
})
|
||||
}
|
||||
impl StateModels {
|
||||
pub fn load(
|
||||
root: &Path,
|
||||
@@ -236,38 +281,13 @@ impl StateModels {
|
||||
let mut baked = Vec::with_capacity(variants.len());
|
||||
let mut index = BTreeMap::new();
|
||||
for ((name, turns, lock), model) in variants.into_iter().zip(resolved) {
|
||||
let model = model?;
|
||||
let particle = model
|
||||
.material("particle")?
|
||||
.map(|material| sprites.insert(root, &material.sprite))
|
||||
.transpose()?;
|
||||
let faces: Vec<BakedFace> =
|
||||
bake_block_model(&model, BlockModelTransform::new(turns, lock))?
|
||||
.into_iter()
|
||||
.map(|quad| {
|
||||
let layer = sprites.insert(root, &quad.material.sprite)?;
|
||||
Ok(BakedFace {
|
||||
translucent: quad.material.force_translucent
|
||||
|| sprites.translucent[layer as usize],
|
||||
opaque: sprites.region_opaque(layer, &quad.uv),
|
||||
quad,
|
||||
layer,
|
||||
})
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
let cube = super::cube::cube_styles(&faces);
|
||||
let model = bake_resolved(root, sprites, &model?, turns, lock)?;
|
||||
index.insert((name.clone(), turns, lock), baked.len() as u32);
|
||||
let variant = Variant {
|
||||
model: name,
|
||||
rotations: turns,
|
||||
uv_lock: lock,
|
||||
};
|
||||
let model = BakedModel {
|
||||
ambient_occlusion: model.ambient_occlusion,
|
||||
particle,
|
||||
faces,
|
||||
cube,
|
||||
};
|
||||
baked.push((variant, model));
|
||||
}
|
||||
// Every variant of every definition was baked above.
|
||||
@@ -276,8 +296,58 @@ impl StateModels {
|
||||
choices,
|
||||
states,
|
||||
baked,
|
||||
standalone: BTreeMap::new(),
|
||||
})
|
||||
}
|
||||
/// Bakes `names`, models no blockstate needs to name (vanilla's
|
||||
/// standalone models, NeoForge's `ModelEvent.RegisterStandalone`), as
|
||||
/// they are, unturned, for [`Self::standalone`]. One a block state
|
||||
/// already bakes unturned is shared. A model that doesn't read (missing,
|
||||
/// or a parent missing) is logged and left out, as a missing blockstate
|
||||
/// is; the others still bake.
|
||||
pub(crate) fn bake_standalone(
|
||||
&mut self,
|
||||
root: &Path,
|
||||
sprites: &mut Sprites,
|
||||
models: &crate::model_library::ParsedModels,
|
||||
names: &[String],
|
||||
) -> Result<()> {
|
||||
for name in names {
|
||||
if self.standalone.contains_key(name) {
|
||||
continue;
|
||||
}
|
||||
if let Some(i) = self
|
||||
.baked
|
||||
.iter()
|
||||
.position(|(v, _)| v.model == *name && v.rotations == [0; 3] && !v.uv_lock)
|
||||
{
|
||||
self.standalone.insert(name.clone(), i as u32);
|
||||
continue;
|
||||
}
|
||||
let model = models
|
||||
.block_library(root, vec![name.clone()])
|
||||
.and_then(|library| bake_variant(root, sprites, &library, name, [0; 3], false));
|
||||
match model {
|
||||
Ok(model) => {
|
||||
self.standalone
|
||||
.insert(name.clone(), self.baked.len() as u32);
|
||||
let variant = Variant {
|
||||
model: name.clone(),
|
||||
rotations: [0; 3],
|
||||
uv_lock: false,
|
||||
};
|
||||
self.baked.push((variant, model));
|
||||
}
|
||||
Err(e) => log::warn!("Horizon: standalone model {name} not loaded: {e}"),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// A model baked by [`Self::bake_standalone`], by id (`mymod:block/shaft_half`).
|
||||
pub fn standalone(&self, name: &str) -> Option<&BakedModel> {
|
||||
let &i = self.standalone.get(name)?;
|
||||
Some(&self.baked[i as usize].1)
|
||||
}
|
||||
pub fn collect(
|
||||
&self,
|
||||
state: BlockStateId,
|
||||
@@ -342,3 +412,103 @@ pub fn descriptor(name: &str, overrides: &[(&str, &str)]) -> BlockState {
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model_library::ParsedModels;
|
||||
|
||||
struct Fixture(std::path::PathBuf);
|
||||
impl Fixture {
|
||||
fn write(&self, name: &str, json: &str) {
|
||||
let (namespace, path) = name.split_once(':').unwrap();
|
||||
let path = self
|
||||
.0
|
||||
.join(format!("assets/{namespace}/models/{path}.json"));
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, json).unwrap();
|
||||
}
|
||||
}
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standalone_models_bake_once_and_skip_what_does_not_read() -> Result<()> {
|
||||
let fixture = Fixture(
|
||||
std::env::temp_dir().join(format!("horizon-standalone-{}", std::process::id())),
|
||||
);
|
||||
// A half shaft: one element, two faces; a child with its parent's elements.
|
||||
fixture.write(
|
||||
"lanef:block/shaft_half",
|
||||
r##"{"textures":{"side":"lanef:block/shaft"},"elements":[{"from":[6,6,0],"to":[10,10,8],"faces":{"north":{"texture":"#side"},"up":{"texture":"#side"}}}]}"##,
|
||||
);
|
||||
fixture.write(
|
||||
"lanef:block/cog",
|
||||
r#"{"parent":"lanef:block/shaft_half","textures":{"side":"lanef:block/cog"}}"#,
|
||||
);
|
||||
fixture.write("lanef:block/orphan", r#"{"parent":"lanef:block/absent"}"#);
|
||||
let mut models = StateModels::default();
|
||||
let mut sprites = Sprites::default();
|
||||
let names: Vec<String> = [
|
||||
"lanef:block/shaft_half",
|
||||
"lanef:block/cog",
|
||||
"lanef:block/orphan",
|
||||
"lanef:block/absent",
|
||||
"lanef:block/shaft_half",
|
||||
]
|
||||
.map(str::to_owned)
|
||||
.into();
|
||||
let parsed = ParsedModels::load(&fixture.0);
|
||||
models.bake_standalone(&fixture.0, &mut sprites, &parsed, &names)?;
|
||||
let shaft = models.standalone("lanef:block/shaft_half").expect("baked");
|
||||
assert_eq!(shaft.faces.len(), 2);
|
||||
let cog = models.standalone("lanef:block/cog").expect("baked");
|
||||
assert_eq!(cog.faces.len(), 2);
|
||||
assert_ne!(cog.faces[0].layer, shaft.faces[0].layer, "its own texture");
|
||||
assert!(models.standalone("lanef:block/orphan").is_none());
|
||||
assert!(models.standalone("lanef:block/absent").is_none());
|
||||
assert!(models.standalone("lanef:block/unnamed").is_none());
|
||||
// Named twice, baked once.
|
||||
assert_eq!(models.baked.len(), 2);
|
||||
// Without a parsed library, the files are read directly.
|
||||
let mut direct = StateModels::default();
|
||||
direct.bake_standalone(&fixture.0, &mut sprites, &ParsedModels::default(), &names)?;
|
||||
assert_eq!(
|
||||
direct.standalone("lanef:block/cog").map(|m| m.faces.len()),
|
||||
Some(2)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_standalone_model_a_state_bakes_unturned_is_shared() -> Result<()> {
|
||||
let mut models = StateModels::default();
|
||||
let variant = |rotations| Variant {
|
||||
model: "lanef:block/shared".into(),
|
||||
rotations,
|
||||
uv_lock: false,
|
||||
};
|
||||
let empty = || BakedModel {
|
||||
ambient_occlusion: true,
|
||||
particle: None,
|
||||
faces: Vec::new(),
|
||||
cube: None,
|
||||
};
|
||||
models.baked.push((variant([0, 90, 0]), empty()));
|
||||
models.baked.push((variant([0; 3]), empty()));
|
||||
let names = vec!["lanef:block/shared".to_owned()];
|
||||
let root = std::env::temp_dir().join("horizon-standalone-none");
|
||||
models.bake_standalone(
|
||||
&root,
|
||||
&mut Sprites::default(),
|
||||
&ParsedModels::default(),
|
||||
&names,
|
||||
)?;
|
||||
assert_eq!(models.baked.len(), 2);
|
||||
assert_eq!(models.standalone.get("lanef:block/shared"), Some(&1));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
//! Optional, texture-matched native overlays. No extra draw calls or world light changes.
|
||||
//! Optional, texture-matched overlays drawn full bright over their base texture in the same
|
||||
//! terrain fragment: native mods' (`<ns>:emissive/...`) and OptiFine's emissive block textures
|
||||
//! (`block/stone_e.png` beside `block/stone.png`). No extra draw calls or world light changes.
|
||||
use crate::sprites::Sprites;
|
||||
use std::{collections::BTreeMap, path::Path};
|
||||
|
||||
pub const MAX: usize = 32;
|
||||
/// Overlay pairs the terrain shader reads (`Globals.emissive_layers`, terrain.wgsl).
|
||||
pub const MAX: usize = 128;
|
||||
/// Native mods' own `emissive/` textures, as their API documents.
|
||||
const NATIVE: usize = 32;
|
||||
|
||||
/// Load only bounded, explicitly named emissive textures from the active pack.
|
||||
pub fn load(root: &Path, sprites: &mut Sprites) {
|
||||
@@ -12,7 +17,7 @@ pub fn load(root: &Path, sprites: &mut Sprites) {
|
||||
name.split_once(':')
|
||||
.is_some_and(|(_, p)| p.starts_with("emissive/"))
|
||||
})
|
||||
.take(MAX)
|
||||
.take(NATIVE)
|
||||
.collect();
|
||||
names.sort();
|
||||
for name in names {
|
||||
@@ -23,6 +28,106 @@ pub fn load(root: &Path, sprites: &mut Sprites) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A connected-texture tile's sprite name without `.png`: tiles outside `textures/` are named by
|
||||
/// their resource path (`Sprites::insert_file`, which only they use), block sprites by an id.
|
||||
fn tile_file(name: &str) -> Option<&str> {
|
||||
name.strip_prefix("assets/")?;
|
||||
name.strip_suffix(".png")
|
||||
}
|
||||
|
||||
/// OptiFine's emissive overlays of the block sprites already loaded (what the terrain draws;
|
||||
/// items draw from the HUD atlas): for each
|
||||
/// `<ns>:block/<name>`, or connected-texture tile `assets/<ns>/.../<name>.png`, whose
|
||||
/// `<name><suffix>.png` sits beside it, the overlay is loaded too and paired with it, blocks
|
||||
/// first, then tiles, each by name, at most [`MAX`] - [`NATIVE`] (native mods keep their share of
|
||||
/// the table). A pack without such files adds nothing.
|
||||
pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)> {
|
||||
let suffix = horizon_optifine::emissive::suffix(root);
|
||||
// Blocks with identical pixels share a layer (and the shader pairs by layer): a shared
|
||||
// base would glow on every block that shares it, so it gets no overlay.
|
||||
let mut sharing: BTreeMap<u32, usize> = BTreeMap::new();
|
||||
for layer in sprites.layers.values() {
|
||||
*sharing.entry(*layer).or_default() += 1;
|
||||
}
|
||||
let mut bases: Vec<String> = sprites
|
||||
.layers
|
||||
.iter()
|
||||
.filter(|(name, layer)| {
|
||||
sharing[layer] == 1
|
||||
&& (name
|
||||
.split_once(':')
|
||||
.is_some_and(|(_, p)| p.starts_with("block/") && !p.ends_with(&suffix))
|
||||
|| tile_file(name).is_some_and(|stem| !stem.ends_with(&suffix)))
|
||||
})
|
||||
.map(|(name, _)| name.clone())
|
||||
.collect();
|
||||
// Blocks before tiles: a pack's many tiles must not crowd vanilla blocks' overlays out.
|
||||
bases.sort_by_key(|name| (tile_file(name).is_some(), name.clone()));
|
||||
let mut pairs = Vec::new();
|
||||
for base in bases {
|
||||
// A tile is named by its file, a block sprite by its id.
|
||||
let (overlay, file) = match tile_file(&base) {
|
||||
Some(stem) => {
|
||||
let overlay = format!("{stem}{suffix}.png");
|
||||
(overlay.clone(), root.join(overlay))
|
||||
}
|
||||
None => {
|
||||
let overlay = format!("{base}{suffix}");
|
||||
let file = crate::atlas_sources::texture_path(root, &overlay, "textures");
|
||||
(overlay, file)
|
||||
}
|
||||
};
|
||||
if !std::fs::metadata(&file).is_ok_and(|m| m.is_file()) {
|
||||
continue;
|
||||
}
|
||||
if pairs.len() == MAX - NATIVE {
|
||||
log::warn!(
|
||||
"Horizon: more than {} emissive block overlays: {overlay} and later skipped",
|
||||
MAX - NATIVE
|
||||
);
|
||||
break;
|
||||
}
|
||||
if std::fs::metadata(&file).is_ok_and(|m| m.len() > 1 << 20) {
|
||||
log::warn!("Horizon: emissive overlay {overlay} is over 1 MiB, skipped");
|
||||
continue;
|
||||
}
|
||||
// A file that doesn't load would glow as the missing texture: no pair instead.
|
||||
let loaded = if overlay.starts_with("assets/") {
|
||||
sprites.insert_file(root, &overlay)
|
||||
} else {
|
||||
sprites.try_insert(root, &overlay)
|
||||
};
|
||||
match loaded {
|
||||
Ok(_) => pairs.push((base, overlay)),
|
||||
Err(e) => log::warn!("Horizon: emissive overlay {overlay} skipped: {e}"),
|
||||
}
|
||||
}
|
||||
pairs
|
||||
}
|
||||
|
||||
/// The overlay layer's flag (its high bit) for an OptiFine overlay, which draws cutout: opaque
|
||||
/// where its alpha is at least 0.1 (OptiFine renders SOLID textures' overlays as CUTOUT).
|
||||
pub const CUTOUT: u32 = 1 << 31;
|
||||
|
||||
/// The frame's overlay table: native mods' pairs (at most [`NATIVE`] requested), then the packs'
|
||||
/// OptiFine pairs flagged [`CUTOUT`] (at most [`MAX`] - [`NATIVE`], as [`load_optifine`] loads),
|
||||
/// one per base layer with native mods' first, sorted by base layer for the shader's early exit.
|
||||
pub fn table<'a>(
|
||||
layers: &BTreeMap<String, u32>,
|
||||
native: impl IntoIterator<Item = (&'a str, &'a str)>,
|
||||
optifine: &'a [(String, String)],
|
||||
) -> Vec<[u32; 4]> {
|
||||
let mut by_base: BTreeMap<u32, [u32; 4]> = BTreeMap::new();
|
||||
for pair in mappings(layers, native.into_iter().take(NATIVE)) {
|
||||
by_base.insert(pair[0], pair);
|
||||
}
|
||||
let optifine = optifine.iter().map(|(b, o)| (b.as_str(), o.as_str()));
|
||||
for [b, o, z, w] in mappings(layers, optifine) {
|
||||
by_base.entry(b).or_insert([b, o | CUTOUT, z, w]);
|
||||
}
|
||||
by_base.into_values().collect()
|
||||
}
|
||||
|
||||
/// Only loaded base/overlay pairs are sent to the GPU; sorted for vertex lookup.
|
||||
pub fn mappings<'a>(
|
||||
layers: &BTreeMap<String, u32>,
|
||||
@@ -40,6 +145,147 @@ pub fn mappings<'a>(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A 16x16 image of one colour, `shade` its red (images of different shades never share a
|
||||
/// sprite layer).
|
||||
fn png(path: &Path, shade: u8) {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let file = std::io::BufWriter::new(std::fs::File::create(path).unwrap());
|
||||
let mut encoder = png::Encoder::new(file, 16, 16);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().unwrap();
|
||||
let pixels: Vec<u8> = (0..16 * 16).flat_map(|_| [shade, 9, 9, 255]).collect();
|
||||
writer.write_image_data(&pixels).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optifine_overlays_pair_with_their_loaded_blocks() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-block-emissive-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let block = root.join("assets/minecraft/textures/block");
|
||||
let names = [
|
||||
"stone",
|
||||
"stone_e",
|
||||
"dirt",
|
||||
"glowstone_e",
|
||||
"deepslate",
|
||||
"deepslate_glow",
|
||||
"andesite",
|
||||
"granite",
|
||||
];
|
||||
for (i, name) in names.iter().enumerate() {
|
||||
png(&block.join(format!("{name}.png")), i as u8 * 20);
|
||||
}
|
||||
// Same pixels as andesite: the two share a layer, so neither gets an overlay.
|
||||
png(&block.join("diorite.png"), 6 * 20);
|
||||
png(&block.join("diorite_e.png"), 200);
|
||||
// A broken overlay: skipped, not drawn as the missing texture.
|
||||
std::fs::write(block.join("granite_e.png"), b"not a png").unwrap();
|
||||
png(&root.join("assets/minecraft/textures/item/stick.png"), 210);
|
||||
png(
|
||||
&root.join("assets/minecraft/textures/item/stick_e.png"),
|
||||
220,
|
||||
);
|
||||
let mut sprites = Sprites::default();
|
||||
for name in [
|
||||
"stone",
|
||||
"dirt",
|
||||
"deepslate",
|
||||
"andesite",
|
||||
"diorite",
|
||||
"granite",
|
||||
] {
|
||||
sprites
|
||||
.insert(&root, &format!("minecraft:block/{name}"))
|
||||
.unwrap();
|
||||
}
|
||||
// Items draw from the HUD atlas, not the terrain: no pair.
|
||||
sprites.insert(&root, "minecraft:item/stick").unwrap();
|
||||
// glowstone isn't loaded: its overlay pairs with nothing.
|
||||
let pairs = load_optifine(&root, &mut sprites);
|
||||
assert_eq!(
|
||||
pairs,
|
||||
[(
|
||||
"minecraft:block/stone".into(),
|
||||
"minecraft:block/stone_e".into()
|
||||
)]
|
||||
);
|
||||
assert!(sprites.layers.contains_key("minecraft:block/stone_e"));
|
||||
assert!(!sprites.layers.contains_key("minecraft:block/granite_e"));
|
||||
// The pack's own suffix.
|
||||
let props = root.join("assets/minecraft/optifine/emissive.properties");
|
||||
std::fs::create_dir_all(props.parent().unwrap()).unwrap();
|
||||
std::fs::write(&props, "suffix.emissive=_glow").unwrap();
|
||||
// Connected-texture tiles pair with the overlay beside them.
|
||||
let tiles = root.join("assets/minecraft/optifine/ctm/lamp");
|
||||
png(&tiles.join("0.png"), 230);
|
||||
png(&tiles.join("0_glow.png"), 240);
|
||||
png(&tiles.join("1.png"), 250);
|
||||
// A tile outside `ctm/` (`tiles=~/glass/2`) is a tile all the same.
|
||||
png(&root.join("assets/minecraft/optifine/glass/2.png"), 245);
|
||||
png(
|
||||
&root.join("assets/minecraft/optifine/glass/2_glow.png"),
|
||||
246,
|
||||
);
|
||||
for tile in ["ctm/lamp/0", "ctm/lamp/1", "glass/2"] {
|
||||
sprites
|
||||
.insert_file(&root, &format!("assets/minecraft/optifine/{tile}.png"))
|
||||
.unwrap();
|
||||
}
|
||||
let pairs = load_optifine(&root, &mut sprites);
|
||||
assert_eq!(
|
||||
pairs,
|
||||
// Blocks first, then tiles.
|
||||
[
|
||||
(
|
||||
"minecraft:block/deepslate".into(),
|
||||
"minecraft:block/deepslate_glow".into()
|
||||
),
|
||||
(
|
||||
"assets/minecraft/optifine/ctm/lamp/0.png".into(),
|
||||
"assets/minecraft/optifine/ctm/lamp/0_glow.png".into()
|
||||
),
|
||||
(
|
||||
"assets/minecraft/optifine/glass/2.png".into(),
|
||||
"assets/minecraft/optifine/glass/2_glow.png".into()
|
||||
),
|
||||
]
|
||||
);
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_table_keeps_native_pairs_first_and_flags_optifine_cutout() {
|
||||
let layers: BTreeMap<String, u32> =
|
||||
[("a", 5), ("a_e", 6), ("b", 2), ("b_e", 3), ("native", 9)]
|
||||
.map(|(n, l)| (n.to_owned(), l))
|
||||
.into();
|
||||
let optifine = [
|
||||
("a".to_owned(), "a_e".to_owned()),
|
||||
("b".to_owned(), "b_e".to_owned()),
|
||||
];
|
||||
// A native mod pairs `a` too: its pair wins; `b` stays the pack's, flagged.
|
||||
let t = table(&layers, [("a", "native")], &optifine);
|
||||
assert_eq!(
|
||||
t,
|
||||
[[2, 3 | CUTOUT, 0, 0], [5, 9, 0, 0]],
|
||||
"sorted by base layer"
|
||||
);
|
||||
// Native mods keep their 32: of 40 requests on 40 bases, the first 32.
|
||||
let layers: BTreeMap<String, u32> =
|
||||
(0..41u32).map(|i| (format!("s{i}"), 100 + i)).collect();
|
||||
let names: Vec<String> = (1..41).map(|i| format!("s{i}")).collect();
|
||||
let many = names.iter().map(|n| (n.as_str(), "s0"));
|
||||
let t = table(&layers, many, &[]);
|
||||
assert_eq!(t.len(), 32);
|
||||
assert_eq!(
|
||||
t.last(),
|
||||
Some(&[132, 100, 0, 0]),
|
||||
"s32 last; s33 to s40 cut"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn only_existing_pairs_are_mapped() {
|
||||
let layers = [
|
||||
|
||||
@@ -3358,7 +3358,7 @@ impl OptionsUi {
|
||||
let m = crate::screen_draw::Measure(&atlas.font);
|
||||
let mouse = (cursor.0.floor() as i32, cursor.1.floor() as i32);
|
||||
let packs = self.packs(atlas, (w, h));
|
||||
let mut draws = packs.draw(&m, mouse, blink);
|
||||
let mut draws = packs.draw(&m, mouse, blink, crate::hud_overlays::now_ms());
|
||||
packs.bind_icons(&mut draws, atlas, &mut batch.patches);
|
||||
crate::screen_draw::paint(batch, atlas, &draws);
|
||||
return;
|
||||
@@ -5345,6 +5345,61 @@ mod tests {
|
||||
assert!(ui.resource_error.is_none());
|
||||
}
|
||||
|
||||
/// The texts of an options page, as laid out in `code`'s language, and the keys it lacked.
|
||||
fn page_texts(code: &str, pages: &[Page]) -> (Vec<String>, Vec<String>) {
|
||||
let s = Settings::default();
|
||||
lang::scoped(lang::Language::load(&[], code), || {
|
||||
let mut texts = Vec::new();
|
||||
for page in pages {
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(*page);
|
||||
for w in ui.layout(&s, 480.0, 4000.0).0 {
|
||||
match w.kind {
|
||||
WKind::Button { label: t, .. }
|
||||
| WKind::Slider { label: t, .. }
|
||||
| WKind::Text { text: t, .. } => texts.push(t.plain()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
texts
|
||||
})
|
||||
}
|
||||
|
||||
/// Horizon's own options pages said "Splitscreen...", "Hide block-break particles" and
|
||||
/// "Hotbar: Previous Slot" in every language (#1927).
|
||||
#[test]
|
||||
fn horizon_option_pages_follow_the_language() {
|
||||
let (texts, missed) = page_texts("de_de", &[Page::Horizon, Page::Extras, Page::PadBinds]);
|
||||
for want in [
|
||||
"Splitscreen …",
|
||||
"Partikel",
|
||||
"Blockpartikel weg: Aus",
|
||||
"Weicher Chat: Aus",
|
||||
"Nachrichtenmarkierung: An",
|
||||
"Kanäle für UI-Töne: Aus",
|
||||
"Schnellzugriffsleiste: Vorheriger Platz",
|
||||
"Schnellzugriffsleiste: Nächster Platz",
|
||||
] {
|
||||
assert!(texts.iter().any(|t| t == want), "{want:?} not in {texts:?}");
|
||||
}
|
||||
// Every key these pages ask for is in the table; vanilla's own keys (`options.*`,
|
||||
// `key.*`: dotted ids without spaces) come from the prepared resources, which this
|
||||
// language does not have.
|
||||
let missed: Vec<_> = missed
|
||||
.into_iter()
|
||||
.filter(|k| k.starts_with("horizon.") || k.contains(' ') || !k.contains('.'))
|
||||
.collect();
|
||||
assert_eq!(missed, Vec::<String>::new());
|
||||
let (texts, _) = page_texts("pl_pl", &[Page::Extras, Page::PadBinds]);
|
||||
for want in [
|
||||
"Ukryj cząstki bloków: Wył.",
|
||||
"Pasek szybkiego wyboru: poprzednie pole",
|
||||
] {
|
||||
assert!(texts.iter().any(|t| t == want), "{want:?} not in {texts:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_mods_page_toggles_mods_and_keeps_dev_and_invalid_entries_inactive() {
|
||||
let atlas = atlas();
|
||||
|
||||
@@ -827,7 +827,7 @@ impl PackScreen {
|
||||
|
||||
/// The shown screen's calls (`Screen.extractRenderState`); `blink_ms`
|
||||
/// is the time since the search box took the focus.
|
||||
pub fn draw(&self, m: &Measure, mouse: (i32, i32), blink_ms: u64) -> Vec<Draw> {
|
||||
pub fn draw(&self, m: &Measure, mouse: (i32, i32), blink_ms: u64, now_ms: i64) -> Vec<Draw> {
|
||||
let mut out = Vec::new();
|
||||
// PackSelectionScreen, or the ConfirmScreen over it (its title and
|
||||
// its message).
|
||||
@@ -845,7 +845,7 @@ impl PackScreen {
|
||||
for ((bx, by), key) in buttons.into_iter().zip(["gui.yes", "gui.no"]) {
|
||||
let hover = d::inside(mouse, bx, by, 150, 20);
|
||||
crate::widgets::widget_cursor(hover, true);
|
||||
d::button(&mut out, m, &tr(key), bx, by, 150, 20, true, hover);
|
||||
d::button(&mut out, m, &tr(key), bx, by, 150, 20, true, hover, now_ms);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -863,25 +863,40 @@ impl PackScreen {
|
||||
for side in 0..2 {
|
||||
self.draw_list(&mut out, m, side, mouse);
|
||||
}
|
||||
for (i, (bx, by)) in self.footer().into_iter().enumerate() {
|
||||
self.draw_footer(
|
||||
&mut out,
|
||||
m,
|
||||
mouse,
|
||||
now_ms,
|
||||
[&tr("pack.openFolder"), &tr("gui.done")],
|
||||
);
|
||||
out
|
||||
}
|
||||
/// The footer's two buttons, "Open Pack Folder" and "Done", over their
|
||||
/// labels; a label wider than its button scrolls with the clock.
|
||||
fn draw_footer(
|
||||
&self,
|
||||
out: &mut Vec<Draw>,
|
||||
m: &Measure,
|
||||
mouse: (i32, i32),
|
||||
now_ms: i64,
|
||||
labels: [&Text; 2],
|
||||
) {
|
||||
for (i, ((bx, by), label)) in self.footer().into_iter().zip(labels).enumerate() {
|
||||
let hover = d::inside(mouse, bx, by, 150, 20);
|
||||
let label = tr(if i == 0 {
|
||||
"pack.openFolder"
|
||||
} else {
|
||||
"gui.done"
|
||||
});
|
||||
// The cursor follows the mouse; the highlight also keyboard focus.
|
||||
crate::widgets::widget_cursor(hover, true);
|
||||
d::button(
|
||||
&mut out,
|
||||
out,
|
||||
m,
|
||||
&label,
|
||||
label,
|
||||
bx,
|
||||
by,
|
||||
150,
|
||||
20,
|
||||
true,
|
||||
hover || self.focus == Focus::Button(i),
|
||||
now_ms,
|
||||
);
|
||||
if i == 0 {
|
||||
crate::widgets::last_narratable(|n| {
|
||||
@@ -889,10 +904,9 @@ impl PackScreen {
|
||||
});
|
||||
}
|
||||
if i == 0 && hover {
|
||||
d::tooltip(&mut out, m, &tr("pack.folderInfo"), mouse);
|
||||
d::tooltip(out, m, &tr("pack.folderInfo"), mouse);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
fn draw_list(&self, out: &mut Vec<Draw>, m: &Measure, side: usize, mouse: (i32, i32)) {
|
||||
let l = &self.lists[side];
|
||||
@@ -1151,7 +1165,7 @@ mod icon_tests {
|
||||
pack("file/Iconless", None),
|
||||
];
|
||||
let screen = PackScreen::new(&m, tr("resourcePack.title"), 480, 320, false, packs, &[]);
|
||||
let mut draws = screen.draw(&m, (0, 0), 0);
|
||||
let mut draws = screen.draw(&m, (0, 0), 0, 0);
|
||||
let mut patches = Vec::new();
|
||||
screen.bind_icons(&mut draws, &atlas, &mut patches);
|
||||
let icons: Vec<&str> = draws
|
||||
@@ -1242,3 +1256,131 @@ mod click_sound_tests {
|
||||
assert!(!screen.confirming());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod footer_label_tests {
|
||||
use super::*;
|
||||
use crate::lang::{self, Language};
|
||||
|
||||
/// The footer drawn with these labels (no global language involved) and
|
||||
/// the footer buttons' positions.
|
||||
fn footer(
|
||||
atlas: &HudAtlas,
|
||||
open_folder: &Text,
|
||||
done: &Text,
|
||||
now_ms: i64,
|
||||
) -> (Vec<Draw>, [(i32, i32); 2]) {
|
||||
let m = Measure(&atlas.font);
|
||||
let screen = PackScreen::new(&m, Text::literal("Packs"), 480, 320, false, Vec::new(), &[]);
|
||||
let mut out = Vec::new();
|
||||
screen.draw_footer(&mut out, &m, (0, 0), now_ms, [open_folder, done]);
|
||||
(out, screen.footer())
|
||||
}
|
||||
/// Every label drawn in `[bx + 2, bx + 148] x [by, by + 20]` of its button:
|
||||
/// the scissor of a scrolling one is that box, and the text sits between
|
||||
/// the scissor's calls.
|
||||
fn assert_labels_stay_in_their_buttons(draws: &[Draw], buttons: &[(i32, i32)]) -> usize {
|
||||
let mut scissors = 0;
|
||||
let mut open = None;
|
||||
let mut button = 0;
|
||||
for d in draws {
|
||||
match d {
|
||||
Draw::Sprite { x, y, .. } => {
|
||||
button = buttons.iter().position(|b| *b == (*x, *y)).unwrap();
|
||||
}
|
||||
Draw::Scissor { x0, y0, x1, y1 } => {
|
||||
let (bx, by) = buttons[button];
|
||||
assert_eq!([*x0, *y0, *x1, *y1], [bx + 2, by, bx + 148, by + 20]);
|
||||
assert!(open.replace(()).is_none());
|
||||
scissors += 1;
|
||||
}
|
||||
Draw::NoScissor => assert!(open.take().is_some()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert!(open.is_none());
|
||||
scissors
|
||||
}
|
||||
|
||||
/// The Resource Packs footer with a label wider than its 150 px button
|
||||
/// does not panic and scrolls; the short "Done" keeps its centre.
|
||||
#[test]
|
||||
fn a_footer_label_wider_than_its_button_scrolls() {
|
||||
let atlas =
|
||||
crate::hud::HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets"))
|
||||
.unwrap();
|
||||
let m = Measure(&atlas.font);
|
||||
let wide = Text::literal("Otvori papku s paketami resursov, i jos nesto");
|
||||
assert!(m.width(&wide) > 146);
|
||||
let (draws, buttons) = footer(&atlas, &wide, &Text::literal("Done"), 0);
|
||||
assert_eq!(assert_labels_stay_in_their_buttons(&draws, &buttons), 1);
|
||||
// The clock moves it.
|
||||
let x = |draws: &[Draw]| {
|
||||
draws
|
||||
.iter()
|
||||
.find_map(|d| match d {
|
||||
Draw::Text { x, .. } => Some(*x),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
let (later, _) = footer(&atlas, &wide, &Text::literal("Done"), 2000);
|
||||
assert_ne!(x(&draws), x(&later));
|
||||
// Both too wide: both scroll.
|
||||
let (draws, buttons) = footer(&atlas, &wide, &wide, 0);
|
||||
assert_eq!(assert_labels_stay_in_their_buttons(&draws, &buttons), 2);
|
||||
}
|
||||
|
||||
/// The real language files: the Resource Packs footer in each of the 143
|
||||
/// languages draws without a panic, and in the ones whose "Open Pack
|
||||
/// Folder" is wider than the button (Afrikaans and Macedonian among them,
|
||||
/// the crash of #6129) it scrolls. A local `Language` per file: the global
|
||||
/// language stays as it was.
|
||||
#[test]
|
||||
#[ignore = "requires prepared language data in HORIZON_TEST_RESOURCES or .local/horizon-client/26.3"]
|
||||
fn the_footer_draws_in_every_language_file() {
|
||||
let root = std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3")
|
||||
});
|
||||
let atlas = crate::hud::HudAtlas::build(&root).unwrap();
|
||||
let m = Measure(&atlas.font);
|
||||
let before = lang::code();
|
||||
let mut codes: Vec<String> = std::fs::read_dir(root.join("assets/minecraft/lang"))
|
||||
.unwrap()
|
||||
.filter_map(|e| {
|
||||
let name = e.ok()?.file_name().into_string().ok()?;
|
||||
name.strip_suffix(".json").map(str::to_owned)
|
||||
})
|
||||
.collect();
|
||||
codes.sort();
|
||||
assert!(codes.len() >= 143, "{}", codes.len());
|
||||
let mut scrolling = Vec::new();
|
||||
for code in &codes {
|
||||
let language = Language::load(&[&root], code);
|
||||
let label = |key| Text::literal(language.get(key).unwrap_or(key));
|
||||
let (open_folder, done) = (label("pack.openFolder"), label("gui.done"));
|
||||
for now_ms in [0, 700, 1300, 5000] {
|
||||
let (draws, buttons) = footer(&atlas, &open_folder, &done, now_ms);
|
||||
let n = assert_labels_stay_in_their_buttons(&draws, &buttons);
|
||||
assert_eq!(
|
||||
n,
|
||||
[&open_folder, &done]
|
||||
.iter()
|
||||
.filter(|t| m.width(t) > 146)
|
||||
.count(),
|
||||
"{code}"
|
||||
);
|
||||
if now_ms == 0 && n > 0 {
|
||||
scrolling.push(code.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
for code in ["af_za", "mk_mk"] {
|
||||
assert!(scrolling.contains(&code), "{code} not in {scrolling:?}");
|
||||
}
|
||||
assert_eq!(lang::code(), before, "the global language was left alone");
|
||||
}
|
||||
}
|
||||
@@ -4093,6 +4093,47 @@ impl ParticleEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// The particles that `entries` name, as lights: where each is this frame, its entry's colour
|
||||
/// as RGB and reach, and its alpha as the intensity.
|
||||
pub fn lights(
|
||||
&self,
|
||||
entries: &[horizon_shader_api::manifest::ParticleLight],
|
||||
partial: f32,
|
||||
) -> Vec<([f64; 3], u32, f32, f32)> {
|
||||
let shining: Vec<(ParticleType, u32, f32)> = entries
|
||||
.iter()
|
||||
.flat_map(|e| {
|
||||
let [r, g, b] = e.color.map(|c| (c.clamp(0.0, 1.0) * 255.0) as u32);
|
||||
e.particles.iter().filter_map(move |name| {
|
||||
let name = if name.contains(':') {
|
||||
name.clone()
|
||||
} else {
|
||||
format!("minecraft:{name}")
|
||||
};
|
||||
Some((
|
||||
ParticleType::from_name(&name)?,
|
||||
r << 16 | g << 8 | b,
|
||||
e.reach,
|
||||
))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if shining.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let f = f64::from(partial);
|
||||
self.particles
|
||||
.iter()
|
||||
.filter(|p| p.rgba[3] > 0.0)
|
||||
.filter_map(|p| {
|
||||
let (_, rgb, reach) = shining.iter().find(|(ty, ..)| *ty == p.ty)?;
|
||||
let at =
|
||||
std::array::from_fn(|i| p.previous[i] + (p.position[i] - p.previous[i]) * f);
|
||||
Some((at, *rgb, *reach, p.rgba[3].clamp(0.0, 1.0)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Camera-facing quads (`QuadParticleRenderState.renderRotatedQuad`).
|
||||
pub fn render(
|
||||
&self,
|
||||
|
||||
@@ -1659,7 +1659,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
@@ -133,6 +133,21 @@ pub fn text(out: &mut Vec<Draw>, text: Text, x: i32, y: i32, color: i32, shadow:
|
||||
});
|
||||
}
|
||||
}
|
||||
/// `graphics.text` on text the caller already styled, drawn as it is: a `§`
|
||||
/// in it stays a character, as `FormattedCharSequence.forward`
|
||||
/// (`StringDecomposer.iterate`, not `iterateFormatted`) draws it. An edit box
|
||||
/// builds its own sequence that way, so a `§` in its value shows.
|
||||
pub fn text_as_typed(out: &mut Vec<Draw>, text: Text, x: i32, y: i32, color: i32, shadow: bool) {
|
||||
if (color as u32) >> 24 != 0 {
|
||||
out.push(Draw::Text {
|
||||
text,
|
||||
x,
|
||||
y,
|
||||
color,
|
||||
shadow,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// `StringDecomposer.iterateFormatted`: the drawn characters of `contents`,
|
||||
/// with their index in it and their style; legacy `§` codes restyle what
|
||||
@@ -186,6 +201,11 @@ impl Measure<'_> {
|
||||
pub fn width(&self, t: &Text) -> i32 {
|
||||
self.0.width_text(&t.resolve_legacy()) as i32
|
||||
}
|
||||
/// `Font.width(FormattedCharSequence)` of a sequence drawn as it is: a
|
||||
/// `§` in the text counts as the glyph it is drawn as.
|
||||
pub fn width_drawn(&self, t: &Text) -> i32 {
|
||||
self.0.width_drawn(t) as i32
|
||||
}
|
||||
pub fn width_str(&self, s: &str) -> i32 {
|
||||
self.0.width(s) as i32
|
||||
}
|
||||
@@ -409,8 +429,104 @@ pub fn label(
|
||||
}
|
||||
}
|
||||
|
||||
/// `Button` (`extractDefaultSprite` and the label over its contents, which
|
||||
/// must fit: scrolling labels depend on the clock).
|
||||
/// `ActiveTextCollector.PERIOD_PER_SCROLLED_PIXEL`.
|
||||
const PERIOD_PER_SCROLLED_PIXEL: f64 = 0.5;
|
||||
/// `ActiveTextCollector.MIN_SCROLL_PERIOD`.
|
||||
const MIN_SCROLL_PERIOD: f64 = 3.0;
|
||||
|
||||
/// How far `ActiveTextCollector.defaultScrollingHelper` has slid a line left
|
||||
/// of the box's left edge at `now_ms` (`Util.getMillis`), for a line that
|
||||
/// overflows the box by `max_position` pixels. A whole cycle takes
|
||||
/// `max(max_position * 0.5, 3)` seconds, eased with a sine, so the line rests
|
||||
/// a moment at each end; at time 0 it is at the far end.
|
||||
pub fn scroll_offset(max_position: i32, now_ms: i64) -> i32 {
|
||||
let time = now_ms as f64 / 1000.0;
|
||||
let period = (f64::from(max_position) * PERIOD_PER_SCROLLED_PIXEL).max(MIN_SCROLL_PERIOD);
|
||||
let alpha = horizon_core::jmath::sin(
|
||||
(std::f64::consts::PI / 2.0)
|
||||
* horizon_core::jmath::cos(std::f64::consts::PI * 2.0 * time / period),
|
||||
) / 2.0
|
||||
+ 0.5;
|
||||
// `Mth.lerp(alpha, 0.0, maxPosition)`, then the `(int)` cast.
|
||||
(0.0 + alpha * (f64::from(max_position) - 0.0)) as i32
|
||||
}
|
||||
|
||||
/// `ActiveTextCollector.defaultScrollingHelper` with the line height of 9
|
||||
/// that `acceptScrolling` passes: a message that fits is centred on
|
||||
/// `center_x`, kept inside `[left, right]`; a wider one is left-aligned,
|
||||
/// clipped to the box and slides back and forth with the clock (`now_ms`).
|
||||
/// Vanilla intersects that clip with the enclosing scissor; `paint` does not
|
||||
/// stack scissors, so this must not be drawn inside another `Draw::Scissor`.
|
||||
pub fn scrolling_text(
|
||||
out: &mut Vec<Draw>,
|
||||
m: &Measure,
|
||||
message: &Text,
|
||||
center_x: i32,
|
||||
left: i32,
|
||||
right: i32,
|
||||
top: i32,
|
||||
bottom: i32,
|
||||
now_ms: i64,
|
||||
) {
|
||||
let line_width = m.width(message);
|
||||
let text_top = (top + bottom - 9) / 2 + 1;
|
||||
let available = right - left;
|
||||
if line_width > available {
|
||||
let pos = scroll_offset(line_width - available, now_ms);
|
||||
out.push(Draw::Scissor {
|
||||
x0: left,
|
||||
y0: top,
|
||||
x1: right,
|
||||
y1: bottom,
|
||||
});
|
||||
text(out, message.clone(), left - pos, text_top, WHITE, true);
|
||||
out.push(Draw::NoScissor);
|
||||
} else {
|
||||
// Here `left + line_width / 2 <= right - line_width / 2`, so the
|
||||
// clamp's bounds are in order.
|
||||
let text_x = center_x.clamp(left + line_width / 2, right - line_width / 2);
|
||||
let at = center_left(text_x, line_width);
|
||||
text(out, message.clone(), at, text_top, WHITE, true);
|
||||
}
|
||||
}
|
||||
|
||||
/// `ActiveTextCollector.acceptScrollingWithDefaultCenter`: `scrolling_text`
|
||||
/// centred between `left` and `right`.
|
||||
pub fn scrolling_with_default_center(
|
||||
out: &mut Vec<Draw>,
|
||||
m: &Measure,
|
||||
message: &Text,
|
||||
left: i32,
|
||||
right: i32,
|
||||
top: i32,
|
||||
bottom: i32,
|
||||
now_ms: i64,
|
||||
) {
|
||||
let center = (left + right) / 2;
|
||||
scrolling_text(out, m, message, center, left, right, top, bottom, now_ms);
|
||||
}
|
||||
|
||||
/// `AbstractWidget.extractScrollingStringOverContents`: `message` over the
|
||||
/// widget's box less `margin` on each side (centred, or scrolling when too
|
||||
/// wide).
|
||||
pub fn scrolling_string_over_contents(
|
||||
out: &mut Vec<Draw>,
|
||||
m: &Measure,
|
||||
message: &Text,
|
||||
x: i32,
|
||||
y: i32,
|
||||
w: i32,
|
||||
h: i32,
|
||||
margin: i32,
|
||||
now_ms: i64,
|
||||
) {
|
||||
let (left, right) = (x + margin, x + w - margin);
|
||||
scrolling_with_default_center(out, m, message, left, right, y, y + h, now_ms);
|
||||
}
|
||||
|
||||
/// `Button` (`extractDefaultSprite`, then `AbstractButton.extractDefaultLabel`:
|
||||
/// `extractScrollingStringOverContents` with a margin of 2, which scrolls a
|
||||
/// label wider than the button with the clock, `now_ms`).
|
||||
pub fn button(
|
||||
out: &mut Vec<Draw>,
|
||||
m: &Measure,
|
||||
@@ -421,6 +537,7 @@ pub fn button(
|
||||
h: i32,
|
||||
active: bool,
|
||||
highlighted: bool,
|
||||
now_ms: i64,
|
||||
) {
|
||||
// Its narration: hovered is the mouse over it (`isHovered`), which the
|
||||
// narrator tests.
|
||||
@@ -439,11 +556,7 @@ pub fn button(
|
||||
"widget/button"
|
||||
};
|
||||
sprite(out, sprite_id, x, y, w, h);
|
||||
let (left, right) = (x + 2, x + w - 2);
|
||||
let lw = m.width(label);
|
||||
let top = (y + y + h - 9) / 2 + 1;
|
||||
let cx = ((left + right) / 2).clamp(left + lw / 2, right - lw / 2);
|
||||
text(out, label.clone(), center_left(cx, lw), top, WHITE, true);
|
||||
scrolling_string_over_contents(out, m, label, x, y, w, h, 2, now_ms);
|
||||
}
|
||||
/// `Tooltip.splitTooltip` of a widget's tooltip, shown under the mouse.
|
||||
pub fn tooltip(out: &mut Vec<Draw>, m: &Measure, message: &Text, mouse: (i32, i32)) {
|
||||
@@ -771,8 +884,8 @@ impl TextField {
|
||||
displayed.clone()
|
||||
};
|
||||
let t = styled(half, self.display);
|
||||
let w = m.width(&t);
|
||||
text(out, t, draw_x, ty, color, self.text_shadow);
|
||||
let w = m.width_drawn(&t);
|
||||
text_as_typed(out, t, draw_x, ty, color, self.text_shadow);
|
||||
draw_x += w + 1;
|
||||
}
|
||||
let insert = self.cursor < len || len >= self.max_length;
|
||||
@@ -785,7 +898,7 @@ impl TextField {
|
||||
}
|
||||
if !displayed.is_empty() && on_screen && (rel_cursor as usize) < shown_len {
|
||||
let t = styled(sub(&displayed, rel_cursor as usize, shown_len), self.cursor);
|
||||
text(out, t, draw_x, ty, color, self.text_shadow);
|
||||
text_as_typed(out, t, draw_x, ty, color, self.text_shadow);
|
||||
}
|
||||
if let Some(hint) = &self.hint
|
||||
&& displayed.is_empty()
|
||||
@@ -962,3 +1075,186 @@ pub fn paint(batch: &mut HudBatch, atlas: &HudAtlas, draws: &[Draw]) {
|
||||
crate::ui::tooltip(batch, atlas, &lines, at);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod button_label_tests {
|
||||
use super::*;
|
||||
|
||||
fn atlas() -> HudAtlas {
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
|
||||
}
|
||||
/// A label made of `n` copies of one letter.
|
||||
fn label(n: usize) -> Text {
|
||||
Text::literal("W".repeat(n))
|
||||
}
|
||||
/// The calls of one button: its sprite, then (the scissor,) the label.
|
||||
fn draw(m: &Measure, text: &Text, w: i32, now_ms: i64) -> Vec<Draw> {
|
||||
let mut out = Vec::new();
|
||||
button(&mut out, m, text, 10, 20, w, 20, true, false, now_ms);
|
||||
out
|
||||
}
|
||||
fn scissor_of(draws: &[Draw]) -> Option<[i32; 4]> {
|
||||
draws.iter().find_map(|d| match d {
|
||||
Draw::Scissor { x0, y0, x1, y1 } => Some([*x0, *y0, *x1, *y1]),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
fn text_x(draws: &[Draw]) -> i32 {
|
||||
draws
|
||||
.iter()
|
||||
.find_map(|d| match d {
|
||||
Draw::Text { x, .. } => Some(*x),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the label is drawn")
|
||||
}
|
||||
/// A button `overflow` px too narrow for `text`, whatever the font.
|
||||
fn width_overflowing_by(m: &Measure, text: &Text, overflow: i32) -> i32 {
|
||||
m.width(text) - overflow + 4
|
||||
}
|
||||
|
||||
/// `defaultScrollingHelper`: `pos` runs from the far end (`maxPosition`, at
|
||||
/// time 0) to 0 at half the period (`max(maxPosition * 0.5, 3)` seconds)
|
||||
/// and back, with `maxPosition / 2` a quarter of the way.
|
||||
#[test]
|
||||
fn the_offset_follows_the_vanilla_period_and_easing() {
|
||||
// 8 px over: a 4 s period. At 3 s the cosine is a hair below 0, so
|
||||
// `pos` is a hair below 4 and the `(int)` cast truncates it to 3.
|
||||
let at = |ms| scroll_offset(8, ms);
|
||||
assert_eq!(
|
||||
[at(0), at(1000), at(2000), at(3000), at(4000)],
|
||||
[8, 4, 0, 3, 8]
|
||||
);
|
||||
// 1 px over: the 3 s minimum period, so 1.5 s is the other end.
|
||||
assert_eq!([scroll_offset(1, 0), scroll_offset(1, 1500)], [1, 0]);
|
||||
// 40 px over: a 20 s period.
|
||||
assert_eq!(
|
||||
[
|
||||
scroll_offset(40, 0),
|
||||
scroll_offset(40, 5000),
|
||||
scroll_offset(40, 10000),
|
||||
scroll_offset(40, 20000)
|
||||
],
|
||||
[40, 20, 0, 40]
|
||||
);
|
||||
// It never leaves [0, maxPosition], and the minimum period is 3 s even
|
||||
// for a tiny overflow.
|
||||
for max in 1..200 {
|
||||
for ms in (0..60_000).step_by(37) {
|
||||
let pos = scroll_offset(max, ms);
|
||||
assert!((0..=max).contains(&pos), "{max} {ms} {pos}");
|
||||
}
|
||||
}
|
||||
assert_eq!(scroll_offset(2, 1500), 0);
|
||||
// tools/test_pack_screen_parity.py runs vanilla's own
|
||||
// `defaultScrollingHelper` at fixed clocks for the scrolling labels.
|
||||
}
|
||||
|
||||
/// The bug: a label wider than the button panicked in `i32::clamp`. Now it
|
||||
/// is left-aligned, clipped to the button's contents and slides with the
|
||||
/// clock, and every pixel of it stays inside that box.
|
||||
#[test]
|
||||
fn a_label_wider_than_its_button_scrolls_inside_its_box() {
|
||||
let atlas = atlas();
|
||||
let m = Measure(&atlas.font);
|
||||
let text = label(24);
|
||||
let w = width_overflowing_by(&m, &text, 8);
|
||||
let (left, right) = (10 + 2, 10 + w - 2);
|
||||
for (ms, x) in [(0, left - 8), (1000, left - 4), (2000, left)] {
|
||||
let draws = draw(&m, &text, w, ms);
|
||||
assert_eq!(scissor_of(&draws), Some([left, 20, right, 40]), "{ms}");
|
||||
assert_eq!(text_x(&draws), x, "{ms}");
|
||||
// The scissor wraps the text and nothing else.
|
||||
assert!(matches!(draws[1], Draw::Scissor { .. }));
|
||||
assert!(matches!(draws[2], Draw::Text { .. }));
|
||||
assert_eq!(draws[3], Draw::NoScissor);
|
||||
assert_eq!(draws.len(), 4);
|
||||
// Painted, no vertex of the label leaves the box.
|
||||
let mut batch = HudBatch::new([320, 240]);
|
||||
paint(&mut batch, &atlas, &draws[1..]);
|
||||
assert!(!batch.vertices.is_empty());
|
||||
for v in &batch.vertices {
|
||||
assert!(
|
||||
(left as f32..=right as f32).contains(&v.pos[0])
|
||||
&& (20.0..=40.0).contains(&v.pos[1]),
|
||||
"{ms}: {:?}",
|
||||
v.pos
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A label as wide as the room is centred as before and takes no scissor;
|
||||
/// one pixel more scrolls.
|
||||
#[test]
|
||||
fn the_scroll_starts_one_pixel_past_the_room() {
|
||||
let atlas = atlas();
|
||||
let m = Measure(&atlas.font);
|
||||
let text = label(10);
|
||||
let lw = m.width(&text);
|
||||
let fits = draw(&m, &text, lw + 4, 0);
|
||||
assert_eq!(scissor_of(&fits), None);
|
||||
assert_eq!(text_x(&fits), 10 + 2);
|
||||
assert_eq!(fits.len(), 2);
|
||||
let over = draw(&m, &text, lw + 3, 0);
|
||||
assert_eq!(scissor_of(&over), Some([12, 20, 10 + lw + 3 - 2, 40]));
|
||||
// maxPosition 1: the far end at time 0, the near one 1.5 s later.
|
||||
assert_eq!(text_x(&over), 12 - 1);
|
||||
assert_eq!(text_x(&draw(&m, &text, lw + 3, 1500)), 12);
|
||||
}
|
||||
|
||||
/// A label that fits stays where it was: centred on the button.
|
||||
#[test]
|
||||
fn a_label_that_fits_is_centred() {
|
||||
let atlas = atlas();
|
||||
let m = Measure(&atlas.font);
|
||||
let text = label(6);
|
||||
let lw = m.width(&text);
|
||||
for w in [lw + 5, lw + 6, 150, 200] {
|
||||
let draws = draw(&m, &text, w, 12345);
|
||||
assert_eq!(scissor_of(&draws), None);
|
||||
let (left, right) = (10 + 2, 10 + w - 2);
|
||||
assert_eq!(text_x(&draws), (left + right) / 2 - lw / 2, "{w}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An empty label, and a button too narrow to hold anything (a 1-px one
|
||||
/// has a negative room), neither panic nor draw outside it.
|
||||
#[test]
|
||||
fn empty_labels_and_tiny_buttons_do_not_panic() {
|
||||
let atlas = atlas();
|
||||
let m = Measure(&atlas.font);
|
||||
for text in [Text::empty(), label(1), label(30)] {
|
||||
for w in [-5, 0, 1, 2, 3, 4, 5, 150] {
|
||||
for ms in [0, 1, 1499, 1500, 123_456_789] {
|
||||
let draws = draw(&m, &text, w, ms);
|
||||
let mut batch = HudBatch::new([320, 240]);
|
||||
paint(&mut batch, &atlas, &draws);
|
||||
if let Some([x0, _, x1, _]) = scissor_of(&draws) {
|
||||
// Whatever scrolled is clipped to the (maybe empty) box.
|
||||
for v in batch.vertices.iter().skip(sprite_vertices(&atlas, w)) {
|
||||
assert!(
|
||||
x1 <= x0 || (x0 as f32..=x1 as f32).contains(&v.pos[0]),
|
||||
"{w} {ms}: {:?}",
|
||||
v.pos
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 4 px wide leaves no room: an empty label fits it (0 <= 0).
|
||||
assert_eq!(scissor_of(&draw(&m, &Text::empty(), 4, 0)), None);
|
||||
// 1 px wide: the room is -3 px, even an empty label overflows it.
|
||||
assert!(scissor_of(&draw(&m, &Text::empty(), 1, 0)).is_some());
|
||||
}
|
||||
|
||||
/// The vertices of the button's sprite alone.
|
||||
fn sprite_vertices(atlas: &HudAtlas, w: i32) -> usize {
|
||||
let mut batch = HudBatch::new([320, 240]);
|
||||
let mut out = Vec::new();
|
||||
sprite(&mut out, "widget/button", 10, 20, w, 20);
|
||||
paint(&mut batch, atlas, &out);
|
||||
batch.vertices.len()
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,9 @@ pub struct ScreenQuad {
|
||||
pub blend: Blend,
|
||||
/// How the sprite is sampled: [`SAMPLE_BLUR`] and [`SAMPLE_CLAMP`].
|
||||
pub sampling: u32,
|
||||
/// A tint of the whole view (the underwater overlay), not something in front of it: in HDR
|
||||
/// output the world shines through it (`Renderer::draw_overlay`).
|
||||
pub world_tint: bool,
|
||||
}
|
||||
|
||||
/// What the camera sees this frame.
|
||||
@@ -160,6 +163,7 @@ impl ScreenEffects {
|
||||
}),
|
||||
blend: Blend::Alpha,
|
||||
sampling,
|
||||
world_tint: false,
|
||||
};
|
||||
let full = [[-1.0, -1.0], [1.0, -1.0], [1.0, 1.0], [-1.0, 1.0]];
|
||||
// ScreenEffectRenderer.submitBlockSprite: u1/v1 at (-1, -1), colour 0xFF191919.
|
||||
@@ -180,13 +184,16 @@ impl ScreenEffects {
|
||||
{
|
||||
let (u, v) = (-yaw / 64.0, pitch / 64.0);
|
||||
let uv = [[u + 4.0, v + 4.0], [u, v + 4.0], [u, v], [u + 4.0, v]];
|
||||
out.push(hand_quad(
|
||||
full,
|
||||
uv,
|
||||
rect,
|
||||
[brightness, brightness, brightness, 0.1],
|
||||
texture_sampling("misc/underwater"),
|
||||
));
|
||||
out.push(ScreenQuad {
|
||||
world_tint: true,
|
||||
..hand_quad(
|
||||
full,
|
||||
uv,
|
||||
rect,
|
||||
[brightness, brightness, brightness, 0.1],
|
||||
texture_sampling("misc/underwater"),
|
||||
)
|
||||
});
|
||||
}
|
||||
if input.on_fire
|
||||
&& let Some(rect) = sprite("block/fire_1")
|
||||
@@ -224,6 +231,7 @@ impl ScreenEffects {
|
||||
}),
|
||||
blend,
|
||||
sampling,
|
||||
world_tint: false,
|
||||
};
|
||||
let rgba = |c: u32| {
|
||||
let [a, r, g, b] = c.to_be_bytes();
|
||||
@@ -397,6 +405,9 @@ mod tests {
|
||||
});
|
||||
let sampling: Vec<_> = quads.iter().map(|q| q.sampling).collect();
|
||||
assert_eq!(sampling, [0, SAMPLE_CLAMP, SAMPLE_BLUR]);
|
||||
// Only the underwater overlay tints the world; the block in the way covers it.
|
||||
let tints: Vec<_> = quads.iter().map(|q| q.world_tint).collect();
|
||||
assert_eq!(tints, [false, true, false]);
|
||||
}
|
||||
#[test]
|
||||
fn mcmeta_texture_section() {
|
||||
|
||||
@@ -46,6 +46,30 @@ pub fn time<T>(name: &'static str, f: impl FnOnce() -> T) -> T {
|
||||
SPANS.with(|s| s.borrow_mut().push(Span { name, ms, cpu_ms }));
|
||||
out
|
||||
}
|
||||
/// Consecutive steps of one function timed as spans: each [`Laps::lap`] records the time since
|
||||
/// the previous one (or since [`laps`]) under its name. Nothing is read while spans are off.
|
||||
pub struct Laps(Option<(Instant, Option<Duration>)>);
|
||||
/// Start timing the steps of a function (see [`Laps`]).
|
||||
#[inline]
|
||||
pub fn laps() -> Laps {
|
||||
Laps(enabled().then(|| (Instant::now(), thread_cpu())))
|
||||
}
|
||||
impl Laps {
|
||||
/// Record the step that just ended under `name`, and start the next.
|
||||
#[inline]
|
||||
pub fn lap(&mut self, name: &'static str) {
|
||||
let Some((started, cpu)) = &mut self.0 else {
|
||||
return;
|
||||
};
|
||||
let (now, now_cpu) = (Instant::now(), thread_cpu());
|
||||
let ms = (now - *started).as_secs_f32() * 1000.0;
|
||||
let cpu_ms = now_cpu
|
||||
.zip(*cpu)
|
||||
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
|
||||
SPANS.with(|s| s.borrow_mut().push(Span { name, ms, cpu_ms }));
|
||||
(*started, *cpu) = (now, now_cpu);
|
||||
}
|
||||
}
|
||||
/// The CPU time this thread has used, where the platform tells it: a tick
|
||||
/// with more wall time than CPU time waited (for a lock, or for a core on a
|
||||
/// busy machine).
|
||||
@@ -92,6 +116,161 @@ pub fn describe(spans: &[Span]) -> String {
|
||||
list.join(", ")
|
||||
}
|
||||
|
||||
/// The start's timeline: `HORIZON_STARTUP_TRACE=FILE` writes every named step of
|
||||
/// the start (resource tables, the renderer, the title, a world's open, a
|
||||
/// server's join, a dimension change) on whichever thread ran it, one Chrome
|
||||
/// trace event per line (`tools/startup_trace.py` reads it; wrapped in `[...]`,
|
||||
/// Perfetto opens it). Times are microseconds since the process started. Off,
|
||||
/// a step costs one relaxed load.
|
||||
pub mod startup {
|
||||
use std::{
|
||||
io::Write as _,
|
||||
sync::{
|
||||
Mutex, OnceLock,
|
||||
atomic::{AtomicBool, AtomicU32, Ordering},
|
||||
},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
static ON: AtomicBool = AtomicBool::new(false);
|
||||
static SINK: OnceLock<Option<Mutex<std::fs::File>>> = OnceLock::new();
|
||||
static ORIGIN: OnceLock<Instant> = OnceLock::new();
|
||||
static NEXT_THREAD: AtomicU32 = AtomicU32::new(1);
|
||||
thread_local! {
|
||||
/// This thread's trace id, given (with its name) on its first event.
|
||||
static THREAD: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Opens the trace named by `HORIZON_STARTUP_TRACE`, timed from `origin`
|
||||
/// (the process's start). Call once, first thing.
|
||||
pub fn init(origin: Instant) {
|
||||
ORIGIN.get_or_init(|| origin);
|
||||
let sink = SINK.get_or_init(|| {
|
||||
let path = std::env::var_os("HORIZON_STARTUP_TRACE")?;
|
||||
std::fs::File::create(&path).ok().map(Mutex::new)
|
||||
});
|
||||
ON.store(sink.is_some(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Whether a trace is being written.
|
||||
#[inline]
|
||||
pub fn on() -> bool {
|
||||
ON.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
fn micros(at: Instant) -> u128 {
|
||||
ORIGIN.get().map_or(0, |origin| {
|
||||
at.saturating_duration_since(*origin).as_micros()
|
||||
})
|
||||
}
|
||||
|
||||
fn write(event: std::fmt::Arguments) {
|
||||
let Some(Some(sink)) = SINK.get() else {
|
||||
return;
|
||||
};
|
||||
let tid = THREAD.with(|id| {
|
||||
if id.get() == 0 {
|
||||
id.set(NEXT_THREAD.fetch_add(1, Ordering::Relaxed));
|
||||
let name = std::thread::current()
|
||||
.name()
|
||||
.unwrap_or("unnamed")
|
||||
.replace(['"', '\\'], "_");
|
||||
let mut file = sink.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let _ = writeln!(
|
||||
file,
|
||||
r#"{{"ph":"M","name":"thread_name","pid":1,"tid":{},"args":{{"name":"{name}"}}}}"#,
|
||||
id.get()
|
||||
);
|
||||
}
|
||||
id.get()
|
||||
});
|
||||
let mut file = sink
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let _ = writeln!(file, r#"{{"pid":1,"tid":{tid},{event}}}"#);
|
||||
}
|
||||
|
||||
/// A step running until it is dropped.
|
||||
#[must_use = "the step ends when this is dropped"]
|
||||
pub struct Step {
|
||||
name: &'static str,
|
||||
started: Instant,
|
||||
}
|
||||
impl Drop for Step {
|
||||
fn drop(&mut self) {
|
||||
let end = Instant::now();
|
||||
write(format_args!(
|
||||
r#""ph":"X","name":"{}","ts":{},"dur":{}"#,
|
||||
self.name,
|
||||
micros(self.started),
|
||||
end.saturating_duration_since(self.started).as_micros()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a step named `name` (a literal: no quotes or backslashes).
|
||||
#[inline]
|
||||
pub fn step(name: &'static str) -> Option<Step> {
|
||||
on().then(|| Step {
|
||||
name,
|
||||
started: Instant::now(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs `f` as a step named `name`.
|
||||
#[inline]
|
||||
pub fn time<T>(name: &'static str, f: impl FnOnce() -> T) -> T {
|
||||
let _step = step(name);
|
||||
f()
|
||||
}
|
||||
|
||||
/// A moment of the start: the title drawn, the world in view.
|
||||
#[inline]
|
||||
pub fn mark(name: &'static str) {
|
||||
if on() {
|
||||
write(format_args!(
|
||||
r#""ph":"i","s":"g","name":"{name}","ts":{}"#,
|
||||
micros(Instant::now())
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// The first time `name` happens, as a mark. `once` is the caller's flag.
|
||||
#[inline]
|
||||
pub fn mark_once(once: &AtomicBool, name: &'static str) {
|
||||
if on() && !once.swap(true, Ordering::Relaxed) {
|
||||
mark(name);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set while a world opens, a server is joined or the dimension changes,
|
||||
/// until the view first fills ([`view_filled`]).
|
||||
static FILLING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// A world starts loading: the next full view is marked.
|
||||
pub fn arm(name: &'static str) {
|
||||
if on() {
|
||||
mark(name);
|
||||
FILLING.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Called each frame in a world: marks `world.view_full` the first frame
|
||||
/// after [`arm`] where nothing waits to load, generate, light, mesh or
|
||||
/// upload and the resident columns cover perf_bench's share of the view
|
||||
/// (0.8 pi r^2: corners are never loaded).
|
||||
#[inline]
|
||||
pub fn view_filled(resident: usize, pending: usize, radius: u32) {
|
||||
if FILLING.load(Ordering::Relaxed)
|
||||
&& pending == 0
|
||||
&& resident as f64 >= 0.8 * std::f64::consts::PI * f64::from(radius * radius)
|
||||
&& FILLING.swap(false, Ordering::Relaxed)
|
||||
{
|
||||
mark("world.view_full");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Span;
|
||||
@@ -138,6 +317,25 @@ mod tests {
|
||||
assert!(super::take().is_empty());
|
||||
}
|
||||
|
||||
/// Laps record the steps between them while spans are on, and nothing while off.
|
||||
#[test]
|
||||
fn laps_time_consecutive_steps() {
|
||||
let _ = super::take();
|
||||
let mut off = super::laps();
|
||||
off.lap("unseen");
|
||||
super::enable(true);
|
||||
let mut laps = super::laps();
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
laps.lap("first");
|
||||
laps.lap("second");
|
||||
super::enable(false);
|
||||
off.lap("still unseen");
|
||||
let spans = super::take();
|
||||
let names: Vec<_> = spans.iter().map(|s| s.name).collect();
|
||||
assert_eq!(names, ["first", "second"]);
|
||||
assert!(spans[0].ms >= 2.0);
|
||||
}
|
||||
|
||||
/// The thread's CPU clock moves with work done on it, not with time
|
||||
/// spent asleep.
|
||||
#[cfg(any(unix, all(windows, target_arch = "x86_64")))]
|
||||
|
||||
@@ -302,6 +302,14 @@ impl Sprites {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// [`Sprites::insert`] without the missing-texture fallback: a file that doesn't load is an
|
||||
/// error, and nothing is added.
|
||||
pub fn try_insert(&mut self, root: &Path, name: &str) -> Result<u32> {
|
||||
match self.layers.get(name) {
|
||||
Some(layer) => Ok(*layer),
|
||||
None => self.load(root, name),
|
||||
}
|
||||
}
|
||||
/// The layer of a texture, added on first use. A texture that is missing
|
||||
/// or does not load (a resource pack's filter or a broken file) becomes
|
||||
/// vanilla's missing texture, as in a stitched atlas.
|
||||
@@ -371,7 +379,11 @@ impl Sprites {
|
||||
}
|
||||
}
|
||||
fn load(&mut self, root: &Path, name: &str) -> Result<u32> {
|
||||
let cached = self.disk.as_ref().and_then(|d| d.get(name)).cloned();
|
||||
// Custom Item Textures' files are outside `textures/`, which the disk cache's
|
||||
// fingerprint covers: like `insert_file`'s, they skip it.
|
||||
let cit = name.starts_with("horizon_cit:");
|
||||
let disk = self.disk.as_mut().filter(|_| !cit);
|
||||
let cached = disk.as_ref().and_then(|d| d.get(name)).cloned();
|
||||
let decoded = match cached {
|
||||
Some(decoded) => decoded,
|
||||
None => {
|
||||
@@ -379,7 +391,7 @@ impl Sprites {
|
||||
Some(decoded) => decoded,
|
||||
None => Self::decode(root, name)?,
|
||||
};
|
||||
if let Some(disk) = &mut self.disk {
|
||||
if let Some(disk) = disk {
|
||||
disk.insert(name, decoded.clone());
|
||||
}
|
||||
decoded
|
||||
@@ -401,13 +413,127 @@ impl Sprites {
|
||||
};
|
||||
self.register(root, resource, decoded)
|
||||
}
|
||||
/// The tiles of the texture `name` (`ns:path`) cut into a `columns` x `rows` grid, row by
|
||||
/// row from the top left: a Fusion connecting texture's sheet. Each tile is its own layer
|
||||
/// (named `fusion/<ns>/<path>#<tile>`); a fully transparent one is `None`, as Fusion skips
|
||||
/// empty tiles. Tiles are square, as wide as a column. An animated sheet's frames are whole
|
||||
/// grids one below the other, or with `per_tile` each cell is a column of its tile's frames.
|
||||
/// The sheet's own layer, if models already show it, shows its first tile from then on.
|
||||
pub fn insert_tiles(
|
||||
&mut self,
|
||||
root: &Path,
|
||||
name: &str,
|
||||
(columns, rows): (u32, u32),
|
||||
per_tile: bool,
|
||||
) -> Result<Vec<Option<u32>>> {
|
||||
let path = crate::atlas_sources::texture_path(root, name, "textures");
|
||||
let metadata = match fs::read_to_string(path.with_extension("png.mcmeta")) {
|
||||
Ok(text) => Some(serde_json::from_str::<
|
||||
BTreeMap<String, Box<serde_json::value::RawValue>>,
|
||||
>(&text)?),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let animation = metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.get("animation"))
|
||||
.map(|v| v.get().to_owned());
|
||||
// The sheet's `texture` section is each tile's.
|
||||
let texture = texture_section(metadata.as_ref());
|
||||
let strategy = texture
|
||||
.as_ref()
|
||||
.and_then(|t| t["mipmap_strategy"].as_str())
|
||||
.and_then(crate::mipmap::Strategy::parse)
|
||||
.unwrap_or_default();
|
||||
let alpha_cutoff_bias = texture
|
||||
.as_ref()
|
||||
.and_then(|t| t["alpha_cutoff_bias"].as_f64())
|
||||
.unwrap_or(0.0) as f32;
|
||||
let (width, height, pixels) = read_rgba(&path)?;
|
||||
let side = width / columns.max(1);
|
||||
// How far down the next frame of a tile is, and the next row of tiles.
|
||||
let (frame_step, row_step) = match per_tile {
|
||||
true => (side, height / rows.max(1)),
|
||||
false => (rows * side, side),
|
||||
};
|
||||
let frames = match per_tile {
|
||||
true => row_step / side.max(1),
|
||||
false => height / frame_step.max(1),
|
||||
};
|
||||
let whole = match per_tile {
|
||||
true => height % rows.max(1) == 0 && row_step % side.max(1) == 0,
|
||||
false => height % frame_step.max(1) == 0,
|
||||
};
|
||||
if side == 0 || frames == 0 || width % columns.max(1) != 0 || !whole {
|
||||
return Err(format!("{name} isn't a {columns} x {rows} grid of square tiles").into());
|
||||
}
|
||||
let tiles = (0..columns * rows)
|
||||
.map(|tile| {
|
||||
let frames: Vec<Vec<u8>> = (0..frames)
|
||||
.map(|f| {
|
||||
let (x, y) = (
|
||||
tile % columns * side,
|
||||
tile / columns * row_step + f * frame_step,
|
||||
);
|
||||
(y..y + side)
|
||||
.flat_map(|row| {
|
||||
let start = ((row * width + x) * 4) as usize;
|
||||
pixels[start..start + side as usize * 4].iter().copied()
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
let alphas = || frames.iter().flat_map(|f| f.iter().skip(3).step_by(4));
|
||||
if alphas().all(|&a| a == 0) {
|
||||
return Ok(None);
|
||||
}
|
||||
let decoded = crate::sprite_cache::Decoded {
|
||||
side,
|
||||
animation: animation.clone().filter(|_| frames.len() > 1),
|
||||
strategy,
|
||||
alpha_cutoff_bias,
|
||||
has_transparent: alphas().any(|&a| a == 0),
|
||||
opaque: alphas().all(|&a| a == 255),
|
||||
translucent: alphas().any(|&a| a != 0 && a != 255),
|
||||
frames,
|
||||
};
|
||||
let tile_name = format!("fusion/{}#{tile}", name.replace(':', "/"));
|
||||
self.register(root, &tile_name, decoded).map(Some)
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
// Where Fusion doesn't draw the sheet (items, other models), the lone tile rather than
|
||||
// the whole sheet squeezed square, whose size would set every layer's.
|
||||
if let (Some(&own), Some(&first)) = (self.layers.get(name), tiles.iter().flatten().next()) {
|
||||
self.show_as(own, first);
|
||||
}
|
||||
Ok(tiles)
|
||||
}
|
||||
/// Layer `layer` shows what `source` does, and is no longer the layer of its old pixels.
|
||||
fn show_as(&mut self, layer: u32, source: u32) {
|
||||
self.still.retain(|_, l| *l != layer);
|
||||
let (l, s) = (layer as usize, source as usize);
|
||||
self.native[l] = self.native[s].clone();
|
||||
self.opaque[l] = self.opaque[s];
|
||||
self.translucent[l] = self.translucent[s];
|
||||
self.mips[l] = self.mips[s];
|
||||
self.pbr[l] = self.pbr[s].clone();
|
||||
self.animations.retain(|a| a.layer != layer);
|
||||
if let Some(animation) = self.animations.iter().find(|a| a.layer == source) {
|
||||
let animation = SpriteAnimation {
|
||||
layer,
|
||||
..animation.clone()
|
||||
};
|
||||
self.animations.push(animation);
|
||||
}
|
||||
}
|
||||
/// Read and decode one texture and its `.mcmeta`.
|
||||
fn decode(root: &Path, name: &str) -> Result<crate::sprite_cache::Decoded> {
|
||||
let (namespace, path) = name.split_once(':').ok_or("invalid sprite identifier")?;
|
||||
Self::decode_file(
|
||||
&root.join(format!("assets/{namespace}/textures/{path}.png")),
|
||||
name,
|
||||
)
|
||||
if !name.contains(':') {
|
||||
return Err("invalid sprite identifier".into());
|
||||
}
|
||||
// `texture_path`, so Custom Item Textures' `horizon_cit:` sprites find their file.
|
||||
let path = crate::atlas_sources::texture_path(root, name, "textures");
|
||||
Self::decode_file(&path, name)
|
||||
}
|
||||
fn decode_file(path: &Path, name: &str) -> Result<crate::sprite_cache::Decoded> {
|
||||
let metadata = match fs::read_to_string(path.with_extension("png.mcmeta")) {
|
||||
@@ -503,8 +629,8 @@ impl Sprites {
|
||||
let count = frames.len() as u32;
|
||||
let layer = u32::try_from(self.native.len())?;
|
||||
let pbr = match name.split_once(':') {
|
||||
Some((namespace, path)) if labpbr_maps() => {
|
||||
let path = root.join(format!("assets/{namespace}/textures/{path}.png"));
|
||||
Some(_) if labpbr_maps() => {
|
||||
let path = crate::atlas_sources::texture_path(root, name, "textures");
|
||||
["_n", "_s"].map(|suffix| pbr_map(&path, suffix, count))
|
||||
}
|
||||
_ => [None, None],
|
||||
@@ -514,9 +640,11 @@ impl Sprites {
|
||||
strategy,
|
||||
alpha_cutoff_bias,
|
||||
has_transparent,
|
||||
item: name
|
||||
.split_once(':')
|
||||
.is_some_and(|(_, p)| p.starts_with("item/")),
|
||||
// Custom Item Textures' ids are all item textures.
|
||||
item: name.starts_with("horizon_cit:")
|
||||
|| name
|
||||
.split_once(':')
|
||||
.is_some_and(|(_, p)| p.starts_with("item/")),
|
||||
dither_cutouts: crate::mipmap::dither_cutouts(),
|
||||
};
|
||||
let still = (animation.is_none() && frames.len() == 1).then(|| {
|
||||
@@ -747,6 +875,168 @@ mod tests {
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn tile_sheets_cut_into_a_layer_per_tile() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-sprite-tiles-{}", std::process::id()));
|
||||
let file = root.join("assets/minecraft/textures/block/sheet.png");
|
||||
fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
// 3 columns of 2 x 2 tiles over 4 rows of pixels: the pixel at (x, y) has red x, green
|
||||
// y; the middle column is transparent, and the top-right tile half see-through.
|
||||
let (w, h) = (6u32, 4u32);
|
||||
let pixels: Vec<u8> = (0..w * h)
|
||||
.flat_map(|i| {
|
||||
let (x, y) = (i % w, i / w);
|
||||
let alpha = match x / 2 {
|
||||
1 => 0,
|
||||
2 if y < 2 => 128,
|
||||
_ => 255,
|
||||
};
|
||||
[x as u8, y as u8, 0, alpha]
|
||||
})
|
||||
.collect();
|
||||
let mut encoder = png::Encoder::new(File::create(&file).unwrap(), w, h);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&pixels)
|
||||
.unwrap();
|
||||
// Red and green of each pixel in a layer's frame.
|
||||
let texels = |sprites: &Sprites, layer: u32, frame: usize| -> Vec<(u8, u8)> {
|
||||
sprites.native[layer as usize].1[frame]
|
||||
.chunks(4)
|
||||
.map(|p| (p[0], p[1]))
|
||||
.collect()
|
||||
};
|
||||
let mut sprites = Sprites::default();
|
||||
let name = "minecraft:block/sheet";
|
||||
let own = sprites.insert(&root, name).unwrap();
|
||||
// Still, as a 3 x 2 grid: the empty middle column draws nothing.
|
||||
let tiles = sprites.insert_tiles(&root, name, (3, 2), false).unwrap();
|
||||
// The sheet's own layer now shows the top-left tile, at a tile's size.
|
||||
assert_eq!(texels(&sprites, own, 0), [(0, 0), (1, 0), (0, 1), (1, 1)]);
|
||||
assert_eq!(
|
||||
tiles.iter().map(Option::is_some).collect::<Vec<_>>(),
|
||||
[true, false, true, true, false, true]
|
||||
);
|
||||
let bottom_left = tiles[3].unwrap();
|
||||
assert_eq!(
|
||||
texels(&sprites, bottom_left, 0),
|
||||
[(0, 2), (1, 2), (0, 3), (1, 3)]
|
||||
);
|
||||
assert!(sprites.translucent[tiles[2].unwrap() as usize]);
|
||||
assert!(sprites.opaque[bottom_left as usize]);
|
||||
// Animated, as one row of tiles: two frames, the grid's top and then its bottom half;
|
||||
// per tile, each cell is the column of a tile's frames, which here is the same.
|
||||
fs::write(file.with_extension("png.mcmeta"), r#"{"animation":{}}"#).unwrap();
|
||||
for per_tile in [false, true] {
|
||||
let mut sprites = Sprites::default();
|
||||
let tiles = sprites.insert_tiles(&root, name, (3, 1), per_tile).unwrap();
|
||||
let first = tiles[0].unwrap();
|
||||
assert_eq!(texels(&sprites, first, 0), [(0, 0), (1, 0), (0, 1), (1, 1)]);
|
||||
assert_eq!(texels(&sprites, first, 1), [(0, 2), (1, 2), (0, 3), (1, 3)]);
|
||||
assert_eq!(sprites.animations.len(), 2, "two non-empty tiles");
|
||||
}
|
||||
// Not a grid of square tiles.
|
||||
assert!(sprites.insert_tiles(&root, name, (4, 1), false).is_err());
|
||||
assert!(sprites.insert_tiles(&root, name, (3, 3), false).is_err());
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn custom_item_texture_ids_load_with_their_animation() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-sprite-cit-{}", std::process::id()));
|
||||
let file = root.join("assets/minecraft/optifine/cit/a.png");
|
||||
fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
let mut encoder = png::Encoder::new(File::create(&file).unwrap(), 16, 48);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let pixels: Vec<u8> = (0..16 * 48).flat_map(|_| [200, 10, 10, 255]).collect();
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&pixels)
|
||||
.unwrap();
|
||||
fs::write(file.with_extension("png.mcmeta"), r#"{"animation":{}}"#).unwrap();
|
||||
let mut sprites = Sprites::default();
|
||||
sprites
|
||||
.insert(&root, "horizon_cit:minecraft/optifine/cit/a")
|
||||
.unwrap();
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
// The missing texture would not animate: this one is the file, in three frames.
|
||||
assert_eq!(sprites.animations.len(), 1);
|
||||
assert_eq!(sprites.animations[0].frame_count(), 3);
|
||||
}
|
||||
#[test]
|
||||
fn custom_item_textures_skip_the_disk_cache() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-sprite-cit-disk-{}", std::process::id()));
|
||||
// A realistic root: the cache's fingerprint walks the `textures` trees.
|
||||
let vanilla = root.join("assets/minecraft/textures/item/stick.png");
|
||||
let file = root.join("assets/minecraft/optifine/cit/a.png");
|
||||
let write = |path: &Path, red: u8| {
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let mut encoder = png::Encoder::new(File::create(path).unwrap(), 16, 16);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let pixels: Vec<u8> = (0..256).flat_map(|_| [red, 0, 0, 255]).collect();
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&pixels)
|
||||
.unwrap();
|
||||
};
|
||||
write(&vanilla, 1);
|
||||
write(&file, 10);
|
||||
let id = "horizon_cit:minecraft/optifine/cit/a";
|
||||
let red = |sprites: &Sprites, layer: u32| sprites.native[layer as usize].1[0][0];
|
||||
let mut first = Sprites::with_disk_cache(&root);
|
||||
let layer = first.insert(&root, id).unwrap();
|
||||
assert_eq!(red(&first, layer), 10);
|
||||
first.save_disk_cache();
|
||||
// The pack's file changes; `textures/` does not, so a cached copy would still match.
|
||||
write(&file, 20);
|
||||
let mut second = Sprites::with_disk_cache(&root);
|
||||
let layer = second.insert(&root, id).unwrap();
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
assert_eq!(red(&second, layer), 20);
|
||||
}
|
||||
#[test]
|
||||
fn custom_item_textures_mip_as_items() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-sprite-cit-mip-{}", std::process::id()));
|
||||
let write = |rel: &str| {
|
||||
let path = root.join(rel);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let mut encoder = png::Encoder::new(File::create(path).unwrap(), 16, 16);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
// Any pixels: whether a texture mips as an item is part of the shared-layer key.
|
||||
let pixels: Vec<u8> = (0..256)
|
||||
.flat_map(|i| [9, 9, 9, if i % 2 == 0 { 255 } else { 0 }])
|
||||
.collect();
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&pixels)
|
||||
.unwrap();
|
||||
};
|
||||
write("assets/a/textures/block/x.png");
|
||||
write("assets/a/textures/item/x.png");
|
||||
write("assets/minecraft/optifine/cit/x.png");
|
||||
let mut sprites = Sprites::default();
|
||||
let block = sprites.insert(&root, "a:block/x").unwrap();
|
||||
let item = sprites.insert(&root, "a:item/x").unwrap();
|
||||
let cit = sprites
|
||||
.insert(&root, "horizon_cit:minecraft/optifine/cit/x")
|
||||
.unwrap();
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
// The same pixels share a layer only with the same mipmap settings: the CIT texture's
|
||||
// are the item's.
|
||||
assert_ne!(block, item);
|
||||
assert_eq!(cit, item);
|
||||
}
|
||||
#[test]
|
||||
fn prefetched_textures_take_the_layers_inserting_alone_gives() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-sprite-prefetch-{}", std::process::id()));
|
||||
|
||||
Loaded 100 of 554 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user