mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 02:29:50 -10:00
Compare commits
897
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 | ||
|
|
b6d002a723 | ||
|
|
85ca681ff1 | ||
|
|
22287141a5 | ||
|
|
6aa8930d3f | ||
|
|
f4262df3a2 | ||
|
|
9a4ac94b73 | ||
|
|
09d0f1e1d8 | ||
|
|
efaa1660e9 | ||
|
|
f39318eeb0 | ||
|
|
d40955d303 | ||
|
|
d398fbc8b0 | ||
|
|
39af844866 | ||
|
|
81337c3a2f | ||
|
|
3192adafe0 | ||
|
|
a6705ea839 | ||
|
|
d9b3005690 | ||
|
|
eef19d5cf9 | ||
|
|
1eb93f5884 | ||
|
|
4bc55da08d | ||
|
|
dcd90c2b1f | ||
|
|
41a72c6eda | ||
|
|
3ae606abce | ||
|
|
10eb936805 | ||
|
|
e02a4bf0d3 | ||
|
|
c6292de19c | ||
|
|
29ac896b4c | ||
|
|
802a858cc1 | ||
|
|
753594d0a0 | ||
|
|
341675a110 | ||
|
|
5f86c3c505 | ||
|
|
7df108a7be | ||
|
|
f9eb983ba1 | ||
|
|
ec1b4198fe | ||
|
|
e13a206334 | ||
|
|
2ba45c262d | ||
|
|
0be41c06be | ||
|
|
3e4cfbe7ea | ||
|
|
5da4b13265 | ||
|
|
110e686f4d | ||
|
|
646572e69b | ||
|
|
81403ce81c | ||
|
|
3f87ea235e | ||
|
|
b65ebc85e2 | ||
|
|
0d7046557d | ||
|
|
2c32f22313 | ||
|
|
2620806a43 | ||
|
|
d956b41093 | ||
|
|
9253f573d8 | ||
|
|
4a53264476 | ||
|
|
64b7852431 | ||
|
|
cc4abd50ea | ||
|
|
e676b097eb | ||
|
|
1432581c19 | ||
|
|
adf39cbeb2 | ||
|
|
a8d74ca97b | ||
|
|
c67f07bbdb | ||
|
|
fe74121c5b | ||
|
|
8295846dfd | ||
|
|
d170e4f674 | ||
|
|
bde1cc63c5 | ||
|
|
0b5b68bdd2 | ||
|
|
4d6f818c3a | ||
|
|
b1d131e6e3 | ||
|
|
582dc76fbd | ||
|
|
45ffc8628d | ||
|
|
889f3eddfe | ||
|
|
39d6f25e58 | ||
|
|
4b9cc3eeae | ||
|
|
7372b5842e | ||
|
|
8455585a36 | ||
|
|
98b205d5de | ||
|
|
f2d12697f6 | ||
|
|
11b31e9c09 | ||
|
|
af902896d4 | ||
|
|
4d855f9ac0 | ||
|
|
106eec903f | ||
|
|
43f632aec8 | ||
|
|
b0cf285a76 | ||
|
|
bba6a462e0 | ||
|
|
90d6546eca | ||
|
|
480c65877f | ||
|
|
3498cc6165 | ||
|
|
5096ebc1dd | ||
|
|
b00506bc0e | ||
|
|
4a3058d4ad | ||
|
|
be5c7e2fe2 | ||
|
|
93a3d53b3e | ||
|
|
82ccc27493 | ||
|
|
5adf98a327 | ||
|
|
77f967a80e | ||
|
|
5146537ec6 | ||
|
|
4489732c79 | ||
|
|
68bb5be687 | ||
|
|
0a63e373ad | ||
|
|
291a28fc99 | ||
|
|
70d3441c4a | ||
|
|
54b6232191 | ||
|
|
aea460736c | ||
|
|
af256dcc40 | ||
|
|
e54d91dc0c | ||
|
|
c57bf04727 | ||
|
|
38afa7e7d4 | ||
|
|
d7ff6beecf | ||
|
|
c99bd1a44d | ||
|
|
e81ac01453 | ||
|
|
31fe010646 | ||
|
|
adc19a8a07 | ||
|
|
ba43b0c40f | ||
|
|
26ff2b5e34 | ||
|
|
3639259761 | ||
|
|
c5d0a1c935 | ||
|
|
26415620ea | ||
|
|
ff4b01cd07 | ||
|
|
559baffb0c | ||
|
|
e52114437e | ||
|
|
e0c325ca5a | ||
|
|
93f178d4dd | ||
|
|
4552a50849 | ||
|
|
15583061c1 | ||
|
|
149e80cff5 | ||
|
|
e81f9ad646 | ||
|
|
1645a251ec | ||
|
|
b15db5c782 | ||
|
|
fffa3bf6b8 | ||
|
|
e1d257e6f1 | ||
|
|
f70686be76 | ||
|
|
fe70a48402 | ||
|
|
67ebf35e57 | ||
|
|
1406684c20 | ||
|
|
4b4dca948c | ||
|
|
b2a457fdef | ||
|
|
d9eda46d06 | ||
|
|
1f149381f8 | ||
|
|
02bbcbd466 | ||
|
|
e0d14e4edf | ||
|
|
b4b6eefcfb | ||
|
|
6f31c8d6d1 | ||
|
|
545cb15f18 | ||
|
|
e2c9f5dd08 | ||
|
|
389a495f09 | ||
|
|
7f2fdba6ba | ||
|
|
7f63f42e27 | ||
|
|
91879dc898 | ||
|
|
4dd5d0ce71 | ||
|
|
b0bb5d2a9e | ||
|
|
12c5dc6169 | ||
|
|
860355f55e | ||
|
|
3233aaabe9 | ||
|
|
3ef68721a8 | ||
|
|
8d8ef94673 | ||
|
|
ae487a0658 | ||
|
|
d1e15acd9b | ||
|
|
f9374b154b | ||
|
|
009baf97ad | ||
|
|
e7cd3c4c6c | ||
|
|
3fd4fed964 | ||
|
|
9f8a8ac0e6 | ||
|
|
539933e0a1 | ||
|
|
bfd5c69005 | ||
|
|
54cae6c3ef | ||
|
|
cf23b0da77 | ||
|
|
0ce693d743 | ||
|
|
e5bbad5f2f | ||
|
|
c052c5523a | ||
|
|
5d5e00f783 | ||
|
|
f7e6433729 | ||
|
|
2d4a338796 | ||
|
|
48ec2657df | ||
|
|
a05497bbcd | ||
|
|
40d0364acc | ||
|
|
ce99d8445d | ||
|
|
b5723fd33d | ||
|
|
09ccb39c4f | ||
|
|
5273939a30 | ||
|
|
ef1bbc2c19 | ||
|
|
68f4708d84 | ||
|
|
aa3819d4a5 | ||
|
|
3316c45f44 | ||
|
|
36896744bf | ||
|
|
dc22fb4396 | ||
|
|
f7a1f50728 | ||
|
|
f4aa6beb4a | ||
|
|
8b65ee2372 | ||
|
|
f523a60066 | ||
|
|
27d7fdd859 | ||
|
|
a65582a876 | ||
|
|
34b0265744 | ||
|
|
abcbf3739d | ||
|
|
45bcdca495 | ||
|
|
4e724e319b | ||
|
|
6b2fadfcd5 | ||
|
|
96f1fb36a2 | ||
|
|
2e258065d7 | ||
|
|
0256b28b72 | ||
|
|
8200d52bdd | ||
|
|
ce61a0e1de | ||
|
|
6dbd14d78e | ||
|
|
9791592034 | ||
|
|
afd3e48ccd | ||
|
|
56dbf356a0 | ||
|
|
478e7b45bd | ||
|
|
b7fe922e7c | ||
|
|
db32617271 | ||
|
|
ad7effc349 | ||
|
|
61ea2cbca0 | ||
|
|
473bd8eb0e | ||
|
|
9d911fbe3e | ||
|
|
0299f2fe2f | ||
|
|
a145d469c6 | ||
|
|
0ec0dbb2f4 | ||
|
|
c5e3ef940b | ||
|
|
b83ab5e01d | ||
|
|
5f7cadfff0 | ||
|
|
e8a1e5b997 | ||
|
|
acd32b8d31 | ||
|
|
74dd0ed4c3 | ||
|
|
7ab41043fa | ||
|
|
8695ecb3b4 | ||
|
|
074be79a6a | ||
|
|
08ed0dcb83 | ||
|
|
b080361402 | ||
|
|
6aa575f7d1 | ||
|
|
65d988b030 | ||
|
|
a4bf12b2da | ||
|
|
42635ff154 | ||
|
|
79a2d9d554 | ||
|
|
99bd965511 | ||
|
|
0d45709554 | ||
|
|
936a48fe4b | ||
|
|
afc1d6a0e0 | ||
|
|
1f9719f9b2 | ||
|
|
42cfe492f3 | ||
|
|
226c1ceeef | ||
|
|
21a5d735ea | ||
|
|
0e525f3952 | ||
|
|
e5f945dc55 | ||
|
|
00299919d9 | ||
|
|
5661b09d71 | ||
|
|
40c12c20e0 | ||
|
|
2e2dd80a9b | ||
|
|
a7c004b6ac | ||
|
|
4d6f0a1c4a | ||
|
|
bd13d0f39c | ||
|
|
468ab204c7 | ||
|
|
aa208521eb | ||
|
|
92c1eb1b76 | ||
|
|
408d193d01 | ||
|
|
9f903d2b3f | ||
|
|
e0615ba966 | ||
|
|
ad183565d8 | ||
|
|
ab1f82cf2b | ||
|
|
e6bc883aac | ||
|
|
d8a25fcb16 | ||
|
|
228fbb7d99 | ||
|
|
70c8fe35a2 | ||
|
|
4bcfbe6eb3 | ||
|
|
28722235de | ||
|
|
3f71bd7deb | ||
|
|
3b3ed4b217 | ||
|
|
a0747ddd25 | ||
|
|
a3770664c2 | ||
|
|
f03f847d4f | ||
|
|
75b5ac8ec1 | ||
|
|
48683b1757 | ||
|
|
f720c33d24 | ||
|
|
41d5e253ca | ||
|
|
3bb3899088 | ||
|
|
b5249678a2 | ||
|
|
7b8eb4e6f4 | ||
|
|
2ffc0b65ad | ||
|
|
0ae809ff1a | ||
|
|
3a5555a05e | ||
|
|
b51c4fd973 | ||
|
|
2a33624c1e | ||
|
|
9bbcb50aed | ||
|
|
408e4fda1c | ||
|
|
4cc2d109bb | ||
|
|
7b92e8a8aa | ||
|
|
e7052b5cf9 | ||
|
|
bcd3d93200 | ||
|
|
0395b7220e | ||
|
|
dd6d2d09a2 | ||
|
|
dd0a1e8c39 | ||
|
|
84f8a89fce | ||
|
|
bab9bd9eec | ||
|
|
f511bc7488 | ||
|
|
43f69216d3 | ||
|
|
9bde691abc | ||
|
|
2ece11744d | ||
|
|
12b51783dd | ||
|
|
d9a8fb0667 | ||
|
|
cc1825d017 | ||
|
|
cadfdbcfb1 | ||
|
|
425652fce4 | ||
|
|
6651840801 | ||
|
|
2d75ab2e8f | ||
|
|
953512487b | ||
|
|
bb6e95d5fe | ||
|
|
a9307ea8b7 | ||
|
|
6bf6b1e2c8 | ||
|
|
33f1f52d62 | ||
|
|
5ad154d184 | ||
|
|
243066eeca | ||
|
|
08528c929d | ||
|
|
2863d031b9 | ||
|
|
f780545a63 | ||
|
|
adba2e3fd0 | ||
|
|
39f398091b | ||
|
|
e2c5f32ad4 | ||
|
|
6c6aa33d33 | ||
|
|
4aeaf7d69e | ||
|
|
3ad431c6a3 | ||
|
|
63db5cdd81 | ||
|
|
dd2aa4c808 | ||
|
|
cd225f801e | ||
|
|
7344d153b0 | ||
|
|
88b8be3eea | ||
|
|
3417c795ec | ||
|
|
2d708acee1 | ||
|
|
81caf4c301 | ||
|
|
efbd55185e | ||
|
|
38a989a235 | ||
|
|
b68899faad | ||
|
|
0618cea36e | ||
|
|
4803ac509a | ||
|
|
5919b2ee37 | ||
|
|
a3ea52c7ae | ||
|
|
f606cb820c | ||
|
|
76ef5f41ee | ||
|
|
645e685dc3 | ||
|
|
b5fd86ea95 | ||
|
|
5b01bbb736 | ||
|
|
d86f9b8ae1 | ||
|
|
5c90dcf106 | ||
|
|
6a9479bd4e | ||
|
|
f41546e06e | ||
|
|
d543e34821 | ||
|
|
bdc89c7247 | ||
|
|
8beaf4b572 | ||
|
|
652bb95f02 | ||
|
|
785b64e2ea | ||
|
|
e905e18569 | ||
|
|
56777de07b | ||
|
|
12da8060d9 | ||
|
|
42eecc5c6b | ||
|
|
1fb2b9d0e7 | ||
|
|
02756b92c4 | ||
|
|
3ae016d664 | ||
|
|
789b488061 | ||
|
|
d491259de9 | ||
|
|
22f64bbd46 | ||
|
|
81c62c63a3 | ||
|
|
14a479c392 | ||
|
|
ded1909d91 | ||
|
|
92a19421d2 | ||
|
|
e17f52b8c2 | ||
|
|
dc881c302a | ||
|
|
0e17b03481 | ||
|
|
de76c6cfc6 | ||
|
|
16d1d18ef1 | ||
|
|
7f4a2fe8a0 | ||
|
|
36a9541fb7 | ||
|
|
e3d4a12cf4 | ||
|
|
edefee0edb | ||
|
|
0ed839b8a1 | ||
|
|
1a7b54a71b | ||
|
|
af30750fe9 | ||
|
|
fdaf42a7ae | ||
|
|
dd3bac8217 | ||
|
|
b40792ba62 | ||
|
|
24e1f1b376 | ||
|
|
e39af976c9 | ||
|
|
eca3a71c8c | ||
|
|
ef2142e5a6 | ||
|
|
61aa03d1ab | ||
|
|
d0da18c651 | ||
|
|
5717940f26 | ||
|
|
a8a83423be | ||
|
|
75d834b5f0 | ||
|
|
c98a51a664 | ||
|
|
9d55121379 | ||
|
|
3a90aecb12 | ||
|
|
524543dfa8 | ||
|
|
53a993c972 | ||
|
|
36c8983d68 | ||
|
|
39c43b82bd | ||
|
|
0b0aded17c | ||
|
|
d978845087 | ||
|
|
957eb14a5e | ||
|
|
601772b4fa | ||
|
|
6a61fdbe28 | ||
|
|
be8b04774f | ||
|
|
0698e8d46d | ||
|
|
9afeda6be3 | ||
|
|
89bb9243ab | ||
|
|
981b36ef36 | ||
|
|
c3f2a9928e | ||
|
|
5a8e4ea92c | ||
|
|
3427559f65 | ||
|
|
ecf510cfc2 | ||
|
|
8201422dc0 | ||
|
|
dec609bb7c | ||
|
|
d67da2cf27 | ||
|
|
4aa63389e5 | ||
|
|
92be6a71c2 | ||
|
|
f1d2c12e29 | ||
|
|
4e5296d180 | ||
|
|
d6bce553cc | ||
|
|
085e9f6064 | ||
|
|
bf7ec63e8e | ||
|
|
2d5d714788 | ||
|
|
8b55208237 | ||
|
|
a4e0fc8a75 | ||
|
|
854ab1726c | ||
|
|
3254251593 | ||
|
|
e6c80be2c4 | ||
|
|
fc633db30b | ||
|
|
2bb5bb2965 | ||
|
|
5088717918 | ||
|
|
b8e5247b35 | ||
|
|
711dad1923 | ||
|
|
7bdd8c9ae1 | ||
|
|
39e6679121 | ||
|
|
cd18425b4a | ||
|
|
c01554de38 | ||
|
|
2f86a8c15f | ||
|
|
31f22dbe88 | ||
|
|
67d42261fc | ||
|
|
5f94268324 | ||
|
|
eb83113dbb | ||
|
|
1522c80b8f | ||
|
|
07c9753b76 | ||
|
|
5d97cf0158 | ||
|
|
b79fd522cb | ||
|
|
5f5b78fb47 | ||
|
|
7983faa6ce | ||
|
|
36f20dd0aa | ||
|
|
de5617664b | ||
|
|
e9d179e183 | ||
|
|
e790fdeead | ||
|
|
7e78e51558 | ||
|
|
ee2e77db35 | ||
|
|
c0e2fb0c99 | ||
|
|
4442aaed80 | ||
|
|
d2c801b26f | ||
|
|
cca1842d3c | ||
|
|
2e237b9aa1 | ||
|
|
9b0a0d56a9 | ||
|
|
2bac359308 | ||
|
|
c2aaa3d39f | ||
|
|
ef65ffc544 | ||
|
|
c7739935c3 | ||
|
|
a33d9e37c8 | ||
|
|
f496fff3af | ||
|
|
a36ab31067 | ||
|
|
6c68f82884 | ||
|
|
def33e3892 | ||
|
|
01ecc1c7b5 | ||
|
|
facc7f17ea | ||
|
|
0aebe90bbc | ||
|
|
c73b892e42 | ||
|
|
69193a3635 | ||
|
|
fb350ecfc4 | ||
|
|
3b7a7a9ef3 | ||
|
|
4273e513c0 | ||
|
|
c288428168 | ||
|
|
fa99fc98e6 | ||
|
|
5cc05d62f4 | ||
|
|
0043afcd0c | ||
|
|
5883c0a485 | ||
|
|
66ff309502 | ||
|
|
b89066f4ac | ||
|
|
cfeab5a7e9 | ||
|
|
f9e8af6a94 | ||
|
|
42cad38648 | ||
|
|
86b883ab33 | ||
|
|
8a0ec928fa | ||
|
|
dce5e62714 | ||
|
|
1c1ad8ec32 | ||
|
|
f102160191 | ||
|
|
125b46829c | ||
|
|
5845d88a53 | ||
|
|
02d9ee7962 | ||
|
|
8a197bf691 | ||
|
|
34897bdcd3 | ||
|
|
dc5420e82e | ||
|
|
9850f641b5 | ||
|
|
652be13517 | ||
|
|
e1294dbaf6 | ||
|
|
381c191dc1 | ||
|
|
1241a139e9 | ||
|
|
b5aa2303fb | ||
|
|
616ba9f131 | ||
|
|
32715a15da | ||
|
|
8480ab53d5 | ||
|
|
3fa4cbd9a8 | ||
|
|
f2719daa05 | ||
|
|
05a9ed3057 | ||
|
|
e9aa68c3ed | ||
|
|
8b4a95b099 | ||
|
|
a978363647 | ||
|
|
c853168fb7 | ||
|
|
861ac2b28d | ||
|
|
4dd40471f8 | ||
|
|
dcc73b1491 | ||
|
|
518adb688e | ||
|
|
a407637998 | ||
|
|
56b95a98b9 | ||
|
|
837f0410ea | ||
|
|
7a065ec02b | ||
|
|
df7f146ed9 | ||
|
|
b5dff95be8 | ||
|
|
1d29778ef7 | ||
|
|
01b4e8b995 | ||
|
|
a75f3169eb | ||
|
|
753477f5b4 | ||
|
|
3dcb5cba92 | ||
|
|
37db8a337e | ||
|
|
5547077993 | ||
|
|
79fe52def4 | ||
|
|
bb9e6d6ebf | ||
|
|
2aa319becc | ||
|
|
5c591b13a4 | ||
|
|
137486c83a | ||
|
|
d2cb5a272b |
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.
|
||||
@@ -25,7 +36,8 @@
|
||||
|
||||
<!-- "none", or for each project you read or copied from: its link at the commit, its license (only MIT,
|
||||
BSD-2-Clause, BSD-3-Clause, Apache-2.0, ISC, zlib, 0BSD, MIT-0, Unlicense or CC0-1.0), and what you took.
|
||||
CONTRIBUTING.md "Only original code". -->
|
||||
CONTRIBUTING.md "Only original code". Shader packs: also the sections of docs/cleanroom/shader-packs-spec.md,
|
||||
the documentation pages and the packs you used. -->
|
||||
|
||||
## Self-check
|
||||
|
||||
|
||||
@@ -13,4 +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
|
||||
|
||||
@@ -291,6 +291,11 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo run --locked -p xtask -- package "$BIN/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
|
||||
# Windows x64: Microsoft's DXC, pinned and checksummed, which DirectX 12 uses on integrated GPUs
|
||||
# (gfx/dx12_compiler.rs), with its licence. ARM64 keeps FXC (THIRD_PARTY_NOTICES.md says why).
|
||||
if [[ "${{ matrix.name }}" == windows ]]; then
|
||||
python tools/ci/fetch_dxc.py "dist/${{ matrix.dir }}"
|
||||
fi
|
||||
cd dist
|
||||
rm -f "${{ matrix.asset }}"
|
||||
case "${{ matrix.asset }}" in
|
||||
|
||||
@@ -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`, `graphics_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)), `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` |
|
||||
|
||||
+37
-2
@@ -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/`):
|
||||
@@ -76,7 +86,18 @@ another project's source may also be read and used, **only when that project's l
|
||||
|
||||
User documentation and file formats are fine to read, and packs and mods are fine to run and observe, whatever their
|
||||
license. Shader packs are a clean-room rewrite of the pack format, produced from a written description of it (see
|
||||
#1561); they carry no foreign source, and the rules above still apply to anything you read while working on them.
|
||||
#1561): the [specification and its provenance log](docs/cleanroom/README.md) are public. They carry no foreign
|
||||
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
|
||||
|
||||
@@ -114,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
|
||||
@@ -137,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)
|
||||
|
||||
|
||||
+28
-1
@@ -63,6 +63,32 @@ variable's array specifier does not change later variables.
|
||||
It also reads a struct member outside a constant expression through a pointer to the member,
|
||||
not out of a loaded copy of the whole struct, so a member an atomic function uses can be read.
|
||||
|
||||
## DirectX Shader Compiler
|
||||
|
||||
The Windows x64 game folder holds `dxcompiler.dll`, Microsoft's DirectX Shader Compiler
|
||||
[v1.9.2609](https://github.com/microsoft/DirectXShaderCompiler/releases/tag/v1.9.2609), unmodified: the
|
||||
`bin/x64/dxcompiler.dll` of `dxc_2026_09_29.zip` (zip SHA-256
|
||||
`ad31b1fc8443175d204f77a611fdb3ef2ec42759bdc2f1167368de24a4a7e7f1`, DLL SHA-256
|
||||
`0689e2fee0a1c9df8662a59680469f24e2a3ad7ce6bbbcc9284f7a83172bfc59`, 22,274,872 bytes). The release build downloads
|
||||
it and checks both checksums (`tools/ci/fetch_dxc.py`); it is not in this repository. DirectX 12 loads it only on
|
||||
integrated GPUs (`crates/horizon-client-render/src/gfx/dx12_compiler.rs`,
|
||||
[#8513](https://github.com/horizon-rust/horizon/issues/8513)); every other GPU compiles with FXC, part of Windows.
|
||||
|
||||
Copyright (c) Microsoft Corporation. It is not covered by Horizon's MIT license. It is distributed under the
|
||||
"Microsoft Software License Terms, Microsoft DirectX Shader Compiler" (`LICENSE-MS.txt` of that release). Its
|
||||
Distributable Code terms are the Windows SDK's: end users get terms that protect the code and Microsoft at least as
|
||||
much, and the distributor indemnifies Microsoft. The project accepted them on 2026-11-08. Its open-source part is also under the University of Illinois/NCSA license (`LICENSE-LLVM.txt`). Both
|
||||
files ship beside the DLL as `DXC-LICENSE-MS.txt` and `DXC-LICENSE-LLVM.txt`, with `DXC-NOTICE.txt`
|
||||
(`packaging/windows/`), which tells players those terms apply and that deleting the DLL brings back FXC.
|
||||
|
||||
- Only x64: the Windows SDK's list of distributable files
|
||||
([REDIST.TXT lists](https://learn.microsoft.com/en-us/legal/windows-sdk/redist), updated 2024-10-21) names
|
||||
`Redist\D3D\x64\dxcompiler.dll` and `dxil.dll` and their x86 copies, for classic Windows applications (Horizon is
|
||||
one), and no ARM64 copy. The release zip's own `LICENSE-MS.txt` refers to a list of distributable files that the zip
|
||||
doesn't contain, so the SDK's list is the one followed. The Windows ARM64 build ships no DXC and compiles with FXC.
|
||||
- No `dxil.dll`: since DXC 1.8.2505 the compiler signs shaders with its own validator and no longer loads it, and
|
||||
wgpu never does.
|
||||
|
||||
## libmimalloc-sys and mimalloc
|
||||
|
||||
`third_party/libmimalloc-sys` is [libmimalloc-sys](https://github.com/purpleprotocol/mimalloc_rust)
|
||||
@@ -312,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";
|
||||
@@ -14,6 +14,8 @@ pub enum PistonPart {
|
||||
Entity,
|
||||
State,
|
||||
Properties,
|
||||
/// Any other block entity's whole compound (`BlockEntity.saveWithFullMetadata`).
|
||||
BlockEntity,
|
||||
}
|
||||
|
||||
struct Pretty {
|
||||
@@ -255,6 +257,20 @@ fn piston_hash_order(
|
||||
entries.sort_by_key(|(k, _)| (hash_bucket(c.len(), k), std::cmp::Reverse(*k)));
|
||||
}
|
||||
PistonPart::State => return java_hash_order(c),
|
||||
PistonPart::BlockEntity => {
|
||||
// saveAdditional's fields, then `components`, then saveMetadata's
|
||||
// id and position; the fields keep the default order among them.
|
||||
const METADATA: [&str; 5] = ["components", "id", "x", "y", "z"];
|
||||
let mut entries = java_hash_order(c);
|
||||
entries.sort_by_key(|(k, _)| {
|
||||
let key = string_of(k);
|
||||
(
|
||||
hash_bucket(c.len(), k),
|
||||
METADATA.iter().position(|m| *m == key).map_or(0, |i| i + 1),
|
||||
)
|
||||
});
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
entries
|
||||
}
|
||||
@@ -313,6 +329,14 @@ pub fn java_hash_order(
|
||||
"NoBasePlate",
|
||||
"Marker",
|
||||
"Pose",
|
||||
// Mannequin.addAdditionalSaveData.
|
||||
"profile",
|
||||
"hidden_layers",
|
||||
"main_hand",
|
||||
"pose",
|
||||
"immovable",
|
||||
"description",
|
||||
"hide_description",
|
||||
];
|
||||
// BlockEntity.saveMetadata writes the position after the entity's data.
|
||||
const AFTER: &[&str] = &["x", "y", "z"];
|
||||
@@ -397,6 +421,21 @@ mod tests {
|
||||
assert_eq!(pretty(&tag).plain(), ordinary);
|
||||
}
|
||||
#[test]
|
||||
fn mannequin_compound_orders_pose_before_immovable_in_shared_bucket() {
|
||||
let tag = parse_snbt(
|
||||
r#"{AbsorptionAmount:0.0f,Air:300s,Brain:{memories:{}},DeathTime:0s,FallFlying:0b,Fire:0s,Health:20.0f,HurtTime:0s,Invulnerable:0b,Motion:[0.0d,0.0d,0.0d],OnGround:0b,PortalCooldown:0,Pos:[2.5d,100.0d,2.5d],Rotation:[0.0f,0.0f],UUID:[I;369,0,0,1],attributes:[],current_impulse_context_reset_grace_time:0,description:{text:"Guard"},hide_description:1b,immovable:1b,pose:"crouching",profile:{name:"alex"}}"#,
|
||||
)
|
||||
.unwrap()
|
||||
.0;
|
||||
let rendered = pretty(&tag).plain();
|
||||
let pose_idx = rendered.find("pose: \"crouching\"").unwrap();
|
||||
let immovable_idx = rendered.find("immovable: 1b").unwrap();
|
||||
assert!(
|
||||
pose_idx < immovable_idx,
|
||||
"expected `pose` before `immovable` in bucket 5, got: {rendered}"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn compact_form_matches_vanilla() {
|
||||
assert_eq!(
|
||||
rt("{b:1b,a:\"x\",c:[1,2],d:1.5f,e:2d,f:3L,g:4s}"),
|
||||
@@ -441,4 +480,18 @@ mod tests {
|
||||
let long = parse_snbt("{l:[{a:1},{b:2}]}").unwrap().0;
|
||||
assert_eq!(pretty(&long).plain(), "{l: [{a: 1}, {b: 2}]}");
|
||||
}
|
||||
#[test]
|
||||
fn block_entity_metadata_follows_its_fields() {
|
||||
// A conduit's `/data get block`, as vanilla 26.3 printed it: `Target`
|
||||
// and `components` share a bucket, and saveAdditional wrote `Target` first.
|
||||
let tag = parse_snbt(
|
||||
r#"{id:"minecraft:conduit",x:4098,y:300,z:4098,components:{},Target:[I;1,2,3,4]}"#,
|
||||
)
|
||||
.unwrap()
|
||||
.0;
|
||||
assert_eq!(
|
||||
pretty_piston(&tag, PistonPart::BlockEntity).plain(),
|
||||
r#"{Target: [I; 1, 2, 3, 4], components: {}, x: 4098, y: 300, z: 4098, id: "minecraft:conduit"}"#,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -82,49 +82,6 @@ impl Button {
|
||||
fn bit(self) -> u32 {
|
||||
1 << self as u32
|
||||
}
|
||||
/// What is printed on the button for a pad family (`Auto` reads as Xbox).
|
||||
pub fn label(self, style: GlyphStyle) -> &'static str {
|
||||
let i = self as usize;
|
||||
match style {
|
||||
GlyphStyle::PlayStation => [
|
||||
"×", "○", "□", "△", "L1", "R1", "L2", "R2", "Create", "Options", "L3", "R3", "↑",
|
||||
"↓", "←", "→",
|
||||
][i],
|
||||
GlyphStyle::Nintendo => [
|
||||
"B", "A", "Y", "X", "L", "R", "ZL", "ZR", "−", "+", "LS", "RS", "↑", "↓", "←", "→",
|
||||
][i],
|
||||
GlyphStyle::Xbox | GlyphStyle::Auto => [
|
||||
"A", "B", "X", "Y", "LB", "RB", "LT", "RT", "View", "Menu", "LS", "RS", "↑", "↓",
|
||||
"←", "→",
|
||||
][i],
|
||||
}
|
||||
}
|
||||
/// The glyph's colour (RGB): face buttons in their family's colours,
|
||||
/// the rest white.
|
||||
pub fn color(self, style: GlyphStyle) -> [u8; 3] {
|
||||
let face = match self {
|
||||
Button::South => 0,
|
||||
Button::East => 1,
|
||||
Button::West => 2,
|
||||
Button::North => 3,
|
||||
_ => return [255, 255, 255],
|
||||
};
|
||||
match style {
|
||||
GlyphStyle::PlayStation => [
|
||||
[124, 178, 232],
|
||||
[235, 110, 110],
|
||||
[222, 145, 205],
|
||||
[92, 205, 170],
|
||||
][face],
|
||||
GlyphStyle::Nintendo => [255, 255, 255],
|
||||
GlyphStyle::Xbox | GlyphStyle::Auto => [
|
||||
[110, 200, 90],
|
||||
[235, 90, 80],
|
||||
[80, 150, 240],
|
||||
[240, 200, 60],
|
||||
][face],
|
||||
}
|
||||
}
|
||||
/// The face button that plays this one's part on a Nintendo pad going by its letters
|
||||
/// (`PadSettings::nintendo_letters`): the A it prints on the right is South, which confirms
|
||||
/// and jumps as A does on a Switch; B (bottom) is East, X (top) West, Y (left) North. It is
|
||||
@@ -427,6 +384,101 @@ impl HeldDrop {
|
||||
}
|
||||
}
|
||||
|
||||
/// A job a menu's fixed buttons do. Menus aren't rebindable: this is the one map from a job to
|
||||
/// its button, and a glyph for a menu asks for the job (`GlyphOf::Menu`), never the button.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MenuJob {
|
||||
/// A: press, take, type.
|
||||
Confirm,
|
||||
/// B: back, close.
|
||||
Back,
|
||||
/// X: the screen's second job (Apply, Options, half a stack, delete a letter).
|
||||
Alternate,
|
||||
/// Y: the screen's third job (the exit, a quick move, a space).
|
||||
Extra,
|
||||
/// LB: the page or tab before.
|
||||
PagePrev,
|
||||
/// RB: the page or tab after.
|
||||
PageNext,
|
||||
/// LT: the group before; held, the modifier (shift, direct craft).
|
||||
GroupPrev,
|
||||
/// RT: the group after.
|
||||
GroupNext,
|
||||
/// View: throw.
|
||||
View,
|
||||
/// Menu (Start): done, and cancelling a binding.
|
||||
Start,
|
||||
/// The left stick pressed in.
|
||||
LeftStick,
|
||||
}
|
||||
impl MenuJob {
|
||||
pub const ALL: [MenuJob; 11] = [
|
||||
MenuJob::Confirm,
|
||||
MenuJob::Back,
|
||||
MenuJob::Alternate,
|
||||
MenuJob::Extra,
|
||||
MenuJob::PagePrev,
|
||||
MenuJob::PageNext,
|
||||
MenuJob::GroupPrev,
|
||||
MenuJob::GroupNext,
|
||||
MenuJob::View,
|
||||
MenuJob::Start,
|
||||
MenuJob::LeftStick,
|
||||
];
|
||||
/// The part that does the job (by place; a pad going by its letters is read so the part
|
||||
/// still lands: `PadState::by_letter`).
|
||||
pub const fn button(self) -> Button {
|
||||
match self {
|
||||
MenuJob::Confirm => Button::South,
|
||||
MenuJob::Back => Button::East,
|
||||
MenuJob::Alternate => Button::West,
|
||||
MenuJob::Extra => Button::North,
|
||||
MenuJob::PagePrev => Button::LeftBumper,
|
||||
MenuJob::PageNext => Button::RightBumper,
|
||||
MenuJob::GroupPrev => Button::LeftTrigger,
|
||||
MenuJob::GroupNext => Button::RightTrigger,
|
||||
MenuJob::View => Button::Select,
|
||||
MenuJob::Start => Button::Start,
|
||||
MenuJob::LeftStick => Button::LeftStick,
|
||||
}
|
||||
}
|
||||
/// The job a part does in menus.
|
||||
pub fn of(button: Button) -> Option<MenuJob> {
|
||||
Self::ALL.into_iter().find(|job| job.button() == button)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a glyph stands for: what the button does, never the button. `pad_glyph::Glyph` is built
|
||||
/// from one, so a rebinding or a pad's letters reach every glyph.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum GlyphOf {
|
||||
/// A gameplay action (`key.jump`...), on the player's binding.
|
||||
Bound(&'static str),
|
||||
/// A menu's fixed job.
|
||||
Menu(MenuJob),
|
||||
}
|
||||
impl From<MenuJob> for GlyphOf {
|
||||
fn from(job: MenuJob) -> Self {
|
||||
GlyphOf::Menu(job)
|
||||
}
|
||||
}
|
||||
impl GlyphOf {
|
||||
/// The part that does it under these settings; none while the action is unbound.
|
||||
pub fn button(self, settings: &PadSettings) -> Option<Button> {
|
||||
match self {
|
||||
GlyphOf::Bound(action) => {
|
||||
// A misspelt action would quietly have no glyph.
|
||||
debug_assert!(
|
||||
ACTIONS.iter().any(|a| a.0 == action),
|
||||
"no pad action {action}"
|
||||
);
|
||||
settings.binding(action)
|
||||
}
|
||||
GlyphOf::Menu(job) => Some(job.button()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which button pictures prompts use.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum GlyphStyle {
|
||||
@@ -635,6 +687,10 @@ fn bool_text(v: bool) -> &'static str {
|
||||
if v { "true" } else { "false" }
|
||||
}
|
||||
impl PadSettings {
|
||||
/// A pad of `family` goes by its letters: only a Nintendo pad has letters to go by.
|
||||
pub fn by_letter(&self, family: GlyphStyle) -> bool {
|
||||
self.nintendo_letters && family == GlyphStyle::Nintendo
|
||||
}
|
||||
pub fn load(dir: &Path) -> Self {
|
||||
fs::read_to_string(dir.join(FILE))
|
||||
.map(|t| Self::parse(&t))
|
||||
@@ -1921,11 +1977,6 @@ bind_key.hotbar.2:north
|
||||
assert!(parts.held(Button::LeftBumper));
|
||||
assert_eq!(parts.left, [0.5, -0.25]);
|
||||
assert_eq!(parts.by_letter(), pad);
|
||||
// A part's glyph is the letter of the button playing it.
|
||||
assert_eq!(Button::South.by_letter().label(GlyphStyle::Nintendo), "A");
|
||||
assert_eq!(Button::East.by_letter().label(GlyphStyle::Nintendo), "B");
|
||||
assert_eq!(Button::West.by_letter().label(GlyphStyle::Nintendo), "X");
|
||||
assert_eq!(Button::North.by_letter().label(GlyphStyle::Nintendo), "Y");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2353,12 +2404,7 @@ bind_key.hotbar.2:north
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_and_conflicts() {
|
||||
assert_eq!(Button::South.label(GlyphStyle::Xbox), "A");
|
||||
assert_eq!(Button::South.label(GlyphStyle::PlayStation), "×");
|
||||
assert_eq!(Button::South.label(GlyphStyle::Nintendo), "B");
|
||||
assert_eq!(Button::RightTrigger.label(GlyphStyle::PlayStation), "R2");
|
||||
assert_eq!(Button::DPadLeft.label(GlyphStyle::Xbox), "←");
|
||||
fn styles_resolve_and_conflicts() {
|
||||
assert_eq!(
|
||||
GlyphStyle::Auto.resolve(GlyphStyle::PlayStation),
|
||||
GlyphStyle::PlayStation
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! and of an inventory it drives (Horizon's; vanilla has no gamepad input). The game reads the facts
|
||||
//! from its own hit results and capabilities (`game/pad/guide.rs`); this turns them into rows, and
|
||||
//! places the rows around what was drawn.
|
||||
use crate::gamepad::{Button, GuideDetail, PadSettings, Rect};
|
||||
use crate::gamepad::{Button, GlyphOf, GuideDetail, MenuJob, PadSettings, Rect};
|
||||
|
||||
/// What a use does, by what takes it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -100,26 +100,27 @@ pub struct Facts {
|
||||
pub off_hand_item: bool,
|
||||
}
|
||||
|
||||
/// One button's line: the button and the translation key of what it does.
|
||||
/// One button's line: what the button does (its glyph follows the binding and the pad's letters)
|
||||
/// and the translation key of the caption.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Row {
|
||||
/// Shown before `button`: held with it, or its other half (LB and RB for pages).
|
||||
pub with: Option<Button>,
|
||||
pub button: Button,
|
||||
/// Shown before `of`: held with it, or its other half (LB and RB for pages).
|
||||
pub with: Option<GlyphOf>,
|
||||
pub of: GlyphOf,
|
||||
pub caption: &'static str,
|
||||
}
|
||||
impl Row {
|
||||
fn new(button: Button, caption: &'static str) -> Self {
|
||||
fn new(of: impl Into<GlyphOf>, caption: &'static str) -> Self {
|
||||
Row {
|
||||
with: None,
|
||||
button,
|
||||
of: of.into(),
|
||||
caption,
|
||||
}
|
||||
}
|
||||
fn pair(with: Button, button: Button, caption: &'static str) -> Self {
|
||||
fn pair(with: MenuJob, job: MenuJob, caption: &'static str) -> Self {
|
||||
Row {
|
||||
with: Some(with),
|
||||
button,
|
||||
with: Some(GlyphOf::Menu(with)),
|
||||
of: GlyphOf::Menu(job),
|
||||
caption,
|
||||
}
|
||||
}
|
||||
@@ -155,11 +156,11 @@ impl Guide {
|
||||
/// The rows for these facts on these bindings. An unbound action has no row, and neither has one
|
||||
/// on Start, which is always pause.
|
||||
pub fn resolve(facts: &Facts, settings: &PadSettings) -> Guide {
|
||||
let row = |action: &str, caption: Option<&'static str>| {
|
||||
let button = settings
|
||||
let row = |action: &'static str, caption: Option<&'static str>| {
|
||||
settings
|
||||
.binding(action)
|
||||
.filter(|button| *button != Button::Start)?;
|
||||
Some(Row::new(button, caption?))
|
||||
Some(Row::new(GlyphOf::Bound(action), caption?))
|
||||
};
|
||||
let player = !facts.spectator;
|
||||
let any_item = facts.main_hand_item || facts.off_hand_item;
|
||||
@@ -308,10 +309,10 @@ pub struct MenuFacts {
|
||||
/// An inventory's rows, by what they ask of the player: at the bottom right one face button, at the
|
||||
/// bottom left anything else (a trigger held with a button, the bumpers, View, a stick click). What
|
||||
/// the crosshair is on comes first in its column, the screen's own rows after, except Close, which
|
||||
/// stays lowest so the rest don't move it. Menu buttons aren't rebindable, so these name them.
|
||||
/// stays lowest so the rest don't move it. Menu buttons aren't rebindable, so these name menu jobs.
|
||||
pub fn resolve_menu(f: &MenuFacts) -> Guide {
|
||||
use Button::{East, LeftBumper, LeftStick, LeftTrigger, North, RightBumper, RightTrigger};
|
||||
use Button::{Select, South, West};
|
||||
use MenuJob::{Alternate, Back, Confirm, Extra, GroupNext, GroupPrev};
|
||||
use MenuJob::{LeftStick, PageNext, PagePrev, View};
|
||||
const TAKE: &str = "horizon.controller.guide.take";
|
||||
const MOVE: &str = "horizon.controller.guide.quick_move";
|
||||
let under = match f.under {
|
||||
@@ -321,25 +322,25 @@ pub fn resolve_menu(f: &MenuFacts) -> Guide {
|
||||
result: true,
|
||||
same,
|
||||
..
|
||||
} if !f.carrying || same => vec![Row::new(South, TAKE), Row::new(North, MOVE)],
|
||||
} if !f.carrying || same => vec![Row::new(Confirm, TAKE), Row::new(Extra, MOVE)],
|
||||
Under::Slot { result: true, .. } => Vec::new(),
|
||||
Under::Slot { accepts: false, .. } if f.carrying => Vec::new(),
|
||||
Under::Slot { item, same, .. } if f.carrying => vec![
|
||||
Row::new(
|
||||
South,
|
||||
Confirm,
|
||||
if item && !same {
|
||||
"horizon.controller.guide.swap_stack"
|
||||
} else {
|
||||
"horizon.controller.guide.put"
|
||||
},
|
||||
),
|
||||
Row::new(West, "horizon.controller.guide.put_one"),
|
||||
Row::new(Alternate, "horizon.controller.guide.put_one"),
|
||||
],
|
||||
Under::Slot { item: true, .. } => vec![
|
||||
Row::new(South, TAKE),
|
||||
Row::new(West, "horizon.controller.guide.take_half"),
|
||||
Row::new(North, MOVE),
|
||||
Row::new(Select, "horizon.controller.guide.drop"),
|
||||
Row::new(Confirm, TAKE),
|
||||
Row::new(Alternate, "horizon.controller.guide.take_half"),
|
||||
Row::new(Extra, MOVE),
|
||||
Row::new(View, "horizon.controller.guide.drop"),
|
||||
Row::new(LeftStick, "horizon.controller.guide.drop_stack"),
|
||||
],
|
||||
Under::Nothing | Under::Slot { .. } => Vec::new(),
|
||||
@@ -347,43 +348,44 @@ pub fn resolve_menu(f: &MenuFacts) -> Guide {
|
||||
direct_craft,
|
||||
alternatives,
|
||||
} => [
|
||||
Some(Row::new(South, "horizon.controller.guide.place_recipe")),
|
||||
Some(Row::new(Confirm, "horizon.controller.guide.place_recipe")),
|
||||
direct_craft.then_some(Row::pair(
|
||||
LeftTrigger,
|
||||
South,
|
||||
GroupPrev,
|
||||
Confirm,
|
||||
"horizon.controller.direct_craft",
|
||||
)),
|
||||
Some(Row::new(North, "horizon.controller.guide.place_recipe_all")),
|
||||
alternatives.then_some(Row::new(West, "horizon.controller.guide.alternatives")),
|
||||
Some(Row::new(Extra, "horizon.controller.guide.place_recipe_all")),
|
||||
alternatives.then_some(Row::new(Alternate, "horizon.controller.guide.alternatives")),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect(),
|
||||
Under::Control => vec![Row::new(South, "horizon.controller.guide.select")],
|
||||
Under::Control => vec![Row::new(Confirm, "horizon.controller.guide.select")],
|
||||
};
|
||||
let screen = [
|
||||
f.book.then_some(Row::pair(
|
||||
LeftBumper,
|
||||
RightBumper,
|
||||
PagePrev,
|
||||
PageNext,
|
||||
"horizon.controller.guide.pages",
|
||||
)),
|
||||
f.book.then_some(Row::pair(
|
||||
LeftTrigger,
|
||||
RightTrigger,
|
||||
GroupPrev,
|
||||
GroupNext,
|
||||
"horizon.controller.guide.categories",
|
||||
)),
|
||||
(f.tabs && !f.book).then_some(Row::pair(
|
||||
LeftBumper,
|
||||
RightBumper,
|
||||
PagePrev,
|
||||
PageNext,
|
||||
"horizon.controller.guide.tabs",
|
||||
)),
|
||||
];
|
||||
let close = Row::new(East, "horizon.controller.guide.close");
|
||||
let close = Row::new(Back, "horizon.controller.guide.close");
|
||||
let rows = std::iter::once(close)
|
||||
.chain(under)
|
||||
.chain(screen.into_iter().flatten());
|
||||
let (right, left) =
|
||||
rows.partition(|r| r.with.is_none() && matches!(r.button, South | East | West | North));
|
||||
let (right, left) = rows.partition(|r| {
|
||||
r.with.is_none() && matches!(r.of, GlyphOf::Menu(Confirm | Back | Alternate | Extra))
|
||||
});
|
||||
Guide { left, right }
|
||||
}
|
||||
|
||||
@@ -495,8 +497,11 @@ mod tests {
|
||||
..f
|
||||
}
|
||||
}
|
||||
fn rows(rows: &[Row]) -> Vec<(Button, &'static str)> {
|
||||
rows.iter().map(|r| (r.button, r.caption)).collect()
|
||||
/// Each row's button on these settings, and its caption.
|
||||
fn rows(rows: &[Row], settings: &PadSettings) -> Vec<(Button, &'static str)> {
|
||||
rows.iter()
|
||||
.map(|r| (r.of.button(settings).unwrap(), r.caption))
|
||||
.collect()
|
||||
}
|
||||
fn captions(rows: &[Row]) -> Vec<&'static str> {
|
||||
rows.iter().map(|r| r.caption).collect()
|
||||
@@ -509,9 +514,10 @@ mod tests {
|
||||
item_use: Some(Verb::Place),
|
||||
..on_block(walking(), true, None)
|
||||
};
|
||||
let guide = resolve(&facts, &PadSettings::default());
|
||||
let settings = PadSettings::default();
|
||||
let guide = resolve(&facts, &settings);
|
||||
assert_eq!(
|
||||
rows(&guide.right),
|
||||
rows(&guide.right, &settings),
|
||||
[
|
||||
(Button::RightTrigger, "horizon.controller.guide.break"),
|
||||
(Button::LeftTrigger, "horizon.controller.guide.place"),
|
||||
@@ -519,7 +525,7 @@ mod tests {
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
rows(&guide.left),
|
||||
rows(&guide.left, &settings),
|
||||
[
|
||||
(Button::South, "key.jump"),
|
||||
(Button::RightStick, "key.sneak"),
|
||||
@@ -543,7 +549,7 @@ mod tests {
|
||||
};
|
||||
let guide = resolve(&facts, &settings);
|
||||
assert_eq!(
|
||||
rows(&guide.right),
|
||||
rows(&guide.right, &settings),
|
||||
[(Button::North, "horizon.controller.guide.break")]
|
||||
);
|
||||
// Start only ever pauses.
|
||||
@@ -845,8 +851,8 @@ mod tests {
|
||||
["close", "place_recipe", "place_recipe_all", "alternatives"]
|
||||
);
|
||||
assert_eq!(recipe.left.len(), 1);
|
||||
assert_eq!(recipe.left[0].with, Some(Button::LeftTrigger));
|
||||
assert_eq!(recipe.left[0].button, Button::South);
|
||||
assert_eq!(recipe.left[0].with, Some(GlyphOf::Menu(MenuJob::GroupPrev)));
|
||||
assert_eq!(recipe.left[0].of, GlyphOf::Menu(MenuJob::Confirm));
|
||||
let plain = on(
|
||||
Under::Recipe {
|
||||
direct_craft: false,
|
||||
@@ -892,8 +898,8 @@ mod detail_tests {
|
||||
|
||||
fn rows() -> Guide {
|
||||
Guide {
|
||||
left: vec![Row::new(Button::South, "left")],
|
||||
right: vec![Row::new(Button::East, "right")],
|
||||
left: vec![Row::new(MenuJob::Confirm, "left")],
|
||||
right: vec![Row::new(MenuJob::Back, "right")],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -927,7 +933,7 @@ mod detail_tests {
|
||||
mod slot_tests {
|
||||
use super::*;
|
||||
|
||||
/// The rows for the slot: South or West, whichever column they're in.
|
||||
/// The rows for the slot: Confirm or Alternate, whichever column they're in.
|
||||
fn rows(item: bool, same: bool, result: bool, accepts: bool, carrying: bool) -> usize {
|
||||
let guide = resolve_menu(&MenuFacts {
|
||||
under: Under::Slot {
|
||||
@@ -942,7 +948,7 @@ mod slot_tests {
|
||||
[guide.left, guide.right]
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|row| matches!(row.button, Button::South | Button::West))
|
||||
.filter(|row| matches!(row.of, GlyphOf::Menu(MenuJob::Confirm | MenuJob::Alternate)))
|
||||
.count()
|
||||
}
|
||||
|
||||
|
||||
@@ -97,8 +97,8 @@ rustix = { version = "1", default-features = false, features = ["std", "time", "
|
||||
# Use the registered URI handler without routing links through Explorer/cmd.
|
||||
open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
|
||||
# DirectX 12, where Vulkan has no adapter or device, or by choice (`graphicsApi`, graphics.rs).
|
||||
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or DXC when dxcompiler.dll is
|
||||
# on the PATH.
|
||||
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or on integrated GPUs with DXC
|
||||
# (dxcompiler.dll beside the game, gfx/dx12_compiler.rs).
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
//! A world warp (`native_world_warp`, docs/native-mods.md "World warps") through the real
|
||||
//! `Renderer`, offscreen: a wall of terrain and an entity quad drawn without a warp, with one,
|
||||
//! with one that is off, with one that cuts the wall, with a pixel stage of its own and with
|
||||
//! one that doesn't compile. Run with throwaway HORIZON_HOME, HORIZON_LAUNCHER_HOME,
|
||||
//! XDG_DATA_HOME and XDG_CONFIG_HOME (AGENTS.md):
|
||||
//! `cargo run -p horizon-client-render --example world_warp_check -- <output-dir>`
|
||||
//! `--release ... -- <output-dir> --bench`: instead, the time of a frame of rolling land 49 by 49
|
||||
//! chunks around the camera without a warp, and with one (nothing culled), at 1920 by 1080.
|
||||
use client::{
|
||||
gfx::{Frame, Renderer, ScreenshotRequest, native_render},
|
||||
graphics::GraphicsApi,
|
||||
hud::HudBatch,
|
||||
mesher::{SectionMesh, TerrainVertex, Vertex},
|
||||
};
|
||||
use horizon_client_render as client;
|
||||
use std::{
|
||||
path::Path,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use winit::{
|
||||
application::ApplicationHandler,
|
||||
event::WindowEvent,
|
||||
event_loop::{ActiveEventLoop, EventLoop},
|
||||
window::{Window, WindowId},
|
||||
};
|
||||
|
||||
/// What the mod below gives the renderer each frame.
|
||||
static WARP: Mutex<Option<native_render::WorldWarp>> = Mutex::new(None);
|
||||
struct Mod;
|
||||
impl native_render::RenderHooks for Mod {
|
||||
fn world_warp(&self, event: &mut native_render::WorldWarpEvent<'_>) {
|
||||
if let Some(warp) = WARP.lock().unwrap().as_ref() {
|
||||
*event.warp = warp.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lifts what is left of x = 8 by `params[0].x` and tints it red.
|
||||
const LIFT: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
|
||||
let here = pos.x + warp.camera.x + warp.camera_rest.x;
|
||||
if here < 8.0 { return Warped(pos + vec3(0.0, warp.params[0].x, 0.0), vec3(1.0, 0.0, 0.0)); }
|
||||
return Warped(pos, vec3(1.0));
|
||||
}
|
||||
";
|
||||
/// Cuts every face.
|
||||
const CUT: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
|
||||
return Warped(pos, vec3(-1.0));
|
||||
}
|
||||
";
|
||||
/// Paints the terrain green by its pixels and moves nothing.
|
||||
const PAINT: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
|
||||
return Warped(pos, vec3(1.0));
|
||||
}
|
||||
// [fragments]
|
||||
fn world_fragment(f: Fragment) -> Painted {
|
||||
if !f.front { return Painted(vec4(0.0, 0.0, 0.0, -1.0), 1.0); }
|
||||
return Painted(vec4(0.0, 1.0, 0.0, 1.0), 0.0);
|
||||
}
|
||||
";
|
||||
/// Moves entities ten blocks right: off the screen's middle.
|
||||
const SHIFT: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
|
||||
return Warped(pos + vec3(-10.0, 0.0, 0.0), vec3(1.0));
|
||||
}
|
||||
";
|
||||
|
||||
fn read(path: &Path) -> Vec<[u8; 4]> {
|
||||
let decoder = png::Decoder::new(std::io::BufReader::new(std::fs::File::open(path).unwrap()));
|
||||
let mut reader = decoder.read_info().unwrap();
|
||||
let mut bytes = vec![0; reader.output_buffer_size().unwrap()];
|
||||
let info = reader.next_frame(&mut bytes).unwrap();
|
||||
assert_eq!(info.color_type, png::ColorType::Rgba);
|
||||
bytes.truncate(info.buffer_size());
|
||||
bytes.as_chunks::<4>().0.to_vec()
|
||||
}
|
||||
|
||||
/// Pixels like `want` (within 40 a channel), of an image.
|
||||
fn count(pixels: &[[u8; 4]], want: [u8; 3]) -> usize {
|
||||
pixels
|
||||
.iter()
|
||||
.filter(|p| (0..3).all(|i| p[i].abs_diff(want[i]) < 40))
|
||||
.count()
|
||||
}
|
||||
|
||||
/// A wall across x 0..16, y 0..16 at z = 12 of the section at y 96, both windings.
|
||||
fn wall() -> SectionMesh {
|
||||
let mut mesh = SectionMesh::default();
|
||||
let corners = [
|
||||
[0., 0., 12.],
|
||||
[0., 16., 12.],
|
||||
[16., 16., 12.],
|
||||
[16., 0., 12.],
|
||||
];
|
||||
let uvs = [[0., 1.], [0., 0.], [1., 0.], [1., 1.]];
|
||||
for order in [[0, 1, 2, 3], [3, 2, 1, 0]] {
|
||||
for i in order {
|
||||
mesh.solid
|
||||
.push(TerrainVertex::new(corners[i], uvs[i], 0, u32::MAX, 0xf0f0));
|
||||
}
|
||||
}
|
||||
mesh.sort_solid();
|
||||
mesh
|
||||
}
|
||||
|
||||
fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let out = std::env::args().nth(1).ok_or("output directory required")?;
|
||||
let out = Path::new(&out);
|
||||
std::fs::create_dir_all(out)?;
|
||||
native_render::install(Box::new([&Mod])).map_err(|_| "mod already installed")?;
|
||||
let white = [255; 16 * 16 * 4];
|
||||
let zero = [0; 4];
|
||||
let mut renderer = pollster::block_on(Renderer::new(
|
||||
window,
|
||||
true,
|
||||
&white,
|
||||
16,
|
||||
16,
|
||||
1,
|
||||
&[],
|
||||
&[],
|
||||
(&white, 16, 16),
|
||||
(&zero, 1, 1),
|
||||
(&zero, 1, 1),
|
||||
(&zero, 1),
|
||||
Arc::new(client::gfx::panorama::Faces::colour([0, 0, 0, 255])),
|
||||
None,
|
||||
None,
|
||||
GraphicsApi::Vulkan,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
))?;
|
||||
renderer.resize(320, 180);
|
||||
renderer.fullbright = true;
|
||||
// (keys count sections from the bottom of the world, y -64: 10 is y 96 to 112)
|
||||
renderer.upload((0, 10, 0), &wall());
|
||||
// an entity quad in front of the wall, middle of the screen
|
||||
let entity: Vec<Vertex> = [
|
||||
[7.5, 103.5, 6.0],
|
||||
[7.5, 104.5, 6.0],
|
||||
[8.5, 104.5, 6.0],
|
||||
[8.5, 103.5, 6.0],
|
||||
]
|
||||
.into_iter()
|
||||
.zip([[0., 1.], [0., 0.], [1., 0.], [1., 1.]])
|
||||
.map(|(p, uv)| Vertex {
|
||||
pos: [p[0] - 8.0, p[1] - 104.0, p[2] - 0.5],
|
||||
uv,
|
||||
layer: 0,
|
||||
color: 0xff40_40ff,
|
||||
light: 0xf0,
|
||||
normal: [0, 0, 0, 0],
|
||||
glint_uv: [0.0; 2],
|
||||
})
|
||||
.collect();
|
||||
if std::env::args().any(|a| a == "--bench") {
|
||||
return bench(&mut renderer, out);
|
||||
}
|
||||
let hud = HudBatch::new([320, 180]);
|
||||
let mut frame = fixture_frame(&hud);
|
||||
frame.entities = &entity;
|
||||
let mut shot = |name: &str, warp: Option<native_render::WorldWarp>| {
|
||||
*WARP.lock().unwrap() = warp;
|
||||
let path = out.join(format!("{name}.png"));
|
||||
renderer.screenshot = Some(ScreenshotRequest {
|
||||
path: path.clone(),
|
||||
size: None,
|
||||
scaled: false,
|
||||
});
|
||||
// twice: the first frame of a new warp builds it
|
||||
renderer.render(&frame).unwrap();
|
||||
renderer.render(&frame).unwrap();
|
||||
read(&path)
|
||||
};
|
||||
let warp = |source: &str, on: bool, lift: f32| {
|
||||
let mut w = native_render::WorldWarp::default();
|
||||
w.source = Some(source.into());
|
||||
w.on = on;
|
||||
w.params[0] = lift;
|
||||
Some(w)
|
||||
};
|
||||
let plain = shot("plain", None);
|
||||
let wall_px = count(&plain, [255, 255, 255]);
|
||||
let entity_px = count(&plain, [255, 64, 64]);
|
||||
assert!(wall_px > 1000, "the wall shows without a warp: {wall_px}");
|
||||
assert!(
|
||||
entity_px > 50,
|
||||
"the entity shows without a warp: {entity_px}"
|
||||
);
|
||||
|
||||
let off = shot("off", warp(LIFT, false, 4.0));
|
||||
assert!(off == plain, "a warp that is off draws as no warp");
|
||||
|
||||
let lifted = shot("lift", warp(LIFT, true, 4.0));
|
||||
let red = count(&lifted, [255, 0, 0]);
|
||||
assert!(red > 500, "the warp tints the wall's left half red: {red}");
|
||||
assert!(lifted != plain);
|
||||
|
||||
let cut = shot("cut", warp(CUT, true, 0.0));
|
||||
let left = count(&cut, [255, 255, 255]);
|
||||
assert!(
|
||||
left < wall_px / 20,
|
||||
"a cut leaves the wall out: {left} of {wall_px}"
|
||||
);
|
||||
|
||||
let painted = shot("paint", warp(PAINT, true, 0.0));
|
||||
let green = count(&painted, [0, 255, 0]);
|
||||
assert!(
|
||||
green > wall_px / 2,
|
||||
"the warp's own pixels paint the wall: {green}"
|
||||
);
|
||||
|
||||
let shifted = shot("shift", warp(SHIFT, true, 0.0));
|
||||
let entity_left = count(&shifted[..], [255, 64, 64]);
|
||||
let mid = |p: &[[u8; 4]]| -> Vec<[u8; 4]> {
|
||||
(60..120)
|
||||
.flat_map(|y| p[y * 320 + 130..y * 320 + 190].to_vec())
|
||||
.collect()
|
||||
};
|
||||
assert!(
|
||||
count(&mid(&shifted), [255, 64, 64]) < count(&mid(&plain), [255, 64, 64]) / 4,
|
||||
"the warp moves the entity out of the middle ({entity_left} red-ish pixels left)"
|
||||
);
|
||||
|
||||
let broken = shot(
|
||||
"broken",
|
||||
warp("fn world_warp() -> f32 { return 1; }", true, 0.0),
|
||||
);
|
||||
assert!(broken == plain, "a warp that doesn't compile is left out");
|
||||
|
||||
let back = shot("after", None);
|
||||
assert!(back == plain, "with the warp gone the frame is as before");
|
||||
std::fs::write(
|
||||
out.join("result.json"),
|
||||
serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"wall_pixels": wall_px, "entity_pixels": entity_px, "lifted_red_pixels": red,
|
||||
"cut_wall_pixels_left": left, "painted_green_pixels": green,
|
||||
"off_equals_plain": true, "broken_equals_plain": true, "after_equals_plain": true,
|
||||
"graphics_adapter": client::crash::system_details("fixture").into_iter()
|
||||
.find(|(name, _)| name == "Graphics Adapter").map(|(_, detail)| detail),
|
||||
}))?,
|
||||
)?;
|
||||
println!(
|
||||
"world warp check passed: {}",
|
||||
out.join("result.json").display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rolling land: a column of a section's top faces and the sides they show, at y 64 to 80.
|
||||
fn hills(cx: i32, cz: i32) -> SectionMesh {
|
||||
let height = |x: i32, z: i32| {
|
||||
let (x, z) = (f64::from(cx * 16 + x), f64::from(cz * 16 + z));
|
||||
(8.0 + 4.0 * (x * 0.09).sin() + 3.0 * (z * 0.07 + x * 0.02).cos()).floor() as i32
|
||||
};
|
||||
let mut mesh = SectionMesh::default();
|
||||
let mut quad = |c: [[f32; 3]; 4]| {
|
||||
for (p, uv) in c.into_iter().zip([[0., 1.], [0., 0.], [1., 0.], [1., 1.]]) {
|
||||
mesh.solid
|
||||
.push(TerrainVertex::new(p, uv, 0, 0xff60_a060, 0xf0));
|
||||
}
|
||||
};
|
||||
for x in 0..16 {
|
||||
for z in 0..16 {
|
||||
let h = height(x, z);
|
||||
let (fx, fz, fh) = (x as f32, z as f32, h as f32);
|
||||
quad([
|
||||
[fx, fh, fz],
|
||||
[fx, fh, fz + 1.],
|
||||
[fx + 1., fh, fz + 1.],
|
||||
[fx + 1., fh, fz],
|
||||
]);
|
||||
// the step down to a lower neighbour, toward +x and +z
|
||||
for (dx, dz) in [(1, 0), (0, 1)] {
|
||||
let n = height(x + dx, z + dz);
|
||||
if n < h {
|
||||
let (lo, hi) = (n as f32, fh);
|
||||
if dx == 1 {
|
||||
quad([
|
||||
[fx + 1., lo, fz],
|
||||
[fx + 1., hi, fz],
|
||||
[fx + 1., hi, fz + 1.],
|
||||
[fx + 1., lo, fz + 1.],
|
||||
]);
|
||||
} else {
|
||||
quad([
|
||||
[fx, lo, fz + 1.],
|
||||
[fx + 1., lo, fz + 1.],
|
||||
[fx + 1., hi, fz + 1.],
|
||||
[fx, hi, fz + 1.],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
mesh.sort_solid();
|
||||
mesh
|
||||
}
|
||||
|
||||
/// `--bench`: milliseconds a frame without a warp and with one.
|
||||
fn bench(renderer: &mut Renderer, out: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
||||
const R: i32 = 24;
|
||||
renderer.resize(1920, 1080);
|
||||
let mut sections = 0;
|
||||
for cx in -R..=R {
|
||||
for cz in -R..=R {
|
||||
// (keys count sections from y -64: 8 is y 64 to 80)
|
||||
renderer.upload((cx, 8, cz), &hills(cx, cz));
|
||||
sections += 1;
|
||||
}
|
||||
}
|
||||
let hud = HudBatch::new([1920, 1080]);
|
||||
let mut frame = fixture_frame(&hud);
|
||||
frame.camera = glam::DVec3::new(8.0, 90.0, 8.0);
|
||||
frame.eye = frame.camera;
|
||||
frame.pitch = 15.0;
|
||||
frame.eye_rotation = [0.0, 15.0];
|
||||
frame.distance = 16.0 * R as f32;
|
||||
frame.detail_distance = frame.distance;
|
||||
frame.depth_far = 2000.0;
|
||||
frame.cave_culling = true;
|
||||
let mut time = |warp: Option<native_render::WorldWarp>| {
|
||||
*WARP.lock().unwrap() = warp;
|
||||
for _ in 0..30 {
|
||||
renderer.render(&frame).unwrap();
|
||||
}
|
||||
let started = std::time::Instant::now();
|
||||
const FRAMES: u32 = 240;
|
||||
for _ in 0..FRAMES {
|
||||
renderer.render(&frame).unwrap();
|
||||
}
|
||||
// the last frame's picture, so the GPU has caught up
|
||||
renderer.screenshot = Some(ScreenshotRequest {
|
||||
path: out.join("bench.png"),
|
||||
size: None,
|
||||
scaled: false,
|
||||
});
|
||||
renderer.render(&frame).unwrap();
|
||||
renderer.render(&frame).unwrap();
|
||||
started.elapsed().as_secs_f64() * 1000.0 / f64::from(FRAMES + 2)
|
||||
};
|
||||
let mut wave = native_render::WorldWarp::default();
|
||||
wave.source = Some(
|
||||
"fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
|
||||
let x = pos.x + warp.camera.x + warp.camera_rest.x;
|
||||
return Warped(pos + vec3(0.0, sin(x * 0.05) * 3.0, 0.0), vec3(1.0));
|
||||
}\n"
|
||||
.into(),
|
||||
);
|
||||
wave.on = true;
|
||||
let plain = time(None);
|
||||
let warped = time(Some(wave));
|
||||
let again = time(None);
|
||||
let result = serde_json::json!({
|
||||
"sections": sections, "frame_ms_without_warp": plain, "frame_ms_with_warp": warped,
|
||||
"frame_ms_without_warp_after": again,
|
||||
"graphics_adapter": client::crash::system_details("bench").into_iter()
|
||||
.find(|(name, _)| name == "Graphics Adapter").map(|(_, detail)| detail),
|
||||
});
|
||||
println!("{result}");
|
||||
std::fs::write(out.join("bench.json"), serde_json::to_vec_pretty(&result)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct App;
|
||||
impl ApplicationHandler for App {
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
let window = Arc::new(
|
||||
event_loop
|
||||
.create_window(Window::default_attributes().with_visible(false))
|
||||
.unwrap(),
|
||||
);
|
||||
if let Err(error) = fixture(window) {
|
||||
eprintln!("world warp check failed: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
event_loop.exit();
|
||||
}
|
||||
fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, _: WindowEvent) {}
|
||||
}
|
||||
fn main() {
|
||||
for key in [
|
||||
"HORIZON_HOME",
|
||||
"HORIZON_LAUNCHER_HOME",
|
||||
"XDG_DATA_HOME",
|
||||
"XDG_CONFIG_HOME",
|
||||
] {
|
||||
assert!(
|
||||
std::env::var_os(key).is_some(),
|
||||
"set throwaway {key} (AGENTS.md)"
|
||||
);
|
||||
}
|
||||
EventLoop::new().unwrap().run_app(&mut App).unwrap();
|
||||
}
|
||||
|
||||
fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
Frame {
|
||||
weather: &[],
|
||||
weather_time: client::weather::Time::new(0, 0.0),
|
||||
camera: glam::DVec3::new(8.0, 104.0, 0.5),
|
||||
yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
fov_degrees: 85.0,
|
||||
eye: glam::DVec3::new(8.0, 104.0, 0.5),
|
||||
eye_rotation: [0.0, 0.0],
|
||||
first_person: true,
|
||||
bob: glam::Mat4::IDENTITY,
|
||||
spin: glam::Mat4::IDENTITY,
|
||||
sky: client::environment::Sky::default(),
|
||||
shadow_scene_revision: (0, 0),
|
||||
shadow_cache: false,
|
||||
distance: 128.0,
|
||||
detail_distance: 128.0,
|
||||
depth_far: 1000.0,
|
||||
underwater: false,
|
||||
water_plane: None,
|
||||
cave_culling: false,
|
||||
title: false,
|
||||
sky_blocked: false,
|
||||
skybox: false,
|
||||
end_sky: false,
|
||||
end_sky_sprite: None,
|
||||
boss_world_fog: false,
|
||||
environment_fog: None,
|
||||
water_fog: [0.0; 3],
|
||||
fog: None,
|
||||
dark_disc: false,
|
||||
outline: Vec::new(),
|
||||
outline_after_terrain: false,
|
||||
outline_high_contrast: false,
|
||||
pvp: Default::default(),
|
||||
debug_lines: &[],
|
||||
debug_lines_on_top: &[],
|
||||
debug_crosshair: &[],
|
||||
hud,
|
||||
glint_alpha: 0.75,
|
||||
glint_ticks: 0.0,
|
||||
nether_lighting: false,
|
||||
entities: &[],
|
||||
static_block_entities: &[],
|
||||
visible_static_block_entity_sections: &[],
|
||||
static_block_entity_stats: Default::default(),
|
||||
entity_motion: &[],
|
||||
entity_motion_posed: &[],
|
||||
entity_spans: &[],
|
||||
skin: None,
|
||||
glowing: &[],
|
||||
dynamic_lights: &[],
|
||||
colored_lights: &[],
|
||||
emissive_layers: &[],
|
||||
clouds: None,
|
||||
lightning: Default::default(),
|
||||
additive: &[],
|
||||
see_through: &[],
|
||||
world_primitives: Default::default(),
|
||||
shadow_only: &[],
|
||||
celestial: &[],
|
||||
stars: None,
|
||||
hand: &[],
|
||||
hand_items: &[],
|
||||
hand_fov_degrees: 70.0,
|
||||
screen: &[],
|
||||
pack: Default::default(),
|
||||
border: None,
|
||||
game_time: 0.0,
|
||||
shader_game_time: 0.0,
|
||||
end_portal_sprites: [[0.0; 4]; 2],
|
||||
crumbling_sprite: [0.0; 4],
|
||||
ticks: 0,
|
||||
darkness: (0.0, 0.0),
|
||||
night_vision: 0.0,
|
||||
block_flicker: 0.0,
|
||||
boss_darken: 0.0,
|
||||
chunk_fade: 0.0,
|
||||
post_effects: client::post_chain::Request::none(),
|
||||
menu_blur: None,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -660,6 +660,34 @@ mod pack_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Biomes {
|
||||
/// Biomes from their files' JSON, by name, in id order: for tests elsewhere.
|
||||
pub(crate) fn from_jsons(entries: &[(&str, Value)]) -> Self {
|
||||
let maps = Colormaps {
|
||||
grass: Vec::new(),
|
||||
foliage: Vec::new(),
|
||||
dry: Vec::new(),
|
||||
};
|
||||
let list: Vec<_> = entries
|
||||
.iter()
|
||||
.map(|(name, v)| BiomeInfo::from_json((*name).to_owned(), v, &maps))
|
||||
.collect();
|
||||
Self {
|
||||
builtin: Arc::new(list.clone()),
|
||||
index: list
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, b)| (b.name.clone(), i as u16))
|
||||
.collect(),
|
||||
list,
|
||||
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
|
||||
colormaps: Arc::new(maps),
|
||||
packs: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod color_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -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,11 +80,18 @@ 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,
|
||||
/// `BellBlockEntity`: saved identity only; ringing state is kept separately.
|
||||
Bell,
|
||||
/// `ConduitBlockEntity`: the UUID of its `destroyTarget` (`Target`). Its
|
||||
/// shape and animation are kept separately (`block_entity_anim`).
|
||||
Conduit(Option<[i32; 4]>),
|
||||
/// `EnchantingTableBlockEntity`: its custom name is the world's block name.
|
||||
EnchantingTable,
|
||||
/// Suspicious sand and gravel: saved loot and the transient brushed face.
|
||||
Brushable(Box<Brushable>),
|
||||
/// A shelf holds three displayed item stacks.
|
||||
|
||||
@@ -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),
|
||||
@@ -444,9 +447,23 @@ impl BlockEntity {
|
||||
if name == "minecraft:bell" {
|
||||
return Some(Self::Bell);
|
||||
}
|
||||
if name == "minecraft:conduit" {
|
||||
return Some(Self::Conduit(None));
|
||||
}
|
||||
if name == "minecraft:enchanting_table" {
|
||||
return Some(Self::EnchantingTable);
|
||||
}
|
||||
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()));
|
||||
}
|
||||
@@ -540,6 +557,9 @@ impl BlockEntity {
|
||||
Self::EndGateway(_) => name == "minecraft:end_gateway",
|
||||
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",
|
||||
Self::TestInstanceBlock(_) => name == "minecraft:test_instance_block",
|
||||
@@ -579,6 +599,9 @@ impl BlockEntity {
|
||||
Self::EndGateway(_) => "minecraft:end_gateway",
|
||||
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",
|
||||
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
|
||||
@@ -614,6 +637,9 @@ impl BlockEntity {
|
||||
| Self::TestInstanceBlock(_)
|
||||
| Self::DaylightDetector
|
||||
| Self::Bell
|
||||
| Self::Conduit(_)
|
||||
| Self::EndPortal
|
||||
| Self::EnchantingTable
|
||||
| Self::Brushable(_) => Vec::new(),
|
||||
Self::Mod(m) => m.items.iter().flatten().cloned().collect(),
|
||||
Self::Lectern(l) => l.book.iter().cloned().collect(),
|
||||
@@ -645,6 +671,9 @@ impl BlockEntity {
|
||||
| Self::TestInstanceBlock(_)
|
||||
| Self::DaylightDetector
|
||||
| Self::Bell
|
||||
| Self::Conduit(_)
|
||||
| Self::EndPortal
|
||||
| Self::EnchantingTable
|
||||
| Self::Brushable(_) => &[],
|
||||
Self::Jukebox(j) => &j.items,
|
||||
Self::Crafter(c) => &c.items,
|
||||
@@ -681,6 +710,9 @@ impl BlockEntity {
|
||||
| Self::TestInstanceBlock(_)
|
||||
| Self::DaylightDetector
|
||||
| Self::Bell
|
||||
| Self::Conduit(_)
|
||||
| Self::EndPortal
|
||||
| Self::EnchantingTable
|
||||
| Self::Brushable(_) => &mut [],
|
||||
Self::Jukebox(j) => &mut j.items,
|
||||
Self::Crafter(c) => &mut c.items,
|
||||
@@ -933,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());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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()
|
||||
};
|
||||
|
||||
@@ -175,7 +175,11 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
@vertex fn vs_entity(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
|
||||
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>,
|
||||
@location(5) normal: vec4<f32>, @location(6) glint_uv: vec2<f32>) -> Out {
|
||||
var out = entity_vertex(position, uv, overlay & 0xffffu, color, light, normal);
|
||||
// [world warp entity]
|
||||
// (a world warp's pipelines put the vertex through the mod's `world_warp`: gfx/world_warp.rs)
|
||||
let at = position;
|
||||
// [end world warp entity]
|
||||
var out = entity_vertex(at, uv, overlay & 0xffffu, color, light, normal);
|
||||
out.glint_uv = glint_uv;
|
||||
out.tier = (overlay >> 16u) & 0xffu;
|
||||
return out;
|
||||
@@ -428,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);
|
||||
|
||||
@@ -163,11 +163,20 @@ pub struct Frame {
|
||||
/// The sky and block light at the camera.
|
||||
pub sky_light: i32,
|
||||
pub block_light: i32,
|
||||
/// The camera's biome's climate (`o.temperature`, `o.downfall`).
|
||||
/// The camera's biome (`o.biome()`), and its climate (`o.temperature`, `o.downfall`).
|
||||
pub biome: String,
|
||||
pub temperature: f32,
|
||||
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> {
|
||||
@@ -177,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};
|
||||
@@ -196,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)),
|
||||
@@ -216,6 +240,9 @@ impl horizon_polytone::expr::Host for FrameHost<'_> {
|
||||
"blockLight" if on(Root::Object) || on(Root::Player) => num(f64::from(f.block_light)),
|
||||
"temperature" if on(Root::Object) => num(f64::from(f.temperature)),
|
||||
"downfall" if on(Root::Object) => num(f64::from(f.downfall)),
|
||||
"biome" if on(Root::Object) || on(Root::Player) || on(Root::Camera) => {
|
||||
Ok(V::Str(f.biome.as_str().into()))
|
||||
}
|
||||
"rand" if on(Root::Random) => {
|
||||
// A splitmix64 step: the frame's own sequence.
|
||||
self.seed = self.seed.wrapping_add(0x9e37_79b9_7f4a_7c15);
|
||||
@@ -265,9 +292,9 @@ fn as_number(value: &Value) -> f64 {
|
||||
|
||||
/// A Polytone colormap used as an attribute value (wiki "Colormaps"): an image read at the
|
||||
/// column and row its two expressions pick, each 0 to 1 with 1 the top-left corner.
|
||||
struct Colormap {
|
||||
x: horizon_polytone::expr::Program,
|
||||
y: horizon_polytone::expr::Program,
|
||||
pub(crate) struct Colormap {
|
||||
x: Axis,
|
||||
y: Axis,
|
||||
/// Only the lower-left triangle: the row value is scaled by the column value, as vanilla's
|
||||
/// grass colormap scales downfall by temperature.
|
||||
triangular: bool,
|
||||
@@ -278,11 +305,72 @@ struct Colormap {
|
||||
rgba: Vec<u8>,
|
||||
}
|
||||
|
||||
/// A colormap axis: an expression (or a named sampler, wiki "Default Samplers"), or the
|
||||
/// `biome_id` sampler with its mapper.
|
||||
enum Axis {
|
||||
Expr(horizon_polytone::expr::Program),
|
||||
/// `biome_id`: the value the mapper gives the biome where it samples (0 for the others),
|
||||
/// and the expression naming that biome. Unlike the other axes it counts from the left (or
|
||||
/// top) edge, in pixel coordinates over the mapper's `texture_size`: the column (or row) is
|
||||
/// `floor(value * size)`, so `1` over a `texture_size` of 16 is the second pixel, and the
|
||||
/// biomes left out are the first, as the wiki's "start your mapper at 1" advice expects.
|
||||
BiomeId(HashMap<String, f64>, horizon_polytone::expr::Program),
|
||||
}
|
||||
|
||||
impl Axis {
|
||||
fn value(&self, host: &mut dyn horizon_polytone::expr::Host) -> Option<f64> {
|
||||
match self {
|
||||
Self::Expr(program) => Some(program.eval(host).ok()?.num()),
|
||||
Self::BiomeId(mapper, biome) => {
|
||||
let biome = match biome.eval(host).ok()? {
|
||||
horizon_polytone::expr::Value::Str(s) => s,
|
||||
_ => return Some(0.0),
|
||||
};
|
||||
Some(mapper.get(biome.as_ref()).copied().unwrap_or(0.0))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `biome_id_mapper`: inline, or an id of `polytone/biome_id_mappers/<path>.json`. Its values
|
||||
/// are divided by its `texture_size`, when it has one (pixel coordinates).
|
||||
fn biome_id_mapper(pack_root: &Path, json: &Json) -> HashMap<String, f64> {
|
||||
let object = match json {
|
||||
Json::String(id) => {
|
||||
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
let file = pack_root.join(format!(
|
||||
"assets/{namespace}/polytone/biome_id_mappers/{path}.json"
|
||||
));
|
||||
horizon_world::json_file::read::<Json>(&file).unwrap_or(Json::Null)
|
||||
}
|
||||
other => other.clone(),
|
||||
};
|
||||
let Some(object) = object.as_object() else {
|
||||
return HashMap::new();
|
||||
};
|
||||
let size = object
|
||||
.get("texture_size")
|
||||
.and_then(|v| v.as_f64().or_else(|| v.as_str()?.parse().ok()))
|
||||
.filter(|s| *s > 0.0)
|
||||
.unwrap_or(1.0);
|
||||
object
|
||||
.iter()
|
||||
.filter(|(k, _)| *k != "texture_size")
|
||||
.filter_map(|(k, v)| {
|
||||
let id = if k.contains(':') {
|
||||
k.clone()
|
||||
} else {
|
||||
format!("minecraft:{k}")
|
||||
};
|
||||
Some((id, v.as_f64()? / size))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Colormap {
|
||||
/// `assets/<namespace>/polytone/colormaps/<path>.png` and its optional `.json` (without
|
||||
/// one, the axes are the biome's temperature and downfall). The `biome_id` axis isn't
|
||||
/// supported yet.
|
||||
fn load(pack_root: &Path, id: &str) -> Option<Self> {
|
||||
/// one, the axes are the biome's temperature and downfall).
|
||||
pub(crate) fn load(pack_root: &Path, id: &str) -> Option<Self> {
|
||||
let (namespace, path) = id.split_once(':')?;
|
||||
let base = pack_root.join(format!("assets/{namespace}/polytone/colormaps/{path}"));
|
||||
let png = base.with_extension("png");
|
||||
@@ -295,13 +383,26 @@ impl Colormap {
|
||||
};
|
||||
let json: Json =
|
||||
horizon_world::json_file::read(&base.with_extension("json")).unwrap_or(Json::Null);
|
||||
let axis = |key: &str, default: &str| -> Option<horizon_polytone::expr::Program> {
|
||||
let compile = horizon_polytone::expr::Program::compile;
|
||||
let axis = |key: &str, default: &str| -> Option<Axis> {
|
||||
let src = match &json[key] {
|
||||
Json::String(s) => s.clone(),
|
||||
Json::Number(n) => n.to_string(),
|
||||
_ => default.to_owned(),
|
||||
};
|
||||
match horizon_polytone::expr::Program::compile(&src) {
|
||||
// The named samplers (wiki "Default Samplers") as the expressions they stand for.
|
||||
let src = match src.trim() {
|
||||
"biome_id" => {
|
||||
let mapper = biome_id_mapper(pack_root, &json["biome_id_mapper"]);
|
||||
return Some(Axis::BiomeId(mapper, compile("o.biome()").ok()?));
|
||||
}
|
||||
"temperature" | "legacy_temperature" => "o.temperature".to_owned(),
|
||||
"downfall" => "o.downfall".to_owned(),
|
||||
"y_level" => "o.y / 128".to_owned(),
|
||||
"day_time" => "1 - (g.dayTime % 24000) / 24000".to_owned(),
|
||||
_ => src,
|
||||
};
|
||||
match compile(&src).map(Axis::Expr) {
|
||||
Ok(program) => Some(program),
|
||||
Err(e) => {
|
||||
log::warn!("colormap {id} {key}: {e}");
|
||||
@@ -322,24 +423,145 @@ impl Colormap {
|
||||
|
||||
/// The colour the axes pick, ARGB; `None` for a transparent pixel (the value stays what
|
||||
/// it was, the default `fallback_colormap: original`) or an axis that fails.
|
||||
fn sample(&self, host: &mut dyn horizon_polytone::expr::Host) -> Option<u32> {
|
||||
let x = self.x.eval(host).ok()?.num().clamp(0.0, 1.0);
|
||||
let mut y = self.y.eval(host).ok()?.num().clamp(0.0, 1.0);
|
||||
pub(crate) fn sample(&self, host: &mut dyn horizon_polytone::expr::Host) -> Option<u32> {
|
||||
let x = self.x.value(host)?.clamp(0.0, 1.0);
|
||||
let mut y = self.y.value(host)?.clamp(0.0, 1.0);
|
||||
if self.triangular {
|
||||
y *= x;
|
||||
}
|
||||
let pixel = |v: f64, size: u32| {
|
||||
let p = (1.0 - v) * f64::from(size.saturating_sub(1));
|
||||
let pixel = |axis: &Axis, v: f64, size: u32| {
|
||||
let last = size.saturating_sub(1);
|
||||
if let Axis::BiomeId(..) = axis {
|
||||
return ((v * f64::from(size)).floor() as u32).min(last);
|
||||
}
|
||||
let p = (1.0 - v) * f64::from(last);
|
||||
let p = if self.rounds { p.round() } else { p.floor() };
|
||||
(p as u32).min(size.saturating_sub(1))
|
||||
(p as u32).min(last)
|
||||
};
|
||||
let (px, py) = (pixel(x, self.width), pixel(y, self.height));
|
||||
let (px, py) = (
|
||||
pixel(&self.x, x, self.width),
|
||||
pixel(&self.y, y, self.height),
|
||||
);
|
||||
let i = (py as usize * self.width as usize + px as usize) * 4;
|
||||
let [r, g, b, a] = self.rgba.get(i..i + 4)?.try_into().ok()?;
|
||||
(a != 0).then(|| u32::from(a) << 24 | u32::from(r) << 16 | u32::from(g) << 8 | u32::from(b))
|
||||
}
|
||||
}
|
||||
|
||||
/// The colormaps of the Polytone block and fluid modifiers of the packs merged at `pack_root`:
|
||||
/// (a fluid modifier, its targets, the colormap's id). Inline colormaps aren't read.
|
||||
pub(crate) fn polytone_colormap_modifiers(
|
||||
pack_root: &Path,
|
||||
) -> Vec<(bool, horizon_polytone::Targets, String)> {
|
||||
let mut out = Vec::new();
|
||||
let namespaces = std::fs::read_dir(pack_root.join("assets"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten();
|
||||
for namespace in namespaces {
|
||||
for (kind, fluid) in [("block_modifiers", false), ("fluid_modifiers", true)] {
|
||||
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 Ok(json) = horizon_world::json_file::read::<Json>(&path) else {
|
||||
continue;
|
||||
};
|
||||
let Some(id) = json.get("colormap").and_then(Json::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let id = if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
};
|
||||
let relative = path
|
||||
.strip_prefix(pack_root)
|
||||
.unwrap_or(&path)
|
||||
.to_string_lossy();
|
||||
let implicit = horizon_polytone::implicit_id(&relative, kind);
|
||||
match horizon_polytone::Targets::parse(json.get("targets"), implicit.as_deref())
|
||||
{
|
||||
Ok(targets) => out.push((fluid, targets, id)),
|
||||
Err(e) => log::warn!("{relative}: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// What a block colormap's axes read where it samples: the block (`o.x`, `o.biome()`, its
|
||||
/// biome's climate) and `r.noise`. The world isn't reachable from the mesher, so the rest fails
|
||||
/// and the block keeps its own tint.
|
||||
pub(crate) struct BlockHost<'a> {
|
||||
at: [i32; 3],
|
||||
biome: &'a crate::biome::BiomeInfo,
|
||||
}
|
||||
|
||||
impl<'a> BlockHost<'a> {
|
||||
pub(crate) fn new(at: [i32; 3], biome: &'a crate::biome::BiomeInfo) -> Self {
|
||||
Self { at, biome }
|
||||
}
|
||||
}
|
||||
|
||||
/// The simplex noise `r.noise(x, y)` reads, the same for blocks and particles.
|
||||
pub(crate) fn polytone_noise() -> &'static horizon_core::noise::SimplexNoise {
|
||||
static NOISE: std::sync::OnceLock<horizon_core::noise::SimplexNoise> =
|
||||
std::sync::OnceLock::new();
|
||||
NOISE.get_or_init(|| {
|
||||
horizon_core::noise::SimplexNoise::new(&mut horizon_core::random::LegacyRandom::new(0))
|
||||
})
|
||||
}
|
||||
|
||||
impl horizon_polytone::expr::Host for BlockHost<'_> {
|
||||
fn root(&mut self, root: horizon_polytone::expr::Root) -> horizon_polytone::expr::Value {
|
||||
use horizon_polytone::expr::Root;
|
||||
horizon_polytone::expr::Value::Object(match root {
|
||||
Root::Object => 1,
|
||||
Root::Random => 2,
|
||||
_ => 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn method(
|
||||
&mut self,
|
||||
target: &horizon_polytone::expr::Value,
|
||||
name: &str,
|
||||
args: &[horizon_polytone::expr::Value],
|
||||
) -> std::result::Result<horizon_polytone::expr::Value, String> {
|
||||
use horizon_polytone::expr::Value as V;
|
||||
let num = |n: f64| Ok(V::Num(n));
|
||||
match (target, name) {
|
||||
(V::Object(1), "x" | "y" | "z") => {
|
||||
num(f64::from(self.at[usize::from(name.as_bytes()[0] - b'x')]))
|
||||
}
|
||||
(V::Object(1), "biome") => Ok(V::Str(self.biome.name.as_str().into())),
|
||||
(V::Object(1), "temperature") => num(f64::from(self.biome.temperature)),
|
||||
(V::Object(1), "downfall") => num(f64::from(self.biome.downfall)),
|
||||
(V::Object(2), "noise") => {
|
||||
let mut coords = args.iter().filter(|v| !matches!(v, V::Str(_))).map(V::num);
|
||||
let (x, y) = (coords.next().unwrap_or(0.0), coords.next().unwrap_or(0.0));
|
||||
num(f64::from(polytone_noise().get2(x, y)))
|
||||
}
|
||||
_ => Err(format!("{name} is not available to block colormaps")),
|
||||
}
|
||||
}
|
||||
|
||||
fn function(
|
||||
&mut self,
|
||||
name: &str,
|
||||
_: &[horizon_polytone::expr::Value],
|
||||
) -> std::result::Result<horizon_polytone::expr::Value, String> {
|
||||
Err(format!("{name} is not available to block colormaps"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns Polytone layers into vanilla entries, compiling expressions against the frame.
|
||||
struct Resolver {
|
||||
frame: Arc<RwLock<Frame>>,
|
||||
@@ -968,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.
|
||||
@@ -1016,17 +1243,60 @@ 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]
|
||||
fn biome_id_colormaps_read_the_column_their_mapper_gives_the_biome() {
|
||||
// Left to right: red, green, transparent, blue.
|
||||
let png = png_row(&[
|
||||
[255, 0, 0, 255],
|
||||
[0, 255, 0, 255],
|
||||
[9, 9, 9, 0],
|
||||
[0, 0, 255, 255],
|
||||
]);
|
||||
let env = with_pack_files(&[
|
||||
(
|
||||
"assets/minecraft/polytone/dimension_modifiers/overworld.json",
|
||||
br#"{"attributes_modifiers": {"visual/sky_color": "mypack:sky"}}"#,
|
||||
),
|
||||
(
|
||||
"assets/mypack/polytone/colormaps/sky.json",
|
||||
br#"{"x_axis": "biome_id", "y_axis": 0, "biome_id_mapper": "mypack:biomes"}"#,
|
||||
),
|
||||
("assets/mypack/polytone/colormaps/sky.png", &png),
|
||||
(
|
||||
// Pixel columns from the left, over a texture_size of the image's width.
|
||||
"assets/mypack/polytone/biome_id_mappers/biomes.json",
|
||||
br#"{"minecraft:desert": 3, "swamp": 1, "minecraft:plains": 2, "texture_size": 4}"#,
|
||||
),
|
||||
]);
|
||||
let sky = |biome: &str| {
|
||||
env.set_frame(Frame {
|
||||
biome: biome.to_owned(),
|
||||
..Frame::default()
|
||||
});
|
||||
env.sample(&Sample::default()).sky_color
|
||||
};
|
||||
assert_eq!(sky("minecraft:desert"), [0.0, 0.0, 1.0]);
|
||||
assert_eq!(sky("minecraft:swamp"), [0.0, 1.0, 0.0]);
|
||||
// A biome the mapper doesn't name is 0: the first column.
|
||||
assert_eq!(sky("minecraft:forest"), [1.0, 0.0, 0.0]);
|
||||
// Plains land on the transparent pixel: the colour before stays.
|
||||
assert_eq!(sky("minecraft:plains"), [128.0 / 255.0; 3]);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use crate::{
|
||||
lang,
|
||||
options_ui::{Event, Page},
|
||||
settings::Settings,
|
||||
settings_shell::{self as shell, Group, Output, Row, Screen, Sheet, Value},
|
||||
settings_shell::{self as shell, Binding, Group, Output, Row, Screen, Sheet, Value},
|
||||
};
|
||||
|
||||
const TITLE: &str = "horizon.pages.extras";
|
||||
@@ -36,7 +36,7 @@ pub(crate) enum ExtraRow {
|
||||
fn item_row(item: Item, s: &Settings) -> Row<ExtraRow> {
|
||||
let e = &s.extras;
|
||||
let value = match (item.key_name(), item.switch(e)) {
|
||||
(Some(key), _) => Value::Key(key, item.value(e)),
|
||||
(Some(key), _) => Value::Binding(Binding::key(key, item.value(e))),
|
||||
(None, Some(on)) => Value::Switch(on),
|
||||
(None, None) => Value::Choice(item.value(e)),
|
||||
};
|
||||
@@ -107,22 +107,14 @@ 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 {
|
||||
ExtraRow::Item(item) => match item.key_name() {
|
||||
Some(key) => vec![Output::BindKey(key)],
|
||||
None => {
|
||||
item.cycle(&mut s.extras, back);
|
||||
vec![Output::Event(Event::Changed)]
|
||||
}
|
||||
},
|
||||
ExtraRow::Item(item) => {
|
||||
item.cycle(&mut s.extras, back);
|
||||
vec![Output::Event(Event::Changed)]
|
||||
}
|
||||
ExtraRow::Zoom(i) => {
|
||||
s.mods.toggle(i);
|
||||
vec![Output::Event(Event::Changed)]
|
||||
@@ -208,15 +200,21 @@ mod tests {
|
||||
/// The sort key binds instead of cycling; the wheel leaves it be.
|
||||
#[test]
|
||||
fn the_key_row_waits_for_a_key() {
|
||||
use crate::settings_shell::Capture;
|
||||
let mut s = Settings::default();
|
||||
let mut screen = ExtrasScreen;
|
||||
s.extras.sort = true;
|
||||
let mut shell = Shell::<ExtrasScreen>::default();
|
||||
let row = ExtraRow::Item(Item::SortKey);
|
||||
let key = s.extras.sort_key.clone();
|
||||
let at = shell.point_at(&s, SIZE, row);
|
||||
assert_eq!(
|
||||
screen.press(&mut s, row, false),
|
||||
vec![Output::BindKey(extras::SORT_KEY)]
|
||||
shell.click(&mut s, at, SIZE, false, false, false),
|
||||
vec![
|
||||
Output::Event(Event::ButtonClick),
|
||||
Output::Bind(Capture::Key(extras::SORT_KEY))
|
||||
]
|
||||
);
|
||||
screen.wheel_step(&mut s, row, true);
|
||||
ExtrasScreen.wheel_step(&mut s, row, true);
|
||||
assert_eq!(s.extras.sort_key, key);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -5,6 +5,9 @@
|
||||
//! (`Arena::translucent_indices`): their back-to-front list then stays in one
|
||||
//! page of each and draws in a few multi-draws.
|
||||
use std::collections::{BTreeMap, VecDeque};
|
||||
#[cfg(test)]
|
||||
#[path = "arena/source_tests.rs"]
|
||||
mod source_tests;
|
||||
|
||||
/// Ranges are rounded up to this many vertices to limit fragmentation, and
|
||||
/// start at multiples of it (`flat` names sections by these blocks).
|
||||
@@ -82,6 +85,8 @@ impl FreeList {
|
||||
/// One vertex buffer of the arena; only the newest page grows.
|
||||
struct Page {
|
||||
buffer: wgpu::Buffer,
|
||||
/// Optional shader-pack source records, indexed by the vertex index / 4.
|
||||
sources: Option<wgpu::Buffer>,
|
||||
/// Capacity in vertices.
|
||||
capacity: u64,
|
||||
free: FreeList,
|
||||
@@ -116,6 +121,7 @@ pub struct Arena {
|
||||
closing: Option<u32>,
|
||||
/// A page had to go to system memory (logged once).
|
||||
spilled: bool,
|
||||
sources_enabled: bool,
|
||||
/// Tests: GPU-local pages fail as if video memory were full.
|
||||
#[cfg(test)]
|
||||
video_memory_full: bool,
|
||||
@@ -200,6 +206,7 @@ impl Arena {
|
||||
usage,
|
||||
closing: None,
|
||||
spilled: false,
|
||||
sources_enabled: false,
|
||||
#[cfg(test)]
|
||||
video_memory_full: false,
|
||||
}
|
||||
@@ -249,7 +256,11 @@ impl Arena {
|
||||
}
|
||||
/// Bytes of every page's buffer.
|
||||
pub fn bytes(&self) -> u64 {
|
||||
self.pages.iter().flatten().map(|p| p.capacity).sum::<u64>() * self.stride
|
||||
self.pages
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|p| p.capacity * self.stride + p.sources.as_ref().map_or(0, wgpu::Buffer::size))
|
||||
.sum()
|
||||
}
|
||||
/// The most vertices a page holds.
|
||||
pub fn page_vertices(&self) -> u64 {
|
||||
@@ -281,6 +292,65 @@ impl Arena {
|
||||
.expect("live arena page")
|
||||
.buffer
|
||||
}
|
||||
|
||||
pub fn sources(&self, page: u32) -> Option<&wgpu::Buffer> {
|
||||
self.pages[page as usize].as_ref()?.sources.as_ref()
|
||||
}
|
||||
|
||||
fn source_buffer(device: &wgpu::Device, capacity: u64) -> Option<wgpu::Buffer> {
|
||||
if capacity / 4 * 8 > device.limits().max_storage_buffer_binding_size {
|
||||
return None;
|
||||
}
|
||||
try_create(
|
||||
device,
|
||||
"Terrain quad sources",
|
||||
capacity / 4 * 8,
|
||||
wgpu::BufferUsages::STORAGE
|
||||
| wgpu::BufferUsages::COPY_DST
|
||||
| wgpu::BufferUsages::COPY_SRC,
|
||||
)
|
||||
}
|
||||
|
||||
/// Native meshes allocate no sidecar. Once needed, existing pages also get a zeroed
|
||||
/// sidecar so moves can retain sources. Failure leaves geometry intact and attributes
|
||||
/// unknown; a later source upload may retry its page.
|
||||
pub fn write_sources(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
uploads: &mut super::upload::Uploads,
|
||||
page: u32,
|
||||
start: u64,
|
||||
vertices: u64,
|
||||
sources: &[crate::mesher::TerrainSource],
|
||||
) {
|
||||
if !sources.is_empty() && !self.sources_enabled {
|
||||
self.sources_enabled = true;
|
||||
for p in self.pages.iter_mut().flatten() {
|
||||
p.sources = Self::source_buffer(device, p.capacity);
|
||||
}
|
||||
}
|
||||
let p = self.pages[page as usize].as_mut().expect("live arena page");
|
||||
if p.sources.is_none() && !sources.is_empty() {
|
||||
p.sources = Self::source_buffer(device, p.capacity);
|
||||
}
|
||||
if let Some(buffer) = &p.sources {
|
||||
let bytes = vertices / 4 * 8;
|
||||
if bytes > 0 {
|
||||
// Recycled ranges must never keep another mesh's identity, even for a
|
||||
// missing or incomplete sidecar. Zero records have no valid bit.
|
||||
encoder.clear_buffer(buffer, start / 4 * 8, Some(bytes));
|
||||
if sources.len() as u64 == vertices / 4 {
|
||||
uploads.buffer(
|
||||
encoder,
|
||||
buffer,
|
||||
start / 4 * 8,
|
||||
bytemuck::cast_slice(sources),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Bytes of one vertex (or index).
|
||||
pub fn stride(&self) -> u64 {
|
||||
self.stride
|
||||
@@ -336,6 +406,10 @@ impl Arena {
|
||||
let start = free.take(len).expect("a new page fits one section");
|
||||
let page = Page {
|
||||
buffer,
|
||||
sources: self
|
||||
.sources_enabled
|
||||
.then(|| Self::source_buffer(device, capacity))
|
||||
.flatten(),
|
||||
capacity,
|
||||
free,
|
||||
host,
|
||||
@@ -451,6 +525,10 @@ impl Arena {
|
||||
let free = FreeList::new(capacity);
|
||||
self.pages.push(Some(Page {
|
||||
buffer,
|
||||
sources: self
|
||||
.sources_enabled
|
||||
.then(|| Self::source_buffer(device, capacity))
|
||||
.flatten(),
|
||||
capacity,
|
||||
free,
|
||||
host,
|
||||
@@ -483,6 +561,21 @@ impl Arena {
|
||||
to.1 * self.stride,
|
||||
vertices * self.stride,
|
||||
);
|
||||
if let Some(to_sources) = self.sources(to.0) {
|
||||
let bytes = vertices / 4 * 8;
|
||||
if bytes > 0 {
|
||||
match self.sources(from.0) {
|
||||
Some(from_sources) => encoder.copy_buffer_to_buffer(
|
||||
from_sources,
|
||||
from.1 / 4 * 8,
|
||||
to_sources,
|
||||
to.1 / 4 * 8,
|
||||
bytes,
|
||||
),
|
||||
None => encoder.clear_buffer(to_sources, to.1 / 4 * 8, Some(bytes)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn release(&mut self, page: u32, start: u64, vertices: u64) {
|
||||
let len = Self::span(vertices).min(self.page_limit);
|
||||
@@ -540,9 +633,17 @@ impl Arena {
|
||||
let Some((buffer, host)) = self.create(device, capacity, spill) else {
|
||||
return false;
|
||||
};
|
||||
let sources = self
|
||||
.sources_enabled
|
||||
.then(|| Self::source_buffer(device, capacity))
|
||||
.flatten();
|
||||
let old = self.pages[page].as_mut().expect("live arena page");
|
||||
// Copies recorded for the old buffer run before this one.
|
||||
encoder.copy_buffer_to_buffer(&old.buffer, 0, &buffer, 0, old.capacity * self.stride);
|
||||
if let (Some(from), Some(to)) = (&old.sources, &sources) {
|
||||
encoder.copy_buffer_to_buffer(from, 0, to, 0, from.size());
|
||||
}
|
||||
old.sources = sources;
|
||||
let previous = old.capacity;
|
||||
old.buffer = buffer;
|
||||
old.host = host;
|
||||
@@ -1447,6 +1548,7 @@ mod tests {
|
||||
let free = FreeList(BTreeMap::from([(used, capacity - used)]));
|
||||
let buffer = buffer.clone();
|
||||
arena.pages = vec![Some(Page {
|
||||
sources: None,
|
||||
buffer,
|
||||
capacity,
|
||||
free,
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Original sidecar lifetime controls, executed explicitly on an offscreen adapter.
|
||||
use super::*;
|
||||
use crate::mesher::{TerrainSource, TerrainVertex};
|
||||
|
||||
fn read(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
buffer: &wgpu::Buffer,
|
||||
offset: u64,
|
||||
size: u64,
|
||||
) -> Vec<u8> {
|
||||
let out = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
encoder.copy_buffer_to_buffer(buffer, offset, &out, 0, size);
|
||||
queue.submit([encoder.finish()]);
|
||||
out.slice(..).map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
out.slice(..).get_mapped_range().unwrap().to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "offscreen GPU control; run explicitly with an available adapter"]
|
||||
fn terrain_source_arena_gpu_grow_move_recycle_and_refusal() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default()))
|
||||
.expect("GPU control requires an adapter");
|
||||
eprintln!("TERRAIN_SOURCE_ARENA_ADAPTER {:?}", adapter.get_info());
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let mut arena = Arena::new(&device, 24, GRAIN, true);
|
||||
arena.page_limit = GRAIN * 2;
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let mut uploads = super::super::upload::Uploads::new(&device);
|
||||
let first = arena.alloc(&device, &mut encoder, 4).unwrap();
|
||||
assert!(
|
||||
arena.sources(first.0).is_none(),
|
||||
"default geometry allocates no source buffer"
|
||||
);
|
||||
let geometry = [TerrainVertex::new([2.25, 3.75, 4.5], [0.25, 0.75], 7, 0xff123456, 0x8070); 4];
|
||||
uploads.buffer(
|
||||
&mut encoder,
|
||||
arena.buffer(first.0),
|
||||
first.1 * 24,
|
||||
bytemuck::cast_slice(&geometry),
|
||||
);
|
||||
let source = TerrainSource::new(71, [2, 3, 4], 13, false);
|
||||
arena.write_sources(
|
||||
&device,
|
||||
&mut encoder,
|
||||
&mut uploads,
|
||||
first.0,
|
||||
first.1,
|
||||
4,
|
||||
&[source],
|
||||
);
|
||||
let second = arena.alloc(&device, &mut encoder, GRAIN).unwrap();
|
||||
assert_eq!(second, (first.0, GRAIN), "grew the first page");
|
||||
let third = arena.alloc(&device, &mut encoder, GRAIN).unwrap();
|
||||
assert_ne!(third.0, first.0, "move crosses pages");
|
||||
arena.copy(&mut encoder, first, third, 4);
|
||||
arena.release(first.0, first.1, 4);
|
||||
let reused = arena.alloc_existing(4).unwrap();
|
||||
assert_eq!(reused, first);
|
||||
arena.write_sources(
|
||||
&device,
|
||||
&mut encoder,
|
||||
&mut uploads,
|
||||
reused.0,
|
||||
reused.1,
|
||||
4,
|
||||
&[],
|
||||
);
|
||||
uploads.finish(&encoder);
|
||||
queue.submit([encoder.finish()]);
|
||||
assert_eq!(
|
||||
read(
|
||||
&device,
|
||||
&queue,
|
||||
arena.sources(third.0).unwrap(),
|
||||
third.1 / 4 * 8,
|
||||
8
|
||||
),
|
||||
bytemuck::bytes_of(&source)
|
||||
);
|
||||
assert_eq!(
|
||||
read(
|
||||
&device,
|
||||
&queue,
|
||||
arena.sources(first.0).unwrap(),
|
||||
first.1 / 4 * 8,
|
||||
8
|
||||
),
|
||||
[0; 8]
|
||||
);
|
||||
assert_eq!(
|
||||
read(&device, &queue, arena.buffer(third.0), third.1 * 24, 96),
|
||||
bytemuck::cast_slice::<_, u8>(&geometry)
|
||||
);
|
||||
|
||||
// A real requested binding limit refuses metadata/table allocation. The native
|
||||
// vertex allocation still fits; this is a controlled refusal, not a real OOM.
|
||||
let limits = wgpu::Limits {
|
||||
max_storage_buffer_binding_size: 256,
|
||||
..Default::default()
|
||||
};
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
required_limits: limits,
|
||||
..Default::default()
|
||||
}))
|
||||
.unwrap();
|
||||
let mut arena = Arena::translucent(&device, 24, GRAIN);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let mut uploads = super::super::upload::Uploads::new(&device);
|
||||
let at = arena.alloc(&device, &mut encoder, 4).unwrap();
|
||||
uploads.buffer(
|
||||
&mut encoder,
|
||||
arena.buffer(at.0),
|
||||
at.1 * 24,
|
||||
bytemuck::cast_slice(&geometry),
|
||||
);
|
||||
arena.write_sources(
|
||||
&device,
|
||||
&mut encoder,
|
||||
&mut uploads,
|
||||
at.0,
|
||||
at.1,
|
||||
4,
|
||||
&[source],
|
||||
);
|
||||
assert!(
|
||||
arena.sources(at.0).is_none(),
|
||||
"sidecar refused without losing vertices"
|
||||
);
|
||||
assert!(super::super::pack_post::block_ids(&device, &queue, &[0; 128], &[0, 1]).is_none());
|
||||
uploads.finish(&encoder);
|
||||
queue.submit([encoder.finish()]);
|
||||
assert_eq!(
|
||||
read(&device, &queue, arena.buffer(at.0), at.1 * 24, 96),
|
||||
bytemuck::cast_slice::<_, u8>(&geometry)
|
||||
);
|
||||
}
|
||||
@@ -64,8 +64,10 @@ impl Copies {
|
||||
fn copy(mesh: &SectionMesh) -> SectionMesh {
|
||||
SectionMesh {
|
||||
solid: mesh.solid.clone(),
|
||||
solid_sources: mesh.solid_sources.clone(),
|
||||
sides: mesh.sides,
|
||||
translucent: mesh.translucent.clone(),
|
||||
translucent_sources: mesh.translucent_sources.clone(),
|
||||
packed: mesh.packed.clone(),
|
||||
packed_far: mesh.packed_far,
|
||||
solid_tints: Vec::new(),
|
||||
|
||||
@@ -27,11 +27,13 @@
|
||||
//! as many draws as without culling: drivers that unroll an indirect
|
||||
//! multi-draw on the CPU (Mesa's on Intel Gen9) pay per entry. Phase 2's
|
||||
//! counts come from the GPU, which reads them from the draws themselves:
|
||||
//! Vulkan's native multi-draw, and Metal's, which wgpu replays as one
|
||||
//! indirect draw per entry. Translucent draws are not culled.
|
||||
//! Vulkan's native multi-draw, DirectX 12's `ExecuteIndirect`, and Metal's,
|
||||
//! which wgpu replays as one indirect draw per entry. Translucent draws are
|
||||
//! not culled.
|
||||
//!
|
||||
//! Metal cannot force the depth test before a fragment shader that writes
|
||||
//! storage (naga writes no `[[early_fragment_tests]]`), so there the boxes
|
||||
//! storage (naga writes no `[[early_fragment_tests]]`), nor can DirectX 12
|
||||
//! (`[earlydepthstencil]`, which wgpu doesn't offer there), so there the boxes
|
||||
//! are drawn in a pass of their own between the phases, which reads phase
|
||||
//! 1's depth as a texture and makes the same comparison in the shader: the
|
||||
//! fragment's depth against the stored one, greater or equal passing. The
|
||||
@@ -224,9 +226,9 @@ impl Cull {
|
||||
adapter.features() & wgpu::Features::SHADER_EARLY_DEPTH_TEST
|
||||
}
|
||||
/// Where the device can run the test, if it can: Vulkan with early
|
||||
/// depth tests, or Metal, both drawing an indirect multi-draw's entries
|
||||
/// with the instance counts the GPU wrote, with fragment shaders that
|
||||
/// write storage.
|
||||
/// depth tests, or Metal and DirectX 12 with the test in the shader, all
|
||||
/// drawing an indirect multi-draw's entries with the instance counts the
|
||||
/// GPU wrote, with fragment shaders that write storage.
|
||||
pub fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> Option<DepthTest> {
|
||||
let depth_test = match adapter.get_info().backend {
|
||||
wgpu::Backend::Vulkan | wgpu::Backend::Gl
|
||||
@@ -236,7 +238,9 @@ impl Cull {
|
||||
{
|
||||
DepthTest::Early
|
||||
}
|
||||
wgpu::Backend::Metal => DepthTest::InShader,
|
||||
// DirectX 12 has no forced early depth test in wgpu either: the test runs in the
|
||||
// shader there too.
|
||||
wgpu::Backend::Metal | wgpu::Backend::Dx12 => DepthTest::InShader,
|
||||
_ => return None,
|
||||
};
|
||||
(device
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Which compiler turns DirectX 12's shaders into bytecode: DXC on integrated
|
||||
//! GPUs, FXC on every other adapter (#8513).
|
||||
//!
|
||||
//! Measured on one laptop at render distance 32 (2560x1440, the same world and
|
||||
//! view): DXC took Intel Iris Xe from 44 to 65 FPS (22.1 to 14.7 ms of GPU
|
||||
//! time) and an NVIDIA RTX 3050 Ti from 159 down to 150 FPS. An RX 9070 gained
|
||||
//! about 9 % of GPU time at render distance 32 and lost 0.03 ms at 8. So
|
||||
//! discrete GPUs keep FXC (`d3dcompiler_47.dll`, part of Windows), and only
|
||||
//! integrated ones load DXC (`dxcompiler.dll`, which the Windows x64 zip puts
|
||||
//! beside the game: `tools/ci/fetch_dxc.py`).
|
||||
//!
|
||||
//! wgpu chooses the compiler for a whole instance, before any adapter is
|
||||
//! known. `open_backend` therefore asks for the adapter on an FXC instance and,
|
||||
//! when that adapter is an integrated GPU on DirectX 12, asks again on an
|
||||
//! instance with DXC. That happens once, when the device is opened; nothing
|
||||
//! here runs per frame. Without the DLL, or when it does not load, the game
|
||||
//! keeps the FXC instance and logs one line. `WGPU_DX12_COMPILER` (`fxc`,
|
||||
//! `dxc`, `auto`) overrides the choice: wgpu applies it to every instance.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The DLL's name, as the Windows zip ships it beside `horizon-client.exe`.
|
||||
pub(super) const DLL: &str = "dxcompiler.dll";
|
||||
|
||||
/// Whether `info`, opened on an FXC instance, should be opened again with DXC:
|
||||
/// an integrated GPU on DirectX 12, unless `forced` (`WGPU_DX12_COMPILER` is
|
||||
/// set, so its compiler is already the one every instance uses).
|
||||
pub(super) fn wants_dxc(info: &wgpu::AdapterInfo, forced: bool) -> bool {
|
||||
!forced
|
||||
&& info.backend == wgpu::Backend::Dx12
|
||||
&& info.device_type == wgpu::DeviceType::IntegratedGpu
|
||||
}
|
||||
|
||||
/// Whether `WGPU_DX12_COMPILER` names the compiler.
|
||||
pub(super) fn forced() -> bool {
|
||||
std::env::var_os("WGPU_DX12_COMPILER").is_some()
|
||||
}
|
||||
|
||||
/// `dxcompiler.dll` beside the running executable, if it is there. An absolute
|
||||
/// path, so a copy elsewhere on `PATH` (an older Windows SDK's) is never the
|
||||
/// one loaded.
|
||||
pub(super) fn bundled_dll() -> Option<PathBuf> {
|
||||
let dll = std::env::current_exe().ok()?.parent()?.join(DLL);
|
||||
dll.is_file().then_some(dll)
|
||||
}
|
||||
|
||||
/// The compiler setting that loads `dll`, and fails the instance (rather
|
||||
/// than falling back to FXC on its own) when it doesn't load: `open_backend`
|
||||
/// then keeps the FXC instance it already has.
|
||||
pub(super) fn dxc(dll: &std::path::Path) -> wgpu::Dx12Compiler {
|
||||
wgpu::Dx12Compiler::DynamicDxc {
|
||||
dxc_path: dll.to_string_lossy().into_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the DXC instance offered the adapter the FXC instance had: on a
|
||||
/// laptop with two GPUs, the same request should pick the same one, and the
|
||||
/// game must not switch GPUs because the compiler changed.
|
||||
pub(super) fn same_adapter(fxc: &wgpu::AdapterInfo, dxc: &wgpu::AdapterInfo) -> bool {
|
||||
fxc.backend == dxc.backend
|
||||
&& fxc.vendor == dxc.vendor
|
||||
&& fxc.device == dxc.device
|
||||
&& fxc.device_type == dxc.device_type
|
||||
&& fxc.device_pci_bus_id == dxc.device_pci_bus_id
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn adapter(backend: wgpu::Backend, device_type: wgpu::DeviceType) -> wgpu::AdapterInfo {
|
||||
wgpu::AdapterInfo {
|
||||
name: "Intel(R) Iris(R) Xe Graphics".into(),
|
||||
vendor: 0x8086,
|
||||
device: 0x9a49,
|
||||
device_type,
|
||||
device_pci_bus_id: "0000:00:02.0".into(),
|
||||
driver: String::new(),
|
||||
driver_info: String::new(),
|
||||
backend,
|
||||
subgroup_min_size: 0,
|
||||
subgroup_max_size: 0,
|
||||
transient_saves_memory: None,
|
||||
limit_bucket: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_integrated_gpus_on_directx_12_get_dxc() {
|
||||
use wgpu::{Backend, DeviceType};
|
||||
assert!(wants_dxc(
|
||||
&adapter(Backend::Dx12, DeviceType::IntegratedGpu),
|
||||
false
|
||||
));
|
||||
for kind in [
|
||||
DeviceType::DiscreteGpu,
|
||||
DeviceType::VirtualGpu,
|
||||
DeviceType::Cpu,
|
||||
DeviceType::Other,
|
||||
] {
|
||||
assert!(!wants_dxc(&adapter(Backend::Dx12, kind), false), "{kind:?}");
|
||||
}
|
||||
// Other backends have no shader compiler to choose.
|
||||
for backend in [Backend::Vulkan, Backend::Gl, Backend::Metal] {
|
||||
assert!(
|
||||
!wants_dxc(&adapter(backend, DeviceType::IntegratedGpu), false),
|
||||
"{backend:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wgpu_dx12_compiler_overrides_the_choice() {
|
||||
let igpu = adapter(wgpu::Backend::Dx12, wgpu::DeviceType::IntegratedGpu);
|
||||
assert!(!wants_dxc(&igpu, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_dxc_instance_must_offer_the_same_gpu() {
|
||||
let igpu = adapter(wgpu::Backend::Dx12, wgpu::DeviceType::IntegratedGpu);
|
||||
assert!(same_adapter(&igpu, &igpu.clone()));
|
||||
let dgpu = wgpu::AdapterInfo {
|
||||
name: "NVIDIA GeForce RTX 3050 Ti Laptop GPU".into(),
|
||||
vendor: 0x10de,
|
||||
device: 0x25a0,
|
||||
device_type: wgpu::DeviceType::DiscreteGpu,
|
||||
device_pci_bus_id: "0000:01:00.0".into(),
|
||||
..igpu.clone()
|
||||
};
|
||||
assert!(!same_adapter(&igpu, &dgpu));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -174,11 +175,22 @@ pub fn wanted(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
|
||||
|
||||
/// Whether frames without a shader pack's terrain draw flat: where the backend
|
||||
/// emulates multi-draw (Metal, past `MIN_SECTIONS`), on Intel Gen11/Gen12's Vulkan
|
||||
/// driver, or with `HORIZON_FLAT_DRAWS=1`. Elsewhere per-section draws stay.
|
||||
/// driver, on Qualcomm's Adreno under DirectX 12, or with `HORIZON_FLAT_DRAWS=1`.
|
||||
/// Elsewhere per-section draws stay.
|
||||
pub fn for_vanilla(info: &wgpu::AdapterInfo) -> bool {
|
||||
std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1")
|
||||
|| info.backend == wgpu::Backend::Metal
|
||||
|| is_broken_intel_vulkan(info)
|
||||
|| is_adreno_dx12(info)
|
||||
}
|
||||
|
||||
/// Qualcomm's Adreno under DirectX 12 (Windows on Snapdragon, where Auto picks DirectX 12),
|
||||
/// where wgpu's indirect multi-draw is `ExecuteIndirect`: drawn flat, an X1-45 took 3-11%
|
||||
/// less GPU time a frame at render distance 12 and 3-13% less at 32 (perf_bench's four
|
||||
/// scenes, held still), from 389 sections in view up, the same picture. DXGI names
|
||||
/// Qualcomm `QCOM` (0x4D4F4351); Vulkan, 0x5143.
|
||||
pub fn is_adreno_dx12(info: &wgpu::AdapterInfo) -> bool {
|
||||
info.backend == wgpu::Backend::Dx12 && matches!(info.vendor, 0x4d4f_4351 | 0x5143)
|
||||
}
|
||||
|
||||
/// Intel Gen11 (Ice Lake) and Gen12 (Tiger Lake, Rocket Lake, Alder Lake,
|
||||
@@ -814,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)| {
|
||||
@@ -1044,6 +1056,30 @@ impl Flat {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn adreno_draws_flat_under_dx12_only() {
|
||||
let mut info = wgpu::AdapterInfo {
|
||||
name: "Qualcomm(R) Adreno(TM) X1-45 GPU".into(),
|
||||
vendor: 0x4d4f_4351,
|
||||
device: 0,
|
||||
device_type: wgpu::DeviceType::IntegratedGpu,
|
||||
device_pci_bus_id: String::new(),
|
||||
driver: "31.0.128.0".into(),
|
||||
driver_info: String::new(),
|
||||
backend: wgpu::Backend::Dx12,
|
||||
subgroup_min_size: 0,
|
||||
subgroup_max_size: 0,
|
||||
transient_saves_memory: None,
|
||||
limit_bucket: None,
|
||||
};
|
||||
assert!(is_adreno_dx12(&info));
|
||||
info.backend = wgpu::Backend::Vulkan;
|
||||
assert!(!is_adreno_dx12(&info));
|
||||
info.backend = wgpu::Backend::Dx12;
|
||||
info.vendor = 0x10de;
|
||||
assert!(!is_adreno_dx12(&info));
|
||||
}
|
||||
|
||||
/// Draws as `render` writes them: solid (page, quads at a base vertex)
|
||||
/// first, then translucent (page, sorted page, count, first sorted index,
|
||||
/// base vertex).
|
||||
|
||||
@@ -2735,8 +2735,9 @@ mod tests {
|
||||
|
||||
/// `test` on each device here that draws counts with first instances:
|
||||
/// Vulkan, and DirectX 12 with each of its compilers. Players' DirectX 12
|
||||
/// compiles with FXC, part of Windows: wgpu takes DXC only where
|
||||
/// `dxcompiler.dll` loads, and the game ships none.
|
||||
/// compiles with FXC, part of Windows, and with DXC (`dxcompiler.dll`,
|
||||
/// beside the game in the Windows x64 zip) on integrated GPUs
|
||||
/// (`gfx/dx12_compiler.rs`).
|
||||
fn on_each_device(test: impl Fn(&wgpu::Device, &Queue)) {
|
||||
let mut tested = false;
|
||||
for (backends, compiler, name) in [
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -149,6 +149,21 @@ fn composed() -> Vec<(&'static str, Vec<&'static str>, String)> {
|
||||
vec!["terrain.wgsl", "visbuffer.wgsl"],
|
||||
super::terrain_shader() + include_str!("../visbuffer.wgsl"),
|
||||
),
|
||||
// A native mod's world warp (gfx/world_warp.rs): its copies with the plainest warp.
|
||||
(
|
||||
"world warp terrain",
|
||||
vec![
|
||||
"terrain.wgsl",
|
||||
"world_warp.wgsl",
|
||||
"world_warp_fragment.wgsl",
|
||||
],
|
||||
super::world_warp::terrain_shader(super::world_warp::IDENTITY).unwrap(),
|
||||
),
|
||||
(
|
||||
"world warp entities",
|
||||
vec!["entity.wgsl", "static_entity.wgsl", "world_warp.wgsl"],
|
||||
super::world_warp::entity_shader(super::world_warp::IDENTITY).unwrap(),
|
||||
),
|
||||
(
|
||||
"improved transparency world border",
|
||||
vec!["border.wgsl", "oit.wgsl", "oit_border.wgsl"],
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,16 @@ use super::Vertex;
|
||||
use super::pack_draw::EntitySpan;
|
||||
use glam::{DVec3, Mat4};
|
||||
use std::sync::{
|
||||
OnceLock,
|
||||
Arc, OnceLock,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
mod generated {
|
||||
include!(concat!(env!("OUT_DIR"), "/native_render_generated.rs"));
|
||||
}
|
||||
pub use generated::{HandDrawnEvent, RenderHooks, RenderNeedsEvent, WorldDrawnEvent};
|
||||
pub use generated::{
|
||||
HandDrawnEvent, RenderHooks, RenderNeedsEvent, WorldDrawnEvent, WorldWarpEvent,
|
||||
};
|
||||
pub use wgpu;
|
||||
|
||||
static RENDERERS: OnceLock<Box<[&'static dyn RenderHooks]>> = OnceLock::new();
|
||||
@@ -178,6 +180,38 @@ pub fn render_needs() -> RenderNeeds {
|
||||
needs
|
||||
}
|
||||
|
||||
/// A world warp ([`native_world_warp`]): WGSL that every terrain vertex (far terrain's tiles too)
|
||||
/// and entity vertex goes through, so a mod can bend the drawn world itself: its real geometry,
|
||||
/// light and depth, seen from any side. docs/native-mods.md, "World warps", has the WGSL contract.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub struct WorldWarp {
|
||||
/// WGSL with `fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped`, and optionally,
|
||||
/// after a line `// [fragments]`, `fn world_fragment(f: Fragment) -> Painted` for every
|
||||
/// pixel of the terrain. `None`: no warp.
|
||||
pub source: Option<Arc<str>>,
|
||||
/// Whether this frame draws with the warp. A source given while this is false is still
|
||||
/// built, so a mod can pay for its pipelines before the frame that needs them.
|
||||
pub on: bool,
|
||||
/// What the warp reads as `warp.params`: four `vec4<f32>`.
|
||||
pub params: [f32; 16],
|
||||
}
|
||||
|
||||
/// Asked once a frame, before the world is drawn (not on the title screen's panorama): set
|
||||
/// [`WorldWarp`] to have the frame drawn through your warp. Mods are asked in order and one warp
|
||||
/// draws: the last one set. Keep it cheap: compare and reuse your `Arc<str>`.
|
||||
#[inline(never)]
|
||||
pub fn native_world_warp(warp: &mut WorldWarp) {
|
||||
generated::dispatch_world_warp(RENDERERS.get().map_or(&[], |renderers| renderers), warp);
|
||||
}
|
||||
|
||||
/// This frame's world warp ([`native_world_warp`]).
|
||||
pub fn world_warp() -> WorldWarp {
|
||||
let mut warp = WorldWarp::default();
|
||||
native_world_warp(&mut warp);
|
||||
warp
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -378,6 +412,33 @@ mod tests {
|
||||
assert_eq!(texels, expected);
|
||||
}
|
||||
|
||||
struct Bend(&'static str);
|
||||
impl RenderHooks for Bend {
|
||||
fn world_warp(&self, event: &mut WorldWarpEvent<'_>) {
|
||||
event.warp.source = Some(self.0.into());
|
||||
event.warp.on = true;
|
||||
event.warp.params[0] += 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_mod_to_set_a_warp_draws() {
|
||||
let mut warp = WorldWarp::default();
|
||||
generated::dispatch_world_warp(&[&Depth, &Bend("a"), &Bend("b")], &mut warp);
|
||||
assert_eq!(warp.source.as_deref(), Some("b"));
|
||||
assert!(warp.on);
|
||||
// each saw what the one before left
|
||||
assert_eq!(warp.params[0], 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_mods_there_is_no_warp() {
|
||||
let mut warp = WorldWarp::default();
|
||||
generated::dispatch_world_warp(&[], &mut warp);
|
||||
assert_eq!(warp, WorldWarp::default());
|
||||
assert!(warp.source.is_none() && !warp.on);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_accumulate_in_mod_order() {
|
||||
let mut needs = RenderNeeds::default();
|
||||
|
||||
@@ -630,13 +630,12 @@ impl PackAtlas {
|
||||
"layout(set = 1, binding = 0) readonly buffer HzBlockSlots { uint hz_block_slots[]; };\n\
|
||||
layout(set = 1, binding = 1) readonly buffer HzSlotOrigins { vec4 hz_slot_origins[]; };\n",
|
||||
);
|
||||
// Of each sprite layer, `mc_Entity` (id, then 1 for fluids or 0) and
|
||||
// `at_midBlock.w` (its block's light), the game's table; and the arena page's
|
||||
// vertices (`TerrainVertex`, six words each).
|
||||
// Source metadata follows each quad in its arena page. The immutable table
|
||||
// holds sprite rectangles and the current pack's ids of source states.
|
||||
declarations.push_str(
|
||||
"layout(set = 3, binding = 0) readonly buffer HzBlockIds { int hz_block_ids[]; };\n\
|
||||
"layout(set = 3, binding = 0) readonly buffer HzQuadSources { uvec2 hz_sources[]; };\n\
|
||||
layout(set = 3, binding = 1) readonly buffer HzVertices { uint hz_vertices[]; };\n\
|
||||
layout(set = 3, binding = 2) readonly buffer HzSpriteRects { uvec2 hz_sprite_rects[]; };\n",
|
||||
layout(set = 3, binding = 2) readonly buffer HzTerrainTables { int hz_tables[]; };\n",
|
||||
);
|
||||
"hz_slot_origins[hz_block_slots[uint(gl_VertexIndex) >> 8u]]"
|
||||
} else {
|
||||
@@ -649,7 +648,8 @@ impl PackAtlas {
|
||||
let columns = self.columns;
|
||||
let (cell, side, scale) = if flat {
|
||||
(
|
||||
"uvec2 hz_rect = hz_sprite_rects[hz_layer];\n \
|
||||
"uvec2 hz_rect = uvec2(0u, 1u);\n \
|
||||
if (2u * hz_layer + 1u < uint(hz_tables[0])) hz_rect = uvec2(hz_tables[2u + 2u * hz_layer], hz_tables[3u + 2u * hz_layer]);\n \
|
||||
hz_cell = vec2(float(hz_rect.x & 65535u), float(hz_rect.x >> 16u)) / float(hz_rect.y);\n "
|
||||
.to_owned(),
|
||||
"float(hz_rect.y)",
|
||||
@@ -701,20 +701,27 @@ impl PackAtlas {
|
||||
let mut body = body;
|
||||
if flat {
|
||||
body.push_str(FACE);
|
||||
body.push_str(
|
||||
" uint hz_quad = uint(gl_VertexIndex) / 4u;\n \
|
||||
uvec2 hz_source = uvec2(0u);\n \
|
||||
if (hz_quad < uint(hz_sources.length())) hz_source = hz_sources[hz_quad];\n \
|
||||
bool hz_known_source = (hz_source.y & 8192u) != 0u;\n \
|
||||
int hz_material = -1;\n \
|
||||
if (hz_known_source && hz_source.x < uint(hz_tables[1])) hz_material = hz_tables[2u + uint(hz_tables[0]) + 2u * hz_source.x + ((hz_source.y >> 12u) & 1u)];\n",
|
||||
);
|
||||
}
|
||||
declarations.push_str(
|
||||
"vec2 hz_cell; vec2 hz_scale; vec2 hz_mid; float hz_uv_scale; vec4 hz_tangent;\n",
|
||||
);
|
||||
let entity = if flat {
|
||||
"vec4(float(hz_block_ids[3u * hz_layer]), float(hz_block_ids[3u * hz_layer + 1u]), 0.0, 0.0)"
|
||||
"vec4(float(hz_material), hz_known_source ? float((hz_source.y >> 12u) & 1u) : 0.0, 0.0, 0.0)"
|
||||
} else {
|
||||
"vec4(-1.0, 0.0, 0.0, 0.0)"
|
||||
};
|
||||
// The block a face belongs to: half a block behind its centre (the quad's diagonal);
|
||||
// w, the light it gives (Iris's `BLOCK_EMISSION_ATTRIBUTE`, 0 to 15).
|
||||
// The mesher's owning cell, even for inset, offset, multipart or extended faces;
|
||||
// w is the source's emitted light, independent of sprite and smooth light.
|
||||
let mid_block = if flat {
|
||||
"vec4((floor((hz_p0 + hz_p2) * 0.5 - 0.5 * hz_Normal) + 0.5 - hz_v_pos) * 64.0, \
|
||||
float(hz_block_ids[3u * hz_layer + 2u]))"
|
||||
"(hz_known_source ? vec4((vec3(float(hz_source.y & 15u), float((hz_source.y >> 4u) & 15u), float((hz_source.y >> 8u) & 15u)) + 0.5 - hz_v_pos) * 64.0, float((hz_source.y >> 14u) & 15u)) : vec4(0.0))"
|
||||
} else {
|
||||
"vec4(0.0)"
|
||||
};
|
||||
@@ -758,6 +765,8 @@ impl PackAtlas {
|
||||
item_id_declared: false,
|
||||
far_depth: false,
|
||||
paged_maps: false,
|
||||
water_mask: false,
|
||||
water_patches: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1154,3 +1163,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "pack_atlas/source_ownership_test.rs"]
|
||||
mod source_ownership_test;
|
||||
@@ -49,12 +49,12 @@ fn terrain_fade_reaches_pack_pixels_through_real_instances() {
|
||||
std::env::temp_dir().join(format!("horizon-terrain-fade-{}", std::process::id())),
|
||||
);
|
||||
std::fs::create_dir_all(folder.0.join("shaders")).unwrap();
|
||||
// The guard, initializer, varying and integer output are independent probe code.
|
||||
// The initializer, varying and integer output are independent probe code. The program reads
|
||||
// `mc_chunkFade` unguarded: Horizon defines no `IRIS_FEATURE_*` (spec OQ-2).
|
||||
let vertex = "#version 330\nout vec4 observation;\n\
|
||||
#ifdef IRIS_FEATURE_FADE_VARIABLE\nfloat first = mc_chunkFade;\n#endif\n\
|
||||
void main() {\n#ifdef IRIS_FEATURE_FADE_VARIABLE\n\
|
||||
float first = mc_chunkFade;\n\
|
||||
void main() {\n\
|
||||
observation = vec4(mc_chunkFade, first, gl_Color.a, gl_MultiTexCoord0.x);\n\
|
||||
#else\nobservation = vec4(1.0, 1.0, gl_Color.a, gl_MultiTexCoord0.x);\n#endif\n\
|
||||
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);\n}";
|
||||
let fragment = "#version 330\nin vec4 observation;\nlayout(location = 0) out uvec4 pixel;\n\
|
||||
void main() { pixel = floatBitsToUint(observation); }";
|
||||
@@ -159,7 +159,8 @@ fn terrain_fade_reaches_pack_pixels_through_real_instances() {
|
||||
origin,
|
||||
0x80ffffff,
|
||||
);
|
||||
let fade = if requested { expected } else { 1.0 };
|
||||
// The fade reaches the program whether the pack requested it or not.
|
||||
let fade = expected;
|
||||
let expected = [
|
||||
fade,
|
||||
if geometry { 0.8125 } else { fade },
|
||||
@@ -307,9 +308,12 @@ fn sample(
|
||||
bytemuck::cast_slice(&vertices),
|
||||
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
|
||||
);
|
||||
let ids = buffer(bytemuck::bytes_of(&[0i32; 3]), wgpu::BufferUsages::STORAGE);
|
||||
let ids = buffer(bytemuck::bytes_of(&[0u32; 2]), wgpu::BufferUsages::STORAGE);
|
||||
let rects = buffer(
|
||||
bytemuck::cast_slice(&atlas.rects_table()),
|
||||
bytemuck::cast_slice(&super::super::pack_post::terrain_tables(
|
||||
&[],
|
||||
&atlas.rects_table(),
|
||||
)),
|
||||
wgpu::BufferUsages::STORAGE,
|
||||
);
|
||||
let binding = |n, storage| wgpu::BindGroupLayoutEntry {
|
||||
@@ -326,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 {
|
||||
@@ -363,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();
|
||||
|
||||
@@ -406,13 +406,16 @@ fn sample_probe(
|
||||
bytemuck::bytes_of(&[0., 0., 0., scale]),
|
||||
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
|
||||
);
|
||||
let ids = buffer(bytemuck::bytes_of(&[0i32; 15]), wgpu::BufferUsages::STORAGE);
|
||||
let ids = buffer(bytemuck::bytes_of(&[0u32; 2]), wgpu::BufferUsages::STORAGE);
|
||||
let data = buffer(
|
||||
bytemuck::cast_slice(vertices),
|
||||
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
|
||||
);
|
||||
let rects = buffer(
|
||||
bytemuck::cast_slice(&atlas.rects_table()),
|
||||
bytemuck::cast_slice(&super::super::pack_post::terrain_tables(
|
||||
&[],
|
||||
&atlas.rects_table(),
|
||||
)),
|
||||
wgpu::BufferUsages::STORAGE,
|
||||
);
|
||||
let bindings = [
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
//! Original numeric controls for the production terrain attribute expressions.
|
||||
//! A compute invocation stands for a terrain vertex; it runs the actual input body.
|
||||
use super::*;
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
fn dispatch(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
pipeline: &wgpu::ComputePipeline,
|
||||
groups: &[wgpu::BindGroup],
|
||||
result: &wgpu::Buffer,
|
||||
) -> Vec<[f32; 4]> {
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: result.size(),
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_compute_pass(&Default::default());
|
||||
pass.set_pipeline(pipeline);
|
||||
for (i, g) in groups.iter().enumerate() {
|
||||
pass.set_bind_group(i as u32, g, &[]);
|
||||
}
|
||||
pass.dispatch_workgroups((result.size() / 32) as u32, 1, 1);
|
||||
}
|
||||
encoder.copy_buffer_to_buffer(result, 0, &read, 0, result.size());
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
bytemuck::cast_slice(&read.slice(..).get_mapped_range().unwrap()).to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "offscreen GPU control; run explicitly with an available adapter"]
|
||||
fn terrain_source_ownership_gpu() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default()))
|
||||
.expect("executed GPU control requires an adapter");
|
||||
eprintln!("TERRAIN_SOURCE_ADAPTER {:?}", adapter.get_info());
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let atlas = PackAtlas::new(&device, &mut encoder, &[texture], 1, 1, 1).unwrap();
|
||||
let inputs = atlas.terrain_inputs(true);
|
||||
// Inset faces of two source states sharing sprite zero, an extended multipart face,
|
||||
// and the second state's fluid face. Ownership is authored
|
||||
// fixture data, not inferred from normal, sprite, position or diagonal.
|
||||
let mut vertices = Vec::new();
|
||||
for (x, y) in [(2.0, 3.25), (6.0, 3.25), (7.25, 3.75), (6.0, 3.875)] {
|
||||
for [dx, dz] in [[0.0, 0.0], [0.0, 0.5], [0.5, 0.5], [0.5, 0.0]] {
|
||||
vertices.push(crate::mesher::TerrainVertex::new(
|
||||
[x + dx, y, 4.0 + dz],
|
||||
[0.0; 2],
|
||||
0,
|
||||
u32::MAX,
|
||||
0,
|
||||
));
|
||||
}
|
||||
}
|
||||
let declarations = inputs
|
||||
.declarations
|
||||
.lines()
|
||||
.filter(|l| !l.starts_with("layout(location"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let attribute = |name: &str| {
|
||||
inputs
|
||||
.attributes
|
||||
.iter()
|
||||
.find(|(n, _)| n == name)
|
||||
.unwrap()
|
||||
.1
|
||||
.as_str()
|
||||
};
|
||||
let source = format!(
|
||||
"#version 450\n{declarations}\n\
|
||||
layout(local_size_x=1) in;\n\
|
||||
layout(set=0,binding=0) buffer Results {{ vec4 result[]; }};\n\
|
||||
void main() {{\n\
|
||||
uint hz_index=gl_GlobalInvocationID.x;\n\
|
||||
vec3 hz_v_pos=uintBitsToFloat(uvec3(hz_vertices[6u*hz_index],hz_vertices[6u*hz_index+1u],hz_vertices[6u*hz_index+2u]));\n\
|
||||
uvec2 hz_v_uv=uvec2(0); uint hz_v_layer=0u; vec4 hz_v_color=vec4(1); uvec2 hz_v_light=uvec2(0);\n\
|
||||
vec4 hz_Vertex, hz_MultiTexCoord0, hz_MultiTexCoord1, hz_MultiTexCoord2, hz_Color; vec3 hz_Normal;\n\
|
||||
{}\nresult[2u*hz_index]={}; result[2u*hz_index+1u]={};\n}}",
|
||||
inputs.body.replace("gl_VertexIndex", "hz_index"),
|
||||
attribute("mc_Entity"),
|
||||
attribute("at_midBlock")
|
||||
);
|
||||
let mut frontend = naga::front::glsl::Frontend::default();
|
||||
let module = frontend
|
||||
.parse(
|
||||
&naga::front::glsl::Options::from(naga::ShaderStage::Compute),
|
||||
&source,
|
||||
)
|
||||
.unwrap();
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Original terrain numeric control"),
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
|
||||
});
|
||||
let layouts: Vec<_> = [1, 2, 0, 3]
|
||||
.into_iter()
|
||||
.map(|count| {
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: None,
|
||||
entries: &(0..count)
|
||||
.map(|binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: if count == 1 {
|
||||
wgpu::ShaderStages::COMPUTE
|
||||
} else {
|
||||
wgpu::ShaderStages::COMPUTE | wgpu::ShaderStages::VERTEX
|
||||
},
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Storage {
|
||||
read_only: binding != 0 || count != 1,
|
||||
},
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: None,
|
||||
bind_group_layouts: &layouts.iter().map(Some).collect::<Vec<_>>(),
|
||||
..Default::default()
|
||||
});
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: None,
|
||||
layout: Some(&layout),
|
||||
module: &shader,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let storage = |bytes: &[u8]| {
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytes,
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
})
|
||||
};
|
||||
let result = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: vertices.len() as u64 * 32,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let slots = storage(bytemuck::cast_slice(&[0u32]));
|
||||
let origins = storage(bytemuck::cast_slice(&[[128.0f32, -32.0, -256.0, -1.0]]));
|
||||
let ids = storage(bytemuck::cast_slice(&[
|
||||
crate::mesher::TerrainSource::new(0, [2, 3, 4], 0, false),
|
||||
crate::mesher::TerrainSource::new(1, [6, 3, 4], 13, false),
|
||||
crate::mesher::TerrainSource::new(1, [6, 3, 4], 13, false),
|
||||
crate::mesher::TerrainSource::new(1, [6, 3, 4], 13, true),
|
||||
]));
|
||||
let vertex_storage = storage(bytemuck::cast_slice(&vertices));
|
||||
let rects = storage(bytemuck::cast_slice(
|
||||
&super::super::pack_post::terrain_tables(&[31, -1, 47, 59], &[0, 1]),
|
||||
));
|
||||
let group = |set: u32, buffers: &[&wgpu::Buffer]| {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(set),
|
||||
entries: &buffers
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, b)| wgpu::BindGroupEntry {
|
||||
binding: i as u32,
|
||||
resource: b.as_entire_binding(),
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
})
|
||||
};
|
||||
let groups = [
|
||||
group(0, &[&result]),
|
||||
group(1, &[&slots, &origins]),
|
||||
group(2, &[]),
|
||||
group(3, &[&ids, &vertex_storage, &rects]),
|
||||
];
|
||||
queue.submit([encoder.finish()]);
|
||||
let actual = dispatch(&device, &queue, &pipeline, &groups, &result);
|
||||
let mut mismatches = Vec::new();
|
||||
for (i, vertex) in vertices.iter().enumerate() {
|
||||
let q = i / 4;
|
||||
let expected_id = [31.0, 47.0, 47.0, 59.0][q];
|
||||
let expected_light = [0.0, 13.0, 13.0, 13.0][q];
|
||||
let owner = [
|
||||
[2.5, 3.5, 4.5],
|
||||
[6.5, 3.5, 4.5],
|
||||
[6.5, 3.5, 4.5],
|
||||
[6.5, 3.5, 4.5],
|
||||
][q];
|
||||
let expected_mid = [
|
||||
(owner[0] - vertex.pos[0]) * 64.0,
|
||||
(owner[1] - vertex.pos[1]) * 64.0,
|
||||
(owner[2] - vertex.pos[2]) * 64.0,
|
||||
expected_light,
|
||||
];
|
||||
if actual[i * 2] != [expected_id, if q == 3 { 1.0 } else { 0.0 }, 0.0, 0.0]
|
||||
|| actual[i * 2 + 1] != expected_mid
|
||||
{
|
||||
mismatches.push((
|
||||
i,
|
||||
actual[i * 2],
|
||||
actual[i * 2 + 1],
|
||||
expected_id,
|
||||
expected_mid,
|
||||
));
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
mismatches.is_empty(),
|
||||
"terrain source mismatches: {mismatches:?}"
|
||||
);
|
||||
|
||||
// Rasterize the same native vertices and production input body, with a diagnostic
|
||||
// projection placing one face in each strip. The fragment reports id, emission and
|
||||
// reconstructed owner Y/X after a nonzero section translation; no texture color
|
||||
// can hide a wrong ownership value.
|
||||
let vertex_source = format!(
|
||||
"#version 450\n{}\nlayout(location=0) flat out vec4 control;\n\
|
||||
void main(){{vec4 hz_Vertex,hz_MultiTexCoord0,hz_MultiTexCoord1,hz_MultiTexCoord2,hz_Color;vec3 hz_Normal;\n\
|
||||
{}\nvec4 entity={};vec4 mid={};\n\
|
||||
control=vec4(entity.x,mid.w,hz_Vertex.y+mid.y/64.0,hz_Vertex.x+mid.x/64.0);\n\
|
||||
uint corner=uint(gl_VertexIndex)%4u;float q=float(uint(gl_VertexIndex)/4u);\n\
|
||||
gl_Position=vec4(-1.0+(q+float(corner>=2u))*0.5,(corner==1u||corner==2u)?1.0:-1.0,0.5,1.0);}}",
|
||||
inputs.declarations,
|
||||
inputs.body,
|
||||
attribute("mc_Entity"),
|
||||
attribute("at_midBlock")
|
||||
);
|
||||
let vertex_module = frontend
|
||||
.parse(
|
||||
&naga::front::glsl::Options::from(naga::ShaderStage::Vertex),
|
||||
&vertex_source,
|
||||
)
|
||||
.unwrap();
|
||||
let vertex_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(vertex_module)),
|
||||
});
|
||||
let fragment_shader=device.create_shader_module(wgpu::ShaderModuleDescriptor { label:None,source:wgpu::ShaderSource::Wgsl(
|
||||
"@fragment fn fs(@location(0) @interpolate(flat) control:vec4<f32>)->@location(0) vec4<f32>{return control;}".into()) });
|
||||
let raster = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: None,
|
||||
layout: Some(&layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &vertex_shader,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[Some(wgpu::VertexBufferLayout {
|
||||
array_stride: 24,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &super::super::TERRAIN_ATTRIBUTES,
|
||||
})],
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &fragment_shader,
|
||||
entry_point: Some("fs"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: wgpu::TextureFormat::Rgba32Float,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytemuck::cast_slice(&vertices),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
let indices: Vec<u32> = (0..4)
|
||||
.flat_map(|q| [4 * q, 4 * q + 1, 4 * q + 2, 4 * q, 4 * q + 2, 4 * q + 3])
|
||||
.collect();
|
||||
let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytemuck::cast_slice(&indices),
|
||||
usage: wgpu::BufferUsages::INDEX,
|
||||
});
|
||||
let target = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 16,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba32Float,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = target.create_view(&Default::default());
|
||||
let pixels = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 256,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&raster);
|
||||
for (i, g) in groups.iter().enumerate() {
|
||||
pass.set_bind_group(i as u32, g, &[]);
|
||||
}
|
||||
pass.set_vertex_buffer(0, vertex_buffer.slice(..));
|
||||
pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.draw_indexed(0..24, 0, 0..1);
|
||||
}
|
||||
encoder.copy_texture_to_buffer(
|
||||
target.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &pixels,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: Some(1),
|
||||
},
|
||||
},
|
||||
target.size(),
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
pixels
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let pixels = pixels.slice(..).get_mapped_range().unwrap();
|
||||
let pixels: &[[f32; 4]] = bytemuck::cast_slice(&pixels);
|
||||
for (i, pixel) in pixels.iter().enumerate() {
|
||||
assert_eq!(
|
||||
*pixel,
|
||||
[
|
||||
[31.0, 0.0, -28.5, 130.5],
|
||||
[47.0, 13.0, -28.5, 134.5],
|
||||
[47.0, 13.0, -28.5, 134.5],
|
||||
[59.0, 13.0, -28.5, 134.5]
|
||||
][i / 4],
|
||||
"diagnostic raster pixel {i}"
|
||||
);
|
||||
}
|
||||
|
||||
// Reload changes only the immutable mapping; the same raw state/owner records
|
||||
// resolve to new material ids without baking a previous pack's ids into a mesh.
|
||||
let changed = storage(bytemuck::cast_slice(
|
||||
&super::super::pack_post::terrain_tables(&[211, -1, 223, 59], &[0, 1]),
|
||||
));
|
||||
let changed_groups = [
|
||||
groups[0].clone(),
|
||||
groups[1].clone(),
|
||||
groups[2].clone(),
|
||||
group(3, &[&ids, &vertex_storage, &changed]),
|
||||
];
|
||||
let reloaded = dispatch(&device, &queue, &pipeline, &changed_groups, &result);
|
||||
for i in 0..vertices.len() {
|
||||
assert_eq!(
|
||||
reloaded[2 * i],
|
||||
[
|
||||
if i < 4 {
|
||||
211.0
|
||||
} else if i < 12 {
|
||||
223.0
|
||||
} else {
|
||||
59.0
|
||||
},
|
||||
if i >= 12 { 1.0 } else { 0.0 },
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
);
|
||||
assert_eq!(reloaded[2 * i + 1], actual[2 * i + 1]);
|
||||
}
|
||||
let missing = storage(&[0; 8]);
|
||||
let missing_groups = [
|
||||
groups[0].clone(),
|
||||
groups[1].clone(),
|
||||
groups[2].clone(),
|
||||
group(3, &[&missing, &vertex_storage, &rects]),
|
||||
];
|
||||
let neutral = dispatch(&device, &queue, &pipeline, &missing_groups, &result);
|
||||
for i in 0..vertices.len() {
|
||||
assert_eq!(neutral[2 * i], [-1.0, 0.0, 0.0, 0.0]);
|
||||
assert_eq!(neutral[2 * i + 1], [0.0; 4]);
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
@@ -247,7 +254,7 @@ impl Casters {
|
||||
pub fn cast(&self, span: &EntitySpan, program: usize) -> bool {
|
||||
match program {
|
||||
ENTITY => self.entities && (!self.only_player || span.player),
|
||||
BLOCK => self.blocks && (!self.only_lit || span.emissive),
|
||||
BLOCK | BLOCK_TRANSLUCENT => self.blocks && (!self.only_lit || span.emissive),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -440,7 +447,7 @@ impl PackEntities {
|
||||
let posed = skin.map_or(0, |s| s.vertices);
|
||||
if let Some(skin) = skin {
|
||||
for (span, range, overlay) in skin_runs(skin) {
|
||||
let program = program_of(&span).expect("tagged skin stage");
|
||||
let program = material_program(&span, overlay, drawn).expect("tagged skin stage");
|
||||
let pack = drawn[program] && !span.hidden;
|
||||
let casts = shadow.cast(&span, program);
|
||||
if !pack && !casts {
|
||||
@@ -455,14 +462,16 @@ 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) in runs(entities.len(), spans) {
|
||||
for (span, range, material) in material_runs(entities, spans, drawn) {
|
||||
let run_start = range.start as u32;
|
||||
let whole = &entities[range];
|
||||
// A native fluid carries sprite-array UVs, not shared-atlas UVs.
|
||||
@@ -490,18 +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 = (program_of(&span) == Some(BLOCK) || span.stage == render_stage("NONE"))
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -509,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() {
|
||||
@@ -520,7 +544,9 @@ impl PackEntities {
|
||||
let program = match part {
|
||||
Part::Portal => Some(BLOCK),
|
||||
Part::Beam => Some(BEAM),
|
||||
Part::Plain => program_of(&span).filter(|&p| {
|
||||
// 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)))
|
||||
}),
|
||||
@@ -528,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);
|
||||
}
|
||||
@@ -540,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 {
|
||||
@@ -562,6 +594,7 @@ impl PackEntities {
|
||||
casts,
|
||||
overlay,
|
||||
portal,
|
||||
part == Part::WaterMask,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -633,9 +666,10 @@ impl PackEntities {
|
||||
casts: bool,
|
||||
overlay: u32,
|
||||
portal: bool,
|
||||
water_mask: bool,
|
||||
) {
|
||||
let (entity_id, block_id) = match program {
|
||||
BLOCK => (0, span.id),
|
||||
BLOCK | BLOCK_TRANSLUCENT => (0, span.id),
|
||||
_ => (span.id, 0),
|
||||
};
|
||||
let mut bytes = [0u8; SLOT as usize];
|
||||
@@ -657,6 +691,7 @@ impl PackEntities {
|
||||
shadow: casts,
|
||||
slot: self.draws.len() as u32,
|
||||
portal,
|
||||
water_mask,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -691,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
|
||||
@@ -782,7 +819,76 @@ pub const PARTICLE: usize = 3;
|
||||
pub const DESTROY: usize = 4;
|
||||
/// Beacon and End gateway beams (`gbuffers_beaconbeam`).
|
||||
pub const BEAM: usize = 5;
|
||||
pub const PROGRAMS: usize = 6;
|
||||
pub const BLOCK_TRANSLUCENT: usize = 6;
|
||||
pub const PARTICLE_TRANSLUCENT: usize = 7;
|
||||
pub const PROGRAMS: usize = 8;
|
||||
|
||||
/// Translucent blocks retain the ordinary block shadow program and blockEntityId.
|
||||
pub fn shadow_program(program: usize) -> usize {
|
||||
if program == BLOCK_TRANSLUCENT {
|
||||
BLOCK
|
||||
} else {
|
||||
program
|
||||
}
|
||||
}
|
||||
|
||||
fn material_program(span: &EntitySpan, layer: u32, drawn: [bool; PROGRAMS]) -> Option<usize> {
|
||||
Some(match program_of(span)? {
|
||||
BLOCK
|
||||
if drawn[BLOCK_TRANSLUCENT]
|
||||
&& (span.translucent
|
||||
|| layer & super::OIT_ENTITY != 0
|
||||
|| matches!(
|
||||
layer & 0xffff,
|
||||
super::TRANSLUCENT_ITEM_OVERLAY | super::TRANSLUCENT_GLINT_ITEM_OVERLAY
|
||||
)) =>
|
||||
{
|
||||
BLOCK_TRANSLUCENT
|
||||
}
|
||||
PARTICLE
|
||||
if drawn[PARTICLE_TRANSLUCENT]
|
||||
&& layer & 0xffff == super::TRANSLUCENT_PARTICLE_OVERLAY =>
|
||||
{
|
||||
PARTICLE_TRANSLUCENT
|
||||
}
|
||||
p => p,
|
||||
})
|
||||
}
|
||||
|
||||
/// Split only where an available variant changes a quad's program. Keep contiguous runs,
|
||||
/// their vertex order and the original single draw when no material variant is usable.
|
||||
fn material_runs<'a>(
|
||||
vertices: &'a [Vertex],
|
||||
spans: &'a [EntitySpan],
|
||||
drawn: [bool; PROGRAMS],
|
||||
) -> impl Iterator<Item = (EntitySpan, Range<usize>, Option<usize>)> + 'a {
|
||||
runs(vertices.len(), spans).flat_map(move |(span, range)| {
|
||||
let split = match program_of(&span) {
|
||||
Some(BLOCK) => drawn[BLOCK_TRANSLUCENT],
|
||||
Some(PARTICLE) => drawn[PARTICLE_TRANSLUCENT],
|
||||
_ => false,
|
||||
};
|
||||
let mut start = range.start;
|
||||
std::iter::from_fn(move || {
|
||||
if start >= range.end {
|
||||
return None;
|
||||
}
|
||||
let program = material_program(&span, vertices[start].layer, drawn);
|
||||
let end = if split {
|
||||
(start..range.end)
|
||||
.step_by(4)
|
||||
.skip(1)
|
||||
.find(|&i| material_program(&span, vertices[i].layer, drawn) != program)
|
||||
.unwrap_or(range.end)
|
||||
} else {
|
||||
range.end
|
||||
};
|
||||
let run = start..end;
|
||||
start = end;
|
||||
Some((span, run, program))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Which program draws `span`'s run, given which of them (or their fallbacks) the pack has;
|
||||
/// `None`: Horizon draws it.
|
||||
@@ -940,11 +1046,17 @@ mod tests {
|
||||
assert_eq!(drawn_by_pack(&spans[0], all), Some(ENTITY));
|
||||
assert_eq!(drawn_by_pack(&spans[1], all), Some(PARTICLE));
|
||||
assert_eq!(
|
||||
drawn_by_pack(&spans[1], [true, true, true, false, true, true]),
|
||||
drawn_by_pack(
|
||||
&spans[1],
|
||||
[true, true, true, false, true, true, false, false]
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
drawn_by_pack(&spans[2], [true, false, true, true, true, true]),
|
||||
drawn_by_pack(
|
||||
&spans[2],
|
||||
[true, false, true, true, true, true, false, false]
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(drawn_by_pack(&spans[2], all), Some(BLOCK));
|
||||
@@ -1370,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();
|
||||
@@ -1676,3 +1861,6 @@ mod tier_translate_tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod material_route_tests;
|
||||
@@ -0,0 +1,62 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn optional_variants_split_contiguous_materials_without_guessing_from_alpha() {
|
||||
let mut vertices = Vec::new();
|
||||
for layer in [
|
||||
0,
|
||||
super::super::OIT_ENTITY,
|
||||
0,
|
||||
super::super::TRANSLUCENT_PARTICLE_OVERLAY,
|
||||
0,
|
||||
] {
|
||||
vertices.extend(
|
||||
[Vertex {
|
||||
layer: layer | 3 << 16,
|
||||
color: 0x40808080,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
}; 4],
|
||||
);
|
||||
}
|
||||
let spans = [
|
||||
EntitySpan {
|
||||
stage: render_stage("BLOCK_ENTITIES"),
|
||||
id: 71,
|
||||
..Default::default()
|
||||
},
|
||||
EntitySpan {
|
||||
start: 8,
|
||||
stage: render_stage("PARTICLES"),
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
let collect = |drawn| {
|
||||
material_runs(&vertices, &spans, drawn)
|
||||
.map(|(_, range, p)| (range, p))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let mut legacy = [true; PROGRAMS];
|
||||
legacy[BLOCK_TRANSLUCENT] = false;
|
||||
legacy[PARTICLE_TRANSLUCENT] = false;
|
||||
assert_eq!(
|
||||
collect(legacy),
|
||||
[(0..8, Some(BLOCK)), (8..20, Some(PARTICLE))]
|
||||
);
|
||||
assert_eq!(
|
||||
collect([true; PROGRAMS]),
|
||||
[
|
||||
(0..4, Some(BLOCK)),
|
||||
(4..8, Some(BLOCK_TRANSLUCENT)),
|
||||
(8..12, Some(PARTICLE)),
|
||||
(12..16, Some(PARTICLE_TRANSLUCENT)),
|
||||
(16..20, Some(PARTICLE)),
|
||||
]
|
||||
);
|
||||
let casters = Casters {
|
||||
blocks: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(casters.cast(&spans[0], BLOCK_TRANSLUCENT));
|
||||
assert_eq!(shadow_program(BLOCK_TRANSLUCENT), BLOCK);
|
||||
assert!(!casters.cast(&spans[1], PARTICLE_TRANSLUCENT));
|
||||
}
|
||||
@@ -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.
|
||||
@@ -533,8 +543,7 @@ pub(super) struct World<'a> {
|
||||
pub lightmap: wgpu::TextureView,
|
||||
/// Terrain's geometry, when the pack draws it.
|
||||
pub terrain: Option<Geometry<'a>>,
|
||||
/// Of each block sprite layer, `mc_Entity` (id, then 1 for fluids or 0) and `at_midBlock.w`
|
||||
/// (its block's light): three values a layer.
|
||||
/// Per source block state: its model material id, then its fluid material id.
|
||||
pub block_ids: &'a [i32],
|
||||
/// The atlas of the sun and moon's sprites.
|
||||
pub celestial: Option<wgpu::TextureView>,
|
||||
@@ -649,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`).
|
||||
@@ -667,10 +681,10 @@ pub struct PackPost {
|
||||
down: HashMap<wgpu::TextureFormat, (wgpu::RenderPipeline, wgpu::BindGroupLayout)>,
|
||||
/// The per-draw uniforms of gbuffers programs.
|
||||
draws: DrawUniforms,
|
||||
/// Set 3 of flat terrain (`mc_Entity`'s table, then the sprites' places) and its bind group
|
||||
/// for each arena page, with the page buffer it was made for.
|
||||
/// Set 3 of flat terrain: an unknown-source fallback and immutable state/sprite tables.
|
||||
/// Each page's bind group tracks both its vertex buffer and optional source sidecar.
|
||||
block_ids: Option<(wgpu::BindGroupLayout, wgpu::Buffer, wgpu::Buffer)>,
|
||||
page_groups: HashMap<u32, (wgpu::Buffer, wgpu::BindGroup)>,
|
||||
page_groups: HashMap<u32, (wgpu::Buffer, Option<wgpu::Buffer>, wgpu::BindGroup)>,
|
||||
gui_size: [u32; 2],
|
||||
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
|
||||
pub timing: Option<super::pack_timing::PassTiming>,
|
||||
@@ -908,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),
|
||||
}
|
||||
}
|
||||
@@ -1210,30 +1225,44 @@ pub(super) struct ShadowView {
|
||||
pub depth_planes: bool,
|
||||
}
|
||||
|
||||
/// Set 3 of flat terrain, read by the vertex stage: the sprite layers' table (`mc_Entity`'s id
|
||||
/// and fluid, `at_midBlock.w`; -1, -1, 0 when empty), per arena page, the page's vertices
|
||||
/// (a vertex reads its quad's corners for its normal), and each sprite's place in a stitched
|
||||
/// atlas (`rects`; zeros for a grid, which doesn't read them).
|
||||
fn block_ids(
|
||||
/// An arena page's key, native vertices and optional per-quad source records.
|
||||
pub(super) type TerrainPage = (u32, wgpu::Buffer, Option<wgpu::Buffer>);
|
||||
|
||||
/// Header (sprite-rect words, state count), sprite rectangles, then model/fluid ids.
|
||||
/// Keeping immutable tables together leaves the existing three storage bindings intact.
|
||||
pub(super) fn terrain_tables(table: &[i32], rects: &[u32]) -> Vec<i32> {
|
||||
let rects = if rects.is_empty() { &[0, 0][..] } else { rects };
|
||||
[rects.len() as i32, (table.len() / 2) as i32]
|
||||
.into_iter()
|
||||
.chain(rects.iter().map(|&v| v as i32))
|
||||
.chain(table.iter().copied())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn block_ids(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
table: &[i32],
|
||||
rects: &[u32],
|
||||
) -> (wgpu::BindGroupLayout, wgpu::Buffer, wgpu::Buffer) {
|
||||
let empty = [-1, -1, 0];
|
||||
let table = if table.is_empty() { &empty[..] } else { table };
|
||||
) -> Option<(wgpu::BindGroupLayout, wgpu::Buffer, wgpu::Buffer)> {
|
||||
let buffer = |label, contents: &[u8]| {
|
||||
wgpu::util::DeviceExt::create_buffer_init(
|
||||
if contents.len() as u64 > device.limits().max_storage_buffer_binding_size {
|
||||
return None;
|
||||
}
|
||||
let buffer = super::arena::try_create(
|
||||
device,
|
||||
&wgpu::util::BufferInitDescriptor {
|
||||
label: Some(label),
|
||||
contents,
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
},
|
||||
)
|
||||
label,
|
||||
contents.len() as u64,
|
||||
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
)?;
|
||||
queue.write_buffer(&buffer, 0, contents);
|
||||
Some(buffer)
|
||||
};
|
||||
let rects = if rects.is_empty() { &[0, 0][..] } else { rects };
|
||||
let ids = buffer("Shader pack block ids", bytemuck::cast_slice(table));
|
||||
let rects = buffer("Shader pack sprite rects", bytemuck::cast_slice(rects));
|
||||
let ids = buffer("Unknown terrain sources", &[0; 8])?;
|
||||
let rects = buffer(
|
||||
"Shader pack terrain tables",
|
||||
bytemuck::cast_slice(&terrain_tables(table, rects)),
|
||||
)?;
|
||||
let storage = |binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::VERTEX,
|
||||
@@ -1248,7 +1277,7 @@ fn block_ids(
|
||||
label: Some("Shader pack terrain"),
|
||||
entries: &[storage(0), storage(1), storage(2)],
|
||||
});
|
||||
(layout, ids, rects)
|
||||
Some((layout, ids, rects))
|
||||
}
|
||||
|
||||
/// The game's geometry a gbuffers program draws: its vertex buffers and, for flat draws
|
||||
@@ -1634,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,
|
||||
@@ -1920,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.
|
||||
@@ -2134,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));
|
||||
@@ -2176,6 +2231,7 @@ fn build_pass(
|
||||
group: p.group,
|
||||
pipeline: Pipeline::Render(pipeline),
|
||||
sky_parts,
|
||||
water_mask,
|
||||
layout,
|
||||
samplers,
|
||||
writes,
|
||||
@@ -2315,6 +2371,7 @@ fn build_compute(
|
||||
local,
|
||||
},
|
||||
sky_parts: None,
|
||||
water_mask: None,
|
||||
layout,
|
||||
samplers,
|
||||
writes: Vec::new(),
|
||||
@@ -2583,6 +2640,22 @@ impl PackPost {
|
||||
.fold(0, |m, n| m | 1 << n),
|
||||
jobs: defer.then(Default::default),
|
||||
};
|
||||
// Flat terrain resolves each quad's source state against the current pack.
|
||||
let block_ids = world
|
||||
.terrain
|
||||
.as_ref()
|
||||
.filter(|g| g.table.is_some())
|
||||
.map(|_| {
|
||||
block_ids(device, queue, world.block_ids, world.sprite_rects).ok_or_else(|| {
|
||||
Refused {
|
||||
reason: "terrain source tables could not be allocated".into(),
|
||||
notes: notes.clone(),
|
||||
}
|
||||
})
|
||||
})
|
||||
.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) {
|
||||
@@ -2633,6 +2706,7 @@ impl PackPost {
|
||||
&custom_names,
|
||||
&draws.layout,
|
||||
&pack,
|
||||
water_mask_next.get(),
|
||||
)?
|
||||
};
|
||||
pass.compute.append(&mut computes);
|
||||
@@ -2694,12 +2768,6 @@ impl PackPost {
|
||||
}
|
||||
Some(pass)
|
||||
};
|
||||
// Flat terrain reads `mc_Entity` from a table of the sprite layers.
|
||||
let block_ids = world
|
||||
.terrain
|
||||
.as_ref()
|
||||
.filter(|g| g.table.is_some())
|
||||
.map(|_| block_ids(device, world.block_ids, world.sprite_rects));
|
||||
let terrain_geometry = world.terrain.as_ref().map(|g| Geometry {
|
||||
buffers: g.buffers,
|
||||
table: g.table,
|
||||
@@ -2779,14 +2847,18 @@ impl PackPost {
|
||||
post.particles,
|
||||
post.damaged_block,
|
||||
post.beacon_beam,
|
||||
post.block_translucent,
|
||||
post.particles_translucent,
|
||||
];
|
||||
let late = [
|
||||
let mut late = [
|
||||
false,
|
||||
false,
|
||||
post.translucent_late,
|
||||
post.particles_late,
|
||||
false,
|
||||
true,
|
||||
post.translucent_late,
|
||||
true,
|
||||
];
|
||||
let shadow_only = (post.shadow_only_player, post.shadow_only_lit_blocks);
|
||||
let shadow_programs = [post.shadow_entities, post.shadow_block];
|
||||
@@ -2810,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 {
|
||||
@@ -2828,6 +2904,12 @@ impl PackPost {
|
||||
}
|
||||
})
|
||||
});
|
||||
// Translation alone is insufficient: a refused GPU pipeline must retain the
|
||||
// legacy single particle pass and its timing as well as its geometry grouping.
|
||||
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))))
|
||||
@@ -2851,10 +2933,12 @@ impl PackPost {
|
||||
cull: false,
|
||||
});
|
||||
let hides_clouds = post.clouds_off || post.clouds.is_some() && cloud_geometry.is_some();
|
||||
// Rain and snow test depth but never write it: Iris 1.11.7 left none of either in
|
||||
// `depthtex0`, whatever `rain.depth` said (probe packs of ours, #10492).
|
||||
let weather_geometry = world.weather.then(|| Geometry {
|
||||
buffers: &entity_buffers,
|
||||
table: None,
|
||||
depth_write: post.rain_depth,
|
||||
depth_write: false,
|
||||
atlas: None,
|
||||
material_maps: false,
|
||||
ids: None,
|
||||
@@ -3053,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()
|
||||
@@ -3308,6 +3395,8 @@ impl PackPost {
|
||||
hand_water,
|
||||
depth1,
|
||||
depth2,
|
||||
water_mask,
|
||||
water_mask_dirty: true,
|
||||
shadow_targets,
|
||||
shadow_terrain,
|
||||
portal_rect: [0.0; 4],
|
||||
@@ -3639,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)
|
||||
@@ -3832,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 {
|
||||
@@ -3845,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 {
|
||||
@@ -3866,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,
|
||||
}),
|
||||
@@ -3938,7 +4042,7 @@ impl PackPost {
|
||||
}
|
||||
let (groups, ids) = (&self.page_groups, pass.ids);
|
||||
let page = |rp: &mut wgpu::RenderPass<'_>, page: u32| {
|
||||
if let Some((_, group)) = groups.get(&page).filter(|_| ids) {
|
||||
if let Some((_, _, group)) = groups.get(&page).filter(|_| ids) {
|
||||
rp.set_bind_group(3, group, &[]);
|
||||
}
|
||||
};
|
||||
@@ -4049,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;
|
||||
}
|
||||
@@ -4092,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);
|
||||
@@ -4156,7 +4292,7 @@ impl PackPost {
|
||||
continue;
|
||||
};
|
||||
let mut draws = (geometry.draws.iter())
|
||||
.filter(|d| d.shadow && d.program == program)
|
||||
.filter(|d| d.shadow && super::pack_entities::shadow_program(d.program) == program)
|
||||
.peekable();
|
||||
if draws.peek().is_none() {
|
||||
continue;
|
||||
@@ -4168,7 +4304,11 @@ impl PackPost {
|
||||
encoder,
|
||||
pass,
|
||||
size,
|
||||
(geometry.draws.iter()).any(|d| d.shadow && d.program == program && d.portal),
|
||||
(geometry.draws.iter()).any(|d| {
|
||||
d.shadow
|
||||
&& super::pack_entities::shadow_program(d.program) == program
|
||||
&& d.portal
|
||||
}),
|
||||
);
|
||||
let mut on_portal = false;
|
||||
let mut rp = self.begin(encoder, pass);
|
||||
@@ -4869,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.
|
||||
@@ -4903,6 +5053,7 @@ impl PackPost {
|
||||
depth_texture,
|
||||
depth1,
|
||||
depth2,
|
||||
water_mask,
|
||||
mips,
|
||||
});
|
||||
self.forget_groups();
|
||||
@@ -5002,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 {
|
||||
@@ -5031,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;
|
||||
}
|
||||
@@ -5153,10 +5306,28 @@ impl PackPost {
|
||||
inputs: &PackInputs,
|
||||
size: [u32; 2],
|
||||
window: [u32; 2],
|
||||
pages: &[(u32, wgpu::Buffer)],
|
||||
pages: &[TerrainPage],
|
||||
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) {
|
||||
@@ -5189,8 +5360,8 @@ impl PackPost {
|
||||
if let Some((layout, ids, rects)) = &self.block_ids {
|
||||
// A page that grew has a new buffer.
|
||||
self.page_groups
|
||||
.retain(|p, (b, _)| pages.iter().any(|(q, c)| q == p && c == b));
|
||||
for (page, buffer) in pages {
|
||||
.retain(|p, (b, s, _)| pages.iter().any(|(q, c, t)| q == p && c == b && s == t));
|
||||
for (page, buffer, sources) in pages {
|
||||
self.page_groups.entry(*page).or_insert_with(|| {
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Shader pack terrain"),
|
||||
@@ -5198,7 +5369,7 @@ impl PackPost {
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: ids.as_entire_binding(),
|
||||
resource: sources.as_ref().unwrap_or(ids).as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
@@ -5210,7 +5381,7 @@ impl PackPost {
|
||||
},
|
||||
],
|
||||
});
|
||||
(buffer.clone(), group)
|
||||
(buffer.clone(), sources.clone(), group)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5504,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() {
|
||||
@@ -6176,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.
|
||||
@@ -6413,3 +6586,6 @@ mod mipmap_box_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod colortex_mip_blend_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod material_route_tests;
|
||||
@@ -322,7 +322,7 @@ fn check(sampler: &str, stage: Stage, scene: Scene, expected: [u8; 4]) {
|
||||
let hand = matches!(scene, Scene::Terrain { hand: true, .. });
|
||||
let water = matches!(scene, Scene::Terrain { water: true, .. } | Scene::WaterOnly);
|
||||
let inputs=horizon_shaders::GameInputs {
|
||||
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None,item_id_declared:false,far_depth:false,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()
|
||||
};
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Original end-to-end material routing, timing and blending control; no pack assets.
|
||||
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
|
||||
use super::*;
|
||||
use crate::gfx::{OIT_ENTITY, TRANSLUCENT_PARTICLE_OVERLAY, Vertex, pack_draw::EntitySpan};
|
||||
use crate::hud::HudBatch;
|
||||
use horizon_shaders::render_stage;
|
||||
use wgpu::TextureFormat as T;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires an offscreen graphics adapter; run explicitly"]
|
||||
fn authored_material_programs_render_and_legacy_draws_stay_unsplit() {
|
||||
let gpu = Gpu::new().expect("an executed adapter is required for this control");
|
||||
let mut vertices = Vec::new();
|
||||
for (i, layer) in [0, OIT_ENTITY, 0, TRANSLUCENT_PARTICLE_OVERLAY]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let x = -1.0 + i as f32 * 0.5;
|
||||
for pos in [
|
||||
[x, -1.0, 0.0],
|
||||
[x + 0.5, -1.0, 0.0],
|
||||
[x + 0.5, 1.0, 0.0],
|
||||
[x, 1.0, 0.0],
|
||||
] {
|
||||
vertices.push(Vertex {
|
||||
pos,
|
||||
layer: layer | 2 << 16,
|
||||
color: 0xffff_ffff,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
});
|
||||
}
|
||||
}
|
||||
let spans = [
|
||||
EntitySpan {
|
||||
stage: render_stage("BLOCK_ENTITIES"),
|
||||
id: 17,
|
||||
..Default::default()
|
||||
},
|
||||
EntitySpan {
|
||||
start: 8,
|
||||
stage: render_stage("PARTICLES"),
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
for variants in [false, true] {
|
||||
let vertex = "#version 120\nvoid main(){gl_Position=gl_Vertex;}";
|
||||
let mut files = vec![
|
||||
("final.vsh".into(), VERTEX.into()),
|
||||
("final.fsh".into(), "#version 120\nuniform sampler2D colortex0; varying vec2 uv; void main(){gl_FragColor=vec4(texture2D(colortex0,uv).rgb,1.0);}".into()),
|
||||
("deferred.vsh".into(), VERTEX.into()),
|
||||
("deferred.fsh".into(), "#version 120\nuniform sampler2D colortex0; varying vec2 uv; void main(){gl_FragData[0]=vec4(texture2D(colortex0,uv).rgb*0.5,1.0);}".into()),
|
||||
("shaders.properties".into(), "particles.ordering=mixed\nseparateEntityDraws=true\nblend.gbuffers_block=off\nblend.gbuffers_particles=off\nblend.gbuffers_block_translucent=SRC_ALPHA ONE_MINUS_SRC_ALPHA ZERO ZERO\nblend.gbuffers_particles_translucent=SRC_ALPHA ONE_MINUS_SRC_ALPHA ZERO ZERO\n".into()),
|
||||
];
|
||||
let programs = [
|
||||
("gbuffers_block", "vec4(1.0,0.0,0.0,1.0)"),
|
||||
("gbuffers_particles", "vec4(0.0,0.0,1.0,1.0)"),
|
||||
("gbuffers_block_translucent", "vec4(0.0,1.0,0.0,0.5)"),
|
||||
("gbuffers_particles_translucent", "vec4(1.0,1.0,0.0,0.5)"),
|
||||
];
|
||||
for (name, color) in programs.into_iter().take(if variants { 4 } else { 2 }) {
|
||||
let expected_id = if name.starts_with("gbuffers_block") {
|
||||
17
|
||||
} else {
|
||||
0
|
||||
};
|
||||
files.push((format!("{name}.vsh"), vertex.into()));
|
||||
files.push((format!("{name}.fsh"), format!("#version 120\nuniform int blockEntityId; void main(){{gl_FragData[0]={color}; if (blockEntityId != {expected_id}) gl_FragData[0]=vec4(1.0);}}")));
|
||||
}
|
||||
let color = gpu.texture(T::Rgba8Unorm);
|
||||
let depth = gpu.texture(T::Depth32Float);
|
||||
let mut input = super::super::pack_entities::inputs();
|
||||
input.body = "hz_Vertex=vec4(hz_v_pos,1.0); hz_Color=vec4(1.0);".into();
|
||||
let mut post = gpu.load(
|
||||
files,
|
||||
World {
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap: color.clone(),
|
||||
terrain: None,
|
||||
block_ids: &[],
|
||||
sprite_rects: &[],
|
||||
celestial: None,
|
||||
gui_size: SIZE,
|
||||
clouds: None,
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
horizon_shaders::GameInputs {
|
||||
entities: Some(input),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let hud = HudBatch::new(SIZE);
|
||||
for frame in 0..2 {
|
||||
gpu.clear(&color, &depth, 0.0);
|
||||
let mut rest = Vec::new();
|
||||
post.prepare_entities(
|
||||
&gpu.device,
|
||||
&gpu.queue,
|
||||
&vertices,
|
||||
&spans,
|
||||
[0.; 4],
|
||||
[0.; 4],
|
||||
&mut rest,
|
||||
None,
|
||||
(&[], &[]),
|
||||
);
|
||||
assert!(rest.is_empty());
|
||||
let entities = post.entities.as_ref().unwrap();
|
||||
assert_eq!(entities.geometry.draws.len(), if variants { 4 } else { 2 });
|
||||
let pixels = gpu.render(
|
||||
&mut post,
|
||||
&color,
|
||||
&depth,
|
||||
&fixture_frame(&hud),
|
||||
&mut |_, _, _, _| {},
|
||||
);
|
||||
// Early opaque draws are halved by deferred. Late variants blend alpha 1/2
|
||||
// over world clear 1/16 halved by deferred, giving 1/64 + color/2.
|
||||
let expected = if variants {
|
||||
[[128, 0, 0], [4, 132, 4], [0, 0, 128], [132, 132, 4]]
|
||||
} else {
|
||||
[[128, 0, 0], [128, 0, 0], [0, 0, 255], [0, 0, 255]]
|
||||
};
|
||||
for (i, rgb) in expected.into_iter().enumerate() {
|
||||
let pixel = pixels[(SIZE[1] / 2 * SIZE[0] + i as u32 * 4 + 2) as usize];
|
||||
eprintln!(
|
||||
"MATERIAL_ROUTE variants={variants} frame={frame} strip={i} actual={pixel:?} expected={rgb:?}"
|
||||
);
|
||||
assert!(pixel[..3].iter().zip(rgb).all(|(&a, b)| a.abs_diff(b) <= 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);\
|
||||
|
||||
@@ -5,7 +5,7 @@ use wgpu::util::DeviceExt;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a host GPU; missing adapter is a failure"]
|
||||
fn rain_depth_controls_writes_for_rain_snow_and_fallbacks() {
|
||||
fn rain_and_snow_leave_depth_alone_whatever_rain_depth_says() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
|
||||
eprintln!("Weather depth GPU: {:?}", adapter.get_info());
|
||||
@@ -16,14 +16,10 @@ fn rain_depth_controls_writes_for_rain_snow_and_fallbacks() {
|
||||
}))
|
||||
.unwrap();
|
||||
let mut depth_failures = Vec::new();
|
||||
// Iris 1.11.7 left no rain or snow in `depthtex0` with `rain.depth` absent, true or false,
|
||||
// drawn by `gbuffers_weather` or its fallback (probe packs of ours, #10492).
|
||||
for program in ["gbuffers_weather", "gbuffers_textured_lit"] {
|
||||
for (property, writes) in [
|
||||
("rain.depth=true", true),
|
||||
("rain.depth=false", false),
|
||||
// An evidence-limited compatibility choice: preserve accepted main here.
|
||||
// The permitted packs do not establish the external omitted-value default.
|
||||
("", true),
|
||||
] {
|
||||
for property in ["rain.depth=true", "rain.depth=false", ""] {
|
||||
let mut post = pack(&device, &queue, &adapter, program, property);
|
||||
// Retain the pipelines for another frame, then resize their targets.
|
||||
for size in [[6, 1], [6, 1], [12, 2]] {
|
||||
@@ -90,7 +86,7 @@ fn rain_depth_controls_writes_for_rain_snow_and_fallbacks() {
|
||||
}
|
||||
let depth =
|
||||
f32::from_le_bytes(depths[offset..offset + 4].try_into().unwrap());
|
||||
let expected_depth = if visible && writes { 0.25 } else { 0.5 };
|
||||
let expected_depth = 0.5;
|
||||
if depth != expected_depth {
|
||||
depth_failures.push(format!(
|
||||
"{program} {property:?} size={size:?} ({x},{y}): depth={depth}, expected={expected_depth}"
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,697 @@
|
||||
//! A native mod's world warp ([`native_world_warp`](super::native_render::native_world_warp),
|
||||
//! docs/native-mods.md "World warps"): WGSL that every terrain vertex, far terrain's tiles
|
||||
//! included, and entity vertex goes through, so a mod can bend the drawn world itself (its real
|
||||
//! blocks, light and depth, seen from any side) rather than a picture of it.
|
||||
//!
|
||||
//! The stock shaders hold only comment markers where a warp goes in (terrain.wgsl's
|
||||
//! `[world warp ...]`, entity.wgsl's and static_entity.wgsl's): a frame without a warp draws
|
||||
//! with exactly the shaders it always did. For a warp, copies of them get the markers filled,
|
||||
//! the declarations of `world_warp.wgsl`, the mod's WGSL and, for the terrain, the pixel stage of
|
||||
//! `world_warp_fragment.wgsl` with the mod's `world_fragment` or [`DEFAULT_FRAGMENT`]; the
|
||||
//! pipelines built from them read the warp's uniform at group 2.
|
||||
//!
|
||||
//! Those pipelines don't cull back faces (a warp may turn a face over), and a frame drawn with
|
||||
//! them leaves nothing out by where it was (`Renderer::render`): what is out of view, hidden or
|
||||
//! facing away may be brought into view. It also keeps to the per-section draws, which these
|
||||
//! pipelines are built for.
|
||||
use super::native_render::WorldWarp;
|
||||
use super::{SolidPipeline, TERRAIN_ATTRIBUTES, TerrainVertex, Vertex};
|
||||
use glam::DVec3;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The line in a warp's source after which the terrain's pixel stage begins.
|
||||
const FRAGMENTS: &str = "// [fragments]";
|
||||
/// The pixel stage of a warp without one: the terrain as the game draws it, less faces it cut.
|
||||
pub const DEFAULT_FRAGMENT: &str = "fn world_fragment(f: Fragment) -> Painted {
|
||||
if f.cut > 0.0 { return Painted(vec4(0.0, 0.0, 0.0, -1.0), 1.0); }
|
||||
return Painted(sprite(f.uv, f.layer) * f.color, 1.0);
|
||||
}
|
||||
";
|
||||
/// What terrain.wgsl's markers are filled with: the warp's two values in `TerrainOut`, and how
|
||||
/// they cross to the pixel stage in `TerrainWire`, after its five locations.
|
||||
const VARYINGS: &str = "warp_home: vec4<f32>,
|
||||
warp_at: vec3<f32>,";
|
||||
const WIRE: &str = "@location(5) warp_home: vec4<f32>,
|
||||
@location(6) warp_at: vec3<f32>,";
|
||||
const TO_WIRE: &str = "w.warp_home = o.warp_home;
|
||||
w.warp_at = o.warp_at;";
|
||||
const FROM_WIRE: &str = "o.warp_home = w.warp_home;
|
||||
o.warp_at = w.warp_at;";
|
||||
const VERTEX: &str = "let warp_home = position + origin;
|
||||
let warped = world_warp(warp_home, origin);
|
||||
let pos = warped.pos;";
|
||||
const VERTEX_OUT: &str = "out.color = out.color * vec4(max(warped.tint, vec3(0.0)), 1.0);
|
||||
out.warp_home = vec4(warp_home, select(0.0, 1.0, warped.tint.r < 0.0));
|
||||
out.warp_at = pos;";
|
||||
/// And entity.wgsl's and static_entity.wgsl's: an entity's vertex is its own `section`; a block
|
||||
/// entity's is its section's corner.
|
||||
const ENTITY: &str = "let at = world_warp(position, position).pos;";
|
||||
const STATIC_ENTITY: &str =
|
||||
"let section_corner = vec3<f32>(block_delta) - static_entity.camera_fraction.xyz;
|
||||
let camera_relative = world_warp(section_corner + position, section_corner).pos;";
|
||||
|
||||
/// The plainest warp, for the tests of the shaders it makes.
|
||||
#[cfg(test)]
|
||||
pub(super) const IDENTITY: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped { return Warped(pos, vec3(1.0)); }\n";
|
||||
|
||||
/// `shader` with what stands between the markers `// [name]` and `// [end name]` replaced.
|
||||
fn fill(shader: &str, name: &str, with: &str) -> Result<String, String> {
|
||||
let (from, to) = (format!("// [{name}]"), format!("// [end {name}]"));
|
||||
let a = shader
|
||||
.find(&from)
|
||||
.ok_or_else(|| format!("no `{from}` in the shader"))?;
|
||||
let b = shader[a..]
|
||||
.find(&to)
|
||||
.ok_or_else(|| format!("no `{to}` in the shader"))?
|
||||
+ a;
|
||||
Ok(format!("{}{with}{}", &shader[..a], &shader[b + to.len()..]))
|
||||
}
|
||||
|
||||
/// A warp's source split into what every warped shader takes and the terrain's pixel stage.
|
||||
fn split(source: &str) -> (&str, Option<&str>) {
|
||||
match source.find(FRAGMENTS) {
|
||||
Some(at) => (&source[..at], Some(&source[at + FRAGMENTS.len()..])),
|
||||
None => (source, None),
|
||||
}
|
||||
}
|
||||
|
||||
/// The warp's copy of terrain.wgsl.
|
||||
pub(super) fn terrain_shader(source: &str) -> Result<String, String> {
|
||||
let (vertex, fragment) = split(source);
|
||||
let fragment = fragment
|
||||
.filter(|f| f.contains("fn world_fragment"))
|
||||
.unwrap_or(DEFAULT_FRAGMENT);
|
||||
let shader = fill(&super::terrain_shader(), "world warp varyings", VARYINGS)?;
|
||||
let shader = fill(&shader, "world warp wire", WIRE)?;
|
||||
let shader = fill(&shader, "world warp to wire", TO_WIRE)?;
|
||||
let shader = fill(&shader, "world warp from wire", FROM_WIRE)?;
|
||||
let shader = fill(&shader, "world warp vertex", VERTEX)?;
|
||||
let shader = fill(&shader, "world warp out", VERTEX_OUT)?;
|
||||
Ok(format!(
|
||||
"{shader}\n{}\n{vertex}\n{}\n{fragment}",
|
||||
include_str!("world_warp.wgsl"),
|
||||
include_str!("world_warp_fragment.wgsl"),
|
||||
))
|
||||
}
|
||||
|
||||
/// The warp's copy of entity.wgsl with static_entity.wgsl, for every entity pipeline it draws.
|
||||
pub(super) fn entity_shader(source: &str) -> Result<String, String> {
|
||||
let (vertex, _) = split(source);
|
||||
let shader = fill(&super::static_entity_shader(), "world warp entity", ENTITY)?;
|
||||
let shader = fill(&shader, "world warp static entity", STATIC_ENTITY)?;
|
||||
Ok(format!(
|
||||
"{shader}\n{}\n{vertex}",
|
||||
include_str!("world_warp.wgsl")
|
||||
))
|
||||
}
|
||||
|
||||
fn validate(source: &str) -> Result<(), String> {
|
||||
let module = naga::front::wgsl::parse_str(source).map_err(|e| e.emit_to_string(source))?;
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.map_err(|e| e.emit_to_string(source))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The warp's uniform (`world_warp.wgsl`'s `WorldWarpUniform`): the mod's sixteen floats, then
|
||||
/// the camera split into f32 and what f32 left out of it, with the frame's partial tick.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct Uniform {
|
||||
params: [[f32; 4]; 4],
|
||||
camera: [f32; 4],
|
||||
camera_rest: [f32; 4],
|
||||
}
|
||||
|
||||
fn uniform(warp: &WorldWarp, camera: DVec3, partial_tick: f32) -> Uniform {
|
||||
let (params, _) = warp.params.as_chunks::<4>();
|
||||
let params: [[f32; 4]; 4] = params.try_into().expect("sixteen floats");
|
||||
let at = camera.as_vec3();
|
||||
let rest = camera - at.as_dvec3();
|
||||
Uniform {
|
||||
params,
|
||||
camera: [at.x, at.y, at.z, partial_tick],
|
||||
camera_rest: [rest.x as f32, rest.y as f32, rest.z as f32, 0.0],
|
||||
}
|
||||
}
|
||||
|
||||
/// What the warp's pipelines are built with: the renderer's own layouts and formats.
|
||||
pub(super) struct Kit {
|
||||
/// terrain.wgsl's group 0, and with flat draws the per-section draws' page group 1
|
||||
/// (`vs_section` reads its vertices from it).
|
||||
pub terrain: wgpu::BindGroupLayout,
|
||||
pub pages: Option<wgpu::BindGroupLayout>,
|
||||
/// entity.wgsl's group 0 and static_entity.wgsl's group 1.
|
||||
pub entity: wgpu::BindGroupLayout,
|
||||
pub static_entity: wgpu::BindGroupLayout,
|
||||
pub format: wgpu::TextureFormat,
|
||||
pub sprite_constants: [(&'static str, f64); 2],
|
||||
/// Whether the display offers HDR output: the solid terrain is built lit by it too.
|
||||
pub hdr_output: bool,
|
||||
}
|
||||
|
||||
/// The warp's pipelines, in place of the renderer's while it is on.
|
||||
pub(super) struct Pipelines {
|
||||
pub solid: SolidPipeline,
|
||||
pub translucent: wgpu::RenderPipeline,
|
||||
pub entities: wgpu::RenderPipeline,
|
||||
pub static_entities: wgpu::RenderPipeline,
|
||||
pub translucent_items: wgpu::RenderPipeline,
|
||||
pub text: wgpu::RenderPipeline,
|
||||
pub entity_shadows: wgpu::RenderPipeline,
|
||||
pub hdr_mark: wgpu::RenderPipeline,
|
||||
pub hdr_entity_mark: wgpu::RenderPipeline,
|
||||
}
|
||||
|
||||
/// The warp's uniform, and what binds it.
|
||||
struct Bound {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
buffer: wgpu::Buffer,
|
||||
group: wgpu::BindGroup,
|
||||
}
|
||||
|
||||
/// The renderer's world warp: nothing made until a mod gives one.
|
||||
pub(super) struct WorldWarps {
|
||||
kit: Kit,
|
||||
bound: Option<Bound>,
|
||||
built: Option<(Arc<str>, Pipelines)>,
|
||||
/// The last source that didn't compile, so it is tried once.
|
||||
refused: Option<Arc<str>>,
|
||||
/// Whether the log said a warp went undrawn under a shader pack drawing the terrain.
|
||||
told_undrawable: bool,
|
||||
}
|
||||
|
||||
impl WorldWarps {
|
||||
pub fn new(kit: Kit) -> Self {
|
||||
Self {
|
||||
kit,
|
||||
bound: None,
|
||||
built: None,
|
||||
refused: None,
|
||||
told_undrawable: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Readies this frame's warp: builds its pipelines when its source is new and writes its
|
||||
/// uniform. Whether the frame draws with it: not when it is off, didn't compile, or the
|
||||
/// frame can't take it (`drawable`: a shader pack draws the terrain).
|
||||
pub fn frame(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
uploads: &mut super::upload::Uploads,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
warp: &WorldWarp,
|
||||
camera: DVec3,
|
||||
partial_tick: f32,
|
||||
drawable: bool,
|
||||
) -> bool {
|
||||
let Some(source) = &warp.source else {
|
||||
return false;
|
||||
};
|
||||
let same = |s: &Arc<str>| Arc::ptr_eq(s, source) || **s == **source;
|
||||
if !self.built.as_ref().is_some_and(|(s, _)| same(s)) {
|
||||
if self.refused.as_ref().is_some_and(same) {
|
||||
return false;
|
||||
}
|
||||
match self.build(device, source) {
|
||||
Ok(pipelines) => self.built = Some((source.clone(), pipelines)),
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: a mod's world warp did not compile: {e}");
|
||||
self.refused = Some(source.clone());
|
||||
self.built = None;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !warp.on {
|
||||
return false;
|
||||
}
|
||||
if !drawable {
|
||||
if !self.told_undrawable {
|
||||
log::info!(
|
||||
"Horizon: a mod's world warp isn't drawn while a shader pack draws the terrain"
|
||||
);
|
||||
self.told_undrawable = true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
let bound = self.bound(device);
|
||||
uploads.buffer(
|
||||
encoder,
|
||||
&bound.buffer,
|
||||
0,
|
||||
bytemuck::bytes_of(&uniform(warp, camera, partial_tick)),
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
/// The pipelines of the warp `frame` readied.
|
||||
pub fn pipelines(&self) -> Option<&Pipelines> {
|
||||
self.built.as_ref().map(|(_, p)| p)
|
||||
}
|
||||
|
||||
/// The warp's group 2, once a frame has drawn with it.
|
||||
pub fn group(&self) -> Option<&wgpu::BindGroup> {
|
||||
self.bound.as_ref().map(|b| &b.group)
|
||||
}
|
||||
|
||||
fn bound(&mut self, device: &wgpu::Device) -> &Bound {
|
||||
self.bound.get_or_insert_with(|| bind(device))
|
||||
}
|
||||
|
||||
fn build(&mut self, device: &wgpu::Device, source: &str) -> Result<Pipelines, String> {
|
||||
let started = std::time::Instant::now();
|
||||
let terrain = terrain_shader(source)?;
|
||||
let entity = entity_shader(source)?;
|
||||
validate(&terrain)?;
|
||||
validate(&entity)?;
|
||||
// Validation failures above are the mod's; past here wgpu's are the game's.
|
||||
let warp = &self.bound.get_or_insert_with(|| bind(device)).layout;
|
||||
let kit = &self.kit;
|
||||
let layout = |label, groups: &[Option<&wgpu::BindGroupLayout>]| {
|
||||
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some(label),
|
||||
bind_group_layouts: groups,
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
let terrain_layout = layout(
|
||||
"Warped terrain layout",
|
||||
&[Some(&kit.terrain), None, Some(warp)],
|
||||
);
|
||||
let pages_layout = kit.pages.as_ref().map(|pages| {
|
||||
layout(
|
||||
"Warped terrain layout (pulled)",
|
||||
&[Some(&kit.terrain), Some(pages), Some(warp)],
|
||||
)
|
||||
});
|
||||
let entity_layout = layout(
|
||||
"Warped entity layout",
|
||||
&[Some(&kit.entity), None, Some(warp)],
|
||||
);
|
||||
let static_layout = layout(
|
||||
"Warped static block-entity layout",
|
||||
&[Some(&kit.entity), Some(&kit.static_entity), Some(warp)],
|
||||
);
|
||||
let module = |label, source: String| {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(label),
|
||||
source: wgpu::ShaderSource::Wgsl(source.into()),
|
||||
})
|
||||
};
|
||||
let terrain = module("Warped terrain", terrain);
|
||||
let entity = module("Warped entities", entity);
|
||||
|
||||
let instance_attributes = wgpu::vertex_attr_array![5=>Float32x4];
|
||||
let instances = wgpu::VertexBufferLayout {
|
||||
array_stride: 16,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &instance_attributes,
|
||||
};
|
||||
let vertices = wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<TerrainVertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &TERRAIN_ATTRIBUTES,
|
||||
};
|
||||
let buffered = [Some(vertices), Some(instances.clone())];
|
||||
let pulled = [None, Some(instances)];
|
||||
// As gfx.rs's: a float frame takes light where others take marks.
|
||||
let write_light = (
|
||||
"HDR_WRITE_LIGHT",
|
||||
f64::from(u8::from(kit.format == super::LINEAR_FRAME)),
|
||||
);
|
||||
let lit = [
|
||||
kit.sprite_constants[0],
|
||||
kit.sprite_constants[1],
|
||||
write_light,
|
||||
];
|
||||
let plain = [
|
||||
kit.sprite_constants[0],
|
||||
kit.sprite_constants[1],
|
||||
("HDR_LIGHTING", 0.0),
|
||||
write_light,
|
||||
];
|
||||
let depth = |write| wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(write),
|
||||
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
};
|
||||
// As gfx.rs's terrain pipelines, without back-face culling.
|
||||
let terrain_pipeline = |label,
|
||||
layout: &wgpu::PipelineLayout,
|
||||
vertex,
|
||||
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
|
||||
fragment,
|
||||
constants: &[(&str, f64)],
|
||||
blend| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &terrain,
|
||||
entry_point: Some(vertex),
|
||||
compilation_options: Default::default(),
|
||||
buffers,
|
||||
},
|
||||
primitive: wgpu::PrimitiveState::default(),
|
||||
depth_stencil: Some(depth(true)),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &terrain,
|
||||
entry_point: Some(fragment),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants,
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: kit.format,
|
||||
blend,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
// The per-section solid draws read the arena page with flat draws (`vs_section`), and
|
||||
// vertex buffers without.
|
||||
let (solid_layout, solid_vertex, solid_buffers): (_, _, &[_]) = match &pages_layout {
|
||||
Some(pages) => (pages, "vs_section", &pulled),
|
||||
None => (&terrain_layout, "vs_terrain", &buffered),
|
||||
};
|
||||
let solid = |constants: &[(&str, f64)]| {
|
||||
terrain_pipeline(
|
||||
"Warped terrain solid",
|
||||
solid_layout,
|
||||
solid_vertex,
|
||||
solid_buffers,
|
||||
"fs_warp_solid",
|
||||
constants,
|
||||
None,
|
||||
)
|
||||
};
|
||||
let solid = SolidPipeline {
|
||||
plain: solid(&plain),
|
||||
hdr: kit.hdr_output.then(|| solid(&lit)),
|
||||
};
|
||||
let translucent = terrain_pipeline(
|
||||
"Warped terrain translucent",
|
||||
&terrain_layout,
|
||||
"vs_terrain",
|
||||
&buffered,
|
||||
"fs_warp_translucent",
|
||||
&lit,
|
||||
Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
);
|
||||
|
||||
let entity_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
|
||||
let entity_vertices = wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &entity_attributes,
|
||||
};
|
||||
let origin_attributes = wgpu::vertex_attr_array![7=>Sint32x3];
|
||||
let static_buffers = [
|
||||
Some(entity_vertices.clone()),
|
||||
Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<[i32; 3]>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &origin_attributes,
|
||||
}),
|
||||
];
|
||||
let entity_buffers = [Some(entity_vertices)];
|
||||
// As gfx.rs's `entity_pipeline_with`, for the pipelines a warped frame swaps.
|
||||
let entity_pipeline = |look: &EntityLook| {
|
||||
let (layout, vertex, buffers): (_, _, &[_]) = if look.static_mesh {
|
||||
(&static_layout, "vs_static_entity", &static_buffers)
|
||||
} else {
|
||||
(&entity_layout, "vs_entity", &entity_buffers)
|
||||
};
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(look.label),
|
||||
layout: Some(layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &entity,
|
||||
entry_point: Some(vertex),
|
||||
compilation_options: Default::default(),
|
||||
buffers,
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: look.cull,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
bias: look.bias,
|
||||
..depth(look.depth_write)
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &entity,
|
||||
entry_point: Some(look.fragment),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &[write_light],
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: kit.format,
|
||||
blend: look.blend,
|
||||
write_mask: look.write_mask,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let pipelines = Pipelines {
|
||||
solid,
|
||||
translucent,
|
||||
entities: entity_pipeline(&ENTITIES),
|
||||
static_entities: entity_pipeline(&STATIC_ENTITIES),
|
||||
translucent_items: entity_pipeline(&TRANSLUCENT_ITEMS),
|
||||
text: entity_pipeline(&TEXT),
|
||||
entity_shadows: entity_pipeline(&ENTITY_SHADOWS),
|
||||
hdr_mark: entity_pipeline(&HDR_MARK),
|
||||
hdr_entity_mark: entity_pipeline(&HDR_ENTITY_MARK),
|
||||
};
|
||||
log::info!(
|
||||
"Horizon: a mod's world warp built in {} ms",
|
||||
started.elapsed().as_millis()
|
||||
);
|
||||
Ok(pipelines)
|
||||
}
|
||||
}
|
||||
|
||||
fn bind(device: &wgpu::Device) -> Bound {
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("World warp"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
});
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("World warp"),
|
||||
size: std::mem::size_of::<Uniform>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("World warp"),
|
||||
layout: &layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: buffer.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
Bound {
|
||||
layout,
|
||||
buffer,
|
||||
group,
|
||||
}
|
||||
}
|
||||
|
||||
/// How one of the entity pipelines a warped frame swaps draws: as gfx.rs builds the stock one
|
||||
/// of the same name.
|
||||
struct EntityLook {
|
||||
label: &'static str,
|
||||
static_mesh: bool,
|
||||
cull: Option<wgpu::Face>,
|
||||
depth_write: bool,
|
||||
bias: wgpu::DepthBiasState,
|
||||
blend: Option<wgpu::BlendState>,
|
||||
fragment: &'static str,
|
||||
write_mask: wgpu::ColorWrites,
|
||||
}
|
||||
const NO_BIAS: wgpu::DepthBiasState = wgpu::DepthBiasState {
|
||||
constant: 0,
|
||||
slope_scale: 0.0,
|
||||
clamp: 0.0,
|
||||
};
|
||||
const ENTITIES: EntityLook = EntityLook {
|
||||
label: "Warped entities",
|
||||
static_mesh: false,
|
||||
cull: None,
|
||||
depth_write: true,
|
||||
bias: wgpu::DepthBiasState {
|
||||
constant: 128,
|
||||
slope_scale: 0.0,
|
||||
clamp: 0.0,
|
||||
},
|
||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
fragment: "fs_entity",
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
};
|
||||
const STATIC_ENTITIES: EntityLook = EntityLook {
|
||||
label: "Warped static block entities",
|
||||
static_mesh: true,
|
||||
bias: NO_BIAS,
|
||||
blend: None,
|
||||
fragment: "fs_entity_unmarked",
|
||||
..ENTITIES
|
||||
};
|
||||
const TRANSLUCENT_ITEMS: EntityLook = EntityLook {
|
||||
label: "Warped translucent items",
|
||||
bias: NO_BIAS,
|
||||
..ENTITIES
|
||||
};
|
||||
const TEXT: EntityLook = EntityLook {
|
||||
label: "Warped polygon offset text",
|
||||
cull: Some(wgpu::Face::Back),
|
||||
bias: wgpu::DepthBiasState {
|
||||
constant: 10,
|
||||
slope_scale: 1.0,
|
||||
clamp: 0.0,
|
||||
},
|
||||
fragment: "fs_entity_unmarked",
|
||||
..ENTITIES
|
||||
};
|
||||
const ENTITY_SHADOWS: EntityLook = EntityLook {
|
||||
label: "Warped entity shadows",
|
||||
cull: Some(wgpu::Face::Back),
|
||||
depth_write: false,
|
||||
bias: wgpu::DepthBiasState {
|
||||
constant: 128,
|
||||
slope_scale: 0.015,
|
||||
clamp: 0.0,
|
||||
},
|
||||
fragment: "fs_entity_shadow",
|
||||
..ENTITIES
|
||||
};
|
||||
const HDR_MARK: EntityLook = EntityLook {
|
||||
label: "Warped HDR particle mark",
|
||||
depth_write: false,
|
||||
bias: NO_BIAS,
|
||||
blend: Some(wgpu::BlendState::REPLACE),
|
||||
fragment: "fs_hdr_mark",
|
||||
write_mask: wgpu::ColorWrites::ALPHA,
|
||||
..ENTITIES
|
||||
};
|
||||
/// `HDR_MARK` at the entities' depth bias, for what they drew.
|
||||
const HDR_ENTITY_MARK: EntityLook = EntityLook {
|
||||
label: "Warped HDR entity mark",
|
||||
bias: ENTITIES.bias,
|
||||
..HDR_MARK
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const BEND: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
|
||||
let lift = sin(pos.x * 0.1 + warp.params[0].x) * warp.params[0].y + section.y * 0.0;
|
||||
return Warped(pos + vec3(0.0, lift, 0.0), vec3(1.0));
|
||||
}
|
||||
";
|
||||
|
||||
#[test]
|
||||
fn a_warp_without_fragments_draws_the_terrain_as_the_game_does() {
|
||||
let terrain = terrain_shader(BEND).unwrap();
|
||||
validate(&terrain).unwrap();
|
||||
assert!(terrain.contains(DEFAULT_FRAGMENT));
|
||||
let entity = entity_shader(BEND).unwrap();
|
||||
validate(&entity).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_warps_own_fragments_replace_the_default_and_stay_out_of_the_entities() {
|
||||
let source = format!(
|
||||
"{BEND}{FRAGMENTS}
|
||||
fn world_fragment(f: Fragment) -> Painted {{
|
||||
if !f.front || f.light.x < 1.0 {{ return Painted(vec4(0.0, 0.0, 0.0, -1.0), 1.0); }}
|
||||
let far = length(f.home - f.at) + f.cut;
|
||||
return Painted(sprite(f.uv * 2.0, f.layer) * f.color, 1.0 - min(far, 1.0));
|
||||
}}
|
||||
"
|
||||
);
|
||||
let terrain = terrain_shader(&source).unwrap();
|
||||
validate(&terrain).unwrap();
|
||||
assert!(!terrain.contains(DEFAULT_FRAGMENT));
|
||||
let entity = entity_shader(&source).unwrap();
|
||||
validate(&entity).unwrap();
|
||||
assert!(!entity.contains("world_fragment"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_stock_shaders_hold_nothing_of_a_warp() {
|
||||
for (name, shader) in [
|
||||
("terrain", super::super::terrain_shader()),
|
||||
("entity", super::super::static_entity_shader()),
|
||||
] {
|
||||
validate(&shader).unwrap();
|
||||
for word in ["world_warp(", "warp_home", "Warped", "group(2)"] {
|
||||
assert!(!shader.contains(word), "{name}.wgsl holds {word}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_marker_is_found() {
|
||||
let terrain = terrain_shader(BEND).unwrap();
|
||||
let entity = entity_shader(BEND).unwrap();
|
||||
for shader in [&terrain, &entity] {
|
||||
assert!(!shader.contains("// [world warp"), "a marker left unfilled");
|
||||
}
|
||||
assert!(terrain.contains("let warped = world_warp(warp_home, origin);"));
|
||||
assert!(entity.contains("let at = world_warp(position, position).pos;"));
|
||||
assert!(entity.contains("world_warp(section_corner + position, section_corner)"));
|
||||
assert_eq!(
|
||||
fill("a // [x] b // [end x] c", "x", "B"),
|
||||
Ok("a B c".to_owned())
|
||||
);
|
||||
assert!(fill("a // [x] b", "x", "B").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_warp_that_does_not_compile_is_refused_with_naga_s_reason() {
|
||||
let terrain =
|
||||
terrain_shader("fn world_warp(pos: vec3<f32>) -> Warped { return 1; }").unwrap();
|
||||
let e = validate(&terrain).unwrap_err();
|
||||
assert!(e.contains("world_warp"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_uniform_keeps_the_camera_exact_far_out() {
|
||||
let mut warp = WorldWarp::default();
|
||||
warp.params[5] = 2.0;
|
||||
let camera = DVec3::new(29_999_984.3, 70.25, -12_345_678.9);
|
||||
let u = uniform(&warp, camera, 0.25);
|
||||
assert_eq!(u.params[1][1], 2.0);
|
||||
assert_eq!(u.camera[3], 0.25);
|
||||
for i in 0..3 {
|
||||
let whole = f64::from(u.camera[i]) + f64::from(u.camera_rest[i]);
|
||||
assert!((whole - camera[i]).abs() < 1e-6, "{whole} {}", camera[i]);
|
||||
}
|
||||
assert_eq!(std::mem::size_of::<Uniform>(), 96);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// A native mod's world warp (gfx/world_warp.rs, docs/native-mods.md "World warps"): what the
|
||||
// game declares around the mod's WGSL in the warp's own copies of terrain.wgsl and entity.wgsl.
|
||||
// None of it is in the shaders a frame without a warp draws with.
|
||||
|
||||
// What `world_warp` makes of a vertex: where to draw it, from the camera, and what to multiply
|
||||
// its colour by (terrain only; red under 0 marks its faces cut: `Fragment.cut`).
|
||||
struct Warped {
|
||||
pos: vec3<f32>,
|
||||
tint: vec3<f32>,
|
||||
}
|
||||
struct WorldWarpUniform {
|
||||
// `WorldWarp::params`.
|
||||
params: array<vec4<f32>, 4>,
|
||||
// xyz: the camera in the world as f32; `camera_rest.xyz` what f32 left out of it, so
|
||||
// `(p - warp.camera.xyz) - warp.camera_rest.xyz` is the world's point `p` from the camera,
|
||||
// exact far from the world's origin. w: the frame's partial tick, 0 to 1, for motion
|
||||
// smoother than the ticks `params` are set in.
|
||||
camera: vec4<f32>,
|
||||
camera_rest: vec4<f32>,
|
||||
}
|
||||
@group(2) @binding(0) var<uniform> warp: WorldWarpUniform;
|
||||
@@ -0,0 +1,70 @@
|
||||
// A world warp's terrain pixels (gfx/world_warp.rs): added to the warp's copy of terrain.wgsl
|
||||
// only. `world_fragment` is the mod's if its source has one after `// [fragments]`, else
|
||||
// `DEFAULT_FRAGMENT` in gfx/world_warp.rs (the terrain as the game draws it).
|
||||
|
||||
// A pixel of the land as `world_fragment` gets it.
|
||||
struct Fragment {
|
||||
// Where this point of the world really is, from the camera: `world_warp`'s `pos`.
|
||||
home: vec3<f32>,
|
||||
// Where it is drawn, from the camera.
|
||||
at: vec3<f32>,
|
||||
// `sprite(uv, layer)` is the block's texture here.
|
||||
uv: vec2<f32>,
|
||||
layer: u32,
|
||||
// The vertices' colour: biome tint, light, ambient occlusion and the warp's tint.
|
||||
color: vec4<f32>,
|
||||
// Sky and block light, 0 to 15.
|
||||
light: vec2<f32>,
|
||||
// Over 0 where a vertex of the face had a tint with red under 0.
|
||||
cut: f32,
|
||||
// False on a face's back: the warp's pipelines don't cull, so both sides draw.
|
||||
front: bool,
|
||||
}
|
||||
// What `world_fragment` makes of it: a colour (alpha under 0 leaves the pixel out) and how much
|
||||
// of the game's fog it takes, 0 to 1.
|
||||
struct Painted {
|
||||
color: vec4<f32>,
|
||||
fog: f32,
|
||||
}
|
||||
fn warp_fragment(in: TerrainOut, front: bool) -> Fragment {
|
||||
return Fragment(in.warp_home.xyz, in.warp_at, in.uv, in.layer & 0x7fffu, in.color,
|
||||
in.scene.yx, in.warp_home.w, front);
|
||||
}
|
||||
// As `solid_color`, with the mod's colour in place of the texture's.
|
||||
@fragment fn fs_warp_solid(wire: TerrainWire, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
let in = from_wire(wire);
|
||||
let dx = dpdx(in.local);
|
||||
let dy = dpdy(in.local);
|
||||
if HDR_LIGHTING && globals.hdr_light.z != 0.0 {
|
||||
hdr_normal = normalize(cross(dy, dx));
|
||||
}
|
||||
// (far terrain's tiles and the sections replacing them still cross-fade)
|
||||
if faded(in.fade, in.local, dx, dy) { discard; }
|
||||
let p = world_fragment(warp_fragment(in, front));
|
||||
if p.color.a < 0.0 { discard; }
|
||||
let color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, p.color.a), p.color, in.visibility);
|
||||
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
|
||||
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
|
||||
let finished = solid_finish_at(in, color, color, dpdx(in.uv), dpdy(in.uv));
|
||||
return solid_light(vec4(mix(color.rgb, finished.rgb, p.fog), finished.a));
|
||||
}
|
||||
// As `fs_translucent`.
|
||||
@fragment fn fs_warp_translucent(wire: TerrainWire, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
let in = from_wire(wire);
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
|
||||
hdr_normal = normalize(cross(dpdy(in.local), dpdx(in.local)));
|
||||
}
|
||||
let p = world_fragment(warp_fragment(in, front));
|
||||
if p.color.a < 0.0 { discard; }
|
||||
let color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, p.color.a), p.color, in.visibility);
|
||||
if color.a < 0.01 { discard; }
|
||||
let fogged = apply_fog(color, in.spherical, in.cylindrical);
|
||||
let finished = vec4(mix(color.rgb, fogged.rgb, p.fog), color.a);
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
|
||||
let fog = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
|
||||
let mark = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()), globals.hdr_light.y, fog));
|
||||
return vec4(hdr_written(finished.rgb, mark, globals.hdr_settings, globals.hdr_shares), finished.a);
|
||||
}
|
||||
return finished;
|
||||
}
|
||||
@@ -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,14 +6,14 @@
|
||||
//! 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,
|
||||
lang,
|
||||
options_ui::{Event, Page as OptionsPage},
|
||||
settings::{Opt, Settings},
|
||||
settings_shell::{self as shell, Group, Output, Row, Screen, Sheet, Value},
|
||||
settings_shell::{self as shell, Binding, Group, Output, Row, Screen, Sheet, Value},
|
||||
text::Text,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
@@ -1333,7 +1331,7 @@ fn shown(item: Item, s: &Settings) -> bool {
|
||||
/// A control's label as its name and its value ("Render Distance" and "12 chunks").
|
||||
fn parts(item: Item, s: &Settings) -> (String, Option<String>) {
|
||||
// The shortcut's label is one translated "Shortcut: %s"; the row shows its key apart, as
|
||||
// KeyBindsScreen does ("> key <" while it waits, `settings_shell::Value::Key`).
|
||||
// KeyBindsScreen does ("> key <" while it waits, `settings_shell::Value::Binding`).
|
||||
if item == Item::Horizon(H::HdrKey) {
|
||||
let key = crate::keybinds::display_name(&s.horizon.hdr_key).plain();
|
||||
return (lang::get("Shortcut"), Some(key));
|
||||
@@ -1347,9 +1345,10 @@ fn row(setting: Setting, s: &Settings) -> Row<Item> {
|
||||
let item = setting.item;
|
||||
let (name, value) = parts(item, s);
|
||||
let value = match (item, item.switch(s), item.slider(s)) {
|
||||
(Item::Horizon(H::HdrKey), ..) => {
|
||||
Value::Key(crate::graphics::HDR_KEY, value.unwrap_or_default())
|
||||
}
|
||||
(Item::Horizon(H::HdrKey), ..) => Value::Binding(Binding::key(
|
||||
crate::graphics::HDR_KEY,
|
||||
value.unwrap_or_default(),
|
||||
)),
|
||||
(_, Some(on), _) => Value::Switch(on),
|
||||
(_, None, Some(position)) => Value::Slider {
|
||||
position,
|
||||
@@ -1442,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(),
|
||||
@@ -1466,6 +1466,8 @@ impl Screen for GraphicsScreen {
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
listed: true,
|
||||
heading: None,
|
||||
};
|
||||
rows().into_iter().map(sheet).collect()
|
||||
}
|
||||
@@ -1486,7 +1488,6 @@ impl Screen for GraphicsScreen {
|
||||
Output::Push(OptionsPage::ShaderPacks),
|
||||
]
|
||||
}
|
||||
Item::Horizon(H::HdrKey) => vec![Output::BindKey(crate::graphics::HDR_KEY)],
|
||||
_ => {
|
||||
item.cycle(s, back, self.framebuffer);
|
||||
vec![Output::Event(Event::Changed)]
|
||||
@@ -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 {
|
||||
@@ -1564,7 +1566,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
graphics_style as style,
|
||||
settings_shell::{Columns, Hosted, Shell, Target},
|
||||
settings_shell::{Capture, Columns, Hosted, Shell, Target},
|
||||
};
|
||||
use winit::keyboard::KeyCode as K;
|
||||
|
||||
@@ -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();
|
||||
@@ -1868,11 +1873,38 @@ mod tests {
|
||||
let key = row(&mut ui, &s, Item::Horizon(H::HdrKey));
|
||||
assert_eq!(
|
||||
press(&mut ui, &mut s, at(key)),
|
||||
[CLICK, Output::BindKey(crate::graphics::HDR_KEY)]
|
||||
[CLICK, Output::Bind(Capture::Key(crate::graphics::HDR_KEY))]
|
||||
);
|
||||
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);
|
||||
}
|
||||
@@ -2092,7 +2124,8 @@ mod tests {
|
||||
);
|
||||
let key = find(&s, Item::Horizon(H::HdrKey));
|
||||
assert_eq!(key.name, "Shortcut");
|
||||
assert!(matches!(key.value, Value::Key(crate::graphics::HDR_KEY, _)));
|
||||
let waits = Capture::Key(crate::graphics::HDR_KEY);
|
||||
assert!(matches!(key.value, Value::Binding(ref b) if b.capture == waits));
|
||||
let calibrate = find(&s, Item::Horizon(H::HdrCalibrate));
|
||||
assert_eq!(calibrate.value, Value::Press);
|
||||
}
|
||||
@@ -2729,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);
|
||||
@@ -2746,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);
|
||||
@@ -2761,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
|
||||
@@ -2768,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,
|
||||
@@ -1355,7 +1342,15 @@ impl HudAtlas {
|
||||
.filter(|&size| size != (w, h));
|
||||
let flags = mcmeta.as_deref().and_then(TextureFlags::from_mcmeta);
|
||||
let animated = frames && animation.is_some();
|
||||
let (gw, gh) = gui_size.unwrap_or((w, h));
|
||||
// The cap below keeps GUI textures small; a particle sprite isn't capped, as
|
||||
// vanilla's particle atlas stitches any size (packs draw light shafts and fog
|
||||
// banks from 1024-wide textures).
|
||||
let particle = name.starts_with("particle/") || name.contains(":particle/");
|
||||
let (gw, gh) = if particle {
|
||||
(0, 0)
|
||||
} else {
|
||||
gui_size.unwrap_or((w, h))
|
||||
};
|
||||
let maps = read_maps
|
||||
.then(|| {
|
||||
let stem = path.file_stem()?.to_str()?;
|
||||
@@ -1520,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);
|
||||
@@ -1737,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,
|
||||
@@ -1834,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.
|
||||
@@ -1893,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;
|
||||
@@ -1911,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(
|
||||
@@ -2529,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.
|
||||
@@ -2554,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> {
|
||||
@@ -2628,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 {
|
||||
@@ -2658,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 {
|
||||
@@ -2667,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),
|
||||
@@ -2682,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(),
|
||||
@@ -2706,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.
|
||||
@@ -2730,7 +2805,7 @@ impl HudRenderer {
|
||||
},
|
||||
},
|
||||
"fs_crosshair",
|
||||
false,
|
||||
None,
|
||||
);
|
||||
// BlendFunction.ADDITIVE: ONE, ONE.
|
||||
let add = wgpu::BlendComponent {
|
||||
@@ -2745,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(
|
||||
@@ -2763,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 {
|
||||
@@ -2786,6 +2872,7 @@ impl HudRenderer {
|
||||
invert_pipeline,
|
||||
additive_pipeline,
|
||||
depth_pipeline,
|
||||
depth_glint_pipeline,
|
||||
vignette_pipeline,
|
||||
end_portal_pipeline,
|
||||
layout,
|
||||
@@ -2993,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,
|
||||
@@ -3001,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 {
|
||||
@@ -3052,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) => {
|
||||
@@ -3427,6 +3525,32 @@ mod tests {
|
||||
assert!(!atlas.sprites.contains_key("gems:item/ruby"));
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
/// A pack's particle sprite of any size packs whole, as vanilla's particle atlas stitches
|
||||
/// it (a light shaft drawn from a 1024-wide texture); the cap on GUI textures stays.
|
||||
#[test]
|
||||
fn large_particle_sprites_pack_and_large_gui_textures_dont() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-atlas-big-particle-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let rays = root.join("assets/mypack/textures/particle/light_rays");
|
||||
fs::create_dir_all(&rays).unwrap();
|
||||
write_strip(
|
||||
&rays.join("02.png"),
|
||||
1024,
|
||||
1024,
|
||||
&[[250, 240, 200, 255]],
|
||||
None,
|
||||
);
|
||||
let gui = root.join("assets/minecraft/textures/gui");
|
||||
fs::create_dir_all(&gui).unwrap();
|
||||
write_strip(&gui.join("huge.png"), 2048, 64, &[[1, 2, 3, 255]], None);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let rect = atlas.sprites["mypack:particle/light_rays/02"];
|
||||
assert_eq!(rect.texels((atlas.width, atlas.height)), (1024, 1024));
|
||||
assert_eq!(texel_at(&atlas, rect, 512, 512), [250, 240, 200, 255]);
|
||||
assert!(!atlas.sprites.contains_key("gui/huge"));
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
/// OptiFine random entity variants outside `textures/` pack under the names
|
||||
/// `RandomTextures` gives them; one beside its texture packs once, under its own.
|
||||
#[test]
|
||||
@@ -4567,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);
|
||||
}
|
||||
}
|
||||
@@ -50,10 +50,14 @@ pub fn text_with_backdrop(
|
||||
text(out, t, x, y, color, true);
|
||||
}
|
||||
|
||||
/// `Arrays.sort(T[], Comparator)` for the short arrays the HUD sorts: Java's
|
||||
/// TimSort below `MIN_MERGE` (a natural run, then binary insertion), which
|
||||
/// matters when the comparator is not a total order. Longer arrays fall back
|
||||
/// to a stable sort.
|
||||
/// `Arrays.sort(T[], Comparator)` for the short arrays the HUD sorts. Its Javadoc
|
||||
/// says it is adapted from Tim Peters's list sort for Python, and for fewer than 32
|
||||
/// elements that sort is one run plus binary insertion, as Tim Peters describes it
|
||||
/// in `listsort.txt` (CPython's `Objects/listsort.txt`): take the run at the start,
|
||||
/// reversed in place when it is strictly descending, then insert each remaining
|
||||
/// element by binary search after the elements equal to it, so the sort is stable.
|
||||
/// The order of the comparisons matters because vanilla's comparator here is not a
|
||||
/// total order. Longer arrays fall back to a stable sort.
|
||||
pub fn java_sort<T>(a: &mut [T], cmp: impl Fn(&T, &T) -> Ordering) {
|
||||
let n = a.len();
|
||||
if n < 2 {
|
||||
@@ -63,7 +67,7 @@ pub fn java_sort<T>(a: &mut [T], cmp: impl Fn(&T, &T) -> Ordering) {
|
||||
a.sort_by(cmp);
|
||||
return;
|
||||
}
|
||||
// countRunAndMakeAscending.
|
||||
// The run at the start: strictly descending (then reversed) or non-descending.
|
||||
let mut run = 2;
|
||||
if cmp(&a[1], &a[0]) == Ordering::Less {
|
||||
while run < n && cmp(&a[run], &a[run - 1]) == Ordering::Less {
|
||||
@@ -75,7 +79,7 @@ pub fn java_sort<T>(a: &mut [T], cmp: impl Fn(&T, &T) -> Ordering) {
|
||||
run += 1;
|
||||
}
|
||||
}
|
||||
// binarySort.
|
||||
// Binary insertion of the rest, each element after the last one not greater.
|
||||
for start in run..n {
|
||||
let (mut left, mut right) = (0, start);
|
||||
while left < right {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1283,7 +1283,7 @@ fn relight_layer(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use crate::chunk::{HEIGHT, MIN_Y, SECTIONS};
|
||||
|
||||
@@ -1312,13 +1312,13 @@ mod tests {
|
||||
assert_eq!(dirty(&[(0, 0, 0)]).len(), 8);
|
||||
}
|
||||
|
||||
const AIR: u32 = 0;
|
||||
pub(crate) const AIR: u32 = 0;
|
||||
const STONE: u32 = 1;
|
||||
const TORCH: u32 = 2;
|
||||
pub(crate) const TORCH: u32 = 2;
|
||||
const SLAB: u32 = 3;
|
||||
/// Air, stone, torch and a bottom slab (full bottom face, half sides,
|
||||
/// empty top), with a third face shape for the half sides.
|
||||
fn tables() -> LightTables {
|
||||
pub(crate) fn tables() -> LightTables {
|
||||
let cell = |dampening, emission, faces: [u16; 6]| Cell {
|
||||
dampening,
|
||||
emission,
|
||||
@@ -1337,7 +1337,7 @@ mod tests {
|
||||
3,
|
||||
)
|
||||
}
|
||||
fn world(edit: impl Fn(&mut Chunk)) -> HashMap<(i32, i32), Arc<Chunk>> {
|
||||
pub(crate) fn world(edit: impl Fn(&mut Chunk)) -> HashMap<(i32, i32), Arc<Chunk>> {
|
||||
world_of(crate::chunk::ColumnLayout::VANILLA, edit)
|
||||
}
|
||||
/// Lit chunks around (0, 0) of `layout`, stone up to y 0.
|
||||
|
||||
@@ -7,7 +7,9 @@ use crate::{
|
||||
mesher::SectionMesh,
|
||||
streaming::{LodSource, WorkerContext},
|
||||
};
|
||||
pub mod bake;
|
||||
pub mod cache;
|
||||
mod kept;
|
||||
mod mesh;
|
||||
pub mod packed;
|
||||
mod samples;
|
||||
@@ -201,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;
|
||||
@@ -1320,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,
|
||||
@@ -1687,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,
|
||||
}
|
||||
}
|
||||
@@ -1712,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;
|
||||
@@ -2029,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
|
||||
@@ -2136,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.
|
||||
@@ -2463,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));
|
||||
@@ -2472,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();
|
||||
}
|
||||
@@ -2512,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
|
||||
@@ -2541,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) {
|
||||
@@ -2860,6 +3013,14 @@ impl Lod {
|
||||
if self.outstanding_meshes >= self.mesh_workers * 4 {
|
||||
return;
|
||||
}
|
||||
// As in `request_samples`: the far meshing threads run behind the render thread, so it
|
||||
// never waits for this lock. Busy now, the dirty tiles stay dirty for the next frame.
|
||||
let shared = self.shared.clone();
|
||||
let mut queued = match shared.mesh_jobs.try_lock() {
|
||||
Ok(queued) => queued,
|
||||
Err(std::sync::TryLockError::WouldBlock) => return,
|
||||
Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
|
||||
};
|
||||
let keys: Vec<(TileKey, u64)> = self
|
||||
.dirty
|
||||
.drain()
|
||||
@@ -2878,7 +3039,6 @@ impl Lod {
|
||||
split: self.plan.split.clone(),
|
||||
});
|
||||
let covered = Arc::new(self.covered.clone());
|
||||
let mut queued = self.shared.mesh_jobs.lock().unwrap();
|
||||
let count = keys.len();
|
||||
for (key, version) in keys {
|
||||
let finer = self.finer_of(key);
|
||||
@@ -2989,7 +3149,14 @@ impl Lod {
|
||||
self.wanted_stale = false;
|
||||
}
|
||||
let mut issued = 0;
|
||||
let mut jobs = self.shared.jobs.lock().unwrap();
|
||||
// The far sampling threads take this lock too, and they run behind the render thread
|
||||
// (`far_terrain_current`): one preempted while holding it kept a frame waiting for over
|
||||
// 100 ms on a busy machine. Busy now, the requests go out next frame.
|
||||
let mut jobs = match self.shared.jobs.try_lock() {
|
||||
Ok(jobs) => jobs,
|
||||
Err(std::sync::TryLockError::WouldBlock) => return,
|
||||
Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
|
||||
};
|
||||
while issued < free {
|
||||
let Some(std::cmp::Reverse(wanted)) = self.wanted.pop() else {
|
||||
break;
|
||||
@@ -3005,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;
|
||||
}
|
||||
@@ -3932,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,534 @@
|
||||
//! Baked far terrain: the generator's far samples (`LodSampler::grid`), kept
|
||||
//! in the dimension's folder (`horizon_lod/`) so the next session draws them
|
||||
//! without sampling the noise again. The detail chunks' columns are kept by
|
||||
//! `lod::cache`.
|
||||
//!
|
||||
//! Only generator output is baked: saved chunks still override it
|
||||
//! (`save::SavedLodSampler`), so edits show as before. A fingerprint of the
|
||||
//! game build, the world's data packs, the block and biome registries and the
|
||||
//! level's shape guards the folder; when any of them changes it is emptied.
|
||||
//! Reduced far detail's approximate samples are kept apart from full ones.
|
||||
use super::{Column, Span, Surface};
|
||||
use crate::{chunk::LevelShape, fast_hash::FxHashMap};
|
||||
use flate2::{Compression, read::ZlibDecoder, write::ZlibEncoder};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
fs,
|
||||
io::{BufReader, Read, Seek, SeekFrom, Write},
|
||||
path::{Path, PathBuf},
|
||||
sync::Mutex,
|
||||
};
|
||||
|
||||
const FORMAT: &str = "horizon-lod-bake 3";
|
||||
const FOLDER: &str = "horizon_lod";
|
||||
const FINGERPRINT: &str = "fingerprint.txt";
|
||||
/// Tiles per bucket file side.
|
||||
const GRID_BUCKET: i32 = 8;
|
||||
/// The key written before each record: kind, x, z, stride, side.
|
||||
const KEY_BYTES: usize = 15;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
enum Key {
|
||||
/// `LodSampler::grid(x, z, stride, side)`, sampled approximately or not.
|
||||
Grid {
|
||||
approximate: bool,
|
||||
x: i32,
|
||||
z: i32,
|
||||
stride: i32,
|
||||
side: u16,
|
||||
},
|
||||
}
|
||||
impl Key {
|
||||
fn file(self) -> String {
|
||||
match self {
|
||||
Key::Grid {
|
||||
approximate,
|
||||
x,
|
||||
z,
|
||||
stride,
|
||||
side,
|
||||
} => {
|
||||
let span = stride * i32::from(side) * GRID_BUCKET;
|
||||
format!(
|
||||
"{}{stride}x{side}.{}.{}.bin",
|
||||
if approximate { "a" } else { "g" },
|
||||
x.div_euclid(span),
|
||||
z.div_euclid(span)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
fn bytes(self) -> [u8; KEY_BYTES] {
|
||||
let (kind, x, z, stride, side) = match self {
|
||||
Key::Grid {
|
||||
approximate,
|
||||
x,
|
||||
z,
|
||||
stride,
|
||||
side,
|
||||
} => (u8::from(approximate), x, z, stride, side),
|
||||
};
|
||||
let mut out = [0; KEY_BYTES];
|
||||
out[0] = kind;
|
||||
out[1..5].copy_from_slice(&x.to_le_bytes());
|
||||
out[5..9].copy_from_slice(&z.to_le_bytes());
|
||||
out[9..13].copy_from_slice(&stride.to_le_bytes());
|
||||
out[13..15].copy_from_slice(&side.to_le_bytes());
|
||||
out
|
||||
}
|
||||
fn from_bytes(b: &[u8; KEY_BYTES]) -> Option<Self> {
|
||||
let int = |at: usize| i32::from_le_bytes(b[at..at + 4].try_into().unwrap());
|
||||
match b[0] {
|
||||
kind @ (0 | 1) => Some(Key::Grid {
|
||||
approximate: kind == 1,
|
||||
x: int(1),
|
||||
z: int(5),
|
||||
stride: int(9),
|
||||
side: u16::from_le_bytes([b[13], b[14]]),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One append-only file of records: `[u32 length][key][zlib columns]`.
|
||||
struct Bucket {
|
||||
file: fs::File,
|
||||
/// Each record's compressed columns: offset and length.
|
||||
index: FxHashMap<Key, (u64, u32)>,
|
||||
end: u64,
|
||||
}
|
||||
impl Bucket {
|
||||
fn open(path: &Path) -> std::io::Result<Self> {
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(path)?;
|
||||
let mut index = FxHashMap::default();
|
||||
let mut end = 0u64;
|
||||
let len = file.metadata()?.len();
|
||||
{
|
||||
let mut reader = BufReader::new(&mut file);
|
||||
loop {
|
||||
let mut length = [0; 4];
|
||||
if reader.read_exact(&mut length).is_err() {
|
||||
break;
|
||||
}
|
||||
let length = u32::from_le_bytes(length);
|
||||
let mut key = [0; KEY_BYTES];
|
||||
if (length as usize) < KEY_BYTES || reader.read_exact(&mut key).is_err() {
|
||||
break;
|
||||
}
|
||||
let Some(key) = Key::from_bytes(&key) else {
|
||||
break;
|
||||
};
|
||||
let body = length - KEY_BYTES as u32;
|
||||
let start = end + 4 + KEY_BYTES as u64;
|
||||
// A record cut short by a crash ends the file there.
|
||||
if start + u64::from(body) > len || reader.seek_relative(i64::from(body)).is_err() {
|
||||
break;
|
||||
}
|
||||
index.insert(key, (start, body));
|
||||
end = start + u64::from(body);
|
||||
}
|
||||
}
|
||||
if len != end {
|
||||
file.set_len(end)?;
|
||||
}
|
||||
Ok(Self { file, index, end })
|
||||
}
|
||||
fn read(&mut self, key: Key) -> Option<Vec<u8>> {
|
||||
let &(offset, length) = self.index.get(&key)?;
|
||||
let mut body = vec![0; length as usize];
|
||||
self.file.seek(SeekFrom::Start(offset)).ok()?;
|
||||
self.file.read_exact(&mut body).ok()?;
|
||||
Some(body)
|
||||
}
|
||||
fn append(&mut self, key: Key, body: &[u8]) -> std::io::Result<()> {
|
||||
let length = (KEY_BYTES + body.len()) as u32;
|
||||
let mut record = Vec::with_capacity(4 + length as usize);
|
||||
record.extend_from_slice(&length.to_le_bytes());
|
||||
record.extend_from_slice(&key.bytes());
|
||||
record.extend_from_slice(body);
|
||||
self.file.seek(SeekFrom::Start(self.end))?;
|
||||
self.file.write_all(&record)?;
|
||||
let start = self.end + 4 + KEY_BYTES as u64;
|
||||
self.index.insert(key, (start, body.len() as u32));
|
||||
self.end = start + body.len() as u64;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A dimension's baked far terrain, shared by the far sampling threads.
|
||||
pub struct LodBake {
|
||||
dir: PathBuf,
|
||||
/// Open buckets by file name; `None` for one that failed to open.
|
||||
buckets: Mutex<FxHashMap<String, Option<Bucket>>>,
|
||||
}
|
||||
impl LodBake {
|
||||
/// Opens `dimension/horizon_lod`, emptying it when its fingerprint is not
|
||||
/// this build's, world's, level's and registries'. `None` (no baking) on
|
||||
/// an error.
|
||||
pub fn open(
|
||||
dimension: &Path,
|
||||
shape: LevelShape,
|
||||
block_states: usize,
|
||||
biomes: usize,
|
||||
) -> Option<Self> {
|
||||
let dir = dimension.join(FOLDER);
|
||||
let fingerprint = fingerprint(dimension, shape, block_states, biomes);
|
||||
let current = fs::read_to_string(dir.join(FINGERPRINT)).ok();
|
||||
if current.as_deref() != Some(fingerprint.as_str()) {
|
||||
if current.is_some() {
|
||||
log::info!("Horizon: baked far terrain is out of date; baking it again");
|
||||
}
|
||||
let reset = || -> std::io::Result<()> {
|
||||
if dir.exists() {
|
||||
fs::remove_dir_all(&dir)?;
|
||||
}
|
||||
fs::create_dir_all(&dir)?;
|
||||
fs::write(dir.join(FINGERPRINT), &fingerprint)
|
||||
};
|
||||
if let Err(e) = reset() {
|
||||
log::warn!(
|
||||
"Horizon: far terrain will not be baked ({}): {e}",
|
||||
dir.display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(Self {
|
||||
dir,
|
||||
buckets: Mutex::new(FxHashMap::default()),
|
||||
})
|
||||
}
|
||||
fn with_bucket<T>(&self, key: Key, f: impl FnOnce(&mut Bucket) -> Option<T>) -> Option<T> {
|
||||
let name = key.file();
|
||||
let mut buckets = self.buckets.lock().unwrap();
|
||||
let bucket = buckets
|
||||
.entry(name)
|
||||
.or_insert_with_key(|name| match Bucket::open(&self.dir.join(name)) {
|
||||
Ok(bucket) => Some(bucket),
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: baked far terrain {name}: {e}");
|
||||
None
|
||||
}
|
||||
})
|
||||
.as_mut()?;
|
||||
f(bucket)
|
||||
}
|
||||
fn get(&self, key: Key, count: usize) -> Option<Vec<Column>> {
|
||||
let body = self.with_bucket(key, |bucket| bucket.read(key))?;
|
||||
let mut raw = Vec::new();
|
||||
ZlibDecoder::new(&body[..]).read_to_end(&mut raw).ok()?;
|
||||
decode(&raw, count)
|
||||
}
|
||||
fn put(&self, key: Key, columns: &[Column]) {
|
||||
if self.with_bucket(key, |bucket| Some(bucket.index.contains_key(&key))) != Some(false) {
|
||||
return;
|
||||
}
|
||||
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::fast());
|
||||
let _ = encoder.write_all(&encode(columns));
|
||||
let Ok(body) = encoder.finish() else { return };
|
||||
self.with_bucket(key, |bucket| {
|
||||
if !bucket.index.contains_key(&key)
|
||||
&& let Err(e) = bucket.append(key, &body)
|
||||
{
|
||||
log::warn!("Horizon: baking far terrain: {e}");
|
||||
}
|
||||
Some(())
|
||||
});
|
||||
}
|
||||
/// The baked `LodSampler::grid` result, if any.
|
||||
pub fn grid(
|
||||
&self,
|
||||
approximate: bool,
|
||||
[x, z]: [i32; 2],
|
||||
stride: i32,
|
||||
side: usize,
|
||||
) -> Option<Vec<Column>> {
|
||||
let side16 = u16::try_from(side).ok()?;
|
||||
self.get(
|
||||
Key::Grid {
|
||||
approximate,
|
||||
x,
|
||||
z,
|
||||
stride,
|
||||
side: side16,
|
||||
},
|
||||
side * side,
|
||||
)
|
||||
}
|
||||
pub fn put_grid(
|
||||
&self,
|
||||
approximate: bool,
|
||||
[x, z]: [i32; 2],
|
||||
stride: i32,
|
||||
side: usize,
|
||||
columns: &[Column],
|
||||
) {
|
||||
// A failed direct sample comes back as empty columns: never keep it.
|
||||
let Ok(side) = u16::try_from(side) else {
|
||||
return;
|
||||
};
|
||||
if columns.iter().all(|c| *c == Column::default()) {
|
||||
return;
|
||||
}
|
||||
self.put(
|
||||
Key::Grid {
|
||||
approximate,
|
||||
x,
|
||||
z,
|
||||
stride,
|
||||
side,
|
||||
},
|
||||
columns,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// What baked columns depend on besides the world's seed (fixed per world).
|
||||
fn fingerprint(dimension: &Path, shape: LevelShape, block_states: usize, biomes: usize) -> String {
|
||||
let build = super::cache::build_key(block_states, biomes);
|
||||
let packs = world_packs_digest(dimension);
|
||||
format!(
|
||||
"{FORMAT}\nbuild {build:016x}\nlevel {} {}\ndatapacks {packs}\n",
|
||||
shape.min_y, shape.height,
|
||||
)
|
||||
}
|
||||
/// `files_digest` of the data packs of the world `dimension` is a folder of.
|
||||
pub fn world_packs_digest(dimension: &Path) -> String {
|
||||
// `dimensions/<namespace>/<path>` inside the world folder.
|
||||
dimension
|
||||
.ancestors()
|
||||
.nth(3)
|
||||
.map(|world| files_digest(&world.join("datapacks")))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
/// Every file under `root`: its path, size and change time.
|
||||
pub fn files_digest(root: &Path) -> String {
|
||||
let mut files = Vec::new();
|
||||
let mut stack = vec![root.to_path_buf()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let Ok(meta) = entry.metadata() else { continue };
|
||||
if meta.is_dir() {
|
||||
stack.push(path);
|
||||
} else {
|
||||
let modified = meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map_or(0, |d| d.as_nanos());
|
||||
let relative = path.strip_prefix(root).unwrap_or(&path);
|
||||
files.push(format!("{} {} {modified}", relative.display(), meta.len()));
|
||||
}
|
||||
}
|
||||
}
|
||||
files.sort_unstable();
|
||||
let mut hash = Sha256::new();
|
||||
for file in &files {
|
||||
hash.update(file.as_bytes());
|
||||
hash.update(b"\n");
|
||||
}
|
||||
hash.finalize().iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
fn encode(columns: &[Column]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(columns.len() * 24);
|
||||
out.extend_from_slice(&(columns.len() as u32).to_le_bytes());
|
||||
for column in columns {
|
||||
match column.surface {
|
||||
None => out.push(0),
|
||||
Some(s) => {
|
||||
out.push(if s.water.is_some() { 2 } else { 1 });
|
||||
out.extend_from_slice(&s.height.to_le_bytes());
|
||||
out.extend_from_slice(&s.state.0.to_le_bytes());
|
||||
out.extend_from_slice(&s.biome.to_le_bytes());
|
||||
if let Some((height, state)) = s.water {
|
||||
out.extend_from_slice(&height.to_le_bytes());
|
||||
out.extend_from_slice(&state.0.to_le_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
out.extend_from_slice(&(column.spans.len() as u16).to_le_bytes());
|
||||
for span in &column.spans {
|
||||
out.extend_from_slice(&span.min.to_le_bytes());
|
||||
out.extend_from_slice(&span.max.to_le_bytes());
|
||||
out.extend_from_slice(&span.state.0.to_le_bytes());
|
||||
out.extend_from_slice(&span.biome.to_le_bytes());
|
||||
out.extend_from_slice(&span.light.to_le_bytes());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
fn decode(mut b: &[u8], count: usize) -> Option<Vec<Column>> {
|
||||
fn take<const N: usize>(b: &mut &[u8]) -> Option<[u8; N]> {
|
||||
let (head, rest) = b.split_first_chunk::<N>()?;
|
||||
*b = rest;
|
||||
Some(*head)
|
||||
}
|
||||
let i32_ = |b: &mut &[u8]| take::<4>(b).map(i32::from_le_bytes);
|
||||
let u32_ = |b: &mut &[u8]| take::<4>(b).map(u32::from_le_bytes);
|
||||
let u16_ = |b: &mut &[u8]| take::<2>(b).map(u16::from_le_bytes);
|
||||
let i16_ = |b: &mut &[u8]| take::<2>(b).map(i16::from_le_bytes);
|
||||
if u32_(&mut b)? as usize != count {
|
||||
return None;
|
||||
}
|
||||
let mut columns = Vec::with_capacity(count);
|
||||
for _ in 0..count {
|
||||
let kind = take::<1>(&mut b)?[0];
|
||||
let surface = match kind {
|
||||
0 => None,
|
||||
1 | 2 => {
|
||||
let height = i32_(&mut b)?;
|
||||
let state = BlockStateId(u32_(&mut b)?);
|
||||
let biome = u16_(&mut b)?;
|
||||
let water = if kind == 2 {
|
||||
Some((i32_(&mut b)?, BlockStateId(u32_(&mut b)?)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Some(Surface {
|
||||
height,
|
||||
state,
|
||||
water,
|
||||
biome,
|
||||
})
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let spans = (0..u16_(&mut b)?)
|
||||
.map(|_| {
|
||||
Some(Span {
|
||||
min: i16_(&mut b)?,
|
||||
max: i16_(&mut b)?,
|
||||
state: BlockStateId(u32_(&mut b)?),
|
||||
biome: u16_(&mut b)?,
|
||||
light: u16_(&mut b)?,
|
||||
})
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
columns.push(Column {
|
||||
surface,
|
||||
spans: spans.into(),
|
||||
});
|
||||
}
|
||||
b.is_empty().then_some(columns)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn sample(seed: i32) -> Column {
|
||||
Column {
|
||||
surface: Some(Surface {
|
||||
height: 70 + seed,
|
||||
state: BlockStateId(9),
|
||||
water: (seed % 2 == 0).then_some((80, BlockStateId(86))),
|
||||
biome: 3,
|
||||
}),
|
||||
spans: vec![
|
||||
Span {
|
||||
min: -64,
|
||||
max: (70 + seed) as i16,
|
||||
state: BlockStateId(1),
|
||||
biome: 3,
|
||||
light: 0,
|
||||
},
|
||||
Span {
|
||||
min: (70 + seed) as i16,
|
||||
max: 80,
|
||||
state: BlockStateId(86),
|
||||
biome: 3,
|
||||
light: 240,
|
||||
},
|
||||
]
|
||||
.into(),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn columns_round_trip_and_survive_reopening() {
|
||||
let world = std::env::temp_dir().join(format!("horizon-lod-bake-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&world);
|
||||
let dimension = world.join("dimensions").join("minecraft").join("overworld");
|
||||
fs::create_dir_all(&dimension).unwrap();
|
||||
let grid: Vec<Column> = (0..256).map(sample).collect();
|
||||
let other: Vec<Column> = (1..257).map(sample).collect();
|
||||
let shape = LevelShape::OVERWORLD;
|
||||
{
|
||||
let bake = LodBake::open(&dimension, shape, 100, 10).unwrap();
|
||||
assert_eq!(bake.grid(false, [-64, 32], 4, 16), None);
|
||||
bake.put_grid(false, [-64, 32], 4, 16, &grid);
|
||||
// A second put of the same key is ignored, not appended.
|
||||
bake.put_grid(false, [-64, 32], 4, 16, &[sample(-1)]);
|
||||
assert_eq!(
|
||||
bake.grid(false, [-64, 32], 4, 16).as_deref(),
|
||||
Some(&grid[..])
|
||||
);
|
||||
// Reduced far detail's samples are kept apart from full ones.
|
||||
assert_eq!(bake.grid(true, [-64, 32], 4, 16), None);
|
||||
bake.put_grid(true, [-64, 32], 4, 16, &other);
|
||||
assert_eq!(
|
||||
bake.grid(true, [-64, 32], 4, 16).as_deref(),
|
||||
Some(&other[..])
|
||||
);
|
||||
}
|
||||
let bake = LodBake::open(&dimension, shape, 100, 10).unwrap();
|
||||
assert_eq!(
|
||||
bake.grid(false, [-64, 32], 4, 16).as_deref(),
|
||||
Some(&grid[..])
|
||||
);
|
||||
assert_eq!(bake.grid(false, [-64, 48], 4, 16), None);
|
||||
drop(bake);
|
||||
// A cut-off record (a crash mid-write) is dropped, the rest is kept:
|
||||
// (-128, 32) shares (-64, 32)'s bucket file.
|
||||
let key = Key::Grid {
|
||||
approximate: false,
|
||||
x: -64,
|
||||
z: 32,
|
||||
stride: 4,
|
||||
side: 16,
|
||||
};
|
||||
let file = dimension.join(FOLDER).join(key.file());
|
||||
let full = fs::metadata(&file).unwrap().len();
|
||||
let bake = LodBake::open(&dimension, shape, 100, 10).unwrap();
|
||||
bake.put_grid(false, [-128, 32], 4, 16, &other);
|
||||
drop(bake);
|
||||
let f = fs::OpenOptions::new().write(true).open(&file).unwrap();
|
||||
f.set_len(fs::metadata(&file).unwrap().len() - 5).unwrap();
|
||||
drop(f);
|
||||
let bake = LodBake::open(&dimension, shape, 100, 10).unwrap();
|
||||
assert_eq!(bake.grid(false, [-128, 32], 4, 16), None);
|
||||
assert_eq!(
|
||||
bake.grid(false, [-64, 32], 4, 16).as_deref(),
|
||||
Some(&grid[..])
|
||||
);
|
||||
assert_eq!(fs::metadata(&file).unwrap().len(), full);
|
||||
drop(bake);
|
||||
// Another registry (or data pack, build or level shape) empties the folder.
|
||||
let bake = LodBake::open(&dimension, shape, 101, 10).unwrap();
|
||||
assert_eq!(bake.grid(false, [-64, 32], 4, 16), None);
|
||||
drop(bake);
|
||||
fs::remove_dir_all(&world).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn empty_samples_are_not_baked() {
|
||||
let world = std::env::temp_dir().join(format!("horizon-lod-empty-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&world);
|
||||
let dimension = world.join("dimensions").join("minecraft").join("overworld");
|
||||
fs::create_dir_all(&dimension).unwrap();
|
||||
let bake = LodBake::open(&dimension, LevelShape::OVERWORLD, 1, 1).unwrap();
|
||||
bake.put_grid(false, [0, 0], 16, 16, &vec![Column::default(); 256]);
|
||||
assert_eq!(bake.grid(false, [0, 0], 16, 16), None);
|
||||
drop(bake);
|
||||
fs::remove_dir_all(&world).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ mod leaf_culling;
|
||||
mod lighting;
|
||||
pub mod packed;
|
||||
mod sides;
|
||||
#[cfg(test)]
|
||||
#[path = "mesher/source_tests.rs"]
|
||||
mod source_tests;
|
||||
use crate::connected::{Pick, TileLayer};
|
||||
use crate::{
|
||||
assets::{Assets, RenderAssets},
|
||||
@@ -340,13 +343,41 @@ mod section_mesh_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// A shader terrain quad's unmodified source state and owning section cell. Two words
|
||||
/// per quad, kept beside (not in) the native 24-byte vertex format. Word 1 has cell XYZ
|
||||
/// in three nibbles, a fluid flag, a valid flag, then the emitted light (0..15).
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct TerrainSource {
|
||||
state: u32,
|
||||
cell: u32,
|
||||
}
|
||||
impl TerrainSource {
|
||||
pub(crate) fn new(state: u32, pos: [i32; 3], emission: u8, fluid: bool) -> Self {
|
||||
debug_assert!(pos.iter().all(|&v| (0..16).contains(&v)));
|
||||
Self {
|
||||
state,
|
||||
cell: pos[0] as u32
|
||||
| (pos[1] as u32) << 4
|
||||
| (pos[2] as u32) << 8
|
||||
| u32::from(fluid) << 12
|
||||
| 1 << 13
|
||||
| u32::from(emission.min(15)) << 14,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SectionMesh {
|
||||
pub solid: Vec<TerrainVertex>,
|
||||
/// One source per quad when a shader pack meshes terrain; independent of its sprite
|
||||
/// and geometry. Empty on native/default meshes and on distant terrain.
|
||||
pub solid_sources: Vec<TerrainSource>,
|
||||
/// The buckets `solid` is sorted in by the side its quads face: empty
|
||||
/// until `sort_solid`, which the meshing workers call; `solid` is in
|
||||
/// model order before.
|
||||
pub sides: Sides,
|
||||
pub translucent: Vec<TerrainVertex>,
|
||||
pub translucent_sources: Vec<TerrainSource>,
|
||||
/// Whether `packed` holds far tile records (`lod::packed`) rather than
|
||||
/// section records (`mesher::packed`): the two are read apart, by the base
|
||||
/// vertex their draws use.
|
||||
@@ -380,8 +411,10 @@ impl Default for SectionMesh {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
solid: Vec::new(),
|
||||
solid_sources: Vec::new(),
|
||||
sides: Sides::default(),
|
||||
translucent: Vec::new(),
|
||||
translucent_sources: Vec::new(),
|
||||
packed: Vec::new(),
|
||||
packed_far: false,
|
||||
solid_tints: Vec::new(),
|
||||
@@ -416,7 +449,9 @@ impl SectionMesh {
|
||||
/// Retain geometry capacity after an upload, but none of its old contents.
|
||||
pub fn clear(&mut self) {
|
||||
self.solid.clear();
|
||||
self.solid_sources.clear();
|
||||
self.translucent.clear();
|
||||
self.translucent_sources.clear();
|
||||
self.packed.clear();
|
||||
self.packed_far = false;
|
||||
self.solid_tints.clear();
|
||||
@@ -431,6 +466,8 @@ impl SectionMesh {
|
||||
pub(crate) fn capacity_bytes(&self) -> usize {
|
||||
self.solid.capacity() * std::mem::size_of::<TerrainVertex>()
|
||||
+ self.translucent.capacity() * std::mem::size_of::<TerrainVertex>()
|
||||
+ (self.solid_sources.capacity() + self.translucent_sources.capacity())
|
||||
* std::mem::size_of::<TerrainSource>()
|
||||
+ self.packed.capacity() * std::mem::size_of::<[u32; 6]>()
|
||||
+ self.solid_tints.capacity() * std::mem::size_of::<u32>()
|
||||
+ self.block_entities.capacity() * std::mem::size_of::<([i32; 3], u32)>()
|
||||
@@ -445,7 +482,11 @@ impl SectionMesh {
|
||||
if !self.solid_tints.is_empty() {
|
||||
self.solid_tints.resize(quads, packed::UNKNOWN_TINT);
|
||||
}
|
||||
self.sides = sides::sort_tinted(&mut self.solid, &mut self.solid_tints);
|
||||
self.sides = sides::sort_sourced(
|
||||
&mut self.solid,
|
||||
&mut self.solid_tints,
|
||||
&mut self.solid_sources,
|
||||
);
|
||||
self.bounds = self.compute_bounds();
|
||||
self.packed.clear();
|
||||
self.packed_far = false;
|
||||
@@ -455,6 +496,20 @@ impl SectionMesh {
|
||||
self.solid_tints.clear();
|
||||
}
|
||||
|
||||
/// Every face emitted since `start` belongs to this block, including multipart,
|
||||
/// connected-texture pieces, overlays and duplicated/back-facing fluid faces.
|
||||
fn record_source(&mut self, start: [usize; 2], source: TerrainSource) {
|
||||
for (vertices, sources, start) in [
|
||||
(&self.solid, &mut self.solid_sources, start[0]),
|
||||
(&self.translucent, &mut self.translucent_sources, start[1]),
|
||||
] {
|
||||
if vertices.len() > start {
|
||||
sources.resize(start / 4, TerrainSource::default());
|
||||
sources.resize(vertices.len() / 4, source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the tight axis-aligned bounding box [min, max] of actual terrain geometry vertices.
|
||||
pub fn compute_bounds(&self) -> Option<[glam::Vec3; 2]> {
|
||||
if self.solid.is_empty() && self.translucent.is_empty() {
|
||||
@@ -644,6 +699,8 @@ struct Info {
|
||||
block_entity: bool,
|
||||
tints: [Tint; 2],
|
||||
tint_count: u8,
|
||||
/// A resource pack's Polytone colormap over its tints (an index in `MeshTables::colormaps`).
|
||||
colormap: Option<u16>,
|
||||
/// `OffsetType` as (max horizontal, max vertical): vertical 0 for XZ.
|
||||
offset: Option<(f32, f32)>,
|
||||
/// `isSolid()` (legacy), for fluid corner heights.
|
||||
@@ -700,6 +757,9 @@ pub struct MeshTables {
|
||||
lava_still: u16,
|
||||
lava_flow: u16,
|
||||
animated_layers: Vec<bool>,
|
||||
/// Resource packs' Polytone block and fluid colormaps (#11486), and the water's.
|
||||
colormaps: Vec<Arc<crate::environment::Colormap>>,
|
||||
water_colormap: Option<u16>,
|
||||
}
|
||||
fn stem_color(age: u8) -> u32 {
|
||||
let a = u32::from(age);
|
||||
@@ -842,6 +902,7 @@ impl MeshTables {
|
||||
block_entity: s.block_entity,
|
||||
tints,
|
||||
tint_count,
|
||||
colormap: None,
|
||||
offset,
|
||||
legacy_solid: s.legacy_solid,
|
||||
overlay: s.has_class("HalfTransparentBlock") || s.has_class("LeavesBlock"),
|
||||
@@ -875,8 +936,52 @@ impl MeshTables {
|
||||
lava_still: layer("minecraft:block/lava_still")?,
|
||||
lava_flow: layer("minecraft:block/lava_flow")?,
|
||||
animated_layers,
|
||||
colormaps: Vec::new(),
|
||||
water_colormap: None,
|
||||
})
|
||||
}
|
||||
/// These tables with the colormaps of the Polytone block and fluid modifiers of the packs
|
||||
/// merged at `pack_root` (wiki "Colormaps"): a tinted block they target takes its colour
|
||||
/// from the colormap, and its own tint where the colormap's pixel is transparent.
|
||||
#[must_use]
|
||||
pub fn with_polytone_colormaps(
|
||||
mut self,
|
||||
pack_root: &std::path::Path,
|
||||
registry: &horizon_world::registry::BlockRegistry,
|
||||
) -> Self {
|
||||
let modifiers = crate::environment::polytone_colormap_modifiers(pack_root);
|
||||
if modifiers.is_empty() {
|
||||
return self;
|
||||
}
|
||||
let mut loaded: std::collections::HashMap<String, Option<u16>> = Default::default();
|
||||
let mut index = |id: &str, colormaps: &mut Vec<Arc<crate::environment::Colormap>>| {
|
||||
*loaded.entry(id.to_owned()).or_insert_with(|| {
|
||||
let colormap = crate::environment::Colormap::load(pack_root, id)?;
|
||||
colormaps.push(Arc::new(colormap));
|
||||
Some((colormaps.len() - 1) as u16)
|
||||
})
|
||||
};
|
||||
for (fluid, targets, id) in &modifiers {
|
||||
if *fluid {
|
||||
if targets.matches("minecraft:water", |_| false) {
|
||||
self.water_colormap = index(id, &mut self.colormaps);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(i) = index(id, &mut self.colormaps) else {
|
||||
continue;
|
||||
};
|
||||
for (state, info) in self.info.iter_mut().enumerate() {
|
||||
let Some(block) = registry.state(BlockStateId(state as u32)) else {
|
||||
continue;
|
||||
};
|
||||
if info.tint_count > 0 && targets.matches(&block.name, |_| false) {
|
||||
info.colormap = Some(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
/// Whether a terrain sprite layer has an animated clip.
|
||||
pub fn is_animated(&self, layer: u16) -> bool {
|
||||
self.animated_layers
|
||||
@@ -971,7 +1076,7 @@ impl MeshTables {
|
||||
}
|
||||
let [x, y, z] = pos;
|
||||
let origin = [x >> 4, z >> 4, y & !15];
|
||||
let mut tints = Tints::new(region, biomes, zoom, blend_radius, origin);
|
||||
let mut tints = Tints::new(region, biomes, zoom, blend_radius, origin).with_colormaps(self);
|
||||
Mesher::tint(info, 0, &mut tints, [x & 15, y & 15, z & 15])
|
||||
}
|
||||
/// `BlockColors` for each of the state's tint indices at `pos`, as the
|
||||
@@ -989,7 +1094,8 @@ impl MeshTables {
|
||||
let sources = self.info.get(state.0 as usize).map(|info| {
|
||||
let [x, y, z] = pos;
|
||||
let origin = [x >> 4, z >> 4, y & !15];
|
||||
let mut tints = Tints::new(region, biomes, zoom, blend_radius, origin);
|
||||
let mut tints =
|
||||
Tints::new(region, biomes, zoom, blend_radius, origin).with_colormaps(self);
|
||||
let local = [x & 15, y & 15, z & 15];
|
||||
let colors = [0, 1].map(|i| Mesher::tint(info, i, &mut tints, local));
|
||||
(colors, i32::from(info.tint_count))
|
||||
@@ -1382,6 +1488,11 @@ struct Tints<'a> {
|
||||
layers: [Option<Box<Layer>>; 16],
|
||||
/// `uniform_biome`, once computed.
|
||||
uniform: Option<Option<u16>>,
|
||||
/// The packs' colormaps (`MeshTables::colormaps`), the water's, and their colours so far
|
||||
/// by colormap and section-local block.
|
||||
colormaps: &'a [Arc<crate::environment::Colormap>],
|
||||
water_colormap: Option<u16>,
|
||||
sampled: std::collections::HashMap<(u16, [i32; 3]), Option<u32>>,
|
||||
}
|
||||
impl<'a> Tints<'a> {
|
||||
fn new(
|
||||
@@ -1400,8 +1511,41 @@ impl<'a> Tints<'a> {
|
||||
origin,
|
||||
layers: Default::default(),
|
||||
uniform: None,
|
||||
colormaps: &[],
|
||||
water_colormap: None,
|
||||
sampled: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
/// With the packs' colormaps of `tables`.
|
||||
fn with_colormaps(mut self, tables: &'a MeshTables) -> Self {
|
||||
self.colormaps = &tables.colormaps;
|
||||
self.water_colormap = tables.water_colormap;
|
||||
self
|
||||
}
|
||||
/// Colormap `index`'s colour at block `(x, y, z)` (section-local), `None` where its pixel
|
||||
/// is transparent.
|
||||
fn colormap(&mut self, index: u16, x: i32, y: i32, z: i32) -> Option<u32> {
|
||||
if let Some(&known) = self.sampled.get(&(index, [x, y, z])) {
|
||||
return known;
|
||||
}
|
||||
let colormap = self.colormaps.get(usize::from(index))?;
|
||||
let id = Self::biome_id(&self.zoom, self.region, self.plains, self.origin, [x, y, z]);
|
||||
let [cx, cz, base_y] = self.origin;
|
||||
let at = [cx * 16 + x, base_y + y, cz * 16 + z];
|
||||
let mut host = crate::environment::BlockHost::new(at, self.biomes.get(id));
|
||||
let colour = colormap.sample(&mut host).map(|c| c & 0xFF_FFFF);
|
||||
self.sampled.insert((index, [x, y, z]), colour);
|
||||
colour
|
||||
}
|
||||
/// The water's tint at block `(x, y, z)`: the packs' water colormap, else the biome's.
|
||||
fn water(&mut self, x: i32, y: i32, z: i32) -> u32 {
|
||||
if let Some(index) = self.water_colormap
|
||||
&& let Some(colour) = self.colormap(index, x, y, z)
|
||||
{
|
||||
return colour;
|
||||
}
|
||||
self.get(Resolver::Water, x, y, z)
|
||||
}
|
||||
/// The biome of block `(x, y, z)` (section-local x/z, y in 0..16), as `BiomeManager.getBiome`
|
||||
/// picks it.
|
||||
fn biome(&self, x: i32, y: i32, z: i32) -> &'a str {
|
||||
@@ -1645,7 +1789,8 @@ impl Mesher<'_> {
|
||||
self.zoom,
|
||||
self.blend_radius,
|
||||
[center.x, center.z, base_y],
|
||||
);
|
||||
)
|
||||
.with_colormaps(self.tables);
|
||||
let info = &self.tables.info;
|
||||
let full_faces = &self.tables.full_faces;
|
||||
let catalog = &self.render.model_catalog;
|
||||
@@ -1674,7 +1819,8 @@ impl Mesher<'_> {
|
||||
continue;
|
||||
}
|
||||
if state.fluid.kind() != FluidKind::Empty {
|
||||
let water = tints.get(Resolver::Water, x, y, z);
|
||||
let start = [out.solid.len(), out.translucent.len()];
|
||||
let water = tints.water(x, y, z);
|
||||
let shade = (
|
||||
self.nether_shading,
|
||||
self.shader_pack_lighting,
|
||||
@@ -1682,6 +1828,12 @@ impl Mesher<'_> {
|
||||
self.shader_pack,
|
||||
);
|
||||
fluid::tesselate(self.tables, shade, pad, water, &mut out, [x, y, z]);
|
||||
if self.shader_pack {
|
||||
out.record_source(
|
||||
start,
|
||||
TerrainSource::new(id, [x, y, z], state.emission, true),
|
||||
);
|
||||
}
|
||||
}
|
||||
if !state.model {
|
||||
continue;
|
||||
@@ -1754,6 +1906,7 @@ impl Mesher<'_> {
|
||||
.unwrap_or(true);
|
||||
}
|
||||
let connected = &self.render.connected;
|
||||
let start = [out.solid.len(), out.translucent.len()];
|
||||
for model in &models {
|
||||
for face in &model.faces {
|
||||
if face.quad.cull.is_some_and(|d| !visible[d as usize]) {
|
||||
@@ -1797,6 +1950,12 @@ impl Mesher<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
if self.shader_pack {
|
||||
out.record_source(
|
||||
start,
|
||||
TerrainSource::new(id, [x, y, z], state.emission, false),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1808,13 +1967,18 @@ impl Mesher<'_> {
|
||||
if index < 0 || index >= i32::from(state.tint_count) {
|
||||
return None;
|
||||
}
|
||||
if let Some(colormap) = state.colormap
|
||||
&& let Some(colour) = tints.colormap(colormap, x, y, z)
|
||||
{
|
||||
return Some(colour);
|
||||
}
|
||||
Some(match state.tints[(index as usize).min(1)] {
|
||||
Tint::None => return None,
|
||||
Tint::Grass | Tint::GrassBlock | Tint::SugarCane => tints.get(Resolver::Grass, x, y, z),
|
||||
Tint::TallGrassUpper => tints.get(Resolver::Grass, x, y, z),
|
||||
Tint::Foliage => tints.get(Resolver::Foliage, x, y, z),
|
||||
Tint::DryFoliage => tints.get(Resolver::Dry, x, y, z),
|
||||
Tint::Water => tints.get(Resolver::Water, x, y, z),
|
||||
Tint::Water => tints.water(x, y, z),
|
||||
Tint::Constant(c) => c,
|
||||
Tint::LilyPad => 0x20_8030,
|
||||
Tint::Redstone(p) => redstone_color(p),
|
||||
|
||||
@@ -335,6 +335,7 @@ mod tests {
|
||||
block_entity: false,
|
||||
tints: [super::super::Tint::None; 2],
|
||||
tint_count: 0,
|
||||
colormap: None,
|
||||
offset: None,
|
||||
legacy_solid: false,
|
||||
overlay: leaves,
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
//! cutout ones, so one pass over all of them draws the runs it drew before.
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use super::{OPAQUE_LAYER, TerrainVertex};
|
||||
use super::{OPAQUE_LAYER, TerrainSource, TerrainVertex};
|
||||
|
||||
/// The buckets in mesh order: +X, +Z, other, +Y, -Z, -X, -Y. Seen from
|
||||
/// above (+Y, the commonest case), each pair of horizontal sides with a
|
||||
@@ -247,15 +247,29 @@ fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
|
||||
}
|
||||
|
||||
/// `sort`, moving each quad's tint in `tints` (one per quad, or none) with it.
|
||||
#[cfg(test)]
|
||||
pub fn sort_tinted(solid: &mut Vec<TerrainVertex>, tints: &mut Vec<u32>) -> Sides {
|
||||
sort_sourced(solid, tints, &mut Vec::new())
|
||||
}
|
||||
|
||||
pub(super) fn sort_sourced(
|
||||
solid: &mut Vec<TerrainVertex>,
|
||||
tints: &mut Vec<u32>,
|
||||
sources: &mut Vec<TerrainSource>,
|
||||
) -> Sides {
|
||||
if !sources.is_empty() {
|
||||
sources.resize(solid.len() / 4, TerrainSource::default());
|
||||
}
|
||||
let grouped = GROUPED.load(Ordering::Relaxed);
|
||||
SORT_SCRATCH.with_borrow_mut(|scratch| sort_with(solid, tints, grouped, scratch))
|
||||
SORT_SCRATCH.with_borrow_mut(|scratch| sort_with(solid, tints, sources, grouped, scratch))
|
||||
}
|
||||
|
||||
/// `sort`, with all the opaque parts first when `grouped`.
|
||||
#[cfg(test)]
|
||||
fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
|
||||
SORT_SCRATCH.with_borrow_mut(|scratch| sort_with(solid, &mut Vec::new(), grouped, scratch))
|
||||
SORT_SCRATCH.with_borrow_mut(|scratch| {
|
||||
sort_with(solid, &mut Vec::new(), &mut Vec::new(), grouped, scratch)
|
||||
})
|
||||
}
|
||||
|
||||
/// `sort`'s buffers, kept by each meshing thread so sorting a mesh allocates
|
||||
@@ -267,6 +281,7 @@ struct SortScratch {
|
||||
keys: Vec<u64>,
|
||||
sorted: Vec<[TerrainVertex; 4]>,
|
||||
tints: Vec<u32>,
|
||||
sources: Vec<TerrainSource>,
|
||||
}
|
||||
thread_local! {
|
||||
static SORT_SCRATCH: std::cell::RefCell<SortScratch> = Default::default();
|
||||
@@ -281,6 +296,7 @@ fn total_order_key(v: f32) -> u32 {
|
||||
fn sort_with(
|
||||
solid: &mut Vec<TerrainVertex>,
|
||||
tints: &mut Vec<u32>,
|
||||
sources: &mut Vec<TerrainSource>,
|
||||
grouped: bool,
|
||||
scratch: &mut SortScratch,
|
||||
) -> Sides {
|
||||
@@ -354,6 +370,13 @@ fn sort_with(
|
||||
sorted_tints.extend(keys.iter().map(|&k| tints[(k & 0xFF_FFFF) as usize]));
|
||||
std::mem::swap(tints, sorted_tints);
|
||||
}
|
||||
if sources.len() == quads_len {
|
||||
scratch.sources.clear();
|
||||
scratch
|
||||
.sources
|
||||
.extend(keys.iter().map(|&k| sources[(k & 0xFF_FFFF) as usize]));
|
||||
std::mem::swap(sources, &mut scratch.sources);
|
||||
}
|
||||
}
|
||||
Sides {
|
||||
ends: slot_ends,
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
//! Original synthetic block/model/world controls; no prepared game resources.
|
||||
use super::*;
|
||||
use crate::assets::{Properties, SpritePixels};
|
||||
use horizon_world::registry::BlockRegistry;
|
||||
use serde_json::json;
|
||||
use std::{collections::BTreeMap, path::PathBuf};
|
||||
|
||||
struct Scratch(PathBuf);
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
std::fs::remove_dir_all(&self.0).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture() -> (Scratch, Assets, RenderAssets, MeshTables, Biomes) {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"terrain-source-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&path).unwrap();
|
||||
let write = |name: &str, value: serde_json::Value| {
|
||||
let file = path.join(name);
|
||||
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
std::fs::write(file, value.to_string()).unwrap();
|
||||
};
|
||||
let registry = json!({"defaults":{"minecraft:air":0,"fixture:signal":1},"states":[
|
||||
["air",true,false,"", "minecraft:air"],
|
||||
["signal",false,false,"",{"id":"fixture:signal","properties":{"active":"false","waterlogged":"false"}}],
|
||||
["signal",false,true,"",{"id":"fixture:signal","properties":{"active":"true","waterlogged":"true"}}]
|
||||
]});
|
||||
let registry = Box::leak(Box::new(
|
||||
BlockRegistry::from_facts_json(registry.to_string().as_bytes()).unwrap(),
|
||||
));
|
||||
write(
|
||||
"assets/fixture/blockstates/signal.json",
|
||||
json!({"multipart":[
|
||||
{"apply":{"model":"fixture:block/low"}}, {"apply":{"model":"fixture:block/extended"}}
|
||||
]}),
|
||||
);
|
||||
for (name, from, to) in [
|
||||
("low", [0, 4, 0], [8, 4, 8]),
|
||||
("extended", [20, 12, 0], [28, 12, 8]),
|
||||
] {
|
||||
write(
|
||||
&format!("assets/fixture/models/block/{name}.json"),
|
||||
json!({
|
||||
"textures":{"all":"fixture:block/shared"},
|
||||
"elements":[{"from":from,"to":to,"faces":{"up":{"texture":"#all"}}}]
|
||||
}),
|
||||
);
|
||||
}
|
||||
// The same authored sprite is used by every part of both states.
|
||||
let png_path = path.join("assets/fixture/textures/block/shared.png");
|
||||
std::fs::create_dir_all(png_path.parent().unwrap()).unwrap();
|
||||
let encode = |file: PathBuf| {
|
||||
let mut encoder = png::Encoder::new(std::fs::File::create(file).unwrap(), 1, 1);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&[63, 127, 191, 255])
|
||||
.unwrap();
|
||||
};
|
||||
encode(png_path);
|
||||
let facts = BTreeMap::from([(
|
||||
"fixture:signal".into(),
|
||||
json!({
|
||||
"schema":{"active":{"named":["false","true"]},"waterlogged":{"named":["false","true"]}},
|
||||
"states":[{"active":"false","waterlogged":"false"},{"active":"true","waterlogged":"true"}]
|
||||
}),
|
||||
)]);
|
||||
let mut sprites = crate::sprites::Sprites::default();
|
||||
let catalog = crate::models::StateModels::from_facts(
|
||||
&path,
|
||||
registry,
|
||||
&["fixture:signal"],
|
||||
&mut sprites,
|
||||
&facts,
|
||||
)
|
||||
.unwrap();
|
||||
let render = RenderAssets {
|
||||
sprite_size: 1,
|
||||
sprite_minimum_size: 1,
|
||||
pixels: SpritePixels::new(vec![255; 4]),
|
||||
texture_layers: 1,
|
||||
model_catalog: catalog,
|
||||
model_library: Err("unused synthetic item models".into()),
|
||||
model_placements: vec![],
|
||||
model_gallery: vec![],
|
||||
animations: vec![],
|
||||
sprite_layers: BTreeMap::new(),
|
||||
emissive: vec![],
|
||||
translucent_layers: vec![false],
|
||||
sprite_mips: vec![],
|
||||
pbr_normals: vec![],
|
||||
pbr_specular: vec![],
|
||||
pbr_size: 1,
|
||||
connected: Default::default(),
|
||||
fusion: Default::default(),
|
||||
generation: 1,
|
||||
};
|
||||
let assets = Assets {
|
||||
registry,
|
||||
properties: Properties::for_tests(&[
|
||||
(true, false, false, false),
|
||||
(false, false, false, false),
|
||||
(false, true, false, false),
|
||||
]),
|
||||
material_states: [BlockStateId(0); 6],
|
||||
blocks_fluid_flow: Default::default(),
|
||||
};
|
||||
let air = Info {
|
||||
solid: false,
|
||||
full: false,
|
||||
permeable: true,
|
||||
shade: 1.0,
|
||||
emission: 0,
|
||||
tier: 0,
|
||||
emissive: false,
|
||||
fluid: FluidState::EMPTY,
|
||||
model: false,
|
||||
block_entity: false,
|
||||
tints: [Tint::None; 2],
|
||||
tint_count: 0,
|
||||
colormap: None,
|
||||
offset: None,
|
||||
legacy_solid: false,
|
||||
overlay: false,
|
||||
leaves: false,
|
||||
pane: false,
|
||||
opaque_cube: false,
|
||||
blocks_flow: false,
|
||||
ice: false,
|
||||
opaque_ice: false,
|
||||
sturdy: 0,
|
||||
face: [0; 6],
|
||||
cover: [0.0; 6],
|
||||
};
|
||||
let info = vec![
|
||||
air,
|
||||
Info { model: true, ..air },
|
||||
Info {
|
||||
model: true,
|
||||
emission: 13,
|
||||
fluid: FluidState::WATER,
|
||||
..air
|
||||
},
|
||||
];
|
||||
let tables = MeshTables {
|
||||
info,
|
||||
solid: vec![false; 3],
|
||||
full_faces: vec![0; 3],
|
||||
visibility: FaceVisibility::empty(),
|
||||
water_still: 0,
|
||||
water_flow: 0,
|
||||
water_overlay: 0,
|
||||
lava_still: 0,
|
||||
lava_flow: 0,
|
||||
animated_layers: vec![false],
|
||||
colormaps: Vec::new(),
|
||||
water_colormap: None,
|
||||
};
|
||||
let maps = path.join("assets/minecraft/textures/colormap");
|
||||
std::fs::create_dir_all(&maps).unwrap();
|
||||
for name in ["grass", "foliage", "dry_foliage"] {
|
||||
encode(maps.join(format!("{name}.png")));
|
||||
}
|
||||
write(
|
||||
"data/minecraft/worldgen/biome/plains.json",
|
||||
json!({"temperature":0.5,"downfall":0.5,"effects":{"water_color":4159204}}),
|
||||
);
|
||||
let biomes = Biomes::load(&path).unwrap();
|
||||
(Scratch(path), assets, render, tables, biomes)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terrain_source_cpu_multipart_and_waterlogged_faces_keep_their_owner() {
|
||||
let (_scratch, assets, render, tables, biomes) = fixture();
|
||||
let mut chunk = Chunk::with_layout(3, -2, crate::chunk::ColumnLayout::new(0, 16).unwrap());
|
||||
chunk.set(2, 3, 4, 1);
|
||||
chunk.set(6, 3, 4, 2);
|
||||
let region = [
|
||||
[None, None, None],
|
||||
[None, Some(&chunk), None],
|
||||
[None, None, None],
|
||||
];
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: false,
|
||||
shader_pack_lighting: false,
|
||||
separate_ao: false,
|
||||
shader_pack: true,
|
||||
assets: &assets,
|
||||
render: &render,
|
||||
tables: &tables,
|
||||
biomes: &biomes,
|
||||
visibility: FaceVisibility::empty(),
|
||||
zoom: BiomeZoom::new(0),
|
||||
blend_radius: 0,
|
||||
smooth_lighting: false,
|
||||
cutout_leaves: true,
|
||||
opaque_ice: false,
|
||||
translucent_layers: &[],
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
};
|
||||
let mut mesh = mesher.mesh(®ion, 0);
|
||||
assert_eq!(mesh.solid.len(), 16, "two parts per source state");
|
||||
assert!(
|
||||
!mesh.translucent.is_empty(),
|
||||
"waterlogged fluid geometry was emitted"
|
||||
);
|
||||
assert_eq!(
|
||||
mesh.solid_sources,
|
||||
[
|
||||
TerrainSource::new(1, [2, 3, 4], 0, false),
|
||||
TerrainSource::new(1, [2, 3, 4], 0, false),
|
||||
TerrainSource::new(2, [6, 3, 4], 13, false),
|
||||
TerrainSource::new(2, [6, 3, 4], 13, false)
|
||||
]
|
||||
);
|
||||
assert_eq!(mesh.translucent_sources.len(), mesh.translucent.len() / 4);
|
||||
assert!(
|
||||
mesh.translucent_sources
|
||||
.iter()
|
||||
.all(|s| *s == TerrainSource::new(2, [6, 3, 4], 13, true))
|
||||
);
|
||||
let before: Vec<_> = mesh
|
||||
.solid
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.zip(&mesh.solid_sources)
|
||||
.map(|(q, s)| (bytemuck::cast_slice::<_, u8>(q).to_vec(), *s))
|
||||
.collect();
|
||||
mesh.sort_solid();
|
||||
for (q, s) in mesh
|
||||
.solid
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.zip(&mesh.solid_sources)
|
||||
{
|
||||
assert!(
|
||||
before.contains(&(bytemuck::cast_slice::<_, u8>(q).to_vec(), *s)),
|
||||
"sort changed face ownership"
|
||||
);
|
||||
}
|
||||
// Reusing the same mesh for native/default rendering clears metadata and keeps
|
||||
// native model vertices byte-for-byte (fluid splitting already differs with packs).
|
||||
mesher.shader_pack = false;
|
||||
let native = mesher.mesh_reusing(®ion, 0, mesh, &mut MeshScratch::default());
|
||||
assert!(native.solid_sources.is_empty() && native.translucent_sources.is_empty());
|
||||
for q in native.solid.as_chunks::<4>().0.iter() {
|
||||
assert!(
|
||||
before
|
||||
.iter()
|
||||
.any(|(bytes, _)| bytes == bytemuck::cast_slice::<_, u8>(q)),
|
||||
"source tracking altered native vertices"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terrain_source_cpu_partial_metadata_never_inherits_a_previous_face() {
|
||||
let mut mesh = SectionMesh::default();
|
||||
let quad = |y| {
|
||||
[[0.0, y, 0.0], [0.0, y, 1.0], [1.0, y, 1.0], [1.0, y, 0.0]]
|
||||
.map(|pos| TerrainVertex::new(pos, [0.0; 2], 0, 0, 0))
|
||||
};
|
||||
mesh.solid.extend(quad(1.0));
|
||||
let start = [mesh.solid.len(), 0];
|
||||
mesh.solid.extend(quad(3.0));
|
||||
mesh.solid.extend(quad(2.0));
|
||||
mesh.record_source(start, TerrainSource::new(71, [15, 0, 8], 7, false));
|
||||
assert_eq!(mesh.solid_sources[0], TerrainSource::default());
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<_, u32>(&mesh.solid_sources[1..]),
|
||||
[71, 124943, 71, 124943]
|
||||
);
|
||||
mesh.sort_solid();
|
||||
assert_eq!(
|
||||
mesh.solid
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|q| q[0].pos[1])
|
||||
.collect::<Vec<_>>(),
|
||||
[3.0, 2.0, 1.0]
|
||||
);
|
||||
assert_eq!(
|
||||
mesh.solid_sources,
|
||||
[
|
||||
TerrainSource::new(71, [15, 0, 8], 7, false),
|
||||
TerrainSource::new(71, [15, 0, 8], 7, false),
|
||||
TerrainSource::default()
|
||||
]
|
||||
);
|
||||
mesh.clear();
|
||||
assert!(mesh.solid_sources.is_empty());
|
||||
}
|
||||
@@ -112,3 +112,72 @@ fn one_biome_shortcut_blends_like_the_full_square() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A pack root holding `files` (path, bytes), for colormap tests.
|
||||
fn pack(files: &[(&str, Vec<u8>)]) -> 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);
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"horizon-mesher-colormap-{}-{n}",
|
||||
std::process::id()
|
||||
));
|
||||
for (path, bytes) in files {
|
||||
let path = root.join(path);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
root
|
||||
}
|
||||
|
||||
/// A one-row PNG of RGBA pixels.
|
||||
fn png_row(pixels: &[[u8; 4]]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut encoder = png::Encoder::new(&mut out, pixels.len() as u32, 1);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().unwrap();
|
||||
writer.write_image_data(&pixels.concat()).unwrap();
|
||||
writer.finish().unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_polytone_colormap_tints_by_biome_and_keeps_the_biome_colour_where_transparent() {
|
||||
let biomes = Biomes::from_jsons(&[
|
||||
(
|
||||
"minecraft:plains",
|
||||
serde_json::json!({"effects": {"water_color": 0x3F76E4}}),
|
||||
),
|
||||
(
|
||||
"minecraft:desert",
|
||||
serde_json::json!({"effects": {"water_color": 0x32A598}}),
|
||||
),
|
||||
]);
|
||||
// Left to right: transparent, red. Desert maps to column 1, plains (unnamed) to column 0.
|
||||
let root = pack(&[
|
||||
(
|
||||
"assets/mypack/polytone/colormaps/water.png",
|
||||
png_row(&[[0, 0, 0, 0], [255, 0, 0, 255]]),
|
||||
),
|
||||
(
|
||||
"assets/mypack/polytone/colormaps/water.json",
|
||||
br#"{"x_axis": "biome_id", "y_axis": 0, "biome_id_mapper": {"minecraft:desert": 1, "texture_size": 2}}"#
|
||||
.to_vec(),
|
||||
),
|
||||
]);
|
||||
let colormaps = vec![Arc::new(
|
||||
crate::environment::Colormap::load(&root, "mypack:water").unwrap(),
|
||||
)];
|
||||
for (biome, expected) in [(1, 0xFF_0000), (0, 0x3F_76E4)] {
|
||||
let chunks: Vec<Vec<Chunk>> = (0..3)
|
||||
.map(|rz| (0..3).map(|rx| filled(rx - 1, rz - 1, biome)).collect())
|
||||
.collect();
|
||||
let region: [[Option<&Chunk>; 3]; 3] =
|
||||
std::array::from_fn(|rz| std::array::from_fn(|rx| Some(&chunks[rz][rx])));
|
||||
let mut tints = Tints::new(®ion, &biomes, BiomeZoom::new(0), 0, [0, 0, 64]);
|
||||
tints.colormaps = &colormaps;
|
||||
tints.water_colormap = Some(0);
|
||||
assert_eq!(tints.water(3, 4, 5), expected, "biome {biome}");
|
||||
}
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
@@ -84,6 +84,7 @@ fn info(solid: bool) -> Info {
|
||||
block_entity: false,
|
||||
tints: [Tint::None; 2],
|
||||
tint_count: 0,
|
||||
colormap: None,
|
||||
offset: None,
|
||||
legacy_solid: false,
|
||||
overlay: false,
|
||||
|
||||
@@ -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
|
||||
@@ -1612,11 +1620,9 @@ pub struct OptionsUi {
|
||||
selected_native_key: Option<(usize, usize)>,
|
||||
/// Bindings: the action waiting for a pad button.
|
||||
pub selected_pad_action: Option<&'static str>,
|
||||
/// The glyphs the binds list shows (the setting, else the pad's family).
|
||||
pub pad_style: GlyphStyle,
|
||||
/// The pad goes by its letters (`PadSettings::nintendo_letters`) and the glyphs are
|
||||
/// Nintendo's: each part shows the letter of the button playing it.
|
||||
pub pad_by_letter: bool,
|
||||
/// The glyphs menus show (`Glyphs::of`: the setting, else the pad's family, and a Nintendo
|
||||
/// pad's letters when it goes by them). The game sets it each frame.
|
||||
pub pad_glyphs: crate::pad_glyph::Glyphs,
|
||||
/// The pad's focus drives the page: its hidden pointer sits mid-widget, so a tooltip goes
|
||||
/// under the widget instead of beside the pointer.
|
||||
pub pad_driving: bool,
|
||||
@@ -1660,10 +1666,16 @@ pub struct OptionsUi {
|
||||
/// Scroll the selected language into the middle on the next layout.
|
||||
center_language: bool,
|
||||
graphics: shell::Shell<crate::graphics_ui::GraphicsScreen>,
|
||||
pub controller: crate::controller_ui::ControllerUi,
|
||||
/// Controller Settings, on the settings shell.
|
||||
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>,
|
||||
@@ -1715,8 +1727,7 @@ impl Default for OptionsUi {
|
||||
selected_key: None,
|
||||
selected_native_key: None,
|
||||
selected_pad_action: None,
|
||||
pad_style: GlyphStyle::Xbox,
|
||||
pad_by_letter: false,
|
||||
pad_glyphs: crate::pad_glyph::Glyphs::default(),
|
||||
pad_driving: false,
|
||||
languages: Vec::new(),
|
||||
language_choice: None,
|
||||
@@ -1743,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,
|
||||
@@ -2100,8 +2113,6 @@ impl OptionsUi {
|
||||
.pack_screen
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.search_focused()),
|
||||
Page::Controller | Page::PadBinds => self.controller.typing(),
|
||||
_ if Self::shader_page(self.page()).is_some() => self.shader.typing(),
|
||||
_ => match self.shell() {
|
||||
Some(shell) => shell.typing(),
|
||||
None => self.settings_search_active(),
|
||||
@@ -2212,18 +2223,18 @@ impl OptionsUi {
|
||||
if page == Page::Packs {
|
||||
self.pack_screen = None;
|
||||
}
|
||||
if let Some(at) = Self::controller_page(page) {
|
||||
self.controller.open(at);
|
||||
}
|
||||
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);
|
||||
}
|
||||
if let Some(at) = Self::controller_page(page) {
|
||||
self.controller.show(at);
|
||||
}
|
||||
if page == Page::SplitSetup {
|
||||
self.split_setup.reset();
|
||||
}
|
||||
@@ -2241,6 +2252,8 @@ impl OptionsUi {
|
||||
match self.page() {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -2248,6 +2261,8 @@ impl OptionsUi {
|
||||
match self.page() {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -2256,10 +2271,20 @@ impl OptionsUi {
|
||||
pub fn reveal(&mut self, s: &Settings, size: (f32, f32), rect: [f32; 4]) -> Option<f32> {
|
||||
self.shell_mut()?.reveal(s, size, rect)
|
||||
}
|
||||
/// The window's size, for the graphics screen's vanilla rows that step through its
|
||||
/// resolutions: before each input that can press one.
|
||||
fn graphics_framebuffer(&mut self) {
|
||||
/// The window's size, for the graphics and controller screens' rows that step an option
|
||||
/// (`Opt::cycle`, which a fullscreen resolution needs): before each input that can press one.
|
||||
fn shell_framebuffers(&mut self) {
|
||||
self.graphics.screen.framebuffer = self.framebuffer;
|
||||
self.controller.screen.framebuffer = self.framebuffer;
|
||||
}
|
||||
/// Who and what Controller Settings is about: the game sets it before each draw.
|
||||
pub fn set_controller_context(&mut self, context: crate::controller_ui::Context) {
|
||||
self.controller.screen.context = context;
|
||||
}
|
||||
/// The binding the screen waits on: a controller button's, else a key's.
|
||||
fn waiting(&self) -> Option<shell::Capture> {
|
||||
let pad = self.selected_pad_action.map(shell::Capture::Pad);
|
||||
pad.or(self.selected_key.map(shell::Capture::Key))
|
||||
}
|
||||
/// What a shell screen's outputs do to these options.
|
||||
fn shell_events(&mut self, s: &mut Settings, outputs: Vec<shell::Output>) -> Vec<Event> {
|
||||
@@ -2267,7 +2292,10 @@ impl OptionsUi {
|
||||
for output in outputs {
|
||||
match output {
|
||||
shell::Output::Event(event) => events.push(event),
|
||||
shell::Output::BindKey(name) => self.selected_key = Some(name),
|
||||
shell::Output::Bind(shell::Capture::Key(name)) => self.selected_key = Some(name),
|
||||
shell::Output::Bind(shell::Capture::Pad(action)) => {
|
||||
self.selected_pad_action = Some(action)
|
||||
}
|
||||
shell::Output::Push(page) => {
|
||||
if page == Page::HdrCalibration {
|
||||
self.calibration = Some(crate::hdr_calibration::Calibration::new(s));
|
||||
@@ -2279,57 +2307,6 @@ impl OptionsUi {
|
||||
}
|
||||
events
|
||||
}
|
||||
/// What the controller screen's actions do to these options.
|
||||
fn controller_events(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
actions: Vec<crate::controller_ui::Action>,
|
||||
) -> Vec<Event> {
|
||||
use crate::controller_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::BindPad(action) => self.selected_pad_action = Some(action),
|
||||
Action::Done => events.extend(self.back(s)),
|
||||
}
|
||||
}
|
||||
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 {
|
||||
@@ -2385,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"
|
||||
}
|
||||
@@ -3687,16 +3665,11 @@ impl OptionsUi {
|
||||
c.draw(batch, atlas, cursor);
|
||||
return;
|
||||
}
|
||||
if Self::controller_page(self.page()).is_some() {
|
||||
self.controller.binding = self.selected_pad_action;
|
||||
self.controller.draw(s, batch, atlas, cursor, self.in_world);
|
||||
if self.page() == Page::PackOptions {
|
||||
self.pack_options.draw(batch, atlas, cursor, self.in_world);
|
||||
return;
|
||||
}
|
||||
if Self::shader_page(self.page()).is_some() {
|
||||
self.shader.draw(s, batch, atlas, cursor, self.in_world);
|
||||
return;
|
||||
}
|
||||
let waiting = self.selected_key;
|
||||
let waiting = self.waiting();
|
||||
let in_world = self.in_world;
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
shell.draw(s, batch, atlas, cursor, in_world, waiting);
|
||||
@@ -3713,7 +3686,7 @@ impl OptionsUi {
|
||||
}
|
||||
if self.page() == Page::SplitProfiles {
|
||||
self.split_profiles
|
||||
.draw(batch, atlas, cursor, self.in_world, self.pad_style);
|
||||
.draw(batch, atlas, cursor, self.in_world, self.pad_glyphs);
|
||||
return;
|
||||
}
|
||||
if self.page() == Page::Studio {
|
||||
@@ -4050,6 +4023,10 @@ impl OptionsUi {
|
||||
return self.calibration_outcome(s, outcome);
|
||||
}
|
||||
if self.shell().is_some() {
|
||||
// Waiting on a controller button: a click cancels the wait.
|
||||
if !drag && self.selected_pad_action.take().is_some() {
|
||||
return Vec::new();
|
||||
}
|
||||
// Waiting on a key: a mouse button binds, as on Key Binds.
|
||||
if self.selected_key.is_some() && !drag {
|
||||
let button = if right {
|
||||
@@ -4061,25 +4038,25 @@ impl OptionsUi {
|
||||
}
|
||||
let shift = self.shift;
|
||||
self.framebuffer = framebuffer;
|
||||
self.graphics_framebuffer();
|
||||
self.shell_framebuffers();
|
||||
let outputs = self.shell_mut().map_or_else(Vec::new, |shell| {
|
||||
shell.click(s, (x, y), size, drag, right, shift)
|
||||
});
|
||||
return self.shell_events(s, outputs);
|
||||
}
|
||||
if Self::controller_page(self.page()).is_some() {
|
||||
// Waiting on a button: a click cancels the wait.
|
||||
if !drag && self.selected_pad_action.take().is_some() {
|
||||
return Vec::new();
|
||||
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)),
|
||||
}
|
||||
}
|
||||
let actions =
|
||||
self.controller
|
||||
.click(s, (x, y), size, framebuffer, drag, right, self.shift);
|
||||
return self.controller_events(s, actions);
|
||||
}
|
||||
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);
|
||||
return events;
|
||||
}
|
||||
if self.page() == Page::Packs {
|
||||
let mut events = Vec::new();
|
||||
@@ -4109,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;
|
||||
@@ -4347,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 = self.controller.release() | 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]
|
||||
@@ -4429,7 +4418,7 @@ impl OptionsUi {
|
||||
if self.resource_error.is_some() {
|
||||
return Vec::new();
|
||||
}
|
||||
self.graphics_framebuffer();
|
||||
self.shell_framebuffers();
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
let outputs = shell.wheel(s, size, cursor, amount);
|
||||
return self.shell_events(s, outputs);
|
||||
@@ -4438,12 +4427,8 @@ impl OptionsUi {
|
||||
self.studio.wheel(amount);
|
||||
return Vec::new();
|
||||
}
|
||||
if Self::controller_page(self.page()).is_some() {
|
||||
let actions = self.controller.wheel(s, size, cursor, framebuffer, amount);
|
||||
return self.controller_events(s, actions);
|
||||
}
|
||||
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()
|
||||
@@ -4529,12 +4514,8 @@ impl OptionsUi {
|
||||
self.studio.wheel(amount);
|
||||
return;
|
||||
}
|
||||
if Self::controller_page(self.page()).is_some() {
|
||||
self.controller.scroll(s, size, 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 {
|
||||
@@ -4602,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
|
||||
@@ -4659,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);
|
||||
@@ -4721,10 +4724,6 @@ impl OptionsUi {
|
||||
if shell.typing() {
|
||||
shell.type_text(text);
|
||||
}
|
||||
} else if Self::controller_page(self.page()).is_some() && self.controller.typing() {
|
||||
self.controller.type_text(text);
|
||||
} else if Self::shader_page(self.page()).is_some() && self.shader.typing() {
|
||||
self.shader.type_text(text);
|
||||
} else if self.settings_search_active() {
|
||||
self.settings_search.insert_text(text);
|
||||
self.scroll = 0.0;
|
||||
@@ -4759,12 +4758,6 @@ impl OptionsUi {
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
return shell.shortcut(shortcut, clipboard);
|
||||
}
|
||||
if Self::controller_page(self.page()).is_some() {
|
||||
return self.controller.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;
|
||||
@@ -4881,31 +4874,11 @@ impl OptionsUi {
|
||||
} else {
|
||||
MIN_KEY_GUI
|
||||
};
|
||||
self.graphics_framebuffer();
|
||||
self.shell_framebuffers();
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
let outputs = shell.key(s, key, text, shift, gui);
|
||||
return self.shell_events(s, outputs);
|
||||
}
|
||||
if Self::controller_page(self.page()).is_some() {
|
||||
let size = (self.size.0.max(480.0), self.size.1.max(240.0));
|
||||
let fb = [self.framebuffer[0].max(480), self.framebuffer[1].max(240)];
|
||||
let (actions, _) = self.controller.key_modifiers(s, key, text, shift, size, fb);
|
||||
let mut events = self.controller_events(s, actions);
|
||||
if key == K::Escape {
|
||||
events.extend(self.back(s));
|
||||
}
|
||||
return events;
|
||||
}
|
||||
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);
|
||||
@@ -5566,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(),
|
||||
@@ -5581,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));
|
||||
}
|
||||
@@ -6644,6 +6635,42 @@ mod tests {
|
||||
assert_eq!(ui.page(), Page::Horizon);
|
||||
}
|
||||
|
||||
/// A Bindings row pressed waits for a controller button: the next click or key cancels the
|
||||
/// wait, Escape unbinds the action as on Key Binds, and none of them leaves the screen.
|
||||
#[test]
|
||||
fn a_controller_binding_waits_and_a_click_or_a_key_ends_the_wait() {
|
||||
use crate::controller_ui::Item;
|
||||
use winit::keyboard::KeyCode as K;
|
||||
let atlas = atlas();
|
||||
let mut s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::PadBinds);
|
||||
let size = (854.0, 480.0);
|
||||
ui.size = size;
|
||||
let jump = Item::Bind("key.jump");
|
||||
let (x, y) = ui.controller.point_at(&s, size, jump);
|
||||
let press = |ui: &mut OptionsUi, s: &mut Settings| {
|
||||
ui.click(s, &atlas, x, y, size, [1708, 960], false, false)
|
||||
};
|
||||
assert_eq!(press(&mut ui, &mut s), vec![Event::ButtonClick]);
|
||||
assert_eq!(ui.selected_pad_action, Some("key.jump"));
|
||||
assert!(press(&mut ui, &mut s).is_empty());
|
||||
assert_eq!(ui.selected_pad_action, None);
|
||||
press(&mut ui, &mut s);
|
||||
assert_eq!(ui.key(&mut s, &atlas, K::KeyQ, None), vec![Event::Changed]);
|
||||
assert_eq!(
|
||||
(ui.selected_pad_action, s.controller.is_default("key.jump")),
|
||||
(None, true)
|
||||
);
|
||||
press(&mut ui, &mut s);
|
||||
assert_eq!(
|
||||
ui.key(&mut s, &atlas, K::Escape, None),
|
||||
vec![Event::Changed]
|
||||
);
|
||||
assert_eq!(s.controller.binding("key.jump"), None);
|
||||
assert_eq!(ui.page(), Page::PadBinds);
|
||||
}
|
||||
|
||||
/// Every way a pad or a wheel scrolls a page on the shell moves the same list: Replay Studio's
|
||||
/// took the wheel and missed `scroll_at`, so a stick couldn't scroll it.
|
||||
#[test]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
//! A controller button as the player sees it: Horizon's own art where the pad's family has one
|
||||
//! (`assets/horizon/textures/gui/controller/`, which a resource pack can replace), else the label
|
||||
//! printed on the pad, in its colour, on a dark key. Whatever shows a button draws and measures it
|
||||
//! here; `Button::label` stays for plain text and narration.
|
||||
use crate::gamepad::{Button, GlyphStyle};
|
||||
//! here, from a [`Glyph`]: what the button does (`GlyphOf`), in the player's [`Glyphs`]. A Nintendo
|
||||
//! pad going by its letters is part of `Glyphs`, which only the settings make, so no caller handles
|
||||
//! the letters, and nothing outside this module can turn a bare button into art or a label.
|
||||
use crate::gamepad::{Button, GlyphOf, GlyphStyle, MenuJob, PadSettings};
|
||||
use crate::hud::{HudAtlas, HudBatch, rgba};
|
||||
use crate::pad_cursor;
|
||||
|
||||
@@ -94,23 +96,167 @@ static ART: &[Art] = art![
|
||||
Nintendo DPadRight "nx_right",
|
||||
];
|
||||
|
||||
/// The glyphs a player sees: a pad family's art, and whether a Nintendo pad goes by its letters.
|
||||
/// Opaque: it is only made from the settings and the pad (`of`), or by the one named exception
|
||||
/// (`unheld`), so nothing can drop the letter setting. Letter mode is part of it, and callers
|
||||
/// never handle it: the game already reads such a pad as parts (`PadState::by_letter`), and a
|
||||
/// glyph shows the button that plays its part.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Glyphs {
|
||||
family: GlyphStyle,
|
||||
letters: bool,
|
||||
}
|
||||
|
||||
impl Default for Glyphs {
|
||||
/// The glyphs of the default settings for a pad of no known maker, before any pad is read.
|
||||
fn default() -> Self {
|
||||
Self::of(&PadSettings::default(), GlyphStyle::Auto)
|
||||
}
|
||||
}
|
||||
|
||||
impl Glyphs {
|
||||
/// The glyphs for a pad of `family` under the player's settings: the family they chose, else
|
||||
/// the pad's, going by letters when the pad does (`PadSettings::by_letter`) and the glyphs
|
||||
/// are Nintendo's.
|
||||
pub fn of(settings: &PadSettings, family: GlyphStyle) -> Self {
|
||||
let shown = settings.glyphs.resolve(family);
|
||||
Self {
|
||||
family: shown,
|
||||
letters: shown == GlyphStyle::Nintendo && settings.by_letter(family),
|
||||
}
|
||||
}
|
||||
/// The glyphs for a pad of `family` by the buttons' places, whatever the letter setting. Only
|
||||
/// for a question a controller nobody holds yet answers (splitscreen's join and
|
||||
/// lost-controller prompts): it isn't playing, so its letters aren't read yet
|
||||
/// (docs/controllers.md, Nintendo pads).
|
||||
pub fn unheld(settings: &PadSettings, family: GlyphStyle) -> Self {
|
||||
Self {
|
||||
family: settings.glyphs.resolve(family),
|
||||
letters: false,
|
||||
}
|
||||
}
|
||||
/// The family the art comes from, for naming it ("Glyphs in use").
|
||||
pub fn family(self) -> GlyphStyle {
|
||||
self.family
|
||||
}
|
||||
/// The family whose print shows `part`, and the button on the pad that plays it: the one
|
||||
/// place letter mode turns a part into a physical button (`Button::by_letter`, as input is
|
||||
/// read).
|
||||
fn on_pad(self, part: Button) -> (GlyphStyle, Button) {
|
||||
let button = if self.letters { part.by_letter() } else { part };
|
||||
(self.family, button)
|
||||
}
|
||||
}
|
||||
|
||||
/// A glyph ready to draw: the part a button plays, in a player's [`Glyphs`]. It is only built from
|
||||
/// what the button does (`GlyphOf`), so a rebinding or a pad's letters reach every glyph; there is
|
||||
/// no way to draw or name a bare button.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Glyph {
|
||||
part: Button,
|
||||
glyphs: Glyphs,
|
||||
}
|
||||
|
||||
impl Glyph {
|
||||
/// The glyph for `of` on these settings, in `glyphs`. None while a gameplay action is unbound.
|
||||
pub fn of(of: GlyphOf, settings: &PadSettings, glyphs: Glyphs) -> Option<Self> {
|
||||
let part = of.button(settings)?;
|
||||
Some(Self { part, glyphs })
|
||||
}
|
||||
/// A menu job's glyph: menus aren't rebindable, so it is always there.
|
||||
pub fn menu(job: MenuJob, glyphs: Glyphs) -> Self {
|
||||
Self {
|
||||
part: job.button(),
|
||||
glyphs,
|
||||
}
|
||||
}
|
||||
/// The part this glyph is for (what the pad reads, after any letters), for `pad status`.
|
||||
pub fn part(self) -> Button {
|
||||
self.part
|
||||
}
|
||||
/// What is printed on the button that plays the part: in letter mode, that button's letter.
|
||||
pub fn label(self) -> &'static str {
|
||||
let (family, button) = self.glyphs.on_pad(self.part);
|
||||
printed(button, family)
|
||||
}
|
||||
/// The colour (RGB) of the button that plays the part.
|
||||
pub fn color(self) -> [u8; 3] {
|
||||
let (family, button) = self.glyphs.on_pad(self.part);
|
||||
colour(button, family)
|
||||
}
|
||||
/// Its art, if the family has a picture of the button.
|
||||
fn art(self) -> Option<&'static Art> {
|
||||
let (family, button) = self.glyphs.on_pad(self.part);
|
||||
find(button, family)
|
||||
}
|
||||
}
|
||||
|
||||
/// What is printed on a button of a pad family (`Auto` reads as Xbox).
|
||||
fn printed(button: Button, style: GlyphStyle) -> &'static str {
|
||||
let i = button as usize;
|
||||
match style {
|
||||
GlyphStyle::PlayStation => [
|
||||
"×", "○", "□", "△", "L1", "R1", "L2", "R2", "Create", "Options", "L3", "R3", "↑", "↓",
|
||||
"←", "→",
|
||||
][i],
|
||||
GlyphStyle::Nintendo => [
|
||||
"B", "A", "Y", "X", "L", "R", "ZL", "ZR", "−", "+", "LS", "RS", "↑", "↓", "←", "→",
|
||||
][i],
|
||||
GlyphStyle::Xbox | GlyphStyle::Auto => [
|
||||
"A", "B", "X", "Y", "LB", "RB", "LT", "RT", "View", "Menu", "LS", "RS", "↑", "↓", "←",
|
||||
"→",
|
||||
][i],
|
||||
}
|
||||
}
|
||||
/// The glyph's colour (RGB): face buttons in their family's colours,
|
||||
/// the rest white.
|
||||
fn colour(button: Button, style: GlyphStyle) -> [u8; 3] {
|
||||
let face = match button {
|
||||
Button::South => 0,
|
||||
Button::East => 1,
|
||||
Button::West => 2,
|
||||
Button::North => 3,
|
||||
_ => return [255, 255, 255],
|
||||
};
|
||||
match style {
|
||||
GlyphStyle::PlayStation => [
|
||||
[124, 178, 232],
|
||||
[235, 110, 110],
|
||||
[222, 145, 205],
|
||||
[92, 205, 170],
|
||||
][face],
|
||||
GlyphStyle::Nintendo => [255, 255, 255],
|
||||
GlyphStyle::Xbox | GlyphStyle::Auto => [
|
||||
[110, 200, 90],
|
||||
[235, 90, 80],
|
||||
[80, 150, 240],
|
||||
[240, 200, 60],
|
||||
][face],
|
||||
}
|
||||
}
|
||||
|
||||
/// The picture of a button in a family.
|
||||
fn find(button: Button, style: GlyphStyle) -> Option<&'static Art> {
|
||||
let family = match style {
|
||||
GlyphStyle::Auto => GlyphStyle::Xbox,
|
||||
style => style,
|
||||
family => family,
|
||||
};
|
||||
ART.iter()
|
||||
.find(|art| art.family == family && art.button == button)
|
||||
}
|
||||
|
||||
/// The sprite of a button in a family, if it has one.
|
||||
pub fn sprite(button: Button, style: GlyphStyle) -> Option<&'static str> {
|
||||
/// The sprite of a button in a family, if it has one: the tests name the files by it.
|
||||
#[cfg(test)]
|
||||
fn sprite(button: Button, style: GlyphStyle) -> Option<&'static str> {
|
||||
find(button, style).map(|art| art.sprite)
|
||||
}
|
||||
|
||||
/// The sprite this atlas can draw for the button; none (the boot screen's atlas) means text.
|
||||
pub fn art(atlas: &HudAtlas, button: Button, style: GlyphStyle) -> Option<&'static str> {
|
||||
sprite(button, style).filter(|name| atlas.sprites.contains_key(*name))
|
||||
/// The sprite this atlas can draw for the glyph; none (the boot screen's atlas) means text.
|
||||
pub fn art(atlas: &HudAtlas, glyph: Glyph) -> Option<&'static str> {
|
||||
glyph
|
||||
.art()
|
||||
.map(|art| art.sprite)
|
||||
.filter(|name| atlas.sprites.contains_key(*name))
|
||||
}
|
||||
|
||||
/// How wide the art shows: half its texture (IHDR's width, in a PNG's first bytes) less its edge.
|
||||
@@ -120,31 +266,24 @@ fn art_width(art: &Art) -> f32 {
|
||||
texels as f32 / 2.0 - 2.0 * MARGIN
|
||||
}
|
||||
|
||||
/// How wide the glyph shows.
|
||||
pub fn width(atlas: &HudAtlas, button: Button, style: GlyphStyle) -> f32 {
|
||||
match find(button, style).filter(|a| atlas.sprites.contains_key(a.sprite)) {
|
||||
/// How wide the glyph shows, in GUI pixels.
|
||||
pub fn width(atlas: &HudAtlas, glyph: Glyph) -> f32 {
|
||||
match glyph.art().filter(|a| atlas.sprites.contains_key(a.sprite)) {
|
||||
Some(art) => art_width(art),
|
||||
None => HudBatch::text_width(atlas, button.label(style)).ceil() + 4.0,
|
||||
None => HudBatch::text_width(atlas, glyph.label()).ceil() + 4.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the glyph covers with its label's text at `(x, y)`: `[x, y, w, h]`.
|
||||
pub fn rect(atlas: &HudAtlas, button: Button, style: GlyphStyle, x: f32, y: f32) -> [f32; 4] {
|
||||
[x - 1.0, y - 2.0, width(atlas, button, style), HEIGHT]
|
||||
/// What the glyph covers with its label's text at `(x, y)`: `[x, y, w, h]` in GUI pixels.
|
||||
pub fn rect(atlas: &HudAtlas, glyph: Glyph, x: f32, y: f32) -> [f32; 4] {
|
||||
[x - 1.0, y - 2.0, width(atlas, glyph), HEIGHT]
|
||||
}
|
||||
|
||||
/// Draw the glyph over the 8 pixel text line at `(x, y)`, filling [`rect`]. A dark key backs text
|
||||
/// only: the art brings its own.
|
||||
pub fn draw(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
button: Button,
|
||||
style: GlyphStyle,
|
||||
x: f32,
|
||||
y: f32,
|
||||
) {
|
||||
let [left, top, wide, tall] = rect(atlas, button, style, x, y);
|
||||
if let Some(name) = art(atlas, button, style) {
|
||||
pub fn draw(batch: &mut HudBatch, atlas: &HudAtlas, glyph: Glyph, x: f32, y: f32) {
|
||||
let [left, top, wide, tall] = rect(atlas, glyph, x, y);
|
||||
if let Some(name) = art(atlas, glyph) {
|
||||
batch.sprite_scaled(
|
||||
atlas,
|
||||
name,
|
||||
@@ -155,16 +294,9 @@ pub fn draw(
|
||||
u32::MAX,
|
||||
);
|
||||
} else {
|
||||
let [r, g, b] = button.color(style);
|
||||
let [r, g, b] = glyph.color();
|
||||
batch.fill(atlas, left, top, wide, tall, rgba(40, 40, 40, 255));
|
||||
batch.text(
|
||||
atlas,
|
||||
button.label(style),
|
||||
x + 1.0,
|
||||
y,
|
||||
rgba(r, g, b, 255),
|
||||
false,
|
||||
);
|
||||
batch.text(atlas, glyph.label(), x + 1.0, y, rgba(r, g, b, 255), false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,6 +340,20 @@ mod tests {
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
|
||||
}
|
||||
|
||||
/// Settings as `horizon-controller.txt` has them, with Nintendo Buttons set as `letters` says.
|
||||
fn settings(letters: bool) -> PadSettings {
|
||||
PadSettings::parse(&format!("glyphs:auto\nnintendoLetters:{letters}\n"))
|
||||
}
|
||||
|
||||
/// A glyph for any part on a pad of `family` going by places: the tests reach the table
|
||||
/// directly.
|
||||
fn g(part: Button, family: GlyphStyle) -> Glyph {
|
||||
Glyph {
|
||||
part,
|
||||
glyphs: Glyphs::of(&settings(false), family),
|
||||
}
|
||||
}
|
||||
|
||||
fn file(button: Button, style: GlyphStyle) -> Option<&'static str> {
|
||||
sprite(button, style).and_then(|name| name.strip_prefix("horizon:gui/controller/"))
|
||||
}
|
||||
@@ -271,7 +417,9 @@ mod tests {
|
||||
assert_eq!(file(Button::East, style), Some("nx_a"));
|
||||
assert_eq!(file(Button::West, style), Some("nx_y"));
|
||||
assert_eq!(file(Button::North, style), Some("nx_x"));
|
||||
assert_eq!(file(Button::LeftTrigger, style), Some("nx_zl"));
|
||||
assert_eq!(file(Button::LeftBumper, style), Some("nx_l"));
|
||||
assert_eq!(file(Button::RightTrigger, style), Some("nx_zr"));
|
||||
assert_eq!(file(Button::Select, style), Some("nx_minus"));
|
||||
assert_eq!(file(Button::Start, style), Some("nx_plus"));
|
||||
}
|
||||
|
||||
@@ -283,8 +431,34 @@ mod tests {
|
||||
(GlyphStyle::Nintendo, "nx_"),
|
||||
] {
|
||||
for button in Button::ALL {
|
||||
let name = file(button, style).unwrap();
|
||||
assert!(name.starts_with(prefix), "{button:?} {name}");
|
||||
let name = file(button, style).unwrap_or_else(|| panic!("{button:?}"));
|
||||
assert!(name.starts_with(prefix), "{name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Each family's art is its own drawing: Nintendo's once was Xbox's files copied (LB for L, a red
|
||||
/// B), and View, Create and − were one plain minus.
|
||||
#[test]
|
||||
fn no_two_buttons_share_a_picture_across_families() {
|
||||
let differs = |a: &Art, b: &Art| a.button == b.button && a.family != b.family;
|
||||
// The sticks and the d-pad are the same part on every pad, so they may share a picture.
|
||||
let shared = [
|
||||
Button::LeftStick,
|
||||
Button::RightStick,
|
||||
Button::DPadUp,
|
||||
Button::DPadDown,
|
||||
Button::DPadLeft,
|
||||
Button::DPadRight,
|
||||
];
|
||||
for (i, art) in ART.iter().enumerate() {
|
||||
for other in ART[..i].iter().filter(|o| o.png == art.png) {
|
||||
assert!(
|
||||
differs(art, other) && shared.contains(&art.button),
|
||||
"{} is {}",
|
||||
art.sprite,
|
||||
other.sprite
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -319,20 +493,24 @@ mod tests {
|
||||
#[test]
|
||||
fn art_measures_from_its_shape_and_text_from_its_label() {
|
||||
let atlas = atlas();
|
||||
assert_eq!(width(&atlas, Button::South, GlyphStyle::Xbox), 12.0);
|
||||
assert_eq!(width(&atlas, Button::LeftBumper, GlyphStyle::Xbox), 20.0);
|
||||
assert_eq!(width(&atlas, g(Button::South, GlyphStyle::Xbox)), 12.0);
|
||||
assert_eq!(width(&atlas, g(Button::LeftBumper, GlyphStyle::Xbox)), 20.0);
|
||||
assert_eq!(
|
||||
width(&atlas, Button::LeftBumper, GlyphStyle::PlayStation),
|
||||
width(&atlas, g(Button::LeftBumper, GlyphStyle::PlayStation)),
|
||||
20.0
|
||||
);
|
||||
assert_eq!(width(&atlas, g(Button::DPadUp, GlyphStyle::Nintendo)), 12.0);
|
||||
assert_eq!(
|
||||
width(&atlas, g(Button::LeftTrigger, GlyphStyle::Nintendo)),
|
||||
20.0
|
||||
);
|
||||
assert_eq!(width(&atlas, Button::DPadUp, GlyphStyle::Nintendo), 12.0);
|
||||
let mut no_art = atlas;
|
||||
no_art
|
||||
.sprites
|
||||
.remove(sprite(Button::LeftTrigger, GlyphStyle::Nintendo).unwrap());
|
||||
let label = HudBatch::text_width(&no_art, "ZL").ceil() + 4.0;
|
||||
assert_eq!(
|
||||
width(&no_art, Button::LeftTrigger, GlyphStyle::Nintendo),
|
||||
width(&no_art, g(Button::LeftTrigger, GlyphStyle::Nintendo)),
|
||||
label
|
||||
);
|
||||
}
|
||||
@@ -343,9 +521,9 @@ mod tests {
|
||||
atlas
|
||||
.sprites
|
||||
.remove(sprite(Button::South, GlyphStyle::Xbox).unwrap());
|
||||
assert_eq!(art(&atlas, Button::South, GlyphStyle::Xbox), None);
|
||||
assert_eq!(art(&atlas, g(Button::South, GlyphStyle::Xbox)), None);
|
||||
let label = HudBatch::text_width(&atlas, "A").ceil() + 4.0;
|
||||
assert_eq!(width(&atlas, Button::South, GlyphStyle::Xbox), label);
|
||||
assert_eq!(width(&atlas, g(Button::South, GlyphStyle::Xbox)), label);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -355,8 +533,7 @@ mod tests {
|
||||
draw(
|
||||
&mut batch,
|
||||
&atlas,
|
||||
Button::South,
|
||||
GlyphStyle::Xbox,
|
||||
g(Button::South, GlyphStyle::Xbox),
|
||||
10.0,
|
||||
20.0,
|
||||
);
|
||||
@@ -365,8 +542,7 @@ mod tests {
|
||||
draw(
|
||||
&mut batch,
|
||||
&atlas,
|
||||
Button::LeftBumper,
|
||||
GlyphStyle::Xbox,
|
||||
g(Button::LeftBumper, GlyphStyle::Xbox),
|
||||
10.0,
|
||||
20.0,
|
||||
);
|
||||
@@ -379,8 +555,83 @@ mod tests {
|
||||
let style = GlyphStyle::Nintendo;
|
||||
atlas.sprites.remove(sprite(Button::South, style).unwrap());
|
||||
let mut batch = HudBatch::new([640, 480]);
|
||||
draw(&mut batch, &atlas, Button::South, style, 10.0, 20.0);
|
||||
let [x, y, w, h] = rect(&atlas, Button::South, style, 10.0, 20.0);
|
||||
draw(&mut batch, &atlas, g(Button::South, style), 10.0, 20.0);
|
||||
let [x, y, w, h] = rect(&atlas, g(Button::South, style), 10.0, 20.0);
|
||||
assert_eq!(quads(&batch)[0], [x, y, x + w, y + h]);
|
||||
}
|
||||
|
||||
/// The part a pad going by its letters reads when `button` is pressed (`PadState::by_letter`,
|
||||
/// as `Pads` reads the pad), so the expectation doesn't come from the glyph code.
|
||||
fn part_read_from(button: Button) -> Button {
|
||||
let mut pad = crate::gamepad::PadState::default();
|
||||
pad.set(button, true);
|
||||
let read = pad.by_letter();
|
||||
Button::ALL.into_iter().find(|b| read.held(*b)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn letter_mode_shows_the_button_that_plays_each_part() {
|
||||
let atlas = atlas();
|
||||
let letters = Glyphs::of(&settings(true), GlyphStyle::Nintendo);
|
||||
for button in Button::ALL {
|
||||
let shown = Glyph {
|
||||
part: part_read_from(button),
|
||||
glyphs: letters,
|
||||
};
|
||||
let printed = g(button, GlyphStyle::Nintendo);
|
||||
assert_eq!(shown.label(), printed.label(), "{button:?}");
|
||||
assert_eq!(art(&atlas, shown), art(&atlas, printed), "{button:?}");
|
||||
}
|
||||
// As a Switch: the A on the right confirms and jumps, B goes back, X and Y swap.
|
||||
let label = |job| Glyph::menu(job, letters).label();
|
||||
assert_eq!(label(MenuJob::Confirm), "A");
|
||||
assert_eq!(label(MenuJob::Back), "B");
|
||||
assert_eq!(label(MenuJob::Alternate), "X");
|
||||
assert_eq!(label(MenuJob::Extra), "Y");
|
||||
let confirm = art(&atlas, Glyph::menu(MenuJob::Confirm, letters));
|
||||
assert_eq!(confirm, Some("horizon:gui/controller/nx_a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_nintendo_pad_with_nintendo_glyphs_goes_by_letters() {
|
||||
let label = |settings: &PadSettings, family| {
|
||||
Glyph::menu(MenuJob::Confirm, Glyphs::of(settings, family)).label()
|
||||
};
|
||||
// By places, a Nintendo pad's confirm is the B at the bottom.
|
||||
assert_eq!(label(&settings(false), GlyphStyle::Nintendo), "B");
|
||||
// An Xbox pad has no letters to swap.
|
||||
assert_eq!(label(&settings(true), GlyphStyle::Xbox), "A");
|
||||
// A player who chose Xbox glyphs sees them, though the Nintendo pad is read by letters.
|
||||
let chosen = PadSettings::parse("glyphs:xbox\nnintendoLetters:true\n");
|
||||
assert_eq!(label(&chosen, GlyphStyle::Nintendo), "A");
|
||||
// A controller nobody holds yet is asked by places.
|
||||
let unheld = Glyphs::unheld(&settings(true), GlyphStyle::Nintendo);
|
||||
assert_eq!(Glyph::menu(MenuJob::Confirm, unheld).label(), "B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn position_mode_names_each_part_by_its_place() {
|
||||
assert_eq!(g(Button::South, GlyphStyle::Nintendo).label(), "B");
|
||||
assert_eq!(g(Button::South, GlyphStyle::Xbox).label(), "A");
|
||||
assert_eq!(g(Button::South, GlyphStyle::PlayStation).label(), "×");
|
||||
assert_eq!(
|
||||
g(Button::RightTrigger, GlyphStyle::PlayStation).label(),
|
||||
"R2"
|
||||
);
|
||||
assert_eq!(g(Button::DPadLeft, GlyphStyle::Xbox).label(), "←");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebinding_an_action_changes_its_glyph() {
|
||||
let mut settings = settings(false);
|
||||
let xbox = Glyphs::of(&settings, GlyphStyle::Xbox);
|
||||
let jump = GlyphOf::Bound("key.jump");
|
||||
let before = Glyph::of(jump, &settings, xbox).unwrap();
|
||||
settings.bind("key.jump", Some(Button::North));
|
||||
let after = Glyph::of(jump, &settings, xbox).unwrap();
|
||||
assert_ne!(before.label(), after.label());
|
||||
assert_eq!(after.label(), "Y");
|
||||
settings.bind("key.jump", None);
|
||||
assert_eq!(Glyph::of(jump, &settings, xbox), None);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@ pub use horizon_client_core::particle_type::ParticleType;
|
||||
pub use horizon_items::firework::{DYE_FIREWORK_COLORS, FireworkExplosion, FireworkShape};
|
||||
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.
|
||||
@@ -225,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 {
|
||||
@@ -526,8 +530,14 @@ 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).
|
||||
additive: bool,
|
||||
}
|
||||
fn aabb_at(p: [f64; 3], w: f32, h: f32) -> Aabb {
|
||||
let w = f64::from(w) / 2.0;
|
||||
@@ -890,6 +900,9 @@ impl Particle {
|
||||
random: LegacyRandom::new(0),
|
||||
controller: None,
|
||||
polytone_type: None,
|
||||
model: None,
|
||||
orientation: polytone_gpu::Orientation::Camera,
|
||||
additive: false,
|
||||
}
|
||||
}
|
||||
/// The normal age/removal test and previous pose, without vanilla motion.
|
||||
@@ -1091,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))
|
||||
@@ -1184,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,
|
||||
@@ -1258,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()`).
|
||||
@@ -1286,8 +1324,15 @@ pub struct ParticleEngine {
|
||||
set_sprites: Vec<Vec<u32>>,
|
||||
/// Resource packs' Polytone custom particle types (#11486).
|
||||
polytone: Option<std::sync::Arc<polytone::Registry>>,
|
||||
/// `TrackingEmitter`s: particle type, entity feet position, width, height, ticks left.
|
||||
emitters: Vec<(ParticleOptions, [f64; 3], f64, f64, u32)>,
|
||||
/// 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>>,
|
||||
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.
|
||||
@@ -1465,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),
|
||||
@@ -1479,6 +1525,10 @@ 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(),
|
||||
emitters: Vec::new(),
|
||||
starters: Vec::new(),
|
||||
tracked_explosions: Vec::new(),
|
||||
@@ -1701,7 +1751,34 @@ impl ParticleEngine {
|
||||
}
|
||||
/// Sets (or clears) the resource packs' Polytone custom particle types.
|
||||
pub fn set_polytone(&mut self, registry: Option<std::sync::Arc<polytone::Registry>>) {
|
||||
self.polytone = registry.filter(|r| !r.is_empty());
|
||||
let registry = registry.filter(|r| !r.is_empty());
|
||||
let same = match (&self.polytone, ®istry) {
|
||||
(Some(a), Some(b)) => std::sync::Arc::ptr_eq(a, b),
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
};
|
||||
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;
|
||||
}
|
||||
/// The quads the last render blends additively (`RenderPipelines` additive, src alpha and
|
||||
/// one), for the frame's additive pass.
|
||||
pub fn take_additive(&self) -> Vec<Vertex> {
|
||||
std::mem::take(&mut *self.additive.lock().unwrap_or_else(|e| e.into_inner()))
|
||||
}
|
||||
/// Live GPU particle records (spawns, before `area` spreads them), for tests and status.
|
||||
pub fn polytone_gpu_records(&self) -> usize {
|
||||
self.polytone_gpu.iter().map(polytone_gpu::Store::len).sum()
|
||||
}
|
||||
/// Choose a frame from this particle's declared sprite set.
|
||||
pub fn set_sprite_frame(&self, particle: &mut Particle, frame: usize) {
|
||||
@@ -1877,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);
|
||||
@@ -1905,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]);
|
||||
}
|
||||
@@ -1960,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.
|
||||
@@ -2106,11 +2217,31 @@ 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)
|
||||
{
|
||||
return polytone::make(self, ®istry.clone(), name, &def.clone(), 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;
|
||||
let store = match self.polytone_gpu.iter().position(|s| s.is(&def)) {
|
||||
Some(i) => &mut self.polytone_gpu[i],
|
||||
None => {
|
||||
self.polytone_gpu.push(polytone_gpu::Store::new(&def)?);
|
||||
self.polytone_gpu.last_mut()?
|
||||
}
|
||||
};
|
||||
store.spawn(&particle, now, seed as u64);
|
||||
return None;
|
||||
}
|
||||
return Some(particle);
|
||||
}
|
||||
if let Some(name) = &o.mod_particle {
|
||||
let (set, behaviour) = *self.mod_sets.get(name)?;
|
||||
@@ -3745,21 +3876,49 @@ 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);
|
||||
}
|
||||
// The sounds the packs' custom particles played last tick.
|
||||
if let Some(registry) = &self.polytone {
|
||||
self.sounds.extend(registry.take_sounds());
|
||||
}
|
||||
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 {
|
||||
@@ -3867,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();
|
||||
@@ -4738,6 +4901,8 @@ impl ParticleEngine {
|
||||
let base = Quat::from_rotation_y(std::f32::consts::PI - yaw.to_radians())
|
||||
* Quat::from_rotation_x(-pitch.to_radians());
|
||||
let mut light_cache = ParticleLightCache::default();
|
||||
let mut additive = self.additive.lock().unwrap_or_else(|e| e.into_inner());
|
||||
additive.clear();
|
||||
let mut section_cache =
|
||||
(CULL && frustum.is_some()).then(ParticleSectionFrustumCache::default);
|
||||
for p in &self.particles {
|
||||
@@ -4809,7 +4974,18 @@ impl ParticleEngine {
|
||||
) - camera;
|
||||
(pos.as_vec3(), p.quad_size(partial))
|
||||
});
|
||||
let rotation = if p.roll != 0.0 {
|
||||
let rotation = if p.orientation != polytone_gpu::Orientation::Camera {
|
||||
// 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,
|
||||
);
|
||||
let roll = p.o_roll + (p.roll - p.o_roll) * partial;
|
||||
polytone_gpu::orient(p.orientation, center, velocity, roll, base)
|
||||
} else if p.roll != 0.0 {
|
||||
base * Quat::from_rotation_z(p.o_roll + (p.roll - p.o_roll) * partial)
|
||||
} else {
|
||||
base
|
||||
@@ -4891,6 +5067,36 @@ impl ParticleEngine {
|
||||
r.u0 + du * p.uv[2],
|
||||
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]),
|
||||
@@ -4899,15 +5105,11 @@ impl ParticleEngine {
|
||||
((-1.0, -1.0), [u0, v1]),
|
||||
] {
|
||||
let v = center + rotation * FVec3::new(n.0, n.1, 0.0) * size;
|
||||
out.push(Vertex {
|
||||
target.push(Vertex {
|
||||
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,
|
||||
|
||||
@@ -4916,6 +5118,19 @@ impl ParticleEngine {
|
||||
}
|
||||
}
|
||||
}
|
||||
for store in &self.polytone_gpu {
|
||||
store.render(
|
||||
&self.atlas,
|
||||
camera,
|
||||
base,
|
||||
self.polytone_ticks,
|
||||
partial,
|
||||
&mut light_cache,
|
||||
light_at,
|
||||
out,
|
||||
&mut additive,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// How brightly particle `ty` shines in HDR output, as `mesher::hdr_tier`: flames,
|
||||
@@ -4982,6 +5197,8 @@ const CREEPER: [[f64; 2]; 12] = [
|
||||
[0.0, -0.4],
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tick_bench;
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,439 @@
|
||||
//! How resource packs' Polytone particles are drawn (#11486): the rotation modes of custom
|
||||
//! particles, and GPU particles (wiki "GPU Particles"). A GPU particle never ticks: its spawn is a
|
||||
//! record (position, velocity, spawn time, lifetime, seed, colour, size, roll), and where it is,
|
||||
//! how big, what colour and which frame are a function of that record and the time. Here the
|
||||
//! function runs when the frame's particle quads are built, beside the engine's own particles.
|
||||
use super::{ParticleLevel, ParticleLightCache};
|
||||
use crate::hud::Rect;
|
||||
use crate::mesher::Vertex;
|
||||
use glam::{DVec3, Mat3, Quat, Vec3 as FVec3};
|
||||
use horizon_polytone::particles::{CustomParticle, Gpu, RenderType};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// How a particle quad turns (`rotation_mode`).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(super) enum Orientation {
|
||||
/// `look_at_xyz`: faces the camera (what every other mode falls back to).
|
||||
#[default]
|
||||
Camera,
|
||||
/// `look_at_y`: turns around Y only.
|
||||
AroundY,
|
||||
/// `look_up`: lies flat, facing up.
|
||||
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 {
|
||||
pub(super) fn parse(mode: &str) -> Self {
|
||||
match mode {
|
||||
"look_at_y" => Self::AroundY,
|
||||
"look_up" => Self::Flat,
|
||||
"movement_aligned" => Self::Movement,
|
||||
"none" => Self::Fixed,
|
||||
_ => Self::Camera,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The rotation of a quad (corners in its XY plane, facing +Z) at `center` (relative to the
|
||||
/// camera) moving along `velocity`, rolled by `roll`; `camera` is the camera-facing rotation.
|
||||
pub(super) fn orient(
|
||||
mode: Orientation,
|
||||
center: FVec3,
|
||||
velocity: FVec3,
|
||||
roll: f32,
|
||||
camera: Quat,
|
||||
) -> Quat {
|
||||
let rolled = |q: Quat| q * Quat::from_rotation_z(roll);
|
||||
match mode {
|
||||
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();
|
||||
if right == FVec3::ZERO {
|
||||
return rolled(camera);
|
||||
}
|
||||
Quat::from_mat3(&Mat3::from_cols(right, up, right.cross(up)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
position: [f64; 3],
|
||||
velocity: [f64; 3],
|
||||
/// The engine's tick it spawned on, and its life in ticks.
|
||||
spawn: u64,
|
||||
lifetime: f32,
|
||||
seed: u64,
|
||||
rgba: [f32; 4],
|
||||
size: f32,
|
||||
roll: f32,
|
||||
}
|
||||
|
||||
/// A GPU particle type's records: a ring of `limit` slots, the oldest overwritten.
|
||||
pub(super) struct Store {
|
||||
def: Arc<CustomParticle>,
|
||||
gpu: Gpu,
|
||||
orientation: Orientation,
|
||||
/// The texture's sprite name in the particle atlas (`ns:particle/path`).
|
||||
sprite: String,
|
||||
records: Vec<Record>,
|
||||
next: usize,
|
||||
limit: usize,
|
||||
}
|
||||
|
||||
/// The atlas name of a full texture path: `ns:textures/particle/a/b.png` is `ns:particle/a/b`
|
||||
/// (`particle/a/b` in `minecraft`).
|
||||
fn sprite_name(texture: &str) -> String {
|
||||
let (namespace, path) = texture.split_once(':').unwrap_or(("minecraft", texture));
|
||||
let path = path
|
||||
.trim_start_matches("textures/")
|
||||
.trim_end_matches(".png");
|
||||
if namespace == "minecraft" {
|
||||
path.to_owned()
|
||||
} else {
|
||||
format!("{namespace}:{path}")
|
||||
}
|
||||
}
|
||||
|
||||
/// SplitMix64: a well-mixed number from `seed` and `i`, for each quad's own randoms.
|
||||
fn mix(seed: u64, i: u64) -> u64 {
|
||||
let mut z = seed ^ i.wrapping_mul(0x9E37_79B9_7F4A_7C15);
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
|
||||
/// A number in 0..1 from `mix`.
|
||||
fn unit(seed: u64, i: u64) -> f64 {
|
||||
(mix(seed, i) >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
|
||||
/// Where a record's particle is after `t` ticks, and how fast it moves: gravity pulls down,
|
||||
/// friction (`friction` kept per tick, as a continuous drag) slows it toward a terminal velocity.
|
||||
fn motion(gpu: &Gpu, position: [f64; 3], velocity: [f64; 3], t: f64) -> ([f64; 3], [f64; 3]) {
|
||||
let drag = if gpu.friction > 0.0 && gpu.friction < 1.0 {
|
||||
-gpu.friction.ln()
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let gravity = [0.0, -gpu.gravity, 0.0];
|
||||
let mut at = [0.0; 3];
|
||||
let mut moving = [0.0; 3];
|
||||
for i in 0..3 {
|
||||
let (v, g) = (velocity[i], gravity[i]);
|
||||
if drag > 1.0e-9 {
|
||||
let e = (-drag * t).exp();
|
||||
at[i] = position[i] + v * (1.0 - e) / drag + g * (t / drag - (1.0 - e) / (drag * drag));
|
||||
moving[i] = v * e + g * (1.0 - e) / drag;
|
||||
} else {
|
||||
at[i] = position[i] + v * t + 0.5 * g * t * t;
|
||||
moving[i] = v + g * t;
|
||||
}
|
||||
}
|
||||
(at, moving)
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub(super) fn new(def: &Arc<CustomParticle>) -> Option<Self> {
|
||||
let gpu = def.gpu.clone()?;
|
||||
Some(Self {
|
||||
def: def.clone(),
|
||||
orientation: Orientation::parse(&def.rotation_mode),
|
||||
sprite: sprite_name(&gpu.texture),
|
||||
gpu,
|
||||
records: Vec::new(),
|
||||
next: 0,
|
||||
// The wiki's default ring capacity.
|
||||
limit: def.limit.map_or(16_384, |l| l.max(1) as usize),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn is(&self, def: &Arc<CustomParticle>) -> bool {
|
||||
Arc::ptr_eq(&self.def, def)
|
||||
}
|
||||
|
||||
/// Records a spawn from a particle its initializer set up (position, motion, life, look).
|
||||
pub(super) fn spawn(&mut self, p: &super::Particle, now: u64, seed: u64) {
|
||||
if p.lifetime <= 0 {
|
||||
return;
|
||||
}
|
||||
let record = Record {
|
||||
position: p.position,
|
||||
velocity: p.velocity,
|
||||
spawn: now,
|
||||
lifetime: p.lifetime as f32,
|
||||
seed,
|
||||
rgba: p.rgba,
|
||||
size: p.size,
|
||||
roll: p.roll,
|
||||
};
|
||||
if self.records.len() < self.limit {
|
||||
self.records.push(record);
|
||||
} else {
|
||||
self.records[self.next] = record;
|
||||
self.next = (self.next + 1) % self.limit;
|
||||
}
|
||||
}
|
||||
|
||||
/// Forgets the records whose life is over.
|
||||
pub(super) fn expire(&mut self, now: u64) {
|
||||
let before = self.records.len();
|
||||
self.records
|
||||
.retain(|r| (now.saturating_sub(r.spawn) as f32) < r.lifetime);
|
||||
if self.records.len() != before {
|
||||
self.next = 0;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn len(&self) -> usize {
|
||||
self.records.len()
|
||||
}
|
||||
|
||||
/// The quads of every live record at `now + partial` ticks.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn render(
|
||||
&self,
|
||||
atlas: &HashMap<String, (Rect, bool)>,
|
||||
camera: DVec3,
|
||||
facing: Quat,
|
||||
now: u64,
|
||||
partial: f32,
|
||||
light_cache: &mut ParticleLightCache,
|
||||
light_at: &dyn Fn([i32; 3]) -> u8,
|
||||
out: &mut Vec<Vertex>,
|
||||
additive: &mut Vec<Vertex>,
|
||||
) {
|
||||
if self.def.render_type == RenderType::Invisible {
|
||||
return;
|
||||
}
|
||||
let Some(&(rect, _)) = atlas.get(&self.sprite) else {
|
||||
return;
|
||||
};
|
||||
let gpu = &self.gpu;
|
||||
let translucent = matches!(
|
||||
self.def.render_type,
|
||||
RenderType::Translucent | RenderType::AdditiveTranslucent
|
||||
);
|
||||
let blend_add = self.def.render_type == RenderType::AdditiveTranslucent;
|
||||
let target = if blend_add { additive } else { out };
|
||||
let layer = if translucent && !blend_add {
|
||||
crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let (count, area) = gpu.area.map_or((1, [0.0; 3]), |(size, n)| (n, size));
|
||||
let frames = gpu.frames.max(1);
|
||||
for r in &self.records {
|
||||
let t = (now.saturating_sub(r.spawn)) as f32 + partial;
|
||||
if t >= r.lifetime {
|
||||
continue;
|
||||
}
|
||||
let life = (t / r.lifetime).clamp(0.0, 1.0);
|
||||
let (at, moving) = motion(gpu, r.position, r.velocity, f64::from(t));
|
||||
// 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];
|
||||
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;
|
||||
let to = [channel(16), channel(8), channel(0)];
|
||||
for i in 0..3 {
|
||||
rgb[i] += (to[i] - rgb[i]) * life;
|
||||
}
|
||||
alpha += (alpha * channel(24) - alpha) * life;
|
||||
}
|
||||
let size = gpu
|
||||
.size_end
|
||||
.map_or(r.size, |end| r.size + (end as f32 - r.size) * life);
|
||||
let c = |v: f32| (v.clamp(0.0, 1.0) * 255.0) as u32;
|
||||
let color = c(alpha) << 24 | c(rgb[2]) << 16 | c(rgb[1]) << 8 | c(rgb[0]);
|
||||
let frame = if self.def.random_sprite {
|
||||
(mix(r.seed, 0) % u64::from(frames)) as u32
|
||||
} else {
|
||||
((life * frames as f32) as u32).min(frames - 1)
|
||||
};
|
||||
let (dv, v0) = ((rect.v1 - rect.v0) / frames as f32, rect.v0);
|
||||
let (u0, u1) = (rect.u0, rect.u1);
|
||||
let (v0, v1) = (v0 + dv * frame as f32, v0 + dv * (frame + 1) as f32);
|
||||
let velocity = FVec3::new(moving[0] as f32, moving[1] as f32, moving[2] as f32);
|
||||
for quad in 0..u64::from(count) {
|
||||
// Each quad of an area has its own place in the box, sway phase and roll.
|
||||
let offset: [f64; 3] = if count > 1 {
|
||||
std::array::from_fn(|i| (unit(r.seed, quad * 8 + i as u64 + 1) - 0.5) * area[i])
|
||||
} else {
|
||||
[0.0; 3]
|
||||
};
|
||||
let phase = unit(r.seed, quad * 8 + 4) * std::f64::consts::TAU;
|
||||
let sway = gpu.sway * (f64::from(t) * 0.1 + phase).sin();
|
||||
let sway_z = gpu.sway * (f64::from(t) * 0.07 + phase).cos();
|
||||
let world = [
|
||||
at[0] + offset[0] + sway,
|
||||
at[1] + offset[1],
|
||||
at[2] + offset[2] + sway_z,
|
||||
];
|
||||
let block = ParticleLevel::block_at(world);
|
||||
let packed = light_cache.get(block, light_at);
|
||||
// kill_below_heightmap: under the surface, where the sky doesn't reach.
|
||||
if gpu.kill_below_heightmap && packed >> 4 < 15 {
|
||||
continue;
|
||||
}
|
||||
let block_light = u32::from((packed & 15).max(self.def.light_level));
|
||||
let light = (u32::from(packed >> 4) * 16) | ((block_light * 16) << 8);
|
||||
let center = (DVec3::from_array(world) - camera).as_vec3();
|
||||
let roll = r.roll
|
||||
+ gpu.spin as f32 * t
|
||||
+ if count > 1 {
|
||||
(unit(r.seed, quad * 8 + 5) * std::f64::consts::TAU) as f32
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let rotation = orient(self.orientation, center, velocity, roll, facing);
|
||||
let half = FVec3::new(size, size * gpu.aspect as f32, 0.0);
|
||||
for (n, uv) in [
|
||||
((1.0, -1.0), [u1, v1]),
|
||||
((1.0, 1.0), [u1, v0]),
|
||||
((-1.0, 1.0), [u0, v0]),
|
||||
((-1.0, -1.0), [u0, v1]),
|
||||
] {
|
||||
let corner = FVec3::new(n.0 * half.x, n.1 * half.y, 0.0);
|
||||
target.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: (center + rotation * corner).to_array(),
|
||||
uv,
|
||||
layer,
|
||||
color,
|
||||
light,
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn gpu(gravity: f64, friction: f64) -> Gpu {
|
||||
Gpu {
|
||||
texture: String::new(),
|
||||
frames: 1,
|
||||
gravity,
|
||||
friction,
|
||||
size_end: None,
|
||||
color_end: None,
|
||||
fade: [0.0; 2],
|
||||
sway: 0.0,
|
||||
spin: 0.0,
|
||||
aspect: 1.0,
|
||||
area: None,
|
||||
kill_below_heightmap: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texture_paths_name_their_atlas_sprites() {
|
||||
assert_eq!(
|
||||
sprite_name("davidparticles:textures/particle/weather/rain.png"),
|
||||
"davidparticles:particle/weather/rain"
|
||||
);
|
||||
assert_eq!(
|
||||
sprite_name("minecraft:textures/particle/generic_7.png"),
|
||||
"particle/generic_7"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_follows_gravity_and_drag() {
|
||||
// No drag: a thrown body, x = v t, y = -g t^2 / 2.
|
||||
let (at, v) = motion(&gpu(0.1, 1.0), [0.0; 3], [1.0, 0.0, 0.0], 10.0);
|
||||
assert_eq!(at, [10.0, -5.0, 0.0]);
|
||||
assert!((v[1] + 1.0).abs() < 1e-12);
|
||||
// Drag: the fall speed tends to g / drag (terminal velocity).
|
||||
let friction = 0.9;
|
||||
let (_, v) = motion(&gpu(0.1, friction), [0.0; 3], [0.0; 3], 1000.0);
|
||||
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;
|
||||
let close = |a: FVec3, b: FVec3| (a - b).length() < 1e-5;
|
||||
// Lying flat: facing up.
|
||||
assert!(close(
|
||||
normal(orient(
|
||||
Orientation::Flat,
|
||||
FVec3::ONE,
|
||||
FVec3::ZERO,
|
||||
0.0,
|
||||
Quat::IDENTITY
|
||||
)),
|
||||
FVec3::Y
|
||||
));
|
||||
// Around Y: facing the camera horizontally (the camera at the origin, the quad east).
|
||||
assert!(close(
|
||||
normal(orient(
|
||||
Orientation::AroundY,
|
||||
FVec3::new(5.0, 3.0, 0.0),
|
||||
FVec3::ZERO,
|
||||
0.0,
|
||||
Quat::IDENTITY
|
||||
)),
|
||||
-FVec3::X
|
||||
));
|
||||
// Along its motion: its up is the velocity, its face toward the camera.
|
||||
let q = orient(
|
||||
Orientation::Movement,
|
||||
FVec3::new(0.0, 0.0, -4.0),
|
||||
FVec3::new(0.0, -2.0, 0.0),
|
||||
0.0,
|
||||
Quat::IDENTITY,
|
||||
);
|
||||
assert!(close(q * FVec3::Y, -FVec3::Y));
|
||||
assert!(close(normal(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
|
||||
);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 871 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user