mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-08 17:19:10 -10:00
Compare commits
586
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73b0a91c13 | ||
|
|
e547357ae7 | ||
|
|
db87c15e7c | ||
|
|
83e4f7028b | ||
|
|
cc9da4ef8f | ||
|
|
b310f706dd | ||
|
|
b5bbc989ae | ||
|
|
3f489a5e73 | ||
|
|
ca6a29bf50 | ||
|
|
b6e5125c26 | ||
|
|
85f2a3d6c1 | ||
|
|
c3d493b309 | ||
|
|
0c080475bc | ||
|
|
89a904c8a5 | ||
|
|
93848bf19b | ||
|
|
0ffdf24711 | ||
|
|
0a41b1b12c | ||
|
|
3888a0fcc3 | ||
|
|
f7ce74d8ab | ||
|
|
5e79ce9da6 | ||
|
|
613205b79c | ||
|
|
68f661b686 | ||
|
|
1cf95d4de1 | ||
|
|
b6902327b4 | ||
|
|
94740f1b3b | ||
|
|
182a3c2fe7 | ||
|
|
7c77479175 | ||
|
|
7256e407de | ||
|
|
ff58018a06 | ||
|
|
a2e0595255 | ||
|
|
f52d16d46a | ||
|
|
0d9ebac9ce | ||
|
|
1c6d163156 | ||
|
|
a41ac74237 | ||
|
|
c2535ba21f | ||
|
|
8caa631434 | ||
|
|
dde65ec694 | ||
|
|
a3a423c26f | ||
|
|
851ef6ebbf | ||
|
|
504f35eeb0 | ||
|
|
01e27e93f0 | ||
|
|
625844b654 | ||
|
|
d51ac0e772 | ||
|
|
5852c51e76 | ||
|
|
e4f993d5b3 | ||
|
|
9cd7b899eb | ||
|
|
1db479a6eb | ||
|
|
24321e2da7 | ||
|
|
aad77e73e1 | ||
|
|
133a9e6cb8 | ||
|
|
9c364b492d | ||
|
|
db63f2f6fe | ||
|
|
59e4e5bc0c | ||
|
|
127d79c02c | ||
|
|
0346fe5f36 | ||
|
|
4af07e93ac | ||
|
|
7aed5be227 | ||
|
|
529d713ba0 | ||
|
|
dc8b7dd1c0 | ||
|
|
603ff7030d | ||
|
|
3588d63f6b | ||
|
|
257e4f3f88 | ||
|
|
673bf94146 | ||
|
|
0cc69c80a3 | ||
|
|
7dbbd8bc9f | ||
|
|
8a9f5a1288 | ||
|
|
c57792a7c3 | ||
|
|
40fe249d09 | ||
|
|
246b32c604 | ||
|
|
0c65c8d47b | ||
|
|
fc442a81aa | ||
|
|
df31a2fe7d | ||
|
|
2756d39448 | ||
|
|
33f2d64da8 | ||
|
|
1d6a65b2b0 | ||
|
|
23e9193dc3 | ||
|
|
0b65f696e2 | ||
|
|
51a0732053 | ||
|
|
b33b610d56 | ||
|
|
00ee749a58 | ||
|
|
08eb26ad9e | ||
|
|
1cd834a3d8 | ||
|
|
b7b5cda0ad | ||
|
|
53776cf792 | ||
|
|
2bb1ef83e4 | ||
|
|
5abd285cc1 | ||
|
|
62707dae44 | ||
|
|
f4aeb6aa14 | ||
|
|
040629ea7e | ||
|
|
6ba328d7ca | ||
|
|
812188bcc4 | ||
|
|
f66c07b0c3 | ||
|
|
f4cb8d2fba | ||
|
|
c2b8c12bb7 | ||
|
|
c76286a74a | ||
|
|
f7063d3621 | ||
|
|
d7bbe2f5e3 | ||
|
|
6feba56d0e | ||
|
|
01ecb800e2 | ||
|
|
78151f0e02 | ||
|
|
f858ee752f | ||
|
|
e47abbc7fe | ||
|
|
90796bc21b | ||
|
|
773e7850b5 | ||
|
|
1df9099c8f | ||
|
|
338a8c36f8 | ||
|
|
266bdda26e | ||
|
|
f5515b409c | ||
|
|
ee0a6c2093 | ||
|
|
976346aff5 | ||
|
|
141842b509 | ||
|
|
e5f333e885 | ||
|
|
7da0bdf46d | ||
|
|
63e8ff40fe | ||
|
|
e3242b385b | ||
|
|
991a9b5451 | ||
|
|
f3418e1525 | ||
|
|
c4c0b54fad | ||
|
|
a33d57b36e | ||
|
|
b4e940a4ec | ||
|
|
02cc303caa | ||
|
|
9e053b5fab | ||
|
|
6865327231 | ||
|
|
39f04d6797 | ||
|
|
20cf432402 | ||
|
|
3a00fb7f85 | ||
|
|
d44bca934e | ||
|
|
ce5ba9d529 | ||
|
|
0497e8bc74 | ||
|
|
a5d17ed836 | ||
|
|
857c741402 | ||
|
|
d3017b792e | ||
|
|
465a6752f4 | ||
|
|
0e8f5dbb8a | ||
|
|
fcb88320e1 | ||
|
|
3808ecf716 | ||
|
|
0fcdcc425a | ||
|
|
1700772be1 | ||
|
|
167e772c20 | ||
|
|
298ca95813 | ||
|
|
9803a016c4 | ||
|
|
6ea8967d2f | ||
|
|
121c6b27d5 | ||
|
|
7d0f8b956b | ||
|
|
6de492a6fc | ||
|
|
67e5c2d205 | ||
|
|
669e6b33d2 | ||
|
|
e9846abaf3 | ||
|
|
e263c6a6ef | ||
|
|
3b695e3b18 | ||
|
|
78482b23de | ||
|
|
672e276940 | ||
|
|
896d960bd7 | ||
|
|
fc912ff3a1 | ||
|
|
2a1437a1c0 | ||
|
|
1c385ab9b0 | ||
|
|
06dc58c8a4 | ||
|
|
85878fa594 | ||
|
|
f35632c2a9 | ||
|
|
de4ed2745c | ||
|
|
bab2e82183 | ||
|
|
ef2ce44b59 | ||
|
|
f4938d63aa | ||
|
|
0b5e417e4c | ||
|
|
7c2db89229 | ||
|
|
fe04f52326 | ||
|
|
7a56b3516d | ||
|
|
bd3c2d7a14 | ||
|
|
230ff171f0 | ||
|
|
9a12a64459 | ||
|
|
fa727686e3 | ||
|
|
1e981c99c6 | ||
|
|
790d1e3c27 | ||
|
|
e3cf8bb456 | ||
|
|
08819d4636 | ||
|
|
e5e6e26d91 | ||
|
|
243ab4e220 | ||
|
|
3799edbc34 | ||
|
|
bab8bf1166 | ||
|
|
f0d91bb791 | ||
|
|
05e56173c2 | ||
|
|
25c5fd1db7 | ||
|
|
4243b253d2 | ||
|
|
577ea049d3 | ||
|
|
c1b0538540 | ||
|
|
385393ec3c | ||
|
|
85ce314e76 | ||
|
|
bbe70b1ca2 | ||
|
|
ba81d33cb7 | ||
|
|
fb123d2479 | ||
|
|
fa5554339e | ||
|
|
0ebfe7d116 | ||
|
|
bff36a3b38 | ||
|
|
265b1a3cb7 | ||
|
|
a2b3f4fe86 | ||
|
|
03093772e4 | ||
|
|
62495116b5 | ||
|
|
549c7a2bc8 | ||
|
|
9b17b20107 | ||
|
|
5a562fb5a2 | ||
|
|
b4948662ae | ||
|
|
f1e42dcaf9 | ||
|
|
d9bd370a39 | ||
|
|
e1899cca8f | ||
|
|
834ecc9625 | ||
|
|
f923aa5118 | ||
|
|
9b185113e5 | ||
|
|
47d2e49fc7 | ||
|
|
92618fa3a9 | ||
|
|
f51e2b0ec1 | ||
|
|
35f1f912a3 | ||
|
|
b5254c9f6d | ||
|
|
4132d833ff | ||
|
|
d88c9de224 | ||
|
|
f687a35393 | ||
|
|
25a495c107 | ||
|
|
817641fa36 | ||
|
|
64d2225b47 | ||
|
|
0cd71497b9 | ||
|
|
9358df9085 | ||
|
|
bb827b3bfe | ||
|
|
0f2374a9f1 | ||
|
|
f6e0f987ca | ||
|
|
f60f2a7cd6 | ||
|
|
58ba6ee7bf | ||
|
|
fae353e82a | ||
|
|
982afd44b7 | ||
|
|
27d330d03b | ||
|
|
1570016310 | ||
|
|
edf6782e02 | ||
|
|
03a5d8f278 | ||
|
|
55d85acf5c | ||
|
|
d5504cd328 | ||
|
|
f528cc4298 | ||
|
|
309ac52058 | ||
|
|
5e7b000a0e | ||
|
|
7ee63f2db5 | ||
|
|
e5534306c7 | ||
|
|
30e4b26b37 | ||
|
|
29be573768 | ||
|
|
cb1e8d891e | ||
|
|
0741d53c75 | ||
|
|
ce2ec03a01 | ||
|
|
8de6ff0bca | ||
|
|
1501a0fd60 | ||
|
|
ba342ec0f6 | ||
|
|
546db0269c | ||
|
|
51c2e96495 | ||
|
|
a9fb6ffc9a | ||
|
|
159fb6af86 | ||
|
|
d1a4478daa | ||
|
|
efda24ee40 | ||
|
|
3dd49e2848 | ||
|
|
7b59041a89 | ||
|
|
5fe5747f44 | ||
|
|
841a5a4acd | ||
|
|
e5397553a2 | ||
|
|
196348ee3e | ||
|
|
a44f73e9ae | ||
|
|
3fa342468c | ||
|
|
b7b1d73a66 | ||
|
|
3fe80aae12 | ||
|
|
af996ff963 | ||
|
|
633cac87b5 | ||
|
|
592c4f55ef | ||
|
|
4c100ad4f1 | ||
|
|
99893d81c0 | ||
|
|
34b9d2cd64 | ||
|
|
0b2279aaa3 | ||
|
|
72c9d15042 | ||
|
|
32f28b9d08 | ||
|
|
89f236e3ef | ||
|
|
e2a28f96b8 | ||
|
|
eee4242951 | ||
|
|
b1d469b419 | ||
|
|
44aa36f166 | ||
|
|
3f0328f42f | ||
|
|
d0e38cc6c7 | ||
|
|
fcc01b662b | ||
|
|
fae81ab428 | ||
|
|
0030a4add8 | ||
|
|
c2be77292a | ||
|
|
c5a4b2cdd5 | ||
|
|
caac31c8df | ||
|
|
dd208c4f97 | ||
|
|
7092049967 | ||
|
|
4006b3da52 | ||
|
|
b44fa6e966 | ||
|
|
0331afdf3d | ||
|
|
0952f173b5 | ||
|
|
bff215ab70 | ||
|
|
13b8277019 | ||
|
|
3587cef272 | ||
|
|
92d612f109 | ||
|
|
04d8249f08 | ||
|
|
30628bf79c | ||
|
|
19e86d6782 | ||
|
|
0f82100ddd | ||
|
|
a0794bfd2e | ||
|
|
aac0f59fd7 | ||
|
|
962ce1dfbc | ||
|
|
5c684b8cf1 | ||
|
|
c504f1347a | ||
|
|
0ab94f3e13 | ||
|
|
8e02a3cb80 | ||
|
|
a5cb5bdadd | ||
|
|
3c7500e007 | ||
|
|
31c060e41b | ||
|
|
b3e597d983 | ||
|
|
84b445e017 | ||
|
|
a31ee46ddf | ||
|
|
c23d7cdf4b | ||
|
|
264ea0cc7c | ||
|
|
bee1d42fd2 | ||
|
|
b3aded6e7d | ||
|
|
8bb7788b8e | ||
|
|
1c888ac5b2 | ||
|
|
bcb76434bc | ||
|
|
29cbcd69d1 | ||
|
|
6e42a09d63 | ||
|
|
abbe20ec90 | ||
|
|
87c51909d4 | ||
|
|
20111ddf7b | ||
|
|
252a012722 | ||
|
|
7f9e634e22 | ||
|
|
f4b8e96eca | ||
|
|
77614f2697 | ||
|
|
0cb33a7139 | ||
|
|
cd069f2065 | ||
|
|
271b3626a2 | ||
|
|
aac977d7dd | ||
|
|
3b34e7ba33 | ||
|
|
7c5a4a621d | ||
|
|
f2c1081089 | ||
|
|
ab56dc20bd | ||
|
|
76d91d6b27 | ||
|
|
fb0a077202 | ||
|
|
b90fee34be | ||
|
|
42a0d12f62 | ||
|
|
40a9813dcd | ||
|
|
fa7924b3fb | ||
|
|
e327c79d1f | ||
|
|
b18000dccf | ||
|
|
9c83ccf52f | ||
|
|
321a391209 | ||
|
|
2f400ca79e | ||
|
|
42d1e6d7d7 | ||
|
|
ae40b07eeb | ||
|
|
16fdd05784 | ||
|
|
b6036c08c1 | ||
|
|
6b8be62697 | ||
|
|
1f11377f60 | ||
|
|
7457de69bb | ||
|
|
8db87db310 | ||
|
|
385bd4d0b1 | ||
|
|
0989100291 | ||
|
|
a174de80d3 | ||
|
|
42a73e846d | ||
|
|
24e5d3d707 | ||
|
|
598a294156 | ||
|
|
3bdce4d658 | ||
|
|
da0cab5bfa | ||
|
|
5c6329ba54 | ||
|
|
e8b162cd2f | ||
|
|
b3f312cf99 | ||
|
|
87ba93d8d1 | ||
|
|
5c6d7b6dab | ||
|
|
795ae8e8a0 | ||
|
|
7cd858f208 | ||
|
|
fd055a0020 | ||
|
|
c30e611ec3 | ||
|
|
a5f7ec2c4c | ||
|
|
9a4ac94b73 | ||
|
|
efaa1660e9 | ||
|
|
f39318eeb0 | ||
|
|
d398fbc8b0 | ||
|
|
39af844866 | ||
|
|
81337c3a2f | ||
|
|
3192adafe0 | ||
|
|
a6705ea839 | ||
|
|
d9b3005690 | ||
|
|
eef19d5cf9 | ||
|
|
1eb93f5884 | ||
|
|
4bc55da08d | ||
|
|
dcd90c2b1f | ||
|
|
41a72c6eda | ||
|
|
3ae606abce | ||
|
|
10eb936805 | ||
|
|
29ac896b4c | ||
|
|
f9eb983ba1 | ||
|
|
e13a206334 | ||
|
|
2ba45c262d | ||
|
|
0be41c06be | ||
|
|
3e4cfbe7ea | ||
|
|
110e686f4d | ||
|
|
646572e69b | ||
|
|
a8d74ca97b | ||
|
|
fe74121c5b | ||
|
|
d170e4f674 | ||
|
|
bde1cc63c5 | ||
|
|
0b5b68bdd2 | ||
|
|
39d6f25e58 | ||
|
|
4b9cc3eeae | ||
|
|
b00506bc0e | ||
|
|
4a3058d4ad | ||
|
|
be5c7e2fe2 | ||
|
|
68bb5be687 | ||
|
|
0a63e373ad | ||
|
|
291a28fc99 | ||
|
|
70d3441c4a | ||
|
|
54b6232191 | ||
|
|
aea460736c | ||
|
|
af256dcc40 | ||
|
|
e54d91dc0c | ||
|
|
fffa3bf6b8 | ||
|
|
f70686be76 | ||
|
|
fe70a48402 | ||
|
|
67ebf35e57 | ||
|
|
1406684c20 | ||
|
|
4b4dca948c | ||
|
|
b2a457fdef | ||
|
|
d9eda46d06 | ||
|
|
1f149381f8 | ||
|
|
02bbcbd466 | ||
|
|
e0d14e4edf | ||
|
|
b4b6eefcfb | ||
|
|
6f31c8d6d1 | ||
|
|
545cb15f18 | ||
|
|
e2c9f5dd08 | ||
|
|
389a495f09 | ||
|
|
7f2fdba6ba | ||
|
|
7f63f42e27 | ||
|
|
91879dc898 | ||
|
|
4dd5d0ce71 | ||
|
|
b0bb5d2a9e | ||
|
|
860355f55e | ||
|
|
3233aaabe9 | ||
|
|
8d8ef94673 | ||
|
|
f9374b154b | ||
|
|
009baf97ad | ||
|
|
e7cd3c4c6c | ||
|
|
3fd4fed964 | ||
|
|
539933e0a1 | ||
|
|
bfd5c69005 | ||
|
|
54cae6c3ef | ||
|
|
cf23b0da77 | ||
|
|
0ce693d743 | ||
|
|
e5bbad5f2f | ||
|
|
c052c5523a | ||
|
|
5d5e00f783 | ||
|
|
f7e6433729 | ||
|
|
2d4a338796 | ||
|
|
48ec2657df | ||
|
|
a05497bbcd | ||
|
|
40d0364acc | ||
|
|
ce99d8445d | ||
|
|
b5723fd33d | ||
|
|
09ccb39c4f | ||
|
|
5273939a30 | ||
|
|
68f4708d84 | ||
|
|
aa3819d4a5 | ||
|
|
3316c45f44 | ||
|
|
36896744bf | ||
|
|
dc22fb4396 | ||
|
|
f7a1f50728 | ||
|
|
f4aa6beb4a | ||
|
|
8b65ee2372 | ||
|
|
f523a60066 | ||
|
|
27d7fdd859 | ||
|
|
34b0265744 | ||
|
|
abcbf3739d | ||
|
|
45bcdca495 | ||
|
|
4e724e319b | ||
|
|
6b2fadfcd5 | ||
|
|
96f1fb36a2 | ||
|
|
2e258065d7 | ||
|
|
0256b28b72 | ||
|
|
8200d52bdd | ||
|
|
ce61a0e1de | ||
|
|
9791592034 | ||
|
|
afd3e48ccd | ||
|
|
56dbf356a0 | ||
|
|
478e7b45bd | ||
|
|
db32617271 | ||
|
|
ad7effc349 | ||
|
|
0299f2fe2f | ||
|
|
a145d469c6 | ||
|
|
0ec0dbb2f4 | ||
|
|
c5e3ef940b | ||
|
|
b83ab5e01d | ||
|
|
e8a1e5b997 | ||
|
|
acd32b8d31 | ||
|
|
8695ecb3b4 | ||
|
|
08ed0dcb83 | ||
|
|
6aa575f7d1 | ||
|
|
65d988b030 | ||
|
|
a4bf12b2da | ||
|
|
42635ff154 | ||
|
|
79a2d9d554 | ||
|
|
0d45709554 | ||
|
|
936a48fe4b | ||
|
|
afc1d6a0e0 | ||
|
|
226c1ceeef | ||
|
|
21a5d735ea | ||
|
|
0e525f3952 | ||
|
|
e5f945dc55 | ||
|
|
00299919d9 | ||
|
|
5661b09d71 | ||
|
|
40c12c20e0 | ||
|
|
a7c004b6ac | ||
|
|
4d6f0a1c4a | ||
|
|
bd13d0f39c | ||
|
|
468ab204c7 | ||
|
|
aa208521eb | ||
|
|
92c1eb1b76 | ||
|
|
408d193d01 | ||
|
|
9f903d2b3f | ||
|
|
e0615ba966 | ||
|
|
ad183565d8 | ||
|
|
70c8fe35a2 | ||
|
|
4bcfbe6eb3 | ||
|
|
28722235de | ||
|
|
3f71bd7deb | ||
|
|
3b3ed4b217 | ||
|
|
a0747ddd25 | ||
|
|
a3770664c2 | ||
|
|
f03f847d4f | ||
|
|
408e4fda1c | ||
|
|
7b92e8a8aa | ||
|
|
bcd3d93200 | ||
|
|
2ece11744d | ||
|
|
12b51783dd | ||
|
|
d9a8fb0667 | ||
|
|
cc1825d017 | ||
|
|
cadfdbcfb1 | ||
|
|
bb6e95d5fe | ||
|
|
63db5cdd81 | ||
|
|
81caf4c301 | ||
|
|
efbd55185e | ||
|
|
b68899faad | ||
|
|
0618cea36e | ||
|
|
8beaf4b572 | ||
|
|
14a479c392 | ||
|
|
ded1909d91 | ||
|
|
92a19421d2 | ||
|
|
e17f52b8c2 | ||
|
|
dc881c302a | ||
|
|
0e17b03481 | ||
|
|
7f4a2fe8a0 | ||
|
|
0ed839b8a1 | ||
|
|
dd3bac8217 | ||
|
|
e39af976c9 | ||
|
|
eca3a71c8c | ||
|
|
ef2142e5a6 | ||
|
|
bf7ec63e8e | ||
|
|
b8e5247b35 | ||
|
|
cd18425b4a | ||
|
|
5f94268324 | ||
|
|
eb83113dbb | ||
|
|
1522c80b8f | ||
|
|
9b0a0d56a9 | ||
|
|
69193a3635 | ||
|
|
fb350ecfc4 | ||
|
|
3b7a7a9ef3 | ||
|
|
b89066f4ac | ||
|
|
f9e8af6a94 | ||
|
|
42cad38648 | ||
|
|
86b883ab33 | ||
|
|
8a0ec928fa | ||
|
|
dce5e62714 | ||
|
|
f102160191 | ||
|
|
125b46829c | ||
|
|
5845d88a53 | ||
|
|
02d9ee7962 | ||
|
|
8a197bf691 | ||
|
|
34897bdcd3 | ||
|
|
dc5420e82e | ||
|
|
9850f641b5 | ||
|
|
652be13517 | ||
|
|
e1294dbaf6 | ||
|
|
381c191dc1 | ||
|
|
05a9ed3057 | ||
|
|
7a065ec02b | ||
|
|
df7f146ed9 | ||
|
|
1d29778ef7 | ||
|
|
01b4e8b995 |
No files matched your search
@@ -2,7 +2,8 @@
|
||||
|
||||
## What and why
|
||||
|
||||
<!-- What this changes, and why. Link the issue it fixes ("Fixes #123"). -->
|
||||
<!-- What this changes, and why. Link the issue it fixes ("Fixes #123"). Over about 500 lines: link the issue with its
|
||||
approved spec too ("Spec: #123"), CONTRIBUTING.md "Large pull requests". -->
|
||||
|
||||
## Kind
|
||||
|
||||
@@ -16,6 +17,16 @@
|
||||
- [ ] tooling: tools, CI, tests, harnesses
|
||||
- [ ] docs
|
||||
|
||||
## Layer
|
||||
|
||||
<!-- engine, platform or Horizon (docs/layers.md, "Where a new feature goes"). Reviewers check:
|
||||
- the new code sits in a crate or module of that layer;
|
||||
- no new dependency its layer may not use (CI's layer check): no engine code calling platform or Horizon code,
|
||||
and Horizon code using the platform's public API only, never the engine directly;
|
||||
- Horizon features in the game are behind `horizon-extras`, and platform features that change the vanilla
|
||||
look are off by default;
|
||||
- no new built-in server address outside the Horizon layer. -->
|
||||
|
||||
## Vanilla reference
|
||||
|
||||
<!-- The vanilla classes and methods this follows (tools/rea_vanilla.py), and the harness that checks it.
|
||||
|
||||
@@ -13,5 +13,4 @@
|
||||
87152661 rdbtCVS # added by Ludovic, 2026-10-27
|
||||
84875247 Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
|
||||
86072227 levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
|
||||
87478350 Qwerner88880 # added by Ludovic, 2026-10-31 (Discord vladislav_hagen; native-mod hooks)
|
||||
169198624 itarqos5 # added by Ludovic, 2026-11-08 (Discord Literal; Horizon Team)
|
||||
@@ -78,6 +78,21 @@ jobs:
|
||||
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
|
||||
rustup default stable
|
||||
echo "WGPU_BACKEND=dx12" >> "$GITHUB_ENV"
|
||||
# docs/layers.md: a crate depends only on its own layer or a lower one; today's exceptions only shrink.
|
||||
- name: Crates follow the layers
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: python3 tools/check_layers.py ${BASE_SHA:+--base "$BASE_SHA"}
|
||||
# docs/layers.md#built-in-addresses: every built-in outside address in the Horizon layer's home; the rest only shrinks.
|
||||
- name: Built-in addresses stay in their home
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: python3 tools/check_addresses.py ${BASE_SHA:+--base "$BASE_SHA"}
|
||||
# docs/layers.md#file-size: a Rust file over 3000 lines may not grow (pull requests; main has no base to compare).
|
||||
- name: Big files don't grow
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: python3 tools/check_file_size.py ${BASE_SHA:+--base "$BASE_SHA"}
|
||||
- name: Test offline research tooling
|
||||
run: python -m unittest discover -s tests -v
|
||||
- name: Install Rust toolchain and the ALSA and udev headers the client links against
|
||||
|
||||
@@ -25,6 +25,10 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
|
||||
first line `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, commits
|
||||
`Co-Authored-By: Claude <model> <[email protected]>`, last line
|
||||
`🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
|
||||
- **Layers** ([docs/layers.md](docs/layers.md)): engine uses engine; platform uses engine and platform; Horizon (its extras,
|
||||
cosmetics, shop, telemetry, launcher) uses platform and Horizon only, never the engine: it moves to its own repository
|
||||
later. `tools/check_layers.py` checks it. New Horizon code goes behind `horizon-extras`; built-in addresses go in
|
||||
`horizon-addresses`.
|
||||
- Everything players run is Rust. Python/Java only for dev tools and harness drivers in `tools/`.
|
||||
- Horizon-only features follow [docs/design-system.md](docs/design-system.md) and are off by default where
|
||||
they would change the vanilla look.
|
||||
@@ -61,6 +65,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `horizon-server-files` | `server.properties`, eula, ops/whitelist/bans, usercache |
|
||||
| `horizon-anticheat` | The dedicated server's anticheat, off by default: `[anticheat]` settings, violation levels, the checks' rules; the hub runs them (`lan/anticheat.rs`) ([docs/anticheat.md](docs/anticheat.md)) |
|
||||
| `horizon-cosmetics` | Cosmetic items Horizon clients draw on players (off by default): the item format, bones and animations ([docs/cosmetics.md](docs/cosmetics.md)) |
|
||||
| `horizon-addresses` | Every built-in address of an outside service vanilla doesn't contact (Horizon layer; [docs/layers.md](docs/layers.md#built-in-addresses)) |
|
||||
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
|
||||
| `horizon-mods`, `cargo-horizon-mod` | Content mods (`.hmod`: manifest, content fence, no code), the registry index and `.hpack`, and `cargo horizon-mod` (`new`, `build`, `run`, `check`, `registry`) ([docs/mods.md](docs/mods.md)). Code mods are native, built by the launcher ([docs/native-mods.md](docs/native-mods.md)) |
|
||||
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
|
||||
@@ -70,6 +75,8 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
|
||||
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
|
||||
| `horizon-telemetry`, `horizon-telemetry-server`, `horizon-bench` | Opt-in data collection, its ingest server, the benchmark report format |
|
||||
| `horizon-system` | The machine the game runs on (OS, CPU, memory, drive, power, GPU maker and memory), for the crash report, the renderer's GPU info and telemetry |
|
||||
| `horizon-platform` | The platform's public API: what the Horizon layer may use of the engine ([docs/layers.md](docs/layers.md)); re-exports `horizon-system` today |
|
||||
| `horizon-dualsense`, `horizon-threads`, `horizon-release-sign`, `horizon-bots`, `xtask` | DualSense output, thread priorities, signed release checksums, load-test bots, repo tasks |
|
||||
| `launcher/` | GPUI launcher: `main.rs` (UI), `backend.rs` (installs, updates), `cli.rs`, `mcp.rs`, `*_page.rs` |
|
||||
|
||||
@@ -101,6 +108,7 @@ python3 tools/parity.py --changed-files paths.txt --list # which harnesses cov
|
||||
python3 tools/parity.py mob_ai sculk # named harnesses
|
||||
python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host)
|
||||
python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
python3 tools/lang_coverage.py --summary # the keys each language still misses, and its English leftovers
|
||||
```
|
||||
|
||||
- Harnesses and the game need the vanilla data in `.local/` (`minecraft/`, `horizon-client/26.3`, `datagen/`,
|
||||
@@ -152,17 +160,17 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
|
||||
| Subsystem | Files |
|
||||
|---|---|
|
||||
| Game loop, tick, frame | `game.rs`: `fn run`, `fn tick`, `fn tick_world`, `fn render`; G: `window_handler.rs` (`impl ApplicationHandler for Game`: `window_event`, `about_to_wait`); L: `clock.rs`, `spans.rs`; `horizon-client-input/src/window.rs` |
|
||||
| Game loop, tick, frame | `game.rs`: `fn run`, `fn tick`, `fn tick_world`, `fn render`; G: `window_handler.rs` (`impl ApplicationHandler for Game`: `window_event`, `about_to_wait`), `launch_options.rs` (the command line); L: `clock.rs`, `spans.rs`; `horizon-client-input/src/window.rs` |
|
||||
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs`, `screen_backgrounds.rs` (panorama, menu blur, border wall) |
|
||||
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `server_terrain.rs` (a server's kept chunks), `bluemap.rs` + `bluemap_detect.rs` (far terrain from its web map), `ticket_levels.rs`; G: `world_loading.rs`, `world_entry.rs`, `postprocess.rs`; game.rs `fn update_world` |
|
||||
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `server_terrain.rs` (a server's kept chunks), `bluemap.rs` + `bluemap_detect.rs` (far terrain from its web map), `ticket_levels.rs`; G: `world_loading.rs`, `world_entry.rs`, `postprocess.rs`, `world_update.rs` (`fn update_world`, far terrain) |
|
||||
| World generation | `crates/horizon-world/src/worldgen/` (`generator*.rs`, `feature_graph.rs`, `structure/`, `carver/`) |
|
||||
| Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); game.rs `fn tick_blocks`, `fn process_level_events` |
|
||||
| Mobs | L: `mobs.rs` (types), `mobs/` (`ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `species.rs`, `spawning.rs`, `render.rs`, `anim.rs`); G: `creatures.rs` (ticking, spawning, mob events), one file per mob family (`dragon.rs`, `wither.rs`, `villagers.rs`, `piglins.rs`, `golems.rs`...), `natural_spawning.rs` |
|
||||
| Other entities | L: `entities.rs`, `projectiles.rs`, `arrows.rs`; G: `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` |
|
||||
| Player actions | game.rs (`fn use_hand`, `fn tick_mining`, `fn break_block`); G: `item_actions.rs` (`fn use_item`), `melee.rs` (`fn attack`), `hand.rs`, `combat.rs`, `consume.rs`, `using_item.rs` (an item in use, tick by tick), `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.rs` |
|
||||
| Player actions | game.rs (`fn tick_mining`, `fn break_block`); G: `use_hand.rs` (`fn use_hand`), `item_actions.rs` (`fn use_item`), `melee.rs` (`fn attack`), `hand.rs`, `combat.rs`, `consume.rs`, `using_item.rs` (an item in use, tick by tick), `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.rs` |
|
||||
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs`, `inventory_input.rs` (keys and clicks over a container), `inventory_slots.rs` (slot layout and drawing) |
|
||||
| Commands | parse: crate `horizon-command` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`; `horizon-client-core`: `nbt_path.rs`, `scoreboard.rs`; G: `functions.rs`, `command_blocks.rs` |
|
||||
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `settings_shell.rs` (Horizon's settings screens on it: `extras_ui.rs`, `graphics_ui.rs`, `controller_ui.rs`), `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `hud_kit/` (Horizon overlays' `Room` and `Plate`, [docs/ui-kit.md](docs/ui-kit.md)), `pad_glyph.rs` (controller glyphs, by what the button does), `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `loading_screen.rs` (the loading screen, the pre-game `App`), `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs`, `creative_screen.rs` |
|
||||
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `settings_shell.rs` (Horizon's settings screens on it: `extras_ui.rs`, `graphics_ui.rs`, `controller_ui.rs`, `shader_ui.rs`; a page list too long for its column collapses into one row), `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `hud_kit/` (Horizon overlays' `Room` and `Plate`, the collapsed page strip `tabs` the shell's page list uses, [docs/ui-kit.md](docs/ui-kit.md)), `pad_glyph.rs` (controller glyphs, by what the button does), `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `loading_screen.rs` (the loading screen, the pre-game `App`), `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs`, `creative_screen.rs` |
|
||||
| Multiplayer client | L: `net.rs` (network thread); G: `remote.rs` (`fn handle_packet`, `fn tick_remote`), `remote_*.rs` (mob data, objects, projectiles, vehicles, stations) |
|
||||
| LAN host / server side | L: `lan/` (`conn.rs` handshake + login threads, `hub.rs` + `hub/` play state, `chunks.rs`, `entity.rs`, `tracking.rs`, `guest.rs`, `playerdata.rs`); G: `lan.rs` (`LanHost`), `lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `lan_*.rs` per feature, `lan_entities.rs` (tracked entities) |
|
||||
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
|
||||
|
||||
+34
-1
@@ -38,10 +38,20 @@ and compares their outputs.
|
||||
- **Read the vanilla code before writing Horizon's.** `tools/rea_vanilla.py` queries the pinned game bytecode:
|
||||
`python3 tools/rea_vanilla.py method net.minecraft.world.level.block.DispenserBlock dispenseFrom`.
|
||||
Say in your pull request which vanilla classes and methods you followed.
|
||||
- **One version: 26.3.** Parity work targets 26.3 until its parity is good, redstone first. Don't open pull requests
|
||||
that move Horizon to a newer Minecraft version for now; to join servers on a newer version, put a Via proxy
|
||||
(ViaVersion, ViaProxy) in front of them.
|
||||
- **Gameplay changes come with a harness**, new or extended, so the next change can't silently break them.
|
||||
[The parity workflow](docs/parity-workflow.md) shows how harnesses run; copy a nearby one.
|
||||
- **Horizon's own features** (the launcher, the new menus) follow [the design system](docs/design-system.md) and are
|
||||
off by default wherever they would change the vanilla look.
|
||||
- **Three layers: engine, platform, Horizon.**
|
||||
- Vanilla code never depends on the platform (mods, shader packs, HDR, the settings system) or on Horizon
|
||||
(its extras, cosmetics, the shop, official servers, telemetry).
|
||||
- The platform never depends on Horizon.
|
||||
- Horizon uses only the platform, never the engine: it will live in its own repository.
|
||||
|
||||
[docs/layers.md](docs/layers.md) says where a new feature goes; CI checks the dependencies.
|
||||
- **Everything players run is Rust.** Python and Java are fine for dev tools and harness drivers in `tools/`, never
|
||||
for player-facing code.
|
||||
- **No game files in the repo.** Never commit game files, decompiled source or prepared resources (`.local/`):
|
||||
@@ -80,6 +90,15 @@ license. Shader packs are a clean-room rewrite of the pack format, produced from
|
||||
source, and the rules above still apply to anything you read while working on them. A shader-pack pull request
|
||||
names its sources: the sections of that specification, the documentation pages and the packs it ran.
|
||||
|
||||
## Vendor libraries and Java mods
|
||||
|
||||
- **Upscalers and latency tools.** AMD FSR (MIT) may go in the repository, like any code under an allowed license.
|
||||
NVIDIA's DLSS, Reflex and any other library under an NVIDIA license may not: they ship only as an optional native mod
|
||||
that downloads NVIDIA's files itself, on the player's machine. No NVIDIA binaries, headers or SDK files in the
|
||||
repository.
|
||||
- **The JVM bridge** (running Java mods) is only ever a separate, optional download, never installed or on by
|
||||
default. The game may offer to turn it on when it finds a Java mod; the player decides.
|
||||
|
||||
## Before asking for review
|
||||
|
||||
Run the self-check and paste the block it prints into your pull request:
|
||||
@@ -116,7 +135,8 @@ it (edited on every push, never repeated): talk to its author in the thread befo
|
||||
## Pull requests
|
||||
|
||||
- **Small.** One change per pull request, under about 500 lines of code where you can; split bigger work into
|
||||
pieces that each make sense alone. Small pull requests get reviewed and merged the same day.
|
||||
pieces that each make sense alone. Small pull requests get reviewed and merged the same day. Bigger than that:
|
||||
[large pull requests](#large-pull-requests).
|
||||
- **Fill in the template**: what and why, the vanilla reference, the pr_ready block, screenshots for anything you
|
||||
can see.
|
||||
- **Tick "Allow edits by maintainers".** When something small is left (a lint, a rebase, a nit) we push the fixup
|
||||
@@ -139,6 +159,19 @@ generation, the launcher's updates) wait for a local proof first ([merging](docs
|
||||
Every merge to main is released automatically ([release flow](docs/release-flow.md)), so main always stays playable.
|
||||
How agents, checks, releases and signed updates keep this safe: [security](docs/security.md).
|
||||
|
||||
### Large pull requests
|
||||
|
||||
500 lines stays the default. A pull request that can't be split below it starts from a spec:
|
||||
|
||||
1. **Open an issue with the spec** before the code: what changes, the files it touches, the behaviour players and
|
||||
other code will see, and the tests or harnesses that prove it.
|
||||
2. **A maintainer approves the spec** in the issue. Code written before that may have to change or wait.
|
||||
3. **The pull request links the approved issue** (`Spec: #123` in the description) and stays within it; a change of
|
||||
plan goes back to the issue first.
|
||||
4. **An agent check compares the code with the spec**, run by the maintainers' agents: anything the spec doesn't
|
||||
cover, or that it promises and the code doesn't do, comes back to you.
|
||||
5. **Then the normal review**, as for any pull request.
|
||||
|
||||
## Parity checks on forks
|
||||
|
||||
The `verify` check (formatting, lints, tests) runs on every pull request. The `parity` check runs the affected
|
||||
|
||||
Generated
+20
@@ -1374,6 +1374,10 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-addresses"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "horizon-anticheat"
|
||||
version = "0.1.0"
|
||||
@@ -1592,6 +1596,7 @@ dependencies = [
|
||||
"horizon-protocol",
|
||||
"horizon-shader-api",
|
||||
"horizon-shaders",
|
||||
"horizon-system",
|
||||
"horizon-telemetry",
|
||||
"horizon-theme",
|
||||
"horizon-threads",
|
||||
@@ -1641,6 +1646,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"glam",
|
||||
"horizon-addresses",
|
||||
"png",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1663,6 +1669,7 @@ dependencies = [
|
||||
"chacha20poly1305",
|
||||
"getrandom 0.3.4",
|
||||
"hkdf",
|
||||
"horizon-addresses",
|
||||
"if-addrs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1772,6 +1779,13 @@ dependencies = [
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-platform"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-system",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-polytone"
|
||||
version = "0.1.0"
|
||||
@@ -1909,11 +1923,17 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-system"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "horizon-telemetry"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"horizon-addresses",
|
||||
"horizon-platform",
|
||||
"ruzstd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[workspace]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal", "third_party/naga", "third_party/libmimalloc-sys"]
|
||||
members = [
|
||||
"crates/horizon-addresses",
|
||||
"crates/horizon-core",
|
||||
"crates/horizon-dualsense",
|
||||
"crates/horizon-narrator",
|
||||
@@ -27,6 +28,8 @@ members = [
|
||||
"crates/horizon-theme",
|
||||
"crates/horizon-prepare",
|
||||
"crates/horizon-telemetry",
|
||||
"crates/horizon-system",
|
||||
"crates/horizon-platform",
|
||||
"crates/horizon-bench",
|
||||
"crates/horizon-friends",
|
||||
"crates/horizon-relay",
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/vanilla%20parity-100%25-F0795A" alt="Vanilla parity 100%">
|
||||
<a href="#project-status"><img src="https://img.shields.io/badge/player--visible%20gaps-0%20open-F0795A" alt="Player-visible gaps open"></a>
|
||||
</p>
|
||||
|
||||
@@ -138,13 +137,13 @@ It runs rustfmt, clippy, the touched crates' tests, the Python tests, the public
|
||||
|
||||
## Project status
|
||||
|
||||
**Player-visible gaps: 0 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Animals [0](https://github.com/horizon-rust/horizon/issues/3512); Monsters and bosses [0](https://github.com/horizon-rust/horizon/issues/3513); Entities, projectiles, vehicles and decorations [0](https://github.com/horizon-rust/horizon/issues/3514); Items, enchantments and inventories [0](https://github.com/horizon-rust/horizon/issues/3515); Blocks, redstone and fluids [0](https://github.com/horizon-rust/horizon/issues/3516); World, world generation and saves [0](https://github.com/horizon-rust/horizon/issues/3517); Screens, HUD and client packets [0](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [0](https://github.com/horizon-rust/horizon/issues/3519); Server and players on the server [0](https://github.com/horizon-rust/horizon/issues/3520); Mob AI, villagers and raids [0](https://github.com/horizon-rust/horizon/issues/3521). This number goes down as gaps close (`tools/parity_gaps.py`); the harness results below say that what is tested matches.
|
||||
**Player-visible gaps: 0 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Animals [0](https://github.com/horizon-rust/horizon/issues/3512); Monsters and bosses [0](https://github.com/horizon-rust/horizon/issues/3513); Entities, projectiles, vehicles and decorations [0](https://github.com/horizon-rust/horizon/issues/3514); Items, enchantments and inventories [0](https://github.com/horizon-rust/horizon/issues/3515); Blocks, redstone and fluids [0](https://github.com/horizon-rust/horizon/issues/3516); World, world generation and saves [0](https://github.com/horizon-rust/horizon/issues/3517); Screens, HUD and client packets [0](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [0](https://github.com/horizon-rust/horizon/issues/3519); Server and players on the server [0](https://github.com/horizon-rust/horizon/issues/3520); Mob AI, villagers and raids [0](https://github.com/horizon-rust/horizon/issues/3521). This number goes down as gaps close (`tools/parity_gaps.py`); the table below says what the harnesses compare.
|
||||
|
||||
The table is the project's own audit: every subsystem matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not that nothing else differs. What players still run into: [known issues](docs/known-issues.md).
|
||||
The table is the project's own audit of what the harnesses compare, subsystem by subsystem: a score says how much of that subsystem the harnesses cover and pass, not that nothing else differs from vanilla. What players still run into: [known issues](docs/known-issues.md).
|
||||
|
||||
### Java parity: 100%
|
||||
### What the harnesses compare
|
||||
|
||||
Scores: 0 (absent), 0.25 (started), 0.5 (playable, major features missing), 0.75 (mostly complete) or 1 (complete and verified by differential tests); the percentage is their average. Last audited 2026-10-13: the harnesses pass on Linux and macOS. The [port ledger](tools/port_ledger.py) adds a method-by-method view of what is ported.
|
||||
Scores: 0 (absent), 0.25 (started), 0.5 (playable, major features missing), 0.75 (mostly complete) or 1 (its harnesses cover the subsystem and pass). Last audited 2026-10-13: the harnesses pass on Linux and macOS. The [port ledger](tools/port_ledger.py) adds a method-by-method view of what is ported.
|
||||
|
||||
| Subsystem | Score | Main gaps |
|
||||
| --- | ---: | --- |
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ Owner: [levzzz5154](https://github.com/levzzz5154) · Tracking: [#3911](https://
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
One item for all modding work ([#3911](https://github.com/horizon-rust/horizon/issues/3911)), led by levzzz5154 with vladislav_hagen; the order between the parts is theirs. Native mods on stable hook points (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)) built by the launcher ([#3388](https://github.com/horizon-rust/horizon/issues/3388), [#3855](https://github.com/horizon-rust/horizon/issues/3855)); server-side hooks ([#3878](https://github.com/horizon-rust/horizon/issues/3878), [#3706](https://github.com/horizon-rust/horizon/issues/3706)); mod-owned screens, input and drawing; tools for mod makers (templates, `native.py check`); the registry ([#1634](https://github.com/horizon-rust/horizon/issues/1634), [#1642](https://github.com/horizon-rust/horizon/issues/1642), [#1659](https://github.com/horizon-rust/horizon/issues/1659)); machines with inventories and menus ([#1189](https://github.com/horizon-rust/horizon/issues/1189)); mods that read and change the world through the server. [docs/native-mods.md](docs/native-mods.md).
|
||||
One item for all modding work ([#3911](https://github.com/horizon-rust/horizon/issues/3911)), led by levzzz5154; the order between the parts is theirs. Native mods on stable hook points (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)) built by the launcher ([#3388](https://github.com/horizon-rust/horizon/issues/3388), [#3855](https://github.com/horizon-rust/horizon/issues/3855)); server-side hooks ([#3878](https://github.com/horizon-rust/horizon/issues/3878), [#3706](https://github.com/horizon-rust/horizon/issues/3706)); mod-owned screens, input and drawing; tools for mod makers (templates, `native.py check`); the registry ([#1634](https://github.com/horizon-rust/horizon/issues/1634), [#1642](https://github.com/horizon-rust/horizon/issues/1642), [#1659](https://github.com/horizon-rust/horizon/issues/1659)); machines with inventories and menus ([#1189](https://github.com/horizon-rust/horizon/issues/1189)); mods that read and change the world through the server. [docs/native-mods.md](docs/native-mods.md).
|
||||
|
||||
More: [#3351](https://github.com/horizon-rust/horizon/issues/3351) · [#3353](https://github.com/horizon-rust/horizon/issues/3353) · [#3360](https://github.com/horizon-rust/horizon/issues/3360) · [#1596](https://github.com/horizon-rust/horizon/issues/1596) · [#1635](https://github.com/horizon-rust/horizon/issues/1635)
|
||||
|
||||
|
||||
@@ -338,7 +338,8 @@ is preserved.
|
||||
|
||||
## Starling
|
||||
|
||||
`crates/horizon-client-render/src/touch/layout.rs` is adapted from the touch layout of
|
||||
`crates/horizon-client-render/src/touch/layout.rs` and `crates/horizon-client-render/src/touch/aim.rs`
|
||||
are adapted from the touch layout and touch helpers of
|
||||
[Starling](https://github.com/starling-client/starling), an Android port of Horizon, at commit
|
||||
[150a414b09a1](https://github.com/starling-client/starling/tree/150a414b09a18c900489d9462a7fc035dae744b7)
|
||||
(`android` branch), written mostly by Vasysik. Starling is licensed under the same MIT license as
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "horizon-addresses"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
rust-version.workspace = true
|
||||
license = "MIT"
|
||||
description = "Every built-in address of an outside service vanilla doesn't contact, in one place (docs/layers.md#built-in-addresses)"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Every built-in address of an outside service vanilla doesn't contact, in one place
|
||||
//! (docs/layers.md#built-in-addresses). `tools/check_addresses.py` keeps new ones from going
|
||||
//! anywhere else.
|
||||
//!
|
||||
//! This is a Horizon-layer crate: the engine and the platform never depend on it. Platform code
|
||||
//! that talks to a service gets the address passed in from a Horizon crate.
|
||||
|
||||
/// Horizon's official survival server (SMP), offered once at the top of the server list by the
|
||||
/// game and the launcher (`launcher/links.json` may name another). Its SRV record leads to the
|
||||
/// port.
|
||||
pub const OFFICIAL_SERVER: &str = "play.playhorizon.dev";
|
||||
|
||||
/// The official server's earlier addresses: the game and the launcher move a server list's
|
||||
/// entries for them to [`OFFICIAL_SERVER`], once. A move appends the old address here (and to
|
||||
/// `official_server_previous` in `launcher/links.json`).
|
||||
pub const OFFICIAL_SERVER_PREVIOUS: &[&str] = &["horizon.bunchy7s.dev"];
|
||||
|
||||
/// [`OFFICIAL_SERVER`] with the port its SRV record gives, for askers that don't look SRV up
|
||||
/// (the telemetry server's status checks).
|
||||
pub const OFFICIAL_SERVER_STATUS: &str = "play.playhorizon.dev:24454";
|
||||
|
||||
/// The cosmetics service: cosmetics, emotes, "Show me as a Horizon player"
|
||||
/// (`HORIZON_COSMETICS_API` overrides it).
|
||||
pub const COSMETICS_API: &str = "https://cosmetics.playhorizon.dev";
|
||||
|
||||
/// The telemetry ingest server (`crates/horizon-telemetry-server`): opt-in telemetry, the
|
||||
/// "I'm playing" ping, benchmark uploads (`HORIZON_TELEMETRY_URL` overrides it).
|
||||
pub const TELEMETRY: &str = "https://telemetry.playhorizon.dev";
|
||||
|
||||
/// Public STUN servers for Play with Friends' NAT discovery: two independent operators, so one
|
||||
/// outage doesn't break discovery (`HORIZON_STUN_SERVERS` overrides them).
|
||||
pub const STUN_SERVERS: &[&str] = &["stun.cloudflare.com:3478", "stun.l.google.com:19302"];
|
||||
|
||||
/// Play with Friends' troubleshooting page, shown to hosts nobody can reach.
|
||||
pub const FRIENDS_HELP: &str =
|
||||
"https://github.com/horizon-rust/horizon/blob/main/docs/play-with-friends.md#troubleshooting";
|
||||
@@ -27,6 +27,7 @@ horizon-polytone = { path = "../horizon-polytone" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
horizon-system = { path = "../horizon-system" }
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! `cargo run --profile fast -p horizon-client-render --example pack_load_bench [-- --scale 1]`
|
||||
//! (`.github/workflows/pack-bench.yml` runs it on a branch and on its base, side by side).
|
||||
use horizon_client_render::{
|
||||
gfx::PreparedTextures,
|
||||
hud::HudAtlas,
|
||||
model_library::ParsedModels,
|
||||
packs::{self, Pack},
|
||||
@@ -403,6 +404,30 @@ fn main() {
|
||||
);
|
||||
row("CTM tiles: one by one", sprites(false, false, &tiles, true));
|
||||
row("CTM tiles: prefetched", sprites(false, true, &tiles, true));
|
||||
// The mip chains the renderer uploads, from the same pack layers: the HD pack's
|
||||
// 64 px block sprites fill the array, as they do for a player who has it on.
|
||||
let mut atlas = Sprites::with_disk_cache(&root);
|
||||
for name in &blocks {
|
||||
atlas.insert(&root, name).unwrap();
|
||||
}
|
||||
atlas.finish();
|
||||
let border = vec![255u8; (atlas.size * atlas.size * 4) as usize];
|
||||
row(
|
||||
"texture upload: sprite mip chains, both packs",
|
||||
time(
|
||||
runs,
|
||||
|| {},
|
||||
|| {
|
||||
std::hint::black_box(PreparedTextures::new(
|
||||
&atlas.pixels,
|
||||
atlas.size,
|
||||
atlas.mips.len() as u32,
|
||||
&atlas.mips,
|
||||
(&border, atlas.size),
|
||||
));
|
||||
},
|
||||
),
|
||||
);
|
||||
row(
|
||||
"GUI atlas: decode and stitch, both packs",
|
||||
time(
|
||||
|
||||
@@ -112,6 +112,8 @@ struct Mixer {
|
||||
cameras: Vec<([f64; 3], Basis, u32)>,
|
||||
/// Per-source gain/pan/step cache for one buffer, reused across callbacks.
|
||||
voices: Vec<(ChannelId, [f32; 3], f64)>,
|
||||
/// Mods' audio sources (`push_voice`), each with its queue.
|
||||
mod_audio: Vec<ModAudioSource>,
|
||||
/// A mod's muffle of the sounds it does not exempt.
|
||||
muffle: Muffle,
|
||||
limiter_peak: f32,
|
||||
@@ -121,10 +123,158 @@ struct Mixer {
|
||||
/// The stream reported its device gone (`Library.isCurrentDeviceDisconnected`).
|
||||
#[cfg(feature = "audio")]
|
||||
device_lost: bool,
|
||||
/// The stream's recent backend errors, which tell a dead stream from a hiccup.
|
||||
#[cfg(feature = "audio")]
|
||||
stream_errors: StreamErrors,
|
||||
}
|
||||
|
||||
/// A stream's backend errors close together (`StreamErrors::dead`).
|
||||
#[cfg(any(feature = "audio", test))]
|
||||
#[derive(Default)]
|
||||
struct StreamErrors {
|
||||
/// When the current run of errors began, and how many it holds.
|
||||
since: Option<std::time::Instant>,
|
||||
count: u32,
|
||||
}
|
||||
#[cfg(any(feature = "audio", test))]
|
||||
impl StreamErrors {
|
||||
/// Errors this close together...
|
||||
const WINDOW: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
/// ...this many of them: the stream is dead. Under vanilla a device that fails unrecoverably
|
||||
/// reads as disconnected (`ALC_CONNECTED`, the `ALC_EXT_disconnect` extension), and
|
||||
/// `SoundEngine` opens the output again; cpal reports a dead ALSA stream (`alsa::poll()`
|
||||
/// returning POLLERR, over and over) as a backend error like any other, so a run of them
|
||||
/// stands for it. One alone is a hiccup the stream recovers from.
|
||||
const DEAD_AFTER: u32 = 3;
|
||||
/// One more backend error at `now`: whether the stream counts as dead.
|
||||
fn dead(&mut self, now: std::time::Instant) -> bool {
|
||||
match self.since {
|
||||
Some(since) if now.duration_since(since) <= Self::WINDOW => self.count += 1,
|
||||
_ => {
|
||||
self.since = Some(now);
|
||||
self.count = 1;
|
||||
}
|
||||
}
|
||||
self.count >= Self::DEAD_AFTER
|
||||
}
|
||||
}
|
||||
|
||||
/// Opening the output again after it failed: a few more tries, a couple of seconds apart, so a
|
||||
/// device that is gone for good isn't asked forever.
|
||||
#[cfg(any(feature = "audio", test))]
|
||||
#[derive(Default, Debug, PartialEq)]
|
||||
struct Reopen {
|
||||
/// The tries left; 0 while the output plays, or after the last try.
|
||||
tries: u32,
|
||||
/// Ticks since the last try.
|
||||
ticks: u32,
|
||||
}
|
||||
#[cfg(any(feature = "audio", test))]
|
||||
impl Reopen {
|
||||
const TRIES: u32 = 5;
|
||||
/// Two seconds of client ticks.
|
||||
const TICKS: u32 = 40;
|
||||
/// The output failed to open: whether to keep trying.
|
||||
fn failed(&mut self, retrying: bool) -> bool {
|
||||
self.ticks = 0;
|
||||
self.tries = if retrying {
|
||||
self.tries.saturating_sub(1)
|
||||
} else {
|
||||
Self::TRIES
|
||||
};
|
||||
self.tries > 0
|
||||
}
|
||||
/// A tick while tries are left: whether to try now.
|
||||
fn due(&mut self) -> bool {
|
||||
if self.tries == 0 {
|
||||
return false;
|
||||
}
|
||||
self.ticks += 1;
|
||||
self.ticks >= Self::TICKS
|
||||
}
|
||||
}
|
||||
/// A camera's forward, up and right vectors.
|
||||
type Basis = ([f64; 3], [f64; 3], [f64; 3]);
|
||||
/// How much audio one mod source may have waiting, in seconds (`push_voice`): a burst past it
|
||||
/// is refused rather than delayed, so a stalled sender cannot pile up latency.
|
||||
const MOD_AUDIO_SECONDS: f64 = 2.0;
|
||||
/// How many mod audio sources may play at once, across mods.
|
||||
const MOD_AUDIO_SOURCES: usize = 64;
|
||||
/// The distance at which positioned mod audio has faded out, in blocks (linear, as a sound's
|
||||
/// `AL_LINEAR_DISTANCE` fades).
|
||||
const MOD_AUDIO_RANGE: f32 = 48.0;
|
||||
/// One of a mod's audio sources (`push_voice`): a speaker, keyed by the mod and its own id
|
||||
/// for it. Its chunks play one after another, oldest first, so each speaker's audio stays in
|
||||
/// order while different speakers play at the same time.
|
||||
struct ModAudioSource {
|
||||
mod_id: String,
|
||||
source: u128,
|
||||
chunks: std::collections::VecDeque<ModAudioChunk>,
|
||||
/// How far into the head chunk the callback has got, in its own samples.
|
||||
played: f64,
|
||||
}
|
||||
/// A block of mono samples a mod queued.
|
||||
struct ModAudioChunk {
|
||||
/// Mono samples, -1 to 1.
|
||||
samples: Vec<f32>,
|
||||
/// The rate they were sampled at.
|
||||
rate: u32,
|
||||
/// Where they were spoken, for distance and direction; none plays them centred.
|
||||
at: Option<[f64; 3]>,
|
||||
}
|
||||
impl Mixer {
|
||||
/// Queues `samples` behind what `source` of `mod_id` already has waiting. False, and
|
||||
/// nothing queued, when they are empty or the rate is 0, when the source would hold more
|
||||
/// than [`MOD_AUDIO_SECONDS`], or when a new source would pass [`MOD_AUDIO_SOURCES`].
|
||||
fn push_mod_audio(
|
||||
&mut self,
|
||||
mod_id: &str,
|
||||
source: u128,
|
||||
samples: &[f32],
|
||||
rate: u32,
|
||||
at: Option<[f64; 3]>,
|
||||
) -> bool {
|
||||
if samples.is_empty() || rate == 0 {
|
||||
return false;
|
||||
}
|
||||
let seconds = |len: usize, rate: u32| len as f64 / f64::from(rate);
|
||||
let added = seconds(samples.len(), rate);
|
||||
let chunk = ModAudioChunk {
|
||||
samples: samples.to_vec(),
|
||||
rate,
|
||||
at,
|
||||
};
|
||||
match self
|
||||
.mod_audio
|
||||
.iter()
|
||||
.position(|s| s.source == source && s.mod_id == mod_id)
|
||||
{
|
||||
Some(i) => {
|
||||
let s = &mut self.mod_audio[i];
|
||||
let waiting: f64 = s
|
||||
.chunks
|
||||
.iter()
|
||||
.map(|c| seconds(c.samples.len(), c.rate))
|
||||
.sum();
|
||||
if waiting + added > MOD_AUDIO_SECONDS {
|
||||
return false;
|
||||
}
|
||||
s.chunks.push_back(chunk);
|
||||
}
|
||||
None => {
|
||||
if added > MOD_AUDIO_SECONDS || self.mod_audio.len() >= MOD_AUDIO_SOURCES {
|
||||
return false;
|
||||
}
|
||||
self.mod_audio.push(ModAudioSource {
|
||||
mod_id: mod_id.to_owned(),
|
||||
source,
|
||||
chunks: std::collections::VecDeque::from([chunk]),
|
||||
played: 0.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
/// The id of world `name` (1 and up), given once per name.
|
||||
fn world_id(&mut self, name: &str) -> u32 {
|
||||
let i = match self.world_names.iter().position(|w| w == name) {
|
||||
@@ -164,6 +314,24 @@ impl MuffleFade {
|
||||
[self.amount * left, self.lowpass * left]
|
||||
}
|
||||
}
|
||||
impl MixerBackend {
|
||||
/// Queues a mod's audio for the output callback (`Host::push_voice`). False when nothing
|
||||
/// was queued: no output device, or what [`Mixer::push_mod_audio`] refuses.
|
||||
pub fn push_voice(
|
||||
&self,
|
||||
mod_id: &str,
|
||||
source: u128,
|
||||
samples: &[f32],
|
||||
rate: u32,
|
||||
at: Option<[f64; 3]>,
|
||||
) -> bool {
|
||||
self.live
|
||||
&& self
|
||||
.mixer
|
||||
.lock()
|
||||
.is_ok_and(|mut m| m.push_mod_audio(mod_id, source, samples, rate, at))
|
||||
}
|
||||
}
|
||||
/// The mixer side of the engine (`Backend`).
|
||||
pub struct MixerBackend {
|
||||
mixer: Arc<Mutex<Mixer>>,
|
||||
@@ -286,6 +454,21 @@ pub struct CapturedSound {
|
||||
pub except_entity: Option<u32>,
|
||||
}
|
||||
|
||||
impl Audio {
|
||||
/// Plays a mod's audio (`Host::push_voice`): mono samples, -1 to 1, at `rate`, behind what
|
||||
/// `source` of `mod_id` already has waiting; `at` places them for distance and direction.
|
||||
pub fn push_voice(
|
||||
&self,
|
||||
mod_id: &str,
|
||||
source: u128,
|
||||
samples: &[f32],
|
||||
rate: u32,
|
||||
at: Option<[f64; 3]>,
|
||||
) -> bool {
|
||||
self.backend.push_voice(mod_id, source, samples, rate, at)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Audio {
|
||||
_stream: Option<Stream>,
|
||||
device_name: String,
|
||||
@@ -327,6 +510,9 @@ pub struct Audio {
|
||||
/// Ticks since the output devices were last looked at (`DeviceTracker.tick`).
|
||||
#[cfg(feature = "audio")]
|
||||
device_ticks: u32,
|
||||
/// Opening the output again after it failed to (`Reopen`).
|
||||
#[cfg(feature = "audio")]
|
||||
reopen: Reopen,
|
||||
/// The look at the output devices running on its own thread, until a tick
|
||||
/// takes its answer: listing them can take 100 ms (#11490).
|
||||
#[cfg(feature = "audio")]
|
||||
@@ -836,6 +1022,62 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
mixer.muffle.stages = [[0.0; 2]; 2];
|
||||
}
|
||||
mixer.muffle.bus = bus;
|
||||
// A mod's audio (`push_voice`): every source at once, each playing its own chunks in order;
|
||||
// a chunk that ends mid-buffer hands the rest of the buffer to the next one.
|
||||
if !mixer.mod_audio.is_empty() {
|
||||
let (forward, up, right) = basis(mixer.listener_yaw, mixer.listener_pitch);
|
||||
let listener = mixer.listener;
|
||||
let frames = data.len() / channels.max(1);
|
||||
for source in &mut mixer.mod_audio {
|
||||
let ModAudioSource { chunks, played, .. } = source;
|
||||
let mut f = 0;
|
||||
while f < frames {
|
||||
let Some(chunk) = chunks.front() else {
|
||||
break;
|
||||
};
|
||||
let len = chunk.samples.len();
|
||||
let [left, right_gain, gain] = match chunk.at {
|
||||
Some(at) => {
|
||||
let d = [
|
||||
at[0] - listener[0],
|
||||
at[1] - listener[1],
|
||||
at[2] - listener[2],
|
||||
];
|
||||
let gain = distance_gain(Some(MOD_AUDIO_RANGE), dot(d, d).sqrt());
|
||||
let [l, r] = stereo_gains(dot(d, right), dot(d, up), dot(d, forward));
|
||||
[l * gain, r * gain, gain]
|
||||
}
|
||||
None => [1.0; 3],
|
||||
};
|
||||
let step = f64::from(chunk.rate) / f64::from(rate);
|
||||
while f < frames && (*played as usize) < len {
|
||||
let i = *played as usize;
|
||||
let t = (*played - i as f64) as f32;
|
||||
let next = chunk
|
||||
.samples
|
||||
.get(i + 1)
|
||||
.copied()
|
||||
.unwrap_or(chunk.samples[i]);
|
||||
let s = chunk.samples[i] * (1.0 - t) + next * t;
|
||||
let frame = &mut data[f * channels..(f + 1) * channels];
|
||||
if channels >= 2 {
|
||||
frame[0] += s * left;
|
||||
frame[1] += s * right_gain;
|
||||
} else {
|
||||
frame[0] += s * gain;
|
||||
}
|
||||
*played += step;
|
||||
f += 1;
|
||||
}
|
||||
if *played as usize >= len {
|
||||
// The next chunk starts where this one's last step left off.
|
||||
*played -= len as f64;
|
||||
chunks.pop_front();
|
||||
}
|
||||
}
|
||||
}
|
||||
mixer.mod_audio.retain(|s| !s.chunks.is_empty());
|
||||
}
|
||||
// Limit the whole buffer together, preserving waveform and stereo balance
|
||||
// instead of flattening each overloaded sample; release over 200 ms
|
||||
// (OpenAL Soft's output limiter, `ALC_OUTPUT_LIMITER_SOFT`, releases
|
||||
@@ -903,6 +1145,7 @@ fn open_device(
|
||||
if let Ok(mut m) = mixer.lock() {
|
||||
m.device_name = device.name().unwrap_or_default();
|
||||
m.device_lost = false;
|
||||
m.stream_errors = StreamErrors::default();
|
||||
}
|
||||
Ok((stream, config))
|
||||
}
|
||||
@@ -1052,10 +1295,19 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
|
||||
},
|
||||
move |e| {
|
||||
log::warn!("Horizon: audio stream error: {e}");
|
||||
if matches!(e, cpal::StreamError::DeviceNotAvailable)
|
||||
&& let Ok(mut m) = on_error.lock()
|
||||
{
|
||||
m.device_lost = true;
|
||||
if let Ok(mut m) = on_error.lock() {
|
||||
let dead = match e {
|
||||
cpal::StreamError::DeviceNotAvailable => true,
|
||||
cpal::StreamError::BackendSpecific { .. } => {
|
||||
m.stream_errors.dead(std::time::Instant::now())
|
||||
}
|
||||
};
|
||||
if dead && !m.device_lost {
|
||||
if !matches!(e, cpal::StreamError::DeviceNotAvailable) {
|
||||
log::warn!("Horizon: the audio stream stopped; opening the output again");
|
||||
}
|
||||
m.device_lost = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
None,
|
||||
@@ -1177,6 +1429,8 @@ impl Audio {
|
||||
#[cfg(feature = "audio")]
|
||||
device_ticks: 0,
|
||||
#[cfg(feature = "audio")]
|
||||
reopen: Reopen::default(),
|
||||
#[cfg(feature = "audio")]
|
||||
device_probe: None,
|
||||
}
|
||||
}
|
||||
@@ -1340,6 +1594,8 @@ impl Audio {
|
||||
#[cfg(feature = "audio")]
|
||||
device_ticks: 0,
|
||||
#[cfg(feature = "audio")]
|
||||
reopen: Reopen::default(),
|
||||
#[cfg(feature = "audio")]
|
||||
device_probe: None,
|
||||
}
|
||||
}
|
||||
@@ -1447,6 +1703,27 @@ impl Audio {
|
||||
}
|
||||
changed
|
||||
}
|
||||
/// Opens the output on the chosen device again; `retrying` after an earlier try failed.
|
||||
#[cfg(feature = "audio")]
|
||||
fn reopen_output(&mut self, retrying: bool) {
|
||||
// The old stream lets go of the device first: a dead one may still hold it.
|
||||
self._stream = None;
|
||||
let device = self.device_name.clone();
|
||||
match self.set_device(&device) {
|
||||
Ok(()) => {
|
||||
if retrying {
|
||||
log::info!("Horizon: the audio output is back");
|
||||
}
|
||||
self.reopen = Reopen::default();
|
||||
}
|
||||
Err(e) if self.reopen.failed(retrying) => {
|
||||
log::warn!("Horizon: could not reopen the audio output ({e}); trying again soon");
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: could not reopen the audio output ({e}); it stays off");
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `SoundManager.tick(paused)`, once per client tick.
|
||||
pub fn tick(&mut self, paused: bool, ctx: &TickContext) {
|
||||
#[cfg(feature = "audio")]
|
||||
@@ -1454,10 +1731,9 @@ impl Audio {
|
||||
if self.should_change_device() {
|
||||
// SoundEngine.reload: stop every sound, open the output again.
|
||||
self.stop_all();
|
||||
let device = self.device_name.clone();
|
||||
if let Err(e) = self.set_device(&device) {
|
||||
log::warn!("Horizon: could not reopen the audio output: {e}");
|
||||
}
|
||||
self.reopen_output(false);
|
||||
} else if self.backend.owner == 0 && self.reopen.due() {
|
||||
self.reopen_output(true);
|
||||
}
|
||||
}
|
||||
self.sync_volumes();
|
||||
@@ -1858,6 +2134,46 @@ impl Sounds for Audio {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A run of backend errors close together is a dead stream; one or two, or errors far apart,
|
||||
/// are hiccups.
|
||||
#[test]
|
||||
fn a_run_of_stream_errors_is_a_dead_stream() {
|
||||
use std::time::{Duration, Instant};
|
||||
let start = Instant::now();
|
||||
let at = |ms: u64| start + Duration::from_millis(ms);
|
||||
let mut errors = StreamErrors::default();
|
||||
assert!(!errors.dead(at(0)));
|
||||
assert!(!errors.dead(at(500)));
|
||||
assert!(errors.dead(at(900)));
|
||||
// Spread out, they never add up.
|
||||
let mut apart = StreamErrors::default();
|
||||
for k in 0..10 {
|
||||
assert!(!apart.dead(at(k * 2500)), "error {k}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A failed reopening is tried again every two seconds, five more times, then given up.
|
||||
#[test]
|
||||
fn a_failed_reopening_retries_a_few_times_then_stops() {
|
||||
let mut reopen = Reopen::default();
|
||||
assert!(!reopen.due(), "nothing to retry while the output plays");
|
||||
assert!(reopen.failed(false));
|
||||
let mut tries = 0;
|
||||
for _ in 0..1000 {
|
||||
if reopen.due() {
|
||||
tries += 1;
|
||||
if !reopen.failed(true) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(tries, Reopen::TRIES);
|
||||
assert_eq!(reopen, Reopen::default());
|
||||
// The next lost device starts a fresh run.
|
||||
assert!(reopen.failed(false));
|
||||
assert_eq!(reopen.tries, Reopen::TRIES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_capture_keeps_order_category_entity_and_actor_exclusion() {
|
||||
let mut audio = Audio::silent(Path::new("/nonexistent/horizon-entity-sound-test"));
|
||||
@@ -2517,6 +2833,66 @@ mod tests {
|
||||
assert_eq!(level(&audio), [0.0; 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_mod_audio_source_plays_its_own_chunks_in_order() {
|
||||
let mut m = Mixer::default();
|
||||
// Two speakers of one mod, each queuing two chunks, interleaved.
|
||||
for (source, level) in [(1, 0.1), (2, 0.4), (1, 0.2), (2, 0.3)] {
|
||||
assert!(m.push_mod_audio("voice", source, &[level; 4], 48_000, None));
|
||||
}
|
||||
let mut data = vec![0.0f32; 8 * 2];
|
||||
mix(&mut m, &mut data, 2, 48_000);
|
||||
// Each speaker's first chunk, then its second: never both of one speaker at once.
|
||||
let left: Vec<f32> = data.chunks(2).map(|f| f[0]).collect();
|
||||
let expected = [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5];
|
||||
assert!(
|
||||
left.iter().zip(expected).all(|(a, b)| (a - b).abs() < 1e-6),
|
||||
"{left:?}"
|
||||
);
|
||||
assert!(m.mod_audio.is_empty(), "both sources played out");
|
||||
// Order within a source: its second chunk follows its first.
|
||||
assert!(m.push_mod_audio("voice", 1, &[0.25; 2], 48_000, None));
|
||||
assert!(m.push_mod_audio("voice", 1, &[0.5; 2], 48_000, None));
|
||||
let mut data = vec![0.0f32; 4 * 2];
|
||||
mix(&mut m, &mut data, 2, 48_000);
|
||||
let left: Vec<f32> = data.chunks(2).map(|f| f[0]).collect();
|
||||
assert_eq!(left, [0.25, 0.25, 0.5, 0.5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mod_audio_sources_are_kept_apart_by_mod_and_capped() {
|
||||
let mut m = Mixer::default();
|
||||
assert!(m.push_mod_audio("a", 1, &[0.1; 48_000], 48_000, None));
|
||||
assert!(m.push_mod_audio("b", 1, &[0.1; 48_000], 48_000, None));
|
||||
assert_eq!(
|
||||
m.mod_audio.len(),
|
||||
2,
|
||||
"the same id in two mods is two sources"
|
||||
);
|
||||
// Two seconds wait at most per source; a burst past that is refused, not delayed.
|
||||
assert!(m.push_mod_audio("a", 1, &[0.1; 48_000], 48_000, None));
|
||||
assert!(!m.push_mod_audio("a", 1, &[0.1; 1], 48_000, None));
|
||||
assert!(!m.push_mod_audio("a", 2, &[], 48_000, None));
|
||||
assert!(!m.push_mod_audio("a", 2, &[0.1], 0, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mod_audio_is_panned_and_faded_by_where_it_was_spoken() {
|
||||
let mut m = Mixer::default();
|
||||
// Facing south (yaw 0), -X is the listener's right.
|
||||
assert!(m.push_mod_audio("voice", 1, &[0.5; 16], 48_000, Some([-4.0, 0.0, 0.0])));
|
||||
let mut data = vec![0.0f32; 16 * 2];
|
||||
mix(&mut m, &mut data, 2, 48_000);
|
||||
let left: f32 = data.chunks(2).map(|f| f[0]).sum();
|
||||
let right: f32 = data.chunks(2).map(|f| f[1]).sum();
|
||||
assert!(right > left, "right {right} should beat left {left}");
|
||||
// Past the range it is silent.
|
||||
assert!(m.push_mod_audio("voice", 1, &[0.5; 16], 48_000, Some([0.0, 0.0, 60.0])));
|
||||
let mut data = vec![0.0f32; 16 * 2];
|
||||
mix(&mut m, &mut data, 2, 48_000);
|
||||
assert!(data.iter().all(|s| *s == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_devices_are_looked_at_again_only_when_the_cards_change() {
|
||||
// No fingerprint (not Linux, or the cards could not be read): the list
|
||||
|
||||
@@ -1459,7 +1459,37 @@ impl BlockEntityRenderer {
|
||||
registry: &BlockRegistry,
|
||||
visibility: FaceVisibility,
|
||||
) -> Result<Self> {
|
||||
let all: Value = serde_json::from_slice(&std::fs::read(root.join("entity-models.json"))?)?;
|
||||
Self::load_with_visibility_inner(root, registry, visibility, None, None)
|
||||
}
|
||||
|
||||
/// Load from what the client startup batch already decoded: its entity-model
|
||||
/// document (none reads `entity-models.json`) and the item defaults, avoiding a
|
||||
/// second read and decode of either.
|
||||
pub fn load_with_visibility_models_and_defaults(
|
||||
root: &Path,
|
||||
registry: &BlockRegistry,
|
||||
visibility: FaceVisibility,
|
||||
models: Option<&Value>,
|
||||
defaults: &horizon_world::item_defaults::Defaults,
|
||||
) -> Result<Self> {
|
||||
Self::load_with_visibility_inner(root, registry, visibility, models, Some(defaults))
|
||||
}
|
||||
|
||||
fn load_with_visibility_inner(
|
||||
root: &Path,
|
||||
registry: &BlockRegistry,
|
||||
visibility: FaceVisibility,
|
||||
models: Option<&Value>,
|
||||
defaults: Option<&horizon_world::item_defaults::Defaults>,
|
||||
) -> Result<Self> {
|
||||
let owned_models = if models.is_none() {
|
||||
Some(serde_json::from_slice(&std::fs::read(
|
||||
root.join("entity-models.json"),
|
||||
)?)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let all = models.or(owned_models.as_ref()).expect("entity models");
|
||||
let models = MODELS
|
||||
.iter()
|
||||
.filter_map(|(key, layer)| all.get(*layer).map(|v| (*key, ModelPart::parse(v))))
|
||||
@@ -1469,12 +1499,17 @@ impl BlockEntityRenderer {
|
||||
// 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,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Default::default(),
|
||||
Err(e) => return Err(e.into()),
|
||||
let owned_defaults = if defaults.is_none() {
|
||||
Some(match horizon_world::item_defaults::read(root) {
|
||||
Ok(items) => items,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Default::default(),
|
||||
Err(e) => return Err(e.into()),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
for (item, components) in &items {
|
||||
let items = defaults.or(owned_defaults.as_ref()).unwrap();
|
||||
for (item, components) in items {
|
||||
let Some(pattern) = components["minecraft:provides_pottery_pattern"].as_str() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -871,7 +871,10 @@ impl RolledLoot {
|
||||
/// Puts the loot table back in place of `Items` while they are still the
|
||||
/// roll; false once the container holds anything else.
|
||||
pub fn restore(&self, map: &mut Compound) -> bool {
|
||||
if map.get(&"Items".into()) != Some(&self.items) {
|
||||
// An empty shulker box saves no `Items`, which matches an empty roll.
|
||||
let saved = map.get(&"Items".into());
|
||||
let empty = matches!(&self.items, Tag::List(l) if l.is_empty());
|
||||
if saved != Some(&self.items) && !(saved.is_none() && empty) {
|
||||
return false;
|
||||
}
|
||||
map.remove(&"Items".into());
|
||||
|
||||
@@ -80,6 +80,8 @@ pub enum BlockEntity {
|
||||
Skull(Skull),
|
||||
/// `TheEndGatewayBlockEntity`: its age and configured destination.
|
||||
EndGateway(EndGateway),
|
||||
/// `TheEndPortalBlockEntity`: no data, only identity.
|
||||
EndPortal,
|
||||
/// `DaylightDetectorBlockEntity`: no data, only the ticker
|
||||
/// (`DaylightDetectorBlock.tickEntity`).
|
||||
DaylightDetector,
|
||||
|
||||
@@ -423,49 +423,57 @@ impl SceneCollisions<'_, '_> {
|
||||
if lo.x > hi.x || lo.y > hi.y || lo.z > hi.z || cells > 4096 {
|
||||
return false;
|
||||
}
|
||||
let mut column: Option<((i32, i32), Option<&crate::chunk::Chunk>)> = None;
|
||||
let mut section: Option<((i32, i32), i32, bool)> = None;
|
||||
for z in lo.z..=hi.z {
|
||||
for y in lo.y..=hi.y {
|
||||
for x in lo.x..=hi.x {
|
||||
let outer = [
|
||||
x == lo.x || x == hi.x,
|
||||
y == lo.y || y == hi.y,
|
||||
z == lo.z || z == hi.z,
|
||||
];
|
||||
let kind = outer.into_iter().filter(|&o| o).count();
|
||||
if kind == 3 {
|
||||
// A column and section at a time. Most boxes lie in a few sections whose palette says that no
|
||||
// block in them can matter (nothing that collides, nothing that reaches out of its cell), and
|
||||
// then none of their cells is read: a particle's box at speed spans dozens of cells.
|
||||
for cz in (lo.z >> 4)..=(hi.z >> 4) {
|
||||
for cx in (lo.x >> 4)..=(hi.x >> 4) {
|
||||
// The scan passes the cells of a column that is not resident.
|
||||
let Some(chunk) = self.world.chunks.get(&(cx, cz)).map(|c| &**c) else {
|
||||
continue;
|
||||
};
|
||||
let (x0, x1) = (lo.x.max(cx << 4), hi.x.min((cx << 4) + 15));
|
||||
let (z0, z1) = (lo.z.max(cz << 4), hi.z.min((cz << 4) + 15));
|
||||
for sy in (lo.y >> 4)..=(hi.y >> 4) {
|
||||
let reaches = self.section_reaches_out(chunk, sy);
|
||||
if !reaches && self.section_blocks_all_empty(chunk, sy) {
|
||||
continue;
|
||||
}
|
||||
let at = (x >> 4, z >> 4);
|
||||
let chunk = match column {
|
||||
Some((c, chunk)) if c == at => chunk,
|
||||
_ => {
|
||||
let chunk = self.world.chunks.get(&at).map(|c| &**c);
|
||||
column = Some((at, chunk));
|
||||
chunk
|
||||
}
|
||||
};
|
||||
// The scan passes the cells of a column that is not resident.
|
||||
let Some(chunk) = chunk else {
|
||||
continue;
|
||||
};
|
||||
if kind != 0 {
|
||||
let reaches = match section {
|
||||
Some((c, sy, reaches)) if c == at && sy == y >> 4 => reaches,
|
||||
_ => {
|
||||
let reaches = self.section_reaches_out(chunk, y >> 4);
|
||||
section = Some((at, y >> 4, reaches));
|
||||
reaches
|
||||
let (y0, y1) = (lo.y.max(sy << 4), hi.y.min((sy << 4) + 15));
|
||||
if !reaches {
|
||||
// Nothing in the section reaches out of its cell: only the inner cells matter.
|
||||
for z in z0.max(lo.z + 1)..=z1.min(hi.z - 1) {
|
||||
for y in y0.max(lo.y + 1)..=y1.min(hi.y - 1) {
|
||||
for x in x0.max(lo.x + 1)..=x1.min(hi.x - 1) {
|
||||
if self.binding.may_collide(chunk.block(x, y, z)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
if reaches {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if self.binding.may_collide(chunk.block(x, y, z)) {
|
||||
return false;
|
||||
for z in z0..=z1 {
|
||||
let outer_z = z == lo.z || z == hi.z;
|
||||
for y in y0..=y1 {
|
||||
let outer_y = y == lo.y || y == hi.y;
|
||||
for x in x0..=x1 {
|
||||
let outer = [x == lo.x || x == hi.x, outer_y, outer_z];
|
||||
let kind = outer.into_iter().filter(|&o| o).count();
|
||||
if kind == 3 {
|
||||
continue;
|
||||
}
|
||||
if kind != 0 {
|
||||
if reaches {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if self.binding.may_collide(chunk.block(x, y, z)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -473,6 +481,23 @@ impl SceneCollisions<'_, '_> {
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether every block a section's palette holds has an empty collision shape (air, plants);
|
||||
/// false for a palette of the whole registry or a section outside the column.
|
||||
fn section_blocks_all_empty(&self, chunk: &crate::chunk::Chunk, section_y: i32) -> bool {
|
||||
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
|
||||
.ok()
|
||||
.and_then(|s| chunk.sections.get(s))
|
||||
.map(|section| §ion.blocks);
|
||||
blocks.is_some_and(|blocks| {
|
||||
!blocks.is_global()
|
||||
&& !blocks.maybe_has(|v| {
|
||||
self.binding
|
||||
.collision_block(BlockStateId(v))
|
||||
.map_or(true, |block| !block.empty_shape)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// `may_reach_out`: whether a section's palette holds a block that reaches out of
|
||||
/// its cell; a global palette, or a section outside the column, may.
|
||||
fn section_reaches_out(&self, chunk: &crate::chunk::Chunk, section_y: i32) -> bool {
|
||||
@@ -503,18 +528,7 @@ impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
|
||||
/// From the section's palette: every block it can hold has an empty shape (air, plants).
|
||||
/// A palette of the whole registry, or a section outside the column, is read cell by cell.
|
||||
fn section_is_empty(&mut self, chunk: Self::Chunk, section_y: i32) -> io::Result<bool> {
|
||||
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
|
||||
.ok()
|
||||
.and_then(|s| chunk.sections.get(s))
|
||||
.map(|section| §ion.blocks);
|
||||
Ok(blocks.is_some_and(|blocks| {
|
||||
!blocks.is_global()
|
||||
&& !blocks.maybe_has(|v| {
|
||||
self.binding
|
||||
.collision_block(BlockStateId(v))
|
||||
.map_or(true, |block| !block.empty_shape)
|
||||
})
|
||||
}))
|
||||
Ok(self.section_blocks_all_empty(chunk, section_y))
|
||||
}
|
||||
/// The resident columns do not change while a scan runs, and looking one up changes nothing.
|
||||
fn missing_chunks_stay_missing(&self) -> bool {
|
||||
@@ -999,6 +1013,76 @@ mod shulker_collision_tests {
|
||||
mod section_tests {
|
||||
use super::*;
|
||||
|
||||
impl SceneCollisions<'_, '_> {
|
||||
/// `cells_without_collision` as it was before it skipped sections: every cell of the box, one at a time.
|
||||
fn cells_without_collision_by_cell(&self, bounds: Aabb) -> bool {
|
||||
let (min, max) = (bounds.min_position(), bounds.max_position());
|
||||
if !min.is_finite() || !max.is_finite() {
|
||||
return false;
|
||||
}
|
||||
// The scan's cells: one past the box on every side (`block_collision_scan::cells`).
|
||||
let lo =
|
||||
BlockPos::containing(Vec3::new(min.x - 1.0e-7, min.y - 1.0e-7, min.z - 1.0e-7))
|
||||
.offset(-1, -1, -1);
|
||||
let hi =
|
||||
BlockPos::containing(Vec3::new(max.x + 1.0e-7, max.y + 1.0e-7, max.z + 1.0e-7))
|
||||
.offset(1, 1, 1);
|
||||
let size = |a: i32, b: i32| i64::from(b) - i64::from(a) + 1;
|
||||
let cells = size(lo.x, hi.x) * size(lo.y, hi.y) * size(lo.z, hi.z);
|
||||
if lo.x > hi.x || lo.y > hi.y || lo.z > hi.z || cells > 4096 {
|
||||
return false;
|
||||
}
|
||||
let mut column: Option<((i32, i32), Option<&crate::chunk::Chunk>)> = None;
|
||||
let mut section: Option<((i32, i32), i32, bool)> = None;
|
||||
for z in lo.z..=hi.z {
|
||||
for y in lo.y..=hi.y {
|
||||
for x in lo.x..=hi.x {
|
||||
let outer = [
|
||||
x == lo.x || x == hi.x,
|
||||
y == lo.y || y == hi.y,
|
||||
z == lo.z || z == hi.z,
|
||||
];
|
||||
let kind = outer.into_iter().filter(|&o| o).count();
|
||||
if kind == 3 {
|
||||
continue;
|
||||
}
|
||||
let at = (x >> 4, z >> 4);
|
||||
let chunk = match column {
|
||||
Some((c, chunk)) if c == at => chunk,
|
||||
_ => {
|
||||
let chunk = self.world.chunks.get(&at).map(|c| &**c);
|
||||
column = Some((at, chunk));
|
||||
chunk
|
||||
}
|
||||
};
|
||||
// The scan passes the cells of a column that is not resident.
|
||||
let Some(chunk) = chunk else {
|
||||
continue;
|
||||
};
|
||||
if kind != 0 {
|
||||
let reaches = match section {
|
||||
Some((c, sy, reaches)) if c == at && sy == y >> 4 => reaches,
|
||||
_ => {
|
||||
let reaches = self.section_reaches_out(chunk, y >> 4);
|
||||
section = Some((at, y >> 4, reaches));
|
||||
reaches
|
||||
}
|
||||
};
|
||||
if reaches {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if self.binding.may_collide(chunk.block(x, y, z)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Boxes swept through air, past torches (no collision shape), a fence (a shape
|
||||
/// reaching into the cell above) and the scene's stone: wherever the cells say no
|
||||
/// block can collide, the full resolution returns the movement, to the bit; the rest
|
||||
@@ -1056,6 +1140,11 @@ mod section_tests {
|
||||
(next() - 0.5) * reach,
|
||||
);
|
||||
let fast = scene.cells_without_collision(bb.expand_towards(d));
|
||||
assert_eq!(
|
||||
fast,
|
||||
scene.cells_without_collision_by_cell(bb.expand_towards(d)),
|
||||
"{bb:?} by {d:?}"
|
||||
);
|
||||
let mut full = SceneCollisions {
|
||||
world: &world,
|
||||
binding: &binding,
|
||||
|
||||
@@ -316,7 +316,10 @@ impl SkullSkin {
|
||||
/// The item slots of the container `name`'s block carries, if any.
|
||||
fn container_size(name: &str) -> Option<usize> {
|
||||
match name {
|
||||
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => Some(27),
|
||||
"minecraft:chest"
|
||||
| "minecraft:trapped_chest"
|
||||
| "minecraft:barrel"
|
||||
| "minecraft:ender_chest" => Some(27),
|
||||
n if n.ends_with("copper_chest") => Some(27),
|
||||
"minecraft:hopper" => Some(HOPPER_SLOTS),
|
||||
"minecraft:dispenser" | "minecraft:dropper" => Some(9),
|
||||
@@ -453,6 +456,14 @@ impl BlockEntity {
|
||||
if name == "minecraft:daylight_detector" {
|
||||
return Some(Self::DaylightDetector);
|
||||
}
|
||||
if name == "minecraft:ender_chest" {
|
||||
// EnderChestBlock.newBlockEntity: 27 slots, player-specific inventory.
|
||||
return Some(Self::Chest(vec![None; 27]));
|
||||
}
|
||||
if name == "minecraft:end_portal" {
|
||||
// EndPortalBlock.newBlockEntity: no data, only identity.
|
||||
return Some(Self::EndPortal);
|
||||
}
|
||||
if name.ends_with("copper_golem_statue") {
|
||||
return Some(Self::CopperGolemStatue(Default::default()));
|
||||
}
|
||||
@@ -547,6 +558,7 @@ impl BlockEntity {
|
||||
Self::DaylightDetector => name == "minecraft:daylight_detector",
|
||||
Self::Bell => name == "minecraft:bell",
|
||||
Self::Conduit(_) => name == "minecraft:conduit",
|
||||
Self::EndPortal => name == "minecraft:end_portal",
|
||||
Self::EnchantingTable => name == "minecraft:enchanting_table",
|
||||
Self::StructureBlock(_) => name == "minecraft:structure_block",
|
||||
Self::TestBlock(_) => name == "minecraft:test_block",
|
||||
@@ -588,6 +600,7 @@ impl BlockEntity {
|
||||
Self::DaylightDetector => "minecraft:daylight_detector",
|
||||
Self::Bell => "minecraft:bell",
|
||||
Self::Conduit(_) => "minecraft:conduit",
|
||||
Self::EndPortal => "minecraft:end_portal",
|
||||
Self::EnchantingTable => "minecraft:enchanting_table",
|
||||
Self::Brushable(_) => "minecraft:brushable_block",
|
||||
Self::Campfire(_) => "minecraft:campfire",
|
||||
@@ -625,6 +638,7 @@ impl BlockEntity {
|
||||
| Self::DaylightDetector
|
||||
| Self::Bell
|
||||
| Self::Conduit(_)
|
||||
| Self::EndPortal
|
||||
| Self::EnchantingTable
|
||||
| Self::Brushable(_) => Vec::new(),
|
||||
Self::Mod(m) => m.items.iter().flatten().cloned().collect(),
|
||||
@@ -658,6 +672,7 @@ impl BlockEntity {
|
||||
| Self::DaylightDetector
|
||||
| Self::Bell
|
||||
| Self::Conduit(_)
|
||||
| Self::EndPortal
|
||||
| Self::EnchantingTable
|
||||
| Self::Brushable(_) => &[],
|
||||
Self::Jukebox(j) => &j.items,
|
||||
@@ -696,6 +711,7 @@ impl BlockEntity {
|
||||
| Self::DaylightDetector
|
||||
| Self::Bell
|
||||
| Self::Conduit(_)
|
||||
| Self::EndPortal
|
||||
| Self::EnchantingTable
|
||||
| Self::Brushable(_) => &mut [],
|
||||
Self::Jukebox(j) => &mut j.items,
|
||||
@@ -949,4 +965,34 @@ mod tests {
|
||||
assert!((250..450).contains(&paid), "{paid}");
|
||||
assert!((0..100).all(|_| (1..=2).contains(&create_experience(1, 1.7, &mut random))));
|
||||
}
|
||||
|
||||
/// An ender chest placed with /setblock gets its block entity (`EnderChestBlock.newBlockEntity`),
|
||||
/// so `/data get block` answers with the entity and the chunk saves it.
|
||||
#[test]
|
||||
fn an_ender_chest_starts_as_a_block_entity() {
|
||||
let entity = BlockEntity::for_block("minecraft:ender_chest").expect("an ender chest");
|
||||
// `Chest` variant with 27 slots; `id()` reports the chest kind, block name comes from NBT.
|
||||
assert_eq!(entity.id(), "minecraft:chest");
|
||||
assert_eq!(entity.slots().len(), 27);
|
||||
assert!(entity.slots()[0].is_none());
|
||||
assert!(matches!(entity, BlockEntity::Chest(_)));
|
||||
// `fits`: an ender chest entity fits an ender chest block.
|
||||
assert!(entity.fits("minecraft:ender_chest"));
|
||||
// A stone block is not an ender chest.
|
||||
assert!(BlockEntity::for_block("minecraft:stone").is_none());
|
||||
}
|
||||
|
||||
/// An end portal placed with /setblock gets its block entity (`EndPortalBlock.newBlockEntity`),
|
||||
/// so `/data get block` answers with the entity and the chunk saves it.
|
||||
#[test]
|
||||
fn an_end_portal_starts_as_a_block_entity() {
|
||||
let entity = BlockEntity::for_block("minecraft:end_portal").expect("an end portal");
|
||||
assert_eq!(entity.id(), "minecraft:end_portal");
|
||||
assert_eq!(entity.slots().len(), 0);
|
||||
assert!(matches!(entity, BlockEntity::EndPortal));
|
||||
// `fits`: an end portal entity fits an end portal block.
|
||||
assert!(entity.fits("minecraft:end_portal"));
|
||||
// A stone block is not an end portal.
|
||||
assert!(BlockEntity::for_block("minecraft:stone").is_none());
|
||||
}
|
||||
}
|
||||
@@ -479,6 +479,8 @@ impl Screen for ControllerScreen {
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
listed: true,
|
||||
heading: None,
|
||||
};
|
||||
rows().into_iter().map(sheet).collect()
|
||||
}
|
||||
@@ -532,8 +534,9 @@ impl Screen for ControllerScreen {
|
||||
true
|
||||
}
|
||||
/// This player's controller settings, bindings included, as the screen found them.
|
||||
fn undo(&mut self, s: &mut Settings, before: &Settings) {
|
||||
fn undo(&mut self, s: &mut Settings, before: &Settings) -> Vec<Output> {
|
||||
s.controller = before.controller.clone();
|
||||
Vec::new()
|
||||
}
|
||||
/// Undo is there exactly while the controller settings differ from what the screen opened
|
||||
/// with: a binding the game made while the row waited counts, a value stepped back doesn't.
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use chrono::{DateTime, Local};
|
||||
use horizon_telemetry::system::machine_arch;
|
||||
use horizon_system::{command_output, machine_arch};
|
||||
|
||||
/// What the game is doing, set as it goes: graphics adapter, settings, packs, world.
|
||||
static DETAILS: Mutex<Vec<(&'static str, String)>> = Mutex::new(Vec::new());
|
||||
@@ -230,15 +230,6 @@ pub fn release_tag() -> Option<String> {
|
||||
.then(|| folder.to_owned())
|
||||
}
|
||||
|
||||
fn command_output(program: &str, args: &[&str]) -> Option<String> {
|
||||
let out = std::process::Command::new(program)
|
||||
.args(args)
|
||||
.output()
|
||||
.ok()?;
|
||||
let text = String::from_utf8_lossy(&out.stdout).trim().to_owned();
|
||||
(out.status.success() && !text.is_empty()).then_some(text)
|
||||
}
|
||||
|
||||
fn read(path: &str) -> Option<String> {
|
||||
std::fs::read_to_string(path).ok()
|
||||
}
|
||||
@@ -269,7 +260,7 @@ fn operating_system() -> String {
|
||||
} else if cfg!(target_os = "macos") {
|
||||
command_output("sw_vers", &["-productVersion"]).map(|v| format!("macOS {v}"))
|
||||
} else if cfg!(windows) {
|
||||
horizon_telemetry::system::windows_version()
|
||||
horizon_system::windows_version()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -284,11 +275,11 @@ fn operating_system() -> String {
|
||||
pub fn cpu() -> String {
|
||||
let threads = std::thread::available_parallelism().map_or(0, |n| n.get());
|
||||
let name = if cfg!(target_os = "linux") {
|
||||
read("/proc/cpuinfo").and_then(|t| horizon_telemetry::system::linux_cpu_name(&t))
|
||||
read("/proc/cpuinfo").and_then(|t| horizon_system::linux_cpu_name(&t))
|
||||
} else if cfg!(target_os = "macos") {
|
||||
command_output("sysctl", &["-n", "machdep.cpu.brand_string"])
|
||||
} else if cfg!(windows) {
|
||||
horizon_telemetry::system::windows_cpu()
|
||||
horizon_system::windows_cpu()
|
||||
} else {
|
||||
std::env::var("PROCESSOR_IDENTIFIER").ok()
|
||||
};
|
||||
|
||||
@@ -432,7 +432,15 @@ fn celestial_color(in: Out) -> vec4<f32> {
|
||||
linear_fog(in.fog_distance.y, globals.fog.z, globals.fog.w));
|
||||
return in.color * (1.0 - fog);
|
||||
}
|
||||
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
|
||||
let color = textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
|
||||
if in.overlay == 45u {
|
||||
// A Polytone custom particle drawn additively: the fog over its colour, as vanilla's
|
||||
// particle.fsh, under the additive blend (no alpha cutout: its faint texels add up).
|
||||
let f = max(linear_fog(in.fog_distance.x, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.fog_distance.y, globals.fog.z, globals.fog.w));
|
||||
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
|
||||
let color = celestial_color(in);
|
||||
|
||||
@@ -169,6 +169,14 @@ pub struct Frame {
|
||||
pub downfall: f32,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
/// `g.rain`: the mean of the rain and thunder levels, so plain rain is 0.5 and a
|
||||
/// thunderstorm 1 (observed on the real mod: a pack's `g.rain` colormap row).
|
||||
pub fn global_rain(&self) -> f64 {
|
||||
(f64::from(self.rain) + f64::from(self.thunder)) / 2.0
|
||||
}
|
||||
}
|
||||
|
||||
/// The game side of a pack expression in an attribute value: `g`, `p`, `c`, `o` (the camera's
|
||||
/// position) and `r`, `v` (the value so far) and the pack's `config`.
|
||||
struct FrameHost<'a> {
|
||||
@@ -178,6 +186,21 @@ struct FrameHost<'a> {
|
||||
seed: u64,
|
||||
}
|
||||
|
||||
/// `program`'s value for the frame (a pack's expression outside attribute layers: its `v` is 0).
|
||||
pub(crate) fn eval_frame(
|
||||
program: &horizon_polytone::expr::Program,
|
||||
frame: &Frame,
|
||||
configs: &horizon_polytone::pack::Configs,
|
||||
) -> Option<horizon_polytone::expr::Value> {
|
||||
let mut host = FrameHost {
|
||||
frame,
|
||||
configs,
|
||||
value: 0.0,
|
||||
seed: frame.game_time as u64,
|
||||
};
|
||||
program.eval(&mut host).ok()
|
||||
}
|
||||
|
||||
impl horizon_polytone::expr::Host for FrameHost<'_> {
|
||||
fn root(&mut self, root: horizon_polytone::expr::Root) -> horizon_polytone::expr::Value {
|
||||
use horizon_polytone::expr::{Root, Value as V};
|
||||
@@ -197,7 +220,7 @@ impl horizon_polytone::expr::Host for FrameHost<'_> {
|
||||
let on = |root: Root| *target == V::Object(root as u32);
|
||||
let num = |n: f64| Ok(V::Num(n));
|
||||
match name {
|
||||
"rain" if on(Root::Global) => num(f64::from(f.rain)),
|
||||
"rain" if on(Root::Global) => num(f.global_rain()),
|
||||
"time" if on(Root::Global) => num(f.game_time as f64),
|
||||
"dayTime" if on(Root::Global) => num(f.day_time as f64),
|
||||
"skyType" if on(Root::Global) => num(f64::from(f.sky_type)),
|
||||
@@ -1167,20 +1190,25 @@ mod tests {
|
||||
r#"{"default_value": 7}"#,
|
||||
),
|
||||
]);
|
||||
let at = |rain: f32, chunks: i32| {
|
||||
let at = |rain: f32, thunder: f32, chunks: i32| {
|
||||
env.set_frame(Frame {
|
||||
rain,
|
||||
thunder,
|
||||
render_distance: chunks,
|
||||
..Frame::default()
|
||||
});
|
||||
sky_at(&env, "minecraft:plains", 0.0)
|
||||
};
|
||||
// `c.viewDistance` is in blocks: 16 chunks are 256.
|
||||
assert_eq!(at(0.0, 16).fog_end, 224.0);
|
||||
assert_eq!(at(0.25, 32).fog_end, 498.0);
|
||||
assert_eq!(at(0.0, 0.0, 16).fog_end, 224.0);
|
||||
// `g.rain` is the mean of rain and thunder: half rain is 0.25 (448 + 50), plain rain
|
||||
// 0.5 and a thunderstorm 1, both past the `min` (224 + 100).
|
||||
assert_eq!(at(0.5, 0.0, 32).fog_end, 498.0);
|
||||
assert_eq!(at(1.0, 0.0, 16).fog_end, 324.0);
|
||||
assert_eq!(at(1.0, 1.0, 16).fog_end, 324.0);
|
||||
// `v` is the value the layers before gave: the default start 0, and 192.33 for clouds.
|
||||
assert_eq!(at(0.0, 16).fog_start, 7.0);
|
||||
assert_eq!(at(0.0, 16).cloud_height, 192.33 * 0.5);
|
||||
assert_eq!(at(0.0, 0.0, 16).fog_start, 7.0);
|
||||
assert_eq!(at(0.0, 0.0, 16).cloud_height, 192.33 * 0.5);
|
||||
}
|
||||
|
||||
/// A PNG of RGBA pixels in one row.
|
||||
@@ -1215,18 +1243,21 @@ mod tests {
|
||||
),
|
||||
("assets/mypack/polytone/colormaps/sky.png", &png),
|
||||
]);
|
||||
let sky = |rain: f32| {
|
||||
let sky = |rain: f32, thunder: f32| {
|
||||
env.set_frame(Frame {
|
||||
rain,
|
||||
thunder,
|
||||
..Frame::default()
|
||||
});
|
||||
env.sample(&Sample::default()).sky_color
|
||||
};
|
||||
assert_eq!(sky(0.0), [1.0, 0.0, 0.0]);
|
||||
assert_eq!(sky(0.3), [0.0, 1.0, 0.0]);
|
||||
assert_eq!(sky(1.0), [0.0, 0.0, 1.0]);
|
||||
// A transparent pixel keeps the colour the layers before gave (the dimension's #808080).
|
||||
assert_eq!(sky(0.7), [128.0 / 255.0; 3]);
|
||||
// `g.rain` 0, 0.3 (rain 0.6), 1 (a thunderstorm).
|
||||
assert_eq!(sky(0.0, 0.0), [1.0, 0.0, 0.0]);
|
||||
assert_eq!(sky(0.6, 0.0), [0.0, 1.0, 0.0]);
|
||||
assert_eq!(sky(1.0, 1.0), [0.0, 0.0, 1.0]);
|
||||
// A transparent pixel keeps the colour the layers before gave (the dimension's #808080):
|
||||
// `g.rain` 0.7.
|
||||
assert_eq!(sky(1.0, 0.4), [128.0 / 255.0; 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -107,12 +107,7 @@ impl Screen for ExtrasScreen {
|
||||
}
|
||||
}
|
||||
}
|
||||
vec![Sheet {
|
||||
id: (),
|
||||
name: lang::get(TITLE),
|
||||
about: lang::get(ABOUT),
|
||||
groups,
|
||||
}]
|
||||
vec![Sheet::new((), lang::get(TITLE), lang::get(ABOUT), groups)]
|
||||
}
|
||||
fn press(&mut self, s: &mut Settings, row: ExtraRow, back: bool) -> Vec<Output> {
|
||||
match row {
|
||||
|
||||
@@ -99,10 +99,11 @@ impl FogState {
|
||||
self.block_light_flicker += (a - b) * c * d * 0.1;
|
||||
self.block_light_flicker *= 0.9;
|
||||
}
|
||||
/// `LocalPlayer.aiStep`: water vision ramps up while the eye is in water.
|
||||
pub fn tick(&mut self, eye_in_water: bool) {
|
||||
/// `LocalPlayer.aiStep`: water vision ramps up while the eye is in water, ten times faster
|
||||
/// for a spectator.
|
||||
pub fn tick(&mut self, eye_in_water: bool, spectator: bool) {
|
||||
self.water_vision_time = if eye_in_water {
|
||||
(self.water_vision_time + 1).min(600)
|
||||
(self.water_vision_time + if spectator { 10 } else { 1 }).min(600)
|
||||
} else {
|
||||
(self.water_vision_time - 10).max(0)
|
||||
};
|
||||
@@ -707,11 +708,11 @@ mod tests {
|
||||
fn water_vision_ramps() {
|
||||
let mut s = FogState::default();
|
||||
for _ in 0..100 {
|
||||
s.tick(true);
|
||||
s.tick(true, false);
|
||||
}
|
||||
assert!((s.water_vision(true) - 0.6).abs() < 1e-6);
|
||||
for _ in 0..500 {
|
||||
s.tick(true);
|
||||
s.tick(true, false);
|
||||
}
|
||||
assert_eq!(s.water_vision(true), 1.0);
|
||||
assert_eq!(s.water_vision(false), 0.0);
|
||||
@@ -726,17 +727,17 @@ mod tests {
|
||||
input.fog_type = FogType::Water;
|
||||
// Just in: vanilla's water vision is 0, so the fog ends at a quarter of 96 blocks.
|
||||
let mut state = FogState::default();
|
||||
state.tick(true);
|
||||
state.tick(true, false);
|
||||
let first = setup_fog(&tables, &state, &input);
|
||||
assert_eq!(first.environmental_end, 24.0);
|
||||
for _ in 0..600 {
|
||||
state.tick(true);
|
||||
state.tick(true, false);
|
||||
}
|
||||
let settled = setup_fog(&tables, &state, &input);
|
||||
assert_eq!(settled.environmental_end, 96.0);
|
||||
input.full_water_vision = true;
|
||||
let mut fresh = FogState::default();
|
||||
fresh.tick(true);
|
||||
fresh.tick(true, false);
|
||||
let clear = setup_fog(&tables, &fresh, &input);
|
||||
assert_eq!(clear.environmental_end, settled.environmental_end);
|
||||
assert_eq!(clear.color, settled.color);
|
||||
@@ -782,4 +783,20 @@ mod tests {
|
||||
assert_eq!(fog.environmental_start, -160.0);
|
||||
assert_eq!(fog.environmental_end, 768.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spectator_sees_clearly_under_water_ten_times_sooner() {
|
||||
let mut swimmer = FogState::default();
|
||||
let mut spectator = FogState::default();
|
||||
for _ in 0..60 {
|
||||
swimmer.tick(true, false);
|
||||
spectator.tick(true, true);
|
||||
}
|
||||
// LocalPlayer.getWaterVision after 60 ticks: 60 and 600 ticks of vision.
|
||||
assert!((swimmer.water_vision(true) - 0.36).abs() < 1e-6);
|
||||
assert_eq!(spectator.water_vision(true), 1.0);
|
||||
// Out of the water both lose it 10 a tick.
|
||||
spectator.tick(false, true);
|
||||
assert_eq!(spectator.water_vision_time, 590);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -20,6 +20,10 @@ const MATERIALS: usize = 16;
|
||||
pub const TERRAIN_MODEL_SOLID_OVERLAY: u32 = 42;
|
||||
pub const TERRAIN_MODEL_CUTOUT_OVERLAY: u32 = 43;
|
||||
pub const TERRAIN_MODEL_CUTOUT_MIPPED_OVERLAY: u32 = 44;
|
||||
/// The overlay of a resource pack's Polytone custom particle drawn additively
|
||||
/// (`additive_translucent`): fog tints its colour as vanilla's particle shader does, which the
|
||||
/// other additive geometry does not take.
|
||||
pub const ADDITIVE_PARTICLE_OVERLAY: u32 = 45;
|
||||
/// Native terrain-array UV/payloads must never enter a shared-atlas shader-pack pass.
|
||||
pub fn is_native_terrain(layer: u32) -> bool {
|
||||
matches!(
|
||||
|
||||
@@ -36,7 +36,7 @@ pub(super) struct EntityOutline {
|
||||
|
||||
impl EntityOutline {
|
||||
/// `entity` draws the silhouettes with the entity shader's vertex stage and
|
||||
/// `fs_outline`; `format` is the frame's.
|
||||
/// `fs_outline`; `format` is what it draws onto (`Renderer::overlay_format`).
|
||||
pub(super) fn new(
|
||||
device: &wgpu::Device,
|
||||
entity: (
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
//! start, and the run is drawn indirectly with the count the copy reached.
|
||||
use super::SolidPipeline;
|
||||
use super::arena::GRAIN;
|
||||
use smallvec::SmallVec;
|
||||
|
||||
/// One section draw for `flatten.wgsl`: index count, first sorted index (or
|
||||
/// `QUADS` for a solid draw; phase 2: its entry in phase 2's draws), base
|
||||
@@ -825,7 +826,7 @@ impl Flat {
|
||||
});
|
||||
self.phase2_group = Some((group, count as u32));
|
||||
}
|
||||
let groups: Vec<(wgpu::BindGroup, u32)> = self
|
||||
let groups: SmallVec<[(wgpu::BindGroup, u32); 8]> = self
|
||||
.segments
|
||||
.iter()
|
||||
.filter_map(|&(source, first, count)| {
|
||||
|
||||
@@ -93,6 +93,8 @@ struct Layered {
|
||||
tonemap: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
|
||||
keep: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
|
||||
composite: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
|
||||
/// Compiled only when a screenshot needs it, for its readback format and HUD mode.
|
||||
screenshot: Option<(wgpu::TextureFormat, bool, bool, wgpu::RenderPipeline)>,
|
||||
/// What `tonemap` and `composite` read, written before they run (`begin`).
|
||||
params: wgpu::Buffer,
|
||||
}
|
||||
@@ -187,8 +189,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.
|
||||
/// HDR lighting, whether Horizon lifts this frame's world: off under a Horizon shader pack
|
||||
/// that draws, while a `shaders/` pack's post or material stage runs (the pack's own),
|
||||
/// with HDR Lighting (`look`) off, and with `HORIZON_HDR_LIGHTING=0`, to compare.
|
||||
pub lighting: bool,
|
||||
/// The HDR Lighting setting: off, nothing in the world is lifted, light sources and the
|
||||
/// sun included, so the picture is SDR's on the HDR canvas.
|
||||
@@ -328,15 +331,23 @@ impl Hdr {
|
||||
})
|
||||
};
|
||||
let layered = coverage.then(|| {
|
||||
let with = |label, textures: &[u32], params, entry| {
|
||||
let with = |label, textures: &[u32], params, entry, format| {
|
||||
let layout = layout(label, textures, params);
|
||||
let pipeline = pass(label, &layout, entry, format);
|
||||
(layout, pipeline)
|
||||
};
|
||||
let hud = super::hud_coverage::HUD_FORMAT;
|
||||
Layered {
|
||||
tonemap: with("Horizon HDR tone curve", &[1], true, "fs_tonemap"),
|
||||
keep: with("Horizon HDR HUD backdrop", &[0], false, "fs_keep"),
|
||||
composite: with("Horizon HDR composite", &[0, 4, 5], true, "fs_composite"),
|
||||
tonemap: with("Horizon HDR tone curve", &[1], true, "fs_tonemap", format),
|
||||
keep: with("Horizon HDR HUD backdrop", &[0], false, "fs_keep", hud),
|
||||
composite: with(
|
||||
"Horizon HDR composite",
|
||||
&[0, 4, 5],
|
||||
true,
|
||||
"fs_composite",
|
||||
format,
|
||||
),
|
||||
screenshot: None,
|
||||
params: params(),
|
||||
}
|
||||
});
|
||||
@@ -445,12 +456,11 @@ impl Hdr {
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
)
|
||||
});
|
||||
// In the frame's format, so the HUD's pipelines draw on it as on the frame with HDR
|
||||
// off; the last pass clips it at white.
|
||||
// 8-bit, so each HUD draw clips and rounds as on the frame with HDR off.
|
||||
let hud = layered.then(|| {
|
||||
texture(
|
||||
"HUD",
|
||||
self.format,
|
||||
super::hud_coverage::HUD_FORMAT,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
)
|
||||
});
|
||||
@@ -512,17 +522,110 @@ impl Hdr {
|
||||
self.targets.as_ref().unwrap().frame.clone()
|
||||
}
|
||||
|
||||
/// Lets the targets go, a scaled screenshot's: the next `frame` makes them again.
|
||||
pub fn release_targets(&mut self) {
|
||||
self.targets = None;
|
||||
}
|
||||
|
||||
/// The float frame with HUD coverage (`Layered`).
|
||||
pub fn layered(&self) -> bool {
|
||||
self.layered.is_some()
|
||||
}
|
||||
|
||||
/// The layered frame's HUD target, which the HUD and menus draw on instead of the frame
|
||||
/// once `keep_world` made it, and which an ordinary screenshot shows.
|
||||
/// once `keep_world` made it.
|
||||
pub fn hud(&self) -> Option<&wgpu::Texture> {
|
||||
self.targets.as_ref()?.hud.as_ref()
|
||||
}
|
||||
|
||||
/// The layered frame's SDR screenshot, directly in the readback's texture; the icon
|
||||
/// maps only the world. Where Horizon lifted nothing (`lighting` off: HDR Lighting off,
|
||||
/// or under a shader pack) nothing is mapped. Neither the display's targets nor its
|
||||
/// parameters are changed.
|
||||
pub fn sdr_screenshot(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
format: wgpu::TextureFormat,
|
||||
world_only: bool,
|
||||
) -> Option<wgpu::Texture> {
|
||||
let shoulder = self.lighting;
|
||||
let (l, t) = (self.layered.as_mut()?, self.targets.as_ref()?);
|
||||
if l.screenshot
|
||||
.as_ref()
|
||||
.is_none_or(|(f, w, s, _)| (*f, *w, *s) != (format, world_only, shoulder))
|
||||
{
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("HDR SDR screenshot"),
|
||||
source: wgpu::ShaderSource::Wgsl(shader().into()),
|
||||
});
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("HDR SDR screenshot"),
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_hdr"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_sdr_screenshot"),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &[
|
||||
("screenshot_world_only", f64::from(u8::from(world_only))),
|
||||
("screenshot_shoulder", f64::from(u8::from(shoulder))),
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(format.into())],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
l.screenshot = Some((format, world_only, shoulder, pipeline));
|
||||
}
|
||||
let pipeline = &l.screenshot.as_ref()?.3;
|
||||
let views = [
|
||||
(0, &t.frame),
|
||||
(4, t.coverage.as_ref()?),
|
||||
(5, t.hud.as_ref()?),
|
||||
]
|
||||
.map(|(binding, texture)| (binding, texture.create_view(&Default::default())));
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("HDR SDR screenshot"),
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &views
|
||||
.each_ref()
|
||||
.map(|(binding, view)| wgpu::BindGroupEntry {
|
||||
binding: *binding,
|
||||
resource: wgpu::BindingResource::TextureView(view),
|
||||
}),
|
||||
});
|
||||
let copy = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Horizon screenshot copy"),
|
||||
size: t.frame.size(),
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
Self::draw(
|
||||
encoder,
|
||||
pipeline,
|
||||
&group,
|
||||
©.create_view(&Default::default()),
|
||||
"HDR SDR screenshot",
|
||||
None,
|
||||
);
|
||||
Some(copy)
|
||||
}
|
||||
|
||||
/// On the layered frame, before `tonemap` and `composite`: what they read, for a frame that
|
||||
/// drew a world (`world`) or not.
|
||||
pub fn begin(&self, queue: &wgpu::Queue, world: bool) {
|
||||
@@ -601,6 +704,20 @@ impl Hdr {
|
||||
)
|
||||
}
|
||||
|
||||
/// The display as a 0.2 pack's `Frame` describes it, with the values the last pass uses:
|
||||
/// the headroom (the peak over paper white, at least 1), then paper white, the peak and
|
||||
/// the black level in nits.
|
||||
pub fn display(&self) -> [f32; 4] {
|
||||
let paper = self.paper_white.max(1.0);
|
||||
let headroom = (self.peak / paper).max(1.0);
|
||||
[
|
||||
headroom,
|
||||
paper,
|
||||
paper * headroom,
|
||||
self.black.clamp(0.0, paper),
|
||||
]
|
||||
}
|
||||
|
||||
/// The peak the HDR metadata gives: while calibration's peak step runs, PQ's own
|
||||
/// 10 000 nits, so a compositor that clips at the declared peak (KWin) lets the test
|
||||
/// patch through as bright as the display goes and the logo under it shows.
|
||||
@@ -1142,8 +1259,8 @@ var<private> params: ReferenceParams;
|
||||
});
|
||||
}
|
||||
|
||||
/// The first texel of `texture` once `encoder` ran: four half floats, or 10-bit PQ codes
|
||||
/// for HDR10.
|
||||
/// The first texel of `texture` once `encoder` ran: four half floats, 10-bit PQ codes
|
||||
/// for HDR10, or 8-bit values over 255 for the HUD's target.
|
||||
pub(super) fn texel(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
@@ -1180,6 +1297,9 @@ var<private> params: ReferenceParams;
|
||||
let pixel = u32::from_le_bytes(bytes[..4].try_into().unwrap());
|
||||
return [0, 10, 20, 30].map(|shift| ((pixel >> shift) & 0x3ff) as f32);
|
||||
}
|
||||
if texture.format() == super::super::hud_coverage::HUD_FORMAT {
|
||||
return std::array::from_fn(|i| f32::from(bytes[i]) / 255.0);
|
||||
}
|
||||
std::array::from_fn(|i| half::f16::from_le_bytes([bytes[2 * i], bytes[2 * i + 1]]).to_f32())
|
||||
}
|
||||
|
||||
@@ -1412,6 +1532,173 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
assert!(r == 1.0 && near(g, 0.5) && near(b, 0.2), "{r} {g} {b}");
|
||||
}
|
||||
|
||||
/// What a 0.2 pack's `Frame` reads of the display: the headroom the tone curve eases
|
||||
/// into, never below 1, and the black Horizon lifts after it, never above paper white.
|
||||
#[test]
|
||||
fn the_display_a_pack_reads_is_the_one_the_curve_maps_to() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
(hdr.paper_white, hdr.peak, hdr.black) = (203.0, 1000.0, 0.05);
|
||||
let [headroom, paper, peak, black] = hdr.display();
|
||||
assert_eq!((headroom, paper, black), (1000.0 / 203.0, 203.0, 0.05));
|
||||
assert!((peak - 1000.0).abs() < 1e-3, "{peak}");
|
||||
// A peak under paper white has no headroom: the display tops out at white.
|
||||
(hdr.peak, hdr.black) = (150.0, 400.0);
|
||||
assert_eq!(hdr.display(), [1.0, 203.0, 203.0, 203.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scaled_screenshot_shoots_at_its_size_and_leaves_the_window_its_own_targets() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
eprintln!("scaled screenshot GPU unavailable");
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let extent = |t: &wgpu::Texture| [t.width(), t.height()];
|
||||
hdr.frame(&device, [4, 2]);
|
||||
// `Renderer::draw_scaled_frame`: the frame at the screenshot's size, its SDR picture
|
||||
// and HDR10 copy at that size too, then the targets let go.
|
||||
assert_eq!(extent(&hdr.frame(&device, [8, 4])), [8, 4]);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let sdr = hdr
|
||||
.sdr_screenshot(&device, &mut encoder, F::Bgra8Unorm, false)
|
||||
.unwrap();
|
||||
let pq = hdr.capture(&device, &queue, &mut encoder, true).unwrap();
|
||||
queue.submit([encoder.finish()]);
|
||||
assert_eq!((extent(&sdr), extent(&pq)), ([8, 4], [8, 4]));
|
||||
hdr.release_targets();
|
||||
assert!(hdr.hud().is_none() && hdr.coverage_view().is_none());
|
||||
// The window's next frame makes its own again.
|
||||
assert_eq!(extent(&hdr.frame(&device, [4, 2])), [4, 2]);
|
||||
assert_eq!(extent(hdr.hud().unwrap()), [4, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sdr_screenshots_roll_off_the_world_and_keep_the_hud_as_drawn() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
eprintln!("SDR screenshot GPU unavailable");
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
// These display settings must not scale either the SDR world or the HUD.
|
||||
(hdr.paper_white, hdr.peak, hdr.black, hdr.ui) = (300.0, 1500.0, 0.2, 0.25);
|
||||
let shoot = |hdr: &mut Hdr, world: [f64; 3], hud: [f64; 3], c: f64, icon| {
|
||||
let frame = hdr.frame(&device, [1, 1]);
|
||||
let t = hdr.targets.as_ref().unwrap();
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
fill(&mut encoder, &frame, [world[0], world[1], world[2], 1.0]);
|
||||
fill(
|
||||
&mut encoder,
|
||||
t.hud.as_ref().unwrap(),
|
||||
[hud[0], hud[1], hud[2], 0.0],
|
||||
);
|
||||
fill(&mut encoder, t.coverage.as_ref().unwrap(), [c; 4]);
|
||||
// Read half-floats so 8-bit quantization cannot conceal the curve or RGB ratios.
|
||||
let copy = hdr
|
||||
.sdr_screenshot(&device, &mut encoder, F::Rgba16Float, icon)
|
||||
.unwrap();
|
||||
texel(&device, &queue, encoder, ©)
|
||||
};
|
||||
// Below the linear knee, through the last half-float below it, the stored
|
||||
// channels survive exactly. All inputs here are exactly representable.
|
||||
for gamma in [0.0f32, 0.25, 0.5, 0.75, 0.79248047] {
|
||||
let w = [gamma, gamma / 2.0, 0.0];
|
||||
let got = shoot(&mut hdr, w.map(f64::from), w.map(f64::from), 0.0, false);
|
||||
assert_eq!(got[..3], w.map(|v| half::f16::from_f32(v).to_f32()));
|
||||
}
|
||||
// Independent numeric checkpoints in linear light, not a CPU copy of the shader.
|
||||
for (light, expected) in [
|
||||
(1.0f32, 0.8),
|
||||
(2.0, 0.9111111),
|
||||
(4.0, 0.9578947),
|
||||
(8.0, 0.9794872),
|
||||
] {
|
||||
let g = f64::from(light.powf(1.0 / 2.2));
|
||||
let got = shoot(&mut hdr, [g; 3], [1.0; 3], 0.0, false);
|
||||
assert!(
|
||||
(got[0].powf(2.2) - expected).abs() < 0.002,
|
||||
"{light}: {got:?}"
|
||||
);
|
||||
assert!(got[0] < 1.0);
|
||||
}
|
||||
let mut last = 0.0;
|
||||
for i in 0..65 {
|
||||
let g = f64::from(i) / 16.0;
|
||||
let got = shoot(
|
||||
&mut hdr,
|
||||
[g, g / 2.0, 0.0],
|
||||
[g.min(1.0), (g / 2.0).min(1.0), 0.0],
|
||||
0.0,
|
||||
false,
|
||||
);
|
||||
assert!(got[0] >= last && got[0] <= 1.0, "{g}: {got:?}");
|
||||
assert!((got[0] - 2.0 * got[1]).abs() < 0.002 && got[2] == 0.0);
|
||||
last = got[0];
|
||||
}
|
||||
// Fully covered is exactly the HUD as its 8-bit target holds it, regardless of the
|
||||
// world or UI Brightness.
|
||||
let hud = [64.0, 128.0, 192.0].map(|v: f64| v / 255.0);
|
||||
assert_eq!(
|
||||
shoot(&mut hdr, [2.0, 1.0, 0.5], hud, 1.0, false),
|
||||
[hud[0], hud[1], hud[2], 1.0].map(|v| half::f16::from_f64(v).to_f32())
|
||||
);
|
||||
// At zero coverage the mapped world; the icon also ignores a fully opaque HUD.
|
||||
for (hud, c, icon) in [([1.0, 1.0, 0.5], 0.0, false), ([0.25; 3], 1.0, true)] {
|
||||
let got = shoot(&mut hdr, [2.0, 1.0, 0.5], hud, c, icon);
|
||||
for (g, expected) in got.into_iter().zip([0.983284, 0.491642, 0.245821, 1.0]) {
|
||||
assert!((g - expected).abs() < 0.002, "{got:?}");
|
||||
}
|
||||
}
|
||||
// R8's 128/255 coverage: recover the HUD against the clipped backdrop, then
|
||||
// composite in gamma. The original HUD target and world's light stay intact.
|
||||
let got = shoot(
|
||||
&mut hdr,
|
||||
[2.0, 1.0, 0.5],
|
||||
[0.75, 0.875, 0.4375],
|
||||
128.0 / 255.0,
|
||||
false,
|
||||
);
|
||||
for (g, expected) in got.into_iter().zip([0.741675, 0.62182, 0.31091, 1.0]) {
|
||||
assert!((g - expected).abs() < 0.002, "{got:?}");
|
||||
}
|
||||
let t = hdr.targets.as_ref().unwrap();
|
||||
let encoder = device.create_command_encoder(&Default::default());
|
||||
assert_eq!(
|
||||
texel(&device, &queue, encoder, &t.frame),
|
||||
[2.0, 1.0, 0.5, 1.0]
|
||||
);
|
||||
// The HUD's 8-bit target rounds what is drawn on it.
|
||||
let encoder = device.create_command_encoder(&Default::default());
|
||||
let stored = [191.0, 223.0, 112.0, 0.0].map(|v| v / 255.0);
|
||||
let hud = t.hud.as_ref().unwrap();
|
||||
assert_eq!(texel(&device, &queue, encoder, hud), stored);
|
||||
// With HDR Lighting off or under a pack, Horizon lifts nothing: the screenshot is the
|
||||
// SDR picture, clipped at white.
|
||||
hdr.lighting = false;
|
||||
for gamma in [0.5f32, 0.9, 1.0] {
|
||||
let w = [gamma, gamma / 2.0, 0.0];
|
||||
// Uncovered, the HUD's target holds the world as drawn; the icon ignores the HUD.
|
||||
let got = shoot(&mut hdr, w.map(f64::from), w.map(f64::from), 0.0, false);
|
||||
assert_eq!(got[..3], w.map(|v| half::f16::from_f32(v).to_f32()));
|
||||
let got = shoot(&mut hdr, w.map(f64::from), [0.25; 3], 1.0, true);
|
||||
assert_eq!(got[..3], w.map(|v| half::f16::from_f32(v).to_f32()));
|
||||
}
|
||||
// Partly covered, the 8-bit screenshot is the HUD's target as drawn.
|
||||
let got = shoot(
|
||||
&mut hdr,
|
||||
[1.0, 0.5, 0.0],
|
||||
[0.75, 0.875, 0.4375],
|
||||
128.0 / 255.0,
|
||||
false,
|
||||
);
|
||||
assert_eq!(
|
||||
got.map(|v| (v * 255.0).round()),
|
||||
[191.0, 223.0, 112.0, 255.0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hud_lays_over_the_worlds_light_by_its_coverage() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
//! HUD coverage beside its unchanged colour and alpha, only on the float HDR lane.
|
||||
|
||||
pub(crate) const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::R8Unorm;
|
||||
/// The HUD's own target on the layered frame (`Hdr::hud`): 8-bit, so each HUD draw clips at
|
||||
/// white and rounds as on vanilla's frame. The coverage pipelines are the only ones that draw
|
||||
/// on it.
|
||||
pub(crate) const HUD_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
|
||||
|
||||
pub(crate) fn supported(
|
||||
attachments: u32,
|
||||
@@ -73,8 +77,9 @@ impl Pipeline {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the primary output and all its blend factors, including destination alpha. Wrap the
|
||||
/// final shader result, so pack text's alpha, early returns and discard all apply to coverage.
|
||||
/// Keep the primary output and all its blend factors, including destination alpha, on the
|
||||
/// HUD's target (`HUD_FORMAT`). Wrap the final shader result, so pack text's alpha, early
|
||||
/// returns and discard all apply to coverage.
|
||||
/// `None` for a fragment shader without a colour output at location 0.
|
||||
pub(super) fn pipeline(
|
||||
device: &wgpu::Device,
|
||||
@@ -88,7 +93,8 @@ pub(super) fn pipeline(
|
||||
wgpu::ShaderSource::Naga(m) => m.into_owned(),
|
||||
_ => return None,
|
||||
};
|
||||
let primary = fragment.targets.first()?.clone()?;
|
||||
let mut primary = fragment.targets.first()?.clone()?;
|
||||
primary.format = HUD_FORMAT;
|
||||
let blend = primary.blend.unwrap_or(wgpu::BlendState::REPLACE).color;
|
||||
let additive = matches!(
|
||||
(blend.src_factor, blend.dst_factor),
|
||||
|
||||
@@ -164,16 +164,24 @@ fn fullscreen_pass(
|
||||
}
|
||||
|
||||
/// The tonemap stage's entries as the renderer draws them: the check's, with the result
|
||||
/// encoded into the frame's gamma, where the camera overlays and the HUD draw on it.
|
||||
fn tonemap_entries() -> String {
|
||||
/// encoded into the frame's gamma, where the camera overlays and the HUD draw on it. A pack's
|
||||
/// `[tonemap]` is held to `frame.headroom`. Horizon's own (`default`) is a clip at white in
|
||||
/// SDR; in HDR it leaves the light as it is for Horizon's tone curve (`Hdr::tonemap`), the
|
||||
/// one HDR without a pack uses.
|
||||
fn tonemap_entries(default: bool) -> String {
|
||||
let held = if default {
|
||||
"select(min(horizon_sanitize(out), vec3<f32>(frame.headroom)), horizon_sanitize(out), frame.hdr == 1u)"
|
||||
} else {
|
||||
"min(horizon_sanitize(out), vec3<f32>(frame.headroom))"
|
||||
};
|
||||
StageKind::Tonemap.entries(Api::V0_2).replace(
|
||||
"return vec4<f32>(min(horizon_sanitize(out), vec3<f32>(frame.headroom)), 1.0);",
|
||||
"return vec4<f32>(horizon_encode(min(horizon_sanitize(out), vec3<f32>(frame.headroom))), 1.0);",
|
||||
&format!("return vec4<f32>(horizon_encode({held}), 1.0);"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Horizon's tonemap for a 0.2 pack without `[tonemap]`: SDR's clip at white, which is the
|
||||
/// entries' hold at `frame.headroom` (1 until 0.2 packs get HDR output).
|
||||
/// The tonemap of a 0.2 pack without `[tonemap]`: the picture as it is, held as
|
||||
/// `tonemap_entries` holds Horizon's.
|
||||
const DEFAULT_TONEMAP: &str =
|
||||
"fn tonemap(input: TonemapInput) -> vec3<f32> { return input.color; }\n";
|
||||
|
||||
@@ -350,6 +358,9 @@ pub struct NativeFrame {
|
||||
pub api: Api,
|
||||
pub decoder: Option<Decoder>,
|
||||
mirror: Option<wgpu::TextureView>,
|
||||
/// The HDR display a 0.2 pack's picture goes to this frame (`Hdr::display`: headroom,
|
||||
/// paper white, peak and black level), set before `write`; `None` is SDR.
|
||||
pub display: Option<[f32; 4]>,
|
||||
}
|
||||
|
||||
/// A depth texture of `size` the world's depth is copied into.
|
||||
@@ -559,6 +570,7 @@ impl NativeFrame {
|
||||
api,
|
||||
decoder: (api == Api::V0_2).then(|| Decoder::new(device)),
|
||||
mirror: None,
|
||||
display: None,
|
||||
last: None,
|
||||
eye: [f32::NAN; 4],
|
||||
history_reset: true,
|
||||
@@ -799,6 +811,13 @@ impl NativeFrame {
|
||||
if self.api == Api::V0_2 {
|
||||
inputs.sky_color = decoded(inputs.sky_color);
|
||||
inputs.fog_color = decoded(inputs.fog_color);
|
||||
if let Some([headroom, paper_white, peak, black_level]) = self.display {
|
||||
inputs.hdr = 1;
|
||||
inputs.headroom = headroom;
|
||||
inputs.paper_white = paper_white;
|
||||
inputs.peak = peak;
|
||||
inputs.black_level = black_level;
|
||||
}
|
||||
}
|
||||
if !self.reflects {
|
||||
inputs.has_reflection = 0;
|
||||
@@ -1054,7 +1073,7 @@ impl NativePost {
|
||||
let source = tonemap.map_or_else(
|
||||
|| StageKind::Tonemap.prelude(Api::V0_2) + DEFAULT_TONEMAP,
|
||||
|s| s.source.clone(),
|
||||
) + &tonemap_entries();
|
||||
) + &tonemap_entries(tonemap.is_none());
|
||||
let file = tonemap.map_or("<tonemap>", |s| s.file.as_str());
|
||||
let module = naga::front::wgsl::parse_str(&source)
|
||||
.map_err(|e| format!("{file}: {}", e.emit_to_string(&source)))?;
|
||||
@@ -1118,6 +1137,11 @@ impl NativePost {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the pack has a `[tonemap]` of its own, which replaces Horizon's tone curve.
|
||||
pub fn tonemaps(&self) -> bool {
|
||||
self.display.as_ref().is_some_and(|d| d.label.is_some())
|
||||
}
|
||||
|
||||
/// The passes' labels, in order, then the pack's tonemap's.
|
||||
pub fn labels(&self) -> Vec<&str> {
|
||||
let tonemap = self.display.as_ref().and_then(|d| d.label.as_deref());
|
||||
@@ -1557,8 +1581,8 @@ pub fn frame_inputs(
|
||||
let k = super::native_shadow::distortion(s.distance, s.resolution);
|
||||
2.0 * s.distance / s.resolution.max(1) as f32 * (1.0 - k)
|
||||
}),
|
||||
// Shader API 0.2's display fields, which 0.1 packs don't declare: SDR's values, also
|
||||
// with HDR on, until HDR output is served for 0.2 packs.
|
||||
// Shader API 0.2's display fields, which 0.1 packs don't declare: SDR's values, which
|
||||
// `NativeFrame::display` replaces on an HDR display.
|
||||
hdr: 0,
|
||||
headroom: 1.0,
|
||||
paper_white: 80.0,
|
||||
@@ -2484,7 +2508,7 @@ file = \"b.wgsl\"
|
||||
/// an 8-bit step), at alpha 1.
|
||||
#[test]
|
||||
fn a_0_2_world_comes_back_through_decode_tonemap_and_encode() {
|
||||
assert!(tonemap_entries().contains("horizon_encode(min(horizon_sanitize(out)"));
|
||||
assert!(tonemap_entries(false).contains("horizon_encode(min(horizon_sanitize(out)"));
|
||||
let Some((device, queue)) = device() else {
|
||||
return;
|
||||
};
|
||||
@@ -2600,4 +2624,188 @@ file = \"b.wgsl\"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A 0.2 pack's `Frame` describes the HDR display its picture goes to (`display`), and SDR
|
||||
/// without one: `hdr` 0, `headroom` 1, `paper_white` and `peak` 80, `black_level` 0.
|
||||
#[test]
|
||||
fn a_0_2_frame_describes_the_display_it_goes_to() {
|
||||
let Some((device, _)) = device() else {
|
||||
return;
|
||||
};
|
||||
let hud = crate::hud::HudBatch::new([4, 4]);
|
||||
let frame = crate::gfx::pack_uniforms::initial_history_tests::frame(&hud);
|
||||
let mut native = NativeFrame::new(
|
||||
&device,
|
||||
None,
|
||||
Vec::new(),
|
||||
Default::default(),
|
||||
None,
|
||||
Api::V0_2,
|
||||
);
|
||||
let fields = |i: &FrameInputs| (i.hdr, i.headroom, i.paper_white, i.peak, i.black_level);
|
||||
assert_eq!(
|
||||
fields(&native.next_inputs(&frame, [4, 4])),
|
||||
(0, 1.0, 80.0, 80.0, 0.0)
|
||||
);
|
||||
native.display = Some([4.0, 250.0, 1000.0, 0.05]);
|
||||
assert_eq!(
|
||||
fields(&native.next_inputs(&frame, [4, 4])),
|
||||
(1, 4.0, 250.0, 1000.0, 0.05)
|
||||
);
|
||||
// Read every frame: a change of display shows on the next one.
|
||||
native.display = Some([2.0, 300.0, 600.0, 0.0]);
|
||||
assert_eq!(fields(&native.next_inputs(&frame, [4, 4])).1, 2.0);
|
||||
native.display = None;
|
||||
assert_eq!(fields(&native.next_inputs(&frame, [4, 4])).0, 0);
|
||||
}
|
||||
|
||||
/// On the layered HDR frame a 0.2 pack's light is tone mapped once: by Horizon's curve,
|
||||
/// the one HDR without a pack uses, when the pack has no `[tonemap]`, else by the pack's
|
||||
/// alone, held to the headroom. In SDR Horizon's is a clip at white.
|
||||
#[test]
|
||||
fn a_0_2_pack_is_tone_mapped_once_by_its_own_curve_or_horizons() {
|
||||
let Some((device, queue)) = device() else {
|
||||
return;
|
||||
};
|
||||
// Light 8 times paper white, as the world's float frame stores it, in gamma.
|
||||
let light = 8.0f32;
|
||||
let (paper, peak) = (203.0, 1000.0);
|
||||
let headroom = peak / paper;
|
||||
// The picture shown for that light, in linear light: (a pack's [tonemap], HDR).
|
||||
let shown = |pack_curve: bool, hdr: bool| {
|
||||
let (stage, file, source) = if pack_curve {
|
||||
(
|
||||
"[tonemap]",
|
||||
"t.wgsl",
|
||||
"fn tonemap(input: TonemapInput) -> vec3<f32> { return input.color; }\n",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"[sky]",
|
||||
"s.wgsl",
|
||||
"fn sky(input: SkyInput) -> vec4<f32> { return input.color; }\n",
|
||||
)
|
||||
};
|
||||
let manifest = format!(
|
||||
"schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.2\"\n\
|
||||
{stage}\nfile = \"{file}\"\n"
|
||||
);
|
||||
let files = [(MANIFEST, manifest.as_str()), (file, source)]
|
||||
.into_iter()
|
||||
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
|
||||
.collect();
|
||||
let report = check(&Pack::from_files(files).unwrap()).unwrap();
|
||||
let tonemap = report.stages.iter().find(|s| s.kind == StageKind::Tonemap);
|
||||
let format = wgpu::TextureFormat::Rgba16Float;
|
||||
let frame = NativeFrame::new(
|
||||
&device,
|
||||
None,
|
||||
Vec::new(),
|
||||
Default::default(),
|
||||
None,
|
||||
Api::V0_2,
|
||||
);
|
||||
let inputs = FrameInputs {
|
||||
hdr: u32::from(hdr),
|
||||
headroom: if hdr { headroom } else { 1.0 },
|
||||
..Default::default()
|
||||
};
|
||||
queue.write_buffer(&frame.buffer, 0, bytemuck::bytes_of(&inputs));
|
||||
let mut post = NativePost::new(
|
||||
&device,
|
||||
Vec::new(),
|
||||
&report.manifest,
|
||||
&[],
|
||||
format,
|
||||
&frame,
|
||||
tonemap,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(post.tonemaps(), pack_curve);
|
||||
let mut display = super::super::hdr::Hdr::new(
|
||||
&device,
|
||||
&queue,
|
||||
format,
|
||||
super::super::hdr::Output::Pq,
|
||||
true,
|
||||
);
|
||||
(display.paper_white, display.peak) = (paper, peak);
|
||||
let screen = display.frame(&device, [4, 4]);
|
||||
let usage =
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
|
||||
let color = texture(&device, format, usage);
|
||||
let depth = texture(&device, wgpu::TextureFormat::Depth32Float, usage);
|
||||
let [color, depth] = [&color, &depth].map(|t| t.create_view(&Default::default()));
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let g = f64::from(light.powf(1.0 / 2.2));
|
||||
drop(encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &color,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color {
|
||||
r: g,
|
||||
g,
|
||||
b: g,
|
||||
a: 1.0,
|
||||
}),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
}));
|
||||
let view = screen.create_view(&Default::default());
|
||||
let world = (&color, &depth, &frame.surface_view);
|
||||
post.run(&device, &mut encoder, world, &view, [4, 4], true, None);
|
||||
// The renderer's rule, then its curve, as it runs them after the pack's chain.
|
||||
if crate::gfx::horizon_tone_curve(hdr, true, post.tonemaps()) {
|
||||
display.begin(&queue, true);
|
||||
display.tonemap(&mut encoder, None);
|
||||
}
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 256,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
screen.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let bytes = buffer.get_mapped_range(..).unwrap();
|
||||
half::f16::from_le_bytes([bytes[0], bytes[1]])
|
||||
.to_f32()
|
||||
.powf(2.2)
|
||||
};
|
||||
let near = |got: f32, want: f32| (got - want).abs() <= 0.01 * want;
|
||||
// Horizon's curve once: BT.2390's EETF at 203 and 1000 nits puts light 8 at 4.82
|
||||
// (worked out apart from the shader, as in the HDR tone curve's own test). Clipped at
|
||||
// the headroom first, or mapped twice, it would land lower.
|
||||
let horizon = shown(false, true);
|
||||
assert!(near(horizon, 4.82043), "{horizon}");
|
||||
// The pack's identity curve alone: the light held to the headroom, not eased again.
|
||||
let pack = shown(true, true);
|
||||
assert!(near(pack, headroom), "{pack}");
|
||||
// SDR: a clip at white, the pack's or Horizon's.
|
||||
for pack_curve in [false, true] {
|
||||
let sdr = shown(pack_curve, false);
|
||||
assert!(near(sdr, 1.0), "{pack_curve}: {sdr}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -765,6 +765,8 @@ impl PackAtlas {
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
water_mask: false,
|
||||
water_patches: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,8 +330,30 @@ fn sample(
|
||||
},
|
||||
count: None,
|
||||
};
|
||||
let samplers: Vec<wgpu::BindGroupLayoutEntry> = (program.interface.samplers.iter())
|
||||
.flat_map(|s| {
|
||||
[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: s.binding,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: s.binding + 1,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering),
|
||||
count: None,
|
||||
},
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
let entries = [
|
||||
vec![binding(0, false)],
|
||||
[vec![binding(0, false)], samplers].concat(),
|
||||
if flat {
|
||||
vec![binding(0, true), binding(1, true)]
|
||||
} else {
|
||||
@@ -367,20 +389,54 @@ fn sample(
|
||||
vec![]
|
||||
},
|
||||
];
|
||||
// The program's samplers (water's boats' patches, `horizonWaterMask`): a 1x1 stand-in each,
|
||||
// as the pack binds when there is none, its sampler beside it.
|
||||
let stand_in = device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::R32Float,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default());
|
||||
let nearest = device.create_sampler(&Default::default());
|
||||
let samplers = &program.interface.samplers;
|
||||
let groups: Vec<_> = layouts
|
||||
.iter()
|
||||
.zip(bindings)
|
||||
.map(|(layout, buffers)| {
|
||||
.enumerate()
|
||||
.map(|(group, (layout, buffers))| {
|
||||
let mut entries: Vec<_> = buffers
|
||||
.into_iter()
|
||||
.map(|(binding, buffer)| wgpu::BindGroupEntry {
|
||||
binding,
|
||||
resource: buffer.as_entire_binding(),
|
||||
})
|
||||
.collect();
|
||||
if group == 0 {
|
||||
for s in samplers {
|
||||
entries.push(wgpu::BindGroupEntry {
|
||||
binding: s.binding,
|
||||
resource: wgpu::BindingResource::TextureView(&stand_in),
|
||||
});
|
||||
entries.push(wgpu::BindGroupEntry {
|
||||
binding: s.binding + 1,
|
||||
resource: wgpu::BindingResource::Sampler(&nearest),
|
||||
});
|
||||
}
|
||||
}
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout,
|
||||
entries: &buffers
|
||||
.into_iter()
|
||||
.map(|(binding, buffer)| wgpu::BindGroupEntry {
|
||||
binding,
|
||||
resource: buffer.as_entire_binding(),
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
entries: &entries,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -129,6 +129,8 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
far_depth: false,
|
||||
// Entities, items and block entities read the shared atlas: its own maps (paged_maps.rs).
|
||||
paged_maps: true,
|
||||
water_mask: false,
|
||||
water_patches: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +195,8 @@ pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
water_mask: false,
|
||||
water_patches: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +231,9 @@ pub(super) struct Draw {
|
||||
pub slot: u32,
|
||||
/// End portal or gateway faces: drawn with `end_portal.png` bound as the atlas.
|
||||
pub portal: bool,
|
||||
/// A boat's water patch (`WATER_MASK_OVERLAY`): depth only, with the program's pipeline that
|
||||
/// writes no colour.
|
||||
pub water_mask: bool,
|
||||
}
|
||||
|
||||
/// Which entity runs a pack's shadow pass draws (2.4, 3.2).
|
||||
@@ -455,12 +462,14 @@ impl PackEntities {
|
||||
casts,
|
||||
overlay,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
let first = spans.first().map_or(entities.len(), |s| s.start as usize);
|
||||
extend_without_portals(rest, &entities[..first.min(entities.len())]);
|
||||
let (mut other, mut portal_faces, mut beam_faces) = (Vec::new(), Vec::new(), Vec::new());
|
||||
let mut water_patches = Vec::new();
|
||||
let mut without_fluids = Vec::new();
|
||||
for (span, range, material) in material_runs(entities, spans, drawn) {
|
||||
let run_start = range.start as u32;
|
||||
@@ -490,19 +499,27 @@ impl PackEntities {
|
||||
// gateway: its cube and its beam): each goes to its own program.
|
||||
// Beacon beams come in a run of their own, at stage NONE (game.rs: no stage of 5.4
|
||||
// is theirs), for gbuffers_beaconbeam.
|
||||
let split = (matches!(material, Some(BLOCK | BLOCK_TRANSLUCENT))
|
||||
let split = ((matches!(material, Some(BLOCK | BLOCK_TRANSLUCENT))
|
||||
|| span.stage == render_stage("NONE"))
|
||||
&& whole
|
||||
.chunks(4)
|
||||
.any(|q| matches!(q[0].layer & 0xffff, 6 | 9 | 10));
|
||||
let parts: [(&[Vertex], Part); 3] = if split {
|
||||
.any(|q| matches!(q[0].layer & 0xffff, 6 | 9 | 10)))
|
||||
// A boat's water patch: depth only (`Part::WaterMask`).
|
||||
|| whole
|
||||
.chunks(4)
|
||||
.any(|q| q[0].layer & 0xffff == super::entity_batch::WATER_MASK_OVERLAY);
|
||||
let parts: [(&[Vertex], Part); 4] = if split {
|
||||
other.clear();
|
||||
portal_faces.clear();
|
||||
beam_faces.clear();
|
||||
water_patches.clear();
|
||||
for quad in whole.chunks(4) {
|
||||
match quad[0].layer & 0xffff {
|
||||
9 | 10 => portal_faces.extend_from_slice(quad),
|
||||
6 => beam_faces.extend_from_slice(quad),
|
||||
super::entity_batch::WATER_MASK_OVERLAY => {
|
||||
water_patches.extend_from_slice(quad)
|
||||
}
|
||||
_ => other.extend_from_slice(quad),
|
||||
}
|
||||
}
|
||||
@@ -510,9 +527,15 @@ impl PackEntities {
|
||||
(&other, Part::Plain),
|
||||
(&portal_faces, Part::Portal),
|
||||
(&beam_faces, Part::Beam),
|
||||
(&water_patches, Part::WaterMask),
|
||||
]
|
||||
} else {
|
||||
[(whole, Part::Plain), (&[], Part::Plain), (&[], Part::Plain)]
|
||||
[
|
||||
(whole, Part::Plain),
|
||||
(&[], Part::Plain),
|
||||
(&[], Part::Plain),
|
||||
(&[], Part::Plain),
|
||||
]
|
||||
};
|
||||
for (run, part) in parts {
|
||||
if run.is_empty() {
|
||||
@@ -521,6 +544,8 @@ impl PackEntities {
|
||||
let program = match part {
|
||||
Part::Portal => Some(BLOCK),
|
||||
Part::Beam => Some(BEAM),
|
||||
// Drawn by the entity program, whose depth-only pipeline it takes.
|
||||
Part::WaterMask => material.filter(|&p| p == ENTITY),
|
||||
Part::Plain => material.filter(|&p| {
|
||||
plain(run)
|
||||
|| (p == DESTROY && run.iter().all(|v| matches!(v.layer, 11 | 12)))
|
||||
@@ -529,7 +554,8 @@ impl PackEntities {
|
||||
let pack = program.is_some_and(|p| drawn[p]) && !span.hidden;
|
||||
// Portal faces cast too (a pack's shadow pass sees block entities whole, as Iris
|
||||
// draws them); beams do not.
|
||||
let casts = part != Part::Beam && program.is_some_and(|p| shadow.cast(&span, p));
|
||||
let casts = !matches!(part, Part::Beam | Part::WaterMask)
|
||||
&& program.is_some_and(|p| shadow.cast(&span, p));
|
||||
if !pack && !span.hidden {
|
||||
extend_without_portals(rest, run);
|
||||
}
|
||||
@@ -541,8 +567,13 @@ impl PackEntities {
|
||||
Part::Beam => beam_segments(run, &mut self.scratch),
|
||||
_ => self.scratch.extend_from_slice(run),
|
||||
}
|
||||
// A run copied whole: its vertices keep their order, for `at_velocity`.
|
||||
if !split {
|
||||
// A run copied whole keeps its vertices' order, for `at_velocity`; so does the
|
||||
// start of a split one copied as it is (a boat's hull before its water patch).
|
||||
let in_order = !split
|
||||
|| (part == Part::Plain
|
||||
&& bytemuck::cast_slice::<_, u8>(&whole[..run.len().min(whole.len())])
|
||||
== bytemuck::cast_slice::<_, u8>(run));
|
||||
if in_order {
|
||||
copied.push((run_start, posed + start, run.len() as u32));
|
||||
}
|
||||
if program == DESTROY {
|
||||
@@ -563,6 +594,7 @@ impl PackEntities {
|
||||
casts,
|
||||
overlay,
|
||||
portal,
|
||||
part == Part::WaterMask,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -634,6 +666,7 @@ impl PackEntities {
|
||||
casts: bool,
|
||||
overlay: u32,
|
||||
portal: bool,
|
||||
water_mask: bool,
|
||||
) {
|
||||
let (entity_id, block_id) = match program {
|
||||
BLOCK | BLOCK_TRANSLUCENT => (0, span.id),
|
||||
@@ -658,6 +691,7 @@ impl PackEntities {
|
||||
shadow: casts,
|
||||
slot: self.draws.len() as u32,
|
||||
portal,
|
||||
water_mask,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -692,6 +726,8 @@ enum Part {
|
||||
Plain,
|
||||
Portal,
|
||||
Beam,
|
||||
/// A boat's water patch: depth only (`Draw::water_mask`).
|
||||
WaterMask,
|
||||
}
|
||||
|
||||
/// Beam faces (`block_entity_render::beam`: layer 6, v counting the sprite's repeats up the
|
||||
@@ -1446,6 +1482,79 @@ mod gpu_span_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A boat's water patch (`RenderTypes.waterMask`) is a draw of its own, depth only, that casts
|
||||
/// no shadow; with no pack program for entities, Horizon draws it with the boat.
|
||||
#[test]
|
||||
fn a_boats_water_patch_draws_depth_only() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let layout = super::super::pack_draw::DrawUniforms::new(&device);
|
||||
let mut geometry = PackEntities::new(&device, &layout.layout);
|
||||
let mask = super::super::entity_batch::WATER_MASK_OVERLAY;
|
||||
// One boat: a hull face, then the water patch.
|
||||
let cpu: Vec<Vertex> = [0, 0, 0, 0, mask, mask, mask, mask]
|
||||
.into_iter()
|
||||
.map(|layer| Vertex {
|
||||
layer,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
})
|
||||
.collect();
|
||||
let spans = [EntitySpan {
|
||||
start: 0,
|
||||
id: 7,
|
||||
stage: render_stage("ENTITIES"),
|
||||
..Default::default()
|
||||
}];
|
||||
let mut rest = Vec::new();
|
||||
let shadows = Casters {
|
||||
entities: true,
|
||||
..Default::default()
|
||||
};
|
||||
let mut prepare = |drawn, rest: &mut Vec<Vertex>| {
|
||||
geometry.prepare(
|
||||
&device,
|
||||
&queue,
|
||||
&layout.layout,
|
||||
&cpu,
|
||||
&spans,
|
||||
drawn,
|
||||
([0.; 4], [1; 2]),
|
||||
shadows,
|
||||
rest,
|
||||
None,
|
||||
(&[], &[]),
|
||||
);
|
||||
let draws = (geometry.draws.iter())
|
||||
.map(|d| (d.base, d.program, d.pack, d.shadow, d.water_mask))
|
||||
.collect::<Vec<_>>();
|
||||
let heights: Vec<f32> = geometry.scratch.iter().map(|v| v.pos[1]).collect();
|
||||
(draws, heights)
|
||||
};
|
||||
assert_eq!(
|
||||
prepare([true; PROGRAMS], &mut rest),
|
||||
(
|
||||
vec![
|
||||
(0, ENTITY, true, true, false),
|
||||
(4, ENTITY, true, false, true)
|
||||
],
|
||||
// Where they lie: the patch at the water line, not moved.
|
||||
vec![0.0; 8]
|
||||
)
|
||||
);
|
||||
assert!(rest.is_empty());
|
||||
let mut no_entities = [true; PROGRAMS];
|
||||
no_entities[ENTITY] = false;
|
||||
let (draws, _) = prepare(no_entities, &mut rest);
|
||||
assert!(draws.iter().all(|d| !d.2), "{draws:?}");
|
||||
assert_eq!(rest.len(), 8, "the boat and its patch stay Horizon's");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn posed_and_cpu_draws_keep_offsets_uniforms_and_special_fallbacks() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
|
||||
@@ -53,6 +53,8 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
water_mask: false,
|
||||
water_patches: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,8 @@ void main() {
|
||||
terrain: Some(horizon_shaders::GbuffersInputs {
|
||||
body: "vec2 p = vec2(float((gl_VertexIndex << 1) & 2), float(gl_VertexIndex & 2)); hz_Vertex=vec4(p*2.0-1.0,0.0,1.0);".into(),
|
||||
paged_maps: true,
|
||||
water_mask: false,
|
||||
water_patches: false,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
|
||||
@@ -19,6 +19,7 @@ use std::collections::HashMap;
|
||||
|
||||
use glam::{DMat4, DVec3};
|
||||
use horizon_shaders::{Format, Group, Kind, Post, SHADOW_COLORS};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use super::pack_draw::DrawUniforms;
|
||||
use super::pack_entities::{PROGRAMS, PackEntities};
|
||||
@@ -125,6 +126,8 @@ enum Source {
|
||||
ShadowDepth(usize, bool),
|
||||
/// `shadowcolor0/1` read outside the shadow programs (in them they are `Color`).
|
||||
ShadowColor(usize),
|
||||
/// The boats' water patches' depth (`horizon_shaders::WATER_MASK`), for translucent terrain.
|
||||
WaterMask,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -160,9 +163,11 @@ impl SamplerKind {
|
||||
Source::Color(_) | Source::ShadowColor(_) | Source::Dummy if !filtering => {
|
||||
Self::Nearest
|
||||
}
|
||||
Source::Depth | Source::Depth1 | Source::Depth2 | Source::ShadowDepth(_, false) => {
|
||||
Self::Nearest
|
||||
}
|
||||
Source::Depth
|
||||
| Source::Depth1
|
||||
| Source::Depth2
|
||||
| Source::ShadowDepth(_, false)
|
||||
| Source::WaterMask => Self::Nearest,
|
||||
Source::Noise => Self::Repeat,
|
||||
Source::Shadow | Source::ShadowDepth(_, true) => Self::Comparison,
|
||||
// Blocks look as the game draws them: nearest texels. Between levels, Iris 1.11.7
|
||||
@@ -406,6 +411,9 @@ struct Pass {
|
||||
/// The same program as vanilla blends the sky's parts (`PostProgram::vanilla_sky_blends`):
|
||||
/// without blending for the disc and the void, then with `STARS_BLEND` for the stars.
|
||||
sky_parts: Option<[Slot<wgpu::RenderPipeline>; 2]>,
|
||||
/// The entity program without colour writes, for the boats' water patches
|
||||
/// (`RenderTypes.waterMask`: depth only, so no water shows inside a hull).
|
||||
water_mask: Option<Slot<wgpu::RenderPipeline>>,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
ubo: wgpu::Buffer,
|
||||
block: Vec<u8>,
|
||||
@@ -511,6 +519,8 @@ struct Targets {
|
||||
depth1: Option<(wgpu::Texture, wgpu::TextureView)>,
|
||||
/// `depthtex2`, when a program after the hand reads it without the hand.
|
||||
depth2: Option<(wgpu::Texture, wgpu::TextureView)>,
|
||||
/// The boats' water patches' depth, when translucent terrain reads it (`Source::WaterMask`).
|
||||
water_mask: Option<(wgpu::Texture, wgpu::TextureView)>,
|
||||
/// Both sides' mip chains of the buffers with mipmaps (`color` is their first level).
|
||||
mips: [Option<[Mips; 2]>; BUFFERS],
|
||||
/// Where a pass that both samples and stores a buffer samples it from.
|
||||
@@ -648,6 +658,11 @@ pub struct PackPost {
|
||||
depth1: bool,
|
||||
/// A program after the hand reads `depthtex2`: it is copied before the hand.
|
||||
depth2: bool,
|
||||
/// Translucent terrain reads the boats' water patches (`Targets::water_mask`).
|
||||
water_mask: bool,
|
||||
/// The water patches' depth holds patches (or was never cleared): cleared before the next
|
||||
/// frame's are drawn.
|
||||
water_mask_dirty: bool,
|
||||
/// The shadow buffers, when the shadow pass runs (2.5), and the `shadowcomp` programs.
|
||||
shadow_targets: Option<super::pack_shadow::ShadowTargets>,
|
||||
/// What draws terrain from the light (`shadow`), and translucent terrain (`shadow_water`).
|
||||
@@ -907,6 +922,7 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
|
||||
"lightmap" => Source::Lightmap,
|
||||
"normals" => Source::Normals,
|
||||
"specular" => Source::Specular,
|
||||
horizon_shaders::WATER_MASK => Source::WaterMask,
|
||||
n => horizon_shaders::buffer_index(n).map_or(Source::Dummy, Source::Color),
|
||||
}
|
||||
}
|
||||
@@ -1647,9 +1663,11 @@ fn bindings(
|
||||
Source::ShadowColor(n) => shadow
|
||||
.and_then(|f| f[n].sample_type(None, Some(features)))
|
||||
.unwrap_or(float),
|
||||
Source::Depth | Source::Depth1 | Source::Depth2 | Source::ShadowDepth(..) => {
|
||||
wgpu::TextureSampleType::Float { filterable: false }
|
||||
}
|
||||
Source::Depth
|
||||
| Source::Depth1
|
||||
| Source::Depth2
|
||||
| Source::ShadowDepth(..)
|
||||
| Source::WaterMask => wgpu::TextureSampleType::Float { filterable: false },
|
||||
// Unfilterable on WebGPU-level devices; the lightmap is sampled at texel centres.
|
||||
Source::Lightmap => float,
|
||||
_ => float,
|
||||
@@ -1933,6 +1951,7 @@ fn build_pass(
|
||||
custom: &[(String, String)],
|
||||
draws: &wgpu::BindGroupLayout,
|
||||
pack: &Resources<'_>,
|
||||
water_mask: bool,
|
||||
) -> Option<Pass> {
|
||||
let features = device.features();
|
||||
// The frame's uniforms, then the pack's custom ones.
|
||||
@@ -2147,9 +2166,32 @@ fn build_pass(
|
||||
)
|
||||
})
|
||||
});
|
||||
(layout, pack.render_pipeline(device, job), sky_parts)
|
||||
// The water patches' pipeline: the same program, depth only.
|
||||
let water_mask = water_mask.then(|| {
|
||||
let targets = (job.targets.iter())
|
||||
.map(|t| {
|
||||
t.clone().map(|t| wgpu::ColorTargetState {
|
||||
write_mask: wgpu::ColorWrites::empty(),
|
||||
..t
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
pack.render_pipeline(
|
||||
device,
|
||||
RenderJob {
|
||||
targets,
|
||||
..job.clone()
|
||||
},
|
||||
)
|
||||
});
|
||||
(
|
||||
layout,
|
||||
pack.render_pipeline(device, job),
|
||||
sky_parts,
|
||||
water_mask,
|
||||
)
|
||||
});
|
||||
let (layout, pipeline, sky_parts) = match created {
|
||||
let (layout, pipeline, sky_parts, water_mask) = match created {
|
||||
Ok(created) => created,
|
||||
Err(e) => {
|
||||
notes.push(format!("{}: skipped: {e}", p.name));
|
||||
@@ -2189,6 +2231,7 @@ fn build_pass(
|
||||
group: p.group,
|
||||
pipeline: Pipeline::Render(pipeline),
|
||||
sky_parts,
|
||||
water_mask,
|
||||
layout,
|
||||
samplers,
|
||||
writes,
|
||||
@@ -2328,6 +2371,7 @@ fn build_compute(
|
||||
local,
|
||||
},
|
||||
sky_parts: None,
|
||||
water_mask: None,
|
||||
layout,
|
||||
samplers,
|
||||
writes: Vec::new(),
|
||||
@@ -2610,6 +2654,8 @@ impl PackPost {
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
// The next pass built also gets a depth-only pipeline for the boats' water patches.
|
||||
let water_mask_next = std::cell::Cell::new(false);
|
||||
let mut build = |mut p: horizon_shaders::PostProgram, geometry: Option<&Geometry<'_>>| {
|
||||
// Not wanted any more: the rest is skipped and the build refused below.
|
||||
if cancel.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
@@ -2660,6 +2706,7 @@ impl PackPost {
|
||||
&custom_names,
|
||||
&draws.layout,
|
||||
&pack,
|
||||
water_mask_next.get(),
|
||||
)?
|
||||
};
|
||||
pass.compute.append(&mut computes);
|
||||
@@ -2835,7 +2882,11 @@ impl PackPost {
|
||||
pass
|
||||
})
|
||||
};
|
||||
let mut kind = 0;
|
||||
let entity_passes = programs.map(|p| {
|
||||
// The entity program draws the boats, and their water patches (`Draw::water_mask`).
|
||||
water_mask_next.set(kind == super::pack_entities::ENTITY);
|
||||
kind += 1;
|
||||
p.zip(entity_geometry.as_ref()).and_then(|(p, g)| {
|
||||
if p.name == "gbuffers_damagedblock" {
|
||||
let decal = Geometry {
|
||||
@@ -2858,6 +2909,7 @@ impl PackPost {
|
||||
if entity_passes[super::pack_entities::PARTICLE_TRANSLUCENT].is_some() {
|
||||
late[super::pack_entities::PARTICLE] = false;
|
||||
}
|
||||
water_mask_next.set(false);
|
||||
let shadow_entities = shadow_programs.map(|p| {
|
||||
p.zip(entity_geometry.as_ref())
|
||||
.and_then(|(p, g)| paged(build(p, Some(g))))
|
||||
@@ -3085,6 +3137,9 @@ impl PackPost {
|
||||
let depth1 = (water.is_some() || hand.is_some())
|
||||
&& (reads_depth(Source::Depth1) || (hand.is_none() && reads_depth2));
|
||||
let depth2 = hand.is_some() && reads_depth2;
|
||||
let water_mask = (water.iter())
|
||||
.flat_map(|p| &p.samplers)
|
||||
.any(|s| s.source == Source::WaterMask);
|
||||
let every_pass = || {
|
||||
(passes
|
||||
.iter()
|
||||
@@ -3340,6 +3395,8 @@ impl PackPost {
|
||||
hand_water,
|
||||
depth1,
|
||||
depth2,
|
||||
water_mask,
|
||||
water_mask_dirty: true,
|
||||
shadow_targets,
|
||||
shadow_terrain,
|
||||
portal_rect: [0.0; 4],
|
||||
@@ -3671,6 +3728,7 @@ impl PackPost {
|
||||
}
|
||||
}
|
||||
Source::Depth => &targets.depth,
|
||||
Source::WaterMask => targets.water_mask.as_ref().map_or(&self.dummy, |m| &m.1),
|
||||
// Before translucent terrain the two are the same.
|
||||
Source::Depth1 => match &targets.depth1 {
|
||||
Some(d)
|
||||
@@ -3864,6 +3922,16 @@ impl PackPost {
|
||||
&mut self,
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
pass: &Pass,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
self.begin_with(encoder, pass, false)
|
||||
}
|
||||
|
||||
/// `begin`, or with `water_mask` into the water patches' depth instead of the frame's.
|
||||
fn begin_with<'e>(
|
||||
&mut self,
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
pass: &Pass,
|
||||
water_mask: bool,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
let targets = self.targets.as_ref().expect("resized");
|
||||
let load = wgpu::Operations {
|
||||
@@ -3877,7 +3945,8 @@ impl PackPost {
|
||||
_ => (super::pack_shadow::load(), [load; SHADOW_COLORS]),
|
||||
};
|
||||
let shadow = self.shadow_targets.as_ref().filter(|_| pass.group.shadow());
|
||||
let mut attachments: Vec<Option<wgpu::RenderPassColorAttachment>> = Vec::new();
|
||||
let mut attachments: SmallVec<[Option<wgpu::RenderPassColorAttachment>; BUFFERS]> =
|
||||
SmallVec::new();
|
||||
for &(location, buffer) in &pass.writes {
|
||||
let l = location as usize;
|
||||
if attachments.len() <= l {
|
||||
@@ -3898,7 +3967,10 @@ impl PackPost {
|
||||
color_attachments: &attachments,
|
||||
timestamp_writes,
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: shadow.map_or(&targets.depth, |s| &s.depth.1),
|
||||
view: match &targets.water_mask {
|
||||
Some((_, view)) if water_mask => view,
|
||||
_ => shadow.map_or(&targets.depth, |s| &s.depth.1),
|
||||
},
|
||||
depth_ops: Some(depth_ops),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
@@ -4081,8 +4153,11 @@ impl PackPost {
|
||||
let mut draws = geometry
|
||||
.draws
|
||||
.iter()
|
||||
.filter(|d| d.pack && d.program == program)
|
||||
.filter(|d| d.pack && d.program == program && !d.water_mask)
|
||||
.peekable();
|
||||
let masks: Vec<_> = (geometry.draws.iter())
|
||||
.filter(|d| d.pack && d.program == program && d.water_mask)
|
||||
.collect();
|
||||
if draws.peek().is_none() {
|
||||
continue;
|
||||
}
|
||||
@@ -4124,6 +4199,35 @@ impl PackPost {
|
||||
rp.draw_indexed(0..d.quads * 6, d.base as i32, 0..1);
|
||||
}
|
||||
}
|
||||
// The boats' water patches, depth only, into their own depth: translucent terrain
|
||||
// discards what lies behind them, and the frame's depth stays the pack's.
|
||||
if let Some(mask) = pass.water_mask.as_ref().and_then(|m| m.get())
|
||||
&& !masks.is_empty()
|
||||
&& self
|
||||
.targets
|
||||
.as_ref()
|
||||
.is_some_and(|t| t.water_mask.is_some())
|
||||
{
|
||||
let mut rp = self.begin_with(encoder, pass, true);
|
||||
rp.set_pipeline(mask);
|
||||
rp.set_bind_group(0, &group, &[]);
|
||||
if pass.buffers
|
||||
&& let Some(g) = &self.buffers.group
|
||||
{
|
||||
rp.set_bind_group(4, g, &[]);
|
||||
}
|
||||
rp.set_bind_group(1, &geometry.table_group, &[]);
|
||||
rp.set_vertex_buffer(0, geometry.vertices());
|
||||
rp.set_index_buffer(geometry.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
for d in &masks {
|
||||
if pass.draw {
|
||||
rp.set_bind_group(2, &geometry.group, &[geometry.offset(d.slot)]);
|
||||
}
|
||||
rp.draw_indexed(0..d.quads * 6, d.base as i32, 0..1);
|
||||
}
|
||||
drop(rp);
|
||||
self.water_mask_dirty = true;
|
||||
}
|
||||
self.mips_valid &= !pass.written;
|
||||
}
|
||||
self.entities = Some(entities);
|
||||
@@ -4905,6 +5009,16 @@ impl PackPost {
|
||||
let view = t.create_view(&Default::default());
|
||||
(t, view)
|
||||
});
|
||||
let water_mask = self.water_mask.then(|| {
|
||||
let t = texture(
|
||||
"Shader pack water patches",
|
||||
depth32,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
);
|
||||
let view = t.create_view(&Default::default());
|
||||
(t, view)
|
||||
});
|
||||
self.water_mask_dirty = true;
|
||||
let scratch = std::array::from_fn(|i| {
|
||||
(self.copied & 1 << i != 0).then(|| {
|
||||
// A copy also read as an image before a dispatch.
|
||||
@@ -4939,6 +5053,7 @@ impl PackPost {
|
||||
depth_texture,
|
||||
depth1,
|
||||
depth2,
|
||||
water_mask,
|
||||
mips,
|
||||
});
|
||||
self.forget_groups();
|
||||
@@ -5038,7 +5153,8 @@ impl PackPost {
|
||||
}
|
||||
let group = self.bind(device, queue, encoder, pass, size);
|
||||
let t = self.shadow_targets.as_ref().expect("shadow pass");
|
||||
let mut attachments: Vec<Option<wgpu::RenderPassColorAttachment>> = Vec::new();
|
||||
let mut attachments: SmallVec<[Option<wgpu::RenderPassColorAttachment>; BUFFERS]> =
|
||||
SmallVec::new();
|
||||
for &(location, buffer) in &pass.writes {
|
||||
let l = location as usize;
|
||||
if attachments.len() <= l {
|
||||
@@ -5067,6 +5183,7 @@ impl PackPost {
|
||||
}
|
||||
rp.draw(0..6, 0..1);
|
||||
}
|
||||
drop(attachments);
|
||||
if let Some(t) = &mut self.shadow_targets {
|
||||
t.main ^= pass.flip;
|
||||
}
|
||||
@@ -5193,6 +5310,24 @@ impl PackPost {
|
||||
terrain: &mut TerrainDraw<'_>,
|
||||
) {
|
||||
self.resize(device, size);
|
||||
// Last frame's water patches (or a new texture's zeros) out: depth 1 is no patch.
|
||||
if self.water_mask_dirty
|
||||
&& let Some((_, view)) = self.targets.as_ref().and_then(|t| t.water_mask.as_ref())
|
||||
{
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Shader pack water patches clear"),
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(1.0),
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
self.water_mask_dirty = false;
|
||||
}
|
||||
// A resolution scale leaves the picture smaller (or larger) than the window: the pack's
|
||||
// `final` draws it at the picture's size, and one copy takes it to the window (#9291).
|
||||
let final_view = if size != window && self.passes.iter().any(|p| p.group == Group::Final) {
|
||||
@@ -5540,7 +5675,8 @@ impl PackPost {
|
||||
.as_ref()
|
||||
.map(|t| &t[usize::from(self.main & 1 << i == 0)])
|
||||
};
|
||||
let mut attachments: Vec<Option<wgpu::RenderPassColorAttachment>> = Vec::new();
|
||||
let mut attachments: SmallVec<[Option<wgpu::RenderPassColorAttachment>; BUFFERS]> =
|
||||
SmallVec::new();
|
||||
// Vertex coverage and fragment discard can leave alternate pixels untouched.
|
||||
// The unused sink can still skip loading its old contents.
|
||||
let ops = if !pass.sink || pass.viewport.is_some() {
|
||||
@@ -6212,6 +6348,7 @@ mod tests {
|
||||
&[],
|
||||
&draws.layout,
|
||||
&pack,
|
||||
false,
|
||||
)
|
||||
.unwrap_or_else(|| panic!("the pass builds: {notes:?}"));
|
||||
// colortex3 holds (0.75, 0.5, 0.25, 0.5) before the pass.
|
||||
|
||||
@@ -322,7 +322,7 @@ fn check(sampler: &str, stage: Stage, scene: Scene, expected: [u8; 4]) {
|
||||
let hand = matches!(scene, Scene::Terrain { hand: true, .. });
|
||||
let water = matches!(scene, Scene::Terrain { water: true, .. } | Scene::WaterOnly);
|
||||
let inputs=horizon_shaders::GameInputs {
|
||||
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None,item_id_declared:false,far_depth:false,paged_maps:false }),
|
||||
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None,item_id_declared:false,far_depth:false,paged_maps:false,water_mask:false,water_patches:false }),
|
||||
hand:hand.then(crate::gfx::pack_hand::inputs),..Default::default()
|
||||
};
|
||||
let mut post = gpu.load(
|
||||
@@ -720,6 +720,8 @@ fn shadow_depth_map_reads(
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
water_mask: false,
|
||||
water_patches: false,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -47,6 +47,8 @@ fn pack() -> Post {
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
water_mask: false,
|
||||
water_patches: false,
|
||||
declarations: "layout(location=0)in vec3 fixture_position;\n".into(),
|
||||
body: "hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);\
|
||||
hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);\
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! uniform (set 1) that the vertex inputs read. `gbuffers_skytextured` draws the frame's sun and
|
||||
//! moon quads, textured from the shared atlas.
|
||||
|
||||
use std::ops::Range;
|
||||
use std::{ops::Range, sync::Mutex};
|
||||
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
@@ -105,6 +105,8 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
item_id_declared: false,
|
||||
far_depth: true,
|
||||
paged_maps: false,
|
||||
water_mask: false,
|
||||
water_patches: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +135,8 @@ pub fn celestial_inputs() -> horizon_shaders::GbuffersInputs {
|
||||
item_id_declared: false,
|
||||
far_depth: true,
|
||||
paged_maps: false,
|
||||
water_mask: false,
|
||||
water_patches: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,6 +227,7 @@ pub(super) struct PackSky {
|
||||
uniform: wgpu::Buffer,
|
||||
pub layout: wgpu::BindGroupLayout,
|
||||
group: wgpu::BindGroup,
|
||||
celestial_scratch: Mutex<Vec<Celestial>>,
|
||||
/// Vertex ranges of the sky disc, the sunset fan, the stars, the void and the End's fan.
|
||||
ranges: [Range<u32>; 5],
|
||||
}
|
||||
@@ -302,6 +307,7 @@ impl PackSky {
|
||||
uniform,
|
||||
layout,
|
||||
group,
|
||||
celestial_scratch: Mutex::new(Vec::new()),
|
||||
ranges: [sky, sunset, stars, void, end_fan],
|
||||
}
|
||||
}
|
||||
@@ -359,15 +365,21 @@ impl PackSky {
|
||||
.0
|
||||
.iter()
|
||||
.take(CELESTIAL_QUADS);
|
||||
let triangles: Vec<Celestial> = quads
|
||||
.clone()
|
||||
.flat_map(|q| [0, 1, 2, 0, 2, 3].map(|i| q[i]))
|
||||
.map(|v| Celestial {
|
||||
pos: turn.transform_vector3(v.pos.into()).into(),
|
||||
uv: v.uv,
|
||||
color: v.color,
|
||||
})
|
||||
.collect();
|
||||
let mut triangles = self
|
||||
.celestial_scratch
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
triangles.clear();
|
||||
triangles.extend(
|
||||
quads
|
||||
.clone()
|
||||
.flat_map(|q| [0, 1, 2, 0, 2, 3].map(|i| q[i]))
|
||||
.map(|v| Celestial {
|
||||
pos: turn.transform_vector3(v.pos.into()).into(),
|
||||
uv: v.uv,
|
||||
color: v.color,
|
||||
}),
|
||||
);
|
||||
queue.write_buffer(&self.celestial, 0, bytemuck::cast_slice(&triangles));
|
||||
let [sky, sunset, stars, void, fan] = self.ranges.clone();
|
||||
let part = |stage, textured, range| Part {
|
||||
|
||||
@@ -374,6 +374,41 @@ pub const NAMES: &[&str] = &[
|
||||
"horizonDynamicLight13",
|
||||
"horizonDynamicLight14",
|
||||
"horizonDynamicLight15",
|
||||
// Horizon's own: the boats' water patches, for the shadow pass's water (`water_patches`):
|
||||
// how many, then each one's corner and y range, and its two horizontal edges.
|
||||
"horizonWaterPatchCount",
|
||||
"horizonWaterPatch0",
|
||||
"horizonWaterPatch1",
|
||||
"horizonWaterPatch2",
|
||||
"horizonWaterPatch3",
|
||||
"horizonWaterPatch4",
|
||||
"horizonWaterPatch5",
|
||||
"horizonWaterPatch6",
|
||||
"horizonWaterPatch7",
|
||||
"horizonWaterPatch8",
|
||||
"horizonWaterPatch9",
|
||||
"horizonWaterPatch10",
|
||||
"horizonWaterPatch11",
|
||||
"horizonWaterPatch12",
|
||||
"horizonWaterPatch13",
|
||||
"horizonWaterPatch14",
|
||||
"horizonWaterPatch15",
|
||||
"horizonWaterPatch16",
|
||||
"horizonWaterPatch17",
|
||||
"horizonWaterPatch18",
|
||||
"horizonWaterPatch19",
|
||||
"horizonWaterPatch20",
|
||||
"horizonWaterPatch21",
|
||||
"horizonWaterPatch22",
|
||||
"horizonWaterPatch23",
|
||||
"horizonWaterPatch24",
|
||||
"horizonWaterPatch25",
|
||||
"horizonWaterPatch26",
|
||||
"horizonWaterPatch27",
|
||||
"horizonWaterPatch28",
|
||||
"horizonWaterPatch29",
|
||||
"horizonWaterPatch30",
|
||||
"horizonWaterPatch31",
|
||||
"isElytraFlying",
|
||||
"cloudHeight",
|
||||
];
|
||||
@@ -870,6 +905,7 @@ impl FrameUniforms {
|
||||
),
|
||||
};
|
||||
|
||||
let patches = water_patches(frame.entities);
|
||||
let value = |name: &str| -> Value {
|
||||
let v3 = |v: Vec3| Value::f(&v.to_array());
|
||||
let m4 = |m: Mat4| Value::f(&m.to_cols_array());
|
||||
@@ -960,6 +996,11 @@ impl FrameUniforms {
|
||||
"gbufferProjectionInverse" => m4(projection_inverse),
|
||||
"gbufferPreviousModelView" => m4(previous_view),
|
||||
"gbufferPreviousProjection" => m4(previous_projection),
|
||||
"horizonWaterPatchCount" => Value::i(&[patches.len() as i32]),
|
||||
n if water_patch_index(n).is_some() => Value::f(
|
||||
&(water_patch_index(n).and_then(|k| patches.get(k / 2).map(|p| p[k % 2])))
|
||||
.unwrap_or([0.0; 4]),
|
||||
),
|
||||
"horizonDynamicLightCount" => Value::i(&[frame.dynamic_lights.len() as i32]),
|
||||
n if dynamic_light_index(n).is_some() => Value::f(
|
||||
&(dynamic_light_index(n).and_then(|k| frame.dynamic_lights.get(k)))
|
||||
@@ -1847,6 +1888,37 @@ pub(super) mod initial_history_tests {
|
||||
assert_eq!(float(&u, "horizonDynamicLight0")[..4], [0.0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_boats_water_patch_becomes_its_box() {
|
||||
let vertex = |pos: [f32; 3], layer: u32| super::super::Vertex {
|
||||
pos,
|
||||
layer,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
// A patch 1.75 by 1 block, 0.2 thick, turned 30 degrees, at the hull's water line;
|
||||
// a hull quad on each side of it.
|
||||
let (c, s) = (30f32.to_radians().cos(), 30f32.to_radians().sin());
|
||||
let at = |x: f32, y: f32, z: f32| [2.0 + x * c - z * s, y, 3.0 + x * s + z * c];
|
||||
let mask = super::super::WATER_MASK_OVERLAY;
|
||||
let mut quads = vec![vertex([0.0; 3], 0); 4];
|
||||
for y in [-1.2, -1.0] {
|
||||
quads.extend(
|
||||
[(0.0, 0.0), (1.75, 0.0), (1.75, 1.0), (0.0, 1.0)]
|
||||
.map(|(x, z)| vertex(at(x, y, z), mask)),
|
||||
);
|
||||
}
|
||||
quads.extend(vec![vertex([5.0; 3], 0); 4]);
|
||||
let boxes = water_patches(&quads);
|
||||
assert_eq!(boxes.len(), 1);
|
||||
let [[x, z, low, high], [ax, az, bx, bz]] = boxes[0];
|
||||
let close = |a: f32, b: f32| (a - b).abs() < 1e-4;
|
||||
assert!(close(x, 2.0) && close(z, 3.0) && close(low, -1.2) && close(high, -1.0));
|
||||
// The edges: the 1-block one first (nearer), then the 1.75-block one.
|
||||
assert!(close(ax, -s) && close(az, c), "{boxes:?}");
|
||||
assert!(close(bx, 1.75 * c) && close(bz, 1.75 * s), "{boxes:?}");
|
||||
assert!(water_patches(&quads[..4]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environmental_smoothing_starts_at_the_first_inputs_without_resetting_each_frame() {
|
||||
let hud = HudBatch::new([800, 450]);
|
||||
@@ -2642,6 +2714,67 @@ pub(super) mod initial_history_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `horizonWaterPatch<k>`'s `k`: patch `k / 2`, its corner and y range (even) or edges (odd).
|
||||
fn water_patch_index(name: &str) -> Option<usize> {
|
||||
let k: usize = name.strip_prefix("horizonWaterPatch")?.parse().ok()?;
|
||||
(k < 2 * horizon_shaders::WATER_PATCHES).then_some(k)
|
||||
}
|
||||
|
||||
/// The boats' water patches among the frame's entity quads (`WATER_MASK_OVERLAY`), the first
|
||||
/// `WATER_PATCHES`, each as a camera-relative box: a corner's x and z and the patch's lowest and
|
||||
/// highest y, then its two horizontal edges from that corner. A boat rocks a little: its patch's
|
||||
/// corners are told apart in x and z within 2 cm.
|
||||
pub(super) fn water_patches(entities: &[super::Vertex]) -> Vec<[[f32; 4]; 2]> {
|
||||
let mut out = Vec::new();
|
||||
let mut points: Vec<[f32; 3]> = Vec::new();
|
||||
let flush = |points: &mut Vec<[f32; 3]>, out: &mut Vec<[[f32; 4]; 2]>| {
|
||||
if let Some(b) = patch_box(points)
|
||||
&& out.len() < horizon_shaders::WATER_PATCHES
|
||||
{
|
||||
out.push(b);
|
||||
}
|
||||
points.clear();
|
||||
};
|
||||
for quad in entities.as_chunks::<4>().0 {
|
||||
if quad[0].layer & 0xffff == super::WATER_MASK_OVERLAY {
|
||||
points.extend(quad.iter().map(|v| v.pos));
|
||||
} else if !points.is_empty() {
|
||||
flush(&mut points, &mut out);
|
||||
}
|
||||
}
|
||||
flush(&mut points, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// A water patch's box (`water_patches`) from its corners.
|
||||
fn patch_box(points: &[[f32; 3]]) -> Option<[[f32; 4]; 2]> {
|
||||
let first = points.first()?;
|
||||
let (low, high) = points
|
||||
.iter()
|
||||
.fold((f32::MAX, f32::MIN), |(l, h), p| (l.min(p[1]), h.max(p[1])));
|
||||
let mut corners: Vec<[f32; 2]> = Vec::new();
|
||||
for p in points {
|
||||
let c = [p[0], p[2]];
|
||||
if !corners
|
||||
.iter()
|
||||
.any(|q| (q[0] - c[0]).abs() < 0.02 && (q[1] - c[1]).abs() < 0.02)
|
||||
{
|
||||
corners.push(c);
|
||||
}
|
||||
}
|
||||
let origin = [first[0], first[2]];
|
||||
let mut others: Vec<[f32; 2]> = corners.into_iter().filter(|c| *c != origin).collect();
|
||||
if others.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let distance = |c: &[f32; 2]| (c[0] - origin[0]).powi(2) + (c[1] - origin[1]).powi(2);
|
||||
others.sort_by(|a, b| distance(a).total_cmp(&distance(b)));
|
||||
// The two nearest corners are the edges' ends; the farthest is across.
|
||||
let edge = |c: [f32; 2]| [c[0] - origin[0], c[1] - origin[1]];
|
||||
let (a, b) = (edge(others[0]), edge(others[1]));
|
||||
Some([[origin[0], origin[1], low, high], [a[0], a[1], b[0], b[1]]])
|
||||
}
|
||||
|
||||
/// `horizonDynamicLight<k>`'s `k`, for the lights `Frame::dynamic_lights` holds.
|
||||
fn dynamic_light_index(name: &str) -> Option<usize> {
|
||||
let k: usize = name.strip_prefix("horizonDynamicLight")?.parse().ok()?;
|
||||
|
||||
@@ -47,6 +47,8 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
water_mask: false,
|
||||
water_patches: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,11 @@
|
||||
#[derive(Default)]
|
||||
pub struct Readback {
|
||||
texture: Option<wgpu::Texture>,
|
||||
/// The 8-bit texture `finish` put the frame on, where the swapchain image can't be read
|
||||
/// back; `present` then draws it instead of `texture`.
|
||||
finished: Option<wgpu::Texture>,
|
||||
blitter: Option<(wgpu::TextureFormat, wgpu::util::TextureBlitter)>,
|
||||
/// A frame drew into `texture` and still has to reach the swapchain.
|
||||
/// A frame drew into `texture` (or `finished`) and still has to reach the swapchain.
|
||||
drawn: bool,
|
||||
}
|
||||
|
||||
@@ -53,7 +56,47 @@ impl Readback {
|
||||
self.texture.clone()
|
||||
}
|
||||
|
||||
/// Draws the frame `target` gave onto the swapchain image `surface`; nothing
|
||||
/// The frame `target` gave, finished onto 8 bits of the swapchain image `surface`'s format,
|
||||
/// clipped and rounded as an 8-bit frame holds it, for what draws over it from here on
|
||||
/// (`Renderer::overlay_format`): `surface` itself, or, for a screenshot it can't be copied
|
||||
/// from, a texture of its own that `present` then draws there.
|
||||
pub fn finish(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
surface: &wgpu::Texture,
|
||||
screenshot: bool,
|
||||
) -> Option<wgpu::Texture> {
|
||||
let frame = self.texture.clone().filter(|_| self.drawn)?;
|
||||
let format = surface.format().remove_srgb_suffix();
|
||||
let target = if !screenshot || surface.usage().contains(wgpu::TextureUsages::COPY_SRC) {
|
||||
self.drawn = false;
|
||||
surface.clone()
|
||||
} else {
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Horizon finished frame"),
|
||||
size: frame.size(),
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::COPY_SRC
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
});
|
||||
self.finished = Some(texture.clone());
|
||||
texture
|
||||
};
|
||||
let view = target.create_view(&wgpu::TextureViewDescriptor {
|
||||
format: Some(format),
|
||||
..Default::default()
|
||||
});
|
||||
self.blit(device, encoder, &frame, &view, format);
|
||||
Some(target)
|
||||
}
|
||||
|
||||
/// Draws the frame `target` (or `finish`) gave onto the swapchain image `surface`; nothing
|
||||
/// when this frame drew straight into it.
|
||||
pub fn present(
|
||||
&mut self,
|
||||
@@ -61,10 +104,11 @@ impl Readback {
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
surface: &wgpu::Texture,
|
||||
) {
|
||||
let finished = self.finished.take();
|
||||
if !std::mem::take(&mut self.drawn) {
|
||||
return;
|
||||
}
|
||||
let Some(texture) = self.texture.clone() else {
|
||||
let Some(texture) = finished.or_else(|| self.texture.clone()) else {
|
||||
return;
|
||||
};
|
||||
self.draw_onto(device, encoder, &texture, surface);
|
||||
@@ -346,4 +390,68 @@ mod tests {
|
||||
let float = wgpu::TextureFormat::Rgba16Float;
|
||||
reaches_an_8_bit_swapchain(&device, &queue, float, color, [0, 51, 255, 255]);
|
||||
}
|
||||
|
||||
/// `finish` puts a float frame on 8 bits before the overlays draw: on the swapchain image
|
||||
/// itself, which `present` then leaves alone, or, for a screenshot of an image that can't
|
||||
/// be copied from, on a texture of its own that the screenshot reads and `present` draws.
|
||||
#[test]
|
||||
fn a_float_frame_finishes_on_8_bits_before_the_overlays() {
|
||||
let Some((device, queue)) = device() else {
|
||||
return;
|
||||
};
|
||||
let float = wgpu::TextureFormat::Rgba16Float;
|
||||
let color = wgpu::Color {
|
||||
r: 1.5,
|
||||
g: 0.2,
|
||||
b: -0.5,
|
||||
a: 1.0,
|
||||
};
|
||||
let check = texture(
|
||||
&device,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let blitter = wgpu::util::TextureBlitter::new(&device, F);
|
||||
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
|
||||
for (usage, screenshot) in [
|
||||
(wgpu::TextureUsages::COPY_SRC, false),
|
||||
(wgpu::TextureUsages::COPY_SRC, true),
|
||||
(wgpu::TextureUsages::empty(), false),
|
||||
(wgpu::TextureUsages::empty(), true),
|
||||
] {
|
||||
let surface = texture(
|
||||
&device,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| usage,
|
||||
);
|
||||
let mut readback = Readback::default();
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let frame = readback
|
||||
.target(&device, &surface, float, screenshot)
|
||||
.unwrap();
|
||||
clear(&mut encoder, &frame, color);
|
||||
let finished = readback
|
||||
.finish(&device, &mut encoder, &surface, screenshot)
|
||||
.unwrap();
|
||||
assert_eq!(finished.format(), F);
|
||||
let own = screenshot && usage.is_empty();
|
||||
assert_eq!(finished == surface, !own, "{usage:?} {screenshot}");
|
||||
assert_eq!(readback.drawn, own);
|
||||
let shot = screenshot
|
||||
.then(|| crate::gfx::copy_frame(&device, &mut encoder, &finished, [70, 3]));
|
||||
readback.present(&device, &mut encoder, &surface);
|
||||
blitter.copy(&device, &mut encoder, &view(&surface), &view(&check));
|
||||
let shown = crate::gfx::copy_frame(&device, &mut encoder, &check, [70, 3]);
|
||||
queue.submit([encoder.finish()]);
|
||||
if let Some(error) = pollster::block_on(scope.pop()) {
|
||||
panic!("{usage:?} {screenshot}: {error}");
|
||||
}
|
||||
assert_eq!(last_pixel(&device, &shown), [0, 51, 255, 255]);
|
||||
if let Some(shot) = shot {
|
||||
assert_eq!(last_pixel(&device, &shot), [0, 51, 255, 255]);
|
||||
}
|
||||
assert!(!readback.drawn && readback.finished.is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,28 +19,41 @@ 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; 3] = [AtomicU32::new(0), AtomicU32::new(0), AtomicU32::new(0)];
|
||||
static REPORTED: [AtomicU32; 2] = [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, black) =
|
||||
reported.map_or((0, 0, 0), |r| (r.paper_white, r.peak, r.black));
|
||||
let (paper_white, peak) = reported.map_or((0, 0), |r| (r.paper_white, r.peak));
|
||||
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, black] = REPORTED.each_ref().map(|n| n.load(Ordering::Relaxed));
|
||||
(peak > 0).then_some(Reported {
|
||||
paper_white,
|
||||
peak,
|
||||
black,
|
||||
})
|
||||
let [paper_white, peak] = REPORTED.each_ref().map(|n| n.load(Ordering::Relaxed));
|
||||
(peak > 0).then_some(Reported { paper_white, peak })
|
||||
}
|
||||
|
||||
/// The top of Peak Brightness's scale: `peak_nits_at(PEAK_STEPS)` is PQ's 10 000 nits.
|
||||
pub const PEAK_STEPS: u32 = 52;
|
||||
/// Peak Brightness's value at `step` of its scale, the slider's and calibration's: 200 to
|
||||
/// 2000 nits by 50, where most displays' peaks are, then up to PQ's 10 000 by 500.
|
||||
pub fn peak_nits_at(step: u32) -> u32 {
|
||||
match step.min(PEAK_STEPS) {
|
||||
s @ 0..=36 => 200 + s * 50,
|
||||
s => 2000 + (s - 36) * 500,
|
||||
}
|
||||
}
|
||||
/// Where `nits` sits on Peak Brightness's scale ([`peak_nits_at`]), between steps for a value
|
||||
/// off it.
|
||||
pub fn peak_step_of(nits: f32) -> f32 {
|
||||
let step = if nits <= 2000.0 {
|
||||
(nits - 200.0) / 50.0
|
||||
} else {
|
||||
36.0 + (nits - 2000.0) / 500.0
|
||||
};
|
||||
step.clamp(0.0, PEAK_STEPS as f32)
|
||||
}
|
||||
|
||||
/// Simple-level presets. `Custom` leaves every option as the player set it.
|
||||
@@ -508,6 +521,61 @@ pub fn hdr_offered() -> bool {
|
||||
HDR_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
static SYSTEM_HDR: AtomicU32 = AtomicU32::new(0);
|
||||
/// The window's current system HDR state, independent of swapchain availability.
|
||||
pub fn report_system_hdr(on: Option<bool>) {
|
||||
SYSTEM_HDR.store(on.map_or(0, |on| if on { 2 } else { 1 }), Ordering::Relaxed);
|
||||
}
|
||||
pub fn system_hdr() -> Option<bool> {
|
||||
match SYSTEM_HDR.load(Ordering::Relaxed) {
|
||||
1 => Some(false),
|
||||
2 => Some(true),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum HdrMode {
|
||||
#[default]
|
||||
Off,
|
||||
On,
|
||||
Auto,
|
||||
}
|
||||
impl HdrMode {
|
||||
pub const ALL: [Self; 3] = [Self::Off, Self::On, Self::Auto];
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
Self::Off => "false",
|
||||
Self::On => "true",
|
||||
Self::Auto => "auto",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Off => "Off",
|
||||
Self::On => "On",
|
||||
Self::Auto => "Auto",
|
||||
}
|
||||
}
|
||||
fn parse(value: &str, default: Self) -> Self {
|
||||
match value {
|
||||
"false" | "off" => Self::Off,
|
||||
"true" | "on" => Self::On,
|
||||
"auto" => Self::Auto,
|
||||
_ => default,
|
||||
}
|
||||
}
|
||||
/// On retains the old switch's intent even before a surface is opened; the renderer
|
||||
/// still gates output on availability. Auto requires both availability and a live report.
|
||||
fn wanted(self, offered: bool, system: Option<bool>) -> bool {
|
||||
match self {
|
||||
Self::Off => false,
|
||||
Self::On => true,
|
||||
Self::Auto => offered && system == Some(true),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static TRANSLUCENT_BATCHING_OFFERED: std::sync::atomic::AtomicBool =
|
||||
std::sync::atomic::AtomicBool::new(false);
|
||||
/// Whether the device can batch translucent quads (`Graphics::translucent_batching`): set when
|
||||
@@ -783,16 +851,15 @@ pub struct Graphics {
|
||||
/// Frame generation (Horizon setting), off by default.
|
||||
pub frame_generation: FrameGeneration,
|
||||
/// HDR output (HDR10, or scRGB), where the display path offers it.
|
||||
pub hdr: bool,
|
||||
pub hdr: HdrMode,
|
||||
/// HDR paper white (where SDR white sits), in nits; 0 is Auto: the desktop's SDR
|
||||
/// white, as the system reports it ([`Graphics::paper_white_nits`]).
|
||||
pub hdr_paper_white: u32,
|
||||
/// HDR peak brightness the world's highlights reach, in nits; 0 is Auto:
|
||||
/// HDR peak brightness the world's highlights reach, in nits (200..=10 000); 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 is Auto:
|
||||
/// what the system reports, else 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, the default,
|
||||
/// lifts nothing. 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.
|
||||
@@ -870,7 +937,7 @@ impl Default for Graphics {
|
||||
upscaling_sharpness: 85,
|
||||
screenshot_quality: 1,
|
||||
frame_generation: FrameGeneration::Off,
|
||||
hdr: false,
|
||||
hdr: HdrMode::Off,
|
||||
hdr_paper_white: 0,
|
||||
hdr_peak: 0,
|
||||
hdr_black_level: 0,
|
||||
@@ -902,8 +969,8 @@ impl Graphics {
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
if fs::remove_file(dir.join(HDR_MARKER)).is_ok() && g.hdr {
|
||||
g.hdr = false;
|
||||
if fs::remove_file(dir.join(HDR_MARKER)).is_ok() && g.hdr != HdrMode::Off {
|
||||
g.hdr = HdrMode::Off;
|
||||
g.save(dir);
|
||||
log::warn!(
|
||||
"the last start ended before HDR output showed a frame; HDR is off now (graphics option hdr, in {FILE})"
|
||||
@@ -1000,7 +1067,7 @@ impl Graphics {
|
||||
.unwrap_or(1)
|
||||
}
|
||||
"frameGeneration" => g.frame_generation = FrameGeneration::parse(value),
|
||||
"hdr" => g.hdr = flag(g.hdr),
|
||||
"hdr" => g.hdr = HdrMode::parse(value, g.hdr),
|
||||
"hdrLighting" => g.hdr_lighting = flag(g.hdr_lighting),
|
||||
"hdrPaperWhite" => {
|
||||
g.hdr_paper_white = match number(g.hdr_paper_white) {
|
||||
@@ -1011,7 +1078,7 @@ impl Graphics {
|
||||
"hdrPeak" => {
|
||||
g.hdr_peak = match number(g.hdr_peak) {
|
||||
0 => 0,
|
||||
n => n.clamp(200, 2000),
|
||||
n => n.clamp(200, 10_000),
|
||||
}
|
||||
}
|
||||
"hdrBlackLevel" => g.hdr_black_level = number(g.hdr_black_level).min(100),
|
||||
@@ -1082,7 +1149,7 @@ impl Graphics {
|
||||
self.upscaling_sharpness,
|
||||
self.screenshot_quality,
|
||||
self.frame_generation.name(),
|
||||
bool_text(self.hdr),
|
||||
self.hdr.key(),
|
||||
self.hdr_paper_white,
|
||||
self.hdr_peak,
|
||||
self.hdr_black_level,
|
||||
@@ -1111,10 +1178,14 @@ impl Graphics {
|
||||
pub fn save(&self, dir: &Path) {
|
||||
let _ = fs::write(dir.join(FILE), self.serialize());
|
||||
}
|
||||
/// The single decision shared by rendering and the HDR settings page.
|
||||
pub fn hdr_wanted(&self) -> bool {
|
||||
self.hdr.wanted(hdr_offered(), system_hdr())
|
||||
}
|
||||
/// Whether a renderer made now draws its frame in 16 bits (`Renderer::new`'s `wide_frame`):
|
||||
/// HDR on, with Smooth Gradients.
|
||||
pub fn wide_frame(&self) -> bool {
|
||||
self.hdr && self.hdr_smooth_gradients
|
||||
self.hdr_wanted() && self.hdr_smooth_gradients
|
||||
}
|
||||
/// Paper white, in nits: the player's, or on Auto the desktop's SDR white as the
|
||||
/// system reports it, else 203 (BT.2408's reference white).
|
||||
@@ -1132,13 +1203,10 @@ impl Graphics {
|
||||
n => n,
|
||||
}
|
||||
}
|
||||
/// The black level, in nits: the player's, or on Auto what the system reports, else 0.
|
||||
/// The black level, in nits. Not the system's: files from before saved 0 for that
|
||||
/// (Auto), and a reported black would lift every display's shadows by default.
|
||||
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
|
||||
self.hdr_black_level as f32 / 100.0
|
||||
}
|
||||
/// Worker threads for chunk generation and meshing.
|
||||
pub fn worker_threads(&self) -> usize {
|
||||
@@ -1207,6 +1275,53 @@ pub fn apply_preset(preset: Preset, s: &mut crate::settings::Settings) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hdr_modes_keep_boolean_files_and_round_trip_auto() {
|
||||
assert_eq!(Graphics::parse("").hdr, HdrMode::Off);
|
||||
for (value, expected) in [
|
||||
("true", HdrMode::On),
|
||||
("false", HdrMode::Off),
|
||||
("on", HdrMode::On),
|
||||
("off", HdrMode::Off),
|
||||
("auto", HdrMode::Auto),
|
||||
("unknown", HdrMode::Off),
|
||||
] {
|
||||
let g = Graphics::parse(&format!("hdr:{value}\n"));
|
||||
assert_eq!(g.hdr, expected, "{value}");
|
||||
assert_eq!(Graphics::parse(&g.serialize()).hdr, expected);
|
||||
let saved = g.serialize();
|
||||
let value = saved.lines().find_map(|l| l.strip_prefix("hdr:")).unwrap();
|
||||
// The old boolean parser keeps its Off default for an unknown value.
|
||||
assert_eq!(value == "true", expected == HdrMode::On);
|
||||
}
|
||||
assert_eq!(Graphics::parse("hdr:true\nhdr:unknown\n").hdr, HdrMode::On);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_auto_needs_a_positive_system_report_and_an_output() {
|
||||
for (offered, system, expected) in [
|
||||
(false, None, false),
|
||||
(false, Some(false), false),
|
||||
(false, Some(true), false),
|
||||
(true, None, false),
|
||||
(true, Some(false), false),
|
||||
(true, Some(true), true),
|
||||
] {
|
||||
assert!(!HdrMode::Off.wanted(offered, system));
|
||||
assert!(HdrMode::On.wanted(offered, system));
|
||||
assert_eq!(
|
||||
HdrMode::Auto.wanted(offered, system),
|
||||
expected,
|
||||
"{offered} {system:?}"
|
||||
);
|
||||
}
|
||||
let readings = [Some(false), Some(true), Some(false), None, Some(true)];
|
||||
assert_eq!(
|
||||
readings.map(|r| HdrMode::Auto.wanted(true, r)),
|
||||
[false, true, false, false, true]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_api_choice_matches_the_system() {
|
||||
let choices = GraphicsApi::choices();
|
||||
@@ -1267,7 +1382,7 @@ mod tests {
|
||||
upscaling_sharpness: 40,
|
||||
screenshot_quality: 4,
|
||||
frame_generation: FrameGeneration::Fsr31,
|
||||
hdr: true,
|
||||
hdr: HdrMode::On,
|
||||
hdr_paper_white: 240,
|
||||
hdr_peak: 1400,
|
||||
hdr_black_level: 12,
|
||||
@@ -1352,9 +1467,36 @@ mod tests {
|
||||
};
|
||||
assert_eq!(Graphics::parse(&g.serialize()).gpu_culling, mode);
|
||||
}
|
||||
// 0 is Auto; anything else is a peak the slider reaches.
|
||||
assert_eq!(Graphics::parse("hdrPeak:0\n").hdr_peak, 0);
|
||||
assert_eq!(Graphics::parse("hdrPeak:50\n").hdr_peak, 200);
|
||||
// 0 is Auto; anything else is a peak, up to PQ's 10 000 nits. Older files' values
|
||||
// (up to 2000) read as they were saved.
|
||||
for (file, peak) in [
|
||||
("hdrPeak:0\n", 0),
|
||||
("hdrPeak:50\n", 200),
|
||||
("hdrPeak:1234\n", 1234),
|
||||
("hdrPeak:2000\n", 2000),
|
||||
("hdrPeak:3000\n", 3000),
|
||||
("hdrPeak:10000\n", 10_000),
|
||||
("hdrPeak:25000\n", 10_000),
|
||||
] {
|
||||
let g = Graphics::parse(file);
|
||||
assert_eq!(g.hdr_peak, peak, "{file}");
|
||||
assert_eq!(Graphics::parse(&g.serialize()).hdr_peak, peak, "{file}");
|
||||
}
|
||||
assert_eq!(Graphics::parse("hdrPeak:3000\n").peak_nits(), 3000);
|
||||
// The black level is what's saved, in hundredths of a nit: 0 (Auto in older files)
|
||||
// lifts nothing, and the most is a nit.
|
||||
for (file, hundredths, nits) in [
|
||||
("", 0, 0.0),
|
||||
("hdrBlackLevel:0\n", 0, 0.0),
|
||||
("hdrBlackLevel:5\n", 5, 0.05),
|
||||
("hdrBlackLevel:100\n", 100, 1.0),
|
||||
("hdrBlackLevel:250\n", 100, 1.0),
|
||||
] {
|
||||
let g = Graphics::parse(file);
|
||||
assert_eq!(g.hdr_black_level, hundredths, "{file}");
|
||||
assert_eq!(g.black_level_nits(), nits, "{file}");
|
||||
assert_eq!(Graphics::parse(&g.serialize()), g, "{file}");
|
||||
}
|
||||
assert_eq!(Graphics::parse("hdrPaperWhite:0\n").hdr_paper_white, 0);
|
||||
assert_eq!(Graphics::parse("hdrPaperWhite:5\n").hdr_paper_white, 10);
|
||||
// Only the button's choices.
|
||||
@@ -1385,6 +1527,22 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peak_scale_steps_by_50_to_2000_then_by_500_to_10000() {
|
||||
let scale: Vec<u32> = (0..=PEAK_STEPS).map(peak_nits_at).collect();
|
||||
let fine: Vec<u32> = (200..=2000).step_by(50).collect();
|
||||
let coarse: Vec<u32> = (2500..=10_000).step_by(500).collect();
|
||||
assert_eq!(scale, [fine, coarse].concat());
|
||||
for (step, &nits) in scale.iter().enumerate() {
|
||||
assert_eq!(peak_step_of(nits as f32), step as f32);
|
||||
}
|
||||
// Off the scale, between its neighbours; beyond it, at its ends.
|
||||
assert_eq!(peak_step_of(1225.0), 20.5);
|
||||
assert_eq!(peak_step_of(3250.0), 38.5);
|
||||
assert_eq!(peak_step_of(100.0), 0.0);
|
||||
assert_eq!(peak_step_of(20_000.0), PEAK_STEPS as f32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaf_culling_round_trips_its_stable_names() {
|
||||
assert_eq!(Graphics::default().leaf_culling, LeafCullingMode::Off);
|
||||
|
||||
@@ -189,11 +189,23 @@ fn tick(batch: &mut HudBatch, atlas: &HudAtlas, x: f32, y: f32, color: u32) {
|
||||
/// A flat button with its label centred: Undo, Calibrate..., and Done in the
|
||||
/// violet (`primary`). Pointed at or focused, vanilla's white edge.
|
||||
pub fn button(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
rect: [f32; 4],
|
||||
label: &Text,
|
||||
flags: (bool, bool, bool),
|
||||
) {
|
||||
button_with_room(batch, atlas, rect, label, flags, 0.0);
|
||||
}
|
||||
/// A button whose label leaves `room` at its left, for a controller's glyph drawn there
|
||||
/// (`widgets::action_glyph_room`).
|
||||
pub fn button_with_room(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
rect: [f32; 4],
|
||||
label: &Text,
|
||||
(hover, live, primary): (bool, bool, bool),
|
||||
room: f32,
|
||||
) {
|
||||
let [x, y, w, h] = rect;
|
||||
let face = match (live, primary, hover) {
|
||||
@@ -209,7 +221,15 @@ pub fn button(
|
||||
}
|
||||
let color = if live { WHITE } else { MUTED };
|
||||
let ty = y + ((h - 8.0) / 2.0).floor();
|
||||
scrolling_label(batch, atlas, label, [x + 2.0, ty, w - 4.0], (y, h), color);
|
||||
let left = x + 2.0 + room;
|
||||
scrolling_label(
|
||||
batch,
|
||||
atlas,
|
||||
label,
|
||||
[left, ty, w - 4.0 - room],
|
||||
(y, h),
|
||||
color,
|
||||
);
|
||||
}
|
||||
/// A widget label of `[left, y, width]` inside the widget's `(top, height)`: centred when it fits,
|
||||
/// else slid (vanilla `AbstractWidget.extractScrollingStringOverContents`) and clipped to the box.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! and what they do live here.
|
||||
use crate::{
|
||||
graphics::{
|
||||
EntityCullingMode, FarDetail, FrameGeneration, GpuCullingMode, GraphicsApi,
|
||||
EntityCullingMode, FarDetail, FrameGeneration, GpuCullingMode, GraphicsApi, HdrMode,
|
||||
LeafCullingMode, Overlay, Pacing, ParticleCullingMode, PresentMode, Preset, Upscaling,
|
||||
},
|
||||
graphics_style::Cost,
|
||||
@@ -598,7 +598,7 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
h(
|
||||
H::HdrBlackLevel,
|
||||
None,
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.",
|
||||
"Lifts shadows onto your display's darkest black. 0: true black, no lift.",
|
||||
),
|
||||
h(
|
||||
H::HdrPeak,
|
||||
@@ -608,7 +608,7 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
h(
|
||||
H::HdrPaperWhite,
|
||||
None,
|
||||
"How bright white paper and menus look. Auto: your desktop's white.",
|
||||
"How bright white looks in the world. Auto: your desktop's white.",
|
||||
),
|
||||
h(
|
||||
H::HdrUiBrightness,
|
||||
@@ -780,6 +780,9 @@ fn labelled_percent(caption: &str, percent: u32) -> Text {
|
||||
)
|
||||
}
|
||||
|
||||
/// Peak Brightness's slider: Auto, then each step of the peak's scale.
|
||||
const PEAK_POSITIONS: u32 = crate::graphics::PEAK_STEPS + 1;
|
||||
|
||||
impl H {
|
||||
fn slider(self, s: &Settings) -> Option<f32> {
|
||||
let g = &s.horizon;
|
||||
@@ -805,10 +808,10 @@ impl H {
|
||||
H::UpscalingSharpness => Some(g.upscaling_sharpness as f32 / 100.0),
|
||||
// Auto, then 10..480 by 10.
|
||||
H::HdrPaperWhite => Some(g.hdr_paper_white as f32 / 480.0),
|
||||
// Auto, then 200..2000 by 50.
|
||||
// Auto, then the peak's scale.
|
||||
H::HdrPeak => Some(match g.hdr_peak {
|
||||
0 => 0.0,
|
||||
n => (n as f32 - 150.0) / 1850.0,
|
||||
n => (1.0 + crate::graphics::peak_step_of(n as f32)) / PEAK_POSITIONS as f32,
|
||||
}),
|
||||
H::HdrBlackLevel => Some(g.hdr_black_level as f32 / 100.0),
|
||||
H::HdrUiBrightness => Some((g.hdr_ui_brightness as f32 - 25.0) / 75.0),
|
||||
@@ -823,8 +826,8 @@ impl H {
|
||||
H::Workers => Some(max_workers()),
|
||||
H::ResolutionScale => Some(30),
|
||||
H::UpscalingSharpness => Some(20),
|
||||
H::HdrPaperWhite => Some(41),
|
||||
H::HdrPeak => Some(37),
|
||||
H::HdrPaperWhite => Some(48),
|
||||
H::HdrPeak => Some(PEAK_POSITIONS),
|
||||
H::HdrBlackLevel => Some(100),
|
||||
H::HdrUiBrightness => Some(15),
|
||||
_ => None,
|
||||
@@ -924,7 +927,7 @@ impl H {
|
||||
labelled("Screenshot Quality", format!("{}x", g.screenshot_quality))
|
||||
}
|
||||
H::FrameGeneration => labelled("Frame Generation", g.frame_generation.label()),
|
||||
H::Hdr => labelled("HDR", on_off(g.hdr)),
|
||||
H::Hdr => labelled("HDR", g.hdr.label()),
|
||||
H::HdrCalibrate => Text::literal(lang::get(match setup(s) {
|
||||
Setup::Unknown => "Calibrate...",
|
||||
Setup::Automatic => "Calibrate Anyway...",
|
||||
@@ -941,13 +944,7 @@ impl H {
|
||||
0 => labelled("Peak Brightness", lang::tr("Auto (%s)", &[&g.peak_nits()])),
|
||||
n => labelled("Peak Brightness", n.to_string()),
|
||||
},
|
||||
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::HdrBlackLevel => labelled("Black Level", format!("{:.2}", g.black_level_nits())),
|
||||
H::HdrUiBrightness => labelled_percent("UI Brightness", g.hdr_ui_brightness),
|
||||
H::HdrLighting => labelled("HDR Lighting", on_off(g.hdr_lighting)),
|
||||
H::HdrKey => {
|
||||
@@ -995,9 +992,9 @@ impl H {
|
||||
H::UpscalingSharpness => g.upscaling_sharpness = (v * 20.0).round() as u32 * 5,
|
||||
H::HdrPaperWhite => g.hdr_paper_white = (v * 48.0).round() as u32 * 10,
|
||||
H::HdrPeak => {
|
||||
g.hdr_peak = match (v * 37.0).round() as u32 {
|
||||
g.hdr_peak = match (v * PEAK_POSITIONS as f32).round() as u32 {
|
||||
0 => 0,
|
||||
step => 150 + step * 50,
|
||||
step => crate::graphics::peak_nits_at(step - 1),
|
||||
}
|
||||
}
|
||||
H::HdrBlackLevel => g.hdr_black_level = (v * 100.0).round() as u32,
|
||||
@@ -1079,7 +1076,12 @@ impl H {
|
||||
H::FrameGeneration => {
|
||||
g.frame_generation = step(&FrameGeneration::ALL, g.frame_generation, back)
|
||||
}
|
||||
H::Hdr => g.hdr = !g.hdr,
|
||||
H::Hdr => {
|
||||
g.hdr = step(&HdrMode::ALL, g.hdr, back);
|
||||
if g.hdr == HdrMode::On && !crate::graphics::hdr_offered() {
|
||||
g.hdr = step(&HdrMode::ALL, g.hdr, back);
|
||||
}
|
||||
}
|
||||
H::HdrLighting => g.hdr_lighting = !g.hdr_lighting,
|
||||
H::BatteryMode => g.battery = !g.battery,
|
||||
H::ShadowCache => g.shadow_cache = !g.shadow_cache,
|
||||
@@ -1112,11 +1114,9 @@ impl Item {
|
||||
fn available(self) -> bool {
|
||||
!matches!(self, Item::Vanilla(o) if !o.available())
|
||||
}
|
||||
/// Why a control can't be used now, when it can't: HDR's page works only where
|
||||
/// 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; Translucent Batching switches only where the device can batch
|
||||
/// Why a control can't be used now: HDR's On requires an offered output, while Auto
|
||||
/// can also be chosen on a reporting SDR display. Brightness controls need HDR output
|
||||
/// active; saved values wait for it. Translucent Batching switches where the device can batch
|
||||
/// (`graphics::translucent_batching_offered`), its saved value waiting for one that can,
|
||||
/// Smooth Gradients only where it can draw the 16-bit frame, the same way, and GPU Culling
|
||||
/// only where it is offered (`graphics::gpu_culling_offer`), with the reason it isn't.
|
||||
@@ -1126,8 +1126,7 @@ impl Item {
|
||||
}
|
||||
match self {
|
||||
Item::Horizon(
|
||||
H::Hdr
|
||||
| H::HdrCalibrate
|
||||
H::HdrCalibrate
|
||||
| H::HdrBlackLevel
|
||||
| H::HdrPeak
|
||||
| H::HdrPaperWhite
|
||||
@@ -1150,7 +1149,7 @@ impl Item {
|
||||
| H::HdrUiBrightness
|
||||
| H::HdrLighting
|
||||
| H::HdrSmoothGradients,
|
||||
) if !s.horizon.hdr => Some("Turn HDR on to use it."),
|
||||
) if !s.horizon.hdr_wanted() => Some("Turn HDR on to use it."),
|
||||
Item::Horizon(H::TranslucentBatching)
|
||||
if !crate::graphics::translucent_batching_offered() =>
|
||||
{
|
||||
@@ -1197,7 +1196,6 @@ impl Item {
|
||||
Item::Horizon(H::Weather) => g.weather,
|
||||
Item::Horizon(H::AnimatedTextures) => g.animated_textures,
|
||||
Item::Horizon(H::DitherCutoutMips) => g.dither_cutout_mips,
|
||||
Item::Horizon(H::Hdr) => g.hdr,
|
||||
Item::Horizon(H::HdrLighting) => g.hdr_lighting,
|
||||
Item::Horizon(H::BatteryMode) => g.battery,
|
||||
Item::Horizon(H::ShadowCache) => g.shadow_cache,
|
||||
@@ -1443,6 +1441,7 @@ impl Screen for GraphicsScreen {
|
||||
use crate::graphics as g;
|
||||
let offers = (
|
||||
g::hdr_offered(),
|
||||
g::system_hdr(),
|
||||
g::smooth_gradients_offered(),
|
||||
g::translucent_batching_offered(),
|
||||
g::gpu_culling_offer(),
|
||||
@@ -1467,6 +1466,8 @@ impl Screen for GraphicsScreen {
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
listed: true,
|
||||
heading: None,
|
||||
};
|
||||
rows().into_iter().map(sheet).collect()
|
||||
}
|
||||
@@ -1514,7 +1515,7 @@ impl Screen for GraphicsScreen {
|
||||
/// settings and the vanilla options on its rows. Not the other options (the narrator, or
|
||||
/// one `horizon-ctl options-set` changed meanwhile), nor fullscreen, which F11 and the
|
||||
/// window manager change.
|
||||
fn undo(&mut self, s: &mut Settings, b: &Settings) {
|
||||
fn undo(&mut self, s: &mut Settings, b: &Settings) -> Vec<Output> {
|
||||
s.horizon = b.horizon.clone();
|
||||
s.render_distance = b.render_distance;
|
||||
s.simulation_distance = b.simulation_distance;
|
||||
@@ -1551,6 +1552,7 @@ impl Screen for GraphicsScreen {
|
||||
}
|
||||
}
|
||||
s.file.fullscreen_resolution = b.file.fullscreen_resolution.clone();
|
||||
Vec::new()
|
||||
}
|
||||
/// Calibration can change what Undo puts back, as a row does.
|
||||
fn changes(&self, outputs: &[Output]) -> bool {
|
||||
@@ -1767,14 +1769,14 @@ mod tests {
|
||||
// Calibration counts as a change too.
|
||||
let _offer = HDR_OFFER.lock().unwrap_or_else(|e| e.into_inner());
|
||||
crate::graphics::offer_hdr(true);
|
||||
s.horizon.hdr = true;
|
||||
s.horizon.hdr = HdrMode::On;
|
||||
let calibrate = row(&mut ui, &s, Item::Horizon(H::HdrCalibrate));
|
||||
press(&mut ui, &mut s, at(calibrate));
|
||||
assert!(ui.changed());
|
||||
// From the keyboard: Undo puts it back and goes away.
|
||||
ui.set_focus(Some(Target::Undo));
|
||||
assert_eq!(key(&mut ui, &mut s, K::Enter), [CLICK, CHANGED]);
|
||||
assert!(!s.horizon.hdr && !ui.changed());
|
||||
assert!(!s.horizon.hdr_wanted() && !ui.changed());
|
||||
assert_eq!(ui.focused(), Some(Target::Done));
|
||||
}
|
||||
|
||||
@@ -1789,7 +1791,7 @@ mod tests {
|
||||
assert!(press(&mut ui, &mut s, (r[0] + 4.0, r[1] + 4.0)).is_empty());
|
||||
assert_eq!(s.horizon.chunk_updates, 16);
|
||||
assert!(ui.changes_value());
|
||||
assert!(!ui.release());
|
||||
assert!(ui.release().is_none());
|
||||
// At the track's end: the most, then dragged to its start.
|
||||
assert_eq!(press(&mut ui, &mut s, (tx + tw, r[1] + 4.0)), [DRAGGED]);
|
||||
assert_eq!(H::ChunkUpdates.slider(&s), Some(1.0));
|
||||
@@ -1798,8 +1800,8 @@ mod tests {
|
||||
[DRAGGED]
|
||||
);
|
||||
assert_eq!(H::ChunkUpdates.slider(&s), Some(0.0));
|
||||
assert!(ui.release());
|
||||
assert!(!ui.release());
|
||||
assert!(ui.release().is_some());
|
||||
assert!(ui.release().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1807,7 +1809,7 @@ mod tests {
|
||||
let _offer = HDR_OFFER.lock().unwrap_or_else(|e| e.into_inner());
|
||||
crate::graphics::offer_hdr(true);
|
||||
let mut s = Settings::default();
|
||||
s.horizon.hdr = true;
|
||||
s.horizon.hdr = HdrMode::On;
|
||||
let item = Item::Horizon(H::HdrSmoothGradients);
|
||||
// Where the device can't, the saved On reads Off.
|
||||
crate::graphics::offer_smooth_gradients(false);
|
||||
@@ -1823,39 +1825,42 @@ mod tests {
|
||||
assert_eq!(item.blocked(&s), None);
|
||||
assert_eq!(item.label(&s).plain(), "Smooth Gradients: On");
|
||||
assert_eq!(item.switch(&s), Some(true));
|
||||
s.horizon.hdr = false;
|
||||
s.horizon.hdr = HdrMode::Off;
|
||||
assert_eq!(item.blocked(&s), Some("Turn HDR on to use it."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_waits_for_a_display_that_offers_it_and_its_sliders_for_the_switch() {
|
||||
fn hdr_auto_can_be_selected_before_querying_and_brightness_waits_for_output() {
|
||||
let _offer = HDR_OFFER.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut s = Settings::default();
|
||||
let mut ui = ui();
|
||||
let at = |[x, y, ..]: [f32; 4]| (x + 8.0, y + 4.0);
|
||||
crate::graphics::offer_hdr(false);
|
||||
// A 720p window opened at HDR shows only its greyed rows: Down still reaches the shortcut.
|
||||
// Off has not asked the system yet: Auto must still be reachable to start the watch.
|
||||
let short = (427.0, 240.0);
|
||||
let mut at_hdr = Ui::default();
|
||||
at_hdr.show(Page::Hdr);
|
||||
at_hdr.key(&mut s, K::ArrowDown, None, false, short);
|
||||
assert_eq!(at_hdr.focused_row(), Some(Item::Horizon(H::HdrKey)));
|
||||
assert_eq!(at_hdr.focused_row(), Some(Item::Horizon(H::Hdr)));
|
||||
let hdr = row(&mut ui, &s, Item::Horizon(H::Hdr));
|
||||
assert!(press(&mut ui, &mut s, at(hdr)).is_empty());
|
||||
// Not even with HDR saved on, from another computer: nor its sliders and calibration.
|
||||
s.horizon.hdr = true;
|
||||
for h in [H::Hdr, H::HdrCalibrate, H::HdrPeak, H::HdrPaperWhite] {
|
||||
assert_eq!(press(&mut ui, &mut s, at(hdr)), [CLICK, CHANGED]);
|
||||
assert_eq!(s.horizon.hdr, HdrMode::Auto);
|
||||
assert_eq!(press(&mut ui, &mut s, at(hdr)), [CLICK, CHANGED]);
|
||||
assert_eq!(s.horizon.hdr, HdrMode::Off);
|
||||
// Brightness still needs an output, even with On saved from another computer.
|
||||
s.horizon.hdr = HdrMode::On;
|
||||
for h in [H::HdrCalibrate, H::HdrPeak, H::HdrPaperWhite] {
|
||||
assert!(Item::Horizon(h).blocked(&s).is_some(), "{h:?}");
|
||||
}
|
||||
s.horizon.hdr = false;
|
||||
s.horizon.hdr = HdrMode::Off;
|
||||
crate::graphics::offer_hdr(true);
|
||||
let (tx, _) = Columns::of(SIZE.0, SIZE.1).track();
|
||||
let peak = row(&mut ui, &s, Item::Horizon(H::HdrPeak));
|
||||
assert!(press(&mut ui, &mut s, (tx + 2.0, peak[1] + 4.0)).is_empty());
|
||||
// The switch turns it on, and the sliders below it work.
|
||||
// The choice turns it on, and the sliders below it work.
|
||||
let hdr = row(&mut ui, &s, Item::Horizon(H::Hdr));
|
||||
assert_eq!(press(&mut ui, &mut s, at(hdr)), [CLICK, CHANGED]);
|
||||
assert!(s.horizon.hdr);
|
||||
assert!(s.horizon.hdr_wanted());
|
||||
let peak = row(&mut ui, &s, Item::Horizon(H::HdrPeak));
|
||||
assert_eq!(press(&mut ui, &mut s, (tx + 2.0, peak[1] + 4.0)), [DRAGGED]);
|
||||
ui.release();
|
||||
@@ -1873,6 +1878,33 @@ mod tests {
|
||||
assert!(ui.changed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_auto_is_a_choice_on_a_reporting_sdr_display() {
|
||||
let _offer = HDR_OFFER.lock().unwrap_or_else(|e| e.into_inner());
|
||||
crate::graphics::offer_hdr(false);
|
||||
crate::graphics::report_system_hdr(Some(false));
|
||||
let mut s = Settings::default();
|
||||
let item = Item::Horizon(H::Hdr);
|
||||
assert_eq!(item.switch(&s), None);
|
||||
assert_eq!(item.blocked(&s), None);
|
||||
item.cycle(&mut s, false, FB);
|
||||
assert_eq!(s.horizon.hdr, HdrMode::Auto); // On requires an offered output.
|
||||
assert!(!s.horizon.hdr_wanted());
|
||||
assert!(Item::Horizon(H::HdrPeak).blocked(&s).is_some());
|
||||
item.cycle(&mut s, true, FB);
|
||||
assert_eq!(s.horizon.hdr, HdrMode::Off);
|
||||
crate::graphics::offer_hdr(true);
|
||||
for expected in [HdrMode::On, HdrMode::Auto, HdrMode::Off] {
|
||||
item.cycle(&mut s, false, FB);
|
||||
assert_eq!(s.horizon.hdr, expected);
|
||||
}
|
||||
s.horizon.hdr = HdrMode::Auto;
|
||||
crate::graphics::report_system_hdr(Some(true));
|
||||
assert!(s.horizon.hdr_wanted());
|
||||
assert_eq!(Item::Horizon(H::HdrPeak).blocked(&s), None);
|
||||
crate::graphics::report_system_hdr(None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scrollbar_can_be_held_and_dragged() {
|
||||
// #3552: AbstractScrollArea.updateScrolling holds the scrollbar on a press over its
|
||||
@@ -1891,7 +1923,7 @@ mod tests {
|
||||
assert_eq!(ui.scroll_amount(), content - (cols.bottom - cols.top));
|
||||
drag(&mut ui, &mut s, cols.top - 50.0, true);
|
||||
assert_eq!(ui.scroll_amount(), 0.0);
|
||||
assert!(!ui.release());
|
||||
assert!(ui.release().is_none());
|
||||
drag(&mut ui, &mut s, cols.bottom + 50.0, true);
|
||||
assert_eq!(ui.scroll_amount(), 0.0);
|
||||
}
|
||||
@@ -2730,8 +2762,8 @@ mod tests {
|
||||
let pos = i as f32 / steps as f32;
|
||||
let mut s1 = Settings::default();
|
||||
let mut s2 = Settings::default();
|
||||
s1.horizon.hdr = true;
|
||||
s2.horizon.hdr = true;
|
||||
s1.horizon.hdr = HdrMode::On;
|
||||
s2.horizon.hdr = HdrMode::On;
|
||||
h.set_slider(&mut s1, pos);
|
||||
|
||||
item.step_slider(&mut s1, true, 200.0);
|
||||
@@ -2747,8 +2779,8 @@ mod tests {
|
||||
let pos = i as f32 / steps as f32;
|
||||
let mut s1 = Settings::default();
|
||||
let mut s2 = Settings::default();
|
||||
s1.horizon.hdr = true;
|
||||
s2.horizon.hdr = true;
|
||||
s1.horizon.hdr = HdrMode::On;
|
||||
s2.horizon.hdr = HdrMode::On;
|
||||
h.set_slider(&mut s1, pos);
|
||||
|
||||
item.step_slider(&mut s1, false, 200.0);
|
||||
@@ -2762,6 +2794,81 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn hdr_sliders_step_through_every_value_they_can_be_set_to() {
|
||||
let _offer = HDR_OFFER.lock().unwrap_or_else(|e| e.into_inner());
|
||||
crate::graphics::offer_hdr(true);
|
||||
let value = |h: H, s: &Settings| match h {
|
||||
H::HdrPaperWhite => s.horizon.hdr_paper_white,
|
||||
H::HdrPeak => s.horizon.hdr_peak,
|
||||
H::HdrBlackLevel => s.horizon.hdr_black_level,
|
||||
H::HdrUiBrightness => s.horizon.hdr_ui_brightness,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let fresh = || {
|
||||
let mut s = Settings::default();
|
||||
s.horizon.hdr = HdrMode::On;
|
||||
s
|
||||
};
|
||||
for h in [
|
||||
H::HdrPaperWhite,
|
||||
H::HdrPeak,
|
||||
H::HdrBlackLevel,
|
||||
H::HdrUiBrightness,
|
||||
] {
|
||||
// Everything a drag can land on, each read back where the slider puts it.
|
||||
let mut set = std::collections::BTreeSet::new();
|
||||
for i in 0..=10_000 {
|
||||
let mut s = fresh();
|
||||
h.set_slider(&mut s, i as f32 / 10_000.0);
|
||||
let v = value(h, &s);
|
||||
set.insert(v);
|
||||
let mut again = fresh();
|
||||
h.set_slider(&mut again, h.slider(&s).unwrap());
|
||||
assert_eq!(value(h, &again), v, "{h:?} round trip");
|
||||
}
|
||||
// Everything a key or the controller reaches, from the bottom to the top.
|
||||
let mut s = fresh();
|
||||
h.set_slider(&mut s, 0.0);
|
||||
let mut stepped = std::collections::BTreeSet::from([value(h, &s)]);
|
||||
for _ in 0..h.slider_steps().unwrap() {
|
||||
Item::Horizon(h).step_slider(&mut s, true, 200.0);
|
||||
stepped.insert(value(h, &s));
|
||||
}
|
||||
assert_eq!(stepped, set, "{h:?}");
|
||||
}
|
||||
// Auto, then 10..480 by 10; Auto, then 200..2000 by 50 and 2500..10 000 by 500;
|
||||
// 0..1 nit by 0.01 with 0 no lift.
|
||||
let mut s = fresh();
|
||||
let reach = |h: H, s: &mut Settings| {
|
||||
h.set_slider(s, 0.0);
|
||||
let mut all = vec![value(h, s)];
|
||||
for _ in 0..h.slider_steps().unwrap() {
|
||||
Item::Horizon(h).step_slider(s, true, 200.0);
|
||||
all.push(value(h, s));
|
||||
}
|
||||
all
|
||||
};
|
||||
let paper: Vec<u32> = std::iter::once(0).chain((10..=480).step_by(10)).collect();
|
||||
assert_eq!(reach(H::HdrPaperWhite, &mut s), paper);
|
||||
let peak: Vec<u32> = std::iter::once(0)
|
||||
.chain((200..=2000).step_by(50))
|
||||
.chain((2500..=10_000).step_by(500))
|
||||
.collect();
|
||||
assert_eq!(reach(H::HdrPeak, &mut s), peak);
|
||||
assert_eq!(
|
||||
reach(H::HdrBlackLevel, &mut s),
|
||||
(0..=100).collect::<Vec<u32>>()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn black_level_reads_as_its_lift() {
|
||||
let mut s = Settings::default();
|
||||
let label = |s: &Settings| Item::Horizon(H::HdrBlackLevel).label(s).plain();
|
||||
assert_eq!(label(&s), "Black Level: 0.00");
|
||||
s.horizon.hdr_black_level = 5;
|
||||
assert_eq!(label(&s), "Black Level: 0.05");
|
||||
}
|
||||
/// 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 screen shows, with each setting cycled
|
||||
@@ -2769,7 +2876,7 @@ mod tests {
|
||||
#[test]
|
||||
fn every_graphics_text_is_in_the_language_table() {
|
||||
let (_, missed) = lang::scoped(lang::Language::load(&[], "de_de"), || {
|
||||
for hdr in [false, true] {
|
||||
for hdr in HdrMode::ALL {
|
||||
let mut s = Settings::default();
|
||||
s.horizon.hdr = hdr;
|
||||
s.horizon.taa = true;
|
||||
|
||||
@@ -261,21 +261,27 @@ fn tone_scale(top: f32, headroom: f32, paper: f32) -> f32 {
|
||||
}
|
||||
|
||||
// Hdr::keep_world: the HUD's backdrop, the world clipped to white with alpha 0, the frame as
|
||||
// the HUD found it before.
|
||||
// the HUD found it before. The HUD's target is 8-bit, as that frame was.
|
||||
@fragment fn fs_keep(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let w = textureLoad(frame, vec2<i32>(pos.xy), 0).rgb;
|
||||
return vec4(clamp(w, vec3(0.0), vec3(1.0)), 0.0);
|
||||
}
|
||||
|
||||
// The backdrop `fs_keep` left in the HUD's 8-bit target for world `w`: where no HUD draws, the
|
||||
// target holds exactly this, so the HUD's own part below is exactly 0 there.
|
||||
fn backdrop(w: vec3<f32>) -> vec3<f32> {
|
||||
return round(clamp(w, vec3(0.0), vec3(1.0)) * 255.0) / 255.0;
|
||||
}
|
||||
|
||||
// The HUD over the world in gamma, the space the HUD blends in: its own part, what its backdrop
|
||||
// B doesn't explain (H = P - (1 - c) B, exact for the layering blends), at UI Brightness, over
|
||||
// (1 - c) of the world, light above white included. Where the world is at or below white, at
|
||||
// 100%, that is the HUD's picture as drawn.
|
||||
fn layered(at: vec2<i32>) -> vec3<f32> {
|
||||
let w = max(textureLoad(frame, at, 0).rgb, vec3(0.0));
|
||||
let p = clamp(textureLoad(hud, at, 0).rgb, vec3(0.0), vec3(1.0));
|
||||
let p = textureLoad(hud, at, 0).rgb;
|
||||
let c = textureLoad(coverage, at, 0).r;
|
||||
let h = max(p - (1.0 - c) * min(w, vec3(1.0)), vec3(0.0));
|
||||
let h = max(p - (1.0 - c) * backdrop(w), vec3(0.0));
|
||||
return h * pow(params.ui, 1.0 / 2.2) + (1.0 - c) * w;
|
||||
}
|
||||
|
||||
@@ -284,6 +290,39 @@ fn layered(at: vec2<i32>) -> vec3<f32> {
|
||||
return vec4(layered(vec2<i32>(pos.xy)), 1.0);
|
||||
}
|
||||
|
||||
// Screenshots keep midtones at their drawn brightness, with a shoulder starting at 0.6
|
||||
// paper white in linear light. Unlike the display curve (whose knee is at least white),
|
||||
// this has an unbounded source range: k + (1-k)*(top-k)/(top+1-2*k) approaches SDR
|
||||
// white smoothly, with slope 1 at k. One scale keeps the world's linear RGB ratios.
|
||||
fn screenshot_world(w: vec3<f32>) -> vec3<f32> {
|
||||
let top = pow(max(w.r, max(w.g, w.b)), 2.2);
|
||||
if top <= 0.6 {
|
||||
return w;
|
||||
}
|
||||
let mapped = 0.6 + 0.4 * (top - 0.6) / (top - 0.2);
|
||||
return w * pow(mapped / top, 1.0 / 2.2);
|
||||
}
|
||||
|
||||
override screenshot_world_only: bool = false;
|
||||
// Off where Horizon lifts nothing (HDR Lighting off, or a shader pack's picture): the world is
|
||||
// clipped at white as SDR draws it.
|
||||
override screenshot_shoulder: bool = true;
|
||||
|
||||
// The ordinary PNG, or the world's icon before the HUD: H as drawn, at 100%, over
|
||||
// the SDR world's light. This never changes the targets used by HDR output or its copy.
|
||||
@fragment fn fs_sdr_screenshot(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let at = vec2<i32>(pos.xy);
|
||||
let w = max(textureLoad(frame, at, 0).rgb, vec3(0.0));
|
||||
let mapped = select(min(w, vec3(1.0)), screenshot_world(w), screenshot_shoulder);
|
||||
if screenshot_world_only {
|
||||
return vec4(mapped, 1.0);
|
||||
}
|
||||
let p = textureLoad(hud, at, 0).rgb;
|
||||
let c = textureLoad(coverage, at, 0).r;
|
||||
let h = max(p - (1.0 - c) * backdrop(w), vec3(0.0));
|
||||
return vec4(h + (1.0 - c) * mapped, 1.0);
|
||||
}
|
||||
|
||||
@fragment fn fs_hdr_layered(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
if calibrating(pos) {
|
||||
return calibration_picture(pos);
|
||||
|
||||
@@ -47,8 +47,8 @@ pub struct Calibration {
|
||||
step: usize,
|
||||
/// Nits, in the order of `STEPS`.
|
||||
values: [f32; 3],
|
||||
/// Which values were Auto (peak, paper white): saved as Auto again unless the
|
||||
/// player moved them.
|
||||
/// Which values were Auto (peak, paper white; black has none): saved as Auto again
|
||||
/// unless the player moved them.
|
||||
auto: [Option<f32>; 3],
|
||||
dragging: bool,
|
||||
}
|
||||
@@ -57,12 +57,29 @@ pub struct Calibration {
|
||||
const BAND: f32 = 84.0;
|
||||
|
||||
impl Step {
|
||||
/// Range and step of the slider, in nits: the settings' own.
|
||||
fn range(self) -> (f32, f32, f32) {
|
||||
/// The slider's steps past its first: the settings' own.
|
||||
fn steps(self) -> u32 {
|
||||
match self {
|
||||
Step::Black => (0.0, 1.0, 0.01),
|
||||
Step::Peak => (200.0, 2000.0, 50.0),
|
||||
Step::PaperWhite => (10.0, 480.0, 10.0),
|
||||
Step::Black => 100,
|
||||
Step::Peak => crate::graphics::PEAK_STEPS,
|
||||
Step::PaperWhite => 47,
|
||||
}
|
||||
}
|
||||
/// The nits at `step` of the slider: black 0..1 by 0.01, the peak's scale, paper
|
||||
/// white 10..480 by 10.
|
||||
fn nits_at(self, step: u32) -> f32 {
|
||||
match self {
|
||||
Step::Black => step as f32 / 100.0,
|
||||
Step::Peak => crate::graphics::peak_nits_at(step) as f32,
|
||||
Step::PaperWhite => 10.0 + step as f32 * 10.0,
|
||||
}
|
||||
}
|
||||
/// Where `nits` sits on the slider, in steps.
|
||||
fn step_of(self, nits: f32) -> f32 {
|
||||
match self {
|
||||
Step::Black => nits * 100.0,
|
||||
Step::Peak => crate::graphics::peak_step_of(nits),
|
||||
Step::PaperWhite => (nits - 10.0) / 10.0,
|
||||
}
|
||||
}
|
||||
/// The English name, which is also its key in the language table.
|
||||
@@ -130,7 +147,7 @@ impl Calibration {
|
||||
g.paper_white_nits() as f32,
|
||||
],
|
||||
auto: [
|
||||
(g.hdr_black_level == 0).then_some(g.black_level_nits()),
|
||||
None,
|
||||
(g.hdr_peak == 0).then_some(g.peak_nits() as f32),
|
||||
(g.hdr_paper_white == 0).then_some(g.paper_white_nits() as f32),
|
||||
],
|
||||
@@ -140,9 +157,21 @@ impl Calibration {
|
||||
fn current(&self) -> Step {
|
||||
STEPS[self.step]
|
||||
}
|
||||
fn set(&mut self, v: f32) {
|
||||
let (min, max, step) = self.current().range();
|
||||
self.values[self.step] = (min + ((v - min) / step).round() * step).clamp(min, max);
|
||||
/// The slider's position, 0..=1.
|
||||
fn position(&self) -> f32 {
|
||||
let step = self.current();
|
||||
step.step_of(self.values[self.step]) / step.steps() as f32
|
||||
}
|
||||
/// Puts the slider on its step nearest `position`.
|
||||
fn set(&mut self, position: f32) {
|
||||
let step = self.current();
|
||||
let steps = step.steps() as f32;
|
||||
self.values[self.step] = step.nits_at((position * steps).round().clamp(0.0, steps) as u32);
|
||||
}
|
||||
/// Moves the slider `by` steps from the one nearest its value.
|
||||
fn step_by(&mut self, by: f32) {
|
||||
let steps = self.current().steps() as f32;
|
||||
self.set(((self.position() * steps).round() + by) / steps);
|
||||
}
|
||||
fn label(&self) -> Text {
|
||||
let v = self.values[self.step];
|
||||
@@ -184,7 +213,6 @@ impl Calibration {
|
||||
w,
|
||||
rgba(160, 160, 160, 255),
|
||||
);
|
||||
let (min, max, _) = step.range();
|
||||
let (x, y, sw) = l.slider;
|
||||
ui::slider(
|
||||
batch,
|
||||
@@ -193,7 +221,7 @@ impl Calibration {
|
||||
y,
|
||||
sw,
|
||||
&self.label(),
|
||||
(self.values[self.step] - min) / (max - min),
|
||||
self.position(),
|
||||
over(cursor, l.slider) || self.dragging,
|
||||
true,
|
||||
self.dragging,
|
||||
@@ -238,14 +266,12 @@ impl Calibration {
|
||||
if !self.dragging {
|
||||
return Outcome::Nothing;
|
||||
}
|
||||
let (min, max, _) = self.current().range();
|
||||
self.set(min + ui::slider_value(x, sw, cursor.0) * (max - min));
|
||||
self.set(ui::slider_value(x, sw, cursor.0));
|
||||
return Outcome::Changed;
|
||||
}
|
||||
if over(cursor, l.slider) {
|
||||
self.dragging = true;
|
||||
let (min, max, _) = self.current().range();
|
||||
self.set(min + ui::slider_value(x, sw, cursor.0) * (max - min));
|
||||
self.set(ui::slider_value(x, sw, cursor.0));
|
||||
return Outcome::Changed;
|
||||
}
|
||||
if over(cursor, l.cancel) {
|
||||
@@ -273,14 +299,13 @@ impl Calibration {
|
||||
}
|
||||
/// Left and Right step the slider, Enter goes on, Escape cancels.
|
||||
pub fn key(&mut self, key: KeyCode) -> Outcome {
|
||||
let (_, _, step) = self.current().range();
|
||||
match key {
|
||||
KeyCode::ArrowLeft | KeyCode::ArrowDown => {
|
||||
self.set(self.values[self.step] - step);
|
||||
self.step_by(-1.0);
|
||||
Outcome::Changed
|
||||
}
|
||||
KeyCode::ArrowRight | KeyCode::ArrowUp => {
|
||||
self.set(self.values[self.step] + step);
|
||||
self.step_by(1.0);
|
||||
Outcome::Changed
|
||||
}
|
||||
KeyCode::Enter | KeyCode::NumpadEnter => self.next(),
|
||||
@@ -328,4 +353,73 @@ mod tests {
|
||||
assert_eq!(s.horizon.hdr_peak, before.peak_nits() + 50);
|
||||
assert_eq!(s.horizon.hdr_paper_white, before.hdr_paper_white);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_step_reaches_every_value_of_its_setting() {
|
||||
// Black Level 0..1 nit by 0.01; Peak Brightness 200..2000 by 50, then by 500 to PQ's
|
||||
// 10 000; Paper White 10..480 by 10.
|
||||
let expected: [Vec<f32>; 3] = [
|
||||
(0..=100).map(|n| n as f32 / 100.0).collect(),
|
||||
(200..=2000)
|
||||
.step_by(50)
|
||||
.chain((2500..=10_000).step_by(500))
|
||||
.map(|n| n as f32)
|
||||
.collect(),
|
||||
(10..=480).step_by(10).map(|n| n as f32).collect(),
|
||||
];
|
||||
let mut c = Calibration::new(&Settings::default());
|
||||
for (i, expected) in expected.iter().enumerate() {
|
||||
assert_eq!(c.picture(1.0).0, STEPS[i]);
|
||||
for _ in 0..200 {
|
||||
c.key(KeyCode::ArrowLeft);
|
||||
}
|
||||
let mut seen = vec![c.picture(1.0).1];
|
||||
for _ in 0..200 {
|
||||
c.key(KeyCode::ArrowRight);
|
||||
let v = c.picture(1.0).1;
|
||||
if seen.last() != Some(&v) {
|
||||
seen.push(v);
|
||||
}
|
||||
}
|
||||
assert_eq!(&seen, expected, "{:?}", STEPS[i]);
|
||||
c.key(KeyCode::Enter);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn black_level_saves_the_lift_it_shows_and_peak_reaches_10000() {
|
||||
let mut s = Settings::default();
|
||||
let mut c = Calibration::new(&s);
|
||||
// Untouched, no lift stays no lift.
|
||||
c.save(&mut s);
|
||||
assert_eq!(s.horizon.hdr_black_level, 0);
|
||||
c.key(KeyCode::ArrowRight);
|
||||
c.key(KeyCode::ArrowRight);
|
||||
c.save(&mut s);
|
||||
assert_eq!(s.horizon.hdr_black_level, 2);
|
||||
// Calibrated back down, 0 is no lift: what the step showed.
|
||||
let mut c = Calibration::new(&s);
|
||||
assert!((c.picture(1.0).1 - 0.02).abs() < 1e-6);
|
||||
c.key(KeyCode::ArrowLeft);
|
||||
c.key(KeyCode::ArrowLeft);
|
||||
c.key(KeyCode::Enter);
|
||||
for _ in 0..60 {
|
||||
c.key(KeyCode::ArrowRight);
|
||||
}
|
||||
c.save(&mut s);
|
||||
assert_eq!(s.horizon.hdr_black_level, 0);
|
||||
assert_eq!(s.horizon.black_level_nits(), 0.0);
|
||||
assert_eq!(s.horizon.hdr_peak, 10_000);
|
||||
let mut c = Calibration::new(&s);
|
||||
c.key(KeyCode::Enter);
|
||||
assert_eq!(c.picture(1.0).1, 10_000.0);
|
||||
// One step down from the top is 500 nits; from 2000, 50.
|
||||
c.key(KeyCode::ArrowLeft);
|
||||
assert_eq!(c.picture(1.0).1, 9500.0);
|
||||
s.horizon.hdr_peak = 2000;
|
||||
let mut c = Calibration::new(&s);
|
||||
c.key(KeyCode::Enter);
|
||||
c.key(KeyCode::ArrowLeft);
|
||||
assert_eq!(c.picture(1.0).1, 1950.0);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
//! GUI scale is applied when drawing).
|
||||
use crate::Result;
|
||||
use horizon_assets::animation::AnimationMetadata;
|
||||
use smallvec::SmallVec;
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fs,
|
||||
@@ -902,18 +903,20 @@ impl HudAtlas {
|
||||
}
|
||||
/// Empty slots written at run time: map textures (game/maps.rs), the
|
||||
/// minimap, a native mod's own image, downloaded skins, world and pack
|
||||
/// icons and the on-screen keyboard's text.
|
||||
/// icons and the on-screen keyboard's text. Their pixels are already zero
|
||||
/// in the destination atlas, so keep the source buffers empty and avoid
|
||||
/// duplicating the atlas-sized allocation during packing.
|
||||
fn slots(images: &mut Vec<PackedImage>, trim: (u32, u32), mod_image: bool) {
|
||||
for i in 0..MAP_SLOTS {
|
||||
let name = map_slot_sprite(i).unwrap_or_default();
|
||||
images.push((name.into(), 128, 128, vec![0; 128 * 128 * 4]));
|
||||
images.push((name.into(), 128, 128, Vec::new()));
|
||||
}
|
||||
// The minimap extra's image (game/extras.rs).
|
||||
images.push((
|
||||
MINIMAP_SPRITE.into(),
|
||||
crate::minimap::SIZE as u32,
|
||||
crate::minimap::SIZE as u32,
|
||||
vec![0; crate::minimap::SIZE * crate::minimap::SIZE * 4],
|
||||
Vec::new(),
|
||||
));
|
||||
// A mod's own image (native `mod_image`), only when a mod is loaded.
|
||||
if mod_image {
|
||||
@@ -921,45 +924,29 @@ impl HudAtlas {
|
||||
MOD_IMAGE_SPRITE.into(),
|
||||
MOD_IMAGE_SIZE,
|
||||
MOD_IMAGE_SIZE,
|
||||
vec![0; (MOD_IMAGE_SIZE * MOD_IMAGE_SIZE * 4) as usize],
|
||||
Vec::new(),
|
||||
));
|
||||
}
|
||||
// Downloaded skins (game/scoreboard.rs).
|
||||
for i in 0..SKIN_SLOTS {
|
||||
images.push((format!("horizon/skin{i}"), 64, 64, vec![0; 64 * 64 * 4]));
|
||||
images.push((format!("horizon/skin{i}"), 64, 64, Vec::new()));
|
||||
}
|
||||
for i in 0..TRIM_SLOTS {
|
||||
images.push((
|
||||
format!("horizon/trim{i}"),
|
||||
trim.0,
|
||||
trim.1,
|
||||
vec![0; (trim.0 * trim.1 * 4) as usize],
|
||||
));
|
||||
images.push((format!("horizon/trim{i}"), trim.0, trim.1, Vec::new()));
|
||||
}
|
||||
for i in 0..WORLD_ICON_SLOTS {
|
||||
images.push((
|
||||
format!("horizon/world-icon{i}"),
|
||||
64,
|
||||
64,
|
||||
vec![0; 64 * 64 * 4],
|
||||
));
|
||||
images.push((format!("horizon/world-icon{i}"), 64, 64, Vec::new()));
|
||||
}
|
||||
let side = PACK_ICON_SIZE as usize;
|
||||
for i in 0..PACK_ICON_SLOTS {
|
||||
images.push((
|
||||
format!("horizon/pack-icon{i}"),
|
||||
PACK_ICON_SIZE,
|
||||
PACK_ICON_SIZE,
|
||||
vec![0; side * side * 4],
|
||||
Vec::new(),
|
||||
));
|
||||
}
|
||||
// The on-screen keyboard's text and corner masks (ui_theme), rasterized at run time.
|
||||
images.push((
|
||||
"horizon/ui-text".into(),
|
||||
2048,
|
||||
1024,
|
||||
vec![0; 2048 * 1024 * 4],
|
||||
));
|
||||
images.push(("horizon/ui-text".into(), 2048, 1024, Vec::new()));
|
||||
}
|
||||
/// Only the font's metrics, for a game that draws nothing (the
|
||||
/// integrated or dedicated server): text measures as with the full atlas,
|
||||
@@ -1528,10 +1515,12 @@ impl HudAtlas {
|
||||
{
|
||||
translucent.insert(name.clone());
|
||||
}
|
||||
for row in 0..*h {
|
||||
let src = &data[(row * w * 4) as usize..((row + 1) * w * 4) as usize];
|
||||
let dst = (((py + row) * width + px) * 4) as usize;
|
||||
pixels[dst..dst + (w * 4) as usize].copy_from_slice(src);
|
||||
if !data.is_empty() {
|
||||
for row in 0..*h {
|
||||
let src = &data[(row * w * 4) as usize..((row + 1) * w * 4) as usize];
|
||||
let dst = (((py + row) * width + px) * 4) as usize;
|
||||
pixels[dst..dst + (w * 4) as usize].copy_from_slice(src);
|
||||
}
|
||||
}
|
||||
let b = u32::from(bordered.contains(name));
|
||||
let (px, py, w, h) = (px + b, py + b, w - 2 * b, h - 2 * b);
|
||||
@@ -1745,6 +1734,10 @@ pub enum Blend {
|
||||
Additive,
|
||||
/// Alpha blending with an isolated depth buffer and a pixel scissor.
|
||||
Depth([u32; 4]),
|
||||
/// The glint over the `Depth` run before it with the same scissor, in its
|
||||
/// pass: `RenderPipelines.GLINT`'s additive blend, depth tested against the
|
||||
/// model and never written.
|
||||
DepthGlint([u32; 4]),
|
||||
/// `RenderPipelines.VIGNETTE`: the destination darkened by the texture
|
||||
/// (`ZERO, ONE_MINUS_SRC_COLOR`).
|
||||
Vignette,
|
||||
@@ -1842,12 +1835,31 @@ impl HudBatch {
|
||||
}
|
||||
out
|
||||
}
|
||||
fn runs_in(&self, vertices: std::ops::Range<u32>) -> Vec<(std::ops::Range<u32>, Blend)> {
|
||||
let mut runs = self.runs();
|
||||
for (range, _) in &mut runs {
|
||||
*range = range.start.max(vertices.start)..range.end.min(vertices.end);
|
||||
fn runs_in(
|
||||
&self,
|
||||
vertices: std::ops::Range<u32>,
|
||||
) -> SmallVec<[(std::ops::Range<u32>, Blend); 16]> {
|
||||
let mut runs = SmallVec::new();
|
||||
let mut at = vertices.start;
|
||||
for (range, blend) in &self.blends {
|
||||
if range.start > at {
|
||||
let start = at.max(vertices.start);
|
||||
let end = range.start.min(vertices.end);
|
||||
if start < end {
|
||||
runs.push((start..end, Blend::Alpha));
|
||||
}
|
||||
}
|
||||
let start = range.start.max(vertices.start);
|
||||
let end = range.end.min(vertices.end);
|
||||
if start < end {
|
||||
runs.push((start..end, *blend));
|
||||
}
|
||||
at = at.max(range.end);
|
||||
}
|
||||
let end = (self.vertices.len() as u32).min(vertices.end);
|
||||
if at < end {
|
||||
runs.push((at.max(vertices.start)..end, Blend::Alpha));
|
||||
}
|
||||
runs.retain(|(range, _)| !range.is_empty());
|
||||
runs
|
||||
}
|
||||
/// `AbstractContainerScreen.init`: center in integer GUI coordinates.
|
||||
@@ -1901,8 +1913,39 @@ impl HudBatch {
|
||||
});
|
||||
}
|
||||
}
|
||||
/// `RenderTypes.armorCutoutNoCullGlint` (and the items' glint types) over a
|
||||
/// [`Self::quad_3d`] face: `uv` is the face's atlas UV,
|
||||
/// which the shader masks by, and `glint_uv` the transformed glint UV.
|
||||
/// `armor` samples `enchanted_glint_armor` (`ARMOR_ENTITY_GLINT`), otherwise
|
||||
/// `enchanted_glint_item`. Drawn by [`Self::depth_glint_since`].
|
||||
pub fn quad_3d_glint(
|
||||
&mut self,
|
||||
p: [[f32; 3]; 4],
|
||||
uv: [[f32; 2]; 4],
|
||||
glint_uv: [[f32; 2]; 4],
|
||||
armor: bool,
|
||||
) {
|
||||
let marker = if armor { 4.0 } else { 2.0 };
|
||||
for i in [0, 1, 2, 0, 2, 3] {
|
||||
self.vertices.push(HudVertex {
|
||||
pos: [p[i][0], p[i][1]],
|
||||
uv: [uv[i][0] + marker, uv[i][1]],
|
||||
color: u32::MAX,
|
||||
depth: p[i][2],
|
||||
glint_uv: glint_uv[i],
|
||||
text: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
/// Keep per-vertex depth for an orthographic GUI model, with +z toward the viewer.
|
||||
pub fn depth_since(&mut self, start: usize, clip: [f32; 4]) {
|
||||
self.depth_glint_since(start, self.vertices.len(), clip);
|
||||
}
|
||||
/// `RenderPipelines.GLINT` after a GUI model: [`Self::depth_since`] for a model whose glint faces
|
||||
/// ([`Self::quad_3d_glint`]) follow its own from `glint`: both share the
|
||||
/// depth buffer, and the glint is drawn over the model in the same pass.
|
||||
pub fn depth_glint_since(&mut self, start: usize, glint: usize, clip: [f32; 4]) {
|
||||
let glint = glint.clamp(start, self.vertices.len());
|
||||
let vertices = &mut self.vertices[start..];
|
||||
if vertices.is_empty() {
|
||||
return;
|
||||
@@ -1919,11 +1962,17 @@ impl HudBatch {
|
||||
v.depth = (max - v.depth) / (max - min).max(f32::EPSILON);
|
||||
}
|
||||
let [x0, y0, x1, y1] = clip.map(|v| (v * self.scale).max(0.0) as u32);
|
||||
let clip = [x0, y0, x1.saturating_sub(x0), y1.saturating_sub(y0)];
|
||||
// Separate previews clear depth independently, even if their scissors match.
|
||||
self.blends.push((
|
||||
start as u32..self.vertices.len() as u32,
|
||||
Blend::Depth([x0, y0, x1.saturating_sub(x0), y1.saturating_sub(y0)]),
|
||||
));
|
||||
if glint > start {
|
||||
self.blends
|
||||
.push((start as u32..glint as u32, Blend::Depth(clip)));
|
||||
}
|
||||
let end = self.vertices.len();
|
||||
if end > glint {
|
||||
self.blends
|
||||
.push((glint as u32..end as u32, Blend::DepthGlint(clip)));
|
||||
}
|
||||
}
|
||||
pub fn rect_uv(&mut self, x: f32, y: f32, w: f32, h: f32, r: Rect, color: u32) {
|
||||
self.quad(
|
||||
@@ -2537,6 +2586,8 @@ pub struct HudRenderer {
|
||||
invert_pipeline: Pipeline,
|
||||
additive_pipeline: Pipeline,
|
||||
depth_pipeline: Pipeline,
|
||||
/// `RenderPipelines.GLINT` over a GUI model (`Blend::DepthGlint`).
|
||||
depth_glint_pipeline: Pipeline,
|
||||
vignette_pipeline: Pipeline,
|
||||
end_portal_pipeline: Pipeline,
|
||||
/// The layout of `group`, which the CIT glint pipelines share.
|
||||
@@ -2562,6 +2613,7 @@ impl HudRenderer {
|
||||
format: wgpu::TextureFormat,
|
||||
view: &wgpu::TextureView,
|
||||
glint_view: &wgpu::TextureView,
|
||||
glint_armor_view: &wgpu::TextureView,
|
||||
glint_sampler: &wgpu::Sampler,
|
||||
coverage: bool,
|
||||
) -> Result<Self> {
|
||||
@@ -2636,6 +2688,16 @@ impl HudRenderer {
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 6,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
@@ -2666,6 +2728,10 @@ impl HudRenderer {
|
||||
binding: 5,
|
||||
resource: wgpu::BindingResource::Sampler(&linear_sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 6,
|
||||
resource: wgpu::BindingResource::TextureView(glint_armor_view),
|
||||
},
|
||||
],
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
@@ -2675,7 +2741,8 @@ impl HudRenderer {
|
||||
});
|
||||
let attributes =
|
||||
wgpu::vertex_attr_array![0=>Float32x2,1=>Float32x2,2=>Unorm8x4,3=>Float32,4=>Float32x2];
|
||||
let create = |label, blend, fragment_entry, depth: bool| {
|
||||
// `depth`: the model's depth test, and whether the run writes depth.
|
||||
let create = |label, blend, fragment_entry, depth: Option<bool>| {
|
||||
let desc = wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(&pipeline_layout),
|
||||
@@ -2690,9 +2757,9 @@ impl HudRenderer {
|
||||
})],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: depth.then_some(wgpu::DepthStencilState {
|
||||
depth_stencil: depth.map(|write| wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_write_enabled: Some(write),
|
||||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
@@ -2714,12 +2781,12 @@ impl HudRenderer {
|
||||
let source = wgpu::ShaderSource::Wgsl(include_str!("hud.wgsl").into());
|
||||
Pipeline::new(device, &desc, source, coverage).expect("the HUD's coverage")
|
||||
};
|
||||
let pipeline = create("HUD", wgpu::BlendState::ALPHA_BLENDING, "fs_main", false);
|
||||
let pipeline = create("HUD", wgpu::BlendState::ALPHA_BLENDING, "fs_main", None);
|
||||
let depth_pipeline = create(
|
||||
"HUD model",
|
||||
wgpu::BlendState::ALPHA_BLENDING,
|
||||
"fs_main",
|
||||
true,
|
||||
Some(true),
|
||||
);
|
||||
// BlendFunction.INVERT: ONE_MINUS_DST_COLOR, ONE_MINUS_SRC_COLOR,
|
||||
// ONE, ZERO.
|
||||
@@ -2738,7 +2805,7 @@ impl HudRenderer {
|
||||
},
|
||||
},
|
||||
"fs_crosshair",
|
||||
false,
|
||||
None,
|
||||
);
|
||||
// BlendFunction.ADDITIVE: ONE, ONE.
|
||||
let add = wgpu::BlendComponent {
|
||||
@@ -2753,7 +2820,18 @@ impl HudRenderer {
|
||||
alpha: add,
|
||||
},
|
||||
"fs_main",
|
||||
false,
|
||||
None,
|
||||
);
|
||||
// RenderPipelines.GLINT tests depth EQUAL and writes none; the faces are
|
||||
// the model's own, so LEQUAL passes the same pixels.
|
||||
let depth_glint_pipeline = create(
|
||||
"HUD model glint",
|
||||
wgpu::BlendState {
|
||||
color: add,
|
||||
alpha: add,
|
||||
},
|
||||
"fs_main",
|
||||
Some(false),
|
||||
);
|
||||
// RenderPipelines.VIGNETTE: ZERO, ONE_MINUS_SRC_COLOR, ZERO, ONE.
|
||||
let vignette_pipeline = create(
|
||||
@@ -2771,14 +2849,14 @@ impl HudRenderer {
|
||||
},
|
||||
},
|
||||
"fs_vignette",
|
||||
false,
|
||||
None,
|
||||
);
|
||||
// RenderPipelines.END_PORTAL: opaque.
|
||||
let end_portal_pipeline = create(
|
||||
"HUD end portal",
|
||||
wgpu::BlendState::REPLACE,
|
||||
"fs_end_portal",
|
||||
false,
|
||||
None,
|
||||
);
|
||||
let capacity = 1 << 16;
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
@@ -2794,6 +2872,7 @@ impl HudRenderer {
|
||||
invert_pipeline,
|
||||
additive_pipeline,
|
||||
depth_pipeline,
|
||||
depth_glint_pipeline,
|
||||
vignette_pipeline,
|
||||
end_portal_pipeline,
|
||||
layout,
|
||||
@@ -3001,7 +3080,8 @@ impl HudRenderer {
|
||||
if vertices.is_empty() || size.contains(&0) {
|
||||
return;
|
||||
}
|
||||
let mut attachments = vec![Some(wgpu::RenderPassColorAttachment {
|
||||
let mut attachments = [None, None];
|
||||
attachments[0] = Some(wgpu::RenderPassColorAttachment {
|
||||
view: target,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
@@ -3009,29 +3089,38 @@ impl HudRenderer {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
});
|
||||
let attachment_count;
|
||||
if let Some(view) = coverage {
|
||||
attachments.push(Some(hud_coverage::attachment(view)));
|
||||
attachments[1] = Some(hud_coverage::attachment(view));
|
||||
attachment_count = 2;
|
||||
} else {
|
||||
attachment_count = 1;
|
||||
}
|
||||
let runs = batch.runs_in(vertices);
|
||||
let mut at = 0;
|
||||
while at < runs.len() {
|
||||
let clip = match runs[at].1 {
|
||||
Blend::Depth(clip) => Some(clip),
|
||||
Blend::Depth(clip) | Blend::DepthGlint(clip) => Some(clip),
|
||||
_ => None,
|
||||
};
|
||||
let end = if clip.is_some() {
|
||||
let end = if let Some(clip) = clip {
|
||||
// The model's glint shares its pass and depth buffer.
|
||||
at + 1
|
||||
+ runs[at + 1..]
|
||||
.iter()
|
||||
.take_while(|(_, b)| *b == Blend::DepthGlint(clip))
|
||||
.count()
|
||||
} else {
|
||||
runs[at..]
|
||||
.iter()
|
||||
.position(|(_, b)| matches!(b, Blend::Depth(_)))
|
||||
.position(|(_, b)| matches!(b, Blend::Depth(_) | Blend::DepthGlint(_)))
|
||||
.map_or(runs.len(), |n| at + n)
|
||||
};
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("HUD"),
|
||||
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("HUD")),
|
||||
color_attachments: &attachments,
|
||||
color_attachments: &attachments[..attachment_count],
|
||||
depth_stencil_attachment: clip.map(|_| wgpu::RenderPassDepthStencilAttachment {
|
||||
view: depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
@@ -3060,6 +3149,7 @@ impl HudRenderer {
|
||||
Blend::Invert => &self.invert_pipeline,
|
||||
Blend::Additive => &self.additive_pipeline,
|
||||
Blend::Depth(_) => &self.depth_pipeline,
|
||||
Blend::DepthGlint(_) => &self.depth_glint_pipeline,
|
||||
Blend::Vignette => &self.vignette_pipeline,
|
||||
Blend::EndPortal => &self.end_portal_pipeline,
|
||||
Blend::CitGlint(blend) => {
|
||||
@@ -4601,6 +4691,44 @@ texture=wide",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gui_models_glint_follows_it_in_its_depth_pass() {
|
||||
let mut batch = HudBatch::new([640, 480]);
|
||||
batch.scale = 2.0;
|
||||
let face = |z: f32| [[0.0, 0.0, z], [0.0, 1.0, z], [1.0, 1.0, z], [1.0, 0.0, z]];
|
||||
let uv = [[0.25, 0.5]; 4];
|
||||
let start = batch.vertices.len();
|
||||
batch.quad_3d(face(1.0), uv, u32::MAX);
|
||||
batch.quad_3d(face(3.0), uv, u32::MAX);
|
||||
let glint = batch.vertices.len();
|
||||
batch.quad_3d_glint(face(3.0), uv, [[7.0, 8.0]; 4], true);
|
||||
batch.quad_3d_glint(face(1.0), uv, [[1.0, 2.0]; 4], false);
|
||||
batch.depth_glint_since(start, glint, [1.0, 2.0, 4.0, 6.0]);
|
||||
assert_eq!(
|
||||
batch.runs(),
|
||||
vec![
|
||||
(0..12, Blend::Depth([2, 4, 6, 8])),
|
||||
(12..24, Blend::DepthGlint([2, 4, 6, 8])),
|
||||
]
|
||||
);
|
||||
// The glint shares its face's depth, normalised with the model's.
|
||||
assert_eq!(batch.vertices[12].depth, batch.vertices[6].depth);
|
||||
assert_eq!(batch.vertices[18].depth, batch.vertices[0].depth);
|
||||
// The shader picks the armour glint by u >= 4, the item's by u >= 2,
|
||||
// and masks by the atlas UV under the marker.
|
||||
assert_eq!(batch.vertices[12].uv, [4.25, 0.5]);
|
||||
assert_eq!(batch.vertices[12].glint_uv, [7.0, 8.0]);
|
||||
assert_eq!(batch.vertices[18].uv, [2.25, 0.5]);
|
||||
// A model with no glint keeps a single depth run.
|
||||
let start = batch.vertices.len();
|
||||
batch.quad_3d(face(1.0), uv, u32::MAX);
|
||||
batch.depth_glint_since(start, batch.vertices.len(), [1.0, 2.0, 4.0, 6.0]);
|
||||
assert_eq!(
|
||||
batch.runs().last(),
|
||||
Some(&(24..30, Blend::Depth([2, 4, 6, 8])))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hud_shader_validates_both_fragment_entries() {
|
||||
let module = naga::front::wgsl::parse_str(include_str!("hud.wgsl")).unwrap();
|
||||
|
||||
@@ -12,6 +12,8 @@ struct Hud {
|
||||
@group(0) @binding(3) var glint_tex: texture_2d<f32>;
|
||||
@group(0) @binding(4) var glint_sampler: sampler;
|
||||
@group(0) @binding(5) var linear_sampler: sampler;
|
||||
// enchanted_glint_armor, the armour layers' GlintSampler (entity.wgsl's glint_armor_tex).
|
||||
@group(0) @binding(6) var glint_armor_tex: texture_2d<f32>;
|
||||
struct Out {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@@ -32,11 +34,16 @@ struct Out {
|
||||
}
|
||||
@fragment fn fs_main(in: Out) -> @location(0) vec4<f32> {
|
||||
// u >= 2: the GLINT_TEXTURING sample over the item, masked by the item's
|
||||
// texture alpha (item.fsh's ALPHA_CUTOUT and GLINT path).
|
||||
// texture alpha (item.fsh's ALPHA_CUTOUT and GLINT path). u >= 4: the same
|
||||
// over a GUI model's armour, from enchanted_glint_armor (ARMOR_ENTITY_GLINT).
|
||||
if in.uv.x >= 2.0 {
|
||||
let mask = textureSample(atlas, atlas_sampler, in.uv - vec2(2.0, 0.0)).a;
|
||||
let armor = in.uv.x >= 4.0;
|
||||
let offset = select(2.0, 4.0, armor);
|
||||
let mask = textureSample(atlas, atlas_sampler, in.uv - vec2(offset, 0.0)).a;
|
||||
let item_sample = textureSample(glint_tex, glint_sampler, in.glint_uv);
|
||||
let armor_sample = textureSample(glint_armor_tex, glint_sampler, in.glint_uv);
|
||||
if mask < 0.1 { discard; }
|
||||
var glint = hud.size.w * textureSample(glint_tex, glint_sampler, in.glint_uv);
|
||||
var glint = hud.size.w * select(item_sample, armor_sample, armor);
|
||||
let tint = hud.end_portal[2].yzw;
|
||||
if tint.x >= 0.0 {
|
||||
glint = vec4(max(glint.r, max(glint.g, glint.b)) * tint, glint.a);
|
||||
|
||||
@@ -43,7 +43,8 @@ impl Gpu {
|
||||
let view = atlas.create_view(&Default::default());
|
||||
let hud = HudRenderer::new(
|
||||
&device,
|
||||
wgpu::TextureFormat::Rgba16Float,
|
||||
hud_coverage::HUD_FORMAT,
|
||||
&view,
|
||||
&view,
|
||||
&view,
|
||||
&device.create_sampler(&Default::default()),
|
||||
@@ -70,8 +71,16 @@ impl Gpu {
|
||||
})
|
||||
}
|
||||
|
||||
fn draw(&self, batch: &HudBatch, initial: f64, coverage: bool) -> (Vec<u8>, Vec<u8>) {
|
||||
let frame = self.texture(wgpu::TextureFormat::Rgba16Float);
|
||||
/// `batch` drawn on the HUD's target cleared to `backdrop`, with coverage cleared to
|
||||
/// `initial`: the target's and the coverage's 8-bit texels.
|
||||
fn draw(
|
||||
&self,
|
||||
batch: &HudBatch,
|
||||
backdrop: f64,
|
||||
initial: f64,
|
||||
coverage: bool,
|
||||
) -> (Vec<u8>, Vec<u8>) {
|
||||
let frame = self.texture(hud_coverage::HUD_FORMAT);
|
||||
let mask = self.texture(hud_coverage::FORMAT);
|
||||
let depth = self.texture(wgpu::TextureFormat::Depth32Float);
|
||||
let [frame_view, mask_view, depth_view] =
|
||||
@@ -84,7 +93,7 @@ impl Gpu {
|
||||
bytemuck::bytes_of(&HudUniform::new([4.0, 2.0, 1.0, 0.75], batch.end_portal, 0)),
|
||||
);
|
||||
let mut encoder = self.device.create_command_encoder(&Default::default());
|
||||
for (view, value) in [(&frame_view, 0.3), (&mask_view, initial)] {
|
||||
for (view, value) in [(&frame_view, backdrop), (&mask_view, initial)] {
|
||||
let mut attachment = hud_coverage::attachment(view);
|
||||
attachment.ops.load = wgpu::LoadOp::Clear(wgpu::Color {
|
||||
r: value,
|
||||
@@ -139,7 +148,7 @@ impl Gpu {
|
||||
(
|
||||
color
|
||||
.chunks(256)
|
||||
.flat_map(|r| r[..32].iter().copied())
|
||||
.flat_map(|r| r[..16].iter().copied())
|
||||
.collect(),
|
||||
mask.chunks(256)
|
||||
.flat_map(|r| r[..4].iter().copied())
|
||||
@@ -148,8 +157,8 @@ impl Gpu {
|
||||
}
|
||||
|
||||
fn check(&self, batch: &HudBatch, initial: f64, expected: [u8; 4]) {
|
||||
let (plain, _) = self.draw(batch, initial, false);
|
||||
let (color, mask) = self.draw(batch, initial, true);
|
||||
let (plain, _) = self.draw(batch, 0.3, initial, false);
|
||||
let (color, mask) = self.draw(batch, 0.3, initial, true);
|
||||
assert_eq!(color, plain, "coverage changed the primary colour or alpha");
|
||||
for (got, want) in mask.iter().zip(expected.repeat(2)) {
|
||||
assert!(
|
||||
@@ -219,6 +228,46 @@ fn coverage_follows_hud_blends_discard_scissor_and_depth_without_changing_colour
|
||||
gpu.check(&batch, 0.0, [0, 128, 0, 0]);
|
||||
}
|
||||
|
||||
/// The HUD's target is 8-bit as vanilla's frame is, so each draw clips at white before the
|
||||
/// next blends over it: a text highlight added over a light backdrop, then a 50% black panel.
|
||||
#[test]
|
||||
fn each_hud_draw_clips_at_white_as_on_an_8_bit_frame() {
|
||||
let Some(gpu) = Gpu::new() else {
|
||||
return;
|
||||
};
|
||||
let mut batch = HudBatch::new([4, 2]);
|
||||
quad(
|
||||
&mut batch,
|
||||
Blend::Additive,
|
||||
rgba(128, 128, 128, 255),
|
||||
0.0,
|
||||
0.25,
|
||||
);
|
||||
quad(&mut batch, Blend::Alpha, rgba(0, 0, 0, 128), 0.0, 0.25);
|
||||
// Vanilla's frame in integers: 204 + 128 clips at 255, then the panel keeps 127/255 of it.
|
||||
let (backdrop, highlight, panel) = (204u32, 128u32, 128u32);
|
||||
let clipped = (backdrop + highlight).min(255);
|
||||
let want = ((clipped * (255 - panel)) as f64 / 255.0).round() as u8;
|
||||
assert_eq!(want, 127);
|
||||
// The HUD layer's target, and the 8-bit frame a 0.2 pack finishes on in SDR, where the
|
||||
// HUD draws without coverage.
|
||||
for coverage in [true, false] {
|
||||
let (color, mask) = gpu.draw(&batch, f64::from(backdrop) / 255.0, 0.0, coverage);
|
||||
// Unclipped, the panel would have kept 127/255 of 332: 165.
|
||||
for pixel in [0, 1, 4, 5] {
|
||||
assert_eq!(
|
||||
color[pixel * 4..pixel * 4 + 3],
|
||||
[want; 3],
|
||||
"{coverage}: {color:?}"
|
||||
);
|
||||
}
|
||||
// The highlight hides nothing; the panel hides half of what is under it.
|
||||
if coverage {
|
||||
assert_eq!(mask[0], 128, "{mask:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coverage_preserves_each_cit_blend_and_its_destination_alpha() {
|
||||
let Some(mut gpu) = Gpu::new() else {
|
||||
|
||||
@@ -4,8 +4,26 @@
|
||||
//!
|
||||
//! - `room`: what of a view an overlay may use, clear of the boss bars.
|
||||
//! - `plate`: the box behind a notice's text.
|
||||
//! - `tabs`: a collapsed page strip, the name between arrows with a pip per page.
|
||||
//!
|
||||
//! Vanilla's own display lists (`hud_layout`, `screen_draw`) never use the kit: they stay
|
||||
//! exactly vanilla's.
|
||||
pub mod plate;
|
||||
pub mod room;
|
||||
pub mod tabs;
|
||||
|
||||
/// `0xRRGGBB` and an alpha as the batch's colour (`hud::rgba`), for named colour constants.
|
||||
pub const fn colour(rgb: u32, alpha: u8) -> u32 {
|
||||
(alpha as u32) << 24 | (rgb & 0xFF) << 16 | (rgb >> 8 & 0xFF) << 8 | (rgb >> 16 & 0xFF)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn a_named_colour_is_the_batch_s_rgba() {
|
||||
assert_eq!(
|
||||
super::colour(0x7656FA, 140),
|
||||
crate::hud::rgba(0x76, 0x56, 0xFA, 140)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//! A collapsed page strip: the selected page's name between `‹ ›` arrows, with a pip per page
|
||||
//! under it. A page list with more pages than room (the settings shell's, `settings_shell`)
|
||||
//! shows them this way instead of leaving some out.
|
||||
//!
|
||||
//! [`collapsed`] only lays out (pure, testable); [`draw`] paints a laid-out strip. It knows
|
||||
//! nothing of clicks or bumpers: the screen using it decides what each part does.
|
||||
//!
|
||||
//! Sizes are GUI px. The name is cut to the room between the arrows, and when the pips don't all
|
||||
//! fit across the area, the ones shown are a window around the selected page's.
|
||||
//!
|
||||
//! ```
|
||||
//! use horizon_client_render::hud::HudAtlas;
|
||||
//! use horizon_client_render::hud_kit::tabs;
|
||||
//!
|
||||
//! let atlas = HudAtlas::build(std::path::Path::new("/nonexistent")).unwrap();
|
||||
//! let names = ["General", "Camera", "Build"].map(String::from);
|
||||
//! let strip = tabs::collapsed(&atlas, &names, 0, [8.0, 100.0, 112.0, 16.0]);
|
||||
//! assert_eq!(strip.label, "General");
|
||||
//! assert!(!strip.has_prev && strip.has_next);
|
||||
//! assert_eq!(strip.pips.len(), 3);
|
||||
//! ```
|
||||
use crate::hud::{HudAtlas, HudBatch};
|
||||
use crate::hud_kit::colour;
|
||||
|
||||
/// A pip under the strip: its size and the step from one to the next, in GUI px.
|
||||
pub const PIP: [f32; 2] = [3.0, 2.0];
|
||||
pub const PIP_STEP: f32 = 5.0;
|
||||
/// How far, in GUI px, the arrows sit from the name.
|
||||
pub const ARROW_GAP: f32 = 4.0;
|
||||
/// An arrow with a page that way, and one at an end, with none.
|
||||
pub const ARROW: u32 = colour(0x9FA3AE, 255);
|
||||
pub const ARROW_OFF: u32 = colour(0x9FA3AE, 77);
|
||||
/// The pips (the selected one is white).
|
||||
pub const PIP_OFF: u32 = colour(0xFFFFFF, 89);
|
||||
pub const PIP_ON: u32 = colour(0xFFFFFF, 255);
|
||||
/// The name's colour.
|
||||
pub const NAME: u32 = colour(0xFFFFFF, 255);
|
||||
/// The line of text's height in GUI px (vanilla's font, without its shadow).
|
||||
const LINE: f32 = 8.0;
|
||||
|
||||
/// A laid-out strip.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Strip {
|
||||
/// The area it was laid out in, `[x, y, w, h]` GUI px.
|
||||
pub area: [f32; 4],
|
||||
/// The selected page's index in the names, and its name, cut to fit.
|
||||
pub selected: usize,
|
||||
pub label: String,
|
||||
/// Where the name is drawn, `[x, y, w, h]`.
|
||||
pub label_area: [f32; 4],
|
||||
/// The arrows' text positions `[x, y]` (before, after).
|
||||
pub arrows: [[f32; 2]; 2],
|
||||
/// Whether there is a page before and after the selected one: an arrow without is dimmed.
|
||||
/// A screen may change these (a search skipping pages without matches).
|
||||
pub has_prev: bool,
|
||||
pub has_next: bool,
|
||||
/// The pips `[x, y, w, h]` under the area, and whether each is the selected page's. At most
|
||||
/// as many as fit across the area.
|
||||
pub pips: Vec<([f32; 4], bool)>,
|
||||
}
|
||||
|
||||
/// The strip for `names` with `selected` in `area` (`[x, y, w, h]`, GUI px): the selected name
|
||||
/// between `‹ ›` arrows, cut to fit, with pips under the area. Past the end, the last page.
|
||||
pub fn collapsed(atlas: &HudAtlas, names: &[String], selected: usize, area: [f32; 4]) -> Strip {
|
||||
let [x, y, w, h] = area;
|
||||
let n = names.len();
|
||||
let selected = selected.min(n.saturating_sub(1));
|
||||
let text_y = y + ((h - LINE) / 2.0).floor();
|
||||
let centre = x + w / 2.0;
|
||||
let arrow_w = atlas.font.width("‹").max(atlas.font.width("›"));
|
||||
let room = (w - 2.0 * (arrow_w + ARROW_GAP)).max(0.0);
|
||||
let name = names.get(selected).map_or("", String::as_str);
|
||||
let label = crate::graphics_style::fit(atlas, name, room);
|
||||
let label_w = atlas.font.width(&label).ceil();
|
||||
let left = (centre - label_w / 2.0).floor();
|
||||
let arrows = [
|
||||
[left - ARROW_GAP - arrow_w, text_y],
|
||||
[left + label_w + ARROW_GAP, text_y],
|
||||
];
|
||||
// As many pips as fit across the area, a window round the selected one.
|
||||
let fit = (((w - PIP[0]) / PIP_STEP).floor() as usize + 1).min(n);
|
||||
let first = selected.saturating_sub(fit / 2).min(n - fit.min(n));
|
||||
let pips_x = (centre - (fit as f32 * PIP_STEP - (PIP_STEP - PIP[0])) / 2.0).floor();
|
||||
let pips = (0..fit)
|
||||
.map(|i| {
|
||||
let px = pips_x + i as f32 * PIP_STEP;
|
||||
([px, y + h + 1.0, PIP[0], PIP[1]], first + i == selected)
|
||||
})
|
||||
.collect();
|
||||
Strip {
|
||||
area,
|
||||
selected,
|
||||
label,
|
||||
label_area: [left, y, label_w, h],
|
||||
arrows,
|
||||
has_prev: selected > 0,
|
||||
has_next: selected + 1 < n,
|
||||
pips,
|
||||
}
|
||||
}
|
||||
|
||||
/// Paints `strip`: the name, its arrows (dimmed at an end) and the pips.
|
||||
pub fn draw(batch: &mut HudBatch, atlas: &HudAtlas, strip: &Strip) {
|
||||
let [lx, ly, ..] = strip.label_area;
|
||||
let text_y = ly + ((strip.area[3] - LINE) / 2.0).floor();
|
||||
batch.text(atlas, &strip.label, lx, text_y, NAME, true);
|
||||
let lit = [strip.has_prev, strip.has_next];
|
||||
for (([ax, ay], arrow), on) in strip.arrows.into_iter().zip(["‹", "›"]).zip(lit) {
|
||||
batch.text(
|
||||
atlas,
|
||||
arrow,
|
||||
ax,
|
||||
ay,
|
||||
if on { ARROW } else { ARROW_OFF },
|
||||
true,
|
||||
);
|
||||
}
|
||||
for (pip, on) in &strip.pips {
|
||||
let [px, py, pw, ph] = *pip;
|
||||
batch.fill(atlas, px, py, pw, ph, if *on { PIP_ON } else { PIP_OFF });
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn atlas() -> HudAtlas {
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
|
||||
}
|
||||
|
||||
fn names(list: &[&str]) -> Vec<String> {
|
||||
list.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
const AREA: [f32; 4] = [8.0, 100.0, 112.0, 16.0];
|
||||
|
||||
#[test]
|
||||
fn the_name_sits_centred_between_its_arrows_with_a_pip_per_page() {
|
||||
let atlas = atlas();
|
||||
let pages = names(&["General", "Sky", "Water", "Fog"]);
|
||||
let strip = collapsed(&atlas, &pages, 1, AREA);
|
||||
assert_eq!(strip.label, "Sky");
|
||||
let [nx, _, nw, _] = strip.label_area;
|
||||
assert!((nx + nw / 2.0 - 64.0).abs() <= 1.0, "{nx} {nw}");
|
||||
let [[before, _], [after, _]] = strip.arrows;
|
||||
assert!(before < nx && after >= nx + nw);
|
||||
let on: Vec<bool> = strip.pips.iter().map(|(_, on)| *on).collect();
|
||||
assert_eq!(on, [false, true, false, false]);
|
||||
// The pips sit under the row, centred.
|
||||
let (first, last) = (strip.pips[0].0, strip.pips[3].0);
|
||||
assert!(first[1] >= AREA[1] + AREA[3]);
|
||||
assert!(((first[0] + last[0] + last[2]) / 2.0 - 64.0).abs() <= 1.0);
|
||||
// Past the end, the last page; no pages, nothing to the arrows' sides.
|
||||
assert_eq!(collapsed(&atlas, &pages, 9, AREA).selected, 3);
|
||||
let none = collapsed(&atlas, &[], 0, AREA);
|
||||
assert!(none.label.is_empty() && none.pips.is_empty());
|
||||
assert!(!none.has_prev && !none.has_next);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_name_is_cut_to_stay_between_its_arrows() {
|
||||
let atlas = atlas();
|
||||
let long = names(&["A page with a very long name", "B"]);
|
||||
let strip = collapsed(&atlas, &long, 0, [0.0, 0.0, 80.0, 12.0]);
|
||||
let [_, [after, _]] = strip.arrows;
|
||||
assert!(after + atlas.font.width("›") <= 80.0, "{after}");
|
||||
assert!(strip.label.ends_with("..."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ends_have_no_page_beyond_and_draw_their_arrow_dimmed() {
|
||||
let atlas = atlas();
|
||||
let pages = names(&["A", "B", "C"]);
|
||||
let ends = [(0, false, true), (1, true, true), (2, true, false)];
|
||||
for (selected, prev, next) in ends {
|
||||
let strip = collapsed(&atlas, &pages, selected, AREA);
|
||||
assert_eq!((strip.has_prev, strip.has_next), (prev, next));
|
||||
}
|
||||
// The first page: its `‹` is drawn in the dimmed colour, its `›` in the arrow's.
|
||||
let alpha = |strip: &Strip, i: usize| {
|
||||
let mut batch = HudBatch::new([854, 480]);
|
||||
draw(&mut batch, &atlas, strip);
|
||||
let [ax, _] = strip.arrows[i];
|
||||
(batch.vertices.iter())
|
||||
.filter(|v| {
|
||||
(v.pos[0] - ax).abs() < 4.0 && v.color & 0x00FF_FFFF == ARROW & 0x00FF_FFFF
|
||||
})
|
||||
.map(|v| v.color >> 24)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let first = collapsed(&atlas, &pages, 0, AREA);
|
||||
assert_eq!(alpha(&first, 0), ARROW_OFF >> 24);
|
||||
assert_eq!(alpha(&first, 1), ARROW >> 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_pages_show_as_many_pips_as_fit_round_the_selected_one() {
|
||||
let atlas = atlas();
|
||||
let pages: Vec<String> = (0..60).map(|i| format!("Page {i}")).collect();
|
||||
let [x, _, w, _] = AREA;
|
||||
for selected in [0, 1, 30, 58, 59] {
|
||||
let strip = collapsed(&atlas, &pages, selected, AREA);
|
||||
assert!(strip.pips.len() < pages.len());
|
||||
for ([px, _, pw, _], _) in &strip.pips {
|
||||
assert!(*px >= x && px + pw <= x + w, "{selected}: {px}");
|
||||
}
|
||||
assert_eq!(strip.pips.iter().filter(|p| p.1).count(), 1, "{selected}");
|
||||
}
|
||||
// At the ends, the lit pip is at the window's end too.
|
||||
let first = collapsed(&atlas, &pages, 0, AREA);
|
||||
assert!(first.pips[0].1);
|
||||
let last = collapsed(&atlas, &pages, 59, AREA);
|
||||
assert!(last.pips.last().unwrap().1);
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,7 @@ pub mod options_fixes;
|
||||
pub mod options_ui;
|
||||
pub mod overlay;
|
||||
pub mod pack_browser;
|
||||
pub mod pack_options_ui;
|
||||
pub mod pack_screen;
|
||||
pub mod packs;
|
||||
pub mod pad_cursor;
|
||||
@@ -90,6 +91,7 @@ pub mod paged_maps;
|
||||
pub mod par;
|
||||
pub mod particles;
|
||||
pub mod poi_sections;
|
||||
pub mod polytone_post;
|
||||
pub mod post_chain;
|
||||
pub mod post_glsl;
|
||||
pub mod potent_sulfur;
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::{
|
||||
};
|
||||
pub mod bake;
|
||||
pub mod cache;
|
||||
mod kept;
|
||||
mod mesh;
|
||||
pub mod packed;
|
||||
mod samples;
|
||||
@@ -202,6 +203,15 @@ pub const MAX_CHUNKS: u32 = 1 << 22;
|
||||
/// structures) rather than sampled: cells of up to 4 blocks, out to about
|
||||
/// 1024 blocks at Full far detail.
|
||||
const DETAIL_LEVEL: u8 = 2;
|
||||
/// Kept tiles come back a level at a time (`Lod::bring_back`) while at most
|
||||
/// this many drawn tiles wait for their meshes.
|
||||
const RETURN_BACKLOG: usize = 32;
|
||||
/// Kept tiles of cells a chunk wide or less (levels up to this) come back once
|
||||
/// the near terrain is in and the seam with it sampled, or this long after the
|
||||
/// return: their thousands of meshes held up the near chunks' and the seam's,
|
||||
/// showing caves through unmeshed ground meanwhile.
|
||||
const RETURN_FINE_LEVEL: u8 = 4;
|
||||
const RETURN_SEAM_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
/// The most chunks one detail request asks for, one request each 250 ms:
|
||||
/// generated ones arrive at the decoration threads' pace, cached ones at once.
|
||||
const DETAIL_REQUEST: usize = 512;
|
||||
@@ -1321,6 +1331,12 @@ pub struct Lod {
|
||||
saved: Saved,
|
||||
reduced: Reduced,
|
||||
saved_order: VecDeque<(i32, i32)>,
|
||||
/// Tiles the plan let go, taken back on a return instead of sampled again.
|
||||
kept: kept::KeptTiles,
|
||||
/// Kept tiles the plan wants again, not taken back yet (`bring_back`).
|
||||
returning: Vec<TileKey>,
|
||||
/// When the plan last asked kept tiles back (`RETURN_SEAM_WAIT`).
|
||||
returned_at: std::time::Instant,
|
||||
/// `LodSource::keeps_observed`: tiles sampled without an observed chunk
|
||||
/// are sampled again (`resample_over`).
|
||||
keeps_observed: bool,
|
||||
@@ -1688,6 +1704,9 @@ impl Lod {
|
||||
saved: FxHashMap::default(),
|
||||
reduced: FxHashMap::default(),
|
||||
saved_order: VecDeque::new(),
|
||||
kept: kept::KeptTiles::new(kept::KEPT_FOR, kept::KEPT_BYTES),
|
||||
returning: Vec::new(),
|
||||
returned_at: std::time::Instant::now(),
|
||||
keeps_observed,
|
||||
}
|
||||
}
|
||||
@@ -1713,6 +1732,10 @@ impl Lod {
|
||||
pub fn stats(&self) -> (usize, usize) {
|
||||
(self.active.len(), self.ready.len())
|
||||
}
|
||||
/// The tiles kept for a return, and about how many bytes they hold.
|
||||
pub fn kept(&self) -> (usize, usize) {
|
||||
(self.kept.len(), self.kept.bytes())
|
||||
}
|
||||
pub fn progress(&self) -> (usize, usize) {
|
||||
let mut desired = 0;
|
||||
let mut sampled = 0;
|
||||
@@ -2030,6 +2053,8 @@ impl Lod {
|
||||
if self.retry.len() != retrying {
|
||||
self.wanted_stale = true;
|
||||
}
|
||||
// Kept tiles there are as old: sampled again if the plan wants them.
|
||||
self.kept.forget(|key, _| key.refreshed_by(area, widths));
|
||||
// A first sample in flight read the old data too.
|
||||
let keys: Vec<_> = self
|
||||
.ready
|
||||
@@ -2137,6 +2162,21 @@ impl Lod {
|
||||
for key in resampled_over(&self.ready, &self.pending, chunk) {
|
||||
self.mark_stale(key);
|
||||
}
|
||||
// Kept tiles with the chunk's hole: sampled again if the plan wants them.
|
||||
let (cx, cz) = chunk;
|
||||
self.kept.forget(|key, samples| {
|
||||
let size = key.size();
|
||||
let stride = size / CELLS as i32;
|
||||
let [ox, _, oz] = key.origin();
|
||||
stride > 16
|
||||
&& (ox..ox + size).contains(&(cx * 16))
|
||||
&& (oz..oz + size).contains(&(cz * 16))
|
||||
&& samples
|
||||
.get(
|
||||
((cz * 16 - oz) / stride * CELLS as i32 + (cx * 16 - ox) / stride) as usize,
|
||||
)
|
||||
.is_some_and(|c| *c == Column::default())
|
||||
});
|
||||
}
|
||||
/// A chunk's own columns as its level-0 tile; the same columns again
|
||||
/// (asked again after a `refresh`) mesh nothing.
|
||||
@@ -2464,6 +2504,8 @@ impl Lod {
|
||||
self.area = Some(area);
|
||||
self.replan(camera, detail, distance, elevation, aim);
|
||||
});
|
||||
} else if self.kept.due() {
|
||||
self.kept.trim();
|
||||
}
|
||||
let mut meshes = Vec::new();
|
||||
crate::spans::time("lod_results", || self.receive(&mut meshes));
|
||||
@@ -2473,6 +2515,7 @@ impl Lod {
|
||||
// and the nearest first, where far terrain meets the near chunks.
|
||||
meshes.sort_by_key(|(key, _)| (key.level, key.distance(camera)));
|
||||
crate::spans::time("lod_covered", || self.cover(covered));
|
||||
crate::spans::time("lod_return", || self.bring_back());
|
||||
if std::mem::take(&mut self.reselect) {
|
||||
self.rebuild_selection();
|
||||
}
|
||||
@@ -2513,7 +2556,41 @@ impl Lod {
|
||||
.copied()
|
||||
.collect();
|
||||
self.plan = plan;
|
||||
self.ready.retain(|key, _| self.plan.nodes.contains(key));
|
||||
self.kept.fit_to_view(self.plan.nodes.len());
|
||||
// Tiles leaving the plan are kept for a return, with their detail. Not
|
||||
// stale ones (their source has more), nor ones of cells a chunk wide or
|
||||
// less over the full-detail columns: never drawn there, they came back
|
||||
// as flat grey ground around the near terrain until it loaded.
|
||||
let leaving: Vec<_> = self
|
||||
.ready
|
||||
.keys()
|
||||
.filter(|key| !self.plan.nodes.contains(key))
|
||||
.copied()
|
||||
.collect();
|
||||
for key in leaving {
|
||||
let columns = self.ready.remove(&key).unwrap();
|
||||
if !self.stale.contains(&key) && !self.over_covered(key) {
|
||||
let mask = self.detail_masks.get(&key).copied();
|
||||
self.kept.keep(key, columns, mask);
|
||||
}
|
||||
}
|
||||
self.kept.trim();
|
||||
// Those the plan wants again come back (`bring_back`) instead of being
|
||||
// sampled: a level at a time, coarsest first. Not at the seam with the
|
||||
// near chunks (`Plan::stands_in`), sampled first and fresh there: kept
|
||||
// ones drew grey ground and the near chunks' caves until they loaded.
|
||||
let (returning, seam): (Vec<_>, Vec<_>) = self
|
||||
.plan
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|key| self.kept.contains(key) && !self.ready.contains_key(key))
|
||||
.copied()
|
||||
.partition(|key| self.plan.stands_in(*key));
|
||||
for key in seam {
|
||||
self.kept.take(&key);
|
||||
}
|
||||
self.returning = returning;
|
||||
self.returned_at = std::time::Instant::now();
|
||||
let nodes = &self.plan.nodes;
|
||||
self.mesh_samples.retain(|key| nodes.contains(key));
|
||||
// Queued tiles the view left are not sampled (a running one is
|
||||
@@ -2542,6 +2619,81 @@ impl Lod {
|
||||
self.mark_side_neighbors_of(key);
|
||||
}
|
||||
}
|
||||
/// Whether a tile of cells a chunk wide or less lies over a covered column.
|
||||
fn over_covered(&self, key: TileKey) -> bool {
|
||||
if key.size() > 16 * CELLS as i32 {
|
||||
return false;
|
||||
}
|
||||
let [ox, _, oz] = key.origin();
|
||||
let size = key.size();
|
||||
if let Some([x0, z0, x1, z1]) = self.covered_bounds
|
||||
&& (ox >= x1 || oz >= z1 || ox + size <= x0 || oz + size <= z0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let chunks = (size / 16).max(1);
|
||||
(0..chunks * chunks).any(|i| {
|
||||
self.covered.contains(&(
|
||||
ox.div_euclid(16) + i % chunks,
|
||||
oz.div_euclid(16) + i / chunks,
|
||||
))
|
||||
})
|
||||
}
|
||||
/// Whether a tile at the seam with the near chunks (`Plan::stands_in`)
|
||||
/// still waits for its samples.
|
||||
fn seam_open(&self) -> bool {
|
||||
self.plan.nodes.iter().any(|key| {
|
||||
!self.plan.stands_in(*key)
|
||||
&& !self.plan.split.contains(key)
|
||||
&& !self.ready.contains_key(key)
|
||||
})
|
||||
}
|
||||
/// Takes kept tiles back into the plan, the coarsest level first, the next
|
||||
/// once the meshes are in: all of them at once left the sky showing
|
||||
/// through every tile not meshed yet, where sampling fills a view coarse
|
||||
/// first and refines it.
|
||||
fn bring_back(&mut self) {
|
||||
if self.returning.is_empty() || self.unmeshed.len() > RETURN_BACKLOG {
|
||||
return;
|
||||
}
|
||||
let level = self
|
||||
.returning
|
||||
.iter()
|
||||
.map(|key| key.level)
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
if level <= RETURN_FINE_LEVEL
|
||||
&& self.returned_at.elapsed() < RETURN_SEAM_WAIT
|
||||
&& (self.near_loading || self.seam_open())
|
||||
{
|
||||
return;
|
||||
}
|
||||
let (now, later): (Vec<_>, Vec<_>) =
|
||||
self.returning.drain(..).partition(|key| key.level == level);
|
||||
self.returning = later;
|
||||
// Tiles forgotten meanwhile (`refresh`) are sampled: the queue skipped them.
|
||||
self.wanted_stale = true;
|
||||
let mut returned = Vec::new();
|
||||
for key in now {
|
||||
if self.ready.contains_key(&key) || self.pending.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
if let Some((columns, mask)) = self.kept.take(&key) {
|
||||
self.ready.insert(key, columns);
|
||||
if let Some(mask) = mask {
|
||||
self.detail_masks.insert(key, mask);
|
||||
}
|
||||
returned.push(key);
|
||||
}
|
||||
}
|
||||
if returned.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.rebuild_selection();
|
||||
for key in returned {
|
||||
self.refresh_path(key);
|
||||
}
|
||||
}
|
||||
/// Recomputes every active tile after a plan change, then marks only the
|
||||
/// tiles whose selection changed (plus their neighbours).
|
||||
fn rebuild_selection(&mut self) {
|
||||
@@ -3020,6 +3172,7 @@ impl Lod {
|
||||
|| (self.plan.split.contains(&key) && wanted.class != BACKDROP)
|
||||
|| !self.plan.nodes.contains(&key)
|
||||
|| self.retry.contains_key(&key)
|
||||
|| self.kept.contains(&key)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -3947,6 +4100,90 @@ mod tests {
|
||||
}
|
||||
/// Detail read before a refresh over its chunk is dropped (asked
|
||||
/// again); elsewhere, or read after, it is kept.
|
||||
/// Out and home again: the tiles the trip let go come back, the coarsest
|
||||
/// level first and a level once the meshes are in, without a sample job.
|
||||
#[test]
|
||||
fn a_return_takes_kept_tiles_back_coarse_first_instead_of_sampling() {
|
||||
let mut lod = Lod::scheduler_for_test();
|
||||
let filled = Arc::new(vec![
|
||||
Column::from_surface(Some(Surface {
|
||||
height: 70,
|
||||
state: BlockStateId(1),
|
||||
water: None,
|
||||
biome: 0,
|
||||
}));
|
||||
CELLS * CELLS
|
||||
]);
|
||||
lod.replan([0, 0], 2, 64, 0, None);
|
||||
let home: Vec<_> = lod.plan.nodes.iter().copied().collect();
|
||||
for &key in &home {
|
||||
lod.ready.insert(key, filled.clone());
|
||||
}
|
||||
lod.replan([200_000, 0], 2, 64, 0, None);
|
||||
let left: Vec<_> = home
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|key| !lod.plan.nodes.contains(key))
|
||||
.collect();
|
||||
assert!(!left.is_empty() && left.iter().all(|key| !lod.ready.contains_key(key)));
|
||||
lod.replan([0, 0], 2, 64, 0, None);
|
||||
// Those at the seam with the near chunks are sampled fresh instead.
|
||||
let (wanted, seam): (Vec<_>, Vec<_>) = left
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|key| lod.plan.nodes.contains(key))
|
||||
.partition(|key| lod.plan.stands_in(*key));
|
||||
assert!(!wanted.is_empty() && !seam.is_empty());
|
||||
assert!(seam.iter().all(|key| !lod.kept.contains(key)));
|
||||
// Nothing else to sample: they are on their way back.
|
||||
lod.request_samples([0, 0]);
|
||||
assert!(wanted.iter().all(|key| !lod.pending.contains(key)));
|
||||
assert!(seam.iter().any(|key| lod.pending.contains(key)));
|
||||
let coarsest = wanted.iter().map(|key| key.level).max().unwrap();
|
||||
// Meshes outstanding: nothing comes back yet.
|
||||
lod.unmeshed
|
||||
.extend((0..=RETURN_BACKLOG as i32).map(|x| TileKey {
|
||||
x,
|
||||
z: 9_999,
|
||||
level: 0,
|
||||
}));
|
||||
lod.bring_back();
|
||||
assert!(wanted.iter().all(|key| !lod.ready.contains_key(key)));
|
||||
lod.unmeshed.clear();
|
||||
lod.bring_back();
|
||||
for key in &wanted {
|
||||
assert_eq!(
|
||||
lod.ready.contains_key(key),
|
||||
key.level == coarsest,
|
||||
"{key:?}"
|
||||
);
|
||||
}
|
||||
// Finer ones wait for the seam's samples.
|
||||
for _ in 0..64 {
|
||||
lod.bring_back();
|
||||
}
|
||||
assert!(
|
||||
!lod.returning.is_empty()
|
||||
&& lod
|
||||
.returning
|
||||
.iter()
|
||||
.all(|key| key.level <= RETURN_FINE_LEVEL)
|
||||
);
|
||||
for key in &seam {
|
||||
lod.ready.insert(*key, filled.clone());
|
||||
}
|
||||
assert!(!lod.seam_open());
|
||||
while !lod.returning.is_empty() {
|
||||
lod.unmeshed.clear();
|
||||
lod.bring_back();
|
||||
}
|
||||
assert!(
|
||||
wanted
|
||||
.iter()
|
||||
.all(|key| lod.ready.get(key).is_some_and(|s| Arc::ptr_eq(s, &filled)))
|
||||
);
|
||||
assert!(lod.pending.iter().all(|key| seam.contains(key)));
|
||||
}
|
||||
#[test]
|
||||
fn only_detail_a_later_refresh_covers_is_dropped() {
|
||||
let mut lod = Lod::scheduler_for_test();
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Far tiles the plan let go, kept a while for a return: a teleport back, a
|
||||
//! trip out and home again, the far distance lowered and raised. Taken back
|
||||
//! (`Lod::replan`) instead of sampled again, which costs every far tile its
|
||||
//! noise, biomes and surface rules a second time.
|
||||
//!
|
||||
//! Bounded in time (`KEPT_FOR`) and in memory (`KEPT_BYTES`); past the bytes
|
||||
//! the finest go first, so the coarse shape of every place nearby stays
|
||||
//! longest. Nothing is written to disk: the tiles leave with the `Lod`, that
|
||||
//! is with the world.
|
||||
use super::{Column, Span, TileKey};
|
||||
use crate::fast_hash::FxHashMap;
|
||||
use std::{
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
/// How long a tile is kept after the plan let it go.
|
||||
pub(super) const KEPT_FOR: Duration = Duration::from_secs(600);
|
||||
/// About as many bytes as a 512-chunk view's tiles hold, so one whole place
|
||||
/// is kept; a far tile is some 20 to 60 KB. The most kept at any far distance.
|
||||
pub(super) const KEPT_BYTES: usize = 512 << 20;
|
||||
/// The tiles a 512-chunk view plans: a smaller view keeps a share of
|
||||
/// `KEPT_BYTES` as large as its share of these (`KeptTiles::fit_to_view`), so
|
||||
/// travelling with a short far distance doesn't fill the whole budget.
|
||||
const VIEW_512_TILES: usize = 4700;
|
||||
|
||||
struct Kept {
|
||||
columns: Arc<Vec<Column>>,
|
||||
/// Its detail (`Lod::detail_masks`), so refined cells are not asked again.
|
||||
mask: Option<[u64; 4]>,
|
||||
at: Instant,
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
pub(super) struct KeptTiles {
|
||||
tiles: FxHashMap<TileKey, Kept>,
|
||||
bytes: usize,
|
||||
checked: Instant,
|
||||
time: Duration,
|
||||
budget: usize,
|
||||
}
|
||||
|
||||
impl KeptTiles {
|
||||
pub(super) fn new(time: Duration, budget: usize) -> Self {
|
||||
Self {
|
||||
tiles: FxHashMap::default(),
|
||||
bytes: 0,
|
||||
checked: Instant::now(),
|
||||
time,
|
||||
budget,
|
||||
}
|
||||
}
|
||||
|
||||
/// The budget for a view that plans `tiles`: `KEPT_BYTES` for a 512-chunk
|
||||
/// view, a share of it for a smaller one.
|
||||
pub(super) fn fit_to_view(&mut self, tiles: usize) {
|
||||
self.budget = (KEPT_BYTES / VIEW_512_TILES)
|
||||
.saturating_mul(tiles)
|
||||
.min(KEPT_BYTES);
|
||||
}
|
||||
|
||||
pub(super) fn len(&self) -> usize {
|
||||
self.tiles.len()
|
||||
}
|
||||
|
||||
pub(super) fn bytes(&self) -> usize {
|
||||
self.bytes
|
||||
}
|
||||
|
||||
pub(super) fn contains(&self, key: &TileKey) -> bool {
|
||||
self.tiles.contains_key(key)
|
||||
}
|
||||
|
||||
pub(super) fn keep(&mut self, key: TileKey, columns: Arc<Vec<Column>>, mask: Option<[u64; 4]>) {
|
||||
// Spans past the inline four live on the heap.
|
||||
let bytes = std::mem::size_of_val(columns.as_slice())
|
||||
+ columns
|
||||
.iter()
|
||||
.filter(|column| column.spans.len() > 4)
|
||||
.map(|column| column.spans.len() * std::mem::size_of::<Span>())
|
||||
.sum::<usize>();
|
||||
let kept = Kept {
|
||||
columns,
|
||||
mask,
|
||||
at: Instant::now(),
|
||||
bytes,
|
||||
};
|
||||
self.bytes += bytes;
|
||||
if let Some(old) = self.tiles.insert(key, kept) {
|
||||
self.bytes -= old.bytes;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn take(&mut self, key: &TileKey) -> Option<(Arc<Vec<Column>>, Option<[u64; 4]>)> {
|
||||
let kept = self.tiles.remove(key)?;
|
||||
self.bytes -= kept.bytes;
|
||||
Some((kept.columns, kept.mask))
|
||||
}
|
||||
|
||||
/// Forgets the tiles `stale` picks: their source has more than they hold.
|
||||
pub(super) fn forget(&mut self, mut stale: impl FnMut(&TileKey, &[Column]) -> bool) {
|
||||
let bytes = &mut self.bytes;
|
||||
self.tiles.retain(|key, kept| {
|
||||
let keep = !stale(key, &kept.columns);
|
||||
if !keep {
|
||||
*bytes -= kept.bytes;
|
||||
}
|
||||
keep
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether a minute went by since the last `trim`.
|
||||
pub(super) fn due(&self) -> bool {
|
||||
!self.tiles.is_empty() && self.checked.elapsed() >= Duration::from_secs(60)
|
||||
}
|
||||
|
||||
/// Drops the tiles kept past their time, then, past the budget, the
|
||||
/// finest and the oldest first.
|
||||
pub(super) fn trim(&mut self) {
|
||||
self.checked = Instant::now();
|
||||
let (bytes, time) = (&mut self.bytes, self.time);
|
||||
self.tiles.retain(|_, kept| {
|
||||
let keep = kept.at.elapsed() < time;
|
||||
if !keep {
|
||||
*bytes -= kept.bytes;
|
||||
}
|
||||
keep
|
||||
});
|
||||
if self.bytes <= self.budget {
|
||||
return;
|
||||
}
|
||||
let mut order: Vec<_> = self
|
||||
.tiles
|
||||
.iter()
|
||||
.map(|(key, kept)| (key.level, kept.at, *key))
|
||||
.collect();
|
||||
order.sort_unstable();
|
||||
for (_, _, key) in order {
|
||||
if self.bytes <= self.budget {
|
||||
break;
|
||||
}
|
||||
self.take(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::lod::{CELLS, Surface};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
|
||||
fn tile(height: i32) -> Arc<Vec<Column>> {
|
||||
Arc::new(vec![
|
||||
Column::from_surface(Some(Surface {
|
||||
height,
|
||||
state: BlockStateId(1),
|
||||
water: None,
|
||||
biome: 0,
|
||||
}));
|
||||
CELLS * CELLS
|
||||
])
|
||||
}
|
||||
|
||||
fn key(level: u8) -> TileKey {
|
||||
TileKey { x: 3, z: -2, level }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_kept_tile_comes_back_with_its_detail_once() {
|
||||
let mut kept = KeptTiles::new(KEPT_FOR, usize::MAX);
|
||||
kept.keep(key(0), tile(64), Some([u64::MAX; 4]));
|
||||
let (columns, mask) = kept.take(&key(0)).unwrap();
|
||||
assert_eq!(columns[0].surface.unwrap().height, 64);
|
||||
assert_eq!(mask, Some([u64::MAX; 4]));
|
||||
assert!(kept.take(&key(0)).is_none());
|
||||
assert_eq!(kept.bytes(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_smaller_view_keeps_a_share_of_the_budget() {
|
||||
let mut kept = KeptTiles::new(KEPT_FOR, KEPT_BYTES);
|
||||
kept.fit_to_view(VIEW_512_TILES);
|
||||
assert_eq!(kept.budget, KEPT_BYTES / VIEW_512_TILES * VIEW_512_TILES);
|
||||
// A 128-chunk view plans about a sixteenth of the tiles.
|
||||
kept.fit_to_view(VIEW_512_TILES / 16);
|
||||
assert!(kept.budget <= KEPT_BYTES / 16);
|
||||
kept.fit_to_view(VIEW_512_TILES * 4);
|
||||
assert_eq!(kept.budget, KEPT_BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn past_the_budget_the_finest_go_first_and_past_the_time_all() {
|
||||
let mut kept = KeptTiles::new(KEPT_FOR, usize::MAX);
|
||||
kept.keep(key(4), tile(80), None);
|
||||
let one = kept.bytes();
|
||||
kept.keep(key(0), tile(64), None);
|
||||
kept.keep(key(1), tile(70), None);
|
||||
kept.budget = one * 2;
|
||||
kept.trim();
|
||||
assert!(kept.contains(&key(4)) && kept.contains(&key(1)) && !kept.contains(&key(0)));
|
||||
assert_eq!(kept.bytes(), one * 2);
|
||||
kept.time = Duration::ZERO;
|
||||
kept.trim();
|
||||
assert_eq!((kept.len(), kept.bytes()), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forgotten_tiles_are_not_taken_back() {
|
||||
let mut kept = KeptTiles::new(KEPT_FOR, usize::MAX);
|
||||
kept.keep(key(2), tile(64), None);
|
||||
kept.keep(key(3), tile(64), None);
|
||||
kept.forget(|key, _| key.level == 2);
|
||||
assert!(kept.take(&key(2)).is_none());
|
||||
assert!(kept.take(&key(3)).is_some());
|
||||
assert_eq!(kept.bytes(), 0);
|
||||
}
|
||||
}
|
||||
@@ -1334,6 +1334,8 @@ pub enum Page {
|
||||
ShaderPacks,
|
||||
/// The picked pack's options: the shader screen (`shader_ui`) at the pack's main screen.
|
||||
ShaderOptions,
|
||||
/// The selected resource packs' Polytone options (`pack_options_ui`), from the pack screen.
|
||||
PackOptions,
|
||||
/// Horizon's graphics settings (`graphics_ui`).
|
||||
Graphics,
|
||||
GraphicsAdvanced,
|
||||
@@ -1486,6 +1488,12 @@ fn matches_focus(id: &FocusId, i: usize, wd: &Widget) -> bool {
|
||||
/// What the game must do after an interaction.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Event {
|
||||
/// The Pack Options page closed with changes: the player's choices of its entries, the
|
||||
/// ones that differ from their defaults (the others went back to theirs).
|
||||
PackOptions {
|
||||
entries: Vec<String>,
|
||||
chosen: std::collections::BTreeMap<String, horizon_polytone::pack::ConfigValue>,
|
||||
},
|
||||
/// An accepted button press or the release of a slider.
|
||||
ButtonClick,
|
||||
/// Horizon's own: download the Modrinth hit at this index of the pack
|
||||
@@ -1662,7 +1670,12 @@ pub struct OptionsUi {
|
||||
controller: shell::Shell<crate::controller_ui::ControllerScreen>,
|
||||
/// Horizon Extras, on the settings shell.
|
||||
extras: shell::Shell<crate::extras_ui::ExtrasScreen>,
|
||||
pub shader: crate::shader_ui::ShaderUi,
|
||||
/// Shaders: the shader packs and the picked one's options, on the settings shell.
|
||||
shader: shell::Shell<crate::shader_ui::ShaderScreen>,
|
||||
/// The Pack Options page, and the packs' Polytone config entries by pack id (the game
|
||||
/// fills them beside `pack_infos`).
|
||||
pub pack_options: crate::pack_options_ui::PackOptionsUi,
|
||||
pub pack_configs: std::collections::BTreeMap<String, horizon_polytone::pack::Configs>,
|
||||
pub split_profiles: crate::split_profiles::SplitProfiles,
|
||||
/// HDR calibration while its page is open.
|
||||
pub calibration: Option<crate::hdr_calibration::Calibration>,
|
||||
@@ -1741,6 +1754,8 @@ impl Default for OptionsUi {
|
||||
controller: Default::default(),
|
||||
extras: Default::default(),
|
||||
shader: Default::default(),
|
||||
pack_options: Default::default(),
|
||||
pack_configs: Default::default(),
|
||||
split_profiles: Default::default(),
|
||||
calibration: None,
|
||||
calibration_fullscreen: None,
|
||||
@@ -2098,7 +2113,6 @@ impl OptionsUi {
|
||||
.pack_screen
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.search_focused()),
|
||||
_ if Self::shader_page(self.page()).is_some() => self.shader.typing(),
|
||||
_ => match self.shell() {
|
||||
Some(shell) => shell.typing(),
|
||||
None => self.settings_search_active(),
|
||||
@@ -2209,12 +2223,12 @@ impl OptionsUi {
|
||||
if page == Page::Packs {
|
||||
self.pack_screen = None;
|
||||
}
|
||||
if let Some(at) = Self::shader_page(page) {
|
||||
self.shader.open(at);
|
||||
}
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
shell.open();
|
||||
}
|
||||
if let Some(at) = Self::shader_page(page) {
|
||||
self.shader.show(at);
|
||||
}
|
||||
if let Some(at) = Self::graphics_page(page) {
|
||||
self.graphics.show(at);
|
||||
}
|
||||
@@ -2239,6 +2253,7 @@ impl OptionsUi {
|
||||
Page::Extras => Some(&self.extras),
|
||||
page if Self::graphics_page(page).is_some() => Some(&self.graphics),
|
||||
page if Self::controller_page(page).is_some() => Some(&self.controller),
|
||||
page if Self::shader_page(page).is_some() => Some(&self.shader),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -2247,6 +2262,7 @@ impl OptionsUi {
|
||||
Page::Extras => Some(&mut self.extras),
|
||||
page if Self::graphics_page(page).is_some() => Some(&mut self.graphics),
|
||||
page if Self::controller_page(page).is_some() => Some(&mut self.controller),
|
||||
page if Self::shader_page(page).is_some() => Some(&mut self.shader),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -2291,38 +2307,6 @@ impl OptionsUi {
|
||||
}
|
||||
events
|
||||
}
|
||||
/// What the shader screen's actions do to these options.
|
||||
fn shader_events(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
actions: Vec<crate::shader_ui::Action>,
|
||||
) -> Vec<Event> {
|
||||
use crate::shader_ui::Action;
|
||||
let mut events = Vec::new();
|
||||
for action in actions {
|
||||
match action {
|
||||
Action::ButtonClick => events.push(Event::ButtonClick),
|
||||
Action::Changed => events.push(Event::Changed),
|
||||
Action::SliderDrag => events.push(Event::SliderDrag),
|
||||
Action::Picked => {
|
||||
// The last pack's changed options are kept before the next one's are read.
|
||||
if s.shader_options.as_ref().is_some_and(|o| o.changed) {
|
||||
events.push(Event::ShaderOptions);
|
||||
}
|
||||
events.push(Event::ShaderPack);
|
||||
events.push(Event::OpenShaderOptions);
|
||||
}
|
||||
Action::OpenFolder => events.push(Event::OpenShaderPackFolder),
|
||||
Action::Apply => {
|
||||
events.push(Event::ShaderOptions);
|
||||
events.push(Event::OpenShaderOptions);
|
||||
}
|
||||
Action::NativeChanged(pack) => events.push(Event::NativePackOptions(pack)),
|
||||
Action::Done => events.extend(self.back(s)),
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
/// Where the shader screen opens for `page`, if it is one of its pages.
|
||||
fn shader_page(page: Page) -> Option<crate::shader_ui::Tab> {
|
||||
match page {
|
||||
@@ -2378,6 +2362,7 @@ impl OptionsUi {
|
||||
Page::NativeMod(_) => "Mod Settings",
|
||||
Page::ShaderPacks => "horizon.pages.shader_packs",
|
||||
Page::ShaderOptions => "horizon.pages.shader_options",
|
||||
Page::PackOptions => "horizon.pack.options.title",
|
||||
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr | Page::HdrCalibration => {
|
||||
"options.videoTitle"
|
||||
}
|
||||
@@ -3680,8 +3665,8 @@ impl OptionsUi {
|
||||
c.draw(batch, atlas, cursor);
|
||||
return;
|
||||
}
|
||||
if Self::shader_page(self.page()).is_some() {
|
||||
self.shader.draw(s, batch, atlas, cursor, self.in_world);
|
||||
if self.page() == Page::PackOptions {
|
||||
self.pack_options.draw(batch, atlas, cursor, self.in_world);
|
||||
return;
|
||||
}
|
||||
let waiting = self.waiting();
|
||||
@@ -4059,9 +4044,19 @@ impl OptionsUi {
|
||||
});
|
||||
return self.shell_events(s, outputs);
|
||||
}
|
||||
if Self::shader_page(self.page()).is_some() {
|
||||
let actions = self.shader.click(s, (x, y), size, drag, right, self.shift);
|
||||
return self.shader_events(s, actions);
|
||||
if self.page() == Page::PackOptions {
|
||||
let mut events = Vec::new();
|
||||
for action in self
|
||||
.pack_options
|
||||
.click((x, y), size, drag, right || self.shift)
|
||||
{
|
||||
match action {
|
||||
crate::pack_options_ui::Action::ButtonClick => events.push(Event::ButtonClick),
|
||||
crate::pack_options_ui::Action::SliderDrag => {}
|
||||
crate::pack_options_ui::Action::Done => events.extend(self.back(s)),
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
if self.page() == Page::Packs {
|
||||
let mut events = Vec::new();
|
||||
@@ -4091,6 +4086,14 @@ impl OptionsUi {
|
||||
crate::pack_screen::Action::BrowsePick(i) => {
|
||||
events.push(Event::DownloadPack(i));
|
||||
}
|
||||
crate::pack_screen::Action::Options(id) => {
|
||||
let mut configs = horizon_polytone::pack::Configs::default();
|
||||
if let Some(own) = self.pack_configs.get(&id) {
|
||||
configs.merge(own);
|
||||
}
|
||||
self.pack_options = crate::pack_options_ui::PackOptionsUi::open(configs);
|
||||
self.push(Page::PackOptions);
|
||||
}
|
||||
crate::pack_screen::Action::None => {}
|
||||
}
|
||||
return events;
|
||||
@@ -4329,15 +4332,19 @@ impl OptionsUi {
|
||||
// AbstractSliderButton suppresses the press sound and plays once on
|
||||
// release, even if the pointer has left the widget or its value stayed.
|
||||
let slider = self.dragging.take().is_some();
|
||||
let (shader_slider, shader_pack) = self.shader.release();
|
||||
let shell_slider = self.shell_mut().is_some_and(|shell| shell.release());
|
||||
let graphics_slider = shader_slider | shell_slider;
|
||||
let shell_slider = self.shell_mut().and_then(|shell| shell.release());
|
||||
self.pack_options.release();
|
||||
let calibration_slider = self.calibration.as_mut().is_some_and(|c| c.release());
|
||||
let split_drop = self.split_setup.release();
|
||||
if slider || graphics_slider || calibration_slider {
|
||||
if slider || shell_slider.is_some() || calibration_slider {
|
||||
let mut events = vec![Event::ButtonClick, Event::Changed];
|
||||
// A pack's option is saved, and the pack rebuilt, once per drag.
|
||||
events.extend(shader_pack.map(Event::NativePackOptions));
|
||||
// What the screen does once a drag ends: a shader pack's option is saved, and the
|
||||
// pack rebuilt, once per drag.
|
||||
let done = shell_slider.into_iter().flatten();
|
||||
events.extend(done.filter_map(|output| match output {
|
||||
shell::Output::Event(event) => Some(event),
|
||||
_ => None,
|
||||
}));
|
||||
events
|
||||
} else if split_drop {
|
||||
vec![Event::ButtonClick]
|
||||
@@ -4420,8 +4427,8 @@ impl OptionsUi {
|
||||
self.studio.wheel(amount);
|
||||
return Vec::new();
|
||||
}
|
||||
if Self::shader_page(self.page()).is_some() {
|
||||
self.shader.scroll(s, size, amount);
|
||||
if self.page() == Page::PackOptions {
|
||||
self.pack_options.scroll(size, amount);
|
||||
return Vec::new();
|
||||
}
|
||||
let plain = self.resource_error.is_none()
|
||||
@@ -4507,8 +4514,8 @@ impl OptionsUi {
|
||||
self.studio.wheel(amount);
|
||||
return;
|
||||
}
|
||||
if Self::shader_page(self.page()).is_some() {
|
||||
self.shader.scroll(s, size, amount);
|
||||
if self.page() == Page::PackOptions {
|
||||
self.pack_options.scroll(size, amount);
|
||||
return;
|
||||
}
|
||||
match &mut self.pack_screen {
|
||||
@@ -4576,7 +4583,19 @@ impl OptionsUi {
|
||||
self.pack_screen = Some(screen);
|
||||
}
|
||||
}
|
||||
self.pack_screen.as_mut().expect("made above")
|
||||
// The cards of packs with Polytone options carry their button (data packs have none).
|
||||
let with_options = if self.data_pack_dir.is_none() {
|
||||
self.pack_configs
|
||||
.iter()
|
||||
.filter(|(_, c)| !c.entries().is_empty())
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect()
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
let screen = self.pack_screen.as_mut().expect("made above");
|
||||
screen.with_options = with_options;
|
||||
screen
|
||||
}
|
||||
/// Escape or Done: leave the current page, applying page results.
|
||||
/// After an input on HDR calibration: Save keeps what it landed on, Cancel what was
|
||||
@@ -4633,6 +4652,16 @@ impl OptionsUi {
|
||||
self.language_search.focused = false;
|
||||
self.can_change_value = false;
|
||||
match page {
|
||||
Page::PackOptions => {
|
||||
let ui = std::mem::take(&mut self.pack_options);
|
||||
if ui.changed {
|
||||
let entries = ui.configs.entries().keys().cloned().collect();
|
||||
events.push(Event::PackOptions {
|
||||
entries,
|
||||
chosen: ui.configs.chosen(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Page::ShaderOptions | Page::ShaderPacks => {
|
||||
if s.shader_options.as_ref().is_some_and(|o| o.changed) {
|
||||
events.push(Event::ShaderOptions);
|
||||
@@ -4695,8 +4724,6 @@ impl OptionsUi {
|
||||
if shell.typing() {
|
||||
shell.type_text(text);
|
||||
}
|
||||
} else if Self::shader_page(self.page()).is_some() && self.shader.typing() {
|
||||
self.shader.type_text(text);
|
||||
} else if self.settings_search_active() {
|
||||
self.settings_search.insert_text(text);
|
||||
self.scroll = 0.0;
|
||||
@@ -4731,9 +4758,6 @@ impl OptionsUi {
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
return shell.shortcut(shortcut, clipboard);
|
||||
}
|
||||
if Self::shader_page(self.page()).is_some() {
|
||||
return self.shader.shortcut(shortcut, clipboard);
|
||||
}
|
||||
if self.settings_search_active() {
|
||||
if self.settings_search.shortcut(shortcut, clipboard) {
|
||||
self.scroll = 0.0;
|
||||
@@ -4855,16 +4879,6 @@ impl OptionsUi {
|
||||
let outputs = shell.key(s, key, text, shift, gui);
|
||||
return self.shell_events(s, outputs);
|
||||
}
|
||||
if Self::shader_page(self.page()).is_some() {
|
||||
let size = (self.size.0.max(480.0), self.size.1.max(240.0));
|
||||
let (actions, handled) = self.shader.key_modifiers(s, key, text, shift, size);
|
||||
let mut events = self.shader_events(s, actions);
|
||||
// Escape leaves a screen a link opened first, then the shader screen.
|
||||
if key == K::Escape && !handled {
|
||||
events.extend(self.back(s));
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
if key == K::Escape {
|
||||
return self.back(s);
|
||||
@@ -5525,8 +5539,20 @@ mod tests {
|
||||
/// read, and leaving the shader screen applies them.
|
||||
#[test]
|
||||
fn the_shader_screen_saves_changed_options_before_reading_the_next_packs() {
|
||||
use crate::shader_ui::Action;
|
||||
let mut s = Settings::default();
|
||||
use crate::shader_pack_list::{Entry, Kind};
|
||||
let entry = |file: &str| Entry {
|
||||
file: file.into(),
|
||||
key: file.into(),
|
||||
title: file.into(),
|
||||
kind: Kind::Classic,
|
||||
options: Vec::new(),
|
||||
values: Vec::new(),
|
||||
pages: Vec::new(),
|
||||
};
|
||||
let mut s = Settings {
|
||||
shader_packs: vec![entry("A.zip"), entry("B.zip")],
|
||||
..Settings::default()
|
||||
};
|
||||
s.horizon.shader_pack = "A.zip".into();
|
||||
let mut options = crate::shader_options::ShaderOptions::new(
|
||||
"A.zip".into(),
|
||||
@@ -5540,15 +5566,21 @@ mod tests {
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.open(false);
|
||||
ui.push(Page::ShaderPacks);
|
||||
let events = ui.shader_events(&mut s, vec![Action::Picked]);
|
||||
let size = (854.0, 480.0);
|
||||
let (x, y) = ui
|
||||
.shader
|
||||
.point_at(&s, size, crate::shader_ui::Item::Pack(1));
|
||||
let events = ui.click(&mut s, &atlas(), x, y, size, [1708, 960], false, false);
|
||||
assert!(matches!(
|
||||
events[..],
|
||||
[
|
||||
Event::ButtonClick,
|
||||
Event::ShaderOptions,
|
||||
Event::ShaderPack,
|
||||
Event::OpenShaderOptions
|
||||
]
|
||||
));
|
||||
assert_eq!(s.horizon.shader_pack, "B.zip");
|
||||
let events = ui.back(&mut s);
|
||||
assert!(matches!(events[0], Event::ShaderOptions));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,558 @@
|
||||
//! The Pack Options page (#11486): the config entries of the selected resource packs' Polytone
|
||||
//! `config_entries` (wiki "Polytone Configs"), in the frame of Horizon's settings screens
|
||||
//! (`settings_shell`). Numbers are sliders, texts cycle, booleans switch; the entries sit in
|
||||
//! their `section`s, named by the packs' own translations. Leaving with Done keeps what changed,
|
||||
//! and the packs load again with it.
|
||||
use crate::widgets::Action as Declared;
|
||||
use crate::{
|
||||
graphics_style as style,
|
||||
hud::{HudAtlas, HudBatch},
|
||||
lang,
|
||||
settings_shell::{self as shell, Columns, Line, Lines},
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
use horizon_polytone::pack::{ConfigKind, ConfigValue, Configs};
|
||||
|
||||
/// What a click asks of the options screen.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
ButtonClick,
|
||||
SliderDrag,
|
||||
/// Done: leave, keeping what changed.
|
||||
Done,
|
||||
}
|
||||
|
||||
/// The page's state: the entries being edited, how they are listed, and the pointer's hold.
|
||||
#[derive(Default)]
|
||||
pub struct PackOptionsUi {
|
||||
/// The selected packs' entries and the values being picked.
|
||||
pub configs: Configs,
|
||||
/// Entry ids, by the row index the list uses.
|
||||
ids: Vec<String>,
|
||||
/// The sections in order: the `section` id (none for entries without one) and its rows.
|
||||
sections: Vec<(Option<String>, Vec<u16>)>,
|
||||
scroll: ui::Scroll,
|
||||
dragging: Option<u16>,
|
||||
/// A value changed since the page opened.
|
||||
pub changed: bool,
|
||||
}
|
||||
|
||||
/// The presets' row (and its section): past every entry's index.
|
||||
const PRESETS: u16 = u16::MAX;
|
||||
|
||||
/// A translation, or `fallback` where no language has the key.
|
||||
fn translated(key: &str, fallback: impl FnOnce() -> String) -> String {
|
||||
if lang::has(key) {
|
||||
lang::get(key)
|
||||
} else {
|
||||
fallback()
|
||||
}
|
||||
}
|
||||
|
||||
/// `namespace:path` split, `minecraft` when there is no namespace.
|
||||
fn split(id: &str) -> (&str, &str) {
|
||||
id.split_once(':').unwrap_or(("minecraft", id))
|
||||
}
|
||||
|
||||
/// A name for an id without a translation: `fog_density` is "Fog density".
|
||||
fn readable(path: &str) -> String {
|
||||
let words = path.rsplit('/').next().unwrap_or(path).replace('_', " ");
|
||||
let mut chars = words.chars();
|
||||
chars
|
||||
.next()
|
||||
.map(|c| c.to_uppercase().chain(chars).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
impl PackOptionsUi {
|
||||
/// The page opens on `configs`, the selected packs' entries with the player's values.
|
||||
pub fn open(configs: Configs) -> Self {
|
||||
let mut ids: Vec<(String, Option<String>, i64)> = configs
|
||||
.entries()
|
||||
.iter()
|
||||
.map(|(id, e)| (id.clone(), e.section.clone(), e.display_order))
|
||||
.collect();
|
||||
ids.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
|
||||
// A section's place: the smallest `section_order` among its entries, else its name.
|
||||
let mut sections: Vec<(Option<String>, Option<i64>, Vec<u16>)> = Vec::new();
|
||||
for (row, (id, section, _)) in ids.iter().enumerate() {
|
||||
let order = configs.entries()[id].section_order;
|
||||
match sections.iter_mut().find(|s| &s.0 == section) {
|
||||
Some(s) => {
|
||||
s.1 = match (s.1, order) {
|
||||
(Some(a), Some(b)) => Some(a.min(b)),
|
||||
(a, b) => a.or(b),
|
||||
};
|
||||
s.2.push(row as u16);
|
||||
}
|
||||
None => sections.push((section.clone(), order, vec![row as u16])),
|
||||
}
|
||||
}
|
||||
sections.sort_by(|a, b| match (a.1, b.1) {
|
||||
(Some(x), Some(y)) => x.cmp(&y),
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => a.0.cmp(&b.0),
|
||||
});
|
||||
Self {
|
||||
configs,
|
||||
ids: ids.into_iter().map(|(id, ..)| id).collect(),
|
||||
sections: sections.into_iter().map(|(s, _, rows)| (s, rows)).collect(),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn layout(&self, w: f32, h: f32) -> (Columns, Vec<Line<u16, u16>>, f32) {
|
||||
let cols = Columns::of(w, h);
|
||||
let mut lines = Lines::new();
|
||||
// The pack-wide presets first, when the entries have some.
|
||||
if !self.configs.presets().is_empty() {
|
||||
lines.titled(PRESETS, vec![PRESETS]);
|
||||
}
|
||||
for (i, (_, rows)) in self.sections.iter().enumerate() {
|
||||
lines.titled(i as u16, rows.clone());
|
||||
}
|
||||
let (lines, content) = lines.finish();
|
||||
(cols, lines, content)
|
||||
}
|
||||
|
||||
/// The namespace the presets' translations are under: of the first entry with presets.
|
||||
fn presets_namespace(&self) -> &str {
|
||||
self.configs
|
||||
.entries()
|
||||
.iter()
|
||||
.find(|(_, e)| !e.presets.is_empty())
|
||||
.map_or("minecraft", |(id, _)| split(id).0)
|
||||
}
|
||||
|
||||
/// The pack-wide preset every entry's value matches, if one does.
|
||||
fn current_preset(&self) -> Option<String> {
|
||||
self.configs.presets().into_iter().find(|preset| {
|
||||
self.configs
|
||||
.entries()
|
||||
.iter()
|
||||
.all(|(id, e)| match e.presets.get(preset) {
|
||||
Some(value) => e.accept(value).as_ref() == self.configs.get(id),
|
||||
None => true,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// A preset's label (`<namespace>.presets.<name>`), or the label of none (`.none`).
|
||||
fn preset_label(&self, preset: Option<&str>) -> String {
|
||||
let namespace = self.presets_namespace();
|
||||
match preset {
|
||||
Some(name) => translated(&format!("{namespace}.presets.{name}"), || readable(name)),
|
||||
None => translated(&format!("{namespace}.presets.none"), || {
|
||||
lang::get("horizon.pack.options.presets.custom")
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// The next pack-wide preset after the current one (`back`: before it), applied.
|
||||
fn cycle_preset(&mut self, back: bool) {
|
||||
let presets = self.configs.presets();
|
||||
if presets.is_empty() {
|
||||
return;
|
||||
}
|
||||
let n = presets.len();
|
||||
let next = match self
|
||||
.current_preset()
|
||||
.and_then(|c| presets.iter().position(|p| *p == c))
|
||||
{
|
||||
Some(i) if back => (i + n - 1) % n,
|
||||
Some(i) => (i + 1) % n,
|
||||
None if back => n - 1,
|
||||
None => 0,
|
||||
};
|
||||
let before = self.configs.clone();
|
||||
self.configs.apply_preset(&presets[next]);
|
||||
if self.configs != before {
|
||||
self.changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// A section's heading: `config.<namespace>.section.<id>`, by its first entry's namespace.
|
||||
fn section_name(&self, section: u16) -> String {
|
||||
if section == PRESETS {
|
||||
let key = format!("{}.presets", self.presets_namespace());
|
||||
return translated(&key, || lang::get("horizon.pack.options.presets"));
|
||||
}
|
||||
let Some((id, rows)) = self.sections.get(usize::from(section)) else {
|
||||
return String::new();
|
||||
};
|
||||
let Some(id) = id else {
|
||||
return lang::get("horizon.pack.options.general");
|
||||
};
|
||||
let namespace = rows
|
||||
.first()
|
||||
.and_then(|&r| self.ids.get(usize::from(r)))
|
||||
.map_or("minecraft", |entry| split(entry).0);
|
||||
translated(&format!("config.{namespace}.section.{id}"), || readable(id))
|
||||
}
|
||||
|
||||
/// An entry's name (`config.<namespace>.<name>`) and its value's label.
|
||||
fn parts(&self, row: u16) -> (String, Option<String>) {
|
||||
if row == PRESETS {
|
||||
let label = self.preset_label(self.current_preset().as_deref());
|
||||
return (self.section_name(PRESETS), Some(label));
|
||||
}
|
||||
let Some(id) = self.ids.get(usize::from(row)) else {
|
||||
return (String::new(), None);
|
||||
};
|
||||
let (namespace, path) = split(id);
|
||||
let name = translated(&format!("config.{namespace}.{path}"), || readable(path));
|
||||
let entry = &self.configs.entries()[id];
|
||||
let value = match self.configs.get(id) {
|
||||
Some(ConfigValue::Number(n)) => {
|
||||
let step = match entry.kind {
|
||||
ConfigKind::Number { step, .. } => step,
|
||||
_ => 1.0,
|
||||
};
|
||||
let text = if step >= 1.0 && step.fract() == 0.0 {
|
||||
format!("{n:.0}")
|
||||
} else {
|
||||
format!("{n:.2}")
|
||||
.trim_end_matches('0')
|
||||
.trim_end_matches('.')
|
||||
.to_owned()
|
||||
};
|
||||
Some(text)
|
||||
}
|
||||
Some(ConfigValue::Text(t)) => Some(t.clone()),
|
||||
Some(ConfigValue::Bool(_)) | None => None,
|
||||
};
|
||||
let value = value.map(|v| match &entry.value_translation {
|
||||
Some(key) if lang::has(key) => lang::tr(key, &[&v]),
|
||||
_ => v,
|
||||
});
|
||||
(name, value)
|
||||
}
|
||||
|
||||
/// The tooltip (`config.<namespace>.<name>.tooltip`) and the performance hint.
|
||||
fn description(&self, row: u16) -> String {
|
||||
let Some(id) = self.ids.get(usize::from(row)) else {
|
||||
return String::new();
|
||||
};
|
||||
let (namespace, path) = split(id);
|
||||
let key = format!("config.{namespace}.{path}.tooltip");
|
||||
let mut text = if lang::has(&key) {
|
||||
lang::get(&key)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
if let Some(impact) = &self.configs.entries()[id].performance_impact {
|
||||
let level = lang::get(&format!("horizon.pack.options.impact.{impact}"));
|
||||
let line = lang::tr("horizon.pack.options.impact", &[&level]);
|
||||
text = if text.is_empty() {
|
||||
line
|
||||
} else {
|
||||
format!("{text} {line}")
|
||||
};
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// A slider's place (0 to 1) for a number entry.
|
||||
fn slider(&self, row: u16) -> Option<f32> {
|
||||
let id = self.ids.get(usize::from(row))?;
|
||||
let ConfigKind::Number { min, max, .. } = self.configs.entries()[id].kind else {
|
||||
return None;
|
||||
};
|
||||
let Some(ConfigValue::Number(n)) = self.configs.get(id) else {
|
||||
return None;
|
||||
};
|
||||
Some(if max > min {
|
||||
((n - min) / (max - min)) as f32
|
||||
} else {
|
||||
0.0
|
||||
})
|
||||
}
|
||||
|
||||
fn switch(&self, row: u16) -> Option<bool> {
|
||||
match self.configs.get(self.ids.get(usize::from(row))?)? {
|
||||
ConfigValue::Bool(b) => Some(*b),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw(
|
||||
&mut self,
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
in_world: bool,
|
||||
) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
let (cols, lines, content) = self.layout(w, h);
|
||||
self.scroll.clamp(cols.top, cols.bottom, content);
|
||||
shell::backdrop(batch, atlas, in_world);
|
||||
shell::mark(
|
||||
batch,
|
||||
atlas,
|
||||
&cols,
|
||||
&lang::get("horizon.pack.options.title"),
|
||||
);
|
||||
shell::plate(
|
||||
batch,
|
||||
atlas,
|
||||
&cols,
|
||||
&lang::get("horizon.pack.options.title"),
|
||||
);
|
||||
let over_list = cursor.1 >= cols.top && cursor.1 < cols.bottom;
|
||||
let mut described = None;
|
||||
shell::list(
|
||||
batch,
|
||||
atlas,
|
||||
&cols,
|
||||
&lines,
|
||||
self.scroll.amount,
|
||||
|section| self.section_name(section),
|
||||
|batch, row: u16, y| {
|
||||
let [rx, ry, rw, rh] = cols.row(y);
|
||||
let hover = over_list && ui::hovered(cursor, rx, ry, rw, rh);
|
||||
if hover || self.dragging == Some(row) {
|
||||
described = Some(row);
|
||||
}
|
||||
let (name, value) = self.parts(row);
|
||||
let label = match &value {
|
||||
Some(v) => format!("{name}: {v}"),
|
||||
None => name.clone(),
|
||||
};
|
||||
let view = shell::RowView {
|
||||
label: &label,
|
||||
name: &name,
|
||||
value: value.as_deref(),
|
||||
switch: self.switch(row),
|
||||
slider: self.slider(row),
|
||||
live: true,
|
||||
hover,
|
||||
focused: false,
|
||||
held: self.dragging == Some(row),
|
||||
key: None,
|
||||
};
|
||||
shell::row(batch, atlas, &cols, y, &view);
|
||||
},
|
||||
);
|
||||
shell::scroller(batch, atlas, &cols, content, self.scroll.amount);
|
||||
// The strip: the pointed-at entry's tooltip.
|
||||
let [x, y, ..] = cols.dock;
|
||||
style::panel(batch, atlas, cols.dock);
|
||||
if let Some(row) = described {
|
||||
let name = Text::literal(self.parts(row).0);
|
||||
batch.text_styled(atlas, &name, x + 12.0, y + 8.0, style::WHITE, true);
|
||||
shell::dock_note(batch, atlas, &cols, self.description(row), style::GREY);
|
||||
}
|
||||
let mut foot = vec![(cols.button(0), "gui.done", Some(Declared::Done))];
|
||||
if self
|
||||
.configs
|
||||
.chosen()
|
||||
.iter()
|
||||
.any(|(id, _)| self.ids.contains(id))
|
||||
{
|
||||
foot.push((cols.button(1), "controls.reset", None));
|
||||
}
|
||||
for (rect, key, declared) in foot {
|
||||
if let Some(declared) = declared {
|
||||
crate::widgets::declare_action(declared, rect, true);
|
||||
}
|
||||
let label = Text::literal(lang::get(key));
|
||||
shell::foot_button(batch, atlas, rect, &label, cursor, (false, true));
|
||||
}
|
||||
}
|
||||
|
||||
/// A press (or a drag with `drag`) at `(x, y)`; `back` turns a cycle backwards.
|
||||
pub fn click(
|
||||
&mut self,
|
||||
(x, y): (f32, f32),
|
||||
(w, h): (f32, f32),
|
||||
drag: bool,
|
||||
back: bool,
|
||||
) -> Vec<Action> {
|
||||
let (cols, lines, content) = self.layout(w, h);
|
||||
let at = |[rx, ry, rw, rh]: [f32; 4]| ui::hovered((x, y), rx, ry, rw, rh);
|
||||
if drag {
|
||||
if self.scroll.held() {
|
||||
self.scroll.drag(y, cols.top, cols.bottom, content);
|
||||
} else if let Some(row) = self.dragging {
|
||||
self.set_slider(row, cols.slider_at(x));
|
||||
return vec![Action::SliderDrag];
|
||||
}
|
||||
return Vec::new();
|
||||
}
|
||||
if cols.scrolls()
|
||||
&& self
|
||||
.scroll
|
||||
.press((x, y), cols.bar_x(), cols.top, cols.bottom, content)
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
if at(cols.button(0)) {
|
||||
return vec![Action::ButtonClick, Action::Done];
|
||||
}
|
||||
if at(cols.button(1)) {
|
||||
self.reset();
|
||||
return vec![Action::ButtonClick];
|
||||
}
|
||||
if y < cols.top || y >= cols.bottom {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(row) = lines.iter().find_map(|line| {
|
||||
let row = line.setting?;
|
||||
at(cols.row(cols.top + line.y - self.scroll.amount)).then_some(row)
|
||||
}) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if row == PRESETS {
|
||||
self.cycle_preset(back);
|
||||
return vec![Action::ButtonClick];
|
||||
}
|
||||
let Some(id) = self.ids.get(usize::from(row)).cloned() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let entry = self.configs.entries()[&id].clone();
|
||||
match (&entry.kind, self.configs.get(&id).cloned()) {
|
||||
(ConfigKind::Number { .. }, _) => {
|
||||
self.dragging = Some(row);
|
||||
self.set_slider(row, cols.slider_at(x));
|
||||
return vec![Action::SliderDrag];
|
||||
}
|
||||
(ConfigKind::Bool, Some(ConfigValue::Bool(b))) => {
|
||||
self.set(&id, ConfigValue::Bool(!b));
|
||||
}
|
||||
(ConfigKind::Text { allowed }, Some(ConfigValue::Text(t))) if !allowed.is_empty() => {
|
||||
let i = allowed.iter().position(|a| *a == t).unwrap_or(0);
|
||||
let n = allowed.len();
|
||||
let next = if back { (i + n - 1) % n } else { (i + 1) % n };
|
||||
self.set(&id, ConfigValue::Text(allowed[next].clone()));
|
||||
}
|
||||
_ => return Vec::new(),
|
||||
}
|
||||
vec![Action::ButtonClick]
|
||||
}
|
||||
|
||||
fn set(&mut self, id: &str, value: ConfigValue) {
|
||||
let before = self.configs.get(id).cloned();
|
||||
self.configs.set(id, value);
|
||||
if self.configs.get(id).cloned() != before {
|
||||
self.changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Number entry `row` at slider place `at` (0 to 1).
|
||||
fn set_slider(&mut self, row: u16, at: f32) {
|
||||
let Some(id) = self.ids.get(usize::from(row)).cloned() else {
|
||||
return;
|
||||
};
|
||||
if let ConfigKind::Number { min, max, .. } = self.configs.entries()[&id].kind {
|
||||
self.set(&id, ConfigValue::Number(min + f64::from(at) * (max - min)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Every entry back to its default.
|
||||
fn reset(&mut self) {
|
||||
let defaults: Vec<_> = self
|
||||
.configs
|
||||
.entries()
|
||||
.iter()
|
||||
.map(|(id, e)| (id.clone(), e.default.clone()))
|
||||
.collect();
|
||||
for (id, value) in defaults {
|
||||
self.set(&id, value);
|
||||
}
|
||||
}
|
||||
|
||||
/// The pointer lets go.
|
||||
pub fn release(&mut self) {
|
||||
self.scroll.release();
|
||||
self.dragging = None;
|
||||
}
|
||||
|
||||
/// The mouse wheel scrolls the list.
|
||||
pub fn scroll(&mut self, (w, h): (f32, f32), amount: f32) {
|
||||
let (cols, _, content) = self.layout(w, h);
|
||||
self.scroll
|
||||
.wheel(amount, 18.0, cols.top, cols.bottom, content);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn configs() -> Configs {
|
||||
let mut c = Configs::default();
|
||||
c.add_entry(
|
||||
"p:fog".into(),
|
||||
&serde_json::json!({"default_value": 0.5, "section": "sky", "section_order": 2}),
|
||||
);
|
||||
c.add_entry("p:rain".into(), &serde_json::json!({"default_value": "drops", "allowed_values": ["drops", "streaks"], "section": "weather", "section_order": 1}));
|
||||
c.add_entry(
|
||||
"p:birds".into(),
|
||||
&serde_json::json!({"default_value": true}),
|
||||
);
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sections_sort_by_their_order_and_the_unsectioned_last() {
|
||||
let ui = PackOptionsUi::open(configs());
|
||||
let order: Vec<_> = ui.sections.iter().map(|(s, _)| s.as_deref()).collect();
|
||||
assert_eq!(order, [Some("weather"), Some("sky"), None]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_switch_cycle_and_slide() {
|
||||
let mut ui = PackOptionsUi::open(configs());
|
||||
let size = (480.0, 270.0);
|
||||
let (cols, lines, _) = ui.layout(size.0, size.1);
|
||||
let ids = ui.ids.clone();
|
||||
let point = |id: &str| {
|
||||
let row = ids.iter().position(|i| i == id).unwrap() as u16;
|
||||
let line = lines.iter().find(|l| l.setting == Some(row)).unwrap();
|
||||
let [x, y, w, h] = cols.row(cols.top + line.y);
|
||||
(x + w - 4.0, y + h / 2.0)
|
||||
};
|
||||
let (birds, rain) = (point("p:birds"), point("p:rain"));
|
||||
ui.click(birds, size, false, false);
|
||||
assert_eq!(ui.configs.get("p:birds"), Some(&ConfigValue::Bool(false)));
|
||||
ui.click(rain, size, false, false);
|
||||
assert_eq!(
|
||||
ui.configs.get("p:rain"),
|
||||
Some(&ConfigValue::Text("streaks".into()))
|
||||
);
|
||||
// The slider's far right end is its maximum.
|
||||
let fog = point("p:fog");
|
||||
let (tx, tw) = cols.track();
|
||||
ui.click((tx + tw, fog.1), size, false, false);
|
||||
ui.release();
|
||||
assert_eq!(ui.configs.get("p:fog"), Some(&ConfigValue::Number(1.0)));
|
||||
assert!(ui.changed);
|
||||
assert_eq!(ui.configs.chosen().len(), 3);
|
||||
ui.reset();
|
||||
assert!(ui.configs.chosen().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_presets_row_cycles_the_pack_wide_presets_and_shows_custom_otherwise() {
|
||||
let mut c = Configs::default();
|
||||
let entry = |default: f64, fast: f64, pretty: f64| serde_json::json!({"default_value": default, "presets": {"fast": fast, "pretty": pretty}});
|
||||
c.add_entry("p:fog".into(), &entry(0.5, 0.0, 1.0));
|
||||
c.add_entry("p:dust".into(), &entry(0.5, 0.0, 0.8));
|
||||
let mut ui = PackOptionsUi::open(c);
|
||||
// The defaults are no preset.
|
||||
assert_eq!(ui.current_preset(), None);
|
||||
ui.cycle_preset(false);
|
||||
assert_eq!(ui.current_preset().as_deref(), Some("fast"));
|
||||
assert_eq!(ui.configs.get("p:fog"), Some(&ConfigValue::Number(0.0)));
|
||||
ui.cycle_preset(false);
|
||||
assert_eq!(ui.current_preset().as_deref(), Some("pretty"));
|
||||
ui.cycle_preset(true);
|
||||
assert_eq!(ui.current_preset().as_deref(), Some("fast"));
|
||||
// One value off the preset: custom again.
|
||||
ui.set("p:dust", ConfigValue::Number(0.3));
|
||||
assert_eq!(ui.current_preset(), None);
|
||||
assert!(ui.changed);
|
||||
}
|
||||
}
|
||||
@@ -320,6 +320,8 @@ pub enum Action {
|
||||
OpenFolder,
|
||||
/// The Done button (`onClose`: commit).
|
||||
Done,
|
||||
/// Horizon's own: the options button on the card of this pack, which has Polytone options.
|
||||
Options(String),
|
||||
/// Horizon's own: a Modrinth hit the player picked, to download into the
|
||||
/// packs folder. The screen does not know where that is.
|
||||
BrowsePick(usize),
|
||||
@@ -359,6 +361,8 @@ pub struct PackScreen {
|
||||
browser_icons: Vec<String>,
|
||||
/// Whether that button is drawn at all.
|
||||
pub browsing_allowed: bool,
|
||||
/// The packs with Polytone options (#11486): their cards carry an options button.
|
||||
pub with_options: std::collections::BTreeSet<String>,
|
||||
/// The browser's own search box, separate from the one that filters the
|
||||
/// player's installed packs.
|
||||
browser_search: TextField,
|
||||
@@ -374,6 +378,8 @@ fn tr(key: &str) -> Text {
|
||||
/// height of 20; drawn shorter, neither it nor its label sits right, so the
|
||||
/// button keeps that height and is centred on the shorter search box.
|
||||
const ADD_SIZE: i32 = 20;
|
||||
/// The options button on a pack's card.
|
||||
const OPTIONS_SIZE: i32 = 16;
|
||||
/// A card in the grid: its icon beside its name, author and description.
|
||||
const ROW_H: i32 = 74;
|
||||
/// The air around a card's contents.
|
||||
@@ -440,6 +446,7 @@ impl PackScreen {
|
||||
confirm: None,
|
||||
browser: None,
|
||||
browsing_allowed: false,
|
||||
with_options: Default::default(),
|
||||
backdrop: Backdrop::None,
|
||||
browser_icons: Vec::new(),
|
||||
browser_search: {
|
||||
@@ -632,6 +639,12 @@ impl PackScreen {
|
||||
}
|
||||
let l = &self.lists[side];
|
||||
let hit = l.entry_at(x as i32, y as i32);
|
||||
if let Some(i) = hit
|
||||
&& let Some((id, bx, by)) = self.options_button(side, i)
|
||||
&& d::inside((x as i32, y as i32), bx, by, OPTIONS_SIZE, OPTIONS_SIZE)
|
||||
{
|
||||
return Action::Options(id);
|
||||
}
|
||||
let mut took = false;
|
||||
if let Some(i) = hit {
|
||||
took = true;
|
||||
@@ -1362,6 +1375,45 @@ impl PackScreen {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// The options button on the card of row `i` of list `side`, when its pack has
|
||||
/// Polytone options: the pack and the button's corner, at the card's right edge.
|
||||
fn options_button(&self, side: usize, i: usize) -> Option<(String, i32, i32)> {
|
||||
let l = &self.lists[side];
|
||||
let e = l.entries.get(i)?;
|
||||
let Row::Pack(id) = &e.row else { return None };
|
||||
if !self.with_options.contains(id) {
|
||||
return None;
|
||||
}
|
||||
let x = l.row_left() + l.entry_width() - OPTIONS_SIZE - 2;
|
||||
let y = e.y + (e.height - OPTIONS_SIZE) / 2;
|
||||
Some((id.clone(), x, y))
|
||||
}
|
||||
/// The options button of a card: a small button with a sliders glyph.
|
||||
fn draw_options_button(out: &mut Vec<Draw>, x: i32, y: i32, hover: bool) {
|
||||
crate::widgets::narratable(|| {
|
||||
crate::widgets::Narratable::button(
|
||||
tr("horizon.pack.options").plain(),
|
||||
[x as f32, y as f32, OPTIONS_SIZE as f32, OPTIONS_SIZE as f32],
|
||||
true,
|
||||
)
|
||||
});
|
||||
let sprite = if hover {
|
||||
"widget/button_highlighted"
|
||||
} else {
|
||||
"widget/button"
|
||||
};
|
||||
d::sprite(out, sprite, x, y, OPTIONS_SIZE, OPTIONS_SIZE);
|
||||
let (line, knob) = if hover {
|
||||
(0xFF_C6_C6_C6u32 as i32, -1)
|
||||
} else {
|
||||
(0xFF_A0_A0_A0u32 as i32, 0xFF_E0_E0_E0u32 as i32)
|
||||
};
|
||||
// Three sliders, their knobs at different places.
|
||||
for (row, at) in [(4, 8), (8, 4), (12, 9)] {
|
||||
d::fill(out, x + 3, y + row, x + 13, y + row + 1, line);
|
||||
d::fill(out, x + at, y + row - 1, x + at + 2, y + row + 2, knob);
|
||||
}
|
||||
}
|
||||
/// The Add button, just right of the search box.
|
||||
fn add_button(&self, m: &Measure) -> (i32, i32) {
|
||||
let (sx, sy) = self.header(m)[2];
|
||||
@@ -1470,6 +1522,7 @@ impl PackScreen {
|
||||
});
|
||||
let (left, w) = (l.row_left(), l.entry_width());
|
||||
let narrow = if l.scrollable() { 6 } else { 0 };
|
||||
let mut tooltip = None;
|
||||
for (i, e) in l.entries.iter().enumerate() {
|
||||
if e.y + e.height < l.y || e.y > l.bottom() {
|
||||
continue;
|
||||
@@ -1560,7 +1613,16 @@ impl PackScreen {
|
||||
}
|
||||
}
|
||||
}
|
||||
let max = 157 - narrow;
|
||||
let mut max = 157 - narrow;
|
||||
if let Some((_, bx, by)) = self.options_button(side, i) {
|
||||
let hover = hovered == Some(i)
|
||||
&& d::inside(mouse, bx, by, OPTIONS_SIZE, OPTIONS_SIZE);
|
||||
Self::draw_options_button(out, bx, by, hover);
|
||||
if hover {
|
||||
tooltip = Some(tr("horizon.pack.optionsInfo"));
|
||||
}
|
||||
max -= OPTIONS_SIZE + 2;
|
||||
}
|
||||
d::string_widget(out, m, &name, cx + 34, cy + 1, max);
|
||||
let lines = d::label_lines(m, &desc, max, 2, &desc_style);
|
||||
d::label(out, &lines, cx + 34, cy + 12, max, false);
|
||||
@@ -1568,6 +1630,9 @@ impl PackScreen {
|
||||
}
|
||||
}
|
||||
out.push(Draw::NoScissor);
|
||||
if let Some(t) = tooltip {
|
||||
d::tooltip(out, m, &t, mouse);
|
||||
}
|
||||
d::blit(
|
||||
out,
|
||||
&format!("textures/gui/{prefix}header_separator.png"),
|
||||
@@ -1652,6 +1717,45 @@ mod icon_tests {
|
||||
assert!(load_icon(b"not a png").is_none());
|
||||
}
|
||||
|
||||
/// A pack with Polytone options carries an options button on its card, which opens
|
||||
/// that pack's options and nothing else; a pack without options has none.
|
||||
#[test]
|
||||
fn a_card_with_options_opens_its_own_pack_options() {
|
||||
let atlas =
|
||||
crate::hud::HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets"))
|
||||
.unwrap();
|
||||
let m = Measure(&atlas.font);
|
||||
let packs = vec![pack("file/Configurable", None), pack("file/Plain", None)];
|
||||
let mut screen = PackScreen::new(&m, tr("resourcePack.title"), 480, 320, false, packs, &[]);
|
||||
screen.with_options.insert("file/Configurable".into());
|
||||
let row = |screen: &PackScreen, id: &str| {
|
||||
screen.lists[0]
|
||||
.entries
|
||||
.iter()
|
||||
.position(|e| e.row == Row::Pack(id.into()))
|
||||
.unwrap()
|
||||
};
|
||||
assert!(
|
||||
screen
|
||||
.options_button(0, row(&screen, "file/Plain"))
|
||||
.is_none()
|
||||
);
|
||||
let (id, bx, by) = screen
|
||||
.options_button(0, row(&screen, "file/Configurable"))
|
||||
.unwrap();
|
||||
assert_eq!(id, "file/Configurable");
|
||||
let (cx, cy) = (bx + OPTIONS_SIZE / 2, by + OPTIONS_SIZE / 2);
|
||||
// Hovered, it explains itself.
|
||||
let draws = screen.draw(&m, (cx, cy), 0, 0);
|
||||
assert!(draws.iter().any(|d| matches!(d, Draw::Tooltip { .. })));
|
||||
assert_eq!(
|
||||
screen.click(&m, cx as f64, cy as f64),
|
||||
Action::Options("file/Configurable".into())
|
||||
);
|
||||
// The press opened the options, it did not move the pack.
|
||||
assert!(screen.commit().is_empty());
|
||||
}
|
||||
|
||||
/// Programmer Art and High Contrast draw their own `pack.png`; a pack
|
||||
/// without one keeps `unknown_pack`.
|
||||
#[test]
|
||||
|
||||
@@ -522,8 +522,85 @@ pub fn polytone_configs(source: &Source) -> horizon_polytone::pack::Configs {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (id, value) in polytone_choices() {
|
||||
if configs.entries().contains_key(&id) {
|
||||
configs.set(&id, value);
|
||||
}
|
||||
}
|
||||
configs
|
||||
}
|
||||
|
||||
/// A pack's Polytone config entries and one's value, for the game's Pack Options.
|
||||
pub use horizon_polytone::pack::{ConfigValue, Configs};
|
||||
|
||||
/// The player's Polytone config choices (#11486): what they changed from the packs' defaults,
|
||||
/// by entry id, for every pack. `polytone_configs` lays them over each pack's defaults.
|
||||
static POLYTONE_CHOICES: std::sync::RwLock<BTreeMap<String, horizon_polytone::pack::ConfigValue>> =
|
||||
std::sync::RwLock::new(BTreeMap::new());
|
||||
|
||||
pub fn polytone_choices() -> BTreeMap<String, horizon_polytone::pack::ConfigValue> {
|
||||
POLYTONE_CHOICES
|
||||
.read()
|
||||
.map(|c| c.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Replaces the player's Polytone config choices (packs read them when they load again).
|
||||
pub fn set_polytone_choices(choices: BTreeMap<String, horizon_polytone::pack::ConfigValue>) {
|
||||
if let Ok(mut c) = POLYTONE_CHOICES.write() {
|
||||
*c = choices;
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the choices are kept: `polytone-configs.json` beside `options.txt`.
|
||||
pub fn polytone_choices_file() -> std::path::PathBuf {
|
||||
crate::logging::player_dir().join("polytone-configs.json")
|
||||
}
|
||||
|
||||
/// Reads the choices from `path` (none when it is missing or broken).
|
||||
pub fn load_polytone_choices(path: &Path) -> BTreeMap<String, horizon_polytone::pack::ConfigValue> {
|
||||
use horizon_polytone::pack::ConfigValue;
|
||||
let Ok(bytes) = fs::read(path) else {
|
||||
return BTreeMap::new();
|
||||
};
|
||||
let Ok(serde_json::Value::Object(map)) = serde_json::from_slice(&bytes) else {
|
||||
log::warn!("{}: not a JSON object", path.display());
|
||||
return BTreeMap::new();
|
||||
};
|
||||
map.into_iter()
|
||||
.filter_map(|(id, v)| {
|
||||
let value = match v {
|
||||
serde_json::Value::Number(n) => ConfigValue::Number(n.as_f64()?),
|
||||
serde_json::Value::String(s) => ConfigValue::Text(s),
|
||||
serde_json::Value::Bool(b) => ConfigValue::Bool(b),
|
||||
_ => return None,
|
||||
};
|
||||
Some((id, value))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Writes the choices to `path`.
|
||||
pub fn save_polytone_choices(
|
||||
path: &Path,
|
||||
choices: &BTreeMap<String, horizon_polytone::pack::ConfigValue>,
|
||||
) -> std::io::Result<()> {
|
||||
use horizon_polytone::pack::ConfigValue;
|
||||
let map: serde_json::Map<String, serde_json::Value> = choices
|
||||
.iter()
|
||||
.map(|(id, v)| {
|
||||
let json = match v {
|
||||
ConfigValue::Number(n) => serde_json::json!(n),
|
||||
ConfigValue::Text(s) => serde_json::json!(s),
|
||||
ConfigValue::Bool(b) => serde_json::json!(b),
|
||||
};
|
||||
(id.clone(), json)
|
||||
})
|
||||
.collect();
|
||||
let text = serde_json::to_string_pretty(&serde_json::Value::Object(map))
|
||||
.map_err(std::io::Error::other)?;
|
||||
fs::write(path, text)
|
||||
}
|
||||
fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
|
||||
pack_for(id, title, source, RESOURCE_FORMAT)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ pub use horizon_items::firework::{DYE_FIREWORK_COLORS, FireworkExplosion, Firewo
|
||||
mod native;
|
||||
pub mod polytone;
|
||||
mod polytone_gpu;
|
||||
mod polytone_model;
|
||||
pub use native::{ParticleController, ParticleUpdate};
|
||||
|
||||
/// `ParticleOptions` with the parameters of the option classes used here.
|
||||
@@ -226,6 +227,8 @@ enum Light {
|
||||
AgeEmission4,
|
||||
/// `FireflyParticle.getLightCoords`: block light fading in and out, no sky.
|
||||
Firefly,
|
||||
/// A Polytone particle's `light_level`: block light at least this level.
|
||||
MinBlock(u8),
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Behaviour {
|
||||
@@ -527,8 +530,10 @@ pub struct Particle {
|
||||
/// `Particle.random`: the particle's own generator (`RandomSource.create()`).
|
||||
random: LegacyRandom,
|
||||
controller: Option<Box<dyn native::Controller>>,
|
||||
/// A resource pack's Polytone custom particle type, for its `limit`.
|
||||
/// A resource pack's Polytone custom particle type, for its `limit` and `exclusion_radius`.
|
||||
polytone_type: Option<std::sync::Arc<str>>,
|
||||
/// A Polytone model particle's faces, drawn instead of its sprite quad.
|
||||
model: Option<std::sync::Arc<polytone_model::ParticleModel>>,
|
||||
/// How a Polytone custom particle's quad turns (`rotation_mode`).
|
||||
orientation: polytone_gpu::Orientation,
|
||||
/// Blended additively (a Polytone `additive_translucent` particle).
|
||||
@@ -895,6 +900,7 @@ impl Particle {
|
||||
random: LegacyRandom::new(0),
|
||||
controller: None,
|
||||
polytone_type: None,
|
||||
model: None,
|
||||
orientation: polytone_gpu::Orientation::Camera,
|
||||
additive: false,
|
||||
}
|
||||
@@ -1098,6 +1104,7 @@ impl Particle {
|
||||
let b = (b * b) * (b * b);
|
||||
(sky, (block + (b.clamp(0.0, 1.0) * 240.0) as u32).min(240))
|
||||
}
|
||||
Light::MinBlock(level) => (sky, block.max(u32::from(level) * 16)),
|
||||
Light::Firefly => (
|
||||
0,
|
||||
(255.0 * fade_amount(self.lifetime_progress(self.age as f32 + partial), 0.1, 0.3))
|
||||
@@ -1191,6 +1198,27 @@ pub struct QuadSnapshot {
|
||||
pub light: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TrackingTarget {
|
||||
LocalPlayer,
|
||||
Entity(i32),
|
||||
}
|
||||
|
||||
/// The entity position and current dimensions that `getX/Y/Z(double)` use.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct TrackingBody {
|
||||
pub feet: [f64; 3],
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
}
|
||||
|
||||
struct TrackingEmitter {
|
||||
options: ParticleOptions,
|
||||
target: Option<TrackingTarget>,
|
||||
body: TrackingBody,
|
||||
remaining: u32,
|
||||
}
|
||||
|
||||
/// World access for particle physics.
|
||||
pub struct ParticleLevel<'a> {
|
||||
pub world: &'a World,
|
||||
@@ -1265,6 +1293,9 @@ impl ParticleLevel<'_> {
|
||||
pub struct ParticleEngine {
|
||||
particles: Vec<Particle>,
|
||||
pending: Vec<Particle>,
|
||||
/// While the live particles tick (out of `particles`), what their emitters spawn waits
|
||||
/// here, so a spawn's `limit` and `exclusion_radius` see every live particle.
|
||||
spawned_in_tick: Option<Vec<(ParticleOptions, [f64; 3], [f64; 3])>>,
|
||||
/// `ParticleEngine.random`, handed to every `ParticleProvider`.
|
||||
random: LegacyRandom,
|
||||
/// `ClientLevel.random` as particle constructors use it (`level.getRandom()`).
|
||||
@@ -1293,13 +1324,15 @@ pub struct ParticleEngine {
|
||||
set_sprites: Vec<Vec<u32>>,
|
||||
/// Resource packs' Polytone custom particle types (#11486).
|
||||
polytone: Option<std::sync::Arc<polytone::Registry>>,
|
||||
/// Vanilla particle types a pack replaces with a custom one of the same id
|
||||
/// (`assets/minecraft/polytone/custom_particles/rain.json` is `minecraft:rain`).
|
||||
polytone_overrides: HashMap<ParticleType, String>,
|
||||
/// The packs' GPU particles' records, one store per type, and the ticks they count in.
|
||||
polytone_gpu: Vec<polytone_gpu::Store>,
|
||||
polytone_ticks: u64,
|
||||
/// The last frame's additively blended quads (`take_additive`).
|
||||
additive: std::sync::Mutex<Vec<Vertex>>,
|
||||
/// `TrackingEmitter`s: particle type, entity feet position, width, height, ticks left.
|
||||
emitters: Vec<(ParticleOptions, [f64; 3], f64, f64, u32)>,
|
||||
emitters: Vec<TrackingEmitter>,
|
||||
/// Camera position for the 32-block spawn limiter.
|
||||
pub camera: [f64; 3],
|
||||
/// The Particles option (`ParticleStatus`): 0 all, 1 decreased, 2 minimal.
|
||||
@@ -1477,6 +1510,7 @@ impl ParticleEngine {
|
||||
let mut engine = Self {
|
||||
particles: Vec::new(),
|
||||
pending: Vec::new(),
|
||||
spawned_in_tick: None,
|
||||
random: LegacyRandom::new(0x7061_7274),
|
||||
level_random: LegacyRandom::new(0x6c65_7665),
|
||||
spell_random: LegacyRandom::new(0x7370_656c),
|
||||
@@ -1491,6 +1525,7 @@ impl ParticleEngine {
|
||||
mod_sets: HashMap::new(),
|
||||
set_sprites: Vec::new(),
|
||||
polytone: None,
|
||||
polytone_overrides: HashMap::new(),
|
||||
polytone_gpu: Vec::new(),
|
||||
polytone_ticks: 0,
|
||||
additive: std::sync::Mutex::default(),
|
||||
@@ -1724,6 +1759,15 @@ impl ParticleEngine {
|
||||
};
|
||||
if !same {
|
||||
self.polytone_gpu.clear();
|
||||
self.polytone_overrides = registry
|
||||
.iter()
|
||||
.flat_map(|r| {
|
||||
ParticleType::ALL.iter().filter_map(|&ty| {
|
||||
let id = format!("minecraft:{}", ty.name());
|
||||
r.get(&id).is_some().then_some((ty, id))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
self.polytone = registry;
|
||||
}
|
||||
@@ -1910,27 +1954,51 @@ impl ParticleEngine {
|
||||
pub fn tracking_emitter(
|
||||
&mut self,
|
||||
options: ParticleOptions,
|
||||
feet: [f64; 3],
|
||||
width: f64,
|
||||
height: f64,
|
||||
target: TrackingTarget,
|
||||
body: TrackingBody,
|
||||
) {
|
||||
self.tracking_emitter_for(options, feet, width, height, 3);
|
||||
self.tracking_emitter_for(options, target, body, 3);
|
||||
}
|
||||
/// `createTrackingEmitter(entity, type, lifetime)`.
|
||||
pub fn tracking_emitter_for(
|
||||
&mut self,
|
||||
options: ParticleOptions,
|
||||
feet: [f64; 3],
|
||||
width: f64,
|
||||
height: f64,
|
||||
target: TrackingTarget,
|
||||
body: TrackingBody,
|
||||
life: u32,
|
||||
) {
|
||||
self.emit(&options, feet, width, height);
|
||||
self.emit(&options, body);
|
||||
if life > 1 {
|
||||
self.emitters.push((options, feet, width, height, life - 1));
|
||||
self.emitters.push(TrackingEmitter {
|
||||
options,
|
||||
target: Some(target),
|
||||
body,
|
||||
remaining: life - 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
fn emit(&mut self, options: &ParticleOptions, feet: [f64; 3], width: f64, height: f64) {
|
||||
/// Vanilla keeps the old entity reference until the emitter expires. Keep
|
||||
/// its final body if it leaves the level, even when the id is paired again.
|
||||
pub fn detach_tracking_target(
|
||||
&mut self,
|
||||
target: TrackingTarget,
|
||||
mut last_body: impl FnMut() -> Option<TrackingBody>,
|
||||
) {
|
||||
for emitter in &mut self.emitters {
|
||||
if emitter.target == Some(target) {
|
||||
emitter.body = last_body().unwrap_or(emitter.body);
|
||||
emitter.target = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `TrackingEmitter.tick`: the spawn attempts use the tracked entity's
|
||||
/// current feet and dimensions.
|
||||
fn emit(&mut self, options: &ParticleOptions, body: TrackingBody) {
|
||||
let TrackingBody {
|
||||
feet,
|
||||
width,
|
||||
height,
|
||||
} = body;
|
||||
for _ in 0..16 {
|
||||
let r = &mut self.random;
|
||||
let xa = f64::from(r.next_float() * 2.0 - 1.0);
|
||||
@@ -1938,9 +2006,9 @@ impl ParticleEngine {
|
||||
let za = f64::from(r.next_float() * 2.0 - 1.0);
|
||||
if xa * xa + ya * ya + za * za <= 1.0 {
|
||||
let p = [
|
||||
feet[0] + width * xa / 4.0,
|
||||
feet[0] + width * (xa / 4.0),
|
||||
feet[1] + height * (0.5 + ya / 4.0),
|
||||
feet[2] + width * za / 4.0,
|
||||
feet[2] + width * (za / 4.0),
|
||||
];
|
||||
self.add(options, p, [xa, ya + 0.2, za]);
|
||||
}
|
||||
@@ -1993,8 +2061,18 @@ impl ParticleEngine {
|
||||
return;
|
||||
}
|
||||
let seed = self.seeds.next_long();
|
||||
if let Some(particle) = self.make(options, p, v, seed) {
|
||||
if let Some(mut particle) = self.make(options, p, v, seed) {
|
||||
*self.made.entry(options.ty.name()).or_default() += 1;
|
||||
// Resource packs' Polytone particle modifiers (#11486), once as it spawns.
|
||||
if let Some(registry) = &self.polytone
|
||||
&& registry.modifies_particles()
|
||||
{
|
||||
let id = match &options.mod_particle {
|
||||
Some(name) => name.clone(),
|
||||
None => format!("minecraft:{}", options.ty.name()),
|
||||
};
|
||||
polytone::modify(registry, &id, &mut particle);
|
||||
}
|
||||
// The provider has drawn, so rejecting here keeps vanilla's RNG order.
|
||||
// shortcut: counts live plus queued particles by scanning instead of
|
||||
// vanilla's counted map; use a counter if spore spawning shows in profiles.
|
||||
@@ -2139,12 +2217,17 @@ impl ParticleEngine {
|
||||
let ty = o.ty;
|
||||
// A native mod's SimpleParticleType: vanilla's TextureSheetParticle with
|
||||
// the sprite set its particles/<name>.json names, no tint, 20..40 ticks.
|
||||
if let Some(name) = &o.mod_particle
|
||||
// A pack's custom particle: a new type, or one replacing the vanilla type of its id.
|
||||
let custom = match &o.mod_particle {
|
||||
Some(name) => Some(name.as_str()),
|
||||
None => self.polytone_overrides.get(&ty).map(String::as_str),
|
||||
};
|
||||
if let Some(name) = custom
|
||||
&& let Some(registry) = &self.polytone
|
||||
&& let Some(def) = registry.get(name)
|
||||
{
|
||||
let (registry, def) = (registry.clone(), def.clone());
|
||||
let particle = polytone::make(self, ®istry, name, &def, o, p, v, seed)?;
|
||||
let (registry, def, name) = (registry.clone(), def.clone(), name.to_owned());
|
||||
let particle = polytone::make(self, ®istry, &name, &def, o, p, v, seed)?;
|
||||
if def.gpu.is_some() {
|
||||
// A GPU particle is only a record: it never becomes an engine particle.
|
||||
let now = self.polytone_ticks;
|
||||
@@ -3793,7 +3876,16 @@ impl ParticleEngine {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A tick where no emitter follows an entity's live body (tests, fixtures, benches).
|
||||
pub fn tick(&mut self, level: &ParticleLevel) {
|
||||
self.tick_tracking(level, |_| None);
|
||||
}
|
||||
/// A tick whose tracking emitters read each followed entity's live body.
|
||||
pub fn tick_tracking(
|
||||
&mut self,
|
||||
level: &ParticleLevel,
|
||||
mut tracking_body: impl FnMut(TrackingTarget) -> Option<TrackingBody>,
|
||||
) {
|
||||
self.polytone_ticks += 1;
|
||||
for store in &mut self.polytone_gpu {
|
||||
store.expire(self.polytone_ticks);
|
||||
@@ -3804,18 +3896,29 @@ impl ParticleEngine {
|
||||
}
|
||||
self.tick_starters();
|
||||
self.tick_explosions(level);
|
||||
for (options, feet, width, height, life) in std::mem::take(&mut self.emitters) {
|
||||
self.emit(&options, feet, width, height);
|
||||
if life > 1 {
|
||||
self.emitters.push((options, feet, width, height, life - 1));
|
||||
let mut emitters = std::mem::take(&mut self.emitters);
|
||||
emitters.retain_mut(|emitter| {
|
||||
if let Some(target) = emitter.target {
|
||||
match tracking_body(target) {
|
||||
Some(body) => emitter.body = body,
|
||||
None => emitter.target = None,
|
||||
}
|
||||
}
|
||||
}
|
||||
self.emit(&emitter.options, emitter.body);
|
||||
emitter.remaining -= 1;
|
||||
emitter.remaining > 0
|
||||
});
|
||||
self.emitters = emitters;
|
||||
let mut particles = std::mem::take(&mut self.particles);
|
||||
self.spawned_in_tick = Some(Vec::new());
|
||||
for p in &mut particles {
|
||||
self.tick_particle(p, level);
|
||||
}
|
||||
particles.retain(|p| !p.removed);
|
||||
self.particles = particles;
|
||||
for (options, pos, velocity) in self.spawned_in_tick.take().unwrap_or_default() {
|
||||
self.add(&options, pos, velocity);
|
||||
}
|
||||
let pending = std::mem::take(&mut self.pending);
|
||||
for mut p in pending {
|
||||
if p.pre_tick {
|
||||
@@ -3923,8 +4026,12 @@ impl ParticleEngine {
|
||||
if let Some(frame) = update.frame {
|
||||
self.set_sprite_frame(p, frame);
|
||||
}
|
||||
for (options, pos, velocity) in update.spawned {
|
||||
self.add(&options, pos, velocity);
|
||||
if let Some(later) = &mut self.spawned_in_tick {
|
||||
later.extend(update.spawned);
|
||||
} else {
|
||||
for (options, pos, velocity) in update.spawned {
|
||||
self.add(&options, pos, velocity);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
p.remove();
|
||||
@@ -4868,7 +4975,10 @@ impl ParticleEngine {
|
||||
(pos.as_vec3(), p.quad_size(partial))
|
||||
});
|
||||
let rotation = if p.orientation != polytone_gpu::Orientation::Camera {
|
||||
let velocity = FVec3::new(
|
||||
// A custom particle's `movement_aligned` quad has its texture's top against
|
||||
// its motion (measured in Java: a light ray rising toward the noon sun hangs
|
||||
// below the leaves it starts at); a GPU particle's has it along its motion.
|
||||
let velocity = -FVec3::new(
|
||||
p.velocity[0] as f32,
|
||||
p.velocity[1] as f32,
|
||||
p.velocity[2] as f32,
|
||||
@@ -4958,6 +5068,35 @@ impl ParticleEngine {
|
||||
r.v0 + dv * p.uv[3],
|
||||
);
|
||||
let target: &mut Vec<Vertex> = if p.additive { &mut additive } else { &mut *out };
|
||||
let overlay = if p.additive {
|
||||
crate::gfx::ADDITIVE_PARTICLE_OVERLAY
|
||||
} else if layer {
|
||||
crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// A Polytone model particle: its model's faces, scaled by its size and turned as
|
||||
// its quad would be.
|
||||
if let Some(model) = &p.model {
|
||||
for quad in &model.quads {
|
||||
let Some(&(r, _)) = self.atlas.get(&quad.sprite) else {
|
||||
continue;
|
||||
};
|
||||
let (du, dv) = (r.u1 - r.u0, r.v1 - r.v0);
|
||||
for (corner, uv) in quad.corners.iter().zip(quad.uv) {
|
||||
target.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: (center + rotation * (*corner * size)).to_array(),
|
||||
uv: [r.u0 + du * uv[0], r.v0 + dv * uv[1]],
|
||||
layer: overlay | u32::from(hdr_tier(p.ty)) << 16,
|
||||
color,
|
||||
light,
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for rotation in rotations.into_iter().flatten() {
|
||||
for (n, uv) in [
|
||||
((1.0, -1.0), [u1, v1]),
|
||||
@@ -4970,11 +5109,7 @@ impl ParticleEngine {
|
||||
normal: [0; 4],
|
||||
pos: v.to_array(),
|
||||
uv,
|
||||
layer: if layer {
|
||||
crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY
|
||||
} else {
|
||||
0
|
||||
} | u32::from(hdr_tier(p.ty)) << 16,
|
||||
layer: overlay | u32::from(hdr_tier(p.ty)) << 16,
|
||||
color,
|
||||
light,
|
||||
|
||||
@@ -5062,6 +5197,8 @@ const CREEPER: [[f64; 2]; 12] = [
|
||||
[0.0, -0.4],
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tick_bench;
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -11,8 +11,8 @@ use crate::environment::Frame;
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use horizon_polytone::expr::{Host, Program, Root, Value as V};
|
||||
use horizon_polytone::particles::{
|
||||
BlockEmitters, CustomParticle, Emitter, EntityEmitters, LiquidAffinity, RenderType,
|
||||
SoundEmitter,
|
||||
BlockEmitters, CustomParticle, Emitter, EntityEmitters, LiquidAffinity, ParticleModifier,
|
||||
RenderType, SoundEmitter,
|
||||
};
|
||||
use horizon_world::registry::BlockRegistry;
|
||||
use serde_json::Value as Json;
|
||||
@@ -37,10 +37,15 @@ pub struct Registry {
|
||||
blocks: &'static BlockRegistry,
|
||||
/// Each custom particle type's colormap (`colormap`), by type id.
|
||||
tints: HashMap<String, Arc<Tint>>,
|
||||
/// Each model particle type's faces (`model`), by type id.
|
||||
models: HashMap<String, Arc<super::polytone_model::ParticleModel>>,
|
||||
/// Sound ids as the engine's sound queue holds them, interned once.
|
||||
sound_names: Mutex<HashMap<String, &'static str>>,
|
||||
/// Sounds the custom particles played this tick (`ParticleEngine::tick` plays them).
|
||||
sounds: Mutex<Vec<Sound>>,
|
||||
/// The particle modifiers with their colormaps, and by particle type the ones aimed at it.
|
||||
particle_modifiers: Vec<(ParticleModifier, Option<Tint>)>,
|
||||
modifiers_by_type: Mutex<HashMap<String, Arc<[usize]>>>,
|
||||
}
|
||||
|
||||
/// A sound for the engine's queue: id, position, volume, pitch, delayed by the distance.
|
||||
@@ -151,6 +156,7 @@ impl Registry {
|
||||
let mut types = HashMap::new();
|
||||
let mut modifiers = Vec::new();
|
||||
let mut entity_modifiers = Vec::new();
|
||||
let mut particle_modifiers = Vec::new();
|
||||
let namespaces = std::fs::read_dir(pack_root.join("assets"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
@@ -176,6 +182,28 @@ impl Registry {
|
||||
Err(e) => log::warn!("{relative}: {e}"),
|
||||
}
|
||||
}
|
||||
for (relative, json) in json_files(pack_root, &polytone.join("particle_modifiers")) {
|
||||
let implicit = horizon_polytone::implicit_id(&relative, "particle_modifiers");
|
||||
match ParticleModifier::parse(&json, implicit.as_deref()) {
|
||||
// A filter needs the block or item a particle carries, which particles here
|
||||
// only keep as a sprite: such a modifier would hit the wrong particles.
|
||||
Ok(Some(m)) if m.filter.is_some() => {
|
||||
log::warn!("{relative}: particle modifier filters aren't supported yet");
|
||||
}
|
||||
Ok(Some(m)) => {
|
||||
let tint = m.colormap.as_ref().and_then(|json| {
|
||||
let tint = Tint::read(pack_root, json);
|
||||
if tint.is_none() {
|
||||
log::warn!("{relative}: colormap {json} not found");
|
||||
}
|
||||
tint
|
||||
});
|
||||
particle_modifiers.push((m, tint));
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => log::warn!("{relative}: {e}"),
|
||||
}
|
||||
}
|
||||
for (relative, json) in json_files(pack_root, &polytone.join("block_modifiers")) {
|
||||
let implicit = horizon_polytone::implicit_id(&relative, "block_modifiers");
|
||||
match BlockEmitters::parse(&json, implicit.as_deref()) {
|
||||
@@ -212,6 +240,12 @@ impl Registry {
|
||||
.clone()
|
||||
})
|
||||
.collect();
|
||||
let models = super::polytone_model::read_models(
|
||||
pack_root,
|
||||
types
|
||||
.iter()
|
||||
.filter_map(|(id, def)| Some((id, def.render.get("model")?))),
|
||||
);
|
||||
let tints = types
|
||||
.iter()
|
||||
.filter_map(|(id, def)| {
|
||||
@@ -235,8 +269,11 @@ impl Registry {
|
||||
configs,
|
||||
blocks,
|
||||
tints,
|
||||
models,
|
||||
sound_names: Mutex::default(),
|
||||
sounds: Mutex::default(),
|
||||
particle_modifiers,
|
||||
modifiers_by_type: Mutex::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,6 +281,29 @@ impl Registry {
|
||||
self.types.is_empty()
|
||||
&& self.block_emitters.iter().all(Option::is_none)
|
||||
&& self.entity_modifiers.is_empty()
|
||||
&& self.particle_modifiers.is_empty()
|
||||
}
|
||||
|
||||
/// Whether any pack modifies vanilla or mod particles (`particle_modifiers`).
|
||||
pub(super) fn modifies_particles(&self) -> bool {
|
||||
!self.particle_modifiers.is_empty()
|
||||
}
|
||||
|
||||
/// The particle modifiers aimed at a particle type (`minecraft:flame`), in the order read;
|
||||
/// particle tags aren't resolved here.
|
||||
fn modifiers_for(&self, id: &str) -> Arc<[usize]> {
|
||||
let mut cache = self
|
||||
.modifiers_by_type
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(found) = cache.get(id) {
|
||||
return found.clone();
|
||||
}
|
||||
let found: Arc<[usize]> = (0..self.particle_modifiers.len())
|
||||
.filter(|&i| self.particle_modifiers[i].0.targets.matches(id, |_| false))
|
||||
.collect();
|
||||
cache.insert(id.to_owned(), found.clone());
|
||||
found
|
||||
}
|
||||
|
||||
/// A sound id as the engine's sound queue holds it.
|
||||
@@ -661,7 +721,7 @@ impl Host for Ctx<'_> {
|
||||
(CAMERA, "x" | "y" | "z") => num(frame.camera[axis(name)]),
|
||||
(CAMERA, "viewDistance") => num(f64::from(frame.render_distance * 16)),
|
||||
(CAMERA, _) => self.positional(ParticleLevel::block_at(frame.camera), name, args),
|
||||
(GLOBAL, "rain") => num(f64::from(frame.rain)),
|
||||
(GLOBAL, "rain") => num(frame.global_rain()),
|
||||
(GLOBAL, "time") => num(frame.game_time as f64),
|
||||
(GLOBAL, "dayTime") => num(frame.day_time as f64),
|
||||
(GLOBAL, "skyType") => num(f64::from(frame.sky_type)),
|
||||
@@ -1044,11 +1104,11 @@ impl super::ParticleController for Controller {
|
||||
}
|
||||
// Moves with collision when it has physics, else freely; then the friction.
|
||||
if def.has_physics {
|
||||
let before = p.position;
|
||||
p.move_by(level, p.velocity);
|
||||
let still = (0..3).all(|i| (p.position[i] - before[i]).abs() < 1.0e-7);
|
||||
// `kill_when_still`: a particle a collision stopped (one that landed), not one that
|
||||
// was never moving: in Java a resting emitter with physics lives out its life.
|
||||
if (def.kill_on_contact && (p.on_ground || p.stopped_by_collision))
|
||||
|| (def.kill_when_still && still)
|
||||
|| (def.kill_when_still && p.stopped_by_collision)
|
||||
{
|
||||
p.remove();
|
||||
}
|
||||
@@ -1105,19 +1165,41 @@ pub(super) fn make(
|
||||
return None;
|
||||
}
|
||||
}
|
||||
// `exclusion_radius`: none spawns within that distance of a live one of its type (rain
|
||||
// splashes, one per patch of ground).
|
||||
if def.exclusion_radius > 0.0 && def.gpu.is_none() {
|
||||
let r2 = def.exclusion_radius * def.exclusion_radius;
|
||||
let crowded = engine.particles.iter().chain(&engine.pending).any(|p| {
|
||||
!p.removed
|
||||
&& p.polytone_type.as_deref() == Some(name)
|
||||
&& (0..3)
|
||||
.map(|i| (p.position[i] - position[i]).powi(2))
|
||||
.sum::<f64>()
|
||||
< r2
|
||||
});
|
||||
if crowded {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let mut random = LegacyRandom::new(seed);
|
||||
let mut particle = Particle::at(&mut random, ParticleType::Smoke, position);
|
||||
particle.velocity = velocity;
|
||||
particle.gravity = 0.0;
|
||||
particle.has_physics = def.has_physics;
|
||||
let mut frames = 0;
|
||||
// A model particle (`model`) isn't drawn yet: it still ticks and emits. A GPU particle is
|
||||
// drawn from its record instead (`polytone_gpu`).
|
||||
// A model particle draws its model's faces; one whose model is missing still ticks and
|
||||
// emits. A GPU particle is drawn from its record instead (`polytone_gpu`).
|
||||
let model = registry.models.get(name).cloned();
|
||||
let hidden = def.render_type == RenderType::Invisible
|
||||
|| def.render.contains_key("model")
|
||||
|| (def.render.contains_key("model") && model.is_none())
|
||||
|| def.gpu.is_some();
|
||||
match engine.mod_sets.get(name) {
|
||||
Some(&(set, _)) => {
|
||||
// A mod's sprite set, or for a type replacing a vanilla one, that type's.
|
||||
let set = engine.mod_sets.get(name).map(|&(set, _)| set).or_else(|| {
|
||||
let vanilla = name.strip_prefix("minecraft:")?;
|
||||
engine.sets.get(&ParticleType::from_name(vanilla)?).copied()
|
||||
});
|
||||
match set {
|
||||
Some(set) => {
|
||||
let sprites = engine.set_sprites.get(set as usize)?;
|
||||
frames = sprites.len();
|
||||
particle.set = Some(set);
|
||||
@@ -1129,11 +1211,22 @@ pub(super) fn make(
|
||||
particle.sprite = Sprite::Index(*sprites.get(pick).or(sprites.first())?);
|
||||
}
|
||||
None if hidden => {}
|
||||
None if model.is_some() => {}
|
||||
None => return None,
|
||||
}
|
||||
particle.no_render = hidden;
|
||||
if let Some(model) = model {
|
||||
// The sprite lookup that admits a particle to drawing: its first face's.
|
||||
particle.sprite = Sprite::Named(model.quads[0].sprite.clone());
|
||||
particle.model = Some(model);
|
||||
}
|
||||
particle.orientation = super::polytone_gpu::Orientation::parse(&def.rotation_mode);
|
||||
particle.additive = def.render_type == RenderType::AdditiveTranslucent;
|
||||
// `light_level`: the block light the particle gives itself (wiki "Custom Particle Types"),
|
||||
// a floor under the light where it is, as GPU particles have.
|
||||
if def.light_level > 0 {
|
||||
particle.light = super::Light::MinBlock(def.light_level);
|
||||
}
|
||||
particle.behaviour = Behaviour::Controlled {
|
||||
translucent: def.render_type != RenderType::Opaque,
|
||||
};
|
||||
@@ -1170,7 +1263,8 @@ pub(super) fn make(
|
||||
customs.extend(seed.customs.iter().map(|(k, v)| (k.clone(), *v)));
|
||||
}
|
||||
particle.random = random;
|
||||
particle.polytone_type = def.limit.map(|_| Arc::from(name));
|
||||
particle.polytone_type =
|
||||
(def.limit.is_some() || def.exclusion_radius > 0.0).then(|| Arc::from(name));
|
||||
particle.bb = super::aabb_at(position, particle.bb_width, particle.bb_height);
|
||||
particle.controller = Some(Box::new(Controller {
|
||||
def: def.clone(),
|
||||
@@ -1185,6 +1279,70 @@ pub(super) fn make(
|
||||
Some(particle)
|
||||
}
|
||||
|
||||
/// Runs the packs' particle modifiers aimed at `id` on a particle that was just made: each
|
||||
/// expression once, against the particle as its constructor left it (`size` scales it, `speed`
|
||||
/// sets its power, as vanilla's `Particle.scale` and `setPower`).
|
||||
pub(super) fn modify(registry: &Registry, id: &str, p: &mut Particle) {
|
||||
let modifiers = registry.modifiers_for(id);
|
||||
if modifiers.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Ok(frame) = registry.frame.read() else {
|
||||
return;
|
||||
};
|
||||
let mut random = std::mem::replace(&mut p.random, LegacyRandom::new(0));
|
||||
let (mut custom, mut customs) = (0.0, BTreeMap::new());
|
||||
for &i in modifiers.iter() {
|
||||
let (modifier, tint) = ®istry.particle_modifiers[i];
|
||||
let mut ctx = Ctx {
|
||||
subject: Subject::Particle(p),
|
||||
random: &mut random,
|
||||
custom: &mut custom,
|
||||
customs: &mut customs,
|
||||
frame: &frame,
|
||||
registry,
|
||||
level: None,
|
||||
blocks: Vec::new(),
|
||||
};
|
||||
// Every expression reads the particle before this modifier changes it.
|
||||
let value =
|
||||
|ctx: &mut Ctx, key: &str| modifier.fields.get(key).and_then(|prog| eval(prog, ctx));
|
||||
let values = MODIFIED.map(|key| value(&mut ctx, key));
|
||||
let tinted = tint.as_ref().and_then(|t| t.sample(&mut ctx));
|
||||
drop(ctx);
|
||||
let [color, red, green, blue, alpha, life, size, speed] = values;
|
||||
if let Some(rgb) = tinted {
|
||||
p.rgba[..3].copy_from_slice(&rgb);
|
||||
}
|
||||
if let Some(c) = color {
|
||||
let c = c as i64;
|
||||
for (channel, shift) in [16, 8, 0].into_iter().enumerate() {
|
||||
p.rgba[channel] = ((c >> shift) & 0xff) as f32 / 255.0;
|
||||
}
|
||||
}
|
||||
for (channel, v) in [red, green, blue, alpha].into_iter().enumerate() {
|
||||
if let Some(v) = v {
|
||||
p.rgba[channel] = v as f32;
|
||||
}
|
||||
}
|
||||
if let Some(life) = life {
|
||||
p.lifetime = life as i32;
|
||||
}
|
||||
if let Some(size) = size {
|
||||
p.scale(size as f32);
|
||||
}
|
||||
if let Some(speed) = speed {
|
||||
p.set_power(speed as f32);
|
||||
}
|
||||
}
|
||||
p.random = random;
|
||||
}
|
||||
|
||||
/// The fields a particle modifier sets, in the order `modify` applies them.
|
||||
const MODIFIED: [&str; 8] = [
|
||||
"color", "red", "green", "blue", "alpha", "life", "size", "speed",
|
||||
];
|
||||
|
||||
impl ParticleEngine {
|
||||
/// The emitters of the packs' entity modifiers aimed at `entity` (its type, as
|
||||
/// `minecraft:player`) standing at `position`, run once a client tick.
|
||||
@@ -1420,6 +1578,39 @@ mod tests {
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
/// `light_level` is a floor under the block light a custom particle is drawn with (as the
|
||||
/// real mod draws a lit `light_level` 8 particle at night, measured beside it): the world's
|
||||
/// light where it is brighter.
|
||||
#[test]
|
||||
fn light_level_is_a_floor_under_the_block_light() {
|
||||
let blocks = blocks();
|
||||
let root = pack(&[
|
||||
(
|
||||
"assets/test/polytone/custom_particles/spark.json",
|
||||
serde_json::json!({"has_physics": false, "light_level": 8, "initializer": {"lifetime": "10"}}),
|
||||
),
|
||||
(
|
||||
"assets/test/polytone/block_modifiers/torch.json",
|
||||
serde_json::json!({"targets": ["minecraft:torch"], "particle_emitters": [{"particle": "test:spark", "chance": "1"}]}),
|
||||
),
|
||||
]);
|
||||
let scene = scene(blocks);
|
||||
let level = ParticleLevel {
|
||||
world: &scene.world,
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
engine.polytone_animate(&level, [1, 64, 1]);
|
||||
let p = engine.pending.pop().unwrap();
|
||||
// Dark: the floor, 8 (light coordinates count 16 a level). The sky light is the world's.
|
||||
assert_eq!(p.light_coords(0.0, 240, 0), (240, 128));
|
||||
// A brighter block light is kept.
|
||||
assert_eq!(p.light_coords(0.0, 0, 208), (0, 208));
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ticker_moves_the_particle_and_removes_it() {
|
||||
let blocks = blocks();
|
||||
@@ -1497,6 +1688,199 @@ mod tests {
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_particle_spawns_within_the_exclusion_radius_of_its_type() {
|
||||
let blocks = blocks();
|
||||
let root = pack(&[
|
||||
(
|
||||
"assets/test/polytone/custom_particles/splash.json",
|
||||
serde_json::json!({"has_physics": false, "exclusion_radius": 1, "render_type": "invisible"}),
|
||||
),
|
||||
(
|
||||
"assets/test/polytone/custom_particles/drop.json",
|
||||
serde_json::json!({"has_physics": false, "render_type": "invisible"}),
|
||||
),
|
||||
(
|
||||
"assets/minecraft/polytone/entity_modifiers/player.json",
|
||||
serde_json::json!({"emitters": [
|
||||
{"particle": "test:splash", "x": "0", "chance": "1"},
|
||||
// 0.5 from the first: excluded.
|
||||
{"particle": "test:splash", "x": "0.5", "chance": "1"},
|
||||
// 3 away: spawns.
|
||||
{"particle": "test:splash", "x": "3", "chance": "1"},
|
||||
// Another type is not excluded by it.
|
||||
{"particle": "test:drop", "x": "0.5", "chance": "1"}
|
||||
]}),
|
||||
),
|
||||
]);
|
||||
let scene = scene(blocks);
|
||||
let level = ParticleLevel {
|
||||
world: &scene.world,
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
};
|
||||
let registry = Registry::read(&root, Default::default(), blocks);
|
||||
let mut engine = engine(registry);
|
||||
engine.polytone_entity(&level, "minecraft:player", [1.0, 64.0, 1.0]);
|
||||
let mut xs: Vec<f64> = engine.pending.iter().map(|p| p.position[0]).collect();
|
||||
xs.sort_by(f64::total_cmp);
|
||||
assert_eq!(xs, [1.0, 1.5, 4.0]);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
/// Measured in Java: a custom particle's `movement_aligned` quad has its texture's top
|
||||
/// against its motion, so the freepack's light rays, rising toward the noon sun, hang
|
||||
/// below the leaves they start at instead of over them.
|
||||
#[test]
|
||||
fn a_rising_movement_aligned_particle_hangs_its_texture_below() {
|
||||
let blocks = blocks();
|
||||
let root = pack(&[
|
||||
(
|
||||
"assets/test/polytone/custom_particles/spark.json",
|
||||
serde_json::json!({"has_physics": false, "rotation_mode": "movement_aligned",
|
||||
"initializer": {"lifetime": "100", "size": "1"}}),
|
||||
),
|
||||
(
|
||||
"assets/minecraft/polytone/entity_modifiers/player.json",
|
||||
serde_json::json!({"emitters": [{"particle": "test:spark", "chance": "1", "dy": "0.01"}]}),
|
||||
),
|
||||
]);
|
||||
let scene = scene(blocks);
|
||||
let level = ParticleLevel {
|
||||
world: &scene.world,
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
let rect = crate::hud::Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 16,
|
||||
h: 16,
|
||||
};
|
||||
engine.rects = vec![Some(rect); 13];
|
||||
engine.polytone_entity(&level, "minecraft:player", [1.0, 64.0, 1.0]);
|
||||
engine.tick(&level);
|
||||
let p = engine.particles.last().unwrap();
|
||||
// The camera 5 blocks south, at the particle's height.
|
||||
let camera = DVec3::new(p.position[0], p.position[1], p.position[2] + 5.0);
|
||||
let mut out = Vec::new();
|
||||
engine.render(camera, 0.0, 0.0, 0.0, &|_| 0xF0, &mut out);
|
||||
assert_eq!(out.len(), 4);
|
||||
for v in &out {
|
||||
// The texture's top row (v 0) under the centre, its bottom row over it.
|
||||
if v.uv[1] == 0.0 {
|
||||
assert!(v.pos[1] < 0.0, "top row at {:?}", v.pos);
|
||||
} else {
|
||||
assert!(v.pos[1] > 0.0, "bottom row at {:?}", v.pos);
|
||||
}
|
||||
}
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
/// An `additive_translucent` custom particle takes the fog in Java, as vanilla's particle
|
||||
/// shader gives it: its quads say so to the additive pass.
|
||||
#[test]
|
||||
fn an_additive_custom_particle_takes_the_particle_shader_overlay() {
|
||||
let blocks = blocks();
|
||||
let root = pack(&[
|
||||
(
|
||||
"assets/test/polytone/custom_particles/spark.json",
|
||||
serde_json::json!({"has_physics": false, "render_type": "additive_translucent",
|
||||
"initializer": {"lifetime": "100", "size": "1"}}),
|
||||
),
|
||||
(
|
||||
"assets/minecraft/polytone/entity_modifiers/player.json",
|
||||
serde_json::json!({"emitters": [{"particle": "test:spark", "chance": "1"}]}),
|
||||
),
|
||||
]);
|
||||
let scene = scene(blocks);
|
||||
let level = ParticleLevel {
|
||||
world: &scene.world,
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
let rect = crate::hud::Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 16,
|
||||
h: 16,
|
||||
};
|
||||
engine.rects = vec![Some(rect); 13];
|
||||
engine.polytone_entity(&level, "minecraft:player", [1.0, 64.0, 1.0]);
|
||||
engine.tick(&level);
|
||||
let mut out = Vec::new();
|
||||
engine.render(
|
||||
DVec3::new(1.0, 64.0, 6.0),
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
&|_| 0xF0,
|
||||
&mut out,
|
||||
);
|
||||
assert!(out.is_empty());
|
||||
let additive = engine.take_additive();
|
||||
assert_eq!(additive.len(), 4);
|
||||
assert!(
|
||||
additive
|
||||
.iter()
|
||||
.all(|v| v.layer & 0xffff == crate::gfx::ADDITIVE_PARTICLE_OVERLAY)
|
||||
);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
/// A particle's emitter spawns while the live particles tick: its children's
|
||||
/// `exclusion_radius` sees the ones alive, so a ray emitted every tick at one spot stays one.
|
||||
#[test]
|
||||
fn a_particles_emitter_respects_the_exclusion_radius_of_its_children() {
|
||||
let blocks = blocks();
|
||||
let root = pack(&[
|
||||
(
|
||||
"assets/test/polytone/custom_particles/holder.json",
|
||||
serde_json::json!({"has_physics": false, "render_type": "invisible",
|
||||
"initializer": {"lifetime": "100"},
|
||||
"particle_emitters": [{"particle": "test:ray", "chance": "1"}]}),
|
||||
),
|
||||
(
|
||||
"assets/test/polytone/custom_particles/ray.json",
|
||||
serde_json::json!({"has_physics": false, "render_type": "invisible",
|
||||
"exclusion_radius": 1, "initializer": {"lifetime": "100"}}),
|
||||
),
|
||||
(
|
||||
"assets/minecraft/polytone/entity_modifiers/player.json",
|
||||
serde_json::json!({"emitters": [{"particle": "test:holder", "chance": "1"}]}),
|
||||
),
|
||||
]);
|
||||
let scene = scene(blocks);
|
||||
let level = ParticleLevel {
|
||||
world: &scene.world,
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
engine.polytone_entity(&level, "minecraft:player", [1.0, 70.0, 1.0]);
|
||||
for _ in 0..10 {
|
||||
engine.tick(&level);
|
||||
}
|
||||
let rays = engine
|
||||
.particles
|
||||
.iter()
|
||||
.chain(&engine.pending)
|
||||
.filter(|p| p.polytone_type.as_deref() == Some("test:ray"))
|
||||
.count();
|
||||
assert_eq!(rays, 1);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gpu_particle_is_a_record_drawn_until_its_life_ends() {
|
||||
let blocks = blocks();
|
||||
@@ -1627,4 +2011,113 @@ mod tests {
|
||||
);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn particle_modifiers_scale_power_and_recolour_their_particles_once() {
|
||||
let root = pack(&[
|
||||
(
|
||||
"assets/minecraft/polytone/particle_modifiers/flame.json",
|
||||
serde_json::json!({"size": "2", "life": "LIFETIME + 5", "red": "0.5", "speed": "2"}),
|
||||
),
|
||||
(
|
||||
"assets/pack/polytone/particle_modifiers/smoky.json",
|
||||
serde_json::json!({"targets": ["minecraft:smoke"], "color": "0x336699"}),
|
||||
),
|
||||
]);
|
||||
let registry = Registry::read(&root, Default::default(), blocks());
|
||||
let flame = || {
|
||||
let mut p = Particle::blank(ParticleType::Flame, [0.0; 3]);
|
||||
p.size = 0.1;
|
||||
p.lifetime = 10;
|
||||
p.velocity = [0.25, f64::from(0.1f32), -0.5];
|
||||
p
|
||||
};
|
||||
let mut p = flame();
|
||||
modify(®istry, "minecraft:flame", &mut p);
|
||||
// `size` multiplies, `speed` is vanilla's power: x and z doubled, a y of 0.1 kept.
|
||||
assert_eq!((p.size, p.lifetime, p.rgba[0]), (0.2, 15, 0.5));
|
||||
assert_eq!(p.velocity, [0.5, f64::from(0.1f32), -1.0]);
|
||||
// Another type's modifier, by its targets: the packed colour.
|
||||
let mut p = flame();
|
||||
modify(®istry, "minecraft:smoke", &mut p);
|
||||
assert_eq!(
|
||||
&p.rgba[..3],
|
||||
&[
|
||||
0x33 as f32 / 255.0,
|
||||
0x66 as f32 / 255.0,
|
||||
0x99 as f32 / 255.0
|
||||
]
|
||||
);
|
||||
assert_eq!((p.size, p.lifetime), (0.1, 10));
|
||||
// No modifier aimed at it: untouched.
|
||||
let mut p = flame();
|
||||
modify(®istry, "minecraft:soul_fire_flame", &mut p);
|
||||
assert_eq!(
|
||||
(p.size, p.lifetime, p.velocity),
|
||||
(0.1, 10, [0.25, f64::from(0.1f32), -0.5])
|
||||
);
|
||||
}
|
||||
|
||||
/// The freepack's light rays come from an emitter with physics that rests beside a leaf and
|
||||
/// emits every tick of its life: resting is not being still, only a stopped fall is.
|
||||
#[test]
|
||||
fn a_resting_particle_with_physics_lives_out_its_life() {
|
||||
let blocks = blocks();
|
||||
let root = pack(&[
|
||||
(
|
||||
"assets/test/polytone/custom_particles/rest.json",
|
||||
serde_json::json!({"has_physics": true, "render_type": "invisible",
|
||||
"initializer": {"lifetime": "20"}}),
|
||||
),
|
||||
(
|
||||
"assets/minecraft/polytone/entity_modifiers/player.json",
|
||||
serde_json::json!({"emitters": [{"particle": "test:rest", "chance": "1"}]}),
|
||||
),
|
||||
]);
|
||||
let scene = scene(blocks);
|
||||
let level = ParticleLevel {
|
||||
world: &scene.world,
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
engine.polytone_entity(&level, "minecraft:player", [1.5, 70.0, 1.5]);
|
||||
let mut p = engine.pending.pop().unwrap();
|
||||
for _ in 0..10 {
|
||||
engine.tick_particle(&mut p, &level);
|
||||
assert!(!p.removed);
|
||||
}
|
||||
// Stopped by a collision, it goes.
|
||||
p.stopped_by_collision = true;
|
||||
engine.tick_particle(&mut p, &level);
|
||||
assert!(p.removed);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pack_custom_particle_replaces_the_vanilla_type_of_its_id() {
|
||||
let root = pack(&[(
|
||||
"assets/minecraft/polytone/custom_particles/flame.json",
|
||||
serde_json::json!({"has_physics": false, "initializer": {"lifetime": "33", "size": "0.75"}}),
|
||||
)]);
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks()));
|
||||
// The vanilla flame's sprite set (what the pack's particles/flame.json lists).
|
||||
engine.sets.insert(ParticleType::Flame, 1);
|
||||
engine.set_sprites.push(vec![20, 21]);
|
||||
engine.create(
|
||||
&ParticleOptions::simple(ParticleType::Flame),
|
||||
[1.0, 65.0, 1.0],
|
||||
[0.0; 3],
|
||||
);
|
||||
assert_eq!(engine.pending.len(), 1);
|
||||
let p = &engine.pending[0];
|
||||
assert!(
|
||||
p.controller.is_some(),
|
||||
"the pack's particle, not vanilla's flame"
|
||||
);
|
||||
assert_eq!((p.lifetime, p.size), (33, 0.75));
|
||||
assert!(matches!(p.sprite, Sprite::Index(20)));
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ pub(super) enum Orientation {
|
||||
Flat,
|
||||
/// `movement_aligned`: stands along its velocity, facing the camera around it.
|
||||
Movement,
|
||||
/// `none`: fixed in the world's axes (its +Z facing south).
|
||||
Fixed,
|
||||
}
|
||||
|
||||
impl Orientation {
|
||||
@@ -31,6 +33,7 @@ impl Orientation {
|
||||
"look_at_y" => Self::AroundY,
|
||||
"look_up" => Self::Flat,
|
||||
"movement_aligned" => Self::Movement,
|
||||
"none" => Self::Fixed,
|
||||
_ => Self::Camera,
|
||||
}
|
||||
}
|
||||
@@ -50,6 +53,7 @@ pub(super) fn orient(
|
||||
Orientation::Camera => rolled(camera),
|
||||
Orientation::AroundY => rolled(Quat::from_rotation_y((-center.x).atan2(-center.z))),
|
||||
Orientation::Flat => rolled(Quat::from_rotation_x(-std::f32::consts::FRAC_PI_2)),
|
||||
Orientation::Fixed => rolled(Quat::IDENTITY),
|
||||
Orientation::Movement => {
|
||||
let up = velocity.normalize_or_zero();
|
||||
let right = up.cross((-center).normalize_or_zero()).normalize_or_zero();
|
||||
@@ -61,6 +65,24 @@ pub(super) fn orient(
|
||||
}
|
||||
}
|
||||
|
||||
/// The alpha a particle keeps at `life` (0 to 1 of its lifetime) with `fade_in` and `fade_out`
|
||||
/// fractions of it spent fading: each ramp eases in and out (smoothstep), as Polytone's are
|
||||
/// measured to (a linear ramp leaves young rain drops twice as bright).
|
||||
fn fade(life: f32, fade_in: f32, fade_out: f32) -> f32 {
|
||||
let ease = |x: f32| {
|
||||
let x = x.clamp(0.0, 1.0);
|
||||
x * x * (3.0 - 2.0 * x)
|
||||
};
|
||||
let mut alpha = 1.0;
|
||||
if fade_in > 0.0 {
|
||||
alpha *= ease(life / fade_in);
|
||||
}
|
||||
if fade_out > 0.0 {
|
||||
alpha *= ease((1.0 - life) / fade_out);
|
||||
}
|
||||
alpha
|
||||
}
|
||||
|
||||
/// One GPU particle spawn.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Record {
|
||||
@@ -239,12 +261,7 @@ impl Store {
|
||||
// Fading in and out, and the colour and size toward their ends.
|
||||
let [fade_in, fade_out] = gpu.fade.map(|f| f as f32);
|
||||
let mut alpha = r.rgba[3];
|
||||
if fade_in > 0.0 {
|
||||
alpha *= (life / fade_in).min(1.0);
|
||||
}
|
||||
if fade_out > 0.0 {
|
||||
alpha *= ((1.0 - life) / fade_out).min(1.0);
|
||||
}
|
||||
alpha *= fade(life, fade_in, fade_out);
|
||||
let mut rgb = [r.rgba[0], r.rgba[1], r.rgba[2]];
|
||||
if let Some(end) = gpu.color_end {
|
||||
let channel = |shift: u32| ((end >> shift) & 0xff) as f32 / 255.0;
|
||||
@@ -368,6 +385,20 @@ mod tests {
|
||||
assert!((v[1] + 0.1 / -f64::ln(friction)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// Measured in Java on a line of particles whose age grows with x: a quarter of the way
|
||||
/// through its fade-in a particle has about a sixth of its alpha, not a quarter; half at half.
|
||||
#[test]
|
||||
fn fades_ease_in_and_out() {
|
||||
let close = |a: f32, b: f32| (a - b).abs() < 1e-4;
|
||||
assert!(close(fade(0.0, 0.4, 0.2), 0.0));
|
||||
assert!(close(fade(0.1, 0.4, 0.2), 0.15625));
|
||||
assert!(close(fade(0.2, 0.4, 0.2), 0.5));
|
||||
assert!(close(fade(0.5, 0.4, 0.2), 1.0));
|
||||
assert!(close(fade(0.9, 0.4, 0.2), 0.5));
|
||||
assert!(close(fade(1.0, 0.4, 0.2), 0.0));
|
||||
assert!(close(fade(0.3, 0.0, 0.0), 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orientations_turn_the_quad_normal() {
|
||||
let normal = |q: Quat| q * FVec3::Z;
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
//! Polytone model particles (`"model"` on a custom particle type, #11486): a particle drawn as
|
||||
//! the faces of a block model (`assets/<ns>/models/<path>.json`) instead of one sprite quad.
|
||||
//! Visual Effects+ draws its block glows this way: a glow texture on a plane in front of the
|
||||
//! block, turned to the camera and blended additively.
|
||||
//!
|
||||
//! The model is read the vanilla way (elements, faces, `#texture` references, parents, element
|
||||
//! rotations) into quads around the model's centre, in blocks; the particle's size scales them
|
||||
//! and its rotation mode turns them as it turns a sprite quad (`+Z` facing the camera for
|
||||
//! `look_at_xyz`, world axes for `none`).
|
||||
use glam::{Quat, Vec3};
|
||||
use serde_json::{Map, Value as Json};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
/// One face of a model particle: corners in blocks around the model's centre, in drawing order,
|
||||
/// with their texture coordinates inside the sprite (0 to 1).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct ModelQuad {
|
||||
pub(crate) corners: [Vec3; 4],
|
||||
pub(crate) uv: [[f32; 2]; 4],
|
||||
/// The sprite's atlas name (`davidparticles:block/glow_test_1`, `block/stone`).
|
||||
pub(crate) sprite: String,
|
||||
}
|
||||
|
||||
/// A model particle's faces.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) struct ParticleModel {
|
||||
pub(crate) quads: Vec<ModelQuad>,
|
||||
}
|
||||
|
||||
/// `ns:path` with `minecraft` when the namespace is left out.
|
||||
fn split_id(id: &str) -> (&str, &str) {
|
||||
id.split_once(':').unwrap_or(("minecraft", id))
|
||||
}
|
||||
|
||||
/// The atlas name of a texture id: `minecraft:block/stone` is `block/stone`.
|
||||
fn sprite_name(texture: &str) -> String {
|
||||
match split_id(texture) {
|
||||
("minecraft", path) => path.to_owned(),
|
||||
(namespace, path) => format!("{namespace}:{path}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A model JSON and its parents' `textures` and `elements`, the child's winning.
|
||||
fn read_model(pack_root: &Path, id: &str, depth: u32) -> Option<(Map<String, Json>, Vec<Json>)> {
|
||||
if depth > 8 {
|
||||
return None;
|
||||
}
|
||||
let (namespace, path) = split_id(id);
|
||||
let file = pack_root
|
||||
.join("assets")
|
||||
.join(namespace)
|
||||
.join("models")
|
||||
.join(format!("{path}.json"));
|
||||
let json: Json = horizon_world::json_file::read(&file).ok()?;
|
||||
let (mut textures, mut elements) = match json.get("parent").and_then(Json::as_str) {
|
||||
Some(parent) => read_model(pack_root, parent, depth + 1).unwrap_or_default(),
|
||||
None => Default::default(),
|
||||
};
|
||||
if let Some(own) = json.get("textures").and_then(Json::as_object) {
|
||||
textures.extend(own.iter().map(|(k, v)| (k.clone(), v.clone())));
|
||||
}
|
||||
if let Some(own) = json.get("elements").and_then(Json::as_array) {
|
||||
elements.clone_from(own);
|
||||
}
|
||||
Some((textures, elements))
|
||||
}
|
||||
|
||||
/// A `#name` texture reference followed through the model's textures to an id.
|
||||
fn resolve_texture(textures: &Map<String, Json>, reference: &str) -> Option<String> {
|
||||
let mut name = reference;
|
||||
for _ in 0..8 {
|
||||
let Some(key) = name.strip_prefix('#') else {
|
||||
return Some(name.to_owned());
|
||||
};
|
||||
name = textures.get(key)?.as_str()?;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn vec3(v: &Json) -> Option<Vec3> {
|
||||
let a = v.as_array()?;
|
||||
Some(Vec3::new(
|
||||
a.first()?.as_f64()? as f32,
|
||||
a.get(1)?.as_f64()? as f32,
|
||||
a.get(2)?.as_f64()? as f32,
|
||||
))
|
||||
}
|
||||
|
||||
/// A face's corners (in model pixels) in vanilla's order: top left, bottom left, bottom right,
|
||||
/// top right, as seen from outside the face.
|
||||
fn face_corners(face: &str, f: Vec3, t: Vec3) -> Option<[Vec3; 4]> {
|
||||
let v = Vec3::new;
|
||||
Some(match face {
|
||||
"south" => [
|
||||
v(f.x, t.y, t.z),
|
||||
v(f.x, f.y, t.z),
|
||||
v(t.x, f.y, t.z),
|
||||
v(t.x, t.y, t.z),
|
||||
],
|
||||
"north" => [
|
||||
v(t.x, t.y, f.z),
|
||||
v(t.x, f.y, f.z),
|
||||
v(f.x, f.y, f.z),
|
||||
v(f.x, t.y, f.z),
|
||||
],
|
||||
"east" => [
|
||||
v(t.x, t.y, t.z),
|
||||
v(t.x, f.y, t.z),
|
||||
v(t.x, f.y, f.z),
|
||||
v(t.x, t.y, f.z),
|
||||
],
|
||||
"west" => [
|
||||
v(f.x, t.y, f.z),
|
||||
v(f.x, f.y, f.z),
|
||||
v(f.x, f.y, t.z),
|
||||
v(f.x, t.y, t.z),
|
||||
],
|
||||
"up" => [
|
||||
v(f.x, t.y, f.z),
|
||||
v(f.x, t.y, t.z),
|
||||
v(t.x, t.y, t.z),
|
||||
v(t.x, t.y, f.z),
|
||||
],
|
||||
"down" => [
|
||||
v(f.x, f.y, t.z),
|
||||
v(f.x, f.y, f.z),
|
||||
v(t.x, f.y, f.z),
|
||||
v(t.x, f.y, t.z),
|
||||
],
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The `uv` a face has when its JSON gives none: the element's extent on that face.
|
||||
fn default_uv(face: &str, f: Vec3, t: Vec3) -> [f32; 4] {
|
||||
match face {
|
||||
"north" => [16.0 - t.x, 16.0 - t.y, 16.0 - f.x, 16.0 - f.y],
|
||||
"south" => [f.x, 16.0 - t.y, t.x, 16.0 - f.y],
|
||||
"east" => [16.0 - t.z, 16.0 - t.y, 16.0 - f.z, 16.0 - f.y],
|
||||
"west" => [f.z, 16.0 - t.y, t.z, 16.0 - f.y],
|
||||
"up" => [f.x, f.z, t.x, t.z],
|
||||
_ => [f.x, 16.0 - t.z, t.x, 16.0 - f.z],
|
||||
}
|
||||
}
|
||||
|
||||
/// An element's `rotation` (`angle` degrees around `axis` through `origin`, in pixels).
|
||||
fn element_rotation(element: &Json) -> Option<(Quat, Vec3)> {
|
||||
let r = element.get("rotation")?;
|
||||
let angle = (r.get("angle")?.as_f64()? as f32).to_radians();
|
||||
if angle == 0.0 {
|
||||
return None;
|
||||
}
|
||||
let axis = match r.get("axis")?.as_str()? {
|
||||
"x" => Vec3::X,
|
||||
"y" => Vec3::Y,
|
||||
_ => Vec3::Z,
|
||||
};
|
||||
let origin = r.get("origin").and_then(vec3).unwrap_or(Vec3::splat(8.0));
|
||||
Some((Quat::from_axis_angle(axis, angle), origin))
|
||||
}
|
||||
|
||||
impl ParticleModel {
|
||||
/// The model `id` (`davidparticles:block/fullblock_glow`) under a pack overlay root; `None`
|
||||
/// when it's missing or has no face to draw.
|
||||
pub(crate) fn read(pack_root: &Path, id: &str) -> Option<Self> {
|
||||
let (textures, elements) = read_model(pack_root, id, 0)?;
|
||||
let mut quads = Vec::new();
|
||||
for element in &elements {
|
||||
let (Some(f), Some(t)) = (
|
||||
element.get("from").and_then(vec3),
|
||||
element.get("to").and_then(vec3),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let rotation = element_rotation(element);
|
||||
let faces = element.get("faces").and_then(Json::as_object);
|
||||
for (face, spec) in faces.into_iter().flatten() {
|
||||
let Some(corners) = face_corners(face, f, t) else {
|
||||
continue;
|
||||
};
|
||||
// A face with no area (a plane's edges) draws nothing.
|
||||
let span = (corners[0] - corners[2]).abs();
|
||||
if [span.x, span.y, span.z]
|
||||
.iter()
|
||||
.filter(|&&s| s > 1.0e-4)
|
||||
.count()
|
||||
< 2
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(sprite) = spec
|
||||
.get("texture")
|
||||
.and_then(Json::as_str)
|
||||
.and_then(|r| resolve_texture(&textures, r))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let uv = match spec.get("uv").and_then(Json::as_array) {
|
||||
Some(a) if a.len() == 4 => {
|
||||
let n = |i: usize| a[i].as_f64().unwrap_or(0.0) as f32;
|
||||
[n(0), n(1), n(2), n(3)]
|
||||
}
|
||||
_ => default_uv(face, f, t),
|
||||
};
|
||||
if uv[0] == uv[2] || uv[1] == uv[3] {
|
||||
continue;
|
||||
}
|
||||
let corners = corners.map(|c| {
|
||||
let c = match rotation {
|
||||
Some((q, origin)) => q * (c - origin) + origin,
|
||||
None => c,
|
||||
};
|
||||
c / 16.0 - Vec3::splat(0.5)
|
||||
});
|
||||
let [u0, v0, u1, v1] = uv.map(|x| x / 16.0);
|
||||
quads.push(ModelQuad {
|
||||
corners,
|
||||
uv: [[u0, v0], [u0, v1], [u1, v1], [u1, v0]],
|
||||
sprite: sprite_name(&sprite),
|
||||
});
|
||||
}
|
||||
}
|
||||
(!quads.is_empty()).then_some(Self { quads })
|
||||
}
|
||||
}
|
||||
|
||||
/// The packs' model particles: each custom particle type with a `model`, its faces.
|
||||
pub(crate) fn read_models<'a>(
|
||||
pack_root: &Path,
|
||||
types: impl Iterator<Item = (&'a String, &'a Json)>,
|
||||
) -> HashMap<String, std::sync::Arc<ParticleModel>> {
|
||||
types
|
||||
.filter_map(|(id, model)| {
|
||||
let model_id = model.as_str()?;
|
||||
match ParticleModel::read(pack_root, model_id) {
|
||||
Some(m) => Some((id.clone(), std::sync::Arc::new(m))),
|
||||
None => {
|
||||
log::warn!("custom particle {id}: model {model_id} not found or empty");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn write(root: &Path, path: &str, json: serde_json::Value) {
|
||||
let file = root.join(path);
|
||||
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
std::fs::write(file, json.to_string()).unwrap();
|
||||
}
|
||||
|
||||
fn root() -> std::path::PathBuf {
|
||||
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
std::env::temp_dir().join(format!("horizon-particle-model-{}-{n}", std::process::id()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_glow_plane_keeps_its_one_face_in_front_of_the_centre() {
|
||||
let root = root();
|
||||
// Visual Effects+'s glow, as Blockbench writes it: a plane at z 18.25 whose edges have
|
||||
// no area, and faces without a texture.
|
||||
write(
|
||||
&root,
|
||||
"assets/fx/models/block/glow.json",
|
||||
serde_json::json!({
|
||||
"textures": {"2": "fx:block/glow", "particle": "fx:block/glow"},
|
||||
"elements": [{
|
||||
"from": [1.25, 1.25, 18.25], "to": [14.75, 14.75, 18.25],
|
||||
"faces": {
|
||||
"north": {"uv": [0, 0, 0, 0], "texture": "#2"},
|
||||
"east": {"uv": [0, 0, 0, 4], "texture": "#missing"},
|
||||
"south": {"uv": [0, 0, 16, 16], "texture": "#2"}
|
||||
}
|
||||
}]
|
||||
}),
|
||||
);
|
||||
let model = ParticleModel::read(&root, "fx:block/glow").unwrap();
|
||||
assert_eq!(model.quads.len(), 1);
|
||||
let quad = &model.quads[0];
|
||||
assert_eq!(quad.sprite, "fx:block/glow");
|
||||
// 18.25 px is 0.640625 blocks in front of the centre; the plane spans ±0.421875.
|
||||
let z = 18.25 / 16.0 - 0.5;
|
||||
let e = 6.75 / 16.0;
|
||||
assert_eq!(
|
||||
quad.corners,
|
||||
[
|
||||
Vec3::new(-e, e, z),
|
||||
Vec3::new(-e, -e, z),
|
||||
Vec3::new(e, -e, z),
|
||||
Vec3::new(e, e, z)
|
||||
]
|
||||
);
|
||||
assert_eq!(quad.uv, [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]]);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parents_textures_and_default_uvs_resolve_as_vanilla() {
|
||||
let root = root();
|
||||
write(
|
||||
&root,
|
||||
"assets/minecraft/models/block/base.json",
|
||||
serde_json::json!({
|
||||
"textures": {"all": "block/stone"},
|
||||
"elements": [{"from": [0, 0, 0], "to": [16, 8, 16], "faces": {"up": {"texture": "#all"}}}]
|
||||
}),
|
||||
);
|
||||
write(
|
||||
&root,
|
||||
"assets/fx/models/slab.json",
|
||||
serde_json::json!({
|
||||
"parent": "block/base", "textures": {"all": "fx:block/sand"}
|
||||
}),
|
||||
);
|
||||
let model = ParticleModel::read(&root, "fx:slab").unwrap();
|
||||
assert_eq!(model.quads.len(), 1);
|
||||
// The child's texture wins; `up` at y 8 px, seen from above with north on top.
|
||||
assert_eq!(model.quads[0].sprite, "fx:block/sand");
|
||||
assert_eq!(model.quads[0].corners[0], Vec3::new(-0.5, 0.0, -0.5));
|
||||
assert_eq!(model.quads[0].corners[2], Vec3::new(0.5, 0.0, 0.5));
|
||||
assert_eq!(model.quads[0].uv[2], [1.0, 1.0]);
|
||||
assert!(ParticleModel::read(&root, "fx:missing").is_none());
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
//! The particle stress scene's load on `ParticleEngine`, outside the game: `cargo test --profile fast
|
||||
//! -p horizon-client-render --lib particle_tick_cost -- --ignored --nocapture` (prepared resources;
|
||||
//! `PARTICLE_BENCH_SECS` sets the run, default 5). It spawns what tools/stress_scenes.py's `particles`
|
||||
//! scene makes each tick (flames, cosy smoke, end rods, TNT debris) over its stone floor and times the
|
||||
//! spawns and the engine tick apart, once the engine holds as many particles as it will.
|
||||
use super::*;
|
||||
use crate::{blocks::BlockFacts, chunk::Chunk, collisions, world::World};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const FLOOR_Y: i32 = 289;
|
||||
|
||||
/// A cheap, repeatable stream of numbers in [0, 1).
|
||||
struct Lcg(u64);
|
||||
impl Lcg {
|
||||
fn next(&mut self) -> f64 {
|
||||
self.0 = self
|
||||
.0
|
||||
.wrapping_mul(6_364_136_223_846_793_005)
|
||||
.wrapping_add(1_442_695_040_888_963_407);
|
||||
((self.0 >> 11) as f64) / ((1u64 << 53) as f64)
|
||||
}
|
||||
fn signed(&mut self) -> f64 {
|
||||
self.next() * 2.0 - 1.0
|
||||
}
|
||||
}
|
||||
|
||||
type Emitter = (ParticleType, usize, [f64; 3], [f64; 3], f64);
|
||||
|
||||
fn spawn_tick(engine: &mut ParticleEngine, rng: &mut Lcg, types: &[Emitter], rockets: usize) {
|
||||
for _ in 0..rockets {
|
||||
// `summon firework_rocket` with a large ball of three colours and a trail, bursting 18 ticks up.
|
||||
let explosion = FireworkExplosion {
|
||||
shape: FireworkShape::LargeBall,
|
||||
colors: vec![0xFF0000, 0x00FF00, 0x0000FF],
|
||||
fade: Vec::new(),
|
||||
trail: true,
|
||||
twinkle: false,
|
||||
};
|
||||
let at = [-5.5 + rng.next() * 12.0, f64::from(FLOOR_Y + 20), 20.5];
|
||||
engine.fireworks(at, [0.0, 0.1, 0.0], vec![explosion], engine.camera);
|
||||
}
|
||||
for &(ty, count, center, delta, speed) in types {
|
||||
let options = ParticleOptions::simple(ty);
|
||||
for _ in 0..count {
|
||||
let p = [
|
||||
center[0] + rng.signed() * delta[0],
|
||||
center[1] + rng.signed() * delta[1],
|
||||
center[2] + rng.signed() * delta[2],
|
||||
];
|
||||
let v = [
|
||||
rng.signed() * speed,
|
||||
rng.signed() * speed,
|
||||
rng.signed() * speed,
|
||||
];
|
||||
engine.add(&options, p, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "a measurement, not a check"]
|
||||
fn particle_tick_cost() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("cached-collision.nbt").exists() {
|
||||
eprintln!("no prepared resources: skipped");
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let collision = collisions::load(&root, assets.registry).unwrap();
|
||||
let mut world = World::new(facts.air, 0);
|
||||
let floor = assets
|
||||
.registry
|
||||
.default_state("minecraft:smooth_stone")
|
||||
.unwrap();
|
||||
for cz in -1..=3 {
|
||||
for cx in -2..=2 {
|
||||
world
|
||||
.chunks
|
||||
.insert((cx, cz), Arc::new(Chunk::empty(cx, cz)));
|
||||
}
|
||||
}
|
||||
for z in 2..=40 {
|
||||
for x in -24..=24 {
|
||||
world.set(x, FLOOR_Y, z, floor).unwrap();
|
||||
}
|
||||
}
|
||||
let level = ParticleLevel {
|
||||
world: &world,
|
||||
facts: &facts,
|
||||
collision: &collision,
|
||||
player: None,
|
||||
};
|
||||
let ty = |name: &str| ParticleType::from_name(name).unwrap();
|
||||
let (fx, fy, fz) = (0.5, f64::from(FLOOR_Y + 5), 20.5);
|
||||
let tnt = [0.5, f64::from(FLOOR_Y + 24), 26.5];
|
||||
// The scene's emitters, per tick, and TNT's debris (poof and smoke).
|
||||
let flame: Emitter = (ty("flame"), 320, [fx, fy, fz], [8.0, 4.0, 8.0], 0.01);
|
||||
let cosy: Emitter = (
|
||||
ty("campfire_cosy_smoke"),
|
||||
8,
|
||||
[fx, fy, fz],
|
||||
[8.0, 4.0, 8.0],
|
||||
0.0,
|
||||
);
|
||||
let rod: Emitter = (ty("end_rod"), 80, [fx, fy, fz], [8.0, 4.0, 8.0], 0.05);
|
||||
let poof: Emitter = (ty("poof"), 258, tnt, [3.0, 3.0, 3.0], 0.4);
|
||||
let smoke: Emitter = (ty("smoke"), 238, tnt, [3.0, 3.0, 3.0], 0.4);
|
||||
let cases: [(&str, Vec<Emitter>, usize); 7] = [
|
||||
("flame 320/tick", vec![flame], 0),
|
||||
("cosy smoke 8/tick", vec![cosy], 0),
|
||||
("end rod 80/tick", vec![rod], 0),
|
||||
("poof 258/tick", vec![poof], 0),
|
||||
("smoke 238/tick", vec![smoke], 0),
|
||||
("3 rockets/tick", vec![], 3),
|
||||
("the whole scene", vec![flame, cosy, rod, poof, smoke], 3),
|
||||
];
|
||||
let secs: f64 = std::env::var("PARTICLE_BENCH_SECS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(2.0);
|
||||
for (name, types, rockets) in cases {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
engine.camera = [0.5, 298.0, 0.5];
|
||||
let mut rng = Lcg(1234);
|
||||
for _ in 0..600 {
|
||||
spawn_tick(&mut engine, &mut rng, &types, rockets);
|
||||
engine.tick(&level);
|
||||
}
|
||||
let (mut spawn, mut tick, mut ticks, mut alive) =
|
||||
(Duration::ZERO, Duration::ZERO, 0u64, 0usize);
|
||||
let end = Instant::now() + Duration::from_secs_f64(secs);
|
||||
while Instant::now() < end {
|
||||
let t = Instant::now();
|
||||
spawn_tick(&mut engine, &mut rng, &types, rockets);
|
||||
spawn += t.elapsed();
|
||||
let t = Instant::now();
|
||||
engine.tick(&level);
|
||||
tick += t.elapsed();
|
||||
alive += engine.len();
|
||||
ticks += 1;
|
||||
}
|
||||
let per = |d: Duration| d.as_secs_f64() * 1000.0 / ticks as f64;
|
||||
let alive = alive / ticks as usize;
|
||||
eprintln!(
|
||||
"{name:20} alive {alive:6} | per tick: spawn {:6.3} ms, engine tick {:6.3} ms | {:5.0} ns per particle",
|
||||
per(spawn),
|
||||
per(tick),
|
||||
per(tick) * 1.0e6 / alive.max(1) as f64
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
//! Resource packs' Polytone post chains (#11486, wiki "Shaders"): vanilla `post_effect` chains a
|
||||
//! pack runs over the world while their `activation_condition` holds, with uniform blocks from
|
||||
//! expressions (`expression_uniforms`), the `PolyGlobals` block, and targets of their own
|
||||
//! (`post_targets`) kept between frames. `post_chain` runs them; this reads what they add.
|
||||
use crate::environment::Frame;
|
||||
use crate::post_chain::{Extras, InternalTarget};
|
||||
use horizon_polytone::expr::Program;
|
||||
use serde_json::Value as Json;
|
||||
use std::path::Path;
|
||||
|
||||
/// A `polytone/post_chains/<path>.json`.
|
||||
struct Chain {
|
||||
/// The `post_effect` chain it runs.
|
||||
effect: String,
|
||||
activation: Option<Program>,
|
||||
/// Each expression uniform: its block's name and expression.
|
||||
uniforms: Vec<(String, Program)>,
|
||||
}
|
||||
|
||||
/// The packs' post chains and targets.
|
||||
pub struct PolytonePost {
|
||||
chains: Vec<Chain>,
|
||||
targets: std::collections::BTreeMap<String, InternalTarget>,
|
||||
configs: horizon_polytone::pack::Configs,
|
||||
}
|
||||
|
||||
/// What `PolyGlobals` holds (wiki "Extra Shader Uniforms").
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct PolyGlobals {
|
||||
/// The world's projection (reverse-Z, as 26.3's and Horizon's are), and its model-view (the
|
||||
/// camera's turn).
|
||||
pub projection: glam::Mat4,
|
||||
pub model_view: glam::Mat4,
|
||||
/// The sun's angle in radians, 0 at the horizon.
|
||||
pub sun_angle: f32,
|
||||
/// The time of day in ticks (0 to 24000), and the game ticks this frame advanced.
|
||||
pub day_time: f32,
|
||||
pub delta_time: f32,
|
||||
/// The player's feet: `PolyPlayerBlockPos`, and `PolyPlayerOffset` as vanilla's
|
||||
/// `CameraOffset` is (the block position less the position, so `polyPlayerPos()` is the
|
||||
/// block position less the offset).
|
||||
pub player: [f64; 3],
|
||||
}
|
||||
|
||||
impl PolyGlobals {
|
||||
/// The std140 block: `mat4, mat4, float, float, float, ivec3, vec3`.
|
||||
pub fn bytes(&self) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(176);
|
||||
for m in [self.projection, self.model_view] {
|
||||
out.extend(m.to_cols_array().iter().flat_map(|v| v.to_le_bytes()));
|
||||
}
|
||||
for v in [self.sun_angle, self.day_time, self.delta_time, 0.0] {
|
||||
out.extend(v.to_le_bytes());
|
||||
}
|
||||
let block = self.player.map(f64::floor);
|
||||
for v in block {
|
||||
out.extend((v as i32).to_le_bytes());
|
||||
}
|
||||
out.extend(0i32.to_le_bytes());
|
||||
for i in 0..3 {
|
||||
out.extend(((block[i] - self.player[i]) as f32).to_le_bytes());
|
||||
}
|
||||
out.extend(0f32.to_le_bytes());
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Every `.json` under `assets/<namespace>/polytone/<kind>`, by its implicit id.
|
||||
fn files(root: &Path, kind: &str) -> Vec<(String, Json)> {
|
||||
let mut out = Vec::new();
|
||||
let namespaces = std::fs::read_dir(root.join("assets"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten();
|
||||
for namespace in namespaces {
|
||||
let mut stack = vec![namespace.path().join("polytone").join(kind)];
|
||||
while let Some(dir) = stack.pop() {
|
||||
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
continue;
|
||||
}
|
||||
let relative = path
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(&path)
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let Some(id) = horizon_polytone::implicit_id(&relative, kind) else {
|
||||
continue;
|
||||
};
|
||||
match horizon_world::json_file::read::<Json>(&path) {
|
||||
Ok(json) => out.push((id, json)),
|
||||
Err(e) => log::warn!("{relative}: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn compile(where_: &str, src: &Json) -> Option<Program> {
|
||||
let text = match src {
|
||||
Json::String(s) => s.clone(),
|
||||
Json::Number(n) => n.to_string(),
|
||||
Json::Bool(b) => b.to_string(),
|
||||
_ => return None,
|
||||
};
|
||||
match Program::compile(&text) {
|
||||
Ok(p) => Some(p),
|
||||
Err(e) => {
|
||||
log::warn!("{where_}: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PolytonePost {
|
||||
/// The post chains and targets of the packs merged at `root`; `None` without chains.
|
||||
pub fn read(root: &Path, configs: horizon_polytone::pack::Configs) -> Option<Self> {
|
||||
let chains: Vec<Chain> = files(root, "post_chains")
|
||||
.into_iter()
|
||||
.filter_map(|(id, json)| {
|
||||
let effect = json["post_chain"].as_str()?;
|
||||
let effect = if effect.contains(':') {
|
||||
effect.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{effect}")
|
||||
};
|
||||
let activation = json
|
||||
.get("activation_condition")
|
||||
.and_then(|c| compile(&id, c));
|
||||
let uniforms = json["expression_uniforms"]
|
||||
.as_object()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|(name, src)| Some((name.clone(), compile(&id, src)?)))
|
||||
.collect();
|
||||
Some(Chain {
|
||||
effect,
|
||||
activation,
|
||||
uniforms,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if chains.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let targets = files(root, "post_targets")
|
||||
.into_iter()
|
||||
.map(|(id, json)| {
|
||||
let size = |key: &str| json[key].as_u64().filter(|n| *n > 0).map(|n| n as u32);
|
||||
let target = InternalTarget {
|
||||
width: size("width"),
|
||||
height: size("height"),
|
||||
persistent: true,
|
||||
clear: [0.0; 4],
|
||||
};
|
||||
(id, target)
|
||||
})
|
||||
.collect();
|
||||
Some(Self {
|
||||
chains,
|
||||
targets,
|
||||
configs,
|
||||
})
|
||||
}
|
||||
|
||||
/// The chains to run this frame (those whose condition holds), and what they add.
|
||||
pub fn frame(&self, frame: &Frame, globals: &PolyGlobals) -> (Vec<String>, Extras) {
|
||||
let mut effects = Vec::new();
|
||||
let mut extras = Extras {
|
||||
targets: self.targets.clone(),
|
||||
blocks: Default::default(),
|
||||
};
|
||||
extras
|
||||
.blocks
|
||||
.insert("PolyGlobals".to_owned(), globals.bytes());
|
||||
for chain in &self.chains {
|
||||
let on = chain.activation.as_ref().is_none_or(|c| {
|
||||
crate::environment::eval_frame(c, frame, &self.configs).is_some_and(|v| v.truthy())
|
||||
});
|
||||
if !on {
|
||||
continue;
|
||||
}
|
||||
for (name, program) in &chain.uniforms {
|
||||
let value = crate::environment::eval_frame(program, frame, &self.configs)
|
||||
.map_or(0.0, |v| v.num()) as f32;
|
||||
let mut block = value.to_le_bytes().to_vec();
|
||||
block.resize(16, 0);
|
||||
extras.blocks.insert(name.clone(), block);
|
||||
}
|
||||
effects.push(chain.effect.clone());
|
||||
}
|
||||
(effects, extras)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn poly_globals_follow_std140() {
|
||||
let g = PolyGlobals {
|
||||
projection: glam::Mat4::from_diagonal(glam::Vec4::splat(2.0)),
|
||||
sun_angle: 1.5,
|
||||
day_time: 6000.0,
|
||||
delta_time: 0.5,
|
||||
player: [10.25, -3.5, 7.0],
|
||||
..Default::default()
|
||||
};
|
||||
let b = g.bytes();
|
||||
let f = |at: usize| f32::from_le_bytes(b[at..at + 4].try_into().unwrap());
|
||||
let i = |at: usize| i32::from_le_bytes(b[at..at + 4].try_into().unwrap());
|
||||
assert_eq!(b.len(), 176);
|
||||
assert_eq!(f(0), 2.0);
|
||||
assert_eq!((f(128), f(132), f(136)), (1.5, 6000.0, 0.5));
|
||||
// ivec3 at 144, vec3 at 160 (std140 aligns both to 16). The offset is the block position
|
||||
// less the position, as vanilla's CameraOffset: the block position less it is the position.
|
||||
assert_eq!((i(144), i(148), i(152)), (10, -4, 7));
|
||||
assert_eq!((f(160), f(164), f(168)), (-0.25, -0.5, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chains_run_while_their_condition_holds_with_their_uniforms() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-polytone-post-{}", std::process::id()));
|
||||
let write = |path: &str, text: &str| {
|
||||
let path = root.join(path);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, text).unwrap();
|
||||
};
|
||||
write(
|
||||
"assets/p/polytone/post_chains/glare.json",
|
||||
r#"{"activation_condition": "g.rain() < 0.5", "post_chain": "p:glare",
|
||||
"expression_uniforms": {"Weather": "1 - g.rain()"}}"#,
|
||||
);
|
||||
write(
|
||||
"assets/p/polytone/post_targets/history.json",
|
||||
r#"{"width": 1, "height": 1}"#,
|
||||
);
|
||||
let post = PolytonePost::read(&root, Default::default()).unwrap();
|
||||
let mut frame = Frame {
|
||||
rain: 0.25,
|
||||
..Frame::default()
|
||||
};
|
||||
let (effects, extras) = post.frame(&frame, &PolyGlobals::default());
|
||||
assert_eq!(effects, ["p:glare"]);
|
||||
// `g.rain` is the mean of rain (0.25) and thunder (0): 0.125.
|
||||
assert_eq!(
|
||||
f32::from_le_bytes(extras.blocks["Weather"][..4].try_into().unwrap()),
|
||||
0.875
|
||||
);
|
||||
assert!(extras.targets["p:history"].persistent);
|
||||
frame.rain = 1.0;
|
||||
assert!(post.frame(&frame, &PolyGlobals::default()).0.is_empty());
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -385,6 +385,8 @@ enum Block {
|
||||
Globals,
|
||||
/// One of the pass's `uniforms` groups.
|
||||
Custom(wgpu::Buffer),
|
||||
/// A block the request sets every frame (`Extras::blocks`), by name.
|
||||
Dynamic(String, wgpu::Buffer),
|
||||
}
|
||||
|
||||
/// A pass running the resources' own GLSL, translated by `post_glsl`.
|
||||
@@ -474,6 +476,17 @@ pub struct Request<'a> {
|
||||
pub menu_blur_radius: f32,
|
||||
/// What the `Globals` block holds besides the screen size and the blur radius.
|
||||
pub globals: Globals,
|
||||
/// What resource packs' Polytone post chains add, when a pack has some.
|
||||
pub extras: Option<&'a Extras>,
|
||||
}
|
||||
|
||||
/// What resource packs' Polytone post chains add to every chain (#11486, wiki "Shaders"): their
|
||||
/// declared targets (`post_targets`), kept between frames, and uniform blocks set every frame.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Extras {
|
||||
pub targets: BTreeMap<String, InternalTarget>,
|
||||
/// Each block's std140 bytes, by its name (`PolyGlobals`, an expression uniform's).
|
||||
pub blocks: BTreeMap<String, Vec<u8>>,
|
||||
}
|
||||
|
||||
/// `GlobalSettingsUniform.update`'s other values.
|
||||
@@ -516,6 +529,7 @@ impl<'a> Request<'a> {
|
||||
root: Path::new(""),
|
||||
menu_blur_radius: 0.0,
|
||||
globals: Globals::default(),
|
||||
extras: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -544,8 +558,31 @@ pub struct PostEffects {
|
||||
/// root they load from; `pending` until they arrive.
|
||||
preloading: Option<(PathBuf, mpsc::Receiver<Preloaded>)>,
|
||||
pending: HashSet<String>,
|
||||
/// This frame's `Extras::blocks`.
|
||||
extra_blocks: BTreeMap<String, Vec<u8>>,
|
||||
/// The frame's depth with GL's rows, for the passes that read `minecraft:main`'s: the
|
||||
/// pipeline, and the texture at the frame's size.
|
||||
gl_depth: Option<(wgpu::BindGroupLayout, wgpu::RenderPipeline)>,
|
||||
gl_depth_target: Option<(wgpu::Texture, wgpu::TextureView)>,
|
||||
}
|
||||
|
||||
/// The frame's depth with its rows flipped as the chains' colour is (row 0 at the bottom). Its
|
||||
/// values stay: 26.3 keeps reverse-Z depth as Horizon does (the sky is 0), and packs' post
|
||||
/// shaders read it so.
|
||||
const GL_DEPTH_WGSL: &str = r#"
|
||||
@group(0) @binding(0) var depth: texture_depth_2d;
|
||||
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
||||
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
return vec4<f32>(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
}
|
||||
@fragment fn fs(@builtin(position) at: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let size = textureDimensions(depth);
|
||||
let x = min(u32(at.x), size.x - 1u);
|
||||
let y = size.y - 1u - min(u32(at.y), size.y - 1u);
|
||||
return vec4<f32>(textureLoad(depth, vec2<u32>(x, y), 0), 0.0, 0.0, 1.0);
|
||||
}
|
||||
"#;
|
||||
|
||||
/// A chain [`PostEffects::preload`] loaded, by id.
|
||||
type Preloaded = (String, Result<Option<Chain>, String>);
|
||||
|
||||
@@ -628,6 +665,9 @@ impl PostEffects {
|
||||
failed: HashSet::new(),
|
||||
preloading: None,
|
||||
pending: HashSet::new(),
|
||||
extra_blocks: BTreeMap::new(),
|
||||
gl_depth: None,
|
||||
gl_depth_target: None,
|
||||
root: PathBuf::new(),
|
||||
work: None,
|
||||
staging: None,
|
||||
@@ -685,7 +725,7 @@ impl PostEffects {
|
||||
let ids = ids.clone();
|
||||
move || {
|
||||
for id in ids {
|
||||
let mut chain = Self::load(&root, &device, &queue, &id);
|
||||
let mut chain = Self::load(&root, &device, &queue, &id, &Extras::default());
|
||||
if let Ok(Some(chain)) = &mut chain {
|
||||
for pass in &mut chain.passes {
|
||||
if let Some(translated) = &mut pass.translated {
|
||||
@@ -784,11 +824,18 @@ impl PostEffects {
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
id: &str,
|
||||
extras: &Extras,
|
||||
) -> Result<Option<Chain>, String> {
|
||||
let Ok(text) = std::fs::read_to_string(resource_path(root, id)) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let config = Config::parse(&text)?;
|
||||
let mut config = Config::parse(&text)?;
|
||||
for (target, t) in &extras.targets {
|
||||
config
|
||||
.targets
|
||||
.entry(target.clone())
|
||||
.or_insert_with(|| t.clone());
|
||||
}
|
||||
// isPostEffectValid / LevelTargetBundle.MAIN_TARGETS: the frame is the one
|
||||
// external target Horizon has.
|
||||
if let Some(bad) = config.external_targets().into_iter().find(|t| *t != MAIN) {
|
||||
@@ -812,18 +859,26 @@ impl PostEffects {
|
||||
let mut passes = Vec::new();
|
||||
for (n, p) in config.passes.iter().enumerate() {
|
||||
let output = index(&p.output).ok_or("a target is missing")?;
|
||||
let pass =
|
||||
match Self::translated_pass(root, device, queue, n, p, output, &index, &mut images)
|
||||
{
|
||||
Ok(pass) => pass,
|
||||
Err(e) => match Self::standin_pass(device, queue, n, p, output, &index) {
|
||||
Ok(pass) => {
|
||||
log::debug!("post effect {id} pass {n} uses the built-in shaders: {e}");
|
||||
pass
|
||||
}
|
||||
Err(_) => return Err(e),
|
||||
},
|
||||
};
|
||||
let pass = match Self::translated_pass(
|
||||
root,
|
||||
device,
|
||||
queue,
|
||||
n,
|
||||
p,
|
||||
output,
|
||||
&index,
|
||||
&mut images,
|
||||
extras,
|
||||
) {
|
||||
Ok(pass) => pass,
|
||||
Err(e) => match Self::standin_pass(device, queue, n, p, output, &index) {
|
||||
Ok(pass) => {
|
||||
log::debug!("post effect {id} pass {n} uses the built-in shaders: {e}");
|
||||
pass
|
||||
}
|
||||
Err(_) => return Err(e),
|
||||
},
|
||||
};
|
||||
passes.push(pass);
|
||||
}
|
||||
Ok(Some(Chain {
|
||||
@@ -847,6 +902,7 @@ impl PostEffects {
|
||||
output: usize,
|
||||
index: &dyn Fn(&str) -> Option<usize>,
|
||||
images: &mut Vec<(wgpu::Texture, wgpu::TextureView, [u32; 2])>,
|
||||
extras: &Extras,
|
||||
) -> Result<Pass, String> {
|
||||
let program = crate::post_glsl::translate(root, &p.vertex, &p.fragment)?;
|
||||
let mut refs = Vec::new();
|
||||
@@ -879,6 +935,15 @@ impl PostEffects {
|
||||
let block = match name.as_str() {
|
||||
"SamplerInfo" => Block::Info,
|
||||
"Globals" => Block::Globals,
|
||||
other if !p.uniforms.contains_key(other) && extras.blocks.contains_key(other) => {
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Post effect pack uniforms"),
|
||||
size: UNIFORM_BYTES,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
Block::Dynamic(other.to_owned(), buffer)
|
||||
}
|
||||
other => {
|
||||
let values = p
|
||||
.uniforms
|
||||
@@ -1130,6 +1195,7 @@ impl PostEffects {
|
||||
) -> bool {
|
||||
self.use_root(req.root);
|
||||
self.collect_preloaded();
|
||||
self.extra_blocks = req.extras.map(|e| e.blocks.clone()).unwrap_or_default();
|
||||
let mut applied = Vec::new();
|
||||
for effect in req.effects {
|
||||
let id = normalise(effect);
|
||||
@@ -1138,7 +1204,9 @@ impl PostEffects {
|
||||
continue;
|
||||
}
|
||||
if !self.chains.contains_key(&id) {
|
||||
let loaded = match Self::load(&self.root, device, queue, &id) {
|
||||
let empty = Extras::default();
|
||||
let extras = req.extras.unwrap_or(&empty);
|
||||
let loaded = match Self::load(&self.root, device, queue, &id, extras) {
|
||||
Ok(chain) => chain,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to load post effect {id}: {e}");
|
||||
@@ -1207,6 +1275,11 @@ impl PostEffects {
|
||||
timing.as_deref_mut(),
|
||||
);
|
||||
self.staging = Some(staging);
|
||||
if let Some(view) = depth {
|
||||
self.gl_depth(device, encoder, view, size);
|
||||
}
|
||||
let gl_depth = self.gl_depth_target.take();
|
||||
let depth = depth.and(gl_depth.as_ref().map(|(_, v)| v));
|
||||
for id in &applied {
|
||||
let mut chain = self.chains.remove(id).flatten().expect("loaded above");
|
||||
self.run_chain(
|
||||
@@ -1237,9 +1310,117 @@ impl PostEffects {
|
||||
);
|
||||
self.blit = Some(blit);
|
||||
self.work = Some(work);
|
||||
self.gl_depth_target = gl_depth;
|
||||
true
|
||||
}
|
||||
|
||||
/// Copies `depth` (the frame's) into `gl_depth_target` with GL's rows, at `size`.
|
||||
fn gl_depth(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
depth: &wgpu::TextureView,
|
||||
size: [u32; 2],
|
||||
) {
|
||||
let (layout, pipeline) = self.gl_depth.get_or_insert_with(|| {
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Post effect GL depth"),
|
||||
source: wgpu::ShaderSource::Wgsl(GL_DEPTH_WGSL.into()),
|
||||
});
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Post effect GL depth"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Depth,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Post effect GL depth"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Post effect GL depth"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("vs"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::TextureFormat::R32Float.into())],
|
||||
}),
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
(layout, pipeline)
|
||||
});
|
||||
if self
|
||||
.gl_depth_target
|
||||
.as_ref()
|
||||
.is_none_or(|(t, _)| t.size().width != size[0] || t.size().height != size[1])
|
||||
{
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Post effect GL depth"),
|
||||
size: wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::R32Float,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = texture.create_view(&Default::default());
|
||||
self.gl_depth_target = Some((texture, view));
|
||||
}
|
||||
let (_, target) = self.gl_depth_target.as_ref().expect("made above");
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Post effect GL depth"),
|
||||
layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(depth),
|
||||
}],
|
||||
});
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Post effect GL depth"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: target,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
multiview_mask: None,
|
||||
});
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn run_chain(
|
||||
&mut self,
|
||||
@@ -1438,6 +1619,13 @@ impl PostEffects {
|
||||
Block::Info => &pass.info,
|
||||
Block::Globals => &self.globals,
|
||||
Block::Custom(buffer) => buffer,
|
||||
Block::Dynamic(name, buffer) => {
|
||||
if let Some(bytes) = self.extra_blocks.get(name) {
|
||||
let n = bytes.len().min(UNIFORM_BYTES as usize) & !3;
|
||||
queue.write_buffer(buffer, 0, &bytes[..n]);
|
||||
}
|
||||
buffer
|
||||
}
|
||||
};
|
||||
entries.push(wgpu::BindGroupEntry {
|
||||
binding: *binding,
|
||||
@@ -1831,6 +2019,7 @@ mod tests {
|
||||
root,
|
||||
menu_blur_radius: 5.0,
|
||||
globals: Globals::default(),
|
||||
extras: None,
|
||||
},
|
||||
None,
|
||||
);
|
||||
@@ -1962,6 +2151,7 @@ mod tests {
|
||||
root,
|
||||
menu_blur_radius: 0.0,
|
||||
globals: Globals::default(),
|
||||
extras: None,
|
||||
},
|
||||
None,
|
||||
));
|
||||
@@ -2050,6 +2240,99 @@ mod tests {
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
/// The frame's depth reaches a pass as 26.3 keeps it, reverse-Z (the sky 0, a near pixel
|
||||
/// close to 1), not as GL's window depth.
|
||||
#[test]
|
||||
fn a_pass_reads_the_frame_depth_as_it_is() {
|
||||
let Some((device, queue)) = device() else {
|
||||
return;
|
||||
};
|
||||
let root = scratch(
|
||||
"depth",
|
||||
r#"{"passes":[{"vertex_shader":"minecraft:core/quad","fragment_shader":"minecraft:post/depth",
|
||||
"inputs":[{"sampler_name":"Depth","target":"minecraft:main","use_depth_buffer":true}],
|
||||
"output":"minecraft:main"}]}"#,
|
||||
);
|
||||
own_glsl(&root);
|
||||
std::fs::write(
|
||||
root.join("assets/minecraft/shaders/post/depth.fsh"),
|
||||
"#version 330\nuniform sampler2D DepthSampler;\nlayout(location = 0) in vec2 texCoord;\n\
|
||||
layout(location = 0) out vec4 fragColor;\n\
|
||||
void main() { fragColor = vec4(texture(DepthSampler, texCoord).r, 0.0, 0.0, 1.0); }\n",
|
||||
)
|
||||
.unwrap();
|
||||
let size = wgpu::Extent3d {
|
||||
width: 8,
|
||||
height: 4,
|
||||
depth_or_array_layers: 1,
|
||||
};
|
||||
let texture = |format, usage| {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage,
|
||||
view_formats: &[],
|
||||
})
|
||||
};
|
||||
let frame = texture(
|
||||
wgpu::TextureFormat::Rgba8Unorm,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let depth = texture(
|
||||
wgpu::TextureFormat::Depth32Float,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
);
|
||||
let (view, depth_view) = (
|
||||
frame.create_view(&Default::default()),
|
||||
depth.create_view(&Default::default()),
|
||||
);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &depth_view,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(0.25),
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
let mut effects = PostEffects::new(&device);
|
||||
let ran = effects.apply(
|
||||
&device,
|
||||
&queue,
|
||||
&mut encoder,
|
||||
&frame,
|
||||
&view,
|
||||
[8, 4],
|
||||
Some(&depth_view),
|
||||
&Request {
|
||||
effects: &["minecraft:depth".to_owned()],
|
||||
root: &root,
|
||||
menu_blur_radius: 0.0,
|
||||
globals: Globals::default(),
|
||||
extras: None,
|
||||
},
|
||||
None,
|
||||
);
|
||||
let buffer = crate::gfx::copy_frame(&device, &mut encoder, &frame, [8, 4]);
|
||||
queue.submit([encoder.finish()]);
|
||||
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let red = buffer.get_mapped_range(..).unwrap()[0];
|
||||
assert!(ran);
|
||||
// 0.25 of 255, not 0.75.
|
||||
assert!(red.abs_diff(64) <= 1, "{red}");
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
/// `core/quad.vsh`, `post/mine.fsh` and the `tint` image of `a_chain_runs_its_own_glsl`.
|
||||
fn own_glsl(root: &Path) {
|
||||
let shaders = root.join("assets/minecraft/shaders");
|
||||
@@ -2189,13 +2472,22 @@ mod tests {
|
||||
for name in ["blur", "creeper", "invert", "spider"] {
|
||||
let id = format!("minecraft:{name}");
|
||||
assert!(
|
||||
PostEffects::load(&root, &device, &queue, &id)
|
||||
PostEffects::load(&root, &device, &queue, &id, &Extras::default())
|
||||
.unwrap()
|
||||
.is_some(),
|
||||
"{id}"
|
||||
);
|
||||
}
|
||||
// entity_outline needs targets and shaders the world pass has of its own.
|
||||
assert!(PostEffects::load(&root, &device, &queue, "minecraft:entity_outline").is_err());
|
||||
assert!(
|
||||
PostEffects::load(
|
||||
&root,
|
||||
&device,
|
||||
&queue,
|
||||
"minecraft:entity_outline",
|
||||
&Extras::default()
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -165,7 +165,7 @@ pub struct Context {
|
||||
pub spectator: bool,
|
||||
/// `isSteppingCarefully` (sneaking).
|
||||
pub stepping_carefully: bool,
|
||||
/// `dampensVibrations` (a warden).
|
||||
/// `dampensVibrations` (a warden or a wool item).
|
||||
pub dampens: bool,
|
||||
/// The source is a living entity wardens may target.
|
||||
pub targetable: bool,
|
||||
@@ -187,11 +187,10 @@ pub fn valid_vibration(listens: bool, event: &str, ctx: &Context) -> bool {
|
||||
if ctx.stepping_carefully && ignored_when_sneaking(event) {
|
||||
return false;
|
||||
}
|
||||
if ctx.dampens {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
!ctx.dampening_state
|
||||
// Nonliving sources have no Who handle, but ItemEntity still carries its
|
||||
// stack's dampensVibrations flag (including after Entity.kill removes it).
|
||||
!ctx.dampens && !ctx.dampening_state
|
||||
}
|
||||
|
||||
impl Selector {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -19,8 +19,11 @@ pub struct ShaderOptions {
|
||||
pub chosen: BTreeMap<String, String>,
|
||||
/// The screens opened from the main one, innermost last.
|
||||
pub path: Vec<String>,
|
||||
/// Something changed since the page opened.
|
||||
/// The values differ from those the pack's file holds (`saved`): saving them loads the pack
|
||||
/// again.
|
||||
pub changed: bool,
|
||||
/// The values the pack's file holds, as read or as last saved.
|
||||
saved: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl ShaderOptions {
|
||||
@@ -39,14 +42,28 @@ impl ShaderOptions {
|
||||
chosen: BTreeMap::new(),
|
||||
path: Vec::new(),
|
||||
changed: false,
|
||||
saved: BTreeMap::new(),
|
||||
};
|
||||
for (name, value) in chosen {
|
||||
out.set(&name, value);
|
||||
}
|
||||
out.saved = out.chosen.clone();
|
||||
out.changed = false;
|
||||
out
|
||||
}
|
||||
|
||||
/// The values back to `chosen` (Undo): changed while they differ from the file's.
|
||||
pub fn restore(&mut self, chosen: BTreeMap<String, String>) {
|
||||
self.chosen = chosen;
|
||||
self.changed = self.chosen != self.saved;
|
||||
}
|
||||
|
||||
/// The pack's file holds `saved` now, saved by another way than this page's.
|
||||
pub fn saved_as(&mut self, saved: BTreeMap<String, String>) {
|
||||
self.saved = saved;
|
||||
self.changed = self.chosen != self.saved;
|
||||
}
|
||||
|
||||
/// The screen shown: the innermost opened, else the main one.
|
||||
pub fn screen(&self) -> &str {
|
||||
self.path.last().map_or("", String::as_str)
|
||||
@@ -74,7 +91,7 @@ impl ShaderOptions {
|
||||
} else {
|
||||
self.chosen.insert(name.to_owned(), value);
|
||||
}
|
||||
self.changed = true;
|
||||
self.changed = self.chosen != self.saved;
|
||||
}
|
||||
|
||||
/// The option's next value (the previous one with `back`), wrapping around.
|
||||
@@ -123,9 +140,8 @@ impl ShaderOptions {
|
||||
|
||||
/// Option `name` back at the pack's default.
|
||||
pub fn reset_option(&mut self, name: &str) {
|
||||
if self.chosen.remove(name).is_some() {
|
||||
self.changed = true;
|
||||
}
|
||||
self.chosen.remove(name);
|
||||
self.changed = self.chosen != self.saved;
|
||||
}
|
||||
|
||||
/// The profile after the current one (the first when none matches) with its values.
|
||||
@@ -150,10 +166,8 @@ impl ShaderOptions {
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
if !self.chosen.is_empty() {
|
||||
self.chosen.clear();
|
||||
self.changed = true;
|
||||
}
|
||||
self.chosen.clear();
|
||||
self.changed = self.chosen != self.saved;
|
||||
}
|
||||
|
||||
fn named(&self, key: &str, or: &str) -> Text {
|
||||
|
||||
@@ -73,29 +73,6 @@ impl Entry {
|
||||
changed
|
||||
}
|
||||
|
||||
/// Option `i`'s slider one step on (`back`: one before), stopping at its ends. Whether its
|
||||
/// value changed.
|
||||
pub fn nudge_option(&mut self, i: usize, back: bool) -> bool {
|
||||
let (Some(o), Some(v)) = (self.options.get(i), self.values.get_mut(i)) else {
|
||||
return false;
|
||||
};
|
||||
let dir = if back { -1.0 } else { 1.0 };
|
||||
let new = match &o.kind {
|
||||
OptionKind::Float { min, max, step, .. } => {
|
||||
let (min, max, step) = (f64::from(*min), f64::from(*max), f64::from(*step));
|
||||
let n = ((*v - min) / step).round() + dir;
|
||||
on_f32((min + n * step).clamp(min, max))
|
||||
}
|
||||
OptionKind::Int { min, max, .. } => {
|
||||
(v.round() + dir).clamp(f64::from(*min), f64::from(*max))
|
||||
}
|
||||
_ => return false,
|
||||
};
|
||||
let changed = new != *v;
|
||||
*v = new;
|
||||
changed
|
||||
}
|
||||
|
||||
/// Every option back to its default.
|
||||
pub fn reset_options(&mut self) {
|
||||
let defaults = self.options.iter().map(|o| o.kind.default_constant());
|
||||
@@ -353,15 +330,36 @@ fn step_value(kind: &OptionKind, v: f64, back: bool) -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where `v` sits between the option's minimum and maximum, 0 to 1; `None` for the options
|
||||
/// that are buttons.
|
||||
/// A float option's stops past its minimum: its steps, the last one cut short at the maximum
|
||||
/// when the range isn't a whole number of them.
|
||||
pub fn float_steps(min: f32, max: f32, step: f32) -> f64 {
|
||||
let (min, max, step) = (f64::from(min), f64::from(max), f64::from(step));
|
||||
((max - min) / step - STEP_SLACK).ceil()
|
||||
}
|
||||
/// What a range a hair over a whole number of steps loses to rounding, short of another stop.
|
||||
const STEP_SLACK: f64 = 1e-6;
|
||||
|
||||
/// Where `v` sits between the option's minimum and maximum, 0 to 1, as `slider_value` reads
|
||||
/// it back: a float at its stop (the maximum the last one), an int along its range. `None` for
|
||||
/// the options that are buttons.
|
||||
fn slider_position(kind: &OptionKind, v: f64) -> Option<f32> {
|
||||
let (min, max) = match kind {
|
||||
OptionKind::Float { min, max, .. } => (f64::from(*min), f64::from(*max)),
|
||||
OptionKind::Int { min, max, .. } => (f64::from(*min), f64::from(*max)),
|
||||
let position = match kind {
|
||||
OptionKind::Float { min, max, step, .. } => {
|
||||
let steps = float_steps(*min, *max, *step);
|
||||
let stop = ((v - f64::from(*min)) / f64::from(*step)).round();
|
||||
if v >= f64::from(*max) || steps <= 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
stop / steps
|
||||
}
|
||||
}
|
||||
OptionKind::Int { min, max, .. } => {
|
||||
let (min, max) = (f64::from(*min), f64::from(*max));
|
||||
(v - min) / (max - min)
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(((v - min) / (max - min)).clamp(0.0, 1.0) as f32)
|
||||
Some(position.clamp(0.0, 1.0) as f32)
|
||||
}
|
||||
|
||||
/// The option's value nearest the slider position `at`: a float on its steps from the
|
||||
@@ -371,8 +369,8 @@ fn slider_value(kind: &OptionKind, at: f32) -> Option<f64> {
|
||||
let at = f64::from(at.clamp(0.0, 1.0));
|
||||
match kind {
|
||||
OptionKind::Float { min, max, step, .. } => {
|
||||
let steps = float_steps(*min, *max, *step);
|
||||
let (min, max, step) = (f64::from(*min), f64::from(*max), f64::from(*step));
|
||||
let steps = ((max - min) / step - 1e-6).ceil();
|
||||
Some(on_f32((min + (at * steps).round() * step).min(max)))
|
||||
}
|
||||
OptionKind::Int { min, max, .. } => {
|
||||
@@ -649,17 +647,47 @@ mod tests {
|
||||
assert_eq!(entry.values[3], 1.0);
|
||||
entry.set_slider_position(3, 0.6);
|
||||
assert_eq!(entry.values[3], f64::from(0.6_f32));
|
||||
}
|
||||
|
||||
// The arrow keys: a step at a time, not round past the ends.
|
||||
entry.values[0] = 2.0;
|
||||
assert!(!entry.nudge_option(0, false));
|
||||
assert_eq!(entry.values[0], 2.0);
|
||||
assert!(entry.nudge_option(0, true));
|
||||
assert_eq!(entry.values[0], f64::from(1.95_f32));
|
||||
entry.values[1] = 0.0;
|
||||
assert!(!entry.nudge_option(1, true));
|
||||
assert!(entry.nudge_option(1, false));
|
||||
assert_eq!(entry.values[1], 1.0);
|
||||
assert!(!entry.nudge_option(2, false));
|
||||
/// The arrow keys step a slider through its stops (the shell's `slider_step`, by the stops
|
||||
/// `float_steps` counts): a float whose range isn't a whole number of steps walks every one
|
||||
/// both ways, its maximum included, and stops at the ends.
|
||||
#[test]
|
||||
fn the_arrow_keys_walk_a_floats_stops_both_ways() {
|
||||
let kind = OptionKind::Float {
|
||||
default: 0.0,
|
||||
min: 0.0,
|
||||
max: 1.0,
|
||||
step: 0.3,
|
||||
};
|
||||
let mut entry = Entry {
|
||||
file: "p".into(),
|
||||
key: "horizon:p".into(),
|
||||
title: "P".into(),
|
||||
kind: Kind::Horizon,
|
||||
options: vec![horizon_shader_api::PackOption {
|
||||
id: "d".into(),
|
||||
name: "D".into(),
|
||||
description: String::new(),
|
||||
kind,
|
||||
}],
|
||||
values: vec![0.0],
|
||||
pages: Vec::new(),
|
||||
};
|
||||
let steps = float_steps(0.0, 1.0, 0.3) as u32;
|
||||
let mut walk = |forward: bool| {
|
||||
let mut seen = Vec::new();
|
||||
for _ in 0..steps + 2 {
|
||||
let at = entry.slider_position(0).unwrap();
|
||||
let next = crate::settings_shell::slider_step(at, Some(steps), 100.0, forward);
|
||||
entry.set_slider_position(0, next);
|
||||
seen.push(entry.values[0]);
|
||||
}
|
||||
seen
|
||||
};
|
||||
// The `k`th stop, as the pack's f32 `override` gets it.
|
||||
let stop = |k: u8| f64::from((f64::from(k) * f64::from(0.3_f32)) as f32);
|
||||
assert_eq!(walk(true), [stop(1), stop(2), stop(3), 1.0, 1.0, 1.0]);
|
||||
assert_eq!(walk(false), [stop(3), stop(2), stop(1), 0.0, 0.0, 0.0]);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -5,6 +5,13 @@ pub use crate::chunk::block_entity::{SavedLine, SignData, SignText};
|
||||
use crate::{scoreboard, text::Text};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
/// `getBooleanOr(key, false)` and `Codec.BOOL`: any numeric tag's nonzero byte.
|
||||
fn nbt_flag(tag: &Tag, key: &str) -> bool {
|
||||
tag.get(key)
|
||||
.and_then(horizon_client_core::nbt_path::int_value)
|
||||
.is_some_and(|value| value as i8 != 0)
|
||||
}
|
||||
|
||||
/// `DyeColor` names and `getTextColor` values by id.
|
||||
pub const DYE_TEXT: [(&str, u32); 16] = [
|
||||
("white", 0xFFFFFF),
|
||||
@@ -201,12 +208,12 @@ impl SignData {
|
||||
out.color = i as u8;
|
||||
}
|
||||
}
|
||||
out.glowing = matches!(t.get("has_glowing_text"), Some(Tag::Byte(b)) if *b != 0);
|
||||
out.glowing = nbt_flag(t, "has_glowing_text");
|
||||
out
|
||||
};
|
||||
self.front = side(tag.get("front_text"));
|
||||
self.back = side(tag.get("back_text"));
|
||||
self.waxed = matches!(tag.get("is_waxed"), Some(Tag::Byte(b)) if *b != 0);
|
||||
self.waxed = nbt_flag(tag, "is_waxed");
|
||||
self.allow_op_features =
|
||||
matches!(tag.get("allow_op_features"), Some(Tag::Byte(b)) if *b != 0);
|
||||
}
|
||||
@@ -301,6 +308,19 @@ pub fn editor_sprite(folder: &str, wood: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn waxed_and_glowing_text_take_any_number() {
|
||||
let tag = Tag::compound([
|
||||
("is_waxed", Tag::Int(1)),
|
||||
(
|
||||
"front_text",
|
||||
Tag::compound([("has_glowing_text", Tag::Short(1))]),
|
||||
),
|
||||
]);
|
||||
let mut decoded = SignData::default();
|
||||
decoded.decode(&tag);
|
||||
assert!(decoded.waxed && decoded.front.glowing);
|
||||
}
|
||||
#[test]
|
||||
fn round_trips_nbt() {
|
||||
let mut data = SignData {
|
||||
hanging: false,
|
||||
|
||||
@@ -978,6 +978,10 @@ pub struct SoundEngine {
|
||||
instance_by_source: Vec<(usize, InstanceId)>,
|
||||
gain_by_source: [f32; 11],
|
||||
ticking: Vec<InstanceId>,
|
||||
live_scratch: Vec<(u64, ChannelId)>,
|
||||
mapped_scratch: Vec<u64>,
|
||||
removed_scratch: Vec<InstanceId>,
|
||||
ticking_scratch: Vec<InstanceId>,
|
||||
/// `queuedSounds` (insertion order).
|
||||
queued: Vec<(InstanceId, i32)>,
|
||||
delete_time: HashMap<InstanceId, i32>,
|
||||
@@ -1015,6 +1019,10 @@ impl SoundEngine {
|
||||
instance_by_source: Vec::new(),
|
||||
gain_by_source: [1.0; 11],
|
||||
ticking: Vec::new(),
|
||||
live_scratch: Vec::new(),
|
||||
mapped_scratch: Vec::new(),
|
||||
removed_scratch: Vec::new(),
|
||||
ticking_scratch: Vec::new(),
|
||||
queued: Vec::new(),
|
||||
delete_time: HashMap::new(),
|
||||
queued_tickable: Vec::new(),
|
||||
@@ -1479,20 +1487,26 @@ impl SoundEngine {
|
||||
self.schedule_tick(backend);
|
||||
}
|
||||
fn schedule_tick(&mut self, backend: &mut impl Backend) {
|
||||
let live: Vec<(u64, ChannelId)> = self
|
||||
.handles
|
||||
.iter()
|
||||
.filter_map(|(h, handle)| handle.channel.map(|c| (*h, c)))
|
||||
.collect();
|
||||
for (h, c) in live {
|
||||
let mut live = std::mem::take(&mut self.live_scratch);
|
||||
live.clear();
|
||||
live.extend(
|
||||
self.handles
|
||||
.iter()
|
||||
.filter_map(|(h, handle)| handle.channel.map(|c| (*h, c))),
|
||||
);
|
||||
for (h, c) in live.drain(..) {
|
||||
if backend.stopped(c) {
|
||||
self.release_handle(h, backend);
|
||||
}
|
||||
}
|
||||
// Released handles nobody maps any more.
|
||||
let mapped: Vec<u64> = self.instance_to_channel.iter().map(|(_, h)| *h).collect();
|
||||
let mut mapped = std::mem::take(&mut self.mapped_scratch);
|
||||
mapped.clear();
|
||||
mapped.extend(self.instance_to_channel.iter().map(|(_, h)| *h));
|
||||
self.handles
|
||||
.retain(|h, handle| handle.channel.is_some() || mapped.contains(h));
|
||||
self.live_scratch = live;
|
||||
self.mapped_scratch = mapped;
|
||||
}
|
||||
fn handle_stopped(&self, handle: u64) -> bool {
|
||||
self.handles.get(&handle).is_none_or(|h| h.stopped)
|
||||
@@ -1502,7 +1516,10 @@ impl SoundEngine {
|
||||
for id in std::mem::take(&mut self.queued_tickable) {
|
||||
self.play(id, backend);
|
||||
}
|
||||
for id in self.ticking.clone() {
|
||||
let mut ticking = std::mem::take(&mut self.ticking_scratch);
|
||||
ticking.clear();
|
||||
ticking.extend(self.ticking.iter().copied());
|
||||
for id in ticking.drain(..) {
|
||||
let Some(instance) = self.instances.get(&id) else {
|
||||
continue;
|
||||
};
|
||||
@@ -1549,7 +1566,8 @@ impl SoundEngine {
|
||||
self.notify(id, subtitle, distance);
|
||||
}
|
||||
}
|
||||
let mut removed = Vec::new();
|
||||
let mut removed = std::mem::take(&mut self.removed_scratch);
|
||||
removed.clear();
|
||||
let mut index = 0;
|
||||
while index < self.instance_to_channel.len() {
|
||||
let (id, handle) = self.instance_to_channel[index];
|
||||
@@ -1596,9 +1614,11 @@ impl SoundEngine {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
for id in removed {
|
||||
for id in removed.drain(..) {
|
||||
self.collect(id);
|
||||
}
|
||||
self.ticking_scratch = ticking;
|
||||
self.removed_scratch = removed;
|
||||
}
|
||||
fn tick_music_when_paused(&mut self) {
|
||||
let mut index = 0;
|
||||
|
||||
@@ -1654,11 +1654,9 @@ mod tests {
|
||||
"L'écran partagé est désactivé."
|
||||
);
|
||||
assert!(labels(&ui).contains(&"Écran partagé : Désactivé".to_owned()));
|
||||
// German translates it now; a key a language lacks still falls back to the bundled
|
||||
// English (lang's `layers_selected_language_over_english`).
|
||||
lang::set(lang::Language::load(&[], "de_de"));
|
||||
assert_eq!(
|
||||
ui.status_lines().unwrap()[0],
|
||||
"Splitscreen is off.",
|
||||
"untranslated new keys use bundled English"
|
||||
);
|
||||
assert_eq!(ui.status_lines().unwrap()[0], "Splitscreen ist aus.");
|
||||
}
|
||||
}
|
||||
@@ -530,6 +530,12 @@ pub struct Streaming {
|
||||
saves: Arc<dyn Saves>,
|
||||
/// Results received while `save` waited, for the next `update`.
|
||||
deferred: Vec<Done>,
|
||||
/// Per-update collection scratch, retained to avoid allocator churn while
|
||||
/// the view is moving or many columns are dirty.
|
||||
light_scratch: Vec<(i64, i32, i32)>,
|
||||
gone_scratch: Vec<(i32, i32)>,
|
||||
mesh_candidates_scratch: Vec<(i64, i32, i32, SectionMask)>,
|
||||
mesh_columns_scratch: Vec<((i32, i32), SectionMask)>,
|
||||
sequence: u64,
|
||||
pub center: (i32, i32),
|
||||
/// The view distance around `center`; below 0, no area there (a dedicated
|
||||
@@ -1130,6 +1136,10 @@ impl Streaming {
|
||||
written: Default::default(),
|
||||
saves,
|
||||
deferred: Vec::new(),
|
||||
light_scratch: Vec::new(),
|
||||
gone_scratch: Vec::new(),
|
||||
mesh_candidates_scratch: Vec::new(),
|
||||
mesh_columns_scratch: Vec::new(),
|
||||
sequence: 0,
|
||||
center: (0, 0),
|
||||
radius: 8,
|
||||
@@ -1664,6 +1674,15 @@ impl Streaming {
|
||||
if self.paused {
|
||||
return (uploads, Vec::new());
|
||||
}
|
||||
let mut stale = Vec::new();
|
||||
let mut light = std::mem::take(&mut self.light_scratch);
|
||||
light.clear();
|
||||
let mut gone = std::mem::take(&mut self.gone_scratch);
|
||||
gone.clear();
|
||||
let mut candidates = std::mem::take(&mut self.mesh_candidates_scratch);
|
||||
candidates.clear();
|
||||
let mut columns = std::mem::take(&mut self.mesh_columns_scratch);
|
||||
columns.clear();
|
||||
ExtraCover::refresh(&mut self.cover, &self.extra);
|
||||
self.cover_current = true;
|
||||
// The sweeps below walk every loaded column and proto chunk. Run them
|
||||
@@ -1678,7 +1697,6 @@ impl Streaming {
|
||||
let deferred = std::mem::take(&mut self.deferred);
|
||||
let received = self.shared.done.take();
|
||||
// Meshes made stale meanwhile, freed with the uploaded ones (`free`).
|
||||
let mut stale = Vec::new();
|
||||
for done in deferred.into_iter().chain(received) {
|
||||
match done {
|
||||
Done::Proto(x, z, proto, decorated) => {
|
||||
@@ -1831,8 +1849,8 @@ impl Streaming {
|
||||
protos.contains_key(k) || finishing.contains(k) || world.chunks.contains_key(k)
|
||||
});
|
||||
// Finalize chunks whose neighbourhood is decorated.
|
||||
let candidates: Vec<(i32, i32)> = self.protos.keys().copied().collect();
|
||||
for (x, z) in candidates {
|
||||
let finalize_candidates: Vec<(i32, i32)> = self.protos.keys().copied().collect();
|
||||
for (x, z) in finalize_candidates {
|
||||
if world.chunks.contains_key(&(x, z))
|
||||
|| self.finishing.contains(&(x, z))
|
||||
|| !self.in_range(x, z, 1)
|
||||
@@ -1990,8 +2008,6 @@ impl Streaming {
|
||||
// grew with the render distance and outlived the columns they
|
||||
// were queued for (#3629). What stays in `dirty_light` is asked
|
||||
// for again, from the current centre, as capacity frees.
|
||||
let mut light: Vec<(i64, i32, i32)> = Vec::new();
|
||||
let mut gone: Vec<(i32, i32)> = Vec::new();
|
||||
for &(x, z) in world.dirty_light.iter() {
|
||||
if !world.chunks.contains_key(&(x, z)) {
|
||||
gone.push((x, z));
|
||||
@@ -1999,12 +2015,12 @@ impl Streaming {
|
||||
light.push((self.distance(x, z), x, z));
|
||||
}
|
||||
}
|
||||
for key in gone {
|
||||
for key in gone.drain(..) {
|
||||
world.dirty_light.remove(&key);
|
||||
}
|
||||
let room = (self.workers * 4).saturating_sub(self.lighting.len());
|
||||
nearest_first(&mut light, room);
|
||||
for (_, x, z) in light {
|
||||
for (_, x, z) in light.drain(..) {
|
||||
let Some(region) = self.light_region(world, x, z) else {
|
||||
continue;
|
||||
};
|
||||
@@ -2023,6 +2039,10 @@ impl Streaming {
|
||||
if !self.mesh {
|
||||
world.dirty_sections.clear();
|
||||
self.cover_current = false;
|
||||
self.light_scratch = light;
|
||||
self.gone_scratch = gone;
|
||||
self.mesh_candidates_scratch = candidates;
|
||||
self.mesh_columns_scratch = columns;
|
||||
return (uploads, removed);
|
||||
}
|
||||
// Meshing once the column and its neighbours are lit, and the column
|
||||
@@ -2056,9 +2076,8 @@ impl Streaming {
|
||||
}
|
||||
// Column by column: the checks below hold for all of a column's
|
||||
// sections, and columns waiting for light cost one look per frame.
|
||||
let mut candidates: Vec<(i64, i32, i32, SectionMask)> = Vec::new();
|
||||
let columns: Vec<_> = world.dirty_sections.columns().collect();
|
||||
for ((x, z), sections) in columns {
|
||||
columns.extend(world.dirty_sections.columns());
|
||||
for ((x, z), sections) in columns.iter().copied() {
|
||||
if !world.chunks.contains_key(&(x, z)) {
|
||||
world.dirty_sections.take_column(x, z);
|
||||
continue;
|
||||
@@ -2096,7 +2115,7 @@ impl Streaming {
|
||||
// capacity frees; `requeue` then has a small queue to keep close.
|
||||
let room = (self.workers * 32).saturating_sub(self.meshing.len());
|
||||
nearest_first(&mut candidates, room);
|
||||
for (_, x, z, sections) in candidates {
|
||||
for (_, x, z, sections) in candidates.drain(..) {
|
||||
let Some(region) = Self::region(world, x, z) else {
|
||||
continue;
|
||||
};
|
||||
@@ -2132,6 +2151,10 @@ impl Streaming {
|
||||
}
|
||||
}
|
||||
self.cover_current = false;
|
||||
self.light_scratch = light;
|
||||
self.gone_scratch = gone;
|
||||
self.mesh_candidates_scratch = candidates;
|
||||
self.mesh_columns_scratch = columns;
|
||||
(uploads, removed)
|
||||
}
|
||||
fn schedule_generation(&mut self, world: &World, workers: usize) {
|
||||
|
||||
@@ -151,31 +151,23 @@ fn rotated_grid(uv: vec2<f32>, layer: u32, pixel: vec2<f32>, du: vec2<f32>,
|
||||
let dv_len = length(dv);
|
||||
let effective = sqrt(min(du_len, dv_len) * max(du_len, dv_len));
|
||||
let exact = max(0.0, log2(effective / min_pixel));
|
||||
let low = floor(exact);
|
||||
let mip_blend = fract(exact);
|
||||
// From this level on the sprite is a single texel (0: the views stop before one is), and
|
||||
// the sampler clamps any level past it to it: there every tap reads the same value, so
|
||||
// one tap gives the four's mean. Distant tiles sit there.
|
||||
let single = globals.filtering.z;
|
||||
if single > 0.0 && low >= single {
|
||||
return sprite_level(uv, layer, low);
|
||||
if single > 0.0 && floor(exact) >= single {
|
||||
return sprite_level(uv, layer, floor(exact));
|
||||
}
|
||||
let high_single = single > 0.0 && low + 1.0 >= single;
|
||||
// sampleRGSS takes each of the four taps at the two mip levels around `exact` and mixes them
|
||||
// by fract(exact). The sprite sampler's mip filter is linear, so a tap at the fractional
|
||||
// level is that mix: four texture instructions where the shader mix took eight.
|
||||
var offsets = array<vec2<f32>, 4>(
|
||||
vec2(0.125, 0.375), vec2(-0.125, -0.375), vec2(0.375, -0.125), vec2(-0.375, 0.125));
|
||||
var sum_low = vec4(0.0);
|
||||
var sum_high = vec4(0.0);
|
||||
var sum = vec4(0.0);
|
||||
for (var i = 0; i < 4; i++) {
|
||||
let p = uv + offsets[i] * pixel;
|
||||
sum_low += sprite_level(p, layer, low);
|
||||
if !high_single {
|
||||
sum_high += sprite_level(p, layer, low + 1.0);
|
||||
}
|
||||
sum += sprite_level(uv + offsets[i] * pixel, layer, exact);
|
||||
}
|
||||
if high_single {
|
||||
sum_high = 4.0 * sprite_level(uv, layer, low + 1.0);
|
||||
}
|
||||
return mix(sum_low * 0.25, sum_high * 0.25, mip_blend);
|
||||
return sum * 0.25;
|
||||
}
|
||||
// Distant terrain tiles (lod/mesh.rs) count their UVs in blocks past 1: each block takes the
|
||||
// sprite again, as separate faces would under the clamped sampler. Sprite UVs in [0, 1] stay.
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
//! On-screen touch controls for touch-screen devices (Android first). Nothing
|
||||
//! here runs unless a touch surface is in use.
|
||||
pub mod aim;
|
||||
pub mod layout;
|
||||
pub use aim::{
|
||||
attack_indicator, camera_delta, hotbar_slot, item_places_entity_on_tap, item_uses_on_tap,
|
||||
projectile_crosshair, screen_direction,
|
||||
};
|
||||
pub use layout::{Control, Mode, Placement, Settings};
|
||||
@@ -0,0 +1,255 @@
|
||||
// Adapted from Starling (https://github.com/starling-client/starling, `android` branch at
|
||||
// 150a414b09a1, `crates/horizon-client-render/src/touch.rs`), written by Vasysik and the
|
||||
// Starling contributors. Copyright (c) 2026 Ludovic Marie and the Horizon contributors, under the
|
||||
// same MIT license as Horizon (LICENSE at the repository root).
|
||||
|
||||
//! What a touch aims at and what it does: camera turns from a drag, the ray
|
||||
//! through a tapped point, the hotbar slot under a finger, and which held
|
||||
//! items act on a short tap. Pure functions; the touch controls call them.
|
||||
use super::layout::Settings;
|
||||
|
||||
/// Items that place an entity where the player taps, like a block: a short tap
|
||||
/// places them instead of waiting for a long press.
|
||||
pub fn item_places_entity_on_tap(item: &str) -> bool {
|
||||
let Some(id) = item.strip_prefix("minecraft:") else {
|
||||
return false;
|
||||
};
|
||||
id.ends_with("_boat")
|
||||
|| id.ends_with("_raft")
|
||||
|| id.ends_with("_spawn_egg")
|
||||
|| id == "minecart"
|
||||
|| id.ends_with("_minecart")
|
||||
|| matches!(
|
||||
id,
|
||||
"armor_stand" | "item_frame" | "glow_item_frame" | "painting" | "end_crystal"
|
||||
)
|
||||
}
|
||||
|
||||
/// Items used on the tapped surface (buckets, bone meal, flint and steel...):
|
||||
/// a short tap uses them instead of waiting for a long press.
|
||||
pub fn item_uses_on_tap(item: &str) -> bool {
|
||||
item_places_entity_on_tap(item)
|
||||
|| item
|
||||
.strip_prefix("minecraft:")
|
||||
.is_some_and(|id| id.ends_with("_bucket") && id != "milk_bucket")
|
||||
|| matches!(
|
||||
item,
|
||||
"minecraft:bone_meal"
|
||||
| "minecraft:flint_and_steel"
|
||||
| "minecraft:fire_charge"
|
||||
| "minecraft:honeycomb"
|
||||
| "minecraft:glass_bottle"
|
||||
| "minecraft:bucket"
|
||||
)
|
||||
}
|
||||
|
||||
/// Items that are aimed rather than pointed at a block: while one is held, the
|
||||
/// tap schemes show the crosshair and shoot along it.
|
||||
pub fn projectile_crosshair(item: &str) -> bool {
|
||||
matches!(
|
||||
item,
|
||||
"minecraft:bow"
|
||||
| "minecraft:crossbow"
|
||||
| "minecraft:trident"
|
||||
| "minecraft:snowball"
|
||||
| "minecraft:egg"
|
||||
| "minecraft:ender_pearl"
|
||||
| "minecraft:experience_bottle"
|
||||
| "minecraft:splash_potion"
|
||||
| "minecraft:lingering_potion"
|
||||
| "minecraft:wind_charge"
|
||||
| "minecraft:fishing_rod"
|
||||
)
|
||||
}
|
||||
|
||||
/// The camera turn in degrees (yaw, pitch) for a drag of `delta`, normalized to
|
||||
/// a `width` x `height` surface. Equal physical drags turn equally on either
|
||||
/// axis, in landscape and in portrait; a drag across the long side is 180°.
|
||||
pub fn camera_delta(delta: [f32; 2], width: u32, height: u32, s: &Settings) -> [f32; 2] {
|
||||
let scale = width.max(height).max(1) as f32;
|
||||
[
|
||||
delta[0] * width as f32 / scale * 180.0 * s.sensitivity * s.sensitivity_horizontal,
|
||||
delta[1] * height as f32 / scale * 180.0 * s.sensitivity * s.sensitivity_vertical,
|
||||
]
|
||||
}
|
||||
|
||||
/// The attack indicator to draw (the `attackIndicator` option: 0 off,
|
||||
/// 1 crosshair, 2 hotbar). Without a crosshair, the crosshair gauge moves to
|
||||
/// the hotbar, as in vanilla's hotbar setting.
|
||||
pub fn attack_indicator(setting: u8, crosshair: bool) -> u8 {
|
||||
if setting == 1 && !crosshair {
|
||||
2
|
||||
} else {
|
||||
setting
|
||||
}
|
||||
}
|
||||
|
||||
/// The unit ray from the eye through `point` (normalized surface coordinates,
|
||||
/// y down) for a perspective view with vertical `fov` in degrees. The view
|
||||
/// itself does not turn: a tap acts where it lands.
|
||||
pub fn screen_direction(
|
||||
forward: [f64; 3],
|
||||
up: [f64; 3],
|
||||
point: [f32; 2],
|
||||
aspect: f64,
|
||||
fov: f64,
|
||||
) -> [f64; 3] {
|
||||
// forward x up: screen right in the game's coordinates.
|
||||
let right = [
|
||||
forward[1] * up[2] - forward[2] * up[1],
|
||||
forward[2] * up[0] - forward[0] * up[2],
|
||||
forward[0] * up[1] - forward[1] * up[0],
|
||||
];
|
||||
let t = (fov.to_radians() / 2.0).tan();
|
||||
let x = (f64::from(point[0]) * 2.0 - 1.0) * aspect * t;
|
||||
let y = (1.0 - f64::from(point[1]) * 2.0) * t;
|
||||
let ray = std::array::from_fn::<_, 3, _>(|i| forward[i] + right[i] * x + up[i] * y);
|
||||
let length = ray.iter().map(|v| v * v).sum::<f64>().sqrt();
|
||||
ray.map(|v| v / length)
|
||||
}
|
||||
|
||||
/// The hotbar slot under `point`, in GUI pixels on a `width` x `height` GUI:
|
||||
/// the nine 20-pixel slots of the 22-pixel bar centred at the bottom.
|
||||
pub fn hotbar_slot(width: f32, height: f32, point: [f32; 2]) -> Option<usize> {
|
||||
let x = point[0] - (width / 2.0 - 90.0);
|
||||
(point[1] >= height - 22.0 && point[1] < height && (0.0..180.0).contains(&x))
|
||||
.then(|| (x / 20.0) as usize)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn camera_turns_by_physical_distance_on_both_axes() {
|
||||
let mut settings = Settings::default();
|
||||
for (w, h) in [(2400, 1080), (1080, 2400)] {
|
||||
// 100 pixels right and 100 pixels down.
|
||||
let drag = [100.0 / w as f32, 100.0 / h as f32];
|
||||
let turn = camera_delta(drag, w, h, &settings);
|
||||
assert!((turn[0] - turn[1]).abs() < 0.0001);
|
||||
settings.sensitivity_vertical = 0.5;
|
||||
let tuned = camera_delta(drag, w, h, &settings);
|
||||
assert!((tuned[0] - tuned[1] * 2.0).abs() < 0.0001);
|
||||
settings.sensitivity_vertical = 1.0;
|
||||
}
|
||||
let full = camera_delta([1.0, 0.0], 2400, 1080, &settings);
|
||||
assert!((full[0] - 180.0).abs() < 0.0001);
|
||||
settings.sensitivity = 2.0;
|
||||
let fast = camera_delta([0.5, 0.0], 2400, 1080, &settings);
|
||||
assert!((fast[0] - 180.0).abs() < 0.0001);
|
||||
// A zero-sized surface turns nothing instead of dividing by zero.
|
||||
assert_eq!(camera_delta([0.5, 0.5], 0, 0, &settings), [0.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_tools_act_on_tap_but_projectiles_aim() {
|
||||
for id in [
|
||||
"bone_meal",
|
||||
"flint_and_steel",
|
||||
"fire_charge",
|
||||
"water_bucket",
|
||||
"lava_bucket",
|
||||
"powder_snow_bucket",
|
||||
"bucket",
|
||||
"honeycomb",
|
||||
"glass_bottle",
|
||||
] {
|
||||
assert!(item_uses_on_tap(&format!("minecraft:{id}")), "{id}");
|
||||
}
|
||||
assert!(!item_uses_on_tap("minecraft:milk_bucket"));
|
||||
assert!(!item_uses_on_tap("minecraft:apple"));
|
||||
for id in [
|
||||
"bow",
|
||||
"crossbow",
|
||||
"snowball",
|
||||
"egg",
|
||||
"wind_charge",
|
||||
"trident",
|
||||
] {
|
||||
let id = format!("minecraft:{id}");
|
||||
assert!(!item_uses_on_tap(&id), "{id}");
|
||||
assert!(projectile_crosshair(&id), "{id}");
|
||||
}
|
||||
assert!(!projectile_crosshair("minecraft:firework_rocket"));
|
||||
assert!(!projectile_crosshair("minecraft:stone"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_placers_act_on_tap() {
|
||||
for id in [
|
||||
"oak_boat",
|
||||
"bamboo_chest_raft",
|
||||
"villager_spawn_egg",
|
||||
"minecart",
|
||||
"chest_minecart",
|
||||
"armor_stand",
|
||||
"item_frame",
|
||||
"glow_item_frame",
|
||||
"painting",
|
||||
"end_crystal",
|
||||
] {
|
||||
let id = format!("minecraft:{id}");
|
||||
assert!(item_places_entity_on_tap(&id), "{id}");
|
||||
assert!(item_uses_on_tap(&id), "{id}");
|
||||
}
|
||||
for id in [
|
||||
"egg",
|
||||
"snowball",
|
||||
"wind_charge",
|
||||
"firework_rocket",
|
||||
"fishing_rod",
|
||||
"apple",
|
||||
"wheat_seeds",
|
||||
"diamond_helmet",
|
||||
"bow",
|
||||
] {
|
||||
assert!(
|
||||
!item_places_entity_on_tap(&format!("minecraft:{id}")),
|
||||
"{id}"
|
||||
);
|
||||
}
|
||||
// Only vanilla ids: a modded item that happens to end in `_boat` is left alone.
|
||||
assert!(!item_places_entity_on_tap("example:spell_boat"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attack_gauge_moves_to_the_hotbar_without_a_crosshair() {
|
||||
assert_eq!(attack_indicator(0, false), 0);
|
||||
assert_eq!(attack_indicator(1, false), 2);
|
||||
assert_eq!(attack_indicator(1, true), 1);
|
||||
assert_eq!(attack_indicator(2, false), 2);
|
||||
assert_eq!(attack_indicator(2, true), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tap_ray_goes_through_the_point_without_turning_the_view() {
|
||||
let forward = [0.0, 0.0, 1.0];
|
||||
let up = [0.0, 1.0, 0.0];
|
||||
assert_eq!(
|
||||
screen_direction(forward, up, [0.5, 0.5], 2.0, 90.0),
|
||||
forward
|
||||
);
|
||||
let ray = screen_direction(forward, up, [0.75, 0.25], 2.0, 90.0);
|
||||
// Right of centre is -x for this view; above centre is +y.
|
||||
assert!(ray[0] < 0.0 && ray[1] > 0.0 && ray[2] > 0.0);
|
||||
assert!((ray.iter().map(|x| x * x).sum::<f64>() - 1.0).abs() < 1e-10);
|
||||
// The top edge at a 90° vertical fov is 45° up.
|
||||
let top = screen_direction(forward, up, [0.5, 0.0], 2.0, 90.0);
|
||||
assert!((top[1] - top[2]).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hotbar_has_nine_slots_and_nothing_beside_it() {
|
||||
for i in 0..9 {
|
||||
assert_eq!(
|
||||
hotbar_slot(427.0, 240.0, [133.5 + i as f32 * 20.0, 230.0]),
|
||||
Some(i)
|
||||
);
|
||||
}
|
||||
assert_eq!(hotbar_slot(427.0, 240.0, [320.0, 230.0]), None);
|
||||
assert_eq!(hotbar_slot(427.0, 240.0, [100.0, 230.0]), None);
|
||||
// Above the bar is the world, not a slot.
|
||||
assert_eq!(hotbar_slot(427.0, 240.0, [213.5, 210.0]), None);
|
||||
}
|
||||
}
|
||||
@@ -168,6 +168,12 @@ pub struct Settings {
|
||||
pub button_visibility: [u8; CONTROLS],
|
||||
/// One row of placements per [`Mode`], indexed by [`Control`].
|
||||
pub layouts: [[Placement; CONTROLS]; 3],
|
||||
/// Camera turn speed for look drags; scales both axes.
|
||||
pub sensitivity: f32,
|
||||
/// Extra scale on the yaw (left and right) turn.
|
||||
pub sensitivity_horizontal: f32,
|
||||
/// Extra scale on the pitch (up and down) turn.
|
||||
pub sensitivity_vertical: f32,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
@@ -203,6 +209,9 @@ impl Default for Settings {
|
||||
shield_button: true,
|
||||
button_visibility: [FOLLOW_SCHEME; CONTROLS],
|
||||
layouts,
|
||||
sensitivity: 1.0,
|
||||
sensitivity_horizontal: 1.0,
|
||||
sensitivity_vertical: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -238,6 +247,13 @@ impl Settings {
|
||||
for p in self.layouts.iter_mut().flatten() {
|
||||
p.sanitize();
|
||||
}
|
||||
for v in [
|
||||
&mut self.sensitivity,
|
||||
&mut self.sensitivity_horizontal,
|
||||
&mut self.sensitivity_vertical,
|
||||
] {
|
||||
*v = finite_or(*v, 1.0).clamp(0.1, 3.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether attack and use act on the crosshair rather than on a tap.
|
||||
@@ -385,6 +401,28 @@ mod tests {
|
||||
assert_eq!(p.opacity, 0.65);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sensitivity_stays_finite_and_in_range() {
|
||||
let mut s = Settings {
|
||||
sensitivity: f32::INFINITY,
|
||||
sensitivity_horizontal: 9.0,
|
||||
sensitivity_vertical: 0.0,
|
||||
..Settings::default()
|
||||
};
|
||||
s.sanitize();
|
||||
assert_eq!(
|
||||
[
|
||||
s.sensitivity,
|
||||
s.sensitivity_horizontal,
|
||||
s.sensitivity_vertical
|
||||
],
|
||||
[1.0, 3.0, 0.1]
|
||||
);
|
||||
// Files saved before these options existed keep the defaults.
|
||||
let read: Settings = serde_json::from_str(r#"{"mode":"tap"}"#).unwrap();
|
||||
assert_eq!(read.sensitivity, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_hands_mirror_and_dpad_can_swap_jump_and_sneak() {
|
||||
let mut s = Settings::default();
|
||||
|
||||
@@ -108,6 +108,9 @@ pub struct World {
|
||||
pub inhabited: FxHashMap<(i32, i32), i64>,
|
||||
pub game_time: i64,
|
||||
pub day_time: i64,
|
||||
/// The End's world clock (`WorldClocks.THE_END`, the End sky flash's), saved apart from
|
||||
/// the game time and, like every clock, still while `advance_time` is off.
|
||||
pub end_clock: i64,
|
||||
pub weather: Weather,
|
||||
/// Every block position written since the log was last drained, kept
|
||||
/// only while the world is open to the network (see `lan`).
|
||||
@@ -238,6 +241,7 @@ impl World {
|
||||
inhabited: FxHashMap::default(),
|
||||
game_time: 0,
|
||||
day_time: 1000,
|
||||
end_clock: 0,
|
||||
weather: Weather::default(),
|
||||
block_log: None,
|
||||
block_log_held: Vec::new(),
|
||||
|
||||
@@ -148,9 +148,7 @@
|
||||
"Size of menus and the HUD.": "Grootte van spyskaarte en die HUD.",
|
||||
"Sets the options below together. Changing one makes it Custom.": "Stel die opsies hieronder saam. Om een te verander, maak dit Pasgemaak.",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lava, vuur en sonlig skyn helderder as wit. Benodig 'n HDR-skerm.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Jou skerm is die donkerste swart. 0 vir OLED; LCD's gloei 'n bietjie.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "Hoe helder hoogtepunte word. Outo: die hoogtepunt wat jou stelsel rapporteer.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "Hoe helder wit papier en spyskaarte lyk. Outo: jou lessenaar is wit.",
|
||||
"How bright the HUD and menus are, against paper white.": "Hoe helder die HUD en spyskaarte is, teenoor papierwit.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Hoe ver skare, gewasse en masjiene aanhou werk. SVE tyd.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Hoe ver weg skares en items getrek word, as 'n deel van normaal.",
|
||||
|
||||
@@ -163,9 +163,7 @@
|
||||
"Size of menus and the HUD.": "حجم القوائم وHUD.",
|
||||
"Sets the options below together. Changing one makes it Custom.": "يضبط الخيارات أدناه معًا. تغيير واحد يجعله مخصصًا.",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "الحمم البركانية والنار وأشعة الشمس تتألق أكثر من اللون الأبيض. يحتاج إلى شاشة HDR.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "اللون الأسود الداكن لشاشتك. 0 لـ OLED؛ تتوهج شاشات LCD قليلاً.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "كيف تحصل على النقاط البارزة الساطعة. تلقائي: ذروة تقارير النظام الخاص بك.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "كيف تبدو الورق والقوائم البيضاء الزاهية. تلقائي: سطح المكتب الخاص بك باللون الأبيض.",
|
||||
"How bright the HUD and menus are, against paper white.": "مدى سطوع شاشة HUD والقوائم مقابل الورق الأبيض.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "إلى أي مدى يستمر الغوغاء والمحاصيل والآلات في العمل. وقت وحدة المعالجة المركزية.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "إلى أي مدى يتم رسم الغوغاء والعناصر، كنسبة من المعدل الطبيعي.",
|
||||
|
||||
@@ -147,9 +147,7 @@
|
||||
"Size of menus and the HUD.": "Menyuların ölçüsü və HUD.",
|
||||
"Sets the options below together. Changing one makes it Custom.": "Aşağıdakı seçimləri birlikdə təyin edir. Birinin dəyişdirilməsi onu Xüsusi edir.",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lava, od və günəş işığı ağdan daha parlaq parlayır. HDR displey lazımdır.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Ekranınız ən tünd qaradır. OLED üçün 0; LCD-lər bir az parlayır.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "Parlaq məqamlar necə əldə edilir. Avtomatik: sisteminizin ən yüksək hesabatı.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "Ağ kağız və menyular necə parlaq görünür. Avtomatik: masaüstünüz ağ rəngdədir.",
|
||||
"How bright the HUD and menus are, against paper white.": "HUD və menyular ağ kağıza qarşı nə qədər parlaqdır.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Kütlələr, məhsullar və maşınlar nə qədər işləməyə davam edir. CPU vaxtı.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Nə qədər uzağa mob və əşyalar çəkilir, normal bir pay olaraq.",
|
||||
|
||||
@@ -155,9 +155,7 @@
|
||||
"Size of menus and the HUD.": "Размер меню һәм HUD.",
|
||||
"Sets the options below together. Changing one makes it Custom.": "Түбәндәге варианттарҙы бергә ҡуя. Бер үҙгәртеү уны Ҡулланыусы итә.",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Лава, ут һәм ҡояш нурҙары аҡтан да сағыуыраҡ балҡый. HDR дисплей кәрәк.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Һеҙҙең дисплей’ы иң ҡара ҡара. 0 өсөн OLED; ЖК-дисплейҙар бер аҙ балҡый.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "Нисек сағыу өҫтөнлөктәр ала. Авто: пик һеҙҙең система отчеттар.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "Нисек сағыу аҡ ҡағыҙ, менюлар күренә. Авто: һеҙҙең өҫтәл’ы аҡ.",
|
||||
"How bright the HUD and menus are, against paper white.": "Нисек сағыу HUD һәм менюлар, ҡағыҙ аҡ ҡаршы.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Күпме алыҫҡа мобтар, культуралар һәм машиналар эшләп тора. Процессор ваҡыты.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Нисек алыҫҡа мобтар һәм әйберҙәр тартыла, өлөшө булараҡ нормаль.",
|
||||
|
||||
@@ -150,9 +150,7 @@
|
||||
"Size of menus and the HUD.": "Памер меню і HUD.",
|
||||
"Sets the options below together. Changing one makes it Custom.": "Разам усталёўвае параметры ніжэй. Змена аднаго робіць яго карыстальніцкім.",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Лава, агонь і сонечнае святло ззяюць ярчэй белага. Патрэбен дысплей HDR.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Самы цёмны чорны на вашым дысплеі. 0 для OLED; ВК-дысплеі трохі свецяцца.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "Якія яркія моманты становяцца. Аўта: пік, пра які паведамляе ваша сістэма.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "Як ярка выглядаюць белая папера і меню. Аўтаматычна: ваш працоўны стол белы.",
|
||||
"How bright the HUD and menus are, against paper white.": "Наколькі яркія HUD і меню на фоне белай паперы.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Як далёка працягваюць працаваць мобы, ураджай і машыны. працэсарны час.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Наколькі далёка намаляваны мобы і прадметы ў долі звычайнага.",
|
||||
|
||||
@@ -147,9 +147,7 @@
|
||||
"Size of menus and the HUD.": "Размер на менютата и HUD.",
|
||||
"Sets the options below together. Changing one makes it Custom.": "Задава заедно опциите по-долу. Промяната на един го прави персонализиран.",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Лавата, огънят и слънчевата светлина блестят по-ярко от бялото. Има нужда от HDR дисплей.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Вашият дисплей е най-тъмно черен. 0 за OLED; LCD дисплеите светят малко.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "Колко ярки стават акцентите. Автоматично: пикът, който вашата система отчита.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "Колко ярки изглеждат бялата хартия и менютата. Автоматично: вашият работен плот е бял.",
|
||||
"How bright the HUD and menus are, against paper white.": "Колко ярки са HUD и менютата на бяла хартия.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Докъде продължават да работят тълпите, културите и машините. процесорно време.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Колко далеч са изтеглени тълпите и предметите, като дял от нормалното.",
|
||||
|
||||
@@ -148,9 +148,7 @@
|
||||
"Size of menus and the HUD.": "Ment ar mennozhioù hag an HUD.",
|
||||
"Sets the options below together. Changing one makes it Custom.": "Lakaat a ra an dibarzhioù amañ dindan asambles. Cheñch unan a ra anezhañ Personelaet.",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lava, tan ha gouloù an heol a lugern muioc'h eget ar gwenn. Ezhomm zo eus ur skramm HDR.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Du teñvalañ eo ho skramm. 0 evit OLED; LCDoù a lugern un tamm.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "Pegen skedus e teu ar sklêrijennoù. Emgefre : ar pik a vez embannet gant ho reizhiad.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "Pegen skedus e seblant ar paper gwenn hag ar mennozhioù. Emgefre : gwenn eo ho burev.",
|
||||
"How bright the HUD and menus are, against paper white.": "Pegen skedus eo an HUD hag ar mennozhioù, a-enep gwenn ar paper.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Pegen pell e kendalc'h ar mobs, ar c'hoadoù hag ar mekanikoù da labourat. Amzer ar CPU.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Pegen pell e vez tennet ar mobs hag an traoù, evel ul lodenn eus ar pezh a vez graet normal.",
|
||||
|
||||
@@ -115,9 +115,7 @@
|
||||
"Size of menus and the HUD.": "Veličina menija i HUD-a.",
|
||||
"Sets the options below together. Changing one makes it Custom.": "Zajedno postavlja donje opcije. Promjenom jednog postaje prilagođeno.",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lava, vatra i sunčeva svjetlost sijaju jače od bijele. Potreban je HDR ekran.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Vaš ekran je najtamnije crn. 0 za OLED; LCD-i malo svijetle.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "Kako svetli naglasci postaju. Automatski: vrhunac o kojem vaš sistem prijavljuje.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "Kako izgledaju svijetli bijeli papir i meniji. Auto: vaša radna površina je bijela.",
|
||||
"How bright the HUD and menus are, against paper white.": "Koliko su svijetli HUD i meniji, naspram bijelog papira.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Koliko mobovi, usjevi i mašine rade. CPU vrijeme.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Koliko su udaljeni mobovi i predmeti, kao udio normalnog.",
|
||||
|
||||
@@ -144,9 +144,7 @@
|
||||
"Size of menus and the HUD.": "Mida dels menús i l'HUD.",
|
||||
"Sets the options below together. Changing one makes it Custom.": "Estableix les opcions següents juntes. Canviar-ne un el fa personalitzat.",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "La lava, el foc i la llum del sol brillen més que el blanc. Necessita una pantalla HDR.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "La pantalla és el negre més fosc. 0 per a OLED; Els LCD brillen una mica.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "Com es fan els reflexos brillants. Automàtic: el pic que informa el vostre sistema.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "Quin aspecte brillant del paper blanc i dels menús. Automàtic: el teu escriptori és blanc.",
|
||||
"How bright the HUD and menus are, against paper white.": "Què brillants són l'HUD i els menús, contra el paper blanc.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Fins on segueixen treballant les turbes, els conreus i les màquines. Temps de CPU.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "A quina distància es dibuixen les turbes i els articles, com a part de la normalitat.",
|
||||
|
||||
@@ -137,9 +137,7 @@
|
||||
"Size of menus and the HUD.": "Velikost menu a HUD.",
|
||||
"Sets the options below together. Changing one makes it Custom.": "Nastaví společně níže uvedené možnosti. Změnou jednoho se stane vlastní.",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Láva, oheň a sluneční světlo září jasněji než bílé. Vyžaduje HDR displej.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Váš displej je nejtmavší černý. 0 pro OLED; LCD trochu svítí.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "Jak se získá jasný melír. Auto: vrchol, který váš systém hlásí.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "Jak vypadají zářivě bílé papíry a nabídky. Auto: vaše plocha je bílá.",
|
||||
"How bright the HUD and menus are, against paper white.": "Jak jasný je HUD a nabídky oproti bílé na papíře.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Jak dalece davy, plodiny a stroje fungují. CPU čas.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Jak daleko jsou mobové a předměty nakresleny jako podíl normálu.",
|
||||
|
||||
@@ -152,9 +152,7 @@
|
||||
"Size of menus and the HUD.": "Менюсен калӑпӑшӗ тата HUD.",
|
||||
"Sets the options below together. Changing one makes it Custom.": "Аялта панӑ опцисене пӗрле лартать. Пӗрне улӑштарни ӑна Custom тӑвать.",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Лава, вут-ҫулӑм тата хӗвел ҫути шурӑран ҫутӑрах ҫуталса тӑраҫҫӗ. HDR дисплей кирлӗ.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Сирӗн дисплей чи тӗксӗм хура. 0 OLED валли; ЖК-дисплейсем кӑштах ҫуталаҫҫӗ.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "Мӗнле ҫутӑ ҫутӑсем пулаҫҫӗ. Авто: сирӗн системӑн пӗлтерекен пикӗ.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "Мӗнле ҫутӑ шурӑ хут тата менюсем курӑнаҫҫӗ. Авто: сирӗн ӗҫ сӗтелӗ шурӑ.",
|
||||
"How bright the HUD and menus are, against paper white.": "HUD тата менюсем мӗнле ҫутӑ, хут ҫинчи шурӑ тӗспе танлаштарсан.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Мобсем, культурӑсем тата машинӑсем мӗн таран ӗҫлеҫҫӗ. Процессор вӑхӑчӗ.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Мобсемпе япаласем мӗн чухлӗ аяккалла туртӑнаҫҫӗ, йӗркеллӗ пай пек.",
|
||||
|
||||
@@ -139,9 +139,7 @@
|
||||
"Size of menus and the HUD.": "Maint y bwydlenni a'r HUD.",
|
||||
"Sets the options below together. Changing one makes it Custom.": "Yn gosod yr opsiynau isod gyda'i gilydd. Mae newid un yn ei gwneud yn Custom.",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Mae lafa, tân a golau'r haul yn disgleirio'n fwy disglair na gwyn. Angen arddangosfa HDR.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Du tywyllaf eich arddangosfa. 0 ar gyfer OLED; Mae LCDs yn tywynnu ychydig.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "Sut mae uchafbwyntiau disglair yn ei gael. Auto: yr uchafbwynt y mae eich system yn ei adrodd.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "Sut olwg sydd ar bapur gwyn llachar a bwydlenni. Auto: gwyn eich bwrdd gwaith.",
|
||||
"How bright the HUD and menus are, against paper white.": "Pa mor llachar yw'r HUD a'r bwydlenni, yn erbyn papur gwyn.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Pa mor bell y mae mobs, cnydau a pheiriannau yn dal i weithio. Amser CPU.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Pa mor bell i ffwrdd mae mobs ac eitemau yn cael eu tynnu, fel cyfran o'r arferol.",
|
||||
|
||||
@@ -131,9 +131,7 @@
|
||||
"Size of menus and the HUD.": "Størrelse på menuer og HUD.",
|
||||
"Sets the options below together. Changing one makes it Custom.": "Indstiller nedenstående muligheder sammen. At ændre en gør det brugerdefineret.",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lava, ild og sollys skinner klarere end hvidt. Har brug for en HDR-skærm.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Din skærm er mørkest sort. 0 for OLED; LCD'er lyser lidt.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "Hvor lyse højdepunkter bliver. Auto: det højeste, dit system rapporterer.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "Hvor lyst hvidt papir og menuer ser ud. Auto: dit skrivebord er hvidt.",
|
||||
"How bright the HUD and menus are, against paper white.": "Hvor lys HUD og menuer er i forhold til hvidt papir.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Hvor langt mobs, afgrøder og maskiner bliver ved med at arbejde. CPU tid.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Hvor langt væk mobs og genstande trækkes, som en andel af normalen.",
|
||||
|
||||
@@ -1,15 +1,74 @@
|
||||
{
|
||||
"horizon.extras.header.world": "Welt",
|
||||
"horizon.extras.pregen": "Chunk-Vorausgenerierung",
|
||||
"horizon.extras.pregen.tooltip": "Aktiviert /pregen in deinen Einzelspieler- und LAN-Welten. Mit aktivierten Cheats erzeugst du mit /pregen start ein Gebiet, bevor du es erkundest. Auf Servern, denen du beitrittst, schaltet es den Befehl nicht frei.",
|
||||
"horizon.options.controller": "Controller",
|
||||
"horizon.options.three_d_skin_layers": "3D-Skin-Schichten",
|
||||
"horizon.options.vibration": "Vibration",
|
||||
"horizon.options.play_in_background": "Im Hintergrund spielen",
|
||||
"horizon.options.adaptive_triggers": "Adaptive Trigger",
|
||||
"horizon.options.gameplay_guide": "Spielanleitung",
|
||||
"horizon.options.gameplay_guide.basic": "Einfach",
|
||||
"horizon.options.gameplay_guide.full": "Vollständig",
|
||||
"horizon.options.gameplay_guide.off": "Aus",
|
||||
"horizon.controller.guide.use": "Benutzen",
|
||||
"horizon.controller.guide.open": "Öffnen",
|
||||
"horizon.controller.guide.ride": "Reiten",
|
||||
"horizon.controller.guide.trade": "Handeln",
|
||||
"horizon.controller.guide.feed": "Füttern",
|
||||
"horizon.controller.guide.place": "Platzieren",
|
||||
"horizon.controller.guide.eat": "Essen",
|
||||
"horizon.controller.guide.drink": "Trinken",
|
||||
"horizon.controller.guide.shoot": "Schießen",
|
||||
"horizon.controller.guide.load": "Laden",
|
||||
"horizon.controller.guide.throw": "Werfen",
|
||||
"horizon.controller.guide.raise": "Heben",
|
||||
"horizon.controller.guide.equip": "Ausrüsten",
|
||||
"horizon.controller.guide.fish": "Angeln",
|
||||
"horizon.controller.guide.zoom": "Zoom",
|
||||
"horizon.controller.guide.boost": "Beschleunigen",
|
||||
"horizon.controller.guide.break": "Abbauen",
|
||||
"horizon.controller.guide.attack": "Angreifen",
|
||||
"horizon.controller.guide.spectate": "Zuschauen",
|
||||
"horizon.controller.guide.spectator_menu": "Zuschauermenü",
|
||||
"horizon.controller.guide.release_shoot": "Zum Schießen loslassen",
|
||||
"horizon.controller.guide.release_throw": "Zum Werfen loslassen",
|
||||
"horizon.controller.guide.release_stop": "Zum Anhalten loslassen",
|
||||
"horizon.controller.guide.fly": "Fliegen",
|
||||
"horizon.controller.guide.fly_up": "Aufsteigen",
|
||||
"horizon.controller.guide.fly_down": "Absteigen",
|
||||
"horizon.controller.guide.swim_up": "Hochschwimmen",
|
||||
"horizon.controller.guide.swim_down": "Hinabschwimmen",
|
||||
"horizon.controller.guide.glide": "Gleiten",
|
||||
"horizon.controller.guide.dismount": "Absitzen",
|
||||
"horizon.controller.guide.stand": "Aufstehen",
|
||||
"horizon.controller.guide.drop": "Gegenstand fallen lassen",
|
||||
"horizon.controller.guide.swap": "Hände tauschen",
|
||||
"horizon.controller.guide.take": "Nehmen",
|
||||
"horizon.controller.guide.take_half": "Hälfte nehmen",
|
||||
"horizon.controller.guide.put": "Ablegen",
|
||||
"horizon.controller.guide.put_one": "Eines ablegen",
|
||||
"horizon.controller.guide.swap_stack": "Tauschen",
|
||||
"horizon.controller.guide.quick_move": "Schnell verschieben",
|
||||
"horizon.controller.guide.drop_stack": "Stapel fallen lassen",
|
||||
"horizon.controller.guide.place_recipe": "Rezept platzieren",
|
||||
"horizon.controller.guide.place_recipe_all": "Alle platzieren",
|
||||
"horizon.controller.guide.alternatives": "Alternativen",
|
||||
"horizon.controller.guide.select": "Auswählen",
|
||||
"horizon.controller.guide.close": "Schließen",
|
||||
"horizon.controller.guide.pages": "Seiten",
|
||||
"horizon.controller.guide.categories": "Kategorien",
|
||||
"horizon.controller.guide.tabs": "Tabs",
|
||||
"horizon.options.lightbar": "Lightbar",
|
||||
"horizon.options.look_sensitivity": "Blickempfindlichkeit",
|
||||
"horizon.options.invert_x": "Blick-X-Achse umkehren",
|
||||
"horizon.options.invert_y": "Blick-Y-Achse umkehren",
|
||||
"horizon.options.craft_on_servers": "Schnelles Herstellen auf Servern",
|
||||
"horizon.controller.direct_craft": "Schnelles Herstellen",
|
||||
"horizon.options.sneak_button": "Schleichtaste",
|
||||
"horizon.options.move_deadzone": "Totzone der Bewegung",
|
||||
"horizon.options.look_deadzone": "Totzone des Blicks",
|
||||
"horizon.options.trigger_threshold": "Auslöseschwelle",
|
||||
"horizon.options.button_glyphs": "Tastensymbole",
|
||||
"horizon.options.on_screen_keyboard": "Bildschirmtastatur",
|
||||
"horizon.options.chat_delay": "Chatverzögerung",
|
||||
@@ -19,6 +78,9 @@
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.nintendo_buttons": "Nintendo-Tasten",
|
||||
"horizon.options.nintendo_buttons.position": "Nach Position",
|
||||
"horizon.options.nintendo_buttons.letter": "Nach Buchstabe",
|
||||
"horizon.options.keyboard.auto": "Auto",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
@@ -62,6 +124,23 @@
|
||||
"horizon.studio.path.locked": "Studio-Kamerapfadwiedergabe aktiviert",
|
||||
"horizon.studio.path.unlocked": "Studio-Kamerapfadwiedergabe gestoppt",
|
||||
"horizon.studio.path.usage": "Verwendung: /studio path <lock|unlock> (zuerst mindestens ein Schlüsselbild hinzufügen)",
|
||||
"horizon.pages.studio": "Replay Studio",
|
||||
"horizon.split.leave": "Verlassen",
|
||||
"horizon.pages.music": "Musik",
|
||||
"horizon.pages.music.menu": "Menü",
|
||||
"horizon.pages.music.overworld": "Oberwelt",
|
||||
"horizon.pages.music.creative": "Kreativ",
|
||||
"horizon.pages.music.underwater": "Unterwasser",
|
||||
"horizon.pages.music.end": "Das Ende",
|
||||
"horizon.pages.music.boss": "Boss",
|
||||
"horizon.pages.music.credits": "Abspann",
|
||||
"horizon.pages.music.overworld_biomes": "Oberwelt-Biome",
|
||||
"horizon.pages.music.nether_biomes": "Nether-Biome",
|
||||
"horizon.pages.music.end_biomes": "End-Biome",
|
||||
"horizon.pages.music.other": "Andere Musik",
|
||||
"horizon.pages.music.empty": "Keine Musiktitel: Die Klänge des Spiels sind nicht geladen.",
|
||||
"horizon.pages.music.tooltip": "Die eingebauten Titel, nach Situation: Spiele einen jetzt ab oder schalte einen aus, damit das Spiel ihn nicht mehr wählt.",
|
||||
"horizon.pages.studio.tooltip": "Durchsuche gespeicherte Aufnahmen und ihre Metadaten.",
|
||||
"horizon.studio.saved_recordings": "Gespeicherte Aufzeichnungen",
|
||||
"horizon.studio.empty": "Keine Aufzeichnungen im Aufnahmeordner gefunden.",
|
||||
"horizon.studio.empty_hint": "Nutze /studio record start <name> in einer Welt oder lege .mcpr-Dateien dort ab.",
|
||||
@@ -98,12 +177,36 @@
|
||||
"horizon.pages.open.controller_binds": "Controller-Belegung …",
|
||||
"horizon.pages.open.horizon_settings": "Horizon-Einstellungen …",
|
||||
"horizon.pages.open.studio": "Replay Studio …",
|
||||
"horizon.pages.open.music": "Musik …",
|
||||
"horizon.mods.empty": "Keine Mods: Lege .hmod-Dateien in den Ordner horizon-mods",
|
||||
"horizon.mods.stopped": " (angehalten)",
|
||||
"horizon.mods.not_built": " (nicht gebaut)",
|
||||
"horizon.mods.dev": " (Entwicklung)",
|
||||
"horizon.mods.settings": "Einstellungen",
|
||||
"horizon.mods.in_world": " (Welt verlassen, um zu ändern)",
|
||||
"horizon.mods.open_folder": "Modordner öffnen",
|
||||
"horizon.mods.browse": "Mods durchsuchen …",
|
||||
"horizon.mods.browse.title": "Mods durchsuchen",
|
||||
"horizon.mods.browse.search": "Mods suchen …",
|
||||
"horizon.mods.browse.project": "Mod",
|
||||
"horizon.mods.browse.loading": "Horizons Mod-Register wird gelesen …",
|
||||
"horizon.mods.browse.failed": "Mod-Register konnte nicht gelesen werden: %s",
|
||||
"horizon.mods.browse.none": "Keine Mod gefunden",
|
||||
"horizon.mods.browse.has_update": " (Update)",
|
||||
"horizon.mods.browse.is_installed": " (installiert)",
|
||||
"horizon.mods.browse.by": "von %s",
|
||||
"horizon.mods.browse.installing": "Wird installiert …",
|
||||
"horizon.mods.browse.install": "Installieren",
|
||||
"horizon.mods.browse.update": "Aktualisieren",
|
||||
"horizon.mods.browse.installed": "Installiert",
|
||||
"horizon.mods.browse.open_page": "Seite öffnen",
|
||||
"horizon.mods.browse.enabled": "Mod",
|
||||
"horizon.mods.browse.changelog": "Änderungsprotokoll",
|
||||
"horizon.mods.browse.done": "%s %s ist installiert und eingeschaltet.",
|
||||
"horizon.mods.browse.install_failed": "Installation fehlgeschlagen: %s",
|
||||
"horizon.mods.switch.worldgen": "ändert die Weltgenerierung",
|
||||
"horizon.mods.switch.registries": "fügt Blöcke, Gegenstände oder andere Registereinträge hinzu",
|
||||
"horizon.mods.switch.restart": "Starte das Spiel neu, um %s anzuwenden: Sie fügen Blöcke, Gegenstände oder Weltgenerierung hinzu.",
|
||||
"horizon.shader_options.profile": "Profil",
|
||||
"horizon.agent.copy_details": "Verbindungsdaten kopieren",
|
||||
"horizon.multiplayer.join": "Beitreten",
|
||||
@@ -115,6 +218,7 @@
|
||||
"LOD Distance": "LOD-Weite",
|
||||
"Far Detail": "Ferndetails",
|
||||
"Cave Culling": "Höhlen-Culling",
|
||||
"GPU Culling": "GPU-Culling",
|
||||
"Entity Culling": "Objekt-Culling",
|
||||
"Chunk Updates": "Chunk-Updates",
|
||||
"Upload Budget": "Upload-Budget",
|
||||
@@ -133,6 +237,10 @@
|
||||
"Peak Brightness": "Spitzenhelligkeit",
|
||||
"Black Level": "Schwarzwert",
|
||||
"UI Brightness": "GUI-Helligkeit",
|
||||
"HDR Lighting": "HDR-Beleuchtung",
|
||||
"Light sources, the sun and sunlight shine past white. Off: just as SDR.": "Lichtquellen, die Sonne und Sonnenlicht leuchten über Weiß hinaus. Aus: wie SDR.",
|
||||
"Smooth Gradients": "Weiche Verläufe",
|
||||
"16-bit frame: dark skies and shadows don't band. A bit more VRAM. Next start.": "16-Bit-Bild: Dunkle Himmel und Schatten zeigen keine Streifen. Etwas mehr VRAM. Nächster Start.",
|
||||
"Battery Mode": "Akkumodus",
|
||||
"Graphics API": "Grafik-API",
|
||||
"Translucent Batching": "Transparenz-Batching",
|
||||
@@ -169,7 +277,28 @@
|
||||
"Medium cost": "Mittlere Kosten",
|
||||
"High cost": "Hohe Kosten",
|
||||
"Saves time": "Spart Zeit",
|
||||
"Not with software rendering, which runs it on the CPU.": "Nicht mit Software-Rendering, das auf der CPU läuft.",
|
||||
"Out of video memory: off until the next start.": "Zu wenig Videospeicher: aus bis zum nächsten Start.",
|
||||
"The graphics device was lost: off until the next start.": "Das Grafikgerät ist verloren gegangen: aus bis zum nächsten Start.",
|
||||
"Not on DirectX 12 yet: try the Vulkan graphics API.": "Noch nicht unter DirectX 12: Probiere die Grafik-API Vulkan.",
|
||||
"Culls nearby terrain on the GPU: less CPU at high render distance, more GPU.": "Culling nahen Geländes auf der GPU: weniger CPU bei großer Sichtweite, mehr GPU.",
|
||||
"Auto: tries it where your CPU limits the frame, keeps it if frames get faster.": "Auto: Probiert es dort, wo deine CPU das Bild begrenzt, und behält es, wenn die Bilder schneller werden.",
|
||||
"horizon.realms.not_signed_in": "Melde dich im Horizon-Launcher mit deinem Microsoft-Konto an, um auf Realms zu spielen.",
|
||||
"General": "Allgemein",
|
||||
"Advanced": "Erweitert",
|
||||
"Undo": "Rückgängig",
|
||||
"Search options": "Einstellungen durchsuchen",
|
||||
"Shortcut": "Tastenkürzel",
|
||||
"Distance": "Entfernung",
|
||||
"Display": "Anzeige",
|
||||
"Interface": "Oberfläche",
|
||||
"Sky and weather": "Himmel und Wetter",
|
||||
"Lighting and entities": "Beleuchtung und Entitäten",
|
||||
"Textures": "Texturen",
|
||||
"Image": "Bild",
|
||||
"Chunk loading": "Chunk-Laden",
|
||||
"Power": "Strom",
|
||||
"Frames": "Bilder",
|
||||
"Overlay": "Einblendung",
|
||||
"HDR": "HDR",
|
||||
"Graphics": "Grafik",
|
||||
@@ -178,6 +307,120 @@
|
||||
"Your display or system doesn't offer HDR output.": "Dein Bildschirm oder System unterstützt keine HDR-Ausgabe.",
|
||||
"Turn HDR on to use it.": "Schalte HDR ein, um es zu nutzen.",
|
||||
"Your GPU or graphics API doesn't support it.": "Deine GPU oder Grafik-API unterstützt das nicht.",
|
||||
"Output": "Ausgabe",
|
||||
"Brightness": "Helligkeit",
|
||||
"How far you see, the window and the HUD.": "Wie weit du siehst, das Fenster und das HUD.",
|
||||
"The sky and weather, lighting, textures and the image.": "Himmel und Wetter, Beleuchtung, Texturen und das Bild.",
|
||||
"Chunk loading, culling and power use.": "Chunk-Laden, Culling und Stromverbrauch.",
|
||||
"How frames reach the screen, and the performance overlay.": "Wie Bilder auf den Bildschirm gelangen und die Leistungsanzeige.",
|
||||
"Brighter-than-white output on an HDR display.": "Ausgabe heller als Weiß auf einem HDR-Bildschirm.",
|
||||
"Results for \"%s\"": "Ergebnisse für „%s“",
|
||||
"No settings match.": "Keine Einstellungen passen.",
|
||||
"horizon.extras.header.menus": "Menüs",
|
||||
"horizon.extras.music_page": "Musikseite",
|
||||
"horizon.extras.wheel_cycles_list_settings.tooltip": "Das Mausrad über einer Schaltfläche in einer Einstellungsliste ändert ihren Wert. Aus scrollt das Rad wie in Vanilla die Liste.",
|
||||
"horizon.extras.wheel_cycles_settings": "Mausrad ändert Einstellungen",
|
||||
"horizon.extras.pack_browser": "Ressourcenpaket-Browser",
|
||||
"horizon.extras.music_page.tooltip": "Musik & Geräusche erhält eine Seite „Musik …“: Spiele jeden eingebauten Titel jetzt ab oder schalte einen aus, damit das Spiel ihn nicht mehr wählt. Nichts ändert sich, bis du einen Titel ausschaltest.",
|
||||
"horizon.friends.relay_stopped": "Zu viele falsche Kurzcodes, deshalb ist das Relay aus, bis du erneut teilst. Freunde können weiterhin mit dem vollständigen Code beitreten.",
|
||||
"horizon.extras.pack_browser.tooltip": "Ressourcenpakete auswählen erhält eine Schaltfläche „Hinzufügen“, die Modrinth durchsucht und ein Paket in deinen Paketordner installiert. Aus kontaktiert das Spiel Modrinth nie.",
|
||||
"horizon.splitscreen.setup.monitor": "Monitor %s",
|
||||
"horizon.splitscreen.setup.monitor_unknown": "ein Monitor",
|
||||
"horizon.splitscreen.setup.window": "Fenster %s",
|
||||
"horizon.splitscreen.setup.new_window": "Neues Fenster",
|
||||
"horizon.splitscreen.setup.fill_monitor": "%s füllen",
|
||||
"horizon.splitscreen.setup.done": "Fertig",
|
||||
"horizon.splitscreen.setup.switch": "%s: %s",
|
||||
"horizon.splitscreen.setup.on": "An",
|
||||
"horizon.splitscreen.setup.off": "Aus",
|
||||
"horizon.splitscreen.setup.player": "Spieler %s",
|
||||
"horizon.splitscreen.setup.revert": "Verwerfen",
|
||||
"horizon.splitscreen.setup.apply": "Anwenden",
|
||||
"horizon.splitscreen.setup.warning.empty_main": "Niemand spielt in Fenster 1: Es wird ausgeblendet",
|
||||
"horizon.splitscreen.setup.warning.empty_close": "Niemand spielt in Fenster %s: Es schließt sich beim Anwenden",
|
||||
"horizon.splitscreen.setup.warning.empty_new": "Niemand spielt in Fenster %s: Es öffnet sich nicht",
|
||||
"horizon.splitscreen.setup.warning.on_main": "Fenster %s füllt den Monitor von Fenster 1: Seine Spieler spielen in Fenster 1",
|
||||
"horizon.splitscreen.setup.warning.unplugged": "Der Monitor von Fenster %s ist nicht angeschlossen: Seine Spieler spielen in Fenster 1",
|
||||
"horizon.splitscreen.setup.status.off": "Splitscreen ist aus.",
|
||||
"horizon.splitscreen.setup.status.enable_hint": "Schalte ihn ein und drücke dann in einer Welt eine Taste auf einem anderen Controller.",
|
||||
"horizon.splitscreen.setup.status.join_hint": "Drücke A, um beizutreten, und wähle ein Profil.",
|
||||
"horizon.splitscreen.setup.status.idle": "Splitscreen inaktiv",
|
||||
"horizon.splitscreen.setup.status.alone": "Keine weiteren lokalen Spieler.",
|
||||
"horizon.splitscreen.setup.status.solo_controls": "Steuerung im Alleingang und Controller-Feedback bleiben unverändert.",
|
||||
"horizon.splitscreen.setup.window_empty": "Fenster %s: leer",
|
||||
"horizon.splitscreen.setup.note.select": "Klicke einen Spieler an oder ziehe ihn auf ein Fenster oder einen Monitor.",
|
||||
"horizon.splitscreen.setup.note.apply": "Mit Anwenden spielst du so, mit Verwerfen machst du es rückgängig.",
|
||||
"horizon.splitscreen.setup.note.place": "Wo soll %s spielen?",
|
||||
"horizon.splitscreen.setup.this_player": "dieser Spieler",
|
||||
"horizon.splitscreen.setup.switch_disabled": "Splitscreen schaltet sich aus, sobald die anderen gegangen sind (ihr Pausenmenü).",
|
||||
"horizon.splitscreen.setup.window_place": "Fenster %s (%s von %s)",
|
||||
"horizon.splitscreen.setup.names": "Namen",
|
||||
"horizon.splitscreen.setup.identify": "Kennzeichnen",
|
||||
"horizon.splitscreen.setup.layout.two": "2 Spieler teilen: %s",
|
||||
"horizon.splitscreen.setup.layout.three": "3 Spieler teilen: %s",
|
||||
"horizon.splitscreen.setup.layout.two.0": "Auto",
|
||||
"horizon.splitscreen.setup.layout.two.1": "Nebeneinander",
|
||||
"horizon.splitscreen.setup.layout.two.2": "Übereinander",
|
||||
"horizon.splitscreen.setup.layout.three.0": "Einer breit oben",
|
||||
"horizon.splitscreen.setup.layout.three.1": "Einer hochkant links",
|
||||
"horizon.splitscreen.setup.layout.three.2": "Nebeneinander",
|
||||
"horizon.splitscreen.setup.help.player": "Klicke %s an oder ziehe daran, um ihn zu verschieben.",
|
||||
"horizon.splitscreen.setup.help.layout_auto": "%s (nebeneinander, wenn das Fenster breit ist). Drücken zum Ändern.",
|
||||
"horizon.splitscreen.setup.help.layout": "%s. Drücken zum Ändern.",
|
||||
"horizon.splitscreen.setup.help.switch": "Splitscreen für diesen Computer, an oder aus.",
|
||||
"horizon.splitscreen.setup.help.identify": "Die Ansicht jedes Spielers zeigt, in welchem Fenster er ist.",
|
||||
"horizon.splitscreen.setup.help.names": "Der Name jedes Spielers in der Ecke seiner Ansicht.",
|
||||
"horizon.splitscreen.setup.help.revert": "Die noch nicht angewendeten Änderungen verwerfen.",
|
||||
"horizon.splitscreen.setup.help.apply": "So spielen, wie die Karte es zeigt.",
|
||||
"horizon.splitscreen.setup.help.unchanged": "Noch nichts geändert.",
|
||||
"horizon.splitscreen.setup.give_up_pad": "Controller abgeben",
|
||||
"horizon.splitscreen.prompt.controller_connected": "Controller verbunden",
|
||||
"horizon.splitscreen.prompt.controller_disconnected": "Controller getrennt",
|
||||
"horizon.splitscreen.prompt.controller_disconnected.hint": "Verbinde ihn erneut oder drücke eine Taste auf einem anderen",
|
||||
"horizon.splitscreen.prompt.join.one": "%s möchte beitreten",
|
||||
"horizon.splitscreen.prompt.join.many": "%s Controller möchten beitreten",
|
||||
"horizon.splitscreen.prompt.join.yes": "Beitreten",
|
||||
"horizon.splitscreen.prompt.join.as_p1": "Als P1 spielen",
|
||||
"horizon.splitscreen.prompt.join.no": "Nein",
|
||||
"horizon.splitscreen.prompt.lost": "%s: Controller getrennt",
|
||||
"horizon.splitscreen.prompt.lost.use_another": "Anderen verwenden",
|
||||
"horizon.splitscreen.prompt.lost.leave": "Verlassen",
|
||||
"horizon.splitscreen.setup.help.give_up_pad": "Dein Controller steuert dich nicht mehr und wird zum Beitreten angeboten (A).",
|
||||
"horizon.splitscreen.setup.note.held_at": "%s nach %s: A legt es hier ab, B legt es zurück.",
|
||||
"horizon.splitscreen.setup.note.held": "%s aufgenommen: Links/rechts wählt den Ort, A legt ab, B legt zurück.",
|
||||
"horizon.splitscreen.online_only": "Dieser Server läuft im Online-Modus: Offline-Profile können ihm nicht beitreten, jeder Spieler braucht sein eigenes Minecraft-Konto.",
|
||||
"horizon.splitscreen.own_account": "Dieser Server benötigt das eigene Minecraft-Konto jedes Spielers",
|
||||
"horizon.splitscreen.local_only": "Andere Spieler können diesem Server nur mit ihrem eigenen Minecraft-Konto beitreten: Offline-Profile spielen in deiner Welt, im LAN oder auf einem Server in deinem lokalen Netzwerk.",
|
||||
"Block Entities": "Blockentitäten",
|
||||
"Shulker Boxes": "Shulker-Kisten",
|
||||
"Reuses settled boxes; lid movement stays animated.": "Verwendet ruhende Kisten erneut; die Deckelbewegung bleibt animiert.",
|
||||
"Signs": "Schilder",
|
||||
"Reuses sign text until text, light, or font changes.": "Verwendet Schildertext erneut, bis sich Text, Licht oder Schriftart ändern.",
|
||||
"Decorated Pots": "Verzierte Tontöpfe",
|
||||
"Reuses still pots; break wobble and sherd changes redraw.": "Verwendet ruhende Töpfe erneut; Wackeln beim Zerbrechen und Scherbenänderungen zeichnen neu.",
|
||||
"Bells": "Glocken",
|
||||
"Reuses still bells; ringing stays animated.": "Verwendet ruhende Glocken erneut; das Läuten bleibt animiert.",
|
||||
"Chests": "Truhen",
|
||||
"Reuses closed chests; moving lids stay animated.": "Verwendet geschlossene Truhen erneut; bewegte Deckel bleiben animiert.",
|
||||
"Banners": "Banner",
|
||||
"Reuses poles; flags and pattern layers still update.": "Verwendet Stangen erneut; Fahnen und Musterschichten aktualisieren sich weiterhin.",
|
||||
"Copper Golem Statues": "Kupfergolem-Statuen",
|
||||
"Reuses statue geometry until its block state changes.": "Verwendet die Statuengeometrie erneut, bis sich ihr Blockzustand ändert.",
|
||||
"Opaque Ice": "Undurchsichtiges Eis",
|
||||
"Draws ice in the solid pass, without its see-through texture.": "Zeichnet Eis im massiven Durchgang, ohne seine durchsichtige Textur.",
|
||||
"Find BlueMap": "BlueMap finden",
|
||||
"On servers, finds their BlueMap web map for far terrain. Next join.": "Findet auf Servern deren BlueMap-Webkarte für fernes Gelände. Nächster Beitritt.",
|
||||
"BlueMap Radius": "BlueMap-Radius",
|
||||
"How far around you BlueMap tiles are downloaded. Next join.": "Wie weit um dich herum BlueMap-Kacheln heruntergeladen werden. Nächster Beitritt.",
|
||||
"Hidden Faces": "Versteckte Flächen",
|
||||
"Drops leaf faces behind two leaf layers: faster, same crowns.": "Lässt Blattflächen hinter zwei Laubschichten weg: schneller, gleiche Kronen.",
|
||||
"%s nits": "%s Nits",
|
||||
"Auto (%s nits)": "Auto (%s Nits)",
|
||||
"%.2f nits": "%.2f Nits",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Versteckt Modelle hinter undurchsichtigen Blöcken. Entitäten aktualisieren sich. Aus bei benutzerdefinierten Paketen.",
|
||||
"Entity Culling Precheck": "Objekt-Culling-Vorprüfung",
|
||||
"Runs the mob AABB precheck after this many loaded mobs. Zero disables it.": "Führt die AABB-Vorprüfung für Kreaturen nach dieser Anzahl geladener Kreaturen aus. Null deaktiviert sie.",
|
||||
"%s mobs": "%s Kreaturen",
|
||||
"Only applies to asynchronous entity culling.": "Nur für asynchrones Objekt-Culling.",
|
||||
"%s chunks": "%s Chunks",
|
||||
"%s per frame": "%s pro Bild",
|
||||
@@ -194,9 +437,7 @@
|
||||
"Size of menus and the HUD.": "Größe der Menüs und des HUDs.",
|
||||
"Sets the options below together. Changing one makes it Custom.": "Stellt Optionen darunter ein. Änderung = „Benutzerdefiniert“.",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lava, Feuer, Sonne heller als Weiß. Braucht HDR-Bildschirm.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Das dunkelste Schwarz deines Bildschirms. 0 für OLED; LCDs leuchten ein wenig.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "Wie hell Lichter werden. Auto: der Spitzenwert, den dein System meldet.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "Wie hell weißes Papier und Menüs aussehen. Auto: das Weiß deines Desktops.",
|
||||
"How bright the HUD and menus are, against paper white.": "Wie hell HUD und Menüs im Vergleich zu Papierweiß sind.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Wie weit Kreaturen, Pflanzen und Maschinen weiterarbeiten. Kostet CPU-Zeit.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Zeichenweite für Kreaturen und Gegenstände, anteilig zur Norm.",
|
||||
@@ -407,10 +648,45 @@
|
||||
"horizon.pages.split_settings": "Splitscreen-Einstellungen",
|
||||
"horizon.pages.split_setup": "Splitscreen",
|
||||
"horizon.pages.split_setup.tooltip": "Spieler an diesem Computer: Controller können deiner Welt beitreten.",
|
||||
"horizon.extras.repro_keys": "Aufnahme-Tastenkürzel",
|
||||
"horizon.extras.repro_keys.tooltip": "F3+R startet oder beendet eine Repro-Aufzeichnung und F3+E schreibt ihr Paket. Standardmäßig aus: Eine Aufzeichnung kopiert die Welt.",
|
||||
"horizon.agent.record_session": "Aufzeichnungssitzung starten",
|
||||
"horizon.agent.record_stop": "Aufzeichnungssitzung beenden",
|
||||
"horizon.agent.export_repro": "Repro-Paket exportieren",
|
||||
"horizon.repro.started": "Aufzeichnung gestartet; F3+R beendet sie und schreibt das Paket.",
|
||||
"horizon.repro.stopped": "Aufzeichnung beendet: %s",
|
||||
"horizon.repro.bundle": "Repro-Paket: %s",
|
||||
"horizon.repro.error": "Repro: %s",
|
||||
"horizon.repro.no_recording": "Es gibt noch keine Aufzeichnung zum Exportieren.",
|
||||
"horizon.repro.replay_started": "Wiedergabe gestartet: %s",
|
||||
"horizon.splitscreen.profiles.offline": " (Offline)",
|
||||
"horizon.splitscreen.profiles.microsoft": " (Microsoft)",
|
||||
"horizon.splitscreen.profiles.sign_in": "Melde dich im Launcher erneut an",
|
||||
"horizon.splitscreen.profiles.online": "Microsoft online",
|
||||
"horizon.splitscreen.account_unavailable": "%s ist offline. Melde dich im Launcher erneut an und starte dann das Spiel neu.",
|
||||
"horizon.splitscreen.account_expired": "Die Microsoft-Anmeldung von %s ist abgelaufen. Melde dich im Launcher erneut an und starte dann das Spiel neu.",
|
||||
"Particle Culling": "Partikel-Culling",
|
||||
"Draws every particle.": "Zeichnet alle Partikel.",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles.": "Überspringt Partikel außerhalb des Bildes, unterhalb eines Pixels oder hinter Terrain.",
|
||||
"Skips offscreen, subpixel and terrain-hidden particles with less render-thread work.": "Überspringt Partikel außerhalb des Bildes, unterhalb eines Pixels oder hinter Terrain und entlastet den Render-Thread.",
|
||||
"horizon.pack.add": "+",
|
||||
"horizon.pack.addInfo": "Ein Ressourcenpaket von Modrinth hinzufügen",
|
||||
"horizon.pack.addWorking": "Modrinth wird angefragt …",
|
||||
"horizon.pack.addNone": "Keine Ressourcenpakete gefunden",
|
||||
"horizon.pack.addTitle": "Ein Ressourcenpaket hinzufügen",
|
||||
"horizon.pack.install": "Installieren",
|
||||
"horizon.pack.installing": "Wird installiert",
|
||||
"horizon.pack.installed": "Installiert",
|
||||
"horizon.pack.search": "Suchen",
|
||||
"horizon.pack.searchHint": "Modrinth durchsuchen …",
|
||||
"horizon.pack.filters": "Filter",
|
||||
"horizon.pack.filter.realistic": "Realistisch",
|
||||
"horizon.pack.filter.simplistic": "Schlicht",
|
||||
"horizon.pack.filter.vanillaLike": "Vanilla-ähnlich",
|
||||
"horizon.pack.filter.x16": "16x",
|
||||
"horizon.pack.filter.x32": "32x",
|
||||
"horizon.pack.filter.x64": "64x",
|
||||
"horizon.pack.addFailed": "Das Paket konnte nicht hinzugefügt werden: %s",
|
||||
"horizon.extras.header.cosmetics": "Kosmetik",
|
||||
"horizon.extras.cosmetics_mine": "Meine Kosmetik",
|
||||
"horizon.extras.cosmetics_mine.tooltip": "Ausgeblendet: Alle Horizon-Spieler sehen dich ohne deine Kosmetik, du selbst auch. Derselbe Schalter wie in der Garderobe des Launchers.",
|
||||
@@ -451,6 +727,11 @@
|
||||
"horizon.tempban.invalid_duration": "%s ist keine Zeitdauer: 30m, 12h, 7d",
|
||||
"horizon.tempban.dedicated_required": "Temporäre Sperren benötigen einen dedizierten Server",
|
||||
"horizon.tempban.success": "%1$s für %2$s, bis %3$s",
|
||||
"horizon.options.pointer_speed": "Geschwindigkeit des Menüzeigers",
|
||||
"horizon.options.scroll_speed": "Scrollgeschwindigkeit im Menü",
|
||||
"horizon.options.repeat_delay": "Verzögerung der Navigationswiederholung",
|
||||
"horizon.options.repeat_interval": "Intervall der Navigationswiederholung",
|
||||
"horizon.options.milliseconds": "%s ms",
|
||||
"horizon.extras.cosmetics_solo": "Im Einzelspieler",
|
||||
"horizon.extras.cosmetics_solo.tooltip": "Deine Kosmetik und das Abzeichen deines Namens auch in deinen Einzelspielerwelten zeigen (deinen Namen mit „Eigenes Namensschild“). Aus: nur auf Servern.",
|
||||
"horizon.extras.horizon_marks": "Horizon-Logos",
|
||||
@@ -460,6 +741,276 @@
|
||||
"horizon.tempban.list.empty": "Es gibt keine befristeten Sperren",
|
||||
"horizon.tempban.list.count": "Es gibt %s befristete Sperre(n):",
|
||||
"horizon.tempban.list.entry": "%1$s wurde von %2$s gesperrt, %3$s (bis %4$s)",
|
||||
"horizon.pages.pvp": "PvP & HUD",
|
||||
"horizon.pages.pvp.tooltip": "Fadenkreuz, HUD und PvP-Einstellungen, alle standardmäßig aus. Nichts hier zeigt, was Vanilla nicht zeigt.",
|
||||
"horizon.pages.open.pvp": "PvP & HUD …",
|
||||
"horizon.pvp.header.crosshair": "Fadenkreuz",
|
||||
"horizon.pvp.crosshair": "Benutzerdefiniertes Fadenkreuz",
|
||||
"horizon.pvp.crosshair_shape": "Form",
|
||||
"horizon.pvp.crosshair_size": "Größe",
|
||||
"horizon.pvp.crosshair_gap": "Abstand",
|
||||
"horizon.pvp.crosshair_thickness": "Dicke",
|
||||
"horizon.pvp.crosshair_colour": "Farbe",
|
||||
"horizon.pvp.crosshair_outline": "Umrandung",
|
||||
"horizon.pvp.crosshair_dot": "Mittelpunkt",
|
||||
"horizon.pvp.crosshair_hover": "Farbe auf Entitäten",
|
||||
"horizon.pvp.crosshair.tooltip": "Zeichnet dein eigenes Fadenkreuz statt dem von Vanilla. Seine ersten Einstellungen sehen aus wie die von Vanilla.",
|
||||
"horizon.pvp.crosshair_shape.tooltip": "Ein Kreuz, ein T, ein X, ein Kreis, ein Quadrat oder nur ein Punkt.",
|
||||
"horizon.pvp.crosshair_size.tooltip": "Die Länge jedes Arms in GUI-Pixeln.",
|
||||
"horizon.pvp.crosshair_gap.tooltip": "Der leere Raum in der Mitte.",
|
||||
"horizon.pvp.crosshair_thickness.tooltip": "Wie breit jede Linie ist.",
|
||||
"horizon.pvp.crosshair_colour.tooltip": "Invertiert zeigt die Farbe darunter invertiert, so wie es Vanillas Fadenkreuz tut.",
|
||||
"horizon.pvp.crosshair_outline.tooltip": "Ein dünner schwarzer Rand, damit es auf jedem Hintergrund sichtbar ist.",
|
||||
"horizon.pvp.crosshair_dot.tooltip": "Ein Punkt in der Mitte des Fadenkreuzes.",
|
||||
"horizon.pvp.crosshair_hover.tooltip": "Eine andere Farbe, während das Fadenkreuz über einer Kreatur oder einem Spieler ist, den du treffen könntest, so wie Vanillas Angriffsanzeige es erkennt.",
|
||||
"horizon.pvp.colour.inverted": "Invertiert",
|
||||
"horizon.pvp.colour.white": "Weiß",
|
||||
"horizon.pvp.colour.red": "Rot",
|
||||
"horizon.pvp.colour.orange": "Orange",
|
||||
"horizon.pvp.colour.yellow": "Gelb",
|
||||
"horizon.pvp.colour.green": "Grün",
|
||||
"horizon.pvp.colour.cyan": "Cyan",
|
||||
"horizon.pvp.colour.blue": "Blau",
|
||||
"horizon.pvp.colour.magenta": "Magenta",
|
||||
"horizon.pvp.colour.black": "Schwarz",
|
||||
"horizon.pvp.shape.cross": "Kreuz",
|
||||
"horizon.pvp.shape.t": "T",
|
||||
"horizon.pvp.shape.x": "X",
|
||||
"horizon.pvp.shape.circle": "Kreis",
|
||||
"horizon.pvp.shape.square": "Quadrat",
|
||||
"horizon.pvp.shape.dot": "Punkt",
|
||||
"horizon.pvp.outline": "Benutzerdefinierte Blockumrandung",
|
||||
"horizon.pvp.outline.tooltip": "Ändert die Umrandung um den Block, den du ansiehst.",
|
||||
"horizon.pvp.outline_colour": "Farbe",
|
||||
"horizon.pvp.outline_colour.tooltip": "Die Farbe der Umrandung. Schwarz bei 40 % ist die von Vanilla.",
|
||||
"horizon.pvp.outline_opacity": "Deckkraft",
|
||||
"horizon.pvp.outline_opacity.tooltip": "Wie kräftig die Umrandung ist.",
|
||||
"horizon.pvp.outline_thickness": "Dicke",
|
||||
"horizon.pvp.outline_thickness.tooltip": "Die Breite der Umrandung, als Vielfaches von Vanilla.",
|
||||
"horizon.pvp.outline_fill": "Füllung",
|
||||
"horizon.pvp.outline_fill.tooltip": "Färbt die Flächen des Blocks, den du ansiehst, leicht ein.",
|
||||
"horizon.pvp.hit": "Benutzerdefinierte Trefferfarbe",
|
||||
"horizon.pvp.hit.tooltip": "Ändert die Farbe, in der Kreaturen und Spieler aufblitzen, wenn sie verletzt werden. Rot bei 30 % ist die von Vanilla.",
|
||||
"horizon.pvp.hit_colour": "Farbe",
|
||||
"horizon.pvp.hit_colour.tooltip": "Die Farbe, in der verletzte Kreaturen und Spieler aufblitzen.",
|
||||
"horizon.pvp.hit_opacity": "Deckkraft",
|
||||
"horizon.pvp.hit_opacity.tooltip": "Wie stark die Farbe sie überdeckt.",
|
||||
"horizon.pvp.header.outline": "Blockumrandung",
|
||||
"horizon.pvp.header.hit": "Trefferfarbe",
|
||||
"horizon.pvp.hud_armour": "Rüstung",
|
||||
"horizon.pvp.hud_armour.tooltip": "Die Rüstung, die du trägst, Kopf zuerst, mit der verbleibenden Haltbarkeit jedes Teils.",
|
||||
"horizon.pvp.hud_armour_corner": "Rüstungsecke",
|
||||
"horizon.pvp.hud_armour_corner.tooltip": "Wo die Rüstung angezeigt wird.",
|
||||
"horizon.pvp.hud_armour_percent": "Haltbarkeit in %",
|
||||
"horizon.pvp.hud_armour_percent.tooltip": "Verbleibende Nutzungen als Prozentwert des Maximums des Teils statt als Zahl.",
|
||||
"horizon.pvp.hud_effects": "Effekte",
|
||||
"horizon.pvp.hud_effects.tooltip": "Deine Effekte mit Namen, Stufen und Restzeit statt der Symbole oben rechts.",
|
||||
"horizon.pvp.hud_effects_corner": "Ecke der Effekte",
|
||||
"horizon.pvp.hud_effects_corner.tooltip": "Wo die Effekte angezeigt werden.",
|
||||
"horizon.pvp.hud_held": "Anzahl des gehaltenen Gegenstands",
|
||||
"horizon.pvp.hud_held.tooltip": "Wie viele des Gegenstands in deiner Hand dein Inventar enthält; Pfeile bei Bogen oder Armbrust.",
|
||||
"horizon.pvp.hud_held_corner": "Ecke der gehaltenen Anzahl",
|
||||
"horizon.pvp.hud_held_corner.tooltip": "Wo die Anzahl angezeigt wird.",
|
||||
"horizon.pvp.hud_background": "Widget-Hintergründe",
|
||||
"horizon.pvp.hud_background.tooltip": "Ein dunkler Kasten hinter jedem Widget.",
|
||||
"horizon.pvp.hud_opacity": "Hintergrunddeckkraft",
|
||||
"horizon.pvp.hud_opacity.tooltip": "Wie dunkel die Kästen hinter den Widgets sind.",
|
||||
"horizon.pvp.hud_text_colour": "Textfarbe",
|
||||
"horizon.pvp.hud_text_colour.tooltip": "Die Textfarbe der Widgets. Die Haltbarkeit behält ihr Grün bis Rot.",
|
||||
"horizon.pvp.hud_shadow": "Textschatten",
|
||||
"horizon.pvp.hud_shadow.tooltip": "Ein Schatten unter dem Text der Widgets, wie ihn Vanillas HUD-Text hat.",
|
||||
"horizon.pvp.header.hud": "HUD-Widgets",
|
||||
"horizon.pvp.corner.top_left": "Oben links",
|
||||
"horizon.pvp.corner.top_right": "Oben rechts",
|
||||
"horizon.pvp.corner.bottom_left": "Unten links",
|
||||
"horizon.pvp.corner.bottom_right": "Unten rechts",
|
||||
"horizon.pvp.hud_ping": "Ping",
|
||||
"horizon.pvp.hud_ping.tooltip": "Die Latenz, die der Server für dich meldet, in Millisekunden. Nur im Mehrspieler.",
|
||||
"horizon.pvp.hud_ping_corner": "Ping-Ecke",
|
||||
"horizon.pvp.hud_ping_corner.tooltip": "Wo der Ping angezeigt wird.",
|
||||
"horizon.pvp.hud_coords": "Koordinaten",
|
||||
"horizon.pvp.hud_coords.tooltip": "Deine Blockposition und die Richtung, in die du blickst, wie F3 sie zeigt. Versteckt, wenn die Welt oder der Server F3 einschränkt.",
|
||||
"horizon.pvp.hud_coords_corner": "Koordinatenecke",
|
||||
"horizon.pvp.hud_coords_corner.tooltip": "Wo die Koordinaten angezeigt werden.",
|
||||
"horizon.pvp.hud_server": "Serveradresse",
|
||||
"horizon.pvp.hud_server.tooltip": "Die Adresse des Servers, auf dem du spielst. Nur im Mehrspieler.",
|
||||
"horizon.pvp.hud_server_corner": "Server-Ecke",
|
||||
"horizon.pvp.hud_server_corner.tooltip": "Wo die Adresse angezeigt wird.",
|
||||
"horizon.pvp.hud_combo": "Combo-Zähler",
|
||||
"horizon.pvp.hud_combo.tooltip": "Treffer in Folge, die der Server bestätigt hat, bis du verletzt wirst oder 3 Sekunden ohne einen vergehen.",
|
||||
"horizon.pvp.hud_combo_corner": "Combo-Ecke",
|
||||
"horizon.pvp.hud_combo_corner.tooltip": "Wo die Combo angezeigt wird.",
|
||||
"horizon.pvp.hud.ping": "Ping: %s ms",
|
||||
"horizon.pvp.hud.coords": "XYZ: %s %s %s",
|
||||
"horizon.pvp.hud.facing": "Blickrichtung: %s",
|
||||
"horizon.pvp.hud.combo": "Combo: %s",
|
||||
"horizon.pvp.hud.direction.north": "Norden",
|
||||
"horizon.pvp.hud.direction.south": "Süden",
|
||||
"horizon.pvp.hud.direction.east": "Osten",
|
||||
"horizon.pvp.hud.direction.west": "Westen",
|
||||
"horizon.pvp.hud_fps": "FPS",
|
||||
"horizon.pvp.hud_fps.tooltip": "Bilder, die in der letzten Sekunde gezeichnet wurden.",
|
||||
"horizon.pvp.hud_fps_corner": "FPS-Ecke",
|
||||
"horizon.pvp.hud_fps_corner.tooltip": "Wo die FPS angezeigt werden.",
|
||||
"horizon.pvp.hud_cps": "Klicks pro Sekunde",
|
||||
"horizon.pvp.hud_cps.tooltip": "Deine linken und rechten Klicks in der letzten Sekunde.",
|
||||
"horizon.pvp.hud_cps_corner": "CPS-Ecke",
|
||||
"horizon.pvp.hud_cps_corner.tooltip": "Wo die Klicks pro Sekunde angezeigt werden.",
|
||||
"horizon.pvp.hud_keys": "Tastenanschläge",
|
||||
"horizon.pvp.hud_keys.tooltip": "Deine Bewegungstasten, Maustasten und Springen, beleuchtet, während du sie hältst, mit Klicks pro Sekunde.",
|
||||
"horizon.pvp.hud_keys_corner": "Ecke der Tastenanschläge",
|
||||
"horizon.pvp.hud_keys_corner.tooltip": "Wo die Tastenanschläge angezeigt werden.",
|
||||
"horizon.pvp.hud_clock": "Uhr",
|
||||
"horizon.pvp.hud_clock.tooltip": "Die Zeit deines Computers.",
|
||||
"horizon.pvp.hud_clock_corner": "Ecke der Uhr",
|
||||
"horizon.pvp.hud_clock_corner.tooltip": "Wo die Uhr angezeigt wird.",
|
||||
"horizon.pvp.hud_reach": "Reichweite",
|
||||
"horizon.pvp.hud_reach.tooltip": "Wie weit von deinen Augen entfernt dein letzter Treffer gelandet ist, für 3 Sekunden. Es misst nur: Deine Reichweite bleibt die von Vanilla.",
|
||||
"horizon.pvp.hud_reach_corner": "Reichweitenecke",
|
||||
"horizon.pvp.hud_reach_corner.tooltip": "Wo die Reichweite angezeigt wird.",
|
||||
"horizon.pvp.hud_speed": "Geschwindigkeit",
|
||||
"horizon.pvp.hud_speed.tooltip": "Wie schnell du dich über den Boden bewegst, in Blöcken pro Sekunde.",
|
||||
"horizon.pvp.hud_speed_corner": "Geschwindigkeitsecke",
|
||||
"horizon.pvp.hud_speed_corner.tooltip": "Wo die Geschwindigkeit angezeigt wird.",
|
||||
"horizon.pvp.hud.fps": "%s FPS",
|
||||
"horizon.pvp.hud.cps": "CPS: %s | %s",
|
||||
"horizon.pvp.hud.reach": "Reichweite: %s",
|
||||
"horizon.pvp.hud.speed": "Geschwindigkeit: %s B/s",
|
||||
"horizon.pvp.hud.keys.attack": "LMB",
|
||||
"horizon.pvp.hud.keys.use": "RMB",
|
||||
"horizon.pvp.hud.keys.cps": "%s CPS",
|
||||
"horizon.pvp.numeric_enchants": "Verzauberungsstufen als Zahlen",
|
||||
"horizon.pvp.numeric_enchants.tooltip": "Tooltips und der Zaubertisch zeigen Schärfe 5 statt Schärfe V.",
|
||||
"horizon.pvp.header.items": "Gegenstände",
|
||||
"horizon.pvp.fire_lower": "Tieferes Feuer",
|
||||
"horizon.pvp.fire_lower.tooltip": "Verschiebt die Flammen, die du beim Brennen siehst, nach unten, damit sie weniger vom Bildschirm bedecken.",
|
||||
"horizon.pvp.shield_size": "Schildgröße",
|
||||
"horizon.pvp.shield_size.tooltip": "Die Größe des Schilds in deiner Hand in der Ego-Perspektive.",
|
||||
"horizon.pvp.totem_size": "Größe der Totem-Animation",
|
||||
"horizon.pvp.totem_size.tooltip": "Die Größe des Totems, das den Bildschirm füllt, wenn es dich rettet.",
|
||||
"horizon.pvp.glint_colour": "Farbe des Verzauberungsschimmers",
|
||||
"horizon.pvp.glint_colour.tooltip": "Die Farbe des Schimmers auf verzauberten Gegenständen und Rüstung. Aus ist Vanillas Lila.",
|
||||
"horizon.pvp.clear_water": "Klares Wasser",
|
||||
"horizon.pvp.clear_water.tooltip": "Unter Wasser siehst du von Anfang an so weit, wie Vanilla es dir nach 30 Sekunden dort erlaubt.",
|
||||
"horizon.pvp.clear_lava": "Klare Lava",
|
||||
"horizon.pvp.clear_lava.tooltip": "In Lava siehst du so weit, wie Vanillas Feuerresistenz es dir erlaubt, ohne den Effekt.",
|
||||
"horizon.pvp.header.visual": "Visuelle Anpassungen",
|
||||
"horizon.pvp.view_time": "Tageszeit",
|
||||
"horizon.pvp.view_time.tooltip": "Die Tageszeit, die du siehst: nur Himmel und sein Licht. Die Zeit der Welt läuft weiter und nichts wird an den Server gesendet.",
|
||||
"horizon.pvp.view_weather": "Wetter",
|
||||
"horizon.pvp.view_weather.tooltip": "Das Wetter, das du siehst: nur Regen, sein Licht und Nebel. Das Wetter der Welt läuft weiter und nichts wird an den Server gesendet.",
|
||||
"horizon.pvp.header.view": "Deine Ansicht",
|
||||
"horizon.pvp.view.world": "Welt",
|
||||
"horizon.pvp.time.morning": "Morgen",
|
||||
"horizon.pvp.time.noon": "Mittag",
|
||||
"horizon.pvp.time.sunset": "Sonnenuntergang",
|
||||
"horizon.pvp.time.midnight": "Mitternacht",
|
||||
"horizon.pvp.weather.clear": "Klar",
|
||||
"horizon.pvp.weather.rain": "Regen",
|
||||
"horizon.pvp.weather.thunder": "Gewitter",
|
||||
"horizon.chat.timestamp": "[%s] ",
|
||||
"horizon.chat.repeats": " (x%s)",
|
||||
"horizon.pvp.header.chat": "Chat",
|
||||
"horizon.chat.copied": "Nachricht kopiert",
|
||||
"horizon.chat.search": "Chat durchsuchen:",
|
||||
"horizon.pvp.chat_timestamps": "Zeitstempel",
|
||||
"horizon.pvp.chat_timestamps.tooltip": "Die Zeit, zu der jede Nachricht kam, davor.",
|
||||
"horizon.pvp.chat_compact": "Kompakter Chat",
|
||||
"horizon.pvp.chat_compact.tooltip": "Eine hintereinander wiederholte Nachricht wird einmal angezeigt, mit der Anzahl, wie oft sie kam: (x3).",
|
||||
"horizon.pvp.chat_copy": "Kopieren beim Klick",
|
||||
"horizon.pvp.chat_copy.tooltip": "Ein Klick auf eine Nachricht im geöffneten Chat kopiert ihren Text, außer der Klick macht etwas anderes.",
|
||||
"horizon.pvp.chat_search": "Chatsuche",
|
||||
"horizon.pvp.chat_search.tooltip": "Strg+F im geöffneten Chat: Was du tippst, zeigt nur die Nachrichten, die es enthalten. Eingabe oder erneut Strg+F beendet es.",
|
||||
"horizon.pvp.preset": "PvP-Preset",
|
||||
"horizon.pvp.preset.tooltip": "Schaltet das benutzerdefinierte Fadenkreuz, die Rüstungs- und Effekt-Widgets, Sprinten und Schleichen als Umschalter, tieferes Feuer sowie den FPS-Zähler und die Tastenanschläge-Mods ein, falls installiert. Deine Einstellungen dafür bleiben.",
|
||||
"horizon.pvp.hud_editor_key": "Taste für den HUD-Editor",
|
||||
"horizon.pvp.hud_editor_key.tooltip": "Öffnet den HUD-Editor aus dem Spiel. Klicke und drücke dann eine Taste; Escape hebt die Belegung auf.",
|
||||
"horizon.pvp.editor": "HUD bearbeiten",
|
||||
"horizon.pvp.editor.tooltip": "Verschiebe die Widgets im Spiel: Ziehe sie überallhin, ändere ihre Größe mit dem Mausrad, rechtsklicke eines, um es in seine Ecke zurückzusetzen.",
|
||||
"horizon.pvp.editor.hint": "Widgets ziehen · Mausrad: Größe · Rechtsklick: zurück in die Ecke",
|
||||
"horizon.pvp.combat_feel": "Kampfgefühl",
|
||||
"horizon.pvp.combat_feel.tooltip": "Legacy zeichnet Kämpfe wie 1.8.9: den Gegenstand in deiner Hand, seinen Schwung, Essen, den Bogen und ein erhobenes Schwert, während du Benutzen hältst, ohne das Absinken nach jedem Treffer oder die Angriffsanzeige. Nur das Aussehen: Was du an einen Server sendest, bleibt gleich.",
|
||||
"horizon.pvp.combat_indicator": "Angriffsanzeige in Legacy",
|
||||
"horizon.pvp.combat_indicator.tooltip": "Behält die Angriffsanzeige unter Legacy, die 1.8.9 nicht hatte.",
|
||||
"horizon.pvp.combat_feel.modern": "Modern",
|
||||
"horizon.pvp.combat_feel.legacy": "Legacy (1.8.9)",
|
||||
"horizon.pvp.header.combat": "Kampfgefühl",
|
||||
"horizon.pvp.combat_sounds": "1.8-Treffergeräusche",
|
||||
"horizon.pvp.combat_sounds.tooltip": "Lässt unter Legacy die Angriffsgeräusche weg, die 1.9 hinzufügte (stark, schwach, Schwung, kritisch …). Verletzungsgeräusche bleiben.",
|
||||
"horizon.pvp.combat_rules": "1.8-Kampf in Welten, die du hostest",
|
||||
"horizon.pvp.combat_rules.tooltip": "Einzelspieler und die Welten, die du im LAN öffnest, kämpfen nach den Regeln von 1.8.9: keine Angriffsabklingzeit oder Schwungangriff, 1.8-Waffenschaden, Stärke, Schärfe und kritische Treffer. Server, denen du beitrittst, behalten ihre eigenen Regeln.",
|
||||
"Bindings": "Belegungen",
|
||||
"Play": "Spielen",
|
||||
"Device": "Gerät",
|
||||
"Look": "Blick",
|
||||
"Movement": "Bewegung",
|
||||
"Menus": "Menüs",
|
||||
"Actions": "Aktionen",
|
||||
"Reset": "Zurücksetzen",
|
||||
"Detected": "Erkannt",
|
||||
"Glyphs": "Symbole",
|
||||
"Feedback": "Feedback",
|
||||
"Controller": "Controller",
|
||||
"How the sticks turn and move you, and how menus repeat and scroll.": "Wie die Sticks dich drehen und bewegen und wie Menüs wiederholen und scrollen.",
|
||||
"Which button does what. Each player has their own.": "Welche Taste was tut. Jeder Spieler hat seine eigenen.",
|
||||
"The controller the game sees, its button glyphs and its feedback.": "Der Controller, den das Spiel sieht, seine Tastensymbole und sein Feedback.",
|
||||
"Press this row, then the button that should do it.": "Drücke diese Zeile und dann die Taste, die es tun soll.",
|
||||
"Off, the game ignores every controller for this player.": "Aus ignoriert das Spiel jeden Controller für diesen Spieler.",
|
||||
"How fast the right stick turns the view.": "Wie schnell der rechte Stick die Ansicht dreht.",
|
||||
"Turn the view the other way, left and right.": "Dreht die Ansicht andersherum, links und rechts.",
|
||||
"Turn the view the other way, up and down.": "Dreht die Ansicht andersherum, hoch und runter.",
|
||||
"How far the right stick moves before the view turns. Raise it if the view drifts.": "Wie weit sich der rechte Stick bewegt, bevor sich die Ansicht dreht. Erhöhe ihn, wenn die Ansicht von selbst wandert.",
|
||||
"Hold the button to sneak, or press it once to start and again to stop.": "Halte die Taste zum Schleichen oder drücke sie einmal zum Starten und erneut zum Aufhören.",
|
||||
"How far the left stick moves before you walk. Raise it if you drift.": "Wie weit sich der linke Stick bewegt, bevor du gehst. Erhöhe ihn, wenn du wanderst.",
|
||||
"How far a trigger is pulled before it counts as pressed.": "Wie weit ein Trigger gezogen wird, bevor er als gedrückt gilt.",
|
||||
"Shows what your buttons do right now, in play and in inventories.": "Zeigt, was deine Tasten gerade tun, im Spiel und in Inventaren.",
|
||||
"Shows only what you can do with what you're looking at, and the main inventory buttons.": "Zeigt nur, was du mit dem tun kannst, was du ansiehst, und die wichtigsten Inventartasten.",
|
||||
"Doesn't show what your buttons do.": "Zeigt nicht, was deine Tasten tun.",
|
||||
"Shows the buttons of the controller you're using.": "Zeigt die Tasten des Controllers, den du verwendest.",
|
||||
"Always shows Xbox buttons.": "Zeigt immer Xbox-Tasten.",
|
||||
"Always shows PlayStation buttons.": "Zeigt immer PlayStation-Tasten.",
|
||||
"Always shows Nintendo buttons.": "Zeigt immer Nintendo-Tasten.",
|
||||
"A, on the right, confirms and jumps and B goes back, as on a Switch.": "A rechts bestätigt und springt, B geht zurück, wie auf einer Switch.",
|
||||
"B, at the bottom, confirms and jumps and A goes back, as on other controllers.": "B unten bestätigt und springt, A geht zurück, wie auf anderen Controllern.",
|
||||
"Opens Steam's keyboard on a Steam Deck under Steam, and Horizon's otherwise.": "Öffnet auf einem Steam Deck unter Steam die Tastatur von Steam, sonst die von Horizon.",
|
||||
"Always opens Horizon's keyboard.": "Öffnet immer die Tastatur von Horizon.",
|
||||
"Always opens Steam's keyboard.": "Öffnet immer die Tastatur von Steam.",
|
||||
"A DualSense's light shows your health, from green through amber to red.": "Das Licht eines DualSense zeigt deine Gesundheit, von Grün über Bernstein bis Rot.",
|
||||
"A DualSense's light changes with the dimension you're in.": "Das Licht eines DualSense ändert sich mit der Dimension, in der du bist.",
|
||||
"A DualSense keeps its own light.": "Ein DualSense behält sein eigenes Licht.",
|
||||
"Lets LT + A craft on servers other than your own worlds. Some servers count it as a macro.": "Erlaubt Handwerken mit LT + A auf Servern, nicht nur in deinen eigenen Welten. Manche Server werten es als Makro.",
|
||||
"Show the buttons for what you can do, while you play with a controller.": "Zeigt die Tasten für das, was du tun kannst, während du mit einem Controller spielst.",
|
||||
"How fast the stick moves the crosshair in inventories.": "Wie schnell der Stick das Fadenkreuz in Inventaren bewegt.",
|
||||
"How fast the right stick scrolls a list.": "Wie schnell der rechte Stick eine Liste scrollt.",
|
||||
"How long a held direction waits before it repeats.": "Wie lange eine gehaltene Richtung wartet, bevor sie sich wiederholt.",
|
||||
"How long a held direction takes between repeats.": "Wie lange eine gehaltene Richtung zwischen Wiederholungen braucht.",
|
||||
"Which on-screen keyboard opens when a text field is pressed.": "Welche Bildschirmtastatur sich öffnet, wenn ein Textfeld gedrückt wird.",
|
||||
"Every action back on its default button.": "Jede Aktion zurück auf ihre Standardtaste.",
|
||||
"These settings belong to this player's profile.": "Diese Einstellungen gehören zum Profil dieses Spielers.",
|
||||
"The controller driving this player, or else the first one connected.": "Der Controller, der diesen Spieler steuert, sonst der zuerst verbundene.",
|
||||
"The USB vendor and product the system reports. Useful when reporting a controller that misbehaves.": "USB-Hersteller und -Produkt, die das System meldet. Nützlich, wenn du einen Controller meldest, der sich falsch verhält.",
|
||||
"The glyph family the game picked from the controller's maker.": "Die Symbolfamilie, die das Spiel anhand des Herstellers des Controllers gewählt hat.",
|
||||
"Follow the controller, or always show one family's buttons.": "Dem Controller folgen oder immer die Tasten einer Familie zeigen.",
|
||||
"Whether a Nintendo controller's A and B work by where they sit or by their letters.": "Ob A und B eines Nintendo-Controllers nach ihrer Position oder nach ihren Buchstaben arbeiten.",
|
||||
"The buttons menus show right now.": "Die Tasten, die Menüs gerade zeigen.",
|
||||
"Rumble when something happens to you.": "Vibriert, wenn dir etwas passiert.",
|
||||
"The controller keeps playing while this window isn't focused, and losing focus won't pause.": "Der Controller spielt weiter, während dieses Fenster nicht im Fokus ist, und der Fokusverlust pausiert nicht.",
|
||||
"A DualSense's triggers push back on bows and tools.": "Die Trigger eines DualSense drücken bei Bogen und Werkzeugen dagegen.",
|
||||
"What a DualSense's light shows, over USB or Bluetooth.": "Was das Licht eines DualSense über USB oder Bluetooth zeigt.",
|
||||
"Player profile": "Spielerprofil",
|
||||
"Vendor and product": "Hersteller und Produkt",
|
||||
"Detected glyphs": "Erkannte Symbole",
|
||||
"Glyphs in use": "Verwendete Symbole",
|
||||
"Unknown": "Unbekannt",
|
||||
"No controller": "Kein Controller",
|
||||
"Editing: %s": "Bearbeiten: %s",
|
||||
"Press the button for this. %s cancels.": "Drücke die Taste dafür. %s bricht ab.",
|
||||
"This button also does: %s": "Diese Taste tut auch: %s",
|
||||
"horizon.pad.notice.connected.press": "Drücken zum Verwenden",
|
||||
"horizon.recipe_transfer.missing": "Fehlende Gegenstände",
|
||||
"horizon.recipe_transfer.no_room": "Inventar voll",
|
||||
"horizon.recipe_transfer.unavailable": "Rezept passt nicht in dieses Menü",
|
||||
"horizon.recipe_transfer.unsupported": "Server erfordert ein bekanntes Rezept",
|
||||
"horizon.studio.timeline.freecam_on": "Freie Kamera: EIN",
|
||||
"horizon.studio.timeline.freecam_off": "Freie Kamera: AUS",
|
||||
"horizon.studio.timeline.freecam_short": "Cam",
|
||||
|
||||
@@ -142,9 +142,7 @@
|
||||
"Size of menus and the HUD.": "Το μέγεθος των μενού και το HUD.",
|
||||
"Sets the options below together. Changing one makes it Custom.": "Ρυθμίζει τις παρακάτω επιλογές μαζί. Η αλλαγή ενός το καθιστά Προσαρμοσμένο.",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Η λάβα, η φωτιά και το φως του ήλιου λάμπουν πιο φωτεινά από το λευκό. Χρειάζεται οθόνη HDR.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Η οθόνη σας είναι το πιο σκούρο μαύρο. 0 για OLED? Οι LCD λάμπουν λίγο.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "Πόσο φωτεινά σημεία αποκτούν. Auto: η κορύφωση που αναφέρει το σύστημά σας.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "Πόσο φωτεινό φαίνεται το λευκό χαρτί και τα μενού. Αυτόματο: η επιφάνεια εργασίας σας είναι λευκή.",
|
||||
"How bright the HUD and menus are, against paper white.": "Πόσο φωτεινά είναι τα HUD και τα μενού, σε σχέση με το λευκό χαρτί.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Πόσο μακριά συνεχίζουν να λειτουργούν όχλοι, καλλιέργειες και μηχανές. Χρόνος CPU.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Πόσο μακριά κληρώνονται όχλοι και αντικείμενα, ως ποσοστό του κανονικού.",
|
||||
|
||||
@@ -307,9 +307,9 @@
|
||||
"Size of menus and the HUD.": "Size of menus and the HUD.",
|
||||
"Sets the options below together. Changing one makes it Custom.": "Sets the options below together. Changing one makes it Custom.",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lava, fire and sunlight shine brighter than white. Needs an HDR display.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Your display's darkest black. 0 for OLED; LCDs glow a little.",
|
||||
"Lifts shadows onto your display's darkest black. 0: true black, no lift.": "Lifts shadows onto your display's darkest black. 0: true black, no lift.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "How bright highlights get. Auto: the peak your system reports.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "How bright white paper and menus look. Auto: your desktop's white.",
|
||||
"How bright white looks in the world. Auto: your desktop's white.": "How bright white looks in the world. Auto: your desktop's white.",
|
||||
"How bright the HUD and menus are, against paper white.": "How bright the HUD and menus are, against paper white.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "How far mobs, crops and machines keep working. CPU time.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "How far away mobs and items are drawn, as a share of normal.",
|
||||
@@ -689,6 +689,17 @@
|
||||
"horizon.pack.filter.x32": "32x",
|
||||
"horizon.pack.filter.x64": "64x",
|
||||
"horizon.pack.addFailed": "The pack could not be added: %s",
|
||||
"horizon.pack.options": "Pack Options",
|
||||
"horizon.pack.optionsInfo": "Options of this pack",
|
||||
"horizon.pack.options.title": "Pack Options",
|
||||
"horizon.pack.options.general": "General",
|
||||
"horizon.pack.options.presets": "Presets",
|
||||
"horizon.pack.options.presets.custom": "Custom",
|
||||
"horizon.pack.options.impact": "Performance impact: %s",
|
||||
"horizon.pack.options.impact.low": "low",
|
||||
"horizon.pack.options.impact.medium": "medium",
|
||||
"horizon.pack.options.impact.high": "high",
|
||||
"horizon.pack.options.impact.varies": "varies",
|
||||
"horizon.extras.header.cosmetics": "Cosmetics",
|
||||
"horizon.extras.cosmetics_mine": "My cosmetics",
|
||||
"horizon.extras.cosmetics_mine.tooltip": "Hidden: every Horizon player sees you without your cosmetics, you included. The same switch as the launcher's Wardrobe.",
|
||||
|
||||
@@ -147,9 +147,7 @@
|
||||
"Size of menus and the HUD.": "Grandeco de menuoj kaj la HUD.",
|
||||
"Sets the options below together. Changing one makes it Custom.": "Kunigas la eblojn sube. Ŝanĝi unu faras ĝin Propra.",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lafo, fajro kaj sunlumo brilas pli hele ol blanka. Bezonas HDR-ekranon.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Via ekrano estas plej malhela nigra. 0 por OLED; LCD-oj brilas iomete.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "Kiel brilaj brilaĵoj ricevas. Aŭtomata: la pinto kiun via sistemo raportas.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "Kiel brile blankaj paperoj kaj menuoj aspektas. Aŭtomata: via labortablo estas blanka.",
|
||||
"How bright the HUD and menus are, against paper white.": "Kiel brilaj la HUD kaj menuoj estas, kontraŭ papera blanka.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Kiom malproksime amasoj, kultivaĵoj kaj maŝinoj daŭre funkcias. CPU-tempo.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Kiom malproksime amasoj kaj eroj estas tiritaj, kiel parto de normalo.",
|
||||
|
||||
Loaded 100 of 556 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user