Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40f82d0e99 | ||
|
|
bc08885276 | ||
|
|
bc7b8889de | ||
|
|
7180a52bf2 | ||
|
|
8e37c4a4b7 | ||
|
|
94ba80f90d | ||
|
|
238fc99add | ||
|
|
ac96d3fa35 | ||
|
|
ceff25fca1 | ||
|
|
36d0695742 | ||
|
|
94484675a8 | ||
|
|
9097d2973d | ||
|
|
00e573cf87 | ||
|
|
a3bf6531e0 | ||
|
|
0ddd7ec41d | ||
|
|
cea1a54e31 | ||
|
|
23dd21e928 | ||
|
|
6cf95bb4d7 | ||
|
|
cf09a6610a | ||
|
|
094bb83dff | ||
|
|
7433b49bef | ||
|
|
dfc91826bd | ||
|
|
2782c5e810 | ||
|
|
86f1749664 | ||
|
|
d149cc1dca | ||
|
|
ae963b0a98 | ||
|
|
c30911cddf | ||
|
|
38eac394be | ||
|
|
5546650bd7 | ||
|
|
c277636360 | ||
|
|
68a01ee7ca | ||
|
|
15e83578ee | ||
|
|
66e8c0d04d | ||
|
|
6bab737cae | ||
|
|
7093c79eeb | ||
|
|
9f5ed29593 | ||
|
|
a4da356144 | ||
|
|
6c2c706959 | ||
|
|
6769422c88 | ||
|
|
ebe7b4a4fd | ||
|
|
1cce1cbf31 | ||
|
|
ed5da43953 | ||
|
|
3295c54097 | ||
|
|
d0da7ec157 | ||
|
|
042d2ce46a | ||
|
|
8f756e8487 | ||
|
|
b7bf458117 | ||
|
|
3dcfd7276b | ||
|
|
bfbf06a439 | ||
|
|
b7b137a829 | ||
|
|
725208d8de | ||
|
|
3516aa9641 | ||
|
|
971f341977 | ||
|
|
cd362fb354 | ||
|
|
216eca2f51 | ||
|
|
7dccd1fa39 | ||
|
|
e84e5b2dd3 | ||
|
|
7b9f3f8e83 | ||
|
|
c9d73d5bea | ||
|
|
5062adfb0c | ||
|
|
56b703899c | ||
|
|
7e6d2121b7 | ||
|
|
a89865b1c5 | ||
|
|
c51a5eec3f | ||
|
|
21aabee4c0 | ||
|
|
7be13f1e9a | ||
|
|
ff83eccf6a | ||
|
|
c8e48f9cea | ||
|
|
bdb6452da9 | ||
|
|
2ddd9705d9 | ||
|
|
868d3b76a5 | ||
|
|
f2718e086d | ||
|
|
dc382b898a | ||
|
|
b5f396461e | ||
|
|
e963d24058 | ||
|
|
3eec96a8b1 | ||
|
|
249e8db13c | ||
|
|
b4f24319ac | ||
|
|
1d2dd56a06 | ||
|
|
22f3da10c0 | ||
|
|
c992f24496 | ||
|
|
005249b984 | ||
|
|
63dae1216c | ||
|
|
962f5949ac | ||
|
|
5e9024e0a1 | ||
|
|
559c2d4e81 | ||
|
|
ee449e0ef2 | ||
|
|
dc3d97ce25 | ||
|
|
cab5f23234 | ||
|
|
2c5d1990ce | ||
|
|
eb3157d251 | ||
|
|
d577d49a78 | ||
|
|
8a9bd15a3d | ||
|
|
a40f3d82c1 | ||
|
|
db8048a1b6 | ||
|
|
7383ff1908 | ||
|
|
4f4c87df64 | ||
|
|
6676c3a9a9 | ||
|
|
c2fb43b761 | ||
|
|
6cfffc5daa | ||
|
|
af3c22b9f1 | ||
|
|
182eec2cb7 | ||
|
|
37bdbf9def | ||
|
|
b5e7227fba | ||
|
|
dbebf0f615 | ||
|
|
2377d42c58 | ||
|
|
7607e92fea | ||
|
|
fdc6ffdd39 | ||
|
|
efca8dab86 | ||
|
|
39377abc12 | ||
|
|
53d1afc5b1 | ||
|
|
a94b5a1b2e | ||
|
|
3d00e7a360 | ||
|
|
04d5e418bf | ||
|
|
b4c7f409d2 | ||
|
|
3365fd6bc4 | ||
|
|
c0da1ac2e1 | ||
|
|
45cefe3278 | ||
|
|
cc606585ad | ||
|
|
032ae31f34 | ||
|
|
47d55b5690 | ||
|
|
6d1235ca18 | ||
|
|
db4a145e6b | ||
|
|
59d958c09e | ||
|
|
1a2015e305 | ||
|
|
197172ef3c | ||
|
|
8ef5c055b0 | ||
|
|
6be9c07ce4 | ||
|
|
c3c0e2d868 | ||
|
|
528d98bf36 | ||
|
|
d74c95f088 | ||
|
|
9d6d292348 | ||
|
|
9d5dbbd2f5 | ||
|
|
3a2bf1f4f8 | ||
|
|
d49eeccd3f | ||
|
|
7e13db2271 | ||
|
|
7973f9935b | ||
|
|
d119d6da2d | ||
|
|
4bc93d9a5f | ||
|
|
671af567f4 | ||
|
|
389d15bda0 | ||
|
|
4ae696550f | ||
|
|
e92dc8108d | ||
|
|
1f19e94d74 | ||
|
|
fcbeaefd37 | ||
|
|
8bf0614d9d | ||
|
|
1d6e973ab8 | ||
|
|
83b3c9306f | ||
|
|
e6184afbe2 | ||
|
|
96c5616505 | ||
|
|
55b78015fe | ||
|
|
cb1198a38d | ||
|
|
17b50d7959 | ||
|
|
7766619ecb | ||
|
|
ec3e4df6e3 | ||
|
|
a0f02b61ea | ||
|
|
7f2e64b603 | ||
|
|
6e21ce9476 | ||
|
|
861b9e7b16 | ||
|
|
365a44ff91 | ||
|
|
358456bb02 | ||
|
|
07068153c6 | ||
|
|
e2a17164ca | ||
|
|
0d24192a23 | ||
|
|
b75193985b | ||
|
|
1a07a02990 | ||
|
|
f5c6b7dfd1 | ||
|
|
8d5dd89421 | ||
|
|
6280d2630c | ||
|
|
e5814ae449 | ||
|
|
c221aa9ec9 | ||
|
|
0568b2beff | ||
|
|
aa741b3570 | ||
|
|
3e77ec425f | ||
|
|
c80f5ae218 | ||
|
|
21860eefba | ||
|
|
0ccd2a4397 | ||
|
|
dd8214d3a3 | ||
|
|
e5dfe2162a | ||
|
|
92dfd34610 | ||
|
|
dea884f9c2 | ||
|
|
2d3ee9d7ef | ||
|
|
2b80362795 | ||
|
|
9c10e5bfe1 | ||
|
|
b524e47aa8 | ||
|
|
9a8927755c | ||
|
|
f6d492dbb4 | ||
|
|
34b5df1397 | ||
|
|
2a018699ac | ||
|
|
515a26c926 | ||
|
|
74eaac0c21 | ||
|
|
64d6abf771 | ||
|
|
950b8ed68b | ||
|
|
c703484705 | ||
|
|
4d300f6aed | ||
|
|
b9efd702dc | ||
|
|
93cee47a13 | ||
|
|
627ac3c0ba | ||
|
|
d178d86b4d | ||
|
|
c8c0157d79 | ||
|
|
c6c60fc163 | ||
|
|
20c4ed3274 | ||
|
|
1610735fcd | ||
|
|
139a42ba8b | ||
|
|
9477cb6d1e | ||
|
|
60f42e7cdf | ||
|
|
5757de7e74 | ||
|
|
79cd63b8bc | ||
|
|
a2fb9bc76e | ||
|
|
1acc70eb43 | ||
|
|
916ab6b8b1 | ||
|
|
b56e58fde3 | ||
|
|
633169ea0f | ||
|
|
0df07ea3cf | ||
|
|
e527784bc3 | ||
|
|
69ea634469 | ||
|
|
5dd06d27cb | ||
|
|
72c6784172 | ||
|
|
3c5a95239d | ||
|
|
678d6da047 | ||
|
|
24c9ad202b | ||
|
|
6020c75004 | ||
|
|
f34a261c77 | ||
|
|
6ee08b4a5a | ||
|
|
91426ff30e | ||
|
|
1215645c4e | ||
|
|
dd0f9c629b | ||
|
|
1636336f99 | ||
|
|
50a11a1107 | ||
|
|
ca1ed5fac5 | ||
|
|
7eb057384c | ||
|
|
9976e2053c | ||
|
|
1875af1a00 | ||
|
|
b9fc2c5539 | ||
|
|
cee8d447f8 | ||
|
|
04880cebe6 | ||
|
|
c3f0df9033 | ||
|
|
ad1ec8e4f6 | ||
|
|
ff66433c34 | ||
|
|
61f922fbd5 | ||
|
|
511e971adc | ||
|
|
211512aee8 | ||
|
|
edc5f94957 | ||
|
|
af593b6559 | ||
|
|
761704ce11 | ||
|
|
74d6a69a0e | ||
|
|
5edd79feaa | ||
|
|
c5121f0f10 | ||
|
|
c2414d0c5d | ||
|
|
b6b65ccb8d | ||
|
|
ce2000390a | ||
|
|
0b5a2c1e4f | ||
|
|
c587403015 | ||
|
|
3ebcf19eed | ||
|
|
f2f02e57bd | ||
|
|
18a2d812a7 | ||
|
|
0f236dfaeb | ||
|
|
1d56cf6658 | ||
|
|
c10aea9eb8 | ||
|
|
bf22beb825 | ||
|
|
5a99cf8ecf | ||
|
|
510bb6f135 | ||
|
|
10fb981317 | ||
|
|
7160eea6d5 | ||
|
|
443a42f97a | ||
|
|
d84e8fa460 | ||
|
|
098efef8e2 | ||
|
|
daf537e0d4 | ||
|
|
75b85d258f | ||
|
|
fe2982ecdd | ||
|
|
7f67dc8333 | ||
|
|
260051a190 | ||
|
|
66dafd2bb6 | ||
|
|
5248e83f28 | ||
|
|
ada4756065 | ||
|
|
2fe3429a9a | ||
|
|
3db7aa7cac | ||
|
|
5e6181b8f3 | ||
|
|
230b167744 | ||
|
|
08f7f87f23 | ||
|
|
5b4984aa32 | ||
|
|
64d3619671 | ||
|
|
693c5326f1 | ||
|
|
145d9e593b | ||
|
|
8396106b2e | ||
|
|
f000ba2db4 | ||
|
|
720146f962 | ||
|
|
bce20797b6 | ||
|
|
6ea90b860a | ||
|
|
d1d212ef32 | ||
|
|
0533979cc8 | ||
|
|
f6eff6b43a | ||
|
|
7abf696d29 | ||
|
|
e766d161d3 | ||
|
|
62f7d0fc17 | ||
|
|
74da693e68 | ||
|
|
311a64ea13 | ||
|
|
3b53c55932 | ||
|
|
f9dee224bc | ||
|
|
a94ef019be | ||
|
|
1c9ebb2318 | ||
|
|
e64dc6ad91 | ||
|
|
3c710450fb | ||
|
|
50295a160c | ||
|
|
79e396c135 | ||
|
|
40c22423dd | ||
|
|
308bf3dae0 | ||
|
|
6a6aa75cb9 | ||
|
|
470f9d01e0 | ||
|
|
8559f9ccd2 | ||
|
|
ae259da5df | ||
|
|
f0ffcadaf5 | ||
|
|
771705a060 | ||
|
|
9b72080c32 | ||
|
|
86255fd295 | ||
|
|
ed3fabb7b4 | ||
|
|
c93db2be5f | ||
|
|
e1cd3420ce | ||
|
|
83c50cf720 | ||
|
|
21d6f5610b | ||
|
|
8321f11360 | ||
|
|
4c231b6c45 | ||
|
|
df6c3384fb | ||
|
|
a7934f85c2 | ||
|
|
05bc440a21 | ||
|
|
fffb893691 | ||
|
|
1dfd9f03a6 | ||
|
|
5bec6ad6ee | ||
|
|
047ec7683e | ||
|
|
0be74d73d6 | ||
|
|
edddfd6008 | ||
|
|
fbc2031786 | ||
|
|
c71978372d | ||
|
|
497c137523 | ||
|
|
0660817c30 | ||
|
|
16341f4d04 | ||
|
|
50901bae92 | ||
|
|
c23c0056a1 | ||
|
|
76c3d4d262 | ||
|
|
c024816c37 | ||
|
|
987dc976d3 | ||
|
|
c9246a4fc5 | ||
|
|
e3451f9fe6 | ||
|
|
282992c1ec | ||
|
|
21b1ebd511 | ||
|
|
eee2cd0db1 | ||
|
|
448ba30415 | ||
|
|
b82eeb4f95 | ||
|
|
8cd25efc36 | ||
|
|
b80251563e | ||
|
|
6aafaf66cd | ||
|
|
f4b5bee9e5 | ||
|
|
4bdd1282b2 | ||
|
|
09722f6830 | ||
|
|
86b52f39ec | ||
|
|
4f311197bd | ||
|
|
4ffc44a599 | ||
|
|
d8483e0972 | ||
|
|
6187796e59 | ||
|
|
49c2e8c55f | ||
|
|
447514aa1d | ||
|
|
8c0ec0945d | ||
|
|
5ed5756b1f | ||
|
|
3df9ec4110 | ||
|
|
0490e076ff | ||
|
|
c5897ab467 | ||
|
|
6442dc599e | ||
|
|
0ed8b278ad | ||
|
|
4639551d77 | ||
|
|
0f70c0c356 | ||
|
|
a574df5379 | ||
|
|
2e4c2b511f | ||
|
|
70db97e662 | ||
|
|
d87da7928c | ||
|
|
481afc2e7b | ||
|
|
0deeb5cb29 | ||
|
|
cf28dc7745 | ||
|
|
805414435e | ||
|
|
b05af110f7 | ||
|
|
e8196d58c4 | ||
|
|
0ef952921d | ||
|
|
57b0346a6d | ||
|
|
1ce7bf44df | ||
|
|
8507cc3e38 | ||
|
|
4678c7c83e | ||
|
|
56dadd398a | ||
|
|
b2a0a39b5e | ||
|
|
6e45279301 | ||
|
|
31d430f40a | ||
|
|
60cbe7fcc3 | ||
|
|
45b9aa75ff | ||
|
|
482caf2db9 | ||
|
|
9698eed74f | ||
|
|
d3adab07d4 | ||
|
|
f9dfc692e5 | ||
|
|
b230cc559c | ||
|
|
0eb683c454 | ||
|
|
d0dcb33382 | ||
|
|
233e09221d | ||
|
|
af9edec8b9 | ||
|
|
f9d90fcb53 | ||
|
|
3bca3ed851 | ||
|
|
3e620360e9 | ||
|
|
5f6de8bae4 | ||
|
|
9a3c1024ff | ||
|
|
9e410f893a | ||
|
|
9f47fec19d | ||
|
|
83b9cfc1d8 | ||
|
|
2a44a86383 | ||
|
|
24c8f66b32 | ||
|
|
de9ab4646d | ||
|
|
bd2e287575 | ||
|
|
78a386e14d | ||
|
|
51060ec716 | ||
|
|
1e1349cab7 | ||
|
|
b34ce5d7f3 | ||
|
|
2982b03bdf | ||
|
|
15da067a21 | ||
|
|
c391c8971c | ||
|
|
2d2a2621f2 | ||
|
|
067ca7dfb3 | ||
|
|
c584aac93d | ||
|
|
c73a1df2a5 | ||
|
|
0f4cd026d3 | ||
|
|
1db4e49c64 | ||
|
|
382776e2ee | ||
|
|
652accb89b | ||
|
|
93e3025e31 | ||
|
|
b3dd537570 | ||
|
|
7cd8193a71 | ||
|
|
4cada3276c | ||
|
|
5a9f3f777a | ||
|
|
8d38384310 | ||
|
|
d2dbe9227c | ||
|
|
d0780fc55e | ||
|
|
92886cd964 | ||
|
|
dfe1e81cd7 | ||
|
|
53b77a0525 | ||
|
|
b768990c0c | ||
|
|
e3926a9a10 | ||
|
|
938365b9e8 | ||
|
|
89fe5eb063 | ||
|
|
43e356e823 | ||
|
|
e0b91d71bf | ||
|
|
56728e147f | ||
|
|
382cf33dd9 | ||
|
|
01ef7b1657 | ||
|
|
630258d2a0 | ||
|
|
9f376f7061 | ||
|
|
4a70193562 | ||
|
|
f079407800 | ||
|
|
e2482f0e1d | ||
|
|
0d6aaca77d | ||
|
|
10ffe236fb | ||
|
|
f2be6f018c | ||
|
|
8704d0e086 | ||
|
|
5d5f14b7c5 | ||
|
|
afe1554048 | ||
|
|
ba6c54fe42 | ||
|
|
2ddadf8edc | ||
|
|
d2efe0374c | ||
|
|
278db50cfd | ||
|
|
12dbbe2251 | ||
|
|
134b443675 | ||
|
|
7903537503 | ||
|
|
0c989e88e1 | ||
|
|
0e5d9f1d12 | ||
|
|
327a39bc56 | ||
|
|
4c82ef0d9e | ||
|
|
2c3c6e355c | ||
|
|
5399fce12b | ||
|
|
9a6fdc6b65 | ||
|
|
35bdf494d1 | ||
|
|
49ffad5aee | ||
|
|
9163335028 | ||
|
|
791468c660 | ||
|
|
0e687efd6e | ||
|
|
7ab182b08e | ||
|
|
8a2a85a683 | ||
|
|
3319947bdf | ||
|
|
3a6e1aabe2 | ||
|
|
d3392cb35b | ||
|
|
db07aba271 | ||
|
|
75bb6a09ff | ||
|
|
4092f19f56 | ||
|
|
a9a14c810e | ||
|
|
9c1aca3474 | ||
|
|
a62af71cef | ||
|
|
0b38e07d1d | ||
|
|
7df5c204c4 | ||
|
|
2d9576f84d | ||
|
|
3e7da21a05 | ||
|
|
92dd7886f0 | ||
|
|
8c154acd4a | ||
|
|
8ff04112a2 | ||
|
|
d595ebdd5e | ||
|
|
7b9719af3d | ||
|
|
4f69ceeb88 | ||
|
|
bf28b98a03 | ||
|
|
779fb404f5 | ||
|
|
196f313399 | ||
|
|
14f5904ee3 | ||
|
|
3e0cd059d6 | ||
|
|
0e811d78e5 | ||
|
|
6fea5b936a | ||
|
|
fd04e8e867 | ||
|
|
687f1bfd12 | ||
|
|
3b735090e4 | ||
|
|
dde35f2b2e | ||
|
|
704c3906e6 | ||
|
|
0413376752 | ||
|
|
c26fb23197 | ||
|
|
50db6475fe | ||
|
|
a84771f764 | ||
|
|
58ac340c1c | ||
|
|
d2612fd508 | ||
|
|
a01f9d5883 | ||
|
|
562175df6c | ||
|
|
13f692f8f0 | ||
|
|
bc16f9aedc | ||
|
|
3eb0cdc6f5 | ||
|
|
fb354374e3 | ||
|
|
644f4d80f7 | ||
|
|
8125ddb602 | ||
|
|
1a26268488 | ||
|
|
53c6cd4fa5 | ||
|
|
26d55e5d39 | ||
|
|
7bd4244e2f | ||
|
|
f82a1bd4fb | ||
|
|
60b7307b3f | ||
|
|
e960166932 | ||
|
|
a4649dc6a1 | ||
|
|
b52536c729 | ||
|
|
9368197061 | ||
|
|
a0a4a905ad | ||
|
|
034fd1593e | ||
|
|
5ed7535acd | ||
|
|
1bb2149e88 | ||
|
|
d59333ab3a | ||
|
|
fb845f8486 | ||
|
|
7ed014c216 | ||
|
|
7cbced85fa | ||
|
|
3690aa6119 | ||
|
|
26d3eaaa9b | ||
|
|
20bbcc3d76 | ||
|
|
029c3e33d8 | ||
|
|
a07652630e | ||
|
|
61a5dcc0db | ||
|
|
d40f83e06c | ||
|
|
1788fb7b84 | ||
|
|
c9592a87f6 | ||
|
|
3b34446f84 | ||
|
|
7c2f9dc7f4 | ||
|
|
0bd6a222a4 | ||
|
|
62b593cb3d | ||
|
|
7fe4eb3931 | ||
|
|
2e88edc841 | ||
|
|
d12994e93c | ||
|
|
44e8e374b9 | ||
|
|
218b421e16 | ||
|
|
83d8f3bf05 | ||
|
|
06f7c5d593 | ||
|
|
79ee436bdf | ||
|
|
6f3a8699be | ||
|
|
6a2f968125 | ||
|
|
1925a4fa6f | ||
|
|
eb83c35515 | ||
|
|
98c7e923c3 | ||
|
|
096a0b33ac | ||
|
|
f4d97fff39 | ||
|
|
056bb5d10a | ||
|
|
5e2520c6d2 | ||
|
|
75d2a607e2 | ||
|
|
e1025cef1a | ||
|
|
0a8dbbe152 | ||
|
|
9757924d07 | ||
|
|
fe62d6c7db | ||
|
|
4c764800c9 | ||
|
|
d1b447525a | ||
|
|
5894135cff | ||
|
|
4a87dcdcde | ||
|
|
565bc0c818 | ||
|
|
acd93b5a0b | ||
|
|
38a3760258 | ||
|
|
f4b7d61b80 | ||
|
|
58b69f8f71 | ||
|
|
f90bfe8f5b | ||
|
|
d227d13690 | ||
|
|
e310be62d4 | ||
|
|
909d703b15 | ||
|
|
3fb08ac12f | ||
|
|
887e94dff3 | ||
|
|
bb30c0b2cf | ||
|
|
ce0ce48661 | ||
|
|
4b502a3c48 | ||
|
|
41a885884c | ||
|
|
274ba45708 | ||
|
|
6f8a26a509 | ||
|
|
b4ac2ce731 | ||
|
|
d83f9d0504 | ||
|
|
64f566311b | ||
|
|
f527f4af5d | ||
|
|
e011920b66 |
No files matched your search
@@ -80,21 +80,24 @@ jobs:
|
||||
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
|
||||
rustup default stable
|
||||
echo "WGPU_BACKEND=dx12" >> "$GITHUB_ENV"
|
||||
# On a pull request, the next three checks compare with HEAD^1: the main that GitHub's merge commit puts the pull
|
||||
# request on. pull_request.base.sha is main at the pull request's last push and falls behind, so main's own
|
||||
# changes since then would count as the pull request's (#13874).
|
||||
# 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"}
|
||||
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
|
||||
run: python3 tools/check_layers.py ${BASE:+--base "$BASE"}
|
||||
# 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"}
|
||||
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
|
||||
run: python3 tools/check_addresses.py ${BASE:+--base "$BASE"}
|
||||
# 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"}
|
||||
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
|
||||
run: python3 tools/check_file_size.py ${BASE:+--base "$BASE"}
|
||||
- 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
|
||||
@@ -217,17 +220,17 @@ jobs:
|
||||
$clang = Get-Command clang -ErrorAction Stop
|
||||
Write-Output "Using $($clang.Source)"
|
||||
& $clang.Source --version
|
||||
- name: Build the game for native Windows ARM64
|
||||
run: cargo build --release --locked --target aarch64-pc-windows-msvc -p horizon-client -p horizon-client-lib --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
- name: Build the game for native Windows ARM64 (the official game, as game-build.yml ships it)
|
||||
run: cargo build --release --locked --target aarch64-pc-windows-msvc -p horizon-official --bin horizon-official -p horizon-client-lib --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
- name: Check the game's PE machine
|
||||
run: python tools/check_pe_arch.py arm64 target/aarch64-pc-windows-msvc/release/horizon-client.exe target/aarch64-pc-windows-msvc/release/horizon-ctl.exe target/aarch64-pc-windows-msvc/release/horizon-prepare.exe target/aarch64-pc-windows-msvc/release/horizon-server.exe
|
||||
run: python tools/check_pe_arch.py arm64 target/aarch64-pc-windows-msvc/release/horizon-official.exe target/aarch64-pc-windows-msvc/release/horizon-ctl.exe target/aarch64-pc-windows-msvc/release/horizon-prepare.exe target/aarch64-pc-windows-msvc/release/horizon-server.exe
|
||||
- name: Check game DLL imports
|
||||
run: python tools/check_imports.py target/aarch64-pc-windows-msvc/release/horizon-client.exe target/aarch64-pc-windows-msvc/release/horizon-ctl.exe target/aarch64-pc-windows-msvc/release/horizon-prepare.exe target/aarch64-pc-windows-msvc/release/horizon-server.exe
|
||||
run: python tools/check_imports.py target/aarch64-pc-windows-msvc/release/horizon-official.exe target/aarch64-pc-windows-msvc/release/horizon-ctl.exe target/aarch64-pc-windows-msvc/release/horizon-prepare.exe target/aarch64-pc-windows-msvc/release/horizon-server.exe
|
||||
- name: Start every game executable
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$client = 'target/aarch64-pc-windows-msvc/release/horizon-client.exe'
|
||||
$client = 'target/aarch64-pc-windows-msvc/release/horizon-official.exe'
|
||||
$output = & $client --help 2>&1
|
||||
if ($LASTEXITCODE -ne 0 -or ($output -join "`n") -notmatch '--resources') { throw 'horizon-client --help failed' }
|
||||
foreach ($binary in @('horizon-ctl.exe', 'horizon-prepare.exe', 'horizon-server.exe')) {
|
||||
|
||||
@@ -168,9 +168,14 @@ jobs:
|
||||
- name: ALSA and udev headers
|
||||
if: runner.os == 'Linux' && !matrix.musl
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
|
||||
- name: Build the official game, horizon-ctl, horizon-prepare and horizon-server
|
||||
if: ${{ !matrix.musl }}
|
||||
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client -p horizon-client-lib --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-official --bin horizon-official -p horizon-client-lib --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
- name: The official game is the game players get, under its name
|
||||
# horizon-official: horizon-client's game with Horizon's own parts plugged in (docs/layers.md, "How a
|
||||
# Horizon crate plugs in"). Everything below, the launcher and players know it as horizon-client.
|
||||
if: ${{ !matrix.musl }}
|
||||
run: cp -f "$BIN/horizon-official${{ matrix.exe }}" "$BIN/horizon-client${{ matrix.exe }}"
|
||||
- name: Every program the release folder takes was built
|
||||
if: ${{ !matrix.musl }}
|
||||
run: |
|
||||
|
||||
@@ -201,6 +201,9 @@ jobs:
|
||||
- name: Verify Windows executable architecture
|
||||
if: runner.os == 'Windows'
|
||||
run: python ../tools/check_pe_arch.py "${{ matrix.pe_arch }}" "$BIN/${{ matrix.binary }}"
|
||||
- name: Unrelated DLLs cannot intercept launcher or setup startup
|
||||
if: runner.os == 'Windows'
|
||||
run: python ../tools/test_launcher_dll_search.py "$BIN/${{ matrix.binary }}"
|
||||
- name: The version is the release's
|
||||
run: |
|
||||
out=$("$BIN/${{ matrix.binary }}" --version)
|
||||
|
||||
@@ -181,7 +181,7 @@ jobs:
|
||||
musl=(); if [[ -f dist/horizon-linux-musl-x86_64.tar.gz ]]; then musl=(dist/horizon-linux-musl-x86_64.tar.gz)
|
||||
else echo "::warning::horizon-linux-musl-x86_64.tar.gz is missing: SHA256SUMS goes out without it"; fi
|
||||
bash tools/ci/sign_release.sh nightly dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
|
||||
"${arm64[@]}" "${musl[@]}" \
|
||||
${arm64[@]+"${arm64[@]}"} ${musl[@]+"${musl[@]}"} \
|
||||
dist/horizon-macos-arm64.zip dist/horizon-macos-x64.zip dist/horizon-server-linux-x86_64.tar.gz \
|
||||
dist/horizon-source.tar.gz
|
||||
- uses: actions/upload-artifact@v7
|
||||
@@ -267,9 +267,9 @@ jobs:
|
||||
gh release create nightly --prerelease --latest=false --target "$SHA" \
|
||||
--title "Nightly $DATE · ${SHA:0:7}" --notes-file dist/nightly-notes.md \
|
||||
"dist/horizon-linux-x86_64.tar.gz#Game files the launcher installs (Nightly): Linux (horizon-linux-x86_64.tar.gz)" \
|
||||
"${musl[@]}" \
|
||||
${musl[@]+"${musl[@]}"} \
|
||||
"dist/horizon-windows-x64.zip#Game files the launcher installs (Nightly): Windows (horizon-windows-x64.zip)" \
|
||||
"${arm64[@]}" \
|
||||
${arm64[@]+"${arm64[@]}"} \
|
||||
"dist/horizon-macos-arm64.zip#Game files the launcher installs (Nightly): macOS on Apple Silicon (horizon-macos-arm64.zip)" \
|
||||
"dist/horizon-macos-x64.zip#Game files the launcher installs (Nightly): macOS on Intel (horizon-macos-x64.zip)" \
|
||||
"dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
|
||||
|
||||
@@ -44,6 +44,8 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
|
||||
- **Machine translations stay unreviewed.** `tools/lang_review/` records only text a person who speaks the language
|
||||
wrote or checked; never `lang_coverage.py --mark-reviewed` machine text ([docs/translations.md](docs/translations.md)).
|
||||
- **Controllers reach what registers.** Draw controls with a registering helper (`ui::button`, `settings_shell::foot_button`); `graphics_style::button` and `screen_draw::button` only paint, and registering one part of a screen ends its crosshair, so register all of it.
|
||||
- **Render passes keep HDR's light.** A pass that reads, filters or redraws the world's picture (upscaling,
|
||||
anti-aliasing, sharpening, frame generation, post effects, captures) follows [docs/hdr-passes.md](docs/hdr-passes.md).
|
||||
|
||||
## Crates
|
||||
|
||||
@@ -79,6 +81,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `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-hooks`, `horizon-game`, `horizon-official`, `horizon-heartbeat` | The layer seam: the engine's hooks for crates above it, the game an executable starts, the official executable (shipped as `horizon-client`) that registers Horizon's in-game parts, the first of them ([docs/layers.md](docs/layers.md#how-a-horizon-crate-plugs-in)) |
|
||||
| `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` |
|
||||
|
||||
|
||||
@@ -13,6 +13,14 @@ else (videos, Discord and other community material) lives elsewhere.
|
||||
suggest something. A pull request for a roadmap item carries its `roadmap:<id>` label; ideas go to the suggestion
|
||||
queue (the hub, or `/suggest` on Discord) rather than to issues, which are for bugs.
|
||||
|
||||
**Mod-related pull requests go to [arismoko/horizon](https://github.com/arismoko/horizon)**, the fork that carries the
|
||||
mod API and reviews and merges that work. That covers the mod API, native hooks and events for mods, native mods,
|
||||
content mods (`.hmod`), mixins, wasm mods, `cargo horizon-mod`, `horizon-mods`, mod builds and the registry, and
|
||||
their docs ([mods](docs/mods.md), [native mods](docs/native-mods.md)). Open the pull request there from your usual
|
||||
branch. Mod-related pull requests opened here are closed with a pointer to it. Engine extension points that Horizon's
|
||||
own crates register (the [layer split](docs/layers.md)), resource and shader pack formats, and gameplay fixes stay
|
||||
here.
|
||||
|
||||
## Build it
|
||||
|
||||
- **Prerequisites:** Rust (stable), Python 3.10+, JDK 25, and on Linux the ALSA and udev headers
|
||||
|
||||
@@ -1418,6 +1418,7 @@ dependencies = [
|
||||
name = "horizon-chat-filter"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-server-files",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
@@ -1450,6 +1451,7 @@ dependencies = [
|
||||
"horizon-dualsense",
|
||||
"horizon-friends",
|
||||
"horizon-hdr",
|
||||
"horizon-hooks",
|
||||
"horizon-items",
|
||||
"horizon-mod-api-gen",
|
||||
"horizon-mods",
|
||||
@@ -1500,7 +1502,6 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"horizon-client-core",
|
||||
"horizon-dualsense",
|
||||
"horizon-theme",
|
||||
"png",
|
||||
"winit",
|
||||
@@ -1528,10 +1529,8 @@ dependencies = [
|
||||
"horizon-client-render",
|
||||
"horizon-command",
|
||||
"horizon-core",
|
||||
"horizon-cosmetics",
|
||||
"horizon-dualsense",
|
||||
"horizon-friends",
|
||||
"horizon-hdr",
|
||||
"horizon-hooks",
|
||||
"horizon-items",
|
||||
"horizon-mcp",
|
||||
"horizon-mods",
|
||||
@@ -1587,13 +1586,12 @@ dependencies = [
|
||||
"horizon-core",
|
||||
"horizon-cosmetics",
|
||||
"horizon-hdr",
|
||||
"horizon-hooks",
|
||||
"horizon-items",
|
||||
"horizon-mod-api-gen",
|
||||
"horizon-narrator",
|
||||
"horizon-optifine",
|
||||
"horizon-polytone",
|
||||
"horizon-protocol",
|
||||
"horizon-render-pipeline",
|
||||
"horizon-shader-api",
|
||||
"horizon-shaders",
|
||||
"horizon-system",
|
||||
@@ -1669,6 +1667,7 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"hkdf",
|
||||
"horizon-addresses",
|
||||
"horizon-platform",
|
||||
"if-addrs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1677,6 +1676,15 @@ dependencies = [
|
||||
"spake2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-game"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-client",
|
||||
"horizon-mods",
|
||||
"horizon-threads",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-hdr"
|
||||
version = "0.1.0"
|
||||
@@ -1688,6 +1696,19 @@ dependencies = [
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-heartbeat"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-platform",
|
||||
"horizon-telemetry",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-hooks"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "horizon-items"
|
||||
version = "0.1.0"
|
||||
@@ -1768,6 +1789,18 @@ dependencies = [
|
||||
"windows 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-official"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-friends",
|
||||
"horizon-game",
|
||||
"horizon-heartbeat",
|
||||
"horizon-platform",
|
||||
"log",
|
||||
"winresource",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-optifine"
|
||||
version = "0.1.0"
|
||||
@@ -1782,7 +1815,13 @@ dependencies = [
|
||||
name = "horizon-platform"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-core",
|
||||
"horizon-hooks",
|
||||
"horizon-prepare",
|
||||
"horizon-protocol",
|
||||
"horizon-server-files",
|
||||
"horizon-system",
|
||||
"horizon-world",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1853,14 +1892,6 @@ dependencies = [
|
||||
"ring",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-render-pipeline"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"naga",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-server"
|
||||
version = "0.1.0"
|
||||
@@ -1872,11 +1903,9 @@ dependencies = [
|
||||
"horizon-anticheat",
|
||||
"horizon-bench",
|
||||
"horizon-chat-filter",
|
||||
"horizon-core",
|
||||
"horizon-mcp",
|
||||
"horizon-platform",
|
||||
"horizon-release-sign",
|
||||
"horizon-server-files",
|
||||
"horizon-world",
|
||||
"libc",
|
||||
"p12-keystore",
|
||||
"png",
|
||||
@@ -1909,7 +1938,7 @@ dependencies = [
|
||||
name = "horizon-shader-api"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-render-pipeline",
|
||||
"bytemuck",
|
||||
"naga",
|
||||
"png",
|
||||
"serde",
|
||||
@@ -1925,7 +1954,6 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"half",
|
||||
"horizon-core",
|
||||
"horizon-render-pipeline",
|
||||
"naga",
|
||||
"png",
|
||||
"zip",
|
||||
|
||||
@@ -30,6 +30,10 @@ members = [
|
||||
"crates/horizon-telemetry",
|
||||
"crates/horizon-system",
|
||||
"crates/horizon-platform",
|
||||
"crates/horizon-hooks",
|
||||
"crates/horizon-game",
|
||||
"crates/horizon-official",
|
||||
"crates/horizon-heartbeat",
|
||||
"crates/horizon-bench",
|
||||
"crates/horizon-friends",
|
||||
"crates/horizon-relay",
|
||||
@@ -39,7 +43,6 @@ members = [
|
||||
"crates/cargo-horizon-mod",
|
||||
"crates/horizon-port-pack",
|
||||
"crates/horizon-shaders",
|
||||
"crates/horizon-render-pipeline",
|
||||
"crates/horizon-optifine",
|
||||
"crates/horizon-polytone",
|
||||
"crates/horizon-shader-api",
|
||||
|
||||
@@ -129,8 +129,9 @@ ports to WGSL of the optical flow and frame interpolation shaders of the
|
||||
[AMD FidelityFX SDK](https://github.com/GPUOpen-LibrariesAndSDKs/FidelityFX-SDK) 1.1.4
|
||||
(`sdk/include/FidelityFX/gpu/{opticalflow,frameinterpolation}`, at commit
|
||||
[c6efa6bf7f20](https://github.com/GPUOpen-LibrariesAndSDKs/FidelityFX-SDK/tree/c6efa6bf7f2027b3ec94f28578bb5965eabb9e55)),
|
||||
and `optical_flow.rs` follows the SDK's `sdk/src/components/opticalflow/ffx_opticalflow.cpp`
|
||||
(its passes, their order and their dispatch sizes). Copyright (C) 2024 Advanced Micro Devices,
|
||||
and `optical_flow.rs` and `interpolation.rs` follow the SDK's
|
||||
`sdk/src/components/{opticalflow/ffx_opticalflow.cpp,frameinterpolation/ffx_frameinterpolation.cpp}`
|
||||
(their passes, their order and their dispatch sizes). Copyright (C) 2024 Advanced Micro Devices,
|
||||
Inc., under the MIT license, whose text heads each file. AMD and FidelityFX are AMD's
|
||||
trademarks; Horizon is not affiliated with AMD.
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ impl Report {
|
||||
/// The kind a file's extension says.
|
||||
pub fn kind_of(path: &Path) -> Option<Kind> {
|
||||
match path.extension()?.to_str()? {
|
||||
"hmod" if horizon_mods::index::native::holds(path) => Some(Kind::NativeMod),
|
||||
"hmod" => Some(Kind::Mod),
|
||||
"hnmod" => Some(Kind::NativeMod),
|
||||
"hpack" => Some(Kind::Modpack),
|
||||
|
||||
@@ -377,6 +377,13 @@ mod tests {
|
||||
zip.finish().unwrap();
|
||||
let report = check::check(&hnmod, check::kind_of(&hnmod).unwrap(), None, None).unwrap();
|
||||
assert!(report.text().contains("machine_mod"), "{}", report.text());
|
||||
let combined = dir.join("machine_mod.hmod");
|
||||
std::fs::copy(&hnmod, &combined).unwrap();
|
||||
assert_eq!(
|
||||
check::kind_of(&combined),
|
||||
Some(horizon_mods::index::Kind::NativeMod)
|
||||
);
|
||||
assert!(check::check(&combined, check::kind_of(&combined).unwrap(), None, None).is_ok());
|
||||
// A pin that is not a commit is refused; with none, the folder builds on the
|
||||
// instance's Horizon.
|
||||
let bad = [
|
||||
|
||||
@@ -80,8 +80,8 @@ pub fn main(args: &[String]) -> Result<(), String> {
|
||||
};
|
||||
let author = flag(rest, "--author").ok_or(USAGE)?;
|
||||
let (base, _) = registry::load(Path::new(base)).map_err(lines)?;
|
||||
let (head, _) = registry::load(Path::new(head)).map_err(lines)?;
|
||||
let review = registry::review(&base, &head, author);
|
||||
let (head, revoked) = registry::load(Path::new(head)).map_err(lines)?;
|
||||
let review = registry::review_with_revoked(&base, &head, &revoked, author);
|
||||
if rest.iter().any(|a| a == "--json") {
|
||||
let json = serde_json::json!({"errors": review.errors, "notes": review.notes});
|
||||
println!("{json}");
|
||||
|
||||
@@ -26,18 +26,23 @@ pub struct AnimationMetadata {
|
||||
pub enum ClipMode {
|
||||
/// `day_time`: frame 0 at time 0, the last at the cycle's end, then again.
|
||||
DayTime { cycle: i64 },
|
||||
/// `screen_time`: the ticks the open screen has been open, over the cycle, held on the last
|
||||
/// frame once it has run (it doesn't loop, measured on the real mod).
|
||||
ScreenTime { cycle: i64 },
|
||||
/// `weather`: frame 0 in clear weather, 1 in rain, 2 in a storm (the rain and thunder levels
|
||||
/// added up), at most the last.
|
||||
Weather,
|
||||
/// A Polytone expression: its value's fraction is how far through the animation it is.
|
||||
Expression(String),
|
||||
/// `game_time`, `screen_time`, `season`: not driven yet; the animation runs on ticks.
|
||||
/// `game_time`, `season`: not driven; the animation runs on ticks.
|
||||
Unsupported(String),
|
||||
}
|
||||
|
||||
/// The values [`ClipMode`]s read this frame.
|
||||
pub struct ModeInputs<'a> {
|
||||
pub day_time: i64,
|
||||
/// The ticks the open screen has been open (0 without one).
|
||||
pub screen_time: i64,
|
||||
/// The rain level plus the thunder level, 0 to 2.
|
||||
pub weather: f32,
|
||||
/// An expression's value this frame, by its source.
|
||||
@@ -116,7 +121,8 @@ impl AnimationMetadata {
|
||||
match mode.as_str() {
|
||||
"day_time" => ClipMode::DayTime { cycle },
|
||||
"weather" => ClipMode::Weather,
|
||||
"game_time" | "screen_time" | "season" => ClipMode::Unsupported(mode),
|
||||
"screen_time" => ClipMode::ScreenTime { cycle },
|
||||
"game_time" | "season" => ClipMode::Unsupported(mode),
|
||||
_ => ClipMode::Expression(mode),
|
||||
}
|
||||
}),
|
||||
@@ -239,6 +245,9 @@ impl AnimationClip {
|
||||
pub fn sample_mode(&self, inputs: &ModeInputs) -> Option<AnimationSample> {
|
||||
let fraction = match self.mode.as_ref()? {
|
||||
ClipMode::DayTime { cycle } => day_fraction(inputs.day_time, *cycle),
|
||||
ClipMode::ScreenTime { cycle } => {
|
||||
(inputs.screen_time.max(0) as f64 / *cycle as f64).min(1.0 - f64::EPSILON)
|
||||
}
|
||||
ClipMode::Weather => {
|
||||
let last = self.sequence.len() - 1;
|
||||
let index = (inputs.weather.max(0.0) as usize).min(last);
|
||||
@@ -288,6 +297,7 @@ mod mode_tests {
|
||||
let expression = move |_: &str| Some(value);
|
||||
clip.sample_mode(&ModeInputs {
|
||||
day_time,
|
||||
screen_time: day_time,
|
||||
weather,
|
||||
expression: &expression,
|
||||
})
|
||||
@@ -302,6 +312,13 @@ mod mode_tests {
|
||||
.map(|t| frame(&day, t, 0.0, 0.0))
|
||||
.into();
|
||||
assert_eq!(frames, [0, 1, 2, 3, 1]);
|
||||
// 26.3-6.8.6, four frames over 200 ticks: 40 ticks open show the first, and from ten
|
||||
// seconds on the last, even after 27 (it doesn't loop).
|
||||
let screen = clip(r#"{"frametime": 1, "mode": "screen_time", "time_cycle_duration": 200}"#);
|
||||
let frames: Vec<u32> = [0, 40, 200, 360, 540]
|
||||
.map(|t| frame(&screen, t, 0.0, 0.0))
|
||||
.into();
|
||||
assert_eq!(frames, [0, 0, 3, 3, 3]);
|
||||
let value = clip(r#"{"frametime": 1, "mode": "0.6"}"#);
|
||||
assert_eq!(frame(&value, 0, 0.0, 0.6), 2);
|
||||
assert_eq!(frame(&value, 0, 0.0, 2.0), 0);
|
||||
@@ -321,6 +338,7 @@ mod mode_tests {
|
||||
clip(r#"{"frametime": 1}"#)
|
||||
.sample_mode(&ModeInputs {
|
||||
day_time: 0,
|
||||
screen_time: 0,
|
||||
weather: 0.0,
|
||||
expression: &|_| None
|
||||
})
|
||||
@@ -330,6 +348,7 @@ mod mode_tests {
|
||||
clip(r#"{"frametime": 1, "mode": "season"}"#)
|
||||
.sample_mode(&ModeInputs {
|
||||
day_time: 0,
|
||||
screen_time: 0,
|
||||
weather: 0.0,
|
||||
expression: &|_| None
|
||||
})
|
||||
|
||||
@@ -7,6 +7,8 @@ rust-version.workspace = true
|
||||
description = "The dedicated server's chat filter: its [chat_filter] settings, the slur list's matcher and the mutes, with no game state (docs/chat-filter.md)"
|
||||
|
||||
[dependencies]
|
||||
# The moderation durations, shared with `/tempban`.
|
||||
horizon-server-files = { path = "../horizon-server-files" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "1.1"
|
||||
|
||||
@@ -10,6 +10,9 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
mod normalise;
|
||||
|
||||
pub use horizon_server_files::duration::{
|
||||
DurationUnit, duration_parts, duration_text, now_millis, parse_duration,
|
||||
};
|
||||
pub use normalise::{Run, Text};
|
||||
|
||||
/// The list built into the game, used when `words_file` is empty.
|
||||
@@ -474,61 +477,6 @@ pub fn append_log(path: &Path, line: &str) -> std::io::Result<()> {
|
||||
writeln!(f, "{line}")
|
||||
}
|
||||
|
||||
/// `/tempmute`'s duration in milliseconds: a positive whole number and a unit, `s`, `m`, `h`, `d` or
|
||||
/// `w` (`10m`, `2h`, `1d`).
|
||||
pub fn parse_duration(text: &str) -> Option<i64> {
|
||||
let unit = match text.chars().last()? {
|
||||
's' => 1_000,
|
||||
'm' => MINUTE,
|
||||
'h' => 60 * MINUTE,
|
||||
'd' => 24 * 60 * MINUTE,
|
||||
'w' => 7 * 24 * 60 * MINUTE,
|
||||
_ => return None,
|
||||
};
|
||||
let count: i64 = text[..text.len() - 1].parse().ok()?;
|
||||
(count > 0).then(|| count.checked_mul(unit)).flatten()
|
||||
}
|
||||
|
||||
/// The unit used when presenting a moderation duration to a player.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DurationUnit {
|
||||
Minute,
|
||||
Hour,
|
||||
Day,
|
||||
}
|
||||
|
||||
/// A duration's rounded count and unit, shared by logs and translated replies.
|
||||
pub fn duration_parts(millis: i64) -> (i64, DurationUnit) {
|
||||
let (unit, kind) = if millis <= 60 * MINUTE {
|
||||
(MINUTE, DurationUnit::Minute)
|
||||
} else if millis <= 48 * 60 * MINUTE {
|
||||
(60 * MINUTE, DurationUnit::Hour)
|
||||
} else {
|
||||
(24 * 60 * MINUTE, DurationUnit::Day)
|
||||
};
|
||||
(
|
||||
millis.div_euclid(unit) + i64::from(millis.rem_euclid(unit) > 0),
|
||||
kind,
|
||||
)
|
||||
}
|
||||
|
||||
/// `millis` for logs, rounded up: minutes up to an hour, hours up to two days, then days.
|
||||
pub fn duration_text(millis: i64) -> String {
|
||||
let (count, unit) = duration_parts(millis);
|
||||
let unit = match unit {
|
||||
DurationUnit::Minute => "minute",
|
||||
DurationUnit::Hour => "hour",
|
||||
DurationUnit::Day => "day",
|
||||
};
|
||||
format!("{count} {unit}{}", if count == 1 { "" } else { "s" })
|
||||
}
|
||||
|
||||
pub fn now_millis() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_millis() as i64)
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DDTHH:MM:SSZ` for epoch milliseconds.
|
||||
pub fn utc(millis: i64) -> String {
|
||||
let secs = millis.div_euclid(1000);
|
||||
|
||||
@@ -13,11 +13,9 @@ winit = "0.30.13"
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
png = "0.18"
|
||||
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
|
||||
# The DualSense's trigger effects (the `controllers` feature).
|
||||
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
|
||||
|
||||
[features]
|
||||
controllers = ["dep:horizon-dualsense"]
|
||||
controllers = []
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -1523,9 +1523,8 @@ impl Rumble {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a trigger feels like on a DualSense, from what its action is doing (docs/controllers.md
|
||||
/// "Adaptive triggers" for the choices). Only the pad's official modes: Nielk1's notes warn the
|
||||
/// others may leave the firmware.
|
||||
/// What a trigger feels like, from what its action is doing (docs/controllers.md "Adaptive
|
||||
/// triggers"); the controller code turns it into a DualSense trigger effect.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum TriggerFeel {
|
||||
Free,
|
||||
@@ -1544,66 +1543,6 @@ pub enum TriggerFeel {
|
||||
/// Mining a block of this hardness: a buzz, stronger and slower on harder blocks.
|
||||
Texture(f32),
|
||||
}
|
||||
#[cfg(feature = "controllers")]
|
||||
impl TriggerFeel {
|
||||
/// The effect, for a trigger that presses its action at `threshold` (0..1) of its travel.
|
||||
pub fn effect(self, threshold: f32) -> horizon_dualsense::TriggerEffect {
|
||||
use horizon_dualsense::trigger;
|
||||
// The zone (of ten along the travel) where the action presses: a catch gives way there,
|
||||
// so the click and the shot coincide.
|
||||
let press = ((threshold.clamp(0.0, 1.0) * 10.0) as u8).clamp(3, 8);
|
||||
match self {
|
||||
TriggerFeel::Free => horizon_dualsense::TRIGGER_OFF,
|
||||
// From the third zone, so the press itself stays light; at most 6 of 8, as the
|
||||
// draw is held.
|
||||
TriggerFeel::Draw(charge) => {
|
||||
trigger::feedback(2, 1 + (5.0 * charge.clamp(0.0, 1.0)).round() as u8)
|
||||
}
|
||||
TriggerFeel::Click => trigger::weapon((press - 2).max(2), press, 8),
|
||||
TriggerFeel::Boost => trigger::weapon((press - 2).max(2), press, 5),
|
||||
TriggerFeel::Brace => trigger::feedback(2, 3),
|
||||
TriggerFeel::Locked => trigger::feedback(1, 8),
|
||||
TriggerFeel::Texture(hardness) if hardness > 0.0 => {
|
||||
// Dirt 2, stone 3, deepslate 4, obsidian 5 of 8.
|
||||
let amplitude = (1.0 + 1.6 * hardness.sqrt()).round().clamp(1.0, 5.0);
|
||||
// Sand and dirt a fine 40 Hz grit; stone 30; wood, deepslate and ores 20;
|
||||
// obsidian a 10 Hz chisel.
|
||||
let hertz = match hardness {
|
||||
h if h < 1.0 => 40,
|
||||
h if h < 2.0 => 30,
|
||||
h if h < 5.0 => 20,
|
||||
_ => 10,
|
||||
};
|
||||
trigger::vibration(1, amplitude as u8, hertz)
|
||||
}
|
||||
TriggerFeel::Texture(_) => horizon_dualsense::TRIGGER_OFF,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "controllers")]
|
||||
impl PadSettings {
|
||||
/// The left and right triggers' effects: each feels the action bound to it, `key.use` or
|
||||
/// `key.attack`.
|
||||
pub fn trigger_effects(
|
||||
&self,
|
||||
using: TriggerFeel,
|
||||
attacking: TriggerFeel,
|
||||
) -> [horizon_dualsense::TriggerEffect; 2] {
|
||||
[Button::LeftTrigger, Button::RightTrigger].map(|trigger| {
|
||||
let actions = self.actions_of(trigger);
|
||||
let feel = if !self.adaptive_triggers {
|
||||
TriggerFeel::Free
|
||||
} else if actions.contains(&"key.use") {
|
||||
using
|
||||
} else if actions.contains(&"key.attack") {
|
||||
attacking
|
||||
} else {
|
||||
TriggerFeel::Free
|
||||
};
|
||||
feel.effect(self.trigger_threshold)
|
||||
})
|
||||
}
|
||||
}
|
||||
/// What a DualSense's lightbar shows (`lightbar:`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Lightbar {
|
||||
@@ -2438,52 +2377,6 @@ bind_key.hotbar.2:north
|
||||
assert_eq!(w.observe(Some(1.0)), Some(1.0));
|
||||
}
|
||||
|
||||
#[cfg(feature = "controllers")]
|
||||
#[test]
|
||||
fn triggers_feel_their_action() {
|
||||
use horizon_dualsense::{TRIGGER_OFF, trigger};
|
||||
let mut s = PadSettings::default();
|
||||
// Use is LT, attack RT by default.
|
||||
let [left, right] = s.trigger_effects(TriggerFeel::Draw(1.0), TriggerFeel::Texture(1.5));
|
||||
assert_eq!(left, trigger::feedback(2, 6));
|
||||
assert_eq!(right, trigger::vibration(1, 3, 30));
|
||||
assert_eq!(
|
||||
s.trigger_effects(TriggerFeel::Draw(0.0), TriggerFeel::Free),
|
||||
[trigger::feedback(2, 1), TRIGGER_OFF]
|
||||
);
|
||||
// The catch gives way in the zone where the default threshold (0.4) presses: weapon
|
||||
// 0x25, zones 2 and 4, strength 8 (stored 7) or 5 (stored 4).
|
||||
let catch = |strength| [0x25, 0x14, 0x00, strength, 0, 0, 0, 0, 0, 0, 0];
|
||||
assert_eq!(TriggerFeel::Click.effect(0.4), catch(7));
|
||||
assert_eq!(TriggerFeel::Boost.effect(0.4), catch(4));
|
||||
// A deeper threshold moves it: zones 5 and 7.
|
||||
assert_eq!(TriggerFeel::Click.effect(0.7)[1..3], [0xa0, 0x00]);
|
||||
assert_eq!(TriggerFeel::Click.effect(0.1), trigger::weapon(2, 3, 8));
|
||||
assert_eq!(TriggerFeel::Brace.effect(0.4), trigger::feedback(2, 3));
|
||||
assert_eq!(TriggerFeel::Locked.effect(0.4), trigger::feedback(1, 8));
|
||||
// Harder blocks buzz harder and slower; instant-break blocks don't.
|
||||
assert_eq!(
|
||||
TriggerFeel::Texture(0.5).effect(0.4),
|
||||
trigger::vibration(1, 2, 40)
|
||||
);
|
||||
assert_eq!(
|
||||
TriggerFeel::Texture(3.0).effect(0.4),
|
||||
trigger::vibration(1, 4, 20)
|
||||
);
|
||||
assert_eq!(
|
||||
TriggerFeel::Texture(50.0).effect(0.4),
|
||||
trigger::vibration(1, 5, 10)
|
||||
);
|
||||
assert_eq!(TriggerFeel::Texture(0.0).effect(0.4), TRIGGER_OFF);
|
||||
assert_eq!(TriggerFeel::Texture(-1.0).effect(0.4), TRIGGER_OFF);
|
||||
s.adaptive_triggers = false;
|
||||
assert_eq!(
|
||||
s.trigger_effects(TriggerFeel::Click, TriggerFeel::Boost),
|
||||
[TRIGGER_OFF; 2]
|
||||
);
|
||||
assert!(!PadSettings::parse(&s.serialize()).adaptive_triggers);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightbar_colours() {
|
||||
let full = Lightbar::Health.colour(1.0, "minecraft:overworld").unwrap();
|
||||
|
||||
@@ -670,6 +670,39 @@ pub fn with_horizon_icon(
|
||||
attributes
|
||||
}
|
||||
|
||||
/// The screen a window is on: its size in physical pixels, its refresh rate where the OS gives
|
||||
/// it, and its scale. For `horizon-ctl status` and the performance statistics' `system.display`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Display {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub refresh_hz: Option<u32>,
|
||||
pub scale: f64,
|
||||
}
|
||||
|
||||
/// The screen `window` is on, else the primary one, else the only one: right after the window
|
||||
/// is made, Wayland doesn't say yet which screen it is on.
|
||||
pub fn monitor(window: &winit::window::Window) -> Option<winit::monitor::MonitorHandle> {
|
||||
let only = || {
|
||||
let mut all = window.available_monitors();
|
||||
all.next().filter(|_| all.next().is_none())
|
||||
};
|
||||
window
|
||||
.current_monitor()
|
||||
.or_else(|| window.primary_monitor())
|
||||
.or_else(only)
|
||||
}
|
||||
|
||||
/// [`monitor`]'s size, refresh rate and scale.
|
||||
pub fn display(window: &winit::window::Window) -> Option<Display> {
|
||||
monitor(window).map(|m| Display {
|
||||
width: m.size().width,
|
||||
height: m.size().height,
|
||||
refresh_hz: m.refresh_rate_millihertz().map(|r| r.div_ceil(1000)),
|
||||
scale: m.scale_factor(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -18,10 +18,10 @@ horizon-client-core = { path = "../horizon-client-core" }
|
||||
horizon-client-input = { path = "../horizon-client-input" }
|
||||
horizon-client-render = { path = "../horizon-client-render" }
|
||||
horizon-command = { path = "../horizon-command" }
|
||||
# Emotes pose the player model (`Mob::emote`, `mobs::anim::setup`).
|
||||
horizon-cosmetics = { path = "../horizon-cosmetics" }
|
||||
horizon-items = { path = "../horizon-items" }
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
# Extension points for the crates above the engine (docs/layers.md, "How a Horizon crate plugs in").
|
||||
horizon-hooks = { path = "../horizon-hooks" }
|
||||
# `horizon-ctl mcp`: the Model Context Protocol, shared with the launcher and the server.
|
||||
horizon-mcp = { path = "../horizon-mcp" }
|
||||
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
|
||||
@@ -42,8 +42,6 @@ horizon-chat-filter = { path = "../horizon-chat-filter" }
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
# Play with Friends: invite codes and their tunnel; the `friends` feature.
|
||||
horizon-friends = { path = "../horizon-friends", optional = true }
|
||||
# The narrator (GameNarrator): text-to-speech of chat, system messages and screens.
|
||||
horizon-narrator = { path = "../horizon-narrator" }
|
||||
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
|
||||
@@ -73,16 +71,13 @@ mio = { version = "1.2.4", features = ["os-poll", "net"] }
|
||||
# Shared chunk frames are found by one keyed 128-bit hash of their body (lan/conn.rs SharedFrames).
|
||||
xxhash-rust = { version = "0.8.19", features = ["xxh3"] }
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md); `live` is
|
||||
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`), the `heartbeat` feature.
|
||||
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
crc32fast = "1"
|
||||
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
|
||||
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
|
||||
gilrs = { version = "0.11", optional = true }
|
||||
# The DualSense's rumble, adaptive triggers and lights over HID (gilrs only reads it).
|
||||
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
|
||||
# HTTPS to the Minecraft services (online-mode session join).
|
||||
ureq = { version = "2", default-features = false, features = ["tls"] }
|
||||
unicode-bidi = "0.3.18"
|
||||
@@ -114,18 +109,17 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
|
||||
|
||||
[features]
|
||||
default = ["audio", "controllers", "discord", "friends", "heartbeat"]
|
||||
default = ["audio", "controllers", "horizon-extras"]
|
||||
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
|
||||
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
|
||||
audio = ["horizon-client-render/audio"]
|
||||
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
|
||||
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
controllers = ["dep:gilrs", "horizon-client-input/controllers"]
|
||||
# Horizon's own parts (docs/forking.md, docs/layers.md): the features below, and the official
|
||||
# server in the list (`official_servers`).
|
||||
horizon-extras = ["discord"]
|
||||
# "Playing Horizon" on the player's Discord profile (`discord_presence`).
|
||||
discord = []
|
||||
# Play with Friends, joining side (`net.rs`): without it no invite code joins.
|
||||
friends = ["dep:horizon-friends"]
|
||||
# The anonymous "I'm playing" ping (`heartbeat.rs`, docs/telemetry.md).
|
||||
heartbeat = ["horizon-telemetry/live"]
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
rng-trace = ["horizon-core/rng-trace"]
|
||||
# Compatibility with earlier experiment commands: main already uses mimalloc.
|
||||
|
||||
@@ -7,6 +7,8 @@ rust-version.workspace = true
|
||||
description = "The client's world data, rendering and screens: chunks, meshing, light, streaming, the renderer, the HUD, menus and settings; no gameplay"
|
||||
|
||||
[dependencies]
|
||||
# The settings stores of the crates above the engine (docs/layers.md, "Platform registries").
|
||||
horizon-hooks = { path = "../horizon-hooks" }
|
||||
horizon-cosmetics = { path = "../horizon-cosmetics" }
|
||||
# The GUI atlas's pixels, kept compressed once uploaded (hud.rs `pack_pixels`).
|
||||
lz4_flex = { version = "0.14", default-features = false, features = ["std", "safe-encode", "safe-decode", "checked-decode"] }
|
||||
@@ -24,7 +26,6 @@ horizon-narrator = { path = "../horizon-narrator" }
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
horizon-polytone = { path = "../horizon-polytone" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
horizon-render-pipeline = { path = "../horizon-render-pipeline" }
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
horizon-system = { path = "../horizon-system" }
|
||||
@@ -76,9 +77,6 @@ audio = ["dep:cpal"]
|
||||
# Helpers horizon-client's tests build their fixtures with.
|
||||
test-support = []
|
||||
|
||||
[build-dependencies]
|
||||
horizon-mod-api-gen = { path = "../horizon-mod-api-gen" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
fn main() {
|
||||
let root =
|
||||
std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("../..");
|
||||
let out_dir = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
|
||||
horizon_mod_api_gen::generate(&root, &out_dir, horizon_mod_api_gen::Target::Render)
|
||||
.expect("generating native render mod API");
|
||||
}
|
||||
@@ -16,6 +16,45 @@ use crate::{
|
||||
#[path = "chat_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
/// `FilterMask.applyWithFormatting` over a player message's signed content, as
|
||||
/// the packet's mask `(type, bit set)`: as it is when passed through, none when
|
||||
/// fully filtered, and when partly filtered each masked UTF-16 unit becomes a
|
||||
/// `#` in `FILTERED_STYLE` (dark grey, hover "chat.filtered").
|
||||
pub fn filtered_message(
|
||||
content: &str,
|
||||
(kind, bits): &(i32, Vec<i64>),
|
||||
) -> Option<serde_json::Value> {
|
||||
match kind {
|
||||
1 => return None,
|
||||
2 => {}
|
||||
_ => return Some(serde_json::Value::from(content)),
|
||||
}
|
||||
let masked = |i: usize| {
|
||||
bits.get(i / 64)
|
||||
.is_some_and(|w| (*w as u64 >> (i % 64)) & 1 != 0)
|
||||
};
|
||||
let units: Vec<u16> = content.encode_utf16().collect();
|
||||
let mut parts = Vec::new();
|
||||
let mut start = 0;
|
||||
while start < units.len() {
|
||||
let filtered = masked(start);
|
||||
let end = (start..units.len())
|
||||
.find(|&i| masked(i) != filtered)
|
||||
.unwrap_or(units.len());
|
||||
parts.push(if filtered {
|
||||
serde_json::json!({
|
||||
"text": "#".repeat(end - start),
|
||||
"color": "dark_gray",
|
||||
"hover_event": {"action": "show_text", "value": {"translate": "chat.filtered"}},
|
||||
})
|
||||
} else {
|
||||
serde_json::Value::from(String::from_utf16_lossy(&units[start..end]))
|
||||
});
|
||||
start = end;
|
||||
}
|
||||
Some(serde_json::json!({"text": "", "extra": parts}))
|
||||
}
|
||||
|
||||
/// `CommandHistory.PERSISTED_COMMANDS_FILE_NAME` and its size cap.
|
||||
const COMMAND_HISTORY_FILE: &str = "command_history.txt";
|
||||
const MAX_PERSISTED_COMMAND_HISTORY: usize = 50;
|
||||
|
||||
@@ -1026,3 +1026,43 @@ fn a_compact_repeat_keeps_the_scrolled_view_still() {
|
||||
says(&mut chat, "new");
|
||||
assert_eq!(chat.scroll, 6);
|
||||
}
|
||||
|
||||
/// `FilterMask.applyWithFormatting`: a pass-through mask keeps the message, a
|
||||
/// fully filtered one hides it, and a partial one turns each masked UTF-16 unit
|
||||
/// into a dark grey `#` whose hover reads `chat.filtered`.
|
||||
#[test]
|
||||
fn a_server_filter_mask_hides_or_masks_the_message() {
|
||||
let text = |v: &serde_json::Value| crate::component::to_text(v, &mut |s| Text::literal(s));
|
||||
let shown = |content: &str, mask: (i32, Vec<i64>)| {
|
||||
filtered_message(content, &mask).map(|v| text(&v).plain())
|
||||
};
|
||||
assert_eq!(shown("hello", (0, vec![])), Some("hello".to_owned()));
|
||||
assert_eq!(shown("hello", (1, vec![])), None);
|
||||
assert_eq!(shown("hello", (2, vec![0b0110])), Some("h##lo".to_owned()));
|
||||
assert_eq!(
|
||||
shown("hello", (2, vec![0b1_0001])),
|
||||
Some("#ell#".to_owned())
|
||||
);
|
||||
// An emoji is two UTF-16 units, so two bits and two `#`.
|
||||
assert_eq!(
|
||||
shown("a\u{1F600}b", (2, vec![0b0110])),
|
||||
Some("a##b".to_owned())
|
||||
);
|
||||
// Bit 65 is in the second word.
|
||||
let long = "x".repeat(70);
|
||||
let mut expected = long.clone();
|
||||
expected.replace_range(65..66, "#");
|
||||
assert_eq!(shown(&long, (2, vec![0, 0b10])), Some(expected));
|
||||
let masked = text(&filtered_message("hello", &(2, vec![0b0110])).unwrap());
|
||||
let hash = masked.spans.iter().find(|s| s.text == "##").unwrap();
|
||||
assert_eq!(hash.style.color, Some(text::DARK_GRAY));
|
||||
let hover = hash.style.hover.as_ref().unwrap();
|
||||
assert_eq!(hover.spans[0].text, "chat.filtered");
|
||||
assert!(
|
||||
masked
|
||||
.spans
|
||||
.iter()
|
||||
.filter(|s| s.text != "##")
|
||||
.all(|s| s.style.color.is_none())
|
||||
);
|
||||
}
|
||||
@@ -121,8 +121,8 @@ impl CommandBlock {
|
||||
condition_met: flag("conditionMet", false),
|
||||
}
|
||||
}
|
||||
/// `CommandBlockEntity.saveAdditional`. The last output is written as a
|
||||
/// plain string component.
|
||||
/// `CommandBlockEntity.saveAdditional`. The last output is the component
|
||||
/// itself (`ComponentSerialization.CODEC`), styles and click events kept.
|
||||
pub fn save(&self) -> Vec<(&'static str, Tag)> {
|
||||
let mut out = vec![
|
||||
("Command", Tag::String(self.command.as_str().into())),
|
||||
@@ -135,7 +135,7 @@ impl CommandBlock {
|
||||
if self.track_output
|
||||
&& let Some(last) = &self.last_output
|
||||
{
|
||||
out.push(("LastOutput", Tag::String(last.plain().as_str().into())));
|
||||
out.push(("LastOutput", scoreboard::component_tag(&last.component())));
|
||||
}
|
||||
out.push((
|
||||
"UpdateLastExecution",
|
||||
|
||||
@@ -43,6 +43,11 @@ pub fn enter_native_mod(call: NativeModCall) -> NativeModGuard {
|
||||
}
|
||||
}
|
||||
|
||||
/// The native mod call this thread is currently inside, if any.
|
||||
pub fn current_native_mod() -> Option<NativeModCall> {
|
||||
IN_NATIVE_MOD.with(Cell::get)
|
||||
}
|
||||
|
||||
/// Puts back the call that `enter_native_mod` found.
|
||||
#[must_use]
|
||||
pub struct NativeModGuard {
|
||||
@@ -244,7 +249,7 @@ fn arch_label(build: &str, machine: &str) -> String {
|
||||
}
|
||||
|
||||
/// `PRETTY_NAME` of /etc/os-release and the kernel, or the system's own version string.
|
||||
fn operating_system() -> String {
|
||||
pub(crate) fn operating_system() -> String {
|
||||
let arch = arch_label(std::env::consts::ARCH, machine_arch());
|
||||
let version = if cfg!(target_os = "linux") {
|
||||
let name = read("/etc/os-release").and_then(|t| {
|
||||
|
||||
@@ -1,27 +1,34 @@
|
||||
//! OptiFine custom skies (`horizon_optifine::sky`) as the game draws them: a pack's layers with
|
||||
//! their decoded textures, read once with the pack, and each frame's choice of layers, their
|
||||
//! brightness and rotation ([`CustomSky::draws`]), which `gfx::custom_sky` draws over the
|
||||
//! vanilla sky. A pack without `optifine/sky` gives an empty [`CustomSky`] and the sky stays
|
||||
//! vanilla.
|
||||
//! Custom sky layers: skyboxes a pack draws over the vanilla sky, in a format vanilla doesn't read
|
||||
//! (OptiFine's), which the platform's pack formats fill ([`crate::pack_hooks`]). The layers'
|
||||
//! textures are read once with the pack; each frame the game asks for the layers to draw, their
|
||||
//! brightness and rotation ([`SkyLayers::draws`]), and `gfx::custom_sky` draws them over the
|
||||
//! vanilla sky. A pack without layers keeps the vanilla sky.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use glam::{Mat4, Vec3};
|
||||
use horizon_optifine::sky::{Blend, Conditions, Skies, SkyLayer, Transition};
|
||||
use glam::Mat4;
|
||||
|
||||
/// A layer's texture, RGBA8.
|
||||
/// A layer's texture, RGBA8: the skybox's six faces in a 3x2 grid, bottom, top and south in the
|
||||
/// top row, west, north and east below (`gfx::custom_sky`'s faces).
|
||||
pub struct Image {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub rgba: Vec<u8>,
|
||||
}
|
||||
|
||||
/// The pack's layers, by world, with one texture per layer (`images[i]` for the `i`th layer
|
||||
/// over all worlds, in `worlds` order).
|
||||
#[derive(Default)]
|
||||
pub struct CustomSky {
|
||||
worlds: Vec<(i32, Vec<SkyLayer>)>,
|
||||
pub images: Vec<Image>,
|
||||
/// How a layer mixes with what is drawn below it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Blend {
|
||||
Add,
|
||||
Subtract,
|
||||
Multiply,
|
||||
Dodge,
|
||||
Burn,
|
||||
Screen,
|
||||
Replace,
|
||||
Overlay,
|
||||
Alpha,
|
||||
}
|
||||
|
||||
/// One layer of a frame: which texture, with which blend, how bright and turned how.
|
||||
@@ -34,62 +41,44 @@ pub struct Draw {
|
||||
pub rotation: Mat4,
|
||||
}
|
||||
|
||||
/// The weather of a frame: thunder is a storm's rain, so it is only `Thunder`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Weather {
|
||||
Clear,
|
||||
Rain,
|
||||
Thunder,
|
||||
}
|
||||
|
||||
/// What a frame's layers may depend on.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Conditions<'a> {
|
||||
/// Ticks of the day, counting the days.
|
||||
pub day_time: i64,
|
||||
pub weather: Weather,
|
||||
/// The biome at the camera, namespaced.
|
||||
pub biome: &'a str,
|
||||
/// The camera's block height.
|
||||
pub y: i32,
|
||||
}
|
||||
|
||||
/// The eased brightness of each layer, kept by the game between frames.
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
world: Option<i32>,
|
||||
eased: Vec<Transition>,
|
||||
/// The world the layers were last drawn for.
|
||||
pub world: Option<i32>,
|
||||
/// Each layer's eased visibility, 0 to 1.
|
||||
pub eased: Vec<f32>,
|
||||
}
|
||||
|
||||
/// Textures past this many texels in all are left out (a skybox is a few megapixels at most).
|
||||
const TEXEL_BUDGET: u64 = 64 << 20;
|
||||
|
||||
impl CustomSky {
|
||||
/// The custom skies of the overlay at `root`. A layer whose texture does not decode is
|
||||
/// dropped with a warning.
|
||||
pub fn load(root: &Path) -> Self {
|
||||
let skies = Skies::scan(root);
|
||||
let mut out = Self::default();
|
||||
let mut texels = 0u64;
|
||||
for (world, sky) in skies.worlds {
|
||||
let mut layers = Vec::new();
|
||||
for layer in sky.layers {
|
||||
match crate::hud::decode(&root.join(&layer.source)) {
|
||||
Ok((width, height, rgba))
|
||||
if texels + u64::from(width) * u64::from(height) <= TEXEL_BUDGET =>
|
||||
{
|
||||
texels += u64::from(width) * u64::from(height);
|
||||
out.images.push(Image {
|
||||
width,
|
||||
height,
|
||||
rgba,
|
||||
});
|
||||
layers.push(layer);
|
||||
}
|
||||
Ok(_) => log::warn!("{}: past the custom sky's texture budget", layer.source),
|
||||
Err(e) => log::warn!("{}: {e:#}", layer.source),
|
||||
}
|
||||
}
|
||||
if !layers.is_empty() {
|
||||
out.worlds.push((world, layers));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.worlds.is_empty()
|
||||
}
|
||||
|
||||
/// Whether `world` has layers (`0` the Overworld, `1` the End).
|
||||
pub fn has(&self, world: i32) -> bool {
|
||||
self.worlds.iter().any(|(w, _)| *w == world)
|
||||
}
|
||||
|
||||
/// This frame's layers of `world` into `out` (cleared first), in drawing order: those whose
|
||||
/// conditions hold, eased over their `transition` by `seconds` since the last frame, at the
|
||||
/// brightness of their fade times. `sun_turns` is the sun's angle over 360° (`0` at noon).
|
||||
pub fn draws(
|
||||
/// A pack's custom sky layers, by world (`0` the Overworld, `1` the End).
|
||||
pub trait SkyLayers: Send + Sync {
|
||||
/// Every layer's texture: [`Draw::image`] indexes it.
|
||||
fn images(&self) -> &[Image];
|
||||
/// Whether `world` has layers.
|
||||
fn has(&self, world: i32) -> bool;
|
||||
/// This frame's layers of `world` into `out` (cleared first), in drawing order, eased by
|
||||
/// `seconds` since the last frame. `sun_turns` is the sun's angle over 360° (`0` at noon).
|
||||
fn draws(
|
||||
&self,
|
||||
world: i32,
|
||||
conditions: &Conditions,
|
||||
@@ -97,147 +86,30 @@ impl CustomSky {
|
||||
seconds: f32,
|
||||
state: &mut State,
|
||||
out: &mut Vec<Draw>,
|
||||
) {
|
||||
);
|
||||
}
|
||||
|
||||
/// No layers: the vanilla sky.
|
||||
struct NoLayers;
|
||||
|
||||
impl SkyLayers for NoLayers {
|
||||
fn images(&self) -> &[Image] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn has(&self, _: i32) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn draws(&self, _: i32, _: &Conditions, _: f32, _: f32, s: &mut State, out: &mut Vec<Draw>) {
|
||||
out.clear();
|
||||
let mut first = 0;
|
||||
let mut found = None;
|
||||
for (w, layers) in &self.worlds {
|
||||
if *w == world {
|
||||
found = Some(layers);
|
||||
break;
|
||||
}
|
||||
first += layers.len();
|
||||
}
|
||||
let Some(layers) = found else {
|
||||
state.world = None;
|
||||
return;
|
||||
};
|
||||
// A new world (or pack) starts each layer where its conditions put it: no fade in.
|
||||
let snap = state.world != Some(world) || state.eased.len() != layers.len();
|
||||
if snap {
|
||||
state.world = Some(world);
|
||||
state.eased.clear();
|
||||
state.eased.resize(layers.len(), Transition::default());
|
||||
}
|
||||
for (i, (layer, eased)) in layers.iter().zip(&mut state.eased).enumerate() {
|
||||
let target = if layer.shows(conditions) { 1.0 } else { 0.0 };
|
||||
let shown = if snap {
|
||||
eased.value = target;
|
||||
target
|
||||
} else {
|
||||
eased.step(target, seconds, layer.transition)
|
||||
};
|
||||
let brightness = shown * layer.brightness(conditions.day_time);
|
||||
if brightness <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let turns = layer.turns(conditions.day_time, sun_turns);
|
||||
let rotation = if turns == 0.0 {
|
||||
Mat4::IDENTITY
|
||||
} else {
|
||||
Mat4::from_axis_angle(Vec3::from_array(layer.axis), turns * std::f32::consts::TAU)
|
||||
};
|
||||
out.push(Draw {
|
||||
image: (first + i) as u16,
|
||||
blend: layer.blend,
|
||||
brightness,
|
||||
rotation,
|
||||
});
|
||||
}
|
||||
s.world = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_optifine::sky::Now;
|
||||
|
||||
fn pack(files: &[(&str, &str)]) -> (std::path::PathBuf, CustomSky) {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"horizon-custom-sky-{}-{}",
|
||||
std::process::id(),
|
||||
files.len()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let dir = root.join("assets/minecraft/optifine/sky/world0");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let png = {
|
||||
let mut bytes = Vec::new();
|
||||
let mut encoder = png::Encoder::new(&mut bytes, 6, 4);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().unwrap();
|
||||
writer
|
||||
.write_image_data(&[255, 0, 0, 255].repeat(24))
|
||||
.unwrap();
|
||||
writer.finish().unwrap();
|
||||
bytes
|
||||
};
|
||||
for (name, text) in files {
|
||||
std::fs::write(dir.join(format!("{name}.properties")), text).unwrap();
|
||||
std::fs::write(dir.join(format!("{name}.png")), &png).unwrap();
|
||||
}
|
||||
let sky = CustomSky::load(&root);
|
||||
(root, sky)
|
||||
}
|
||||
|
||||
fn noon(biome: &str) -> Conditions<'_> {
|
||||
Conditions {
|
||||
day_time: 6000,
|
||||
weather: Now::Clear,
|
||||
biome,
|
||||
y: 64,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_ease_in_at_a_biome_border_but_not_on_arrival() {
|
||||
let (root, sky) = pack(&[
|
||||
("sky1", "biomes=desert\ntransition=2"),
|
||||
("sky2", "blend=alpha\nrotate=false"),
|
||||
]);
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
assert!(sky.has(0) && !sky.has(1));
|
||||
assert_eq!(sky.images[0].width, 6);
|
||||
let (mut state, mut out) = (State::default(), Vec::new());
|
||||
sky.draws(
|
||||
0,
|
||||
&noon("minecraft:desert"),
|
||||
0.0,
|
||||
0.05,
|
||||
&mut state,
|
||||
&mut out,
|
||||
);
|
||||
assert_eq!(
|
||||
out.iter().map(|d| d.brightness).collect::<Vec<_>>(),
|
||||
[1.0, 1.0]
|
||||
);
|
||||
// Out of the desert: the first layer fades over its two seconds.
|
||||
sky.draws(0, &noon("minecraft:plains"), 0.0, 0.5, &mut state, &mut out);
|
||||
assert_eq!(out[0].brightness, 0.75);
|
||||
assert_eq!(out[1].blend, Blend::Alpha);
|
||||
sky.draws(0, &noon("minecraft:plains"), 0.0, 2.0, &mut state, &mut out);
|
||||
assert_eq!(out.len(), 1, "a dark layer is not drawn");
|
||||
assert_eq!(out[0].image, 1);
|
||||
// Into the End, which has no layers, and back: no fade.
|
||||
sky.draws(1, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
|
||||
assert!(out.is_empty());
|
||||
sky.draws(0, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
|
||||
assert_eq!(out.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_turns_west_like_the_sun() {
|
||||
let (root, sky) = pack(&[("sky1", "")]);
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
let (mut state, mut out) = (State::default(), Vec::new());
|
||||
// A quarter turn after noon (sunset): the skybox's zenith has gone down to the west.
|
||||
let c = Conditions {
|
||||
day_time: 12000,
|
||||
..noon("minecraft:plains")
|
||||
};
|
||||
sky.draws(0, &c, 0.25, 0.0, &mut state, &mut out);
|
||||
let up = out[0].rotation.transform_vector3(Vec3::Y);
|
||||
assert!((up - Vec3::NEG_X).length() < 1e-5, "{up}");
|
||||
}
|
||||
/// The custom sky layers of the overlay at `root`, from the installed pack formats.
|
||||
pub fn load(root: &Path) -> Arc<dyn SkyLayers> {
|
||||
crate::pack_hooks::formats()
|
||||
.custom_sky(root)
|
||||
.unwrap_or_else(|| Arc::new(NoLayers))
|
||||
}
|
||||
@@ -305,6 +305,9 @@ pub(crate) struct Colormap {
|
||||
/// (`temperature`, `downfall`, `biome_id`) blend: Polytone 6.8.6's water by `biome_id`
|
||||
/// fades over a biome edge.
|
||||
pub(crate) biome_blend: bool,
|
||||
/// Its axes read nothing that changes up a column of one biome (no `o.y`): a section of
|
||||
/// one biome can sample it once per column.
|
||||
pub(crate) column: bool,
|
||||
width: u32,
|
||||
height: u32,
|
||||
rgba: Vec<u8>,
|
||||
@@ -323,6 +326,18 @@ enum Axis {
|
||||
}
|
||||
|
||||
impl Axis {
|
||||
/// Whether the axis reads only what stays the same up a column of one biome.
|
||||
fn column(&self) -> bool {
|
||||
use horizon_polytone::expr::Root;
|
||||
let flat = |root: Root, name: &str| match root {
|
||||
Root::Object => matches!(name, "x" | "z" | "biome" | "temperature" | "downfall"),
|
||||
Root::Random => name == "noise",
|
||||
_ => false,
|
||||
};
|
||||
match self {
|
||||
Self::Expr(program) | Self::BiomeId(_, program) => program.reads_only(&flat),
|
||||
}
|
||||
}
|
||||
fn value(&self, host: &mut dyn horizon_polytone::expr::Host) -> Option<f64> {
|
||||
match self {
|
||||
Self::Expr(program) => Some(program.eval(host).ok()?.num()),
|
||||
@@ -373,6 +388,23 @@ fn biome_id_mapper(pack_root: &Path, json: &Json) -> HashMap<String, f64> {
|
||||
}
|
||||
|
||||
impl Colormap {
|
||||
/// One colour wherever it samples (a `biome_colors` flat colour, RGB).
|
||||
pub(crate) fn flat(rgb: u32) -> Self {
|
||||
let zero = || Axis::Expr(horizon_polytone::expr::Program::compile("0").expect("a number"));
|
||||
let [_, r, g, b] = rgb.to_be_bytes();
|
||||
Self {
|
||||
x: zero(),
|
||||
y: zero(),
|
||||
triangular: false,
|
||||
rounds: true,
|
||||
biome_blend: false,
|
||||
column: true,
|
||||
width: 1,
|
||||
height: 1,
|
||||
rgba: vec![r, g, b, 255],
|
||||
}
|
||||
}
|
||||
|
||||
/// `assets/<namespace>/polytone/colormaps/<path>.png` and its optional `.json` (without
|
||||
/// one, the axes are the biome's temperature and downfall).
|
||||
pub(crate) fn load(pack_root: &Path, id: &str) -> Option<Self> {
|
||||
@@ -424,9 +456,11 @@ impl Colormap {
|
||||
axis("x_axis", "o.temperature")?,
|
||||
axis("y_axis", "o.downfall")?,
|
||||
);
|
||||
let column = [&x, &y].iter().all(|axis| axis.column());
|
||||
Some(Self {
|
||||
x,
|
||||
y,
|
||||
column,
|
||||
biome_blend: json["biome_blend"].as_bool().unwrap_or(by_biome),
|
||||
triangular: json["triangular"].as_bool() == Some(true),
|
||||
rounds: json["rounds"].as_bool() != Some(false),
|
||||
|
||||
@@ -482,13 +482,16 @@ impl Extras {
|
||||
true
|
||||
}
|
||||
/// Written only once something differs from the defaults, so a vanilla
|
||||
/// setup gains no file.
|
||||
/// setup gains no file. The settings stores of this file follow these lines
|
||||
/// (`horizon_hooks::settings`).
|
||||
pub fn save(&self, dir: &Path) {
|
||||
let path = dir.join(FILE);
|
||||
if *self == Self::default() && !path.exists() {
|
||||
let mut text = self.serialize();
|
||||
let stores_default = horizon_hooks::settings::engine::append(FILE, &mut text);
|
||||
if *self == Self::default() && stores_default && !path.exists() {
|
||||
return;
|
||||
}
|
||||
let _ = fs::write(path, self.serialize());
|
||||
let _ = fs::write(path, text);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! The Horizon Extras page, on the settings shell (`settings_shell`): which rows it has and what
|
||||
//! pressing them does. Each one's value and what it does stay in `extras::Item`.
|
||||
//! pressing them does. Each one's value and what it does stay in `extras::Item`; the sections crates
|
||||
//! add (`horizon_hooks::pages::Place::Extras`) keep theirs.
|
||||
use crate::{
|
||||
extras::{self, Item},
|
||||
lang,
|
||||
@@ -7,6 +8,7 @@ use crate::{
|
||||
settings::Settings,
|
||||
settings_shell::{self as shell, Binding, Group, Output, Row, Screen, Sheet, Value},
|
||||
};
|
||||
use horizon_hooks::pages::{self as added, Act, Label};
|
||||
|
||||
const TITLE: &str = "horizon.pages.extras";
|
||||
const ABOUT: &str = "horizon.pages.extras.tooltip";
|
||||
@@ -31,6 +33,121 @@ pub(crate) enum ExtraRow {
|
||||
/// It isn't installed: where to get it. A note, not a button: Options > Mods already has the
|
||||
/// one way into the Mods page (`every_setting_and_page_is_reached_from_one_place`).
|
||||
ZoomInstall,
|
||||
/// Row `.1` of the section registered page `.0` adds (`Place::Extras`).
|
||||
Added(usize, usize),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// A test's own pages, instead of the game's registered ones: each test runs on its thread.
|
||||
static TEST_PAGES: std::cell::Cell<Option<&'static [&'static dyn added::Page]>> =
|
||||
const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
/// The pages crates registered, all places.
|
||||
fn pages() -> &'static [&'static dyn added::Page] {
|
||||
#[cfg(test)]
|
||||
if let Some(pages) = TEST_PAGES.get() {
|
||||
return pages;
|
||||
}
|
||||
added::engine::all()
|
||||
}
|
||||
|
||||
/// The sections of this page, by their index among the registered pages.
|
||||
fn sections() -> impl Iterator<Item = (usize, &'static dyn added::Page)> {
|
||||
let pages = pages().iter().copied().enumerate();
|
||||
pages.filter(|(_, page)| page.place() == added::Place::Extras)
|
||||
}
|
||||
|
||||
fn label_text(label: Label) -> String {
|
||||
match label {
|
||||
Label::Key(key) => lang::get(key),
|
||||
Label::Value(text) => text,
|
||||
}
|
||||
}
|
||||
|
||||
/// The page's group named `name`, made at the end if it has none.
|
||||
fn group_named<'a>(
|
||||
groups: &'a mut Vec<Group<ExtraRow>>,
|
||||
name: &'static str,
|
||||
) -> &'a mut Group<ExtraRow> {
|
||||
let at = groups.iter().position(|group| group.name == name);
|
||||
let at = at.unwrap_or_else(|| {
|
||||
groups.push(Group {
|
||||
name,
|
||||
rows: Vec::new(),
|
||||
});
|
||||
groups.len() - 1
|
||||
});
|
||||
&mut groups[at]
|
||||
}
|
||||
|
||||
/// The sections' rows, in the groups their titles and headings name.
|
||||
fn add_sections(groups: &mut Vec<Group<ExtraRow>>) {
|
||||
for (page, section) in sections() {
|
||||
let mut rows = Vec::new();
|
||||
section.rows(&mut rows);
|
||||
let mut name = section.title();
|
||||
for (r, row) in rows.into_iter().enumerate() {
|
||||
let id = ExtraRow::Added(page, r);
|
||||
let row = match row {
|
||||
added::Row::Heading(key) => {
|
||||
name = key;
|
||||
continue;
|
||||
}
|
||||
added::Row::Note(text) => Row::new(id, text, Value::Note).live(false),
|
||||
added::Row::Toggle { label, on } => {
|
||||
Row::new(id, lang::get(label), Value::Switch(on))
|
||||
}
|
||||
added::Row::Cycle { label, value } => {
|
||||
Row::new(id, lang::get(label), Value::Choice(label_text(value)))
|
||||
}
|
||||
added::Row::Slider { label, value, text } => {
|
||||
let position = value.clamp(0.0, 1.0);
|
||||
let text = label_text(text);
|
||||
let value = Value::Slider {
|
||||
position,
|
||||
steps: None,
|
||||
text,
|
||||
};
|
||||
Row::new(id, lang::get(label), value)
|
||||
}
|
||||
added::Row::Button { label } => Row::new(id, lang::get(label), Value::Press),
|
||||
added::Row::Key { label, key } => {
|
||||
let key = crate::keybinds::display_name(&key).plain();
|
||||
Row::new(id, lang::get(label), Value::Read(key))
|
||||
}
|
||||
// A row kind newer than this engine.
|
||||
_ => continue,
|
||||
};
|
||||
let row = match section.tooltip(r) {
|
||||
Some(about) => row.about(label_text(about)),
|
||||
None => row,
|
||||
};
|
||||
group_named(groups, name).rows.push(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Row `r` of registered page `page` was acted on: the options screen saves when it closes.
|
||||
fn act(page: usize, r: usize, act: Act) -> Vec<Output> {
|
||||
let Some(section) = pages().get(page) else {
|
||||
return Vec::new();
|
||||
};
|
||||
section.act(r, act);
|
||||
vec![Output::Event(Event::Changed)]
|
||||
}
|
||||
|
||||
/// Whether row `r` of registered page `page` steps (a switch or a cycle).
|
||||
fn steps(page: usize, r: usize) -> bool {
|
||||
let mut rows = Vec::new();
|
||||
if let Some(section) = pages().get(page) {
|
||||
section.rows(&mut rows);
|
||||
}
|
||||
matches!(
|
||||
rows.get(r),
|
||||
Some(added::Row::Toggle { .. } | added::Row::Cycle { .. })
|
||||
)
|
||||
}
|
||||
|
||||
fn item_row(item: Item, s: &Settings) -> Row<ExtraRow> {
|
||||
@@ -85,9 +202,14 @@ impl Screen for ExtrasScreen {
|
||||
fn note_lines(&self) -> usize {
|
||||
NOTE_LINES
|
||||
}
|
||||
/// The rows read the extras, the mods and whether cosmetics are offered.
|
||||
/// The rows read the extras, the mods, whether cosmetics are offered and the sections' rows.
|
||||
fn stamp(&self, s: &Settings) -> u64 {
|
||||
shell::stamp_of(&(&s.extras, &s.mods, horizon_cosmetics::enabled()))
|
||||
let mut added = Vec::new();
|
||||
for (_, section) in sections() {
|
||||
section.rows(&mut added);
|
||||
}
|
||||
let cosmetics = horizon_cosmetics::enabled();
|
||||
shell::stamp_of(&(&s.extras, &s.mods, cosmetics, added))
|
||||
}
|
||||
fn sheets(&self, s: &Settings) -> Vec<Sheet<(), ExtraRow>> {
|
||||
let mut groups = vec![Group {
|
||||
@@ -107,6 +229,7 @@ impl Screen for ExtrasScreen {
|
||||
}
|
||||
}
|
||||
}
|
||||
add_sections(&mut groups);
|
||||
vec![Sheet::new((), lang::get(TITLE), lang::get(ABOUT), groups)]
|
||||
}
|
||||
fn press(&mut self, s: &mut Settings, row: ExtraRow, back: bool) -> Vec<Output> {
|
||||
@@ -125,16 +248,29 @@ impl Screen for ExtrasScreen {
|
||||
Output::Event(Event::NativeSettings(i)),
|
||||
],
|
||||
ExtraRow::ZoomInstall => Vec::new(),
|
||||
ExtraRow::Added(page, r) => {
|
||||
act(page, r, if back { Act::PressBack } else { Act::Press })
|
||||
}
|
||||
}
|
||||
}
|
||||
fn slide(&mut self, _s: &mut Settings, row: ExtraRow, position: f32) {
|
||||
if let ExtraRow::Added(page, r) = row {
|
||||
act(page, r, Act::Slide(position));
|
||||
}
|
||||
}
|
||||
/// With the wheel extra on, the wheel over an item changes it, without a click; the mod's rows
|
||||
/// let the list scroll.
|
||||
fn wheel_step(&mut self, s: &mut Settings, row: ExtraRow, back: bool) -> Option<Vec<Output>> {
|
||||
let ExtraRow::Item(item) = row else {
|
||||
return None;
|
||||
};
|
||||
item.cycle(&mut s.extras, back);
|
||||
Some(vec![Output::Event(Event::Changed)])
|
||||
match row {
|
||||
ExtraRow::Item(item) => {
|
||||
item.cycle(&mut s.extras, back);
|
||||
Some(vec![Output::Event(Event::Changed)])
|
||||
}
|
||||
ExtraRow::Added(page, r) if steps(page, r) => {
|
||||
Some(act(page, r, if back { Act::PressBack } else { Act::Press }))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,4 +428,148 @@ mod tests {
|
||||
shell.key(&mut s, K::ArrowDown, None, false, size);
|
||||
assert_eq!(shell.focused_row(), Some(first));
|
||||
}
|
||||
|
||||
/// A section with a switch in the page's Multiplayer group, then a heading of its own with a
|
||||
/// number that steps either way, a note and a volume.
|
||||
struct Section {
|
||||
on: std::sync::Mutex<bool>,
|
||||
step: std::sync::Mutex<i32>,
|
||||
volume: std::sync::Mutex<f32>,
|
||||
}
|
||||
impl added::Page for Section {
|
||||
fn id(&self) -> &'static str {
|
||||
"test.section"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"horizon.extras.header.multiplayer"
|
||||
}
|
||||
fn place(&self) -> added::Place {
|
||||
added::Place::Extras
|
||||
}
|
||||
fn rows(&self, rows: &mut Vec<added::Row>) {
|
||||
rows.push(added::Row::Toggle {
|
||||
label: "test.section.switch",
|
||||
on: *self.on.lock().unwrap(),
|
||||
});
|
||||
rows.push(added::Row::Heading("test.section.heading"));
|
||||
rows.push(added::Row::Cycle {
|
||||
label: "test.section.step",
|
||||
value: Label::Value(self.step.lock().unwrap().to_string()),
|
||||
});
|
||||
rows.push(added::Row::Note("a note".into()));
|
||||
rows.push(added::Row::Slider {
|
||||
label: "test.section.volume",
|
||||
value: *self.volume.lock().unwrap(),
|
||||
text: Label::Value(String::new()),
|
||||
});
|
||||
}
|
||||
fn act(&self, row: usize, act: Act) {
|
||||
match (row, act) {
|
||||
(0, Act::Press | Act::PressBack) => {
|
||||
let mut on = self.on.lock().unwrap();
|
||||
*on = !*on;
|
||||
}
|
||||
(2, Act::Press) => *self.step.lock().unwrap() += 1,
|
||||
(2, Act::PressBack) => *self.step.lock().unwrap() -= 1,
|
||||
(4, Act::Slide(v)) => *self.volume.lock().unwrap() = v,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A page for Horizon Settings: never on Extras.
|
||||
struct Elsewhere;
|
||||
impl added::Page for Elsewhere {
|
||||
fn id(&self) -> &'static str {
|
||||
"test.elsewhere"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"test.elsewhere"
|
||||
}
|
||||
fn place(&self) -> added::Place {
|
||||
added::Place::HorizonSettings
|
||||
}
|
||||
fn rows(&self, rows: &mut Vec<added::Row>) {
|
||||
rows.push(added::Row::Button {
|
||||
label: "test.elsewhere.button",
|
||||
});
|
||||
}
|
||||
fn act(&self, _: usize, _: Act) {}
|
||||
}
|
||||
|
||||
fn with_section() -> &'static Section {
|
||||
let section: &'static Section = Box::leak(Box::new(Section {
|
||||
on: false.into(),
|
||||
step: 0.into(),
|
||||
volume: 0.5.into(),
|
||||
}));
|
||||
let pages: &'static [&'static dyn added::Page] = Box::leak(Box::new([
|
||||
&Elsewhere as &dyn added::Page,
|
||||
section as &dyn added::Page,
|
||||
]));
|
||||
TEST_PAGES.set(Some(pages));
|
||||
section
|
||||
}
|
||||
|
||||
/// The section's first row joins the page's own Multiplayer group, after its rows; its heading
|
||||
/// starts a group after the page's; a page for elsewhere adds nothing.
|
||||
#[test]
|
||||
fn a_section_joins_the_group_its_title_names_and_heads_its_own() {
|
||||
with_section();
|
||||
let s = Settings::default();
|
||||
let sheets = ExtrasScreen.sheets(&s);
|
||||
let groups = &sheets[0].groups;
|
||||
let ids = |name: &str| -> Vec<ExtraRow> {
|
||||
let group = groups.iter().find(|g| g.name == name).unwrap();
|
||||
group.rows.iter().map(|r| r.id).collect()
|
||||
};
|
||||
let multiplayer = ids("horizon.extras.header.multiplayer");
|
||||
assert_eq!(multiplayer.last(), Some(&ExtraRow::Added(1, 0)));
|
||||
assert_eq!(groups.last().unwrap().name, "test.section.heading");
|
||||
let own = [
|
||||
ExtraRow::Added(1, 2),
|
||||
ExtraRow::Added(1, 3),
|
||||
ExtraRow::Added(1, 4),
|
||||
];
|
||||
assert_eq!(ids("test.section.heading"), own);
|
||||
let all = rows_of(&s);
|
||||
assert!(!all.iter().any(|r| matches!(r.id, ExtraRow::Added(0, _))));
|
||||
}
|
||||
|
||||
/// A click flips the switch, a Shift click steps the number back, the wheel steps it, and the
|
||||
/// note takes no click; the rows show what the section holds after each.
|
||||
#[test]
|
||||
fn a_sections_rows_act_on_it() {
|
||||
let section = with_section();
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<ExtrasScreen>::default();
|
||||
let switch = ExtraRow::Added(1, 0);
|
||||
let at = shell.point_at(&s, SIZE, switch);
|
||||
assert!(
|
||||
!shell
|
||||
.click(&mut s, at, SIZE, false, false, false)
|
||||
.is_empty()
|
||||
);
|
||||
assert!(*section.on.lock().unwrap());
|
||||
let value = |s: &Settings, id| rows_of(s).into_iter().find(|r| r.id == id).unwrap().value;
|
||||
assert_eq!(value(&s, switch), Value::Switch(true));
|
||||
let step = ExtraRow::Added(1, 2);
|
||||
let mut screen = ExtrasScreen;
|
||||
screen.press(&mut s, step, true);
|
||||
assert_eq!(value(&s, step), Value::Choice("-1".into()));
|
||||
assert!(screen.wheel_step(&mut s, step, false).is_some());
|
||||
assert_eq!(*section.step.lock().unwrap(), 0);
|
||||
assert_eq!(
|
||||
screen.wheel_step(&mut s, ExtraRow::Added(1, 3), false),
|
||||
None
|
||||
);
|
||||
let note = shell.point_at(&s, SIZE, ExtraRow::Added(1, 3));
|
||||
assert!(
|
||||
shell
|
||||
.click(&mut s, note, SIZE, false, false, false)
|
||||
.is_empty()
|
||||
);
|
||||
screen.slide(&mut s, ExtraRow::Added(1, 4), 0.25);
|
||||
assert_eq!(*section.volume.lock().unwrap(), 0.25);
|
||||
}
|
||||
}
|
||||
@@ -231,8 +231,8 @@ pub struct FogInputs<'a> {
|
||||
/// `GameRenderer.nightVisionScale` while Night Vision is on without
|
||||
/// Darkness, else 0 (`FogRenderer.computeFogColor`'s brightenFactor).
|
||||
pub night_vision: f32,
|
||||
/// The packs' Polytone `environment` fog settings.
|
||||
pub environment: horizon_polytone::colors::Environment,
|
||||
/// The packs' `environment` fog settings (Polytone's).
|
||||
pub environment: crate::pack_palette::Environment,
|
||||
}
|
||||
|
||||
/// `FogRenderer.setupFog` + `computeFogColor`.
|
||||
@@ -581,7 +581,7 @@ mod tests {
|
||||
let tables = FogTables {
|
||||
water_fog_end: vec![1.0],
|
||||
};
|
||||
let environment = horizon_polytone::colors::Environment {
|
||||
let environment = crate::pack_palette::Environment {
|
||||
powder_snow_fog: Some(0xFF0000),
|
||||
blindness_fog_distance: 40.0,
|
||||
water_fog_brightening: 0.0,
|
||||
@@ -613,7 +613,7 @@ mod tests {
|
||||
}
|
||||
fn inputs_with<'a>(
|
||||
input: &FogInputs<'a>,
|
||||
environment: horizon_polytone::colors::Environment,
|
||||
environment: crate::pack_palette::Environment,
|
||||
) -> FogInputs<'a> {
|
||||
FogInputs {
|
||||
environment,
|
||||
|
||||
@@ -179,7 +179,7 @@ fn packed_wanted() -> bool {
|
||||
std::env::var("HORIZON_PACKED_QUADS").as_deref() != Ok("0")
|
||||
}
|
||||
|
||||
mod animate;
|
||||
pub(crate) mod animate;
|
||||
mod arena;
|
||||
pub mod capture;
|
||||
pub mod compositor;
|
||||
@@ -194,6 +194,7 @@ mod frame_gen;
|
||||
mod gpu_culling;
|
||||
mod hdr;
|
||||
mod hdr_icc;
|
||||
mod hdr_start;
|
||||
mod hdr_stats;
|
||||
pub(crate) mod hud_coverage;
|
||||
pub use hdr::{Calibration as HdrCalibration, Output as HdrOutput};
|
||||
@@ -215,6 +216,7 @@ mod occlusion;
|
||||
mod oit;
|
||||
mod oit_features;
|
||||
pub(crate) mod output;
|
||||
mod output_apply;
|
||||
mod pack_atlas;
|
||||
mod pack_center_depth;
|
||||
pub mod pack_draw;
|
||||
@@ -237,6 +239,7 @@ mod reuse;
|
||||
mod stars;
|
||||
mod surface_setup;
|
||||
use surface_setup::{AdapterChoice, AdapterFailure, open_adapter, pick_adapter, set_up_surface};
|
||||
mod off_page;
|
||||
pub mod submit;
|
||||
mod sunrise_fan;
|
||||
mod timing;
|
||||
@@ -248,6 +251,7 @@ mod wgsl_prefix;
|
||||
mod world_post;
|
||||
pub mod world_primitives;
|
||||
mod world_warp;
|
||||
use off_page::move_off_page;
|
||||
|
||||
/// A section's instance: its camera-relative origin and, in `w`, the step of
|
||||
/// its fixed-point UVs (`terrain.wgsl`'s `terrain_uv`).
|
||||
@@ -724,62 +728,6 @@ impl Part {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Renderer::compact_arena`'s and `shrink_translucent`'s moves: what the meshes
|
||||
/// hold on `page` of `arena` (`part`) goes to its other pages in video memory,
|
||||
/// until the next range would pass `budget` bytes (it keeps what is left; the
|
||||
/// first range moves whatever its size, so none keeps the page closed), the
|
||||
/// copies into `encoder`, the ranges' flat draw ids (`flat`) and, in the
|
||||
/// section arena, GPU culling's records (`records`) along. False when the other
|
||||
/// pages have no room left.
|
||||
fn move_off_page<'a>(
|
||||
arena: &mut arena::Arena,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
mut flat: Option<(&mut flat::Flat, &wgpu::Device, &mut upload::Uploads)>,
|
||||
mut records: Option<&mut gpu_culling::Records>,
|
||||
meshes: impl Iterator<Item = &'a mut SectionGpu>,
|
||||
page: u32,
|
||||
part: Part,
|
||||
budget: &mut u64,
|
||||
) -> bool {
|
||||
debug_assert!(
|
||||
records.is_none() || matches!(part, Part::Section { .. }),
|
||||
"records follow the section arena's ranges"
|
||||
);
|
||||
let mut moved = false;
|
||||
for s in meshes {
|
||||
// Nothing of a mesh here: split, one without solid quads (its (0, 0) is no
|
||||
// range of page 0); in the other arenas, one without translucent quads.
|
||||
let Some((_, start, vertices)) = part.of(s).filter(|r| r.0 == page) else {
|
||||
continue;
|
||||
};
|
||||
let bytes = vertices * arena.stride();
|
||||
if moved && bytes > *budget {
|
||||
break;
|
||||
}
|
||||
let Some(to) = arena.alloc_moved(vertices) else {
|
||||
return false;
|
||||
};
|
||||
arena.copy(encoder, (page, start), to, vertices);
|
||||
arena.release(page, start, vertices);
|
||||
if let (Some((flat, device, uploads)), Some(id)) = (&mut flat, s.flat_id) {
|
||||
let key = match part {
|
||||
Part::Translucent => to.0 + flat::TRANSLUCENT_KEYS,
|
||||
_ => to.0,
|
||||
};
|
||||
flat.place(device, encoder, uploads, id, key, to.1, vertices);
|
||||
}
|
||||
part.set(s, to);
|
||||
// Its solid quads start the range in either layout.
|
||||
if let (Part::Section { .. }, Some(records), Some(id)) =
|
||||
(part, records.as_deref_mut(), s.gpu_id)
|
||||
{
|
||||
records.relocate(id, to.0, u64::from(s.solid_base()));
|
||||
}
|
||||
*budget = budget.saturating_sub(bytes);
|
||||
moved = true;
|
||||
}
|
||||
true
|
||||
}
|
||||
/// Starts moving a page of a large arena far larger than its ranges in use
|
||||
/// (`Arena::oversized`) into a new page of the size they get (`Arena::open_page`),
|
||||
/// for `Renderer::shrink_translucent`: the page closes and `move_off_page` empties
|
||||
@@ -1327,7 +1275,7 @@ pub struct Renderer {
|
||||
celestial_buffer: wgpu::Buffer,
|
||||
end_sky: end_sky::EndSky,
|
||||
stars: stars::Stars,
|
||||
/// OptiFine custom sky layers (`crate::custom_sky`), and the frame's from the game.
|
||||
/// Custom sky layers (`crate::custom_sky`), and the frame's from the game.
|
||||
custom_sky: custom_sky::CustomSky,
|
||||
custom_sky_draws: Vec<crate::custom_sky::Draw>,
|
||||
/// The glowing entities' outline (`LevelRenderer.entityOutlineTarget`).
|
||||
@@ -1526,6 +1474,10 @@ pub struct Renderer {
|
||||
emission_stats: hdr_stats::EmissionStats,
|
||||
/// What this renderer's destination offers and its device draws.
|
||||
output_caps: output::OutputCapabilities,
|
||||
/// An output that failed to apply, not tried again until something it needs changes.
|
||||
output_rejected: Option<output_apply::Rejected>,
|
||||
/// Since when Auto keeps HDR on through the display's loss of its report.
|
||||
auto_off_since: Option<std::time::Instant>,
|
||||
/// The output applied (`update_output`), with the frame every pipeline that draws it
|
||||
/// targets: the swapchain's 8-bit format, or `Rgba16Unorm` when the renderer was made with
|
||||
/// HDR output on and Smooth Gradients and the device draws and blends into it, or
|
||||
@@ -1538,6 +1490,8 @@ pub struct Renderer {
|
||||
/// The marker file of an HDR output that has not presented a frame yet
|
||||
/// (`graphics::HDR_MARKER`).
|
||||
hdr_marker: Option<std::path::PathBuf>,
|
||||
/// The window's graphics driver (`hdr_start`); `None` offscreen.
|
||||
hdr_driver: Option<String>,
|
||||
/// The HDR Lighting setting, kept for the HDR output made when HDR turns on.
|
||||
hdr_lighting_setting: bool,
|
||||
/// The shader pack's post-processing, and the dimension (legacy id) it was loaded for.
|
||||
@@ -2985,11 +2939,13 @@ impl Renderer {
|
||||
adapter.get_downlevel_capabilities().flags,
|
||||
adapter.get_texture_format_features(hud_coverage::FORMAT),
|
||||
);
|
||||
let hdr_driver = surface.is_some().then(|| output::window_driver(&info));
|
||||
let output_caps = output::OutputCapabilities {
|
||||
hdr: hdr_output,
|
||||
wide_frame: wide_ok,
|
||||
float_frame: float_ok,
|
||||
hud_layer: coverage_ok,
|
||||
held: output::held(settings, hdr_driver.as_deref()),
|
||||
};
|
||||
let frame = output::resolve_frame(settings, request, output_caps, display, config.format);
|
||||
let (frame_format, hud_coverage, overlay_format) =
|
||||
@@ -4239,16 +4195,13 @@ impl Renderer {
|
||||
CRUMBLING_BLEND,
|
||||
false,
|
||||
);
|
||||
// RenderPipelines.ENTITY_SHADOW: translucent, back-face culled, depth
|
||||
// test without writes. The quads lie exactly on the block tops like
|
||||
// vanilla's, facing +Y so looking up from below culls them, but
|
||||
// terrain and shadows are separate draw paths, so their fp32 depth
|
||||
// interpolations disagree by a few dozen ulps of the fragment depth
|
||||
// and the shadow's rim z-fights with the block top (speckle that
|
||||
// re-rolls as the camera drifts a subpixel). A constant bias of 128
|
||||
// ulps covers that spread at any resolution while staying far below
|
||||
// the 0.001 a model's feet sit higher, so shadows never bleed onto
|
||||
// the feet.
|
||||
// RenderPipelines.ENTITY_SHADOW: translucent, back-face culled, depth test without writes.
|
||||
// The quads lie exactly on the block tops like vanilla's, facing +Y so looking up from
|
||||
// below culls them, but terrain and shadows are separate draw paths, so their fp32 depth
|
||||
// interpolations disagree by a few dozen ulps of the fragment depth and the shadow's rim
|
||||
// z-fights with the block top (speckle that re-rolls as the camera drifts a subpixel). A
|
||||
// constant bias of 128 ulps covers that spread at any resolution while staying far below
|
||||
// the 0.001 a model's feet sit higher, so shadows never bleed onto the feet.
|
||||
let shadow_blob_pipeline = entity_pipeline_with(
|
||||
"Entity shadows",
|
||||
Some(wgpu::Face::Back),
|
||||
@@ -5346,10 +5299,13 @@ impl Renderer {
|
||||
hdr_lighting_setting: true,
|
||||
emission_stats,
|
||||
output_caps,
|
||||
output_rejected: None,
|
||||
auto_off_since: None,
|
||||
// Nothing is presented as HDR yet: `update_output` applies the settings.
|
||||
output: output::resolve_output(settings, output_caps, display, frame, false),
|
||||
frame_kept_logged: false,
|
||||
hdr_marker: None,
|
||||
hdr_driver,
|
||||
pack_post: None,
|
||||
pack_loading: None,
|
||||
native: None,
|
||||
@@ -5507,18 +5463,18 @@ impl Renderer {
|
||||
))
|
||||
});
|
||||
}
|
||||
/// The pack's OptiFine custom sky layer textures (`crate::custom_sky::CustomSky::load`).
|
||||
pub fn set_custom_sky(&mut self, pack: &crate::custom_sky::CustomSky) {
|
||||
/// The pack's custom sky layer textures (`crate::custom_sky::load`).
|
||||
pub fn set_custom_sky(&mut self, pack: &dyn crate::custom_sky::SkyLayers) {
|
||||
self.custom_sky.set_pack(&self.device, &self.queue, pack);
|
||||
}
|
||||
/// The custom sky layers the next frame draws (`CustomSky::draws`): the game refills it each
|
||||
/// The custom sky layers the next frame draws (`SkyLayers::draws`): the game refills it each
|
||||
/// frame, empty when the world's sky has none.
|
||||
pub fn custom_sky_draws(&mut self) -> &mut Vec<crate::custom_sky::Draw> {
|
||||
&mut self.custom_sky_draws
|
||||
}
|
||||
/// The resource packs' `core/rendertype_lines` (`HudAtlas::lines_shader`) for the block
|
||||
/// outline, or the built-in one.
|
||||
pub fn set_lines_shader(&mut self, program: Option<&horizon_shaders::CoreProgram>) {
|
||||
pub fn set_lines_shader(&mut self, program: Option<&crate::core_shader::CoreProgram>) {
|
||||
let format = self.output.frame.format;
|
||||
self.core_lines =
|
||||
program.and_then(|program| core_lines::CoreLines::new(&self.device, format, program));
|
||||
@@ -5527,8 +5483,8 @@ impl Renderer {
|
||||
/// `stars_shader`), or the built-in sky and stars.
|
||||
pub fn set_sky_shaders(
|
||||
&mut self,
|
||||
disc: Option<&horizon_shaders::CoreProgram>,
|
||||
stars: Option<&horizon_shaders::CoreProgram>,
|
||||
disc: Option<&crate::core_shader::CoreProgram>,
|
||||
stars: Option<&crate::core_shader::CoreProgram>,
|
||||
) {
|
||||
self.core_sky = core_sky::CoreSky::new(&self.device, self.output.frame.format, disc, stars);
|
||||
}
|
||||
@@ -5631,6 +5587,7 @@ impl Renderer {
|
||||
}
|
||||
self.surface_unconfigured = configured.is_err();
|
||||
self.surface = Some(Arc::new(surface));
|
||||
self.surface_recreated();
|
||||
Ok(())
|
||||
}
|
||||
/// The world's depth, which occlusion culling's test and post effects may read.
|
||||
@@ -7083,18 +7040,6 @@ impl Renderer {
|
||||
horizon_hdr::set_metadata(&self.device, surface, hdr.declared_peak(), hdr.black);
|
||||
}
|
||||
}
|
||||
/// The swapchain's configuration: `config` (what every pipeline draws in), or
|
||||
/// HDR10 or scRGB while HDR output is on.
|
||||
fn surface_config(&self) -> wgpu::SurfaceConfiguration {
|
||||
let mut config = self.config.clone();
|
||||
if let Some(hdr) = &self.hdr {
|
||||
config.format = hdr.output.format();
|
||||
config.view_formats = vec![hdr.output.format()];
|
||||
config.color_space = hdr.output.color_space();
|
||||
config.usage = wgpu::TextureUsages::RENDER_ATTACHMENT;
|
||||
}
|
||||
config
|
||||
}
|
||||
/// This renderer's output as applied, with what its destination offers and reports.
|
||||
pub fn output(&self) -> &output::EffectiveOutput {
|
||||
&self.output
|
||||
@@ -7149,106 +7094,11 @@ impl Renderer {
|
||||
output::resolve_frame(
|
||||
settings,
|
||||
request,
|
||||
self.output_caps,
|
||||
self.reading,
|
||||
self.caps_for(settings),
|
||||
output_apply::graced(self.auto_off_since, self.reading, &self.output),
|
||||
self.config.format,
|
||||
)
|
||||
}
|
||||
/// Follows the selected mode even when Auto currently wants SDR. Off never asks
|
||||
/// the system about the display; selecting On or Auto starts a fresh watch.
|
||||
fn watch_hdr(&mut self, mode: crate::graphics::HdrMode) {
|
||||
if mode == crate::graphics::HdrMode::Off {
|
||||
self.display = None;
|
||||
self.display_fresh = false;
|
||||
self.reading.stop_watching();
|
||||
} else if self.display.is_none() {
|
||||
let (watch, now) =
|
||||
DisplayWatch::new(&self.window, self.surface.as_deref(), &self.adapter);
|
||||
self.display = Some(watch);
|
||||
self.refresh_hdr_offer();
|
||||
self.reading = now;
|
||||
self.display_fresh = true;
|
||||
}
|
||||
}
|
||||
/// What the window's surface offers now: Vulkan's offered formats can change with desktop
|
||||
/// HDR or the window's output.
|
||||
fn refresh_hdr_offer(&mut self) {
|
||||
if let Some(surface) = &self.surface {
|
||||
self.output_caps.hdr = hdr::output(&surface.get_capabilities(&self.adapter));
|
||||
}
|
||||
}
|
||||
/// HDR output (Horizon setting) as `settings` ask for it on this renderer's frame
|
||||
/// (`output::resolve_output`): HDR10 or scRGB at paper white, the world's highlights
|
||||
/// expanded up to the peak. Off where the surface can't present it. Offscreen
|
||||
/// (`hdr::offscreen_output`) the frame is drawn the same and its HDR screenshot taken,
|
||||
/// but nothing is presented, and Auto takes its defaults: no display reports.
|
||||
pub fn update_output(
|
||||
&mut self,
|
||||
settings: &crate::graphics::Graphics,
|
||||
marker: &std::path::Path,
|
||||
) {
|
||||
self.watch_hdr(settings.hdr);
|
||||
let next = output::resolve_output(
|
||||
settings,
|
||||
self.output_caps,
|
||||
self.reading,
|
||||
self.output.frame,
|
||||
self.surface.is_some() || self.offscreen.is_some(),
|
||||
);
|
||||
let [paper_white, peak, black] = next.nits;
|
||||
if next.encoding.is_some() != self.hdr.is_some() {
|
||||
// If the game dies before HDR output shows a frame, the marker is still there at
|
||||
// the next start, which then comes up with HDR off.
|
||||
if next.encoding.is_some() {
|
||||
if self.surface.is_some() {
|
||||
let _ = std::fs::write(marker, "HDR output was being switched on\n");
|
||||
self.hdr_marker = Some(marker.to_owned());
|
||||
}
|
||||
} else if let Some(marker) = self.hdr_marker.take() {
|
||||
let _ = std::fs::remove_file(marker);
|
||||
}
|
||||
let frame = self.output.frame;
|
||||
self.hdr = next.encoding.map(|output| {
|
||||
hdr::Hdr::new(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
frame.format,
|
||||
output,
|
||||
frame.hud_layer,
|
||||
)
|
||||
});
|
||||
if let Some(hdr) = &mut self.hdr {
|
||||
hdr.look = self.hdr_lighting_setting;
|
||||
}
|
||||
self.configure_surface();
|
||||
match next.encoding {
|
||||
Some(output) => log::info!(
|
||||
"Horizon renderer: HDR on: {} ({:?}), {paper_white}/{peak} nits",
|
||||
output.name(),
|
||||
output.format()
|
||||
),
|
||||
None => log::info!("Horizon renderer: HDR off"),
|
||||
}
|
||||
self.measure_emission();
|
||||
}
|
||||
if let Some(hdr) = &mut self.hdr {
|
||||
hdr.ui = next.ui_brightness;
|
||||
}
|
||||
if let Some(hdr) = &mut self.hdr
|
||||
&& (hdr.paper_white, hdr.peak, hdr.black) != (paper_white, peak, black)
|
||||
{
|
||||
hdr.paper_white = paper_white;
|
||||
hdr.peak = peak;
|
||||
hdr.black = black;
|
||||
self.send_hdr_metadata();
|
||||
}
|
||||
// An output of another encoding than the one presented doesn't replace it yet, only
|
||||
// Off does: the snapshot says what is presented.
|
||||
self.output = output::EffectiveOutput {
|
||||
encoding: self.hdr.as_ref().map(|hdr| hdr.output),
|
||||
..next
|
||||
};
|
||||
}
|
||||
/// Measures which pixels of each block sprite may shine (`hdr_stats`), at the current
|
||||
/// masks. Only with HDR output on: without it the frame's alpha is never read.
|
||||
pub fn measure_emission(&mut self) {
|
||||
@@ -7258,24 +7108,6 @@ impl Renderer {
|
||||
.measure(&self.device, &self.queue, &views, hdr.masks);
|
||||
}
|
||||
}
|
||||
/// Whether what the system reports for the display changed (another output, the
|
||||
/// player recalibrating the desktop): the settings' Auto values follow it then, so
|
||||
/// apply them again.
|
||||
pub fn display_changed(&mut self) -> bool {
|
||||
flush_display_log();
|
||||
if std::mem::take(&mut self.display_fresh) {
|
||||
return true;
|
||||
}
|
||||
let Some(watch) = self.display.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
let Some(now) = watch.changed(self.reading, self.surface.as_deref(), &self.adapter) else {
|
||||
return false;
|
||||
};
|
||||
self.refresh_hdr_offer();
|
||||
self.reading = now;
|
||||
true
|
||||
}
|
||||
/// The share of real frames frame generation shows a made frame before (1 while it
|
||||
/// keeps up), `None` while it isn't running.
|
||||
/// The posed models' store size (`SkinFrame::base`), with entity skinning.
|
||||
@@ -7661,7 +7493,7 @@ impl Renderer {
|
||||
) -> std::result::Result<Vec<String>, String> {
|
||||
let linear = pack
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.0.manifest.api == horizon_render_pipeline::Api::V0_2);
|
||||
.is_some_and(|p| p.0.manifest.api == horizon_shader_api::Api::V0_2);
|
||||
if (linear || self.output.frame.format == LINEAR_FRAME)
|
||||
&& linear != (self.output.frame.format == LINEAR_FRAME)
|
||||
&& !std::mem::replace(&mut self.frame_kept_logged, true)
|
||||
@@ -7714,7 +7546,7 @@ impl Renderer {
|
||||
inputs.declarations.push_str(
|
||||
"layout(set = 5, binding = 0) readonly buffer HzIndices { uint hz_indices[]; };\n",
|
||||
);
|
||||
horizon_render_pipeline::GeometryInputs {
|
||||
horizon_shaders::GeometryInputs {
|
||||
inputs,
|
||||
pull: pack_atlas::PULL.to_owned(),
|
||||
index: "hz_indices[@]".to_owned(),
|
||||
@@ -7737,7 +7569,7 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
|
||||
fn pack_terrain_inputs(&mut self) -> Option<horizon_render_pipeline::GbuffersInputs> {
|
||||
fn pack_terrain_inputs(&mut self) -> Option<horizon_shaders::GbuffersInputs> {
|
||||
// Flat draws whenever the device has them (`render` keeps them on with a pack).
|
||||
let flat = self.flat.is_some() && self.flat_on;
|
||||
// Flat draws read each sprite's place from a table: the atlas is stitched as the game's.
|
||||
@@ -8463,8 +8295,12 @@ impl Renderer {
|
||||
t.begin(&self.device, &self.queue);
|
||||
}
|
||||
if self.animated_textures {
|
||||
self.hud
|
||||
.animate_sprites(&mut self.uploads, &mut encoder, frame.ticks);
|
||||
self.hud.animate(
|
||||
&mut self.uploads,
|
||||
&mut encoder,
|
||||
frame.ticks,
|
||||
&self.animations,
|
||||
);
|
||||
}
|
||||
self.update_lightmap(
|
||||
&mut encoder,
|
||||
@@ -9402,13 +9238,11 @@ impl Renderer {
|
||||
}
|
||||
// Near water and distant water share one back-to-front draw order.
|
||||
if self.translucent_draws.len() > section_translucent {
|
||||
// Distant water goes after the near chunks', and replaced tiles'
|
||||
// after all: each is a hair behind the one before, so depth leaves
|
||||
// it only where that one has none. Back to front together, a far
|
||||
// tile's water under a near chunk's (one fading in, or brought
|
||||
// into view by a longer render distance) blended twice and looked
|
||||
// opaque. The cost: far water just behind near water shows no
|
||||
// surface of its own there.
|
||||
// Distant water goes after the near chunks', and replaced tiles' after all: each is a
|
||||
// hair behind the one before, so depth leaves it only where that one has none. Back to
|
||||
// front together, a far tile's water under a near chunk's (one fading in, or brought
|
||||
// into view by a longer render distance) blended twice and looked opaque. The cost: far
|
||||
// water just behind near water shows no surface of its own there.
|
||||
let tier = |instance: usize| match instance.checked_sub(visible.len()) {
|
||||
None => 0,
|
||||
Some(tile) if visible_lods[tile].old => 2,
|
||||
@@ -10228,7 +10062,11 @@ impl Renderer {
|
||||
let pack_reads_world = pack_post || native_post;
|
||||
laps.lap("r_uploads");
|
||||
// Phase 2 of occlusion culling draws over the depth, and a shader pack or TAA reads it.
|
||||
// Post effects (a resource pack's chains, `use_depth_buffer`) read the world's depth
|
||||
// after the world pass, as `GameRenderer.render` hands them the main target's.
|
||||
let post_reads_depth = !frame.title && !frame.post_effects.effects.is_empty();
|
||||
let keep_depth = pack_reads_world
|
||||
|| post_reads_depth
|
||||
|| (taa && self.world_post.taa)
|
||||
|| oit_on
|
||||
|| native_render::render_needs().world_depth
|
||||
@@ -10410,7 +10248,7 @@ impl Renderer {
|
||||
self.own_timing.as_mut(),
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.set_bind_group(horizon_render_pipeline::stages::GROUP, &shadow.group, &[]);
|
||||
pass.set_bind_group(horizon_shader_api::stages::GROUP, &shadow.group, &[]);
|
||||
if flat_frame
|
||||
&& let Some(flat) = &self.flat
|
||||
&& let Some(pipeline) = &shadow.flat
|
||||
@@ -10443,7 +10281,7 @@ impl Renderer {
|
||||
self.own_timing.as_mut(),
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.set_bind_group(horizon_render_pipeline::stages::GROUP, &shadow.group, &[]);
|
||||
pass.set_bind_group(horizon_shader_api::stages::GROUP, &shadow.group, &[]);
|
||||
if flat_frame
|
||||
&& let Some(flat) = &self.flat
|
||||
&& let Some(pipeline) = &shadow.flat_translucent
|
||||
@@ -10475,7 +10313,7 @@ impl Renderer {
|
||||
.begin_entities(&mut encoder, "Native shader pack entity shadow");
|
||||
pass.set_pipeline(&shadow.entity);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_bind_group(horizon_render_pipeline::stages::GROUP, &shadow.group, &[]);
|
||||
pass.set_bind_group(horizon_shader_api::stages::GROUP, &shadow.group, &[]);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
|
||||
@@ -10696,7 +10534,7 @@ impl Renderer {
|
||||
let gpu_occluding = gpu_frame && self.gpu_culling.as_ref().is_some_and(|g| g.occluding());
|
||||
let pack_frame = |pass: &mut wgpu::RenderPass<'_>| {
|
||||
if let Some((_, group)) = material {
|
||||
pass.set_bind_group(horizon_render_pipeline::stages::GROUP, group, &[]);
|
||||
pass.set_bind_group(horizon_shader_api::stages::GROUP, group, &[]);
|
||||
}
|
||||
};
|
||||
// A pack's solid terrain that calls `set_surface` also writes the surface target, in
|
||||
@@ -12084,7 +11922,7 @@ impl Renderer {
|
||||
&& self
|
||||
.native
|
||||
.as_ref()
|
||||
.is_none_or(|n| n.frame.api == horizon_render_pipeline::Api::V0_1);
|
||||
.is_none_or(|n| n.frame.api == horizon_shader_api::Api::V0_1);
|
||||
match (&self.hdr, &surface_frame) {
|
||||
(Some(hdr), Some(f)) => hdr.present(
|
||||
&self.queue,
|
||||
@@ -13826,6 +13664,7 @@ mod tests {
|
||||
Some(Reported {
|
||||
paper_white: 240,
|
||||
peak: 998,
|
||||
peak_guessed: true,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -13833,6 +13672,7 @@ mod tests {
|
||||
Some(Reported {
|
||||
paper_white: 480,
|
||||
peak: 480,
|
||||
peak_guessed: true,
|
||||
})
|
||||
);
|
||||
// White above the calibrated peak (a Samsung QD-OLED's 380, the slider at 456).
|
||||
@@ -13841,6 +13681,7 @@ mod tests {
|
||||
Some(Reported {
|
||||
paper_white: 456,
|
||||
peak: 456,
|
||||
peak_guessed: true,
|
||||
})
|
||||
);
|
||||
assert_eq!(os_reported(&info(Some(1000.0), None)), None);
|
||||
@@ -15399,6 +15240,10 @@ struct DisplayWatch {
|
||||
}
|
||||
|
||||
impl DisplayReading {
|
||||
/// What wgpu reads from the OS for `surface`'s display; nothing without one.
|
||||
fn read(surface: Option<&wgpu::Surface>, adapter: &wgpu::Adapter) -> Self {
|
||||
surface.map_or_else(Self::default, |s| Self::os(&s.display_hdr_info(adapter)))
|
||||
}
|
||||
fn os(info: &wgpu::DisplayHdrInfo) -> Self {
|
||||
#[cfg(target_os = "macos")]
|
||||
let hdr = macos_hdr(info);
|
||||
@@ -15439,9 +15284,7 @@ impl DisplayWatch {
|
||||
return (watch, wayland_reading(now));
|
||||
}
|
||||
let _ = window;
|
||||
let now = surface.map_or_else(DisplayReading::default, |surface| {
|
||||
DisplayReading::os(&surface.display_hdr_info(adapter))
|
||||
});
|
||||
let now = DisplayReading::read(surface, adapter);
|
||||
log_os_reported(now.luminance);
|
||||
let watch = Self {
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -15450,30 +15293,28 @@ impl DisplayWatch {
|
||||
};
|
||||
(watch, now)
|
||||
}
|
||||
/// What the system reports, if that changed from `last`.
|
||||
fn changed(
|
||||
/// What the system reports at each poll that is due at `time` (`read`; on Wayland, at each
|
||||
/// event), whether or not it is `last` again: the surface's capabilities are read with it.
|
||||
fn poll(
|
||||
&mut self,
|
||||
last: DisplayReading,
|
||||
surface: Option<&wgpu::Surface>,
|
||||
adapter: &wgpu::Adapter,
|
||||
time: std::time::Instant,
|
||||
read: impl FnOnce() -> DisplayReading,
|
||||
) -> Option<DisplayReading> {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(wayland) = &mut self.wayland {
|
||||
return wayland.changed().map(wayland_reading);
|
||||
}
|
||||
let next = self.polled.as_mut()?;
|
||||
let time = std::time::Instant::now();
|
||||
if time < *next {
|
||||
return None;
|
||||
}
|
||||
*next = time + DISPLAY_POLL;
|
||||
let now = surface.map_or_else(DisplayReading::default, |surface| {
|
||||
DisplayReading::os(&surface.display_hdr_info(adapter))
|
||||
});
|
||||
(now != last).then(|| {
|
||||
let now = read();
|
||||
if now != last {
|
||||
log_os_reported(now.luminance);
|
||||
now
|
||||
})
|
||||
}
|
||||
Some(now)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15531,11 +15372,9 @@ fn log_os_reported(reported: Option<crate::graphics::Reported>) {
|
||||
None => "Horizon renderer: the system doesn't report the display's luminance".into(),
|
||||
});
|
||||
}
|
||||
/// What the OS reports in absolute nits (Windows: DXGI's `MaxLuminance` and the SDR
|
||||
/// white level the brightness slider sets), which is HDR output's scale there: SDR
|
||||
/// white is Paper White, the display's peak is Peak Brightness. Both are kept within
|
||||
/// the settings' ranges. The OS's figures often come from the display's EDID and may
|
||||
/// run optimistic; the player's own value always wins.
|
||||
/// What the OS reports in absolute nits, HDR output's scale (Windows: DXGI's SDR white level,
|
||||
/// the brightness slider's, as Paper White, and its `MaxLuminance`, mostly the display's EDID
|
||||
/// guess, as Peak Brightness), kept within the settings' ranges. The player's values win.
|
||||
fn os_reported(info: &wgpu::DisplayHdrInfo) -> Option<crate::graphics::Reported> {
|
||||
let luminance = info.luminance?;
|
||||
let usable = |n: f32| n.is_finite() && n > 0.0;
|
||||
@@ -15547,6 +15386,7 @@ fn os_reported(info: &wgpu::DisplayHdrInfo) -> Option<crate::graphics::Reported>
|
||||
// Windows' SDR brightness slider can set white above the reported peak (480 against
|
||||
// a calibrated 380): the highlights then stay at white instead of dropping below it.
|
||||
peak: (peak.round() as u32).clamp(200, 10_000).max(paper_white),
|
||||
peak_guessed: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15582,6 +15422,7 @@ fn reported(l: horizon_hdr::wayland::Luminances) -> crate::graphics::Reported {
|
||||
crate::graphics::Reported {
|
||||
paper_white: 203,
|
||||
peak: peak.clamp(200, 10_000),
|
||||
peak_guessed: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15830,11 +15671,11 @@ mod shadow_caster_tests {
|
||||
#[test]
|
||||
fn the_shadow_pass_draws_terrain_outside_the_pack_s_planes() {
|
||||
let kept = |near: Option<f32>, far: Option<f32>, y: f64, min: [f32; 3], depth: bool| {
|
||||
let shadow = horizon_render_pipeline::Shadow {
|
||||
let shadow = horizon_shaders::Shadow {
|
||||
distance: 128.0,
|
||||
near,
|
||||
far,
|
||||
..horizon_render_pipeline::Shadow::default()
|
||||
..horizon_shaders::Shadow::default()
|
||||
};
|
||||
let camera = DVec3::new(13.5, y + 1.62, -13.5);
|
||||
let view = pack_post::ShadowView {
|
||||
|
||||
@@ -64,7 +64,7 @@ enum Writer {
|
||||
},
|
||||
}
|
||||
|
||||
pub(super) struct SpriteAnimator {
|
||||
pub(crate) struct SpriteAnimator {
|
||||
sprites: Vec<Sprite>,
|
||||
groups: Vec<Group>,
|
||||
writer: Writer,
|
||||
@@ -86,6 +86,9 @@ struct AnimationModes {
|
||||
day_time: i64,
|
||||
weather: f32,
|
||||
values: std::collections::HashMap<String, f64>,
|
||||
/// The open screen (an identity the game picks) and the ticks it has been open.
|
||||
screen: Option<u64>,
|
||||
screen_time: i64,
|
||||
}
|
||||
|
||||
impl AnimationModes {
|
||||
@@ -116,8 +119,15 @@ impl super::Renderer {
|
||||
day_time: i64,
|
||||
[rain, thunder]: [f32; 2],
|
||||
frame: Option<&crate::environment::Frame>,
|
||||
screen: Option<u64>,
|
||||
) {
|
||||
let modes = &mut self.animations.modes;
|
||||
// `screen_time`: a tick more while the same screen stays open, from 0 on a new one.
|
||||
modes.screen_time = match (screen, modes.screen) {
|
||||
(Some(open), Some(was)) if open == was => modes.screen_time + 1,
|
||||
_ => 0,
|
||||
};
|
||||
modes.screen = screen;
|
||||
modes.day_time = day_time;
|
||||
modes.weather = rain + thunder;
|
||||
modes.values.clear();
|
||||
@@ -133,6 +143,16 @@ impl super::Renderer {
|
||||
}
|
||||
|
||||
impl SpriteAnimator {
|
||||
/// What the modes read this frame, for the GUI atlas's sprites (their expressions aren't
|
||||
/// compiled: those run on ticks).
|
||||
pub(crate) fn mode_inputs(&self) -> horizon_assets::animation::ModeInputs<'static> {
|
||||
horizon_assets::animation::ModeInputs {
|
||||
day_time: self.modes.day_time,
|
||||
screen_time: self.modes.screen_time,
|
||||
weather: self.modes.weather,
|
||||
expression: &|_| None,
|
||||
}
|
||||
}
|
||||
/// `textures` are the sprite arrays, `split` layers each, `size` pixels
|
||||
/// wide; `animations` hold their frames at that size. `compute`: whether
|
||||
/// the device says it runs compute shaders writing storage textures.
|
||||
@@ -361,6 +381,7 @@ impl SpriteAnimator {
|
||||
let expression = |source: &str| values.get(source).copied();
|
||||
let inputs = horizon_assets::animation::ModeInputs {
|
||||
day_time: self.modes.day_time,
|
||||
screen_time: self.modes.screen_time,
|
||||
weather: self.modes.weather,
|
||||
expression: &expression,
|
||||
};
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
//! and `secondaryBlockOutline` under a high contrast one): an outline its fragment shader colours
|
||||
//! or animates. Without one the outline draws natively (`vs_line` in terrain.wgsl).
|
||||
|
||||
use crate::core_shader::{CoreProgram, Glue};
|
||||
use glam::Mat4;
|
||||
use horizon_shaders::{CoreProgram, Glue};
|
||||
|
||||
/// The outline's edges (`pack_lines::layout`: start, end, ARGB colour and width per instance) as
|
||||
/// vanilla's lines: four vertices an edge, the start twice and the end twice, the side
|
||||
@@ -425,7 +425,7 @@ mod tests {
|
||||
eprintln!("no GPU: skipping");
|
||||
return;
|
||||
};
|
||||
let program = horizon_shaders::translate_core(LINES_VSH, RGB_FSH, &[], &include, &GLUE)
|
||||
let program = crate::core_shader::translate_core(LINES_VSH, RGB_FSH, &[], &include, &GLUE)
|
||||
.expect("the shaders translate");
|
||||
assert_pack_coloured(&draw_edge(&device, &queue, &program));
|
||||
}
|
||||
@@ -441,7 +441,7 @@ mod tests {
|
||||
include(id)
|
||||
.map(|text| text.replace("vec3 ModelOffset;", "vec3 ModelOffset;\n vec4 More;"))
|
||||
};
|
||||
let program = horizon_shaders::translate_core(LINES_VSH, RGB_FSH, &[], &bigger, &GLUE)
|
||||
let program = crate::core_shader::translate_core(LINES_VSH, RGB_FSH, &[], &bigger, &GLUE)
|
||||
.expect("the shaders translate");
|
||||
assert!(CoreLines::new(&device, wgpu::TextureFormat::Rgba8Unorm, &program).is_none());
|
||||
}
|
||||
@@ -470,11 +470,11 @@ mod tests {
|
||||
let (namespace, path) = id.split_once(':')?;
|
||||
std::fs::read_to_string(root.join(format!("assets/{namespace}/{path}"))).ok()
|
||||
};
|
||||
let program = horizon_shaders::translate_core(&vertex, RGB_FSH, &[], &include, &GLUE)
|
||||
let program = crate::core_shader::translate_core(&vertex, RGB_FSH, &[], &include, &GLUE)
|
||||
.expect("vanilla's vertex shader translates");
|
||||
// A pack that replaces only the vertex shader runs vanilla's fragment shader.
|
||||
let fragment = include("minecraft:shaders/core/rendertype_lines.fsh").unwrap();
|
||||
let vanilla = horizon_shaders::translate_core(&vertex, &fragment, &[], &include, &GLUE)
|
||||
let vanilla = crate::core_shader::translate_core(&vertex, &fragment, &[], &include, &GLUE)
|
||||
.expect("vanilla's fragment shader translates");
|
||||
let Some((device, queue)) = device() else {
|
||||
eprintln!("no GPU: skipping the draw");
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//! to the horizon or tints the night sky expects. Without one the sky draws natively (`fs_sky` in
|
||||
//! terrain.wgsl, stars.rs).
|
||||
|
||||
use crate::core_shader::{CoreProgram, Glue};
|
||||
use glam::Mat4;
|
||||
use horizon_shaders::{CoreProgram, Glue};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
/// `DefaultVertexFormat.POSITION`: the disc's and the stars' vertices.
|
||||
@@ -325,7 +325,7 @@ mod tests {
|
||||
let fragment = "#version 330\n#include <minecraft:dynamictransforms.glsl>\n\
|
||||
layout(location = 0) out vec4 fragColor;\n\
|
||||
void main() {\n fragColor = ColorModulator;\n}\n";
|
||||
let program = horizon_shaders::translate_core(&vertex, fragment, &[], &include, &GLUE)
|
||||
let program = crate::core_shader::translate_core(&vertex, fragment, &[], &include, &GLUE)
|
||||
.expect("the sky shaders translate");
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Some((device, queue)) =
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
//! OptiFine custom sky layers on the GPU: one texture per layer, kept with the renderer (its pack),
|
||||
//! Custom sky layers (`crate::custom_sky`) on the GPU: one texture per layer, kept with the renderer (its pack),
|
||||
//! a static skybox mesh of the 3x2 grid's six faces, and one pipeline per blend mode. A frame
|
||||
//! uploads one small uniform per drawn layer ([`crate::custom_sky::Draw`]) and draws 36 vertices
|
||||
//! each, after the vanilla sky's disc and sunrise and before the sun, moon and stars.
|
||||
use glam::Mat4;
|
||||
use horizon_optifine::sky::{Blend, Face};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::custom_sky::{CustomSky as Pack, Draw};
|
||||
use crate::custom_sky::{Blend, Draw, SkyLayers};
|
||||
|
||||
/// The blend modes in the shader's `mode` order (custom_sky.wgsl).
|
||||
const MODES: [Blend; 9] = [
|
||||
@@ -85,6 +84,76 @@ fn blend(mode: Blend) -> wgpu::BlendComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/// A face of the skybox, as the 3x2 grid of its texture holds them: `Bottom`, `Top`, `South` in
|
||||
/// the top row, `West`, `North`, `East` below.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Face {
|
||||
Bottom,
|
||||
Top,
|
||||
South,
|
||||
West,
|
||||
North,
|
||||
East,
|
||||
}
|
||||
|
||||
impl Face {
|
||||
const ALL: [Self; 6] = [
|
||||
Self::Bottom,
|
||||
Self::Top,
|
||||
Self::South,
|
||||
Self::West,
|
||||
Self::North,
|
||||
Self::East,
|
||||
];
|
||||
|
||||
/// The face's cell of the texture, `[u0, v0, u1, v1]` in 0..1.
|
||||
fn cell(self) -> [f32; 4] {
|
||||
let (col, row) = match self {
|
||||
Self::Bottom => (0, 0),
|
||||
Self::Top => (1, 0),
|
||||
Self::South => (2, 0),
|
||||
Self::West => (0, 1),
|
||||
Self::North => (1, 1),
|
||||
Self::East => (2, 1),
|
||||
};
|
||||
let (u, v) = (col as f32 / 3.0, row as f32 / 2.0);
|
||||
[u, v, u + 1.0 / 3.0, v + 0.5]
|
||||
}
|
||||
|
||||
/// The face's corners (on the unit cube around the camera; x east, y up, z south) in the
|
||||
/// order of its cell's `(u0, v0)`, `(u1, v0)`, `(u1, v1)`, `(u0, v1)`. The side faces are
|
||||
/// seen from inside with up at the top of their cell; the top face has north at the bottom of
|
||||
/// its cell, the bottom face north at the top (the template's `N` marks).
|
||||
fn corners(self) -> [[f32; 3]; 4] {
|
||||
match self {
|
||||
// Seen looking north: west on the left.
|
||||
Self::North => [
|
||||
[-1., 1., -1.],
|
||||
[1., 1., -1.],
|
||||
[1., -1., -1.],
|
||||
[-1., -1., -1.],
|
||||
],
|
||||
Self::South => [[1., 1., 1.], [-1., 1., 1.], [-1., -1., 1.], [1., -1., 1.]],
|
||||
Self::East => [[1., 1., -1.], [1., 1., 1.], [1., -1., 1.], [1., -1., -1.]],
|
||||
Self::West => [
|
||||
[-1., 1., 1.],
|
||||
[-1., 1., -1.],
|
||||
[-1., -1., -1.],
|
||||
[-1., -1., 1.],
|
||||
],
|
||||
// Looking up with north toward the cell's bottom edge: east on the right.
|
||||
Self::Top => [[-1., 1., 1.], [1., 1., 1.], [1., 1., -1.], [-1., 1., -1.]],
|
||||
// Looking down with north toward the cell's top edge: east on the right.
|
||||
Self::Bottom => [
|
||||
[-1., -1., -1.],
|
||||
[1., -1., -1.],
|
||||
[1., -1., 1.],
|
||||
[-1., -1., 1.],
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The skybox's 36 vertices: two triangles per face, each corner with its cell's UV.
|
||||
fn mesh() -> Vec<Vertex> {
|
||||
let mut out = Vec::with_capacity(36);
|
||||
@@ -256,10 +325,15 @@ impl CustomSky {
|
||||
}
|
||||
|
||||
/// The pack's layer textures, replacing any before.
|
||||
pub(super) fn set_pack(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, pack: &Pack) {
|
||||
pub(super) fn set_pack(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
pack: &dyn SkyLayers,
|
||||
) {
|
||||
let limit = device.limits().max_texture_dimension_2d;
|
||||
self.groups = pack
|
||||
.images
|
||||
.images()
|
||||
.iter()
|
||||
.map(|image| {
|
||||
// Too large for this GPU: a 1x1 transparent texel, so the layer draws nothing.
|
||||
@@ -413,6 +487,38 @@ impl CustomSky {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_grid_holds_each_face_once_and_faces_point_out() {
|
||||
let mut cells: Vec<_> = Face::ALL
|
||||
.iter()
|
||||
.map(|f| f.cell().map(|x| (x * 6.0) as i32))
|
||||
.collect();
|
||||
cells.dedup();
|
||||
assert_eq!(cells.len(), 6);
|
||||
assert_eq!(Face::East.cell(), [2.0 / 3.0, 0.5, 1.0, 1.0]);
|
||||
for face in Face::ALL {
|
||||
let c = face.corners();
|
||||
let sub = |a: [f32; 3], b: [f32; 3]| [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
||||
let (a, b) = (sub(c[1], c[0]), sub(c[3], c[0]));
|
||||
let n = [
|
||||
a[1] * b[2] - a[2] * b[1],
|
||||
a[2] * b[0] - a[0] * b[2],
|
||||
a[0] * b[1] - a[1] * b[0],
|
||||
];
|
||||
let centre: [f32; 3] =
|
||||
std::array::from_fn(|i| c.iter().map(|p| p[i]).sum::<f32>() / 4.0);
|
||||
// Seen from inside, u runs right and v down, so u x v points away from the camera.
|
||||
let dot: f32 = (0..3).map(|i| n[i] * centre[i]).sum();
|
||||
assert!(dot > 0.0, "{face:?} is mirrored");
|
||||
}
|
||||
// The top cell's bottom edge (v1) is north: its corners 2 and 3 have z = -1.
|
||||
assert_eq!(Face::Top.corners()[2][2], -1.0);
|
||||
assert_eq!(Face::Top.corners()[3][2], -1.0);
|
||||
// The bottom cell's top edge (v0) is north.
|
||||
assert_eq!(Face::Bottom.corners()[0][2], -1.0);
|
||||
assert_eq!(Face::Bottom.corners()[1][2], -1.0);
|
||||
}
|
||||
|
||||
/// What a blend leaves a sky texel of `dst` with when the shader outputs `src`
|
||||
/// (custom_sky.wgsl) for a layer texel `t` at brightness `b`, per channel.
|
||||
fn mix(mode: Blend, dst: f32, t: f32, a: f32, b: f32) -> f32 {
|
||||
|
||||
@@ -6,7 +6,7 @@ struct Layer {
|
||||
// Projection * view (camera-relative) * the layer's rotation.
|
||||
clip_from_sky: mat4x4<f32>,
|
||||
brightness: f32,
|
||||
// horizon_optifine::sky::Blend, in gfx/custom_sky.rs's MODES order.
|
||||
// crate::custom_sky::Blend, in gfx/custom_sky.rs's MODES order.
|
||||
mode: u32,
|
||||
pad: vec2<u32>,
|
||||
hdr_settings: vec4<f32>,
|
||||
|
||||
@@ -1,3 +1,25 @@
|
||||
// This file is part of the FidelityFX SDK.
|
||||
//
|
||||
// Copyright (C) 2024 Advanced Micro Devices, Inc.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files(the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and /or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions :
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
// THE SOFTWARE.
|
||||
|
||||
//! FSR 3.1's frame interpolation (AMD FidelityFX SDK 1.1.4, `ffx_frameinterpolation*`,
|
||||
//! MIT), ported to WGSL (`interpolation.wgsl`): the frame halfway between the previous
|
||||
//! real frame and this one, from the camera's motion vectors, the optical flow
|
||||
|
||||
@@ -185,7 +185,7 @@ pub struct Hdr {
|
||||
/// The display's black level, in nits: shadows are lifted onto it (`hdr.wgsl`).
|
||||
pub black: f32,
|
||||
pub tiers: [Tier; 4],
|
||||
/// The HUD and menus' brightness, as a share of paper white (0.25..=1).
|
||||
/// The HUD and menus' brightness, as a share of paper white: above 1 brighter than it.
|
||||
pub ui: f32,
|
||||
/// Which pixels of a light source shine (`Masks`).
|
||||
pub masks: Masks,
|
||||
@@ -419,6 +419,26 @@ impl Hdr {
|
||||
}
|
||||
}
|
||||
|
||||
/// The same HDR output presenting `output` instead (`output_apply`): the same frame and
|
||||
/// HUD layer, and the player's tuning as it is (HDR Lighting, the world's lighting, the UI
|
||||
/// brightness, the masks and tiers, the debug view, calibration and the luminance). Its
|
||||
/// targets are made again on the next frame.
|
||||
pub fn retargeted(&self, device: &wgpu::Device, queue: &wgpu::Queue, output: Output) -> Self {
|
||||
Self {
|
||||
debug: self.debug,
|
||||
paper_white: self.paper_white,
|
||||
peak: self.peak,
|
||||
black: self.black,
|
||||
tiers: self.tiers,
|
||||
ui: self.ui,
|
||||
masks: self.masks,
|
||||
lighting: self.lighting,
|
||||
look: self.look,
|
||||
calibration: self.calibration,
|
||||
..Self::new(device, queue, self.format, output, self.layered.is_some())
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame every pass draws into this frame, sized like the window.
|
||||
pub fn frame(&mut self, device: &wgpu::Device, size: [u32; 2]) -> wgpu::Texture {
|
||||
if self.targets.as_ref().is_none_or(|t| t.size != size) {
|
||||
@@ -936,7 +956,7 @@ impl Hdr {
|
||||
f32::from(u8::from(pq)),
|
||||
]);
|
||||
params[5] = self.black.clamp(0.0, paper);
|
||||
params[6] = self.ui.clamp(0.25, 1.0);
|
||||
params[6] = self.ui;
|
||||
params[7] = if self.lighting {
|
||||
scene_gain(self.peak, self.paper_white)
|
||||
} else {
|
||||
@@ -1541,6 +1561,93 @@ 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}");
|
||||
}
|
||||
|
||||
/// HDR output switched from HDR10 to scRGB and back (`output_apply`): the player's tuning
|
||||
/// stays, each swapchain gets the same light in its own encoding, and the HDR10 capture
|
||||
/// and the HDR10 picture after the switch back are the ones before it.
|
||||
#[test]
|
||||
fn a_switched_encoding_keeps_the_tuning_and_shows_the_same_light() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
eprintln!("HDR encoding switch GPU skipped: no adapter");
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
(hdr.paper_white, hdr.peak, hdr.black) = (250.0, 800.0, 0.0);
|
||||
(hdr.ui, hdr.look, hdr.lighting) = (0.5, false, false);
|
||||
hdr.masks.parts = 0.4;
|
||||
hdr.tiers[1].share = 0.7;
|
||||
hdr.debug = true;
|
||||
hdr.calibration = Some(super::Calibration {
|
||||
step: 2,
|
||||
nits: 700.0,
|
||||
band: 0.6,
|
||||
});
|
||||
let tuning = |h: &Hdr| {
|
||||
(
|
||||
[h.paper_white, h.peak, h.black, h.ui],
|
||||
(h.look, h.lighting, h.debug, h.layered.is_some(), h.format),
|
||||
(h.masks, h.tiers, h.calibration),
|
||||
)
|
||||
};
|
||||
let switched = hdr.retargeted(&device, &queue, Output::ScRgb);
|
||||
assert_eq!(switched.output, Output::ScRgb);
|
||||
assert_eq!(tuning(&switched), tuning(&hdr));
|
||||
hdr.calibration = None;
|
||||
hdr.debug = false;
|
||||
// The swapchain's first texel (`capture`: the HDR10 screenshot's) for light 2 times
|
||||
// paper white, grey, as the layered frame stores it.
|
||||
let shot = |hdr: &mut Hdr, capture: bool| {
|
||||
let frame = hdr.frame(&device, [1, 1]);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let g = 2f64.powf(1.0 / 2.2);
|
||||
fill(&mut encoder, &frame, [g, g, g, 1.0]);
|
||||
hdr.begin(&queue, true);
|
||||
hdr.keep_world(&mut encoder, None);
|
||||
super::super::hud_coverage::clear(&mut encoder, &hdr.coverage_view().unwrap());
|
||||
let target = if capture {
|
||||
hdr.capture(&device, &queue, &mut encoder, true).unwrap()
|
||||
} else {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: frame.size(),
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: hdr.output.format(),
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
})
|
||||
};
|
||||
if !capture {
|
||||
hdr.present(&queue, &mut encoder, &target, true, None);
|
||||
}
|
||||
texel(&device, &queue, encoder, &target)
|
||||
};
|
||||
// BT.2100's PQ EOTF: a 10-bit code's nits.
|
||||
let pq_nits = |code: f32| {
|
||||
let (m1, m2) = (2610.0 / 16384.0, 2523.0 / 4096.0 * 128.0);
|
||||
let (c1, c2, c3) = (3424.0 / 4096.0, 2413.0 / 128.0, 2392.0 / 128.0);
|
||||
let p = (code / 1023.0f32).powf(1.0 / m2);
|
||||
((p - c1).max(0.0) / (c2 - c3 * p)).powf(1.0 / m1) * 10000.0
|
||||
};
|
||||
let pq = shot(&mut hdr, false);
|
||||
let pq_capture = shot(&mut hdr, true);
|
||||
let mut scrgb = hdr.retargeted(&device, &queue, Output::ScRgb);
|
||||
let linear = shot(&mut scrgb, false);
|
||||
assert_eq!(shot(&mut scrgb, true), pq_capture);
|
||||
// Two times paper white in both: 500 nits, as HDR10's code and as scRGB's 80-nit units.
|
||||
let near = |got: f32| (got - 500.0).abs() <= 0.015 * 500.0;
|
||||
assert!(
|
||||
near(pq_nits(pq[0])) && pq[..3].iter().all(|&c| c == pq[0]),
|
||||
"{pq:?}"
|
||||
);
|
||||
assert!(near(linear[0] * 80.0), "{linear:?}");
|
||||
let mut back = scrgb.retargeted(&device, &queue, Output::Pq);
|
||||
assert_eq!(
|
||||
(shot(&mut back, false), shot(&mut back, true)),
|
||||
(pq, pq_capture)
|
||||
);
|
||||
}
|
||||
|
||||
/// ITU-R BT.2390's black level lift on grey light, in HDR10 and in scRGB, against values
|
||||
/// worked out apart from the shader, in f64 from SMPTE ST 2084: E3 = E2 + b (1 - E2)^4 on
|
||||
/// the PQ signal, for a source from 0 to the peak and b the black level's share of it.
|
||||
@@ -1799,9 +1906,11 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
// Covered: the HUD as drawn, at UI Brightness.
|
||||
(2.0, 0.3, 1.0, 1.0, 0.3),
|
||||
(2.0, 0.3, 1.0, 0.5, 0.3 * 0.5f32.powf(1.0 / 2.2)),
|
||||
// UI Brightness above paper white: brighter than drawn.
|
||||
(2.0, 0.3, 1.0, 2.0, 0.3 * 2f32.powf(1.0 / 2.2)),
|
||||
// Added over the world (the text highlight), which it doesn't hide.
|
||||
(0.6, 0.8, 0.0, 1.0, 0.8),
|
||||
// At or below white, at 100%: the HUD's picture as drawn.
|
||||
// At or below white, at paper white: the HUD's picture as drawn.
|
||||
(0.7, 0.45, 0.5, 1.0, 0.45),
|
||||
] {
|
||||
hdr.ui = ui;
|
||||
@@ -1821,6 +1930,50 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
}
|
||||
}
|
||||
|
||||
/// On the frame without the HUD's layer, the HUD at UI Brightness reaches its nits, and
|
||||
/// above the peak stops there, as on the layered frame.
|
||||
#[test]
|
||||
fn the_hud_at_ui_brightness_stops_at_the_peak() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba8Unorm, Output::ScRgb, false, None);
|
||||
(hdr.paper_white, hdr.peak) = (200.0, 300.0);
|
||||
let target = 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: Output::ScRgb.format(),
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
// UI Brightness (a share of paper white) and the white HUD's nits.
|
||||
for (ui, want) in [(0.5, 100.0), (1.25, 250.0), (2.0, 300.0)] {
|
||||
hdr.ui = ui;
|
||||
let frame = hdr.frame(&device, [1, 1]);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
fill(&mut encoder, &frame, [0.2, 0.2, 0.2, 1.0]);
|
||||
hdr.keep_world(&mut encoder, None);
|
||||
// Opaque white HUD over the world: alpha 1 is full coverage.
|
||||
fill(&mut encoder, &frame, [1.0; 4]);
|
||||
hdr.begin(&queue, true);
|
||||
hdr.present(&queue, &mut encoder, &target, true, None);
|
||||
let got = texel(&device, &queue, encoder, &target);
|
||||
for c in &got[..3] {
|
||||
assert!(
|
||||
(c * 80.0 - want).abs() < 0.5,
|
||||
"UI {ui}: {got:?}, not {want} nits"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_menu_blur_keeps_the_light_it_blurs() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//! A window's HDR output start and what a failed one leaves (`graphics::HDR_MARKER`). Turning
|
||||
//! HDR output on writes a marker naming the window's graphics driver (`output::window_driver`);
|
||||
//! the first frame presented, turning HDR output off and a clean exit remove it. A start that
|
||||
//! finds it holds HDR output back on that driver (`output::held`) until a mode is chosen on the
|
||||
//! HDR page or the driver changes. Offscreen renderers have no driver: they never write the
|
||||
//! marker or hold HDR output back.
|
||||
use super::{Renderer, output};
|
||||
|
||||
impl Renderer {
|
||||
/// What this renderer's destination offers for `settings`: HDR output held back on a window
|
||||
/// whose graphics driver a start failed on (`output::held`).
|
||||
pub(super) fn caps_for(
|
||||
&self,
|
||||
settings: &crate::graphics::Graphics,
|
||||
) -> output::OutputCapabilities {
|
||||
output::OutputCapabilities {
|
||||
held: output::held(settings, self.hdr_driver.as_deref()),
|
||||
..self.output_caps
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes the hold `settings` put on this window's driver into the capabilities, and says it
|
||||
/// once per start.
|
||||
pub(super) fn hold_hdr_back(&mut self, settings: &crate::graphics::Graphics) {
|
||||
self.output_caps = self.caps_for(settings);
|
||||
if self.output_caps.held
|
||||
&& let Some(driver) = &self.hdr_driver
|
||||
{
|
||||
static LOGGED: std::sync::Once = std::sync::Once::new();
|
||||
LOGGED.call_once(|| {
|
||||
log::warn!(
|
||||
"Horizon renderer: HDR output is held back: the last start with this graphics driver ({driver}) ended before HDR output showed a frame. Choose an HDR mode on the HDR page to try again (or remove hdrFailedStart from {})",
|
||||
crate::graphics::FILE
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the marker at `marker` as a window's HDR output turns on (`on`), and removes this
|
||||
/// renderer's marker as it turns off.
|
||||
pub(super) fn mark_hdr_start(&mut self, on: bool, marker: &std::path::Path) {
|
||||
if !on {
|
||||
self.drop_hdr_marker();
|
||||
} else if self.surface.is_some()
|
||||
&& let Some(driver) = &self.hdr_driver
|
||||
{
|
||||
let _ = std::fs::write(marker, crate::graphics::hdr_marker_text(driver));
|
||||
self.hdr_marker = Some(marker.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
/// The graphics driver this window's HDR output starts on (`output::window_driver`);
|
||||
/// `None` offscreen.
|
||||
pub fn hdr_driver(&self) -> Option<&str> {
|
||||
self.hdr_driver.as_deref()
|
||||
}
|
||||
|
||||
/// Removes this renderer's marker of an HDR output that hasn't presented a frame yet, for a
|
||||
/// clean exit: only a crash may leave it.
|
||||
pub fn drop_hdr_marker(&mut self) {
|
||||
if let Some(marker) = self.hdr_marker.take() {
|
||||
let _ = std::fs::remove_file(marker);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,7 @@
|
||||
//! colour. They read the terrain's group 0, the flat draws' group 1 where those run, and the
|
||||
//! pack's frame at group 3.
|
||||
|
||||
use horizon_render_pipeline::stages::GROUP;
|
||||
use horizon_shader_api::check::CheckedStage;
|
||||
use horizon_shader_api::{check::CheckedStage, stages::GROUP};
|
||||
|
||||
use super::native_post::{NativeFrame, present_constants};
|
||||
use super::wgsl_prefix::{TERRAIN, prefixed, section};
|
||||
@@ -370,7 +369,7 @@ fn shader_source(
|
||||
tags: &[(u32, u32)],
|
||||
materials: &[(u32, u32)],
|
||||
(vertex, displaced): (bool, bool),
|
||||
api: horizon_render_pipeline::Api,
|
||||
api: horizon_shader_api::Api,
|
||||
) -> String {
|
||||
format!(
|
||||
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{}{}{ENTRIES}{}{}",
|
||||
@@ -399,7 +398,7 @@ fn module(
|
||||
materials: &[(u32, u32)],
|
||||
vertex: bool,
|
||||
displaced: bool,
|
||||
api: horizon_render_pipeline::Api,
|
||||
api: horizon_shader_api::Api,
|
||||
) -> Result<naga::Module, String> {
|
||||
let text = shader_source(&stage.source, tags, materials, (vertex, displaced), api);
|
||||
naga::front::wgsl::parse_str(&text)
|
||||
@@ -418,7 +417,7 @@ impl NativeMaterial {
|
||||
vertex: bool,
|
||||
displaced: Option<&DisplacedShape<'_>>,
|
||||
) -> Result<Self, String> {
|
||||
let writes_surface = horizon_render_pipeline::stages::calls_set_surface(&stage.source);
|
||||
let writes_surface = horizon_shader_api::stages::calls_set_surface(&stage.source);
|
||||
let module = module(
|
||||
stage,
|
||||
shape.sprite_tags,
|
||||
@@ -587,7 +586,7 @@ impl NativeMaterial {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn coverage_test_shader(api: horizon_render_pipeline::Api) -> String {
|
||||
pub(super) fn coverage_test_shader(api: horizon_shader_api::Api) -> String {
|
||||
use horizon_shader_api::{MANIFEST, Pack, StageKind, check};
|
||||
let manifest = format!(
|
||||
"schema = 1\nid = \"coverage\"\nname = \"Coverage\"\nversion = \"1\"\napi = \"{api}\"\n[material]\nfile = \"material.wgsl\"\n[displacement]\n"
|
||||
@@ -830,7 +829,7 @@ mod tests {
|
||||
.into();
|
||||
let report = check(&Pack::from_files(files).unwrap()).unwrap();
|
||||
let source = report.stages[0].source.clone()
|
||||
+ horizon_render_pipeline::stages::PBR_STUB
|
||||
+ horizon_shader_api::stages::PBR_STUB
|
||||
+ super::super::native_post::line_helpers(api)
|
||||
+ entry;
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
//! One of Horizon's own shader packs while it runs (docs/shaders.md): the frame its stages
|
||||
//! read, its sky, material and post stages, and the timer of their passes.
|
||||
|
||||
use horizon_render_pipeline::StageKind;
|
||||
use horizon_shader_api::Report;
|
||||
use horizon_shader_api::{Report, StageKind};
|
||||
|
||||
use super::displace::Displacement;
|
||||
use super::native_material::{DisplacedShape, NativeMaterial, TerrainShape};
|
||||
@@ -254,7 +253,7 @@ impl NativePack {
|
||||
.transpose()?;
|
||||
// A 0.2 pack always has one: its decode and tonemap run without a chain too.
|
||||
let tonemap = others.iter().find(|s| s.kind == StageKind::Tonemap);
|
||||
let post = (!post.is_empty() || api == horizon_render_pipeline::Api::V0_2)
|
||||
let post = (!post.is_empty() || api == horizon_shader_api::Api::V0_2)
|
||||
.then(|| {
|
||||
let manifest = &report.manifest;
|
||||
NativePost::new(device, post, manifest, constants, format, &frame, tonemap)
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
//! (`NativeFrame`).
|
||||
|
||||
use glam::{Mat4, Vec3};
|
||||
use horizon_render_pipeline::stages::{GROUP, POST_TARGET_BINDING};
|
||||
use horizon_render_pipeline::{Api, Frame as FrameInputs, StageKind};
|
||||
use horizon_shader_api::check::CheckedStage;
|
||||
use horizon_shader_api::{
|
||||
Api, Frame as FrameInputs, StageKind, check::CheckedStage, stages::GROUP,
|
||||
};
|
||||
|
||||
use super::Frame;
|
||||
|
||||
@@ -338,7 +338,7 @@ pub struct NativeFrame {
|
||||
reflection_sampler: wgpu::Sampler,
|
||||
/// The shadow map the stages read, and the camera that draws it when the pack asks for one.
|
||||
pub shadow_map: super::native_shadow::ShadowMap,
|
||||
shadow_camera: Option<horizon_render_pipeline::Shadow>,
|
||||
shadow_camera: Option<horizon_shaders::Shadow>,
|
||||
/// Whether the pack's material reads `reflection` (`NativePack::new`): else no mirror
|
||||
/// is drawn and `has_reflection` stays 0.
|
||||
pub reflects: bool,
|
||||
@@ -1014,9 +1014,10 @@ impl NativePost {
|
||||
texture_entry(11, wgpu::TextureSampleType::Uint),
|
||||
];
|
||||
entries.extend(pack_texture_entries(frame.textures.len()));
|
||||
entries.extend(
|
||||
(0..targets.len() as u32).map(|j| texture_entry(POST_TARGET_BINDING + j, float)),
|
||||
);
|
||||
entries
|
||||
.extend((0..targets.len() as u32).map(|j| {
|
||||
texture_entry(horizon_shader_api::stages::POST_TARGET_BINDING + j, float)
|
||||
}));
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Native shader pack post"),
|
||||
entries: &entries,
|
||||
@@ -1345,7 +1346,7 @@ impl NativePost {
|
||||
view
|
||||
};
|
||||
wgpu::BindGroupEntry {
|
||||
binding: POST_TARGET_BINDING + j as u32,
|
||||
binding: horizon_shader_api::stages::POST_TARGET_BINDING + j as u32,
|
||||
resource: wgpu::BindingResource::TextureView(view),
|
||||
}
|
||||
}));
|
||||
@@ -1496,7 +1497,7 @@ pub fn frame_inputs(
|
||||
size: [u32; 2],
|
||||
seconds: f32,
|
||||
index: u32,
|
||||
shadow: Option<&horizon_render_pipeline::Shadow>,
|
||||
shadow: Option<&horizon_shaders::Shadow>,
|
||||
) -> FrameInputs {
|
||||
let aspect = size[0] as f32 / size[1].max(1) as f32;
|
||||
let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
|
||||
@@ -1654,7 +1655,7 @@ pub fn pack_textures(
|
||||
|
||||
/// Layout entries of `count` pack textures and their samplers.
|
||||
pub(super) fn pack_texture_entries(count: usize) -> Vec<wgpu::BindGroupLayoutEntry> {
|
||||
use horizon_render_pipeline::stages::{TEXTURE_BINDING, TEXTURE_SAMPLER_BINDING};
|
||||
use horizon_shader_api::stages::{TEXTURE_BINDING, TEXTURE_SAMPLER_BINDING};
|
||||
(0..count as u32)
|
||||
.flat_map(|i| {
|
||||
[
|
||||
@@ -1683,7 +1684,7 @@ pub(super) fn pack_texture_entries(count: usize) -> Vec<wgpu::BindGroupLayoutEnt
|
||||
pub(super) fn pack_texture_group_entries(
|
||||
textures: &[(wgpu::TextureView, wgpu::Sampler)],
|
||||
) -> Vec<wgpu::BindGroupEntry<'_>> {
|
||||
use horizon_render_pipeline::stages::{TEXTURE_BINDING, TEXTURE_SAMPLER_BINDING};
|
||||
use horizon_shader_api::stages::{TEXTURE_BINDING, TEXTURE_SAMPLER_BINDING};
|
||||
textures
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -2676,7 +2677,8 @@ file = \"b.wgsl\"
|
||||
|
||||
/// 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.
|
||||
/// alone, held to the headroom. In SDR Horizon's is a clip at white. An HDR output switched
|
||||
/// to scRGB and back to HDR10 (`Hdr::retargeted`) maps it once all the same.
|
||||
#[test]
|
||||
fn a_0_2_pack_is_tone_mapped_once_by_its_own_curve_or_horizons() {
|
||||
let Some((device, queue)) = device() else {
|
||||
@@ -2686,8 +2688,9 @@ file = \"b.wgsl\"
|
||||
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| {
|
||||
// The picture shown for that light, in linear light: (a pack's [tonemap], HDR, the
|
||||
// output switched).
|
||||
let shown = |pack_curve: bool, hdr: bool, switched: bool| {
|
||||
let (stage, file, source) = if pack_curve {
|
||||
(
|
||||
"[tonemap]",
|
||||
@@ -2745,6 +2748,11 @@ file = \"b.wgsl\"
|
||||
true,
|
||||
);
|
||||
(display.paper_white, display.peak) = (paper, peak);
|
||||
if switched {
|
||||
use super::super::hdr::Output::{Pq, ScRgb};
|
||||
display = display.retargeted(&device, &queue, ScRgb);
|
||||
display = display.retargeted(&device, &queue, Pq);
|
||||
}
|
||||
let screen = display.frame(&device, [4, 4]);
|
||||
let usage =
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
|
||||
@@ -2812,14 +2820,16 @@ file = \"b.wgsl\"
|
||||
// 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}");
|
||||
for switched in [false, true] {
|
||||
let horizon = shown(false, true, switched);
|
||||
assert!(near(horizon, 4.82043), "{switched}: {horizon}");
|
||||
// The pack's identity curve alone: the light held to the headroom, not eased again.
|
||||
let pack = shown(true, true, switched);
|
||||
assert!(near(pack, headroom), "{switched}: {pack}");
|
||||
}
|
||||
// SDR: a clip at white, the pack's or Horizon's.
|
||||
for pack_curve in [false, true] {
|
||||
let sdr = shown(pack_curve, false);
|
||||
let sdr = shown(pack_curve, false, false);
|
||||
assert!(near(sdr, 1.0), "{pack_curve}: {sdr}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@ use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
mod generated {
|
||||
include!(concat!(env!("OUT_DIR"), "/native_render_generated.rs"));
|
||||
}
|
||||
/// The hooks' trait and dispatch, made from `native-hooks.json` by `cargo run -p xtask -- mod-api`;
|
||||
/// the game's build checks that the file is current.
|
||||
mod generated;
|
||||
pub use generated::{
|
||||
HandDrawnEvent, RenderHooks, RenderNeedsEvent, WorldDrawnEvent, WorldWarpEvent,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// @generated by cargo run -p xtask -- mod-api; do not edit.
|
||||
use super::*;
|
||||
pub trait RenderHooks: Send + Sync {
|
||||
/// The world is drawn (see [`WorldDrawn`]). Not called on the title screen's panorama.
|
||||
fn world_drawn(&self, event: &mut WorldDrawnEvent<'_, '_>) {
|
||||
let _ = event;
|
||||
}
|
||||
/// Asked as a frame is built and drawn: set what your mod needs.
|
||||
///
|
||||
/// Every mod gets `event.needs` in order and may add, change or remove needs.
|
||||
fn render_needs(&self, event: &mut RenderNeedsEvent<'_>) {
|
||||
let _ = event;
|
||||
}
|
||||
/// The first-person hand is drawn (see [`HandDrawn`]), on every frame [`native_world_drawn`]
|
||||
/// runs, with no hand too. API 364.
|
||||
fn hand_drawn(&self, event: &mut HandDrawnEvent<'_, '_>) {
|
||||
let _ = event;
|
||||
}
|
||||
/// 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>`.
|
||||
///
|
||||
/// Every mod gets `event.warp` in order and may add, change or remove warp.
|
||||
fn world_warp(&self, event: &mut WorldWarpEvent<'_>) {
|
||||
let _ = event;
|
||||
}
|
||||
}
|
||||
/// What [`RenderHooks::world_drawn`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
pub struct WorldDrawnEvent<'a, 'b> {
|
||||
pub world: &'a mut WorldDrawn<'b>,
|
||||
}
|
||||
pub fn dispatch_world_drawn(mods: &[&dyn RenderHooks], world: &mut WorldDrawn<'_>) {
|
||||
let mut event = WorldDrawnEvent { world };
|
||||
for mod_hook in mods {
|
||||
mod_hook.world_drawn(&mut event);
|
||||
}
|
||||
}
|
||||
/// What [`RenderHooks::render_needs`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
pub struct RenderNeedsEvent<'a> {
|
||||
pub needs: &'a mut RenderNeeds,
|
||||
}
|
||||
pub fn dispatch_render_needs(mods: &[&dyn RenderHooks], needs: &mut RenderNeeds) {
|
||||
let mut event = RenderNeedsEvent { needs };
|
||||
for mod_hook in mods {
|
||||
mod_hook.render_needs(&mut event);
|
||||
}
|
||||
}
|
||||
/// What [`RenderHooks::hand_drawn`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
pub struct HandDrawnEvent<'a, 'b> {
|
||||
pub hand: &'a mut HandDrawn<'b>,
|
||||
}
|
||||
pub fn dispatch_hand_drawn(mods: &[&dyn RenderHooks], hand: &mut HandDrawn<'_>) {
|
||||
let mut event = HandDrawnEvent { hand };
|
||||
for mod_hook in mods {
|
||||
mod_hook.hand_drawn(&mut event);
|
||||
}
|
||||
}
|
||||
/// What [`RenderHooks::world_warp`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
pub struct WorldWarpEvent<'a> {
|
||||
pub warp: &'a mut WorldWarp,
|
||||
}
|
||||
pub fn dispatch_world_warp(mods: &[&dyn RenderHooks], warp: &mut WorldWarp) {
|
||||
let mut event = WorldWarpEvent { warp };
|
||||
for mod_hook in mods {
|
||||
mod_hook.world_warp(&mut event);
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,8 @@
|
||||
//! draw lists (`ShadowView`) serve both; the map's texels are distorted toward the camera as the
|
||||
//! prelude's `shadow_position` undoes.
|
||||
|
||||
use horizon_render_pipeline::Shadow;
|
||||
use horizon_render_pipeline::{Api, Frame as FrameInputs, stages::GROUP};
|
||||
use horizon_shader_api::manifest::Shadow as Settings;
|
||||
use horizon_shader_api::{Api, Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP};
|
||||
use horizon_shaders::Shadow;
|
||||
|
||||
use super::native_material::TerrainShape;
|
||||
use super::wgsl_prefix::{TERRAIN, prefixed, section};
|
||||
@@ -388,7 +387,7 @@ fn entity_source() -> String {
|
||||
format!(
|
||||
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}{ENTITY_ENTRIES}",
|
||||
FrameInputs::WGSL.wgsl(),
|
||||
horizon_render_pipeline::stages::SHADOW_HELPERS,
|
||||
horizon_shader_api::stages::SHADOW_HELPERS,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -487,7 +486,7 @@ fn shader_source(
|
||||
(Some(material), _) => (
|
||||
format!(
|
||||
"{material}{}{table}{}{}{GRID_ENTRIES}",
|
||||
horizon_render_pipeline::stages::PBR_STUB,
|
||||
horizon_shader_api::stages::PBR_STUB,
|
||||
super::displace::RELIEF,
|
||||
super::displace::GRID,
|
||||
),
|
||||
@@ -505,7 +504,7 @@ fn shader_source(
|
||||
format!(
|
||||
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}{UNTAKEN}",
|
||||
FrameInputs::WGSL.wgsl(),
|
||||
horizon_render_pipeline::stages::SHADOW_HELPERS,
|
||||
horizon_shader_api::stages::SHADOW_HELPERS,
|
||||
),
|
||||
super::native_material::DISPLACE_VANILLA,
|
||||
),
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
//! names). It reads the renderer's globals (group 0) and the pack's frame (group 3), and
|
||||
//! draws where vanilla's sky draws; the sun, moon, stars and the dark disc are drawn over it.
|
||||
|
||||
use horizon_render_pipeline::stages::GROUP;
|
||||
use horizon_shader_api::check::CheckedStage;
|
||||
use horizon_shader_api::{check::CheckedStage, stages::GROUP};
|
||||
|
||||
use super::native_post::{NativeFrame, present_constants};
|
||||
use super::wgsl_prefix::{TERRAIN, prefixed, section};
|
||||
@@ -41,7 +40,7 @@ fn vanilla_sky() -> String {
|
||||
}
|
||||
|
||||
/// The sky shader for the pack's sky stage `source`.
|
||||
fn shader_source(source: &str, api: horizon_render_pipeline::Api) -> String {
|
||||
fn shader_source(source: &str, api: horizon_shader_api::Api) -> String {
|
||||
format!(
|
||||
"{source}\n// Horizon's sky (terrain.wgsl)\n{}{}{PACK_ENTRY}",
|
||||
vanilla_sky(),
|
||||
@@ -199,7 +198,7 @@ mod tests {
|
||||
// A pack may use the renderer's names for its own things.
|
||||
let own = "override gain: f32;\nconst PI: f32 = 3.0;\nfn view_ray() -> f32 { return PI; }\n\
|
||||
fn sky(input: SkyInput) -> vec4<f32> { return input.color * gain * view_ray(); }\n";
|
||||
let text = shader_source(&stage(own).source, horizon_render_pipeline::Api::V0_1);
|
||||
let text = shader_source(&stage(own).source, horizon_shader_api::Api::V0_1);
|
||||
let module = naga::front::wgsl::parse_str(&text).unwrap_or_else(|e| {
|
||||
panic!("{}", e.emit_to_string(&text));
|
||||
});
|
||||
@@ -262,7 +261,7 @@ mod tests {
|
||||
Vec::new(),
|
||||
Default::default(),
|
||||
None,
|
||||
horizon_render_pipeline::Api::V0_1,
|
||||
horizon_shader_api::Api::V0_1,
|
||||
);
|
||||
queue.write_buffer(
|
||||
&frame.buffer,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
//! `move_off_page`, moved out of `gfx.rs` unchanged.
|
||||
use super::*;
|
||||
|
||||
/// `Renderer::compact_arena`'s and `shrink_translucent`'s moves: what the meshes
|
||||
/// hold on `page` of `arena` (`part`) goes to its other pages in video memory,
|
||||
/// until the next range would pass `budget` bytes (it keeps what is left; the
|
||||
/// first range moves whatever its size, so none keeps the page closed), the
|
||||
/// copies into `encoder`, the ranges' flat draw ids (`flat`) and, in the
|
||||
/// section arena, GPU culling's records (`records`) along. False when the other
|
||||
/// pages have no room left.
|
||||
pub(super) fn move_off_page<'a>(
|
||||
arena: &mut arena::Arena,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
mut flat: Option<(&mut flat::Flat, &wgpu::Device, &mut upload::Uploads)>,
|
||||
mut records: Option<&mut gpu_culling::Records>,
|
||||
meshes: impl Iterator<Item = &'a mut SectionGpu>,
|
||||
page: u32,
|
||||
part: Part,
|
||||
budget: &mut u64,
|
||||
) -> bool {
|
||||
debug_assert!(
|
||||
records.is_none() || matches!(part, Part::Section { .. }),
|
||||
"records follow the section arena's ranges"
|
||||
);
|
||||
let mut moved = false;
|
||||
for s in meshes {
|
||||
// Nothing of a mesh here: split, one without solid quads (its (0, 0) is no
|
||||
// range of page 0); in the other arenas, one without translucent quads.
|
||||
let Some((_, start, vertices)) = part.of(s).filter(|r| r.0 == page) else {
|
||||
continue;
|
||||
};
|
||||
let bytes = vertices * arena.stride();
|
||||
if moved && bytes > *budget {
|
||||
break;
|
||||
}
|
||||
let Some(to) = arena.alloc_moved(vertices) else {
|
||||
return false;
|
||||
};
|
||||
arena.copy(encoder, (page, start), to, vertices);
|
||||
arena.release(page, start, vertices);
|
||||
if let (Some((flat, device, uploads)), Some(id)) = (&mut flat, s.flat_id) {
|
||||
let key = match part {
|
||||
Part::Translucent => to.0 + flat::TRANSLUCENT_KEYS,
|
||||
_ => to.0,
|
||||
};
|
||||
flat.place(device, encoder, uploads, id, key, to.1, vertices);
|
||||
}
|
||||
part.set(s, to);
|
||||
// Its solid quads start the range in either layout.
|
||||
if let (Part::Section { .. }, Some(records), Some(id)) =
|
||||
(part, records.as_deref_mut(), s.gpu_id)
|
||||
{
|
||||
records.relocate(id, to.0, u64::from(s.solid_base()));
|
||||
}
|
||||
*budget = budget.saturating_sub(bytes);
|
||||
moved = true;
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -18,6 +18,43 @@ pub(crate) struct OutputCapabilities {
|
||||
pub float_frame: bool,
|
||||
/// The device keeps the HUD on a layer of its own (`hud_coverage::supported`).
|
||||
pub hud_layer: bool,
|
||||
/// HDR output is held back: the last start on this window's graphics driver ended before
|
||||
/// HDR output showed a frame ([`held`]). The mode stays; the output is SDR.
|
||||
pub held: bool,
|
||||
}
|
||||
|
||||
/// Whether HDR output is held back on a renderer whose window's graphics driver is `driver`
|
||||
/// (`None` offscreen, which never holds it back: only a window writes the marker).
|
||||
pub(crate) fn held(settings: &Graphics, driver: Option<&str>) -> bool {
|
||||
driver.is_some_and(|driver| settings.hdr_held(driver))
|
||||
}
|
||||
|
||||
/// The graphics driver this window's HDR output starts on ([`driver_fingerprint`]), with the
|
||||
/// system's version as crash reports name it (`crash::operating_system`), read once a run.
|
||||
pub(crate) fn window_driver(info: &wgpu::AdapterInfo) -> String {
|
||||
static OS: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
driver_fingerprint(info, OS.get_or_init(crate::crash::operating_system))
|
||||
}
|
||||
|
||||
/// The graphics driver a window's HDR output starts on, in one line: the backend, the
|
||||
/// adapter's vendor and device IDs and its name, the driver's name and its version, and the
|
||||
/// system's version `os`. Another GPU, a driver update or a system update reads differently:
|
||||
/// Metal names no driver, so on macOS the driver updates with the system.
|
||||
pub fn driver_fingerprint(info: &wgpu::AdapterInfo, os: &str) -> String {
|
||||
let adapter = format!(
|
||||
"{:?} {:04x}:{:04x} {}",
|
||||
info.backend, info.vendor, info.device, info.name
|
||||
);
|
||||
[adapter.as_str(), &info.driver, &info.driver_info, os]
|
||||
.map(|field| {
|
||||
field
|
||||
.chars()
|
||||
.map(|c| if c.is_control() { ' ' } else { c })
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_owned()
|
||||
})
|
||||
.join("; ")
|
||||
}
|
||||
|
||||
/// What the system last reported for a renderer's display: its luminance, and whether it
|
||||
@@ -97,13 +134,15 @@ pub struct EffectiveOutput {
|
||||
pub(crate) reported: Option<Reported>,
|
||||
/// The device draws the 16-bit frame (Smooth Gradients).
|
||||
pub(crate) wide_frame_supported: bool,
|
||||
/// HDR output is held back on this window's graphics driver (`OutputCapabilities::held`).
|
||||
pub(crate) held: bool,
|
||||
/// The HDR output presented; `None` is SDR.
|
||||
pub(crate) encoding: Option<Output>,
|
||||
pub(crate) frame: FrameConfiguration,
|
||||
/// Paper white, peak and black level in nits, as HDR output's last pass and its
|
||||
/// metadata take them, while it is on.
|
||||
pub(crate) nits: [f32; 3],
|
||||
/// The HUD and menus' brightness, as a share of paper white.
|
||||
/// The HUD and menus' brightness, as a share of paper white: their nits over its.
|
||||
pub(crate) ui_brightness: f32,
|
||||
}
|
||||
impl Default for EffectiveOutput {
|
||||
@@ -118,6 +157,7 @@ impl EffectiveOutput {
|
||||
system_hdr: None,
|
||||
reported: None,
|
||||
wide_frame_supported: false,
|
||||
held: false,
|
||||
encoding: None,
|
||||
frame: FrameConfiguration {
|
||||
format: wgpu::TextureFormat::Bgra8Unorm,
|
||||
@@ -136,9 +176,10 @@ impl EffectiveOutput {
|
||||
self.encoding.is_some()
|
||||
}
|
||||
/// Whether `mode` asks for HDR here: On always, Auto where the destination offers it and
|
||||
/// the display reports HDR. The output still needs an offer and a frame that can take it.
|
||||
/// the display reports HDR, neither while HDR output is held back. The output still needs
|
||||
/// an offer and a frame that can take it.
|
||||
pub fn requests_hdr(&self, mode: HdrMode) -> bool {
|
||||
requests_hdr(mode, self.offered.is_some(), self.system_hdr)
|
||||
!self.held && requests_hdr(mode, self.offered.is_some(), self.system_hdr)
|
||||
}
|
||||
/// What a 0.2 pack's `Frame` describes: the HDR display on the layered frame, whose
|
||||
/// picture is light; SDR otherwise, where the frame holds SDR's picture, the one HDR
|
||||
@@ -169,6 +210,10 @@ fn requests_hdr(mode: HdrMode, offered: bool, system: Option<bool>) -> bool {
|
||||
HdrMode::Auto => offered && system == Some(true),
|
||||
}
|
||||
}
|
||||
/// Whether `settings` ask for HDR on a destination of `caps` whose display reports `reading`.
|
||||
fn wanted(settings: &Graphics, caps: OutputCapabilities, reading: DisplayReading) -> bool {
|
||||
!caps.held && requests_hdr(settings.hdr, caps.hdr.is_some(), reading.hdr)
|
||||
}
|
||||
|
||||
/// The frame a renderer made now draws in, for `settings`, the pack's `request`, what the
|
||||
/// destination and device can do (`caps`) and what the display reports.
|
||||
@@ -179,7 +224,7 @@ pub(crate) fn resolve_frame(
|
||||
reading: DisplayReading,
|
||||
swapchain: wgpu::TextureFormat,
|
||||
) -> FrameConfiguration {
|
||||
let wanted = requests_hdr(settings.hdr, caps.hdr.is_some(), reading.hdr);
|
||||
let wanted = wanted(settings, caps, reading);
|
||||
let (format, hud_layer) = super::frame_configuration(
|
||||
wanted && settings.hdr_smooth_gradients,
|
||||
(
|
||||
@@ -210,12 +255,13 @@ pub(crate) fn resolve_output(
|
||||
frame: FrameConfiguration,
|
||||
presents: bool,
|
||||
) -> EffectiveOutput {
|
||||
let wanted = requests_hdr(settings.hdr, caps.hdr.is_some(), reading.hdr);
|
||||
let wanted = wanted(settings, caps, reading);
|
||||
EffectiveOutput {
|
||||
offered: caps.hdr,
|
||||
system_hdr: reading.hdr,
|
||||
reported: reading.luminance,
|
||||
wide_frame_supported: caps.wide_frame,
|
||||
held: caps.held,
|
||||
encoding: caps
|
||||
.hdr
|
||||
.filter(|_| wanted && presents && frame.overlay_format == frame.format),
|
||||
@@ -225,7 +271,8 @@ pub(crate) fn resolve_output(
|
||||
settings.peak_nits(reading.luminance) as f32,
|
||||
settings.black_level_nits(),
|
||||
],
|
||||
ui_brightness: settings.hdr_ui_brightness as f32 / 100.0,
|
||||
ui_brightness: settings.ui_nits(reading.luminance) as f32
|
||||
/ settings.paper_white_nits(reading.luminance) as f32,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,11 +294,13 @@ pub(crate) mod tests {
|
||||
wide_frame: true,
|
||||
float_frame: true,
|
||||
hud_layer: true,
|
||||
held: false,
|
||||
},
|
||||
DisplayReading {
|
||||
luminance: Some(Reported {
|
||||
paper_white: 400,
|
||||
peak: 1000,
|
||||
peak_guessed: false,
|
||||
}),
|
||||
hdr: system,
|
||||
},
|
||||
@@ -378,6 +427,118 @@ pub(crate) mod tests {
|
||||
assert_eq!(settings.hdr, HdrMode::On);
|
||||
}
|
||||
|
||||
/// A window on the driver a start failed on stays SDR, on an SDR frame, whatever the
|
||||
/// mode, and the mode stays as chosen; another driver tries HDR again, and an offscreen
|
||||
/// renderer (no driver) never holds HDR back.
|
||||
#[test]
|
||||
fn a_failed_start_holds_hdr_back_on_its_driver_only() {
|
||||
let failed = "Vulkan 10de:2684 GPU; driver; 1.0";
|
||||
for mode in [HdrMode::On, HdrMode::Auto] {
|
||||
let settings = Graphics {
|
||||
hdr: mode,
|
||||
hdr_failed_start: Some(failed.into()),
|
||||
..Graphics::default()
|
||||
};
|
||||
let request = FrameRequest {
|
||||
linear_pack: false,
|
||||
hdr_float: true,
|
||||
};
|
||||
let on = |driver: Option<&str>| {
|
||||
let (mut caps, reading) = window(Some(Output::Pq), Some(true));
|
||||
caps.held = held(&settings, driver);
|
||||
snapshot(&settings, request, caps, reading)
|
||||
};
|
||||
let out = on(Some(failed));
|
||||
assert_eq!(out.encoding, None, "{mode:?}");
|
||||
assert_eq!(out.frame.format, Bgra8Unorm, "{mode:?}");
|
||||
assert!(!out.frame.hud_layer && out.held && !out.requests_hdr(mode));
|
||||
// Still offered: the page lets the player pick a mode to try again.
|
||||
assert!(out.hdr_offered());
|
||||
assert_eq!(settings.hdr, mode);
|
||||
for driver in [Some("Vulkan 10de:2684 GPU; driver; 1.1"), None] {
|
||||
let out = on(driver);
|
||||
assert_eq!(out.encoding, Some(Output::Pq), "{mode:?} {driver:?}");
|
||||
assert_eq!(out.frame.format, LINEAR_FRAME, "{mode:?} {driver:?}");
|
||||
assert!(!out.held && out.requests_hdr(mode));
|
||||
}
|
||||
}
|
||||
// A marker that named no driver holds on whichever window comes first.
|
||||
let legacy = Graphics {
|
||||
hdr: HdrMode::On,
|
||||
hdr_failed_start: Some(String::new()),
|
||||
..Graphics::default()
|
||||
};
|
||||
assert!(held(&legacy, Some(failed)));
|
||||
assert!(!held(&legacy, None));
|
||||
}
|
||||
|
||||
/// The fingerprint tells another GPU, backend, driver version or system version apart, in
|
||||
/// one line.
|
||||
#[test]
|
||||
fn the_driver_fingerprint_is_one_line_that_changes_with_the_driver() {
|
||||
let os = "windows (x86_64) version Windows 11 (26200.7019)";
|
||||
let info = wgpu::AdapterInfo {
|
||||
name: "NVIDIA GeForce RTX 4090".into(),
|
||||
vendor: 0x10de,
|
||||
device: 0x2684,
|
||||
device_type: wgpu::DeviceType::DiscreteGpu,
|
||||
device_pci_bus_id: "0000:01:00.0".into(),
|
||||
driver: "NVIDIA".into(),
|
||||
driver_info: "581.57\n".into(),
|
||||
backend: wgpu::Backend::Vulkan,
|
||||
subgroup_min_size: 32,
|
||||
subgroup_max_size: 32,
|
||||
transient_saves_memory: None,
|
||||
limit_bucket: None,
|
||||
};
|
||||
let fingerprint = driver_fingerprint(&info, os);
|
||||
assert_eq!(
|
||||
fingerprint,
|
||||
"Vulkan 10de:2684 NVIDIA GeForce RTX 4090; NVIDIA; 581.57; \
|
||||
windows (x86_64) version Windows 11 (26200.7019)"
|
||||
);
|
||||
assert_ne!(
|
||||
driver_fingerprint(&info, "windows (x86_64) version Windows 11 (26200.7171)"),
|
||||
fingerprint
|
||||
);
|
||||
// Metal names no driver: a macOS update is what changes it.
|
||||
let metal = wgpu::AdapterInfo {
|
||||
name: "Apple M3".into(),
|
||||
vendor: 0,
|
||||
device: 0,
|
||||
driver: String::new(),
|
||||
driver_info: String::new(),
|
||||
backend: wgpu::Backend::Metal,
|
||||
..info.clone()
|
||||
};
|
||||
let sonoma = driver_fingerprint(&metal, "macos (aarch64) version macOS 14.6");
|
||||
assert_eq!(
|
||||
sonoma,
|
||||
"Metal 0000:0000 Apple M3; ; ; macos (aarch64) version macOS 14.6"
|
||||
);
|
||||
assert_ne!(
|
||||
driver_fingerprint(&metal, "macos (aarch64) version macOS 15.1"),
|
||||
sonoma
|
||||
);
|
||||
let other = [
|
||||
wgpu::AdapterInfo {
|
||||
driver_info: "582.02".into(),
|
||||
..info.clone()
|
||||
},
|
||||
wgpu::AdapterInfo {
|
||||
backend: wgpu::Backend::Dx12,
|
||||
..info.clone()
|
||||
},
|
||||
wgpu::AdapterInfo {
|
||||
device: 0x2704,
|
||||
..info.clone()
|
||||
},
|
||||
];
|
||||
for other in other {
|
||||
assert_ne!(driver_fingerprint(&other, os), fingerprint, "{other:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto's paper white and peak are this display's report; the player's own values win.
|
||||
#[test]
|
||||
fn auto_levels_follow_this_renderers_report() {
|
||||
@@ -407,6 +568,39 @@ pub(crate) mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// UI Brightness reaches HDR output as its nits over paper white's: 1 on Auto, whatever
|
||||
/// paper white is; a value set by hand holds its nits, Paper White on Auto or set by hand.
|
||||
#[test]
|
||||
fn ui_brightness_is_its_nits_over_paper_whites() {
|
||||
let (caps, reading) = window(Some(Output::Pq), Some(true));
|
||||
let share = |paper_white, ui_nits, reading| {
|
||||
let settings = Graphics {
|
||||
hdr: HdrMode::On,
|
||||
hdr_paper_white: paper_white,
|
||||
hdr_ui_nits: ui_nits,
|
||||
..Graphics::default()
|
||||
};
|
||||
let frame = resolve_frame(
|
||||
&settings,
|
||||
FrameRequest::default(),
|
||||
caps,
|
||||
reading,
|
||||
Bgra8Unorm,
|
||||
);
|
||||
resolve_output(&settings, caps, reading, frame, true).ui_brightness
|
||||
};
|
||||
// Auto: the report's 400 nits, 203 without one, or the player's 250.
|
||||
let unreported = DisplayReading::default();
|
||||
for (paper_white, reading) in [(0, reading), (0, unreported), (250, reading)] {
|
||||
assert_eq!(share(paper_white, 0, reading), 1.0);
|
||||
}
|
||||
assert_eq!(share(0, 100, reading), 0.25);
|
||||
assert_eq!(share(0, 480, reading), 1.2);
|
||||
assert_eq!(share(0, 406, unreported), 2.0);
|
||||
assert_eq!(share(250, 100, reading), 0.4);
|
||||
assert_eq!(share(250, 480, reading), 1.92);
|
||||
}
|
||||
|
||||
/// Two renderers' states, a window's and an offscreen one's, made and changed in turn:
|
||||
/// neither's offer or report shows in the other's snapshot, and the offscreen one
|
||||
/// stopping its watch (HDR Off) leaves the window's report alone.
|
||||
@@ -457,6 +651,7 @@ pub(crate) mod tests {
|
||||
window_reading.luminance = Some(Reported {
|
||||
paper_white: 300,
|
||||
peak: 800,
|
||||
peak_guessed: false,
|
||||
});
|
||||
assert_eq!(window_output(window_reading).nits[..2], [300.0, 800.0]);
|
||||
assert_eq!(
|
||||
|
||||
@@ -0,0 +1,945 @@
|
||||
//! Applying the output a renderer resolves (`output::resolve_output`). Each observation of the
|
||||
//! settings, the destination's capabilities and the display resolves one candidate. One that
|
||||
//! changes only values (the luminance, the UI brightness, what the display reports) updates
|
||||
//! them in place. Another encoding or frame is built and the swapchain configured for it before
|
||||
//! the snapshot says so. One that fails is never published as active, and is not tried again
|
||||
//! until the request, the capabilities or the surface change. Auto keeps HDR on through a
|
||||
//! moment's loss of the display's report ([`auto_grace`]).
|
||||
use super::{DisplayReading, DisplayWatch, Renderer, hdr, output};
|
||||
use crate::graphics::HdrMode;
|
||||
use output::{EffectiveOutput, OutputCapabilities};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// What an output's resources are made for: the encoding presented and the frame.
|
||||
type Key = (Option<hdr::Output>, output::FrameConfiguration);
|
||||
fn key(output: &EffectiveOutput) -> Key {
|
||||
(output.encoding, output.frame)
|
||||
}
|
||||
|
||||
/// An output whose resources or swapchain configuration failed, with the capabilities it
|
||||
/// failed on.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(super) struct Rejected {
|
||||
key: Key,
|
||||
caps: OutputCapabilities,
|
||||
}
|
||||
|
||||
/// What applying an output changes in a renderer.
|
||||
trait Present {
|
||||
/// Builds what `output` presents and configures the swapchain for it, then swaps the new
|
||||
/// resources in. On an error the resources stay as they were, and the swapchain may be
|
||||
/// configured for neither output.
|
||||
fn present(&mut self, output: &EffectiveOutput) -> std::result::Result<(), String>;
|
||||
/// Presents SDR whatever happens: a swapchain that doesn't configure now is configured
|
||||
/// again before the next frame (`surface_unconfigured`).
|
||||
fn present_sdr(&mut self);
|
||||
}
|
||||
|
||||
/// The snapshot to publish for `candidate` after `applied`, on `caps`. The same resources
|
||||
/// take the candidate's values as they are. Other ones are presented first and published only
|
||||
/// once that worked. On a failure the output applied stays where it presents again, else
|
||||
/// SDR, and the failed one is kept in `rejected` until the candidate's resources or the
|
||||
/// capabilities change.
|
||||
fn commit(
|
||||
target: &mut impl Present,
|
||||
applied: &EffectiveOutput,
|
||||
candidate: EffectiveOutput,
|
||||
caps: OutputCapabilities,
|
||||
rejected: &mut Option<Rejected>,
|
||||
) -> EffectiveOutput {
|
||||
let wanted = key(&candidate);
|
||||
if rejected.is_some_and(|r| r.key != wanted || r.caps != caps) {
|
||||
*rejected = None;
|
||||
}
|
||||
if wanted == key(applied) {
|
||||
return candidate;
|
||||
}
|
||||
let kept = EffectiveOutput {
|
||||
encoding: applied.encoding,
|
||||
..candidate
|
||||
};
|
||||
if rejected.is_some() {
|
||||
return kept;
|
||||
}
|
||||
let Err(e) = target.present(&candidate) else {
|
||||
return candidate;
|
||||
};
|
||||
log::warn!(
|
||||
"Horizon renderer: {} could not be presented ({e}); keeping {}",
|
||||
name(candidate.encoding),
|
||||
name(applied.encoding)
|
||||
);
|
||||
*rejected = Some(Rejected { key: wanted, caps });
|
||||
if target.present(&kept).is_ok() {
|
||||
return kept;
|
||||
}
|
||||
log::warn!(
|
||||
"Horizon renderer: {} no longer presents; SDR",
|
||||
name(kept.encoding)
|
||||
);
|
||||
target.present_sdr();
|
||||
EffectiveOutput {
|
||||
encoding: None,
|
||||
..candidate
|
||||
}
|
||||
}
|
||||
|
||||
fn name(encoding: Option<hdr::Output>) -> &'static str {
|
||||
encoding.map_or("SDR", hdr::Output::name)
|
||||
}
|
||||
|
||||
/// The swapchain's configuration for `encoding`: `config` (what every pipeline draws in), or
|
||||
/// HDR10 or scRGB.
|
||||
pub(super) fn surface_config(
|
||||
config: &wgpu::SurfaceConfiguration,
|
||||
encoding: Option<hdr::Output>,
|
||||
) -> wgpu::SurfaceConfiguration {
|
||||
let mut config = config.clone();
|
||||
if let Some(output) = encoding {
|
||||
config.format = output.format();
|
||||
config.view_formats = vec![output.format()];
|
||||
config.color_space = output.color_space();
|
||||
config.usage = wgpu::TextureUsages::RENDER_ATTACHMENT;
|
||||
}
|
||||
config
|
||||
}
|
||||
|
||||
/// How long Auto keeps HDR on once the display stops reporting it. A fixed window that
|
||||
/// coalesces the report's churn as a display wakes or the window moves to another one; not a
|
||||
/// measured sleep/wake time.
|
||||
const AUTO_OFF_DELAY: Duration = Duration::from_millis(500);
|
||||
|
||||
/// The display's report `update_output` resolves Auto with at `now`. While Auto presents HDR
|
||||
/// and the surface still offers that encoding, a report that turns false or unknown starts
|
||||
/// the timer `since`, once, and HDR stays on at the values last applied until it has held
|
||||
/// for [`AUTO_OFF_DELAY`]; a positive report before then clears it. Off and On, a changed
|
||||
/// offer, and entering HDR take `raw` at once.
|
||||
fn auto_grace(
|
||||
now: Instant,
|
||||
mode: HdrMode,
|
||||
offered: Option<hdr::Output>,
|
||||
raw: DisplayReading,
|
||||
applied: &EffectiveOutput,
|
||||
since: &mut Option<Instant>,
|
||||
) -> DisplayReading {
|
||||
let lost = mode == HdrMode::Auto
|
||||
&& applied.encoding.is_some()
|
||||
&& offered == applied.encoding
|
||||
&& raw.hdr != Some(true);
|
||||
if !lost {
|
||||
if since.take().is_some() && raw.hdr == Some(true) {
|
||||
log::info!("Horizon renderer: the display reports HDR again; HDR stays on");
|
||||
}
|
||||
} else if since.is_none() {
|
||||
log::info!(
|
||||
"Horizon renderer: the display stopped reporting HDR; Auto keeps it on for {} ms",
|
||||
AUTO_OFF_DELAY.as_millis()
|
||||
);
|
||||
*since = Some(now);
|
||||
} else if grace_over(*since, now) {
|
||||
*since = None;
|
||||
}
|
||||
graced(*since, raw, applied)
|
||||
}
|
||||
|
||||
fn grace_over(since: Option<Instant>, now: Instant) -> bool {
|
||||
since.is_some_and(|since| now.saturating_duration_since(since) >= AUTO_OFF_DELAY)
|
||||
}
|
||||
|
||||
/// What the display reports as the output takes it: during Auto's grace (`since`), HDR at the
|
||||
/// luminance `applied` was resolved with; `raw` otherwise.
|
||||
pub(super) fn graced(
|
||||
since: Option<Instant>,
|
||||
raw: DisplayReading,
|
||||
applied: &EffectiveOutput,
|
||||
) -> DisplayReading {
|
||||
match since {
|
||||
Some(_) => DisplayReading {
|
||||
luminance: applied.reported,
|
||||
hdr: Some(true),
|
||||
},
|
||||
None => raw,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the output resolves again at `now`: a due poll (`read`, with the surface's
|
||||
/// `offer`; on Wayland, an event) changed `reading` or `caps`, or Auto's grace `since` is
|
||||
/// over. Its end reads the display once more first, however long until the next poll, unless
|
||||
/// a read since the deadline did (the last read is a `DISPLAY_POLL` before the next).
|
||||
fn poll_display(
|
||||
watch: Option<&mut DisplayWatch>,
|
||||
reading: &mut DisplayReading,
|
||||
caps: &mut OutputCapabilities,
|
||||
since: Option<Instant>,
|
||||
now: Instant,
|
||||
read: impl FnOnce() -> DisplayReading,
|
||||
offer: impl FnOnce() -> Option<Option<hdr::Output>>,
|
||||
) -> bool {
|
||||
let over = grace_over(since, now);
|
||||
let Some(watch) = watch else {
|
||||
return over;
|
||||
};
|
||||
if let (Some(since), Some(next)) = (since.filter(|_| over), &mut watch.polled)
|
||||
&& *next < since + AUTO_OFF_DELAY + super::DISPLAY_POLL
|
||||
{
|
||||
*next = now;
|
||||
}
|
||||
let Some(read) = watch.poll(*reading, now, read) else {
|
||||
return over;
|
||||
};
|
||||
observe(reading, caps, read, offer()) || over
|
||||
}
|
||||
|
||||
/// What `surface` offers now (`None` offscreen): Vulkan's offered formats can change with
|
||||
/// desktop HDR or the window's output, and a display can offer the other encoding.
|
||||
fn offer(surface: Option<&wgpu::Surface>, adapter: &wgpu::Adapter) -> Option<Option<hdr::Output>> {
|
||||
Some(hdr::output(&surface?.get_capabilities(adapter)))
|
||||
}
|
||||
|
||||
/// Takes what a due poll read into `reading` and `caps`: the display's report `now`, and what
|
||||
/// the surface offers (`offer`, `None` without a surface). Whether either changed, the offer
|
||||
/// alone included.
|
||||
fn observe(
|
||||
reading: &mut DisplayReading,
|
||||
caps: &mut OutputCapabilities,
|
||||
now: DisplayReading,
|
||||
offer: Option<Option<hdr::Output>>,
|
||||
) -> bool {
|
||||
let before = (*reading, caps.hdr);
|
||||
*reading = now;
|
||||
if let Some(offer) = offer {
|
||||
caps.hdr = offer;
|
||||
}
|
||||
before != (*reading, caps.hdr)
|
||||
}
|
||||
|
||||
/// A renderer applying an output, with the marker its HDR output's start writes.
|
||||
struct Applying<'a> {
|
||||
renderer: &'a mut Renderer,
|
||||
marker: &'a std::path::Path,
|
||||
}
|
||||
|
||||
impl Present for Applying<'_> {
|
||||
fn present(&mut self, output: &EffectiveOutput) -> std::result::Result<(), String> {
|
||||
let r = &mut *self.renderer;
|
||||
let current = r.hdr.as_ref().map(|hdr| hdr.output);
|
||||
let switching = output.encoding.is_some_and(|e| current != Some(e));
|
||||
// If the game dies before HDR output shows a frame, its pipelines' build included, the
|
||||
// marker is still there at the next start, which then holds HDR output back on this
|
||||
// driver.
|
||||
if output.encoding.is_none() || switching {
|
||||
r.mark_hdr_start(output.encoding.is_some(), self.marker);
|
||||
}
|
||||
let built = match output.encoding {
|
||||
Some(encoding) if switching => Some(r.build_hdr(encoding, output)?),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(surface) = &r.surface {
|
||||
r.frame_gen.stop();
|
||||
// The submit thread may still present to the old swapchain.
|
||||
r.queue.wait();
|
||||
let hdr = built.as_ref().or(r.hdr.as_ref());
|
||||
Renderer::configure(
|
||||
&r.device,
|
||||
surface,
|
||||
&surface_config(&r.config, output.encoding),
|
||||
hdr.filter(|_| output.encoding.is_some()),
|
||||
&r.device_lost,
|
||||
)?;
|
||||
r.surface_unconfigured = false;
|
||||
}
|
||||
if output.encoding.is_none() {
|
||||
r.hdr = None;
|
||||
} else if built.is_some() {
|
||||
r.hdr = built;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn present_sdr(&mut self) {
|
||||
let r = &mut *self.renderer;
|
||||
r.hdr = None;
|
||||
r.mark_hdr_start(false, self.marker);
|
||||
if let Some(surface) = &r.surface {
|
||||
r.frame_gen.stop();
|
||||
r.queue.wait();
|
||||
r.surface_unconfigured =
|
||||
Renderer::configure(&r.device, surface, &r.config, None, &r.device_lost).is_err();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
/// HDR output presenting `encoding` for `output`, made apart from the one presented: from
|
||||
/// it where there is one, keeping the player's tuning (`Hdr::retargeted`). An error is
|
||||
/// the device's, caught here rather than left for the next frame.
|
||||
fn build_hdr(
|
||||
&self,
|
||||
encoding: hdr::Output,
|
||||
output: &EffectiveOutput,
|
||||
) -> std::result::Result<hdr::Hdr, String> {
|
||||
let validation = self.device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let memory = self.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
|
||||
let mut hdr = match &self.hdr {
|
||||
Some(hdr) => hdr.retargeted(&self.device, &self.queue, encoding),
|
||||
None => {
|
||||
let mut hdr = hdr::Hdr::new(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
output.frame.format,
|
||||
encoding,
|
||||
output.frame.hud_layer,
|
||||
);
|
||||
hdr.look = self.hdr_lighting_setting;
|
||||
hdr
|
||||
}
|
||||
};
|
||||
let memory = pollster::block_on(memory.pop());
|
||||
if let Some(e) = memory.or(pollster::block_on(validation.pop())) {
|
||||
return Err(e.to_string());
|
||||
}
|
||||
// The metadata the swapchain's configuration sends.
|
||||
[hdr.paper_white, hdr.peak, hdr.black] = output.nits;
|
||||
hdr.ui = output.ui_brightness;
|
||||
Ok(hdr)
|
||||
}
|
||||
|
||||
/// HDR output (Horizon setting) as `settings` ask for it on this renderer's frame
|
||||
/// (`output::resolve_output`): HDR10 or scRGB at paper white, the world's highlights
|
||||
/// expanded up to the peak. Off where the surface can't present it. Offscreen
|
||||
/// (`hdr::offscreen_output`) the frame is drawn the same and its HDR screenshot taken,
|
||||
/// but nothing is presented, and Auto takes its defaults: no display reports.
|
||||
pub fn update_output(
|
||||
&mut self,
|
||||
settings: &crate::graphics::Graphics,
|
||||
marker: &std::path::Path,
|
||||
) {
|
||||
let now = Instant::now();
|
||||
self.watch_hdr(settings.hdr);
|
||||
// Auto's grace ends on a fresh read, also where a settings change comes before the
|
||||
// frame's `display_changed`.
|
||||
if grace_over(self.auto_off_since, now) {
|
||||
self.read_display(now);
|
||||
}
|
||||
self.hold_hdr_back(settings);
|
||||
let reading = auto_grace(
|
||||
now,
|
||||
settings.hdr,
|
||||
self.output_caps.hdr,
|
||||
self.reading,
|
||||
&self.output,
|
||||
&mut self.auto_off_since,
|
||||
);
|
||||
let candidate = output::resolve_output(
|
||||
settings,
|
||||
self.output_caps,
|
||||
reading,
|
||||
self.output.frame,
|
||||
self.surface.is_some() || self.offscreen.is_some(),
|
||||
);
|
||||
let (applied, caps) = (self.output, self.output_caps);
|
||||
let mut rejected = self.output_rejected.take();
|
||||
let renderer = &mut Applying {
|
||||
renderer: self,
|
||||
marker,
|
||||
};
|
||||
let next = commit(renderer, &applied, candidate, caps, &mut rejected);
|
||||
self.output_rejected = rejected;
|
||||
let [paper_white, peak, black] = next.nits;
|
||||
if next.encoding != applied.encoding {
|
||||
match next.encoding {
|
||||
Some(output) => log::info!(
|
||||
"Horizon renderer: HDR on: {} ({:?}), {paper_white}/{peak} nits",
|
||||
output.name(),
|
||||
output.format()
|
||||
),
|
||||
None => log::info!("Horizon renderer: HDR off"),
|
||||
}
|
||||
self.measure_emission();
|
||||
}
|
||||
if let Some(hdr) = &mut self.hdr {
|
||||
hdr.ui = next.ui_brightness;
|
||||
if (hdr.paper_white, hdr.peak, hdr.black) != (paper_white, peak, black) {
|
||||
(hdr.paper_white, hdr.peak, hdr.black) = (paper_white, peak, black);
|
||||
self.send_hdr_metadata();
|
||||
}
|
||||
}
|
||||
self.output = next;
|
||||
}
|
||||
|
||||
/// Follows the selected mode even when Auto currently wants SDR. Off never asks
|
||||
/// the system about the display; selecting On or Auto starts a fresh watch.
|
||||
fn watch_hdr(&mut self, mode: crate::graphics::HdrMode) {
|
||||
if mode == crate::graphics::HdrMode::Off {
|
||||
self.display = None;
|
||||
self.display_fresh = false;
|
||||
self.reading.stop_watching();
|
||||
} else if self.display.is_none() {
|
||||
let (watch, now) =
|
||||
DisplayWatch::new(&self.window, self.surface.as_deref(), &self.adapter);
|
||||
self.display = Some(watch);
|
||||
let offer = offer(self.surface.as_deref(), &self.adapter);
|
||||
self.output_caps.hdr = offer.unwrap_or(self.output_caps.hdr);
|
||||
self.reading = now;
|
||||
self.display_fresh = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// The swapchain's configuration: `config` (what every pipeline draws in), or
|
||||
/// HDR10 or scRGB while HDR output is on.
|
||||
pub(super) fn surface_config(&self) -> wgpu::SurfaceConfiguration {
|
||||
surface_config(&self.config, self.hdr.as_ref().map(|hdr| hdr.output))
|
||||
}
|
||||
|
||||
/// After a new window surface: what it offers, and an output that failed on the old one
|
||||
/// may be tried again. The settings apply again.
|
||||
pub(super) fn surface_recreated(&mut self) {
|
||||
let offer = offer(self.surface.as_deref(), &self.adapter);
|
||||
self.output_caps.hdr = offer.unwrap_or(self.output_caps.hdr);
|
||||
self.display_fresh = true;
|
||||
self.output_rejected = None;
|
||||
}
|
||||
|
||||
/// Whether what the system reports for the display, or what the surface offers, changed
|
||||
/// (another output, the player recalibrating the desktop, desktop HDR offering the other
|
||||
/// encoding): the settings' Auto values and the output follow it then, so apply them
|
||||
/// again. Each due poll reads the surface's capabilities as well, even when the display
|
||||
/// reports the same. So does the end of Auto's grace (`auto_grace`), with or without a
|
||||
/// poll or an event then.
|
||||
pub fn display_changed(&mut self) -> bool {
|
||||
super::flush_display_log();
|
||||
if std::mem::take(&mut self.display_fresh) {
|
||||
return true;
|
||||
}
|
||||
self.read_display(Instant::now())
|
||||
}
|
||||
|
||||
/// The display read where a poll is due or Auto's grace is over (`poll_display`).
|
||||
fn read_display(&mut self, now: Instant) -> bool {
|
||||
let (surface, adapter) = (self.surface.as_deref(), &self.adapter);
|
||||
poll_display(
|
||||
self.display.as_mut(),
|
||||
&mut self.reading,
|
||||
&mut self.output_caps,
|
||||
self.auto_off_since,
|
||||
now,
|
||||
|| DisplayReading::read(surface, adapter),
|
||||
|| offer(surface, adapter),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::graphics::{Graphics, HdrMode, Reported};
|
||||
use hdr::Output::{Pq, ScRgb};
|
||||
use output::FrameRequest;
|
||||
use output::tests::{snapshot, window};
|
||||
|
||||
/// A swapchain that records what it was asked to present, and fails `fails`.
|
||||
#[derive(Default)]
|
||||
struct Swapchain {
|
||||
fails: Vec<Option<hdr::Output>>,
|
||||
/// Each encoding presented, with the luminance its metadata sends.
|
||||
asked: Vec<(Option<hdr::Output>, [f32; 3])>,
|
||||
sdr: usize,
|
||||
}
|
||||
impl Present for Swapchain {
|
||||
fn present(&mut self, output: &EffectiveOutput) -> std::result::Result<(), String> {
|
||||
self.asked.push((output.encoding, output.nits));
|
||||
if self.fails.contains(&output.encoding) {
|
||||
return Err("unsupported".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn present_sdr(&mut self) {
|
||||
self.sdr += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const NO_PACK: FrameRequest = FrameRequest {
|
||||
linear_pack: false,
|
||||
hdr_float: true,
|
||||
};
|
||||
fn on() -> Graphics {
|
||||
Graphics {
|
||||
hdr: HdrMode::On,
|
||||
..Graphics::default()
|
||||
}
|
||||
}
|
||||
/// The candidate on a renderer whose frame is `applied`'s.
|
||||
fn candidate(
|
||||
settings: &Graphics,
|
||||
caps: OutputCapabilities,
|
||||
reading: DisplayReading,
|
||||
applied: &EffectiveOutput,
|
||||
) -> EffectiveOutput {
|
||||
output::resolve_output(settings, caps, reading, applied.frame, true)
|
||||
}
|
||||
|
||||
/// HDR stays on while the display offers scRGB instead of HDR10 and then HDR10 again:
|
||||
/// the swapchain, its metadata and the snapshot follow each, with no Off between.
|
||||
#[test]
|
||||
fn the_output_follows_pq_to_scrgb_and_back_while_hdr_stays_on() {
|
||||
let settings = on();
|
||||
let (mut caps, reading) = window(Some(Pq), Some(true));
|
||||
let mut applied = snapshot(&settings, NO_PACK, caps, reading);
|
||||
assert_eq!(applied.encoding, Some(Pq));
|
||||
let (mut swapchain, mut rejected) = (Swapchain::default(), None);
|
||||
for encoding in [ScRgb, Pq] {
|
||||
caps.hdr = Some(encoding);
|
||||
let next = candidate(&settings, caps, reading, &applied);
|
||||
applied = commit(&mut swapchain, &applied, next, caps, &mut rejected);
|
||||
assert_eq!(
|
||||
(applied.encoding, applied.offered),
|
||||
(Some(encoding), Some(encoding))
|
||||
);
|
||||
assert_eq!(
|
||||
applied.frame,
|
||||
snapshot(&settings, NO_PACK, caps, reading).frame
|
||||
);
|
||||
}
|
||||
let metadata = [400.0, 1000.0, 0.0];
|
||||
assert_eq!(
|
||||
swapchain.asked,
|
||||
[(Some(ScRgb), metadata), (Some(Pq), metadata)]
|
||||
);
|
||||
assert_eq!((rejected, swapchain.sdr), (None, 0));
|
||||
}
|
||||
|
||||
/// The display reports the same luminance and HDR, but the surface now offers scRGB: the
|
||||
/// next due poll reads it, and the output follows with the same nits and report.
|
||||
#[test]
|
||||
fn a_change_of_offer_alone_is_seen_at_the_next_poll() {
|
||||
let settings = on();
|
||||
let (mut caps, mut reading) = window(Some(Pq), Some(true));
|
||||
let applied = snapshot(&settings, NO_PACK, caps, reading);
|
||||
let start = std::time::Instant::now();
|
||||
let mut watch = DisplayWatch {
|
||||
#[cfg(target_os = "linux")]
|
||||
wayland: None,
|
||||
polled: Some(start + super::super::DISPLAY_POLL),
|
||||
};
|
||||
let same = reading;
|
||||
assert_eq!(watch.poll(reading, start, || unreachable!()), None);
|
||||
let due = start + super::super::DISPLAY_POLL;
|
||||
assert_eq!(watch.poll(reading, due, || same), Some(same));
|
||||
assert_eq!(watch.polled, Some(due + super::super::DISPLAY_POLL));
|
||||
assert!(observe(&mut reading, &mut caps, same, Some(Some(ScRgb))));
|
||||
// Nothing changed at the next poll, and offscreen nothing is offered to read.
|
||||
assert!(!observe(&mut reading, &mut caps, same, Some(Some(ScRgb))));
|
||||
assert!(!observe(&mut reading, &mut caps, same, None));
|
||||
let next = candidate(&settings, caps, reading, &applied);
|
||||
let published = commit(&mut Swapchain::default(), &applied, next, caps, &mut None);
|
||||
assert_eq!(published.encoding, Some(ScRgb));
|
||||
assert_eq!(
|
||||
(published.nits, published.reported, published.system_hdr),
|
||||
(applied.nits, applied.reported, applied.system_hdr)
|
||||
);
|
||||
}
|
||||
|
||||
/// The display's luminance or the UI brightness change on the same output: the values
|
||||
/// follow, and nothing is built or configured again.
|
||||
#[test]
|
||||
fn new_nits_on_the_same_output_build_nothing() {
|
||||
let mut settings = on();
|
||||
let (caps, mut reading) = window(Some(Pq), Some(true));
|
||||
let applied = snapshot(&settings, NO_PACK, caps, reading);
|
||||
reading.luminance = Some(Reported {
|
||||
paper_white: 250,
|
||||
peak: 600,
|
||||
peak_guessed: false,
|
||||
});
|
||||
// 150 nits under the reported paper white of 250: 0.6 of it.
|
||||
settings.hdr_ui_nits = 150;
|
||||
let mut swapchain = Swapchain::default();
|
||||
let next = candidate(&settings, caps, reading, &applied);
|
||||
let published = commit(&mut swapchain, &applied, next, caps, &mut None);
|
||||
assert_eq!(published, next);
|
||||
assert_eq!(published.nits, [250.0, 600.0, 0.0]);
|
||||
assert_eq!(published.ui_brightness, 0.6);
|
||||
assert!(swapchain.asked.is_empty());
|
||||
}
|
||||
|
||||
/// An encoding that fails is never the snapshot's: the output applied stays where it
|
||||
/// presents again, else SDR. It is not tried again each frame, nor for new nits, only
|
||||
/// once the request or the capabilities change.
|
||||
#[test]
|
||||
fn an_output_that_fails_is_never_active_nor_tried_every_frame() {
|
||||
let settings = on();
|
||||
let (mut caps, mut reading) = window(Some(Pq), Some(true));
|
||||
let pq = snapshot(&settings, NO_PACK, caps, reading);
|
||||
caps.hdr = Some(ScRgb);
|
||||
let mut swapchain = Swapchain {
|
||||
fails: vec![Some(ScRgb)],
|
||||
..Default::default()
|
||||
};
|
||||
let mut rejected = None;
|
||||
let next = candidate(&settings, caps, reading, &pq);
|
||||
let published = commit(&mut swapchain, &pq, next, caps, &mut rejected);
|
||||
assert_eq!(published.encoding, Some(Pq));
|
||||
assert_eq!(swapchain.asked.len(), 2, "scRGB, then HDR10 again");
|
||||
// Frames after, and a new luminance: no new attempt.
|
||||
for paper_white in [400, 400, 300] {
|
||||
reading.luminance = Some(Reported {
|
||||
paper_white,
|
||||
peak: 1000,
|
||||
peak_guessed: false,
|
||||
});
|
||||
let next = candidate(&settings, caps, reading, &published);
|
||||
let again = commit(&mut swapchain, &published, next, caps, &mut rejected);
|
||||
assert_eq!(again.encoding, Some(Pq));
|
||||
assert_eq!(again.nits[0], paper_white as f32);
|
||||
}
|
||||
assert_eq!(swapchain.asked.len(), 2);
|
||||
// Another request (HDR Off, then On) tries it again.
|
||||
let off = Graphics::default();
|
||||
let sdr = commit(
|
||||
&mut swapchain,
|
||||
&published,
|
||||
candidate(&off, caps, reading, &published),
|
||||
caps,
|
||||
&mut rejected,
|
||||
);
|
||||
assert_eq!((sdr.encoding, rejected), (None, None));
|
||||
let next = candidate(&settings, caps, reading, &sdr);
|
||||
assert_eq!(
|
||||
commit(&mut swapchain, &sdr, next, caps, &mut rejected).encoding,
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
swapchain.asked.iter().map(|a| a.0).collect::<Vec<_>>(),
|
||||
[Some(ScRgb), Some(Pq), None, Some(ScRgb), None]
|
||||
);
|
||||
// So does another capability (the hold lifted here stands for any).
|
||||
caps.held = true;
|
||||
let held = candidate(&settings, caps, reading, &sdr);
|
||||
assert_eq!(held.encoding, None);
|
||||
commit(&mut swapchain, &sdr, held, caps, &mut rejected);
|
||||
caps.held = false;
|
||||
let asked = swapchain.asked.len();
|
||||
let next = candidate(&settings, caps, reading, &sdr);
|
||||
commit(&mut swapchain, &sdr, next, caps, &mut rejected);
|
||||
assert_eq!(swapchain.asked.len(), asked + 2);
|
||||
// The output applied failing too: SDR, never the failed one.
|
||||
let mut broken = Swapchain {
|
||||
fails: vec![Some(ScRgb), Some(Pq)],
|
||||
..Default::default()
|
||||
};
|
||||
let next = candidate(&settings, caps, reading, &pq);
|
||||
let published = commit(&mut broken, &pq, next, caps, &mut None);
|
||||
assert_eq!((published.encoding, broken.sdr), (None, 1));
|
||||
assert_eq!(settings.hdr, HdrMode::On, "the mode stays");
|
||||
// A new surface forgets the failure (`surface_recreated`): the same request on the
|
||||
// same capabilities is tried again.
|
||||
let mut swapchain = Swapchain {
|
||||
fails: vec![Some(ScRgb)],
|
||||
..Default::default()
|
||||
};
|
||||
let mut rejected = None;
|
||||
let next = candidate(&settings, caps, reading, &pq);
|
||||
for _ in 0..2 {
|
||||
commit(&mut swapchain, &pq, next, caps, &mut rejected);
|
||||
}
|
||||
assert_eq!(swapchain.asked.len(), 2);
|
||||
rejected = None;
|
||||
commit(&mut swapchain, &pq, next, caps, &mut rejected);
|
||||
assert_eq!(swapchain.asked.len(), 4);
|
||||
}
|
||||
|
||||
/// A window in Auto presenting HDR10 at 400/1000 nits on the layered frame, followed on
|
||||
/// fake time as the game follows a renderer each frame: `display_changed`, then
|
||||
/// `update_output` where it says so.
|
||||
struct Auto {
|
||||
settings: Graphics,
|
||||
start: Instant,
|
||||
watch: DisplayWatch,
|
||||
reading: DisplayReading,
|
||||
caps: OutputCapabilities,
|
||||
applied: EffectiveOutput,
|
||||
since: Option<Instant>,
|
||||
swapchain: Swapchain,
|
||||
/// What the system reports when it is read, and when (ms) it was.
|
||||
report: DisplayReading,
|
||||
reads: Vec<u64>,
|
||||
}
|
||||
impl Auto {
|
||||
/// `polled`: the system is read every `DISPLAY_POLL` from 0, else it sends events
|
||||
/// (`event`), as on Wayland.
|
||||
fn new(polled: bool) -> Self {
|
||||
let settings = Graphics {
|
||||
hdr: HdrMode::Auto,
|
||||
..Graphics::default()
|
||||
};
|
||||
let (caps, reading) = window(Some(Pq), Some(true));
|
||||
let applied = snapshot(&settings, NO_PACK, caps, reading);
|
||||
assert!(applied.encoding == Some(Pq) && applied.frame.hud_layer);
|
||||
let start = Instant::now();
|
||||
Self {
|
||||
settings,
|
||||
start,
|
||||
watch: DisplayWatch {
|
||||
#[cfg(target_os = "linux")]
|
||||
wayland: None,
|
||||
polled: polled.then_some(start),
|
||||
},
|
||||
reading,
|
||||
caps,
|
||||
applied,
|
||||
since: None,
|
||||
swapchain: Swapchain::default(),
|
||||
report: reading,
|
||||
reads: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// A frame at `ms`.
|
||||
fn frame(&mut self, ms: u64) {
|
||||
if self.read_display(ms) {
|
||||
self.update(ms);
|
||||
}
|
||||
}
|
||||
/// `Renderer::read_display` at `ms`.
|
||||
fn read_display(&mut self, ms: u64) -> bool {
|
||||
let now = self.start + Duration::from_millis(ms);
|
||||
let (report, offer, reads) = (self.report, Some(self.caps.hdr), &mut self.reads);
|
||||
let read = || {
|
||||
reads.push(ms);
|
||||
report
|
||||
};
|
||||
let (reading, caps) = (&mut self.reading, &mut self.caps);
|
||||
poll_display(
|
||||
Some(&mut self.watch),
|
||||
reading,
|
||||
caps,
|
||||
self.since,
|
||||
now,
|
||||
read,
|
||||
|| offer,
|
||||
)
|
||||
}
|
||||
/// Frames from `from` to `to` ms, every 10 ms.
|
||||
fn frames(&mut self, from: u64, to: u64) {
|
||||
for ms in (from..=to).step_by(10) {
|
||||
self.frame(ms);
|
||||
}
|
||||
}
|
||||
/// What the system reports from `ms` on: HDR `hdr`, with no luminance unless it is
|
||||
/// HDR, at 300/800 nits then.
|
||||
fn reports(&mut self, hdr: Option<bool>, offer: Option<hdr::Output>) {
|
||||
self.report = DisplayReading {
|
||||
luminance: (hdr == Some(true)).then_some(Reported {
|
||||
paper_white: 300,
|
||||
peak: 800,
|
||||
peak_guessed: false,
|
||||
}),
|
||||
hdr,
|
||||
};
|
||||
self.caps.hdr = offer;
|
||||
}
|
||||
/// The system's event at `ms`, with what it now `reports`.
|
||||
fn event(&mut self, ms: u64, hdr: Option<bool>, offer: Option<hdr::Output>) {
|
||||
self.reports(hdr, offer);
|
||||
self.reading = self.report;
|
||||
self.update(ms);
|
||||
}
|
||||
/// `update_output` at `ms`, in its order.
|
||||
fn update(&mut self, ms: u64) {
|
||||
let now = self.start + Duration::from_millis(ms);
|
||||
if grace_over(self.since, now) {
|
||||
self.read_display(ms);
|
||||
}
|
||||
let applied = &self.applied;
|
||||
let reading = auto_grace(
|
||||
now,
|
||||
self.settings.hdr,
|
||||
self.caps.hdr,
|
||||
self.reading,
|
||||
applied,
|
||||
&mut self.since,
|
||||
);
|
||||
let next = candidate(&self.settings, self.caps, reading, applied);
|
||||
self.applied = commit(&mut self.swapchain, applied, next, self.caps, &mut None);
|
||||
// While HDR stays on, the frame the game asks for (`Renderer::requested_frame`) is
|
||||
// this one: no new renderer.
|
||||
let reading = graced(self.since, self.reading, &self.applied);
|
||||
let frame = output::resolve_frame(
|
||||
&self.settings,
|
||||
NO_PACK,
|
||||
self.caps,
|
||||
reading,
|
||||
wgpu::TextureFormat::Bgra8UnormSrgb,
|
||||
);
|
||||
assert_eq!(
|
||||
frame == self.applied.frame,
|
||||
self.applied.hdr_on(),
|
||||
"{ms} ms"
|
||||
);
|
||||
}
|
||||
fn presented(&self) -> Vec<Option<hdr::Output>> {
|
||||
self.swapchain.asked.iter().map(|a| a.0).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The display's report flickers off and back, false or unknown: HDR stays on, nothing
|
||||
/// is presented again, and the shader `Frame`, what the settings show as active and the
|
||||
/// nits stay as they were applied, even while the report says nothing of them.
|
||||
#[test]
|
||||
fn a_report_that_flickers_off_and_back_swaps_nothing() {
|
||||
for lost in [Some(false), None] {
|
||||
let mut auto = Auto::new(false);
|
||||
let before = auto.applied;
|
||||
auto.event(0, Some(true), Some(Pq));
|
||||
auto.frames(0, 90);
|
||||
let fresh = auto.applied;
|
||||
assert_eq!(fresh.nits, [300.0, 800.0, 0.0], "a positive report applies");
|
||||
auto.event(100, lost, Some(Pq));
|
||||
auto.frames(100, 390);
|
||||
assert_eq!(auto.applied, fresh, "{lost:?}");
|
||||
assert!(auto.applied.requests_hdr(HdrMode::Auto) && auto.applied.display().hdr);
|
||||
auto.event(400, Some(true), Some(Pq));
|
||||
auto.frames(400, 1500);
|
||||
assert_eq!((auto.applied, auto.since), (fresh, None), "{lost:?}");
|
||||
assert!(auto.presented().is_empty(), "{lost:?}");
|
||||
assert_eq!(auto.applied.frame, before.frame);
|
||||
}
|
||||
}
|
||||
|
||||
/// A loss held through 600 ms turns HDR off once, at 500 ms, and on an event-driven
|
||||
/// display with no event then. The same loss reported again doesn't restart the timer.
|
||||
#[test]
|
||||
fn a_loss_held_turns_hdr_off_once_at_the_deadline() {
|
||||
for lost in [Some(false), None] {
|
||||
let mut auto = Auto::new(false);
|
||||
auto.event(0, lost, Some(Pq));
|
||||
auto.frames(0, 190);
|
||||
auto.event(200, lost, Some(Pq));
|
||||
auto.frames(200, 490);
|
||||
assert_eq!(auto.applied.encoding, Some(Pq), "{lost:?}");
|
||||
auto.frame(500);
|
||||
assert_eq!(auto.applied.encoding, None, "{lost:?}");
|
||||
auto.frames(500, 3000);
|
||||
assert_eq!(auto.presented(), [None], "{lost:?}");
|
||||
assert_eq!(auto.since, None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the system is polled, a false read by the poll that starts the timer is read
|
||||
/// again at the deadline, not two seconds after, before it counts: the display came back
|
||||
/// meanwhile, so HDR stays; where it didn't, HDR turns off then. The two-second cadence
|
||||
/// stays otherwise.
|
||||
#[test]
|
||||
fn a_polled_false_is_read_again_at_the_deadline() {
|
||||
for back in [true, false] {
|
||||
let mut auto = Auto::new(true);
|
||||
auto.frames(0, 1990);
|
||||
auto.reports(Some(false), Some(Pq));
|
||||
auto.frame(2000);
|
||||
assert_eq!(
|
||||
(auto.applied.encoding, auto.since.is_some()),
|
||||
(Some(Pq), true)
|
||||
);
|
||||
if back {
|
||||
auto.reports(Some(true), Some(Pq));
|
||||
}
|
||||
auto.frames(2010, 2490);
|
||||
assert_eq!(auto.applied.encoding, Some(Pq), "never before the deadline");
|
||||
auto.frames(2500, 4990);
|
||||
assert_eq!(auto.reads, [0, 2000, 2500, 4500], "back {back}");
|
||||
let after = if back { vec![] } else { vec![None] };
|
||||
assert_eq!(auto.presented(), after, "back {back}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A settings change at the deadline, before the frame's poll (a slider, F11), reads the
|
||||
/// display before the grace ends, and the frame after reads it no more: the display came
|
||||
/// back meanwhile, so HDR stays; where it didn't, HDR turns off then, once.
|
||||
#[test]
|
||||
fn a_settings_change_at_the_deadline_reads_the_display_first() {
|
||||
for back in [true, false] {
|
||||
let mut auto = Auto::new(true);
|
||||
auto.frames(0, 1990);
|
||||
auto.reports(Some(false), Some(Pq));
|
||||
auto.frame(2000);
|
||||
if back {
|
||||
auto.reports(Some(true), Some(Pq));
|
||||
}
|
||||
auto.frames(2010, 2490);
|
||||
auto.update(2500);
|
||||
assert_eq!(auto.applied.hdr_on(), back, "back {back}");
|
||||
auto.frames(2500, 4990);
|
||||
assert_eq!(auto.reads, [0, 2000, 2500, 4500], "back {back}");
|
||||
let after = if back { vec![] } else { vec![None] };
|
||||
assert_eq!(auto.presented(), after, "back {back}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A positive report clears the timer: a later loss has its own 500 ms.
|
||||
#[test]
|
||||
fn a_positive_report_clears_the_timer() {
|
||||
let mut auto = Auto::new(false);
|
||||
auto.event(100, Some(false), Some(Pq));
|
||||
auto.event(300, Some(true), Some(Pq));
|
||||
auto.event(700, Some(false), Some(Pq));
|
||||
auto.frames(700, 1190);
|
||||
assert_eq!(auto.applied.encoding, Some(Pq));
|
||||
auto.frame(1200);
|
||||
assert_eq!(auto.presented(), [None]);
|
||||
}
|
||||
|
||||
/// Off acts at once, and On or Off clear the timer: Auto again starts it anew.
|
||||
#[test]
|
||||
fn off_and_on_take_no_grace() {
|
||||
let mut auto = Auto::new(false);
|
||||
auto.event(100, Some(false), Some(Pq));
|
||||
auto.settings.hdr = HdrMode::Off;
|
||||
auto.update(200);
|
||||
assert_eq!((auto.presented(), auto.since), (vec![None], None));
|
||||
let mut auto = Auto::new(false);
|
||||
auto.event(100, Some(false), Some(Pq));
|
||||
auto.settings.hdr = HdrMode::On;
|
||||
auto.update(200);
|
||||
assert_eq!((auto.applied.encoding, auto.since), (Some(Pq), None));
|
||||
auto.settings.hdr = HdrMode::Auto;
|
||||
auto.update(300);
|
||||
auto.frames(300, 790);
|
||||
assert_eq!(auto.applied.encoding, Some(Pq));
|
||||
auto.frame(800);
|
||||
assert_eq!(auto.presented(), [None]);
|
||||
}
|
||||
|
||||
/// The surface no longer offering the encoding presented ends the grace at once: SDR
|
||||
/// without a report, the other encoding with one.
|
||||
#[test]
|
||||
fn an_encoding_no_longer_offered_is_not_kept() {
|
||||
for offer in [None, Some(ScRgb)] {
|
||||
let mut auto = Auto::new(false);
|
||||
auto.event(100, Some(false), Some(Pq));
|
||||
auto.event(200, Some(false), offer);
|
||||
assert_eq!((auto.presented(), auto.since), (vec![None], None));
|
||||
let mut auto = Auto::new(false);
|
||||
auto.event(100, None, offer);
|
||||
assert_eq!(auto.presented(), [None], "{offer:?}");
|
||||
}
|
||||
let mut auto = Auto::new(false);
|
||||
auto.event(100, Some(false), Some(Pq));
|
||||
auto.event(200, Some(true), Some(ScRgb));
|
||||
assert_eq!(auto.presented(), [Some(ScRgb)]);
|
||||
}
|
||||
|
||||
/// Two windows' timers run apart.
|
||||
#[test]
|
||||
fn two_renderers_keep_their_own_timer() {
|
||||
let (mut a, mut b) = (Auto::new(false), Auto::new(false));
|
||||
b.start = a.start;
|
||||
a.event(100, Some(false), Some(Pq));
|
||||
b.event(300, Some(false), Some(Pq));
|
||||
a.event(400, Some(true), Some(Pq));
|
||||
for ms in (300..=900).step_by(10) {
|
||||
a.frame(ms);
|
||||
b.frame(ms);
|
||||
assert_eq!(b.applied.hdr_on(), ms < 800, "{ms} ms");
|
||||
}
|
||||
assert_eq!((a.presented(), b.presented()), (vec![], vec![None]));
|
||||
}
|
||||
}
|
||||
@@ -597,9 +597,9 @@ const FACE: &str = " uint hz_q = (uint(gl_VertexIndex) & ~3u) * 6u;\n \
|
||||
/// quad lands whole in one part. The colour's alpha can't decide it, as with `separateAo` it
|
||||
/// holds each vertex's own ambient occlusion.
|
||||
pub fn terrain_parts(
|
||||
terrain: &horizon_render_pipeline::GbuffersInputs,
|
||||
terrain: &horizon_shaders::GbuffersInputs,
|
||||
lava: &[u16],
|
||||
) -> [horizon_render_pipeline::GbuffersInputs; 2] {
|
||||
) -> [horizon_shaders::GbuffersInputs; 2] {
|
||||
let opaque = crate::mesher::OPAQUE_LAYER;
|
||||
let lava: String = lava
|
||||
.iter()
|
||||
@@ -619,7 +619,7 @@ pub fn terrain_parts(
|
||||
impl PackAtlas {
|
||||
/// The vertex inputs of a gbuffers program drawing terrain (`TerrainVertex`, terrain.wgsl's
|
||||
/// `vs_terrain` or, for `flat` draws, `vs_terrain_flat`), in atlas coordinates.
|
||||
pub fn terrain_inputs(&self, flat: bool) -> horizon_render_pipeline::GbuffersInputs {
|
||||
pub fn terrain_inputs(&self, flat: bool) -> horizon_shaders::GbuffersInputs {
|
||||
let mut declarations = String::from(
|
||||
"layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in uvec2 hz_v_uv;\n\
|
||||
layout(location = 2) in uint hz_v_layer;\nlayout(location = 3) in vec4 hz_v_color;\n\
|
||||
@@ -753,7 +753,7 @@ impl PackAtlas {
|
||||
("vaColor", "hz_Color"),
|
||||
("vaNormal", "hz_Normal"),
|
||||
];
|
||||
horizon_render_pipeline::GbuffersInputs {
|
||||
horizon_shaders::GbuffersInputs {
|
||||
declarations,
|
||||
body,
|
||||
attributes: attributes
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! translator's `DRAW_UNIFORMS`): one slot per render stage of a uniform buffer, picked with a
|
||||
//! dynamic offset at each draw.
|
||||
|
||||
use horizon_render_pipeline::{RENDER_STAGES, render_stage};
|
||||
use horizon_shaders::{RENDER_STAGES, render_stage};
|
||||
|
||||
/// Where a run of `Frame::entities` begins, and what it is (slice 7): the run lasts until the
|
||||
/// next span. Recorded only while a shader pack draws entities, so Horizon's own frames carry
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use horizon_render_pipeline::render_stage;
|
||||
use horizon_shaders::render_stage;
|
||||
|
||||
use super::Vertex;
|
||||
use super::pack_draw::EntitySpan;
|
||||
@@ -31,7 +31,7 @@ pub fn buffers() -> [Option<wgpu::VertexBufferLayout<'static>>; 1] {
|
||||
/// position, the shared atlas's UV, colour, light (block, sky in 16ths) and the world-oriented
|
||||
/// normal. The quad's corners, read from the same vertices at set 1, give the face normal where
|
||||
/// the vertex has none (custom geometry) and `at_tangent` along the texture's u, as for terrain.
|
||||
pub fn inputs() -> horizon_render_pipeline::GbuffersInputs {
|
||||
pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
let words = std::mem::size_of::<Vertex>() / 4;
|
||||
let mut declarations = "layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in vec2 hz_v_uv;\n\
|
||||
layout(location = 2) in uint hz_v_overlay;\nlayout(location = 3) in vec4 hz_v_color;\n\
|
||||
@@ -112,7 +112,7 @@ pub fn inputs() -> horizon_render_pipeline::GbuffersInputs {
|
||||
// bright.
|
||||
("horizonTier", "float((hz_v_overlay >> 16u) & 255u)"),
|
||||
];
|
||||
horizon_render_pipeline::GbuffersInputs {
|
||||
horizon_shaders::GbuffersInputs {
|
||||
declarations,
|
||||
body,
|
||||
attributes: attributes
|
||||
@@ -121,7 +121,7 @@ pub fn inputs() -> horizon_render_pipeline::GbuffersInputs {
|
||||
.collect(),
|
||||
after: String::new(),
|
||||
// Cutout, as vanilla's entity types mostly are (OQ-23): the loader's test at 0.1.
|
||||
alpha_test: Some(horizon_render_pipeline::AlphaTest {
|
||||
alpha_test: Some(horizon_shaders::AlphaTest {
|
||||
op: ">",
|
||||
reference: 0.1,
|
||||
}),
|
||||
@@ -138,7 +138,7 @@ pub fn inputs() -> horizon_render_pipeline::GbuffersInputs {
|
||||
/// inputs of [`inputs`], each vertex read from set 1 (`hz_entity_vertices`), as `Vertex` lays
|
||||
/// it out. The draw's corners walk its quads two triangles each, as `quad_indices` does: corner
|
||||
/// `c` of a draw from vertex `first` (a whole quad) is drawn as vertex `first / 4 * 6 + c`.
|
||||
pub fn geometry_inputs() -> horizon_render_pipeline::GeometryInputs {
|
||||
pub fn geometry_inputs() -> horizon_shaders::GeometryInputs {
|
||||
let words = std::mem::size_of::<Vertex>() / 4;
|
||||
let pull = format!(
|
||||
" uint hz_pw = hz_vertex_index * {words}u;\n \
|
||||
@@ -150,7 +150,7 @@ pub fn geometry_inputs() -> horizon_render_pipeline::GeometryInputs {
|
||||
hz_v_light = uvec4(hz_pl & 255u, (hz_pl >> 8u) & 255u, (hz_pl >> 16u) & 255u, hz_pl >> 24u);\n \
|
||||
hz_v_normal = unpackSnorm4x8(hz_entity_vertices[hz_pw + 8u]);\n"
|
||||
);
|
||||
horizon_render_pipeline::GeometryInputs {
|
||||
horizon_shaders::GeometryInputs {
|
||||
inputs: inputs(),
|
||||
pull,
|
||||
// Quad `c / 6`, corner 0, 1, 2, 0, 2, 3 of it.
|
||||
@@ -163,7 +163,7 @@ pub fn geometry_inputs() -> horizon_render_pipeline::GeometryInputs {
|
||||
/// What `gbuffers_clouds` reads from the clouds' mesh (entity vertices, `vs_cloud`): the
|
||||
/// position moved by the clouds' offset, the colour of the face's shade from their palette,
|
||||
/// full light, the face's normal. Set 1 holds the clouds' uniform (`clouds::Uniform`).
|
||||
pub fn cloud_inputs() -> horizon_render_pipeline::GbuffersInputs {
|
||||
pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
|
||||
let declarations = "layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in vec2 hz_v_uv;\n\
|
||||
layout(location = 2) in uint hz_v_overlay;\nlayout(location = 3) in vec4 hz_v_color;\n\
|
||||
layout(location = 4) in uvec4 hz_v_light;\nlayout(location = 5) in vec4 hz_v_normal;\n\
|
||||
@@ -183,7 +183,7 @@ pub fn cloud_inputs() -> horizon_render_pipeline::GbuffersInputs {
|
||||
("vaColor", "hz_Color"),
|
||||
("vaNormal", "hz_Normal"),
|
||||
];
|
||||
horizon_render_pipeline::GbuffersInputs {
|
||||
horizon_shaders::GbuffersInputs {
|
||||
declarations,
|
||||
body,
|
||||
attributes: attributes
|
||||
|
||||
@@ -17,7 +17,7 @@ const UNIFORM: u64 = 2 * 64;
|
||||
const SLOT: u64 = 32;
|
||||
|
||||
/// What `gbuffers_hand` reads: the entity inputs, and the hand's projection from set 1.
|
||||
pub fn inputs() -> horizon_render_pipeline::GbuffersInputs {
|
||||
pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
let mut inputs = super::pack_entities::inputs();
|
||||
// No last frame's hand to measure from: the hand's `at_velocity` reads zero (and its set 1
|
||||
// has no previous vertices).
|
||||
@@ -270,7 +270,7 @@ fn translucent_quad(quad: &[Vertex]) -> bool {
|
||||
/// outside block entities, and `currentRenderedItemId`.
|
||||
fn draw_slot(id: i32, translucent: bool) -> [u8; SLOT as usize] {
|
||||
let mut bytes = [0u8; SLOT as usize];
|
||||
let stage = horizon_render_pipeline::render_stage(if translucent {
|
||||
let stage = horizon_shaders::render_stage(if translucent {
|
||||
"HAND_TRANSLUCENT"
|
||||
} else {
|
||||
"HAND_SOLID"
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use horizon_render_pipeline::{Format, Kind};
|
||||
use horizon_shaders::{BufferObject, CustomImage};
|
||||
use horizon_shaders::{BufferObject, CustomImage, Format, Kind};
|
||||
|
||||
pub(super) struct PackImage {
|
||||
pub(super) def: CustomImage,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
/// width in pixels. The strip's corners 0 and 1 are at the start, 2 and 3 at the end (six
|
||||
/// vertices per edge: pack pipelines draw triangle lists); the
|
||||
/// width is applied in screen space after the pack's transform.
|
||||
pub fn inputs() -> horizon_render_pipeline::GbuffersInputs {
|
||||
pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
let declarations =
|
||||
"layout(location = 0) in vec3 hz_l_start;\nlayout(location = 1) in vec3 hz_l_end;\n\
|
||||
layout(location = 2) in vec4 hz_l_argb;\nlayout(location = 3) in float hz_l_width;\n\
|
||||
@@ -41,7 +41,7 @@ pub fn inputs() -> horizon_render_pipeline::GbuffersInputs {
|
||||
if ((hz_l_c & 1) != 0) hz_l_o = -hz_l_o;\n \
|
||||
gl_Position.xy += hz_l_o * gl_Position.w;\n }\n"
|
||||
.to_owned();
|
||||
horizon_render_pipeline::GbuffersInputs {
|
||||
horizon_shaders::GbuffersInputs {
|
||||
declarations,
|
||||
body,
|
||||
attributes: attributes
|
||||
|
||||
@@ -18,8 +18,7 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use glam::{DMat4, DVec3};
|
||||
use horizon_render_pipeline::{BUFFERS, Format, Kind, SHADOW_COLORS};
|
||||
use horizon_shaders::{Group, Post};
|
||||
use horizon_shaders::{BUFFERS, Format, Group, Kind, Post, SHADOW_COLORS};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use super::pack_draw::DrawUniforms;
|
||||
@@ -55,6 +54,10 @@ mod sky_disc_blend_tests;
|
||||
#[cfg(test)]
|
||||
mod undrawn_terrain_tests;
|
||||
|
||||
mod image_formats;
|
||||
use image_formats::{image_format, storage_formats_of};
|
||||
#[cfg(test)]
|
||||
mod full_outputs_tests;
|
||||
#[cfg(test)]
|
||||
mod shadow_faces_tests;
|
||||
|
||||
@@ -125,7 +128,7 @@ 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_render_pipeline::WATER_MASK`), for translucent terrain.
|
||||
/// The boats' water patches' depth (`horizon_shaders::WATER_MASK`), for translucent terrain.
|
||||
WaterMask,
|
||||
}
|
||||
|
||||
@@ -690,7 +693,7 @@ pub struct PackPost {
|
||||
/// `depthtex0` at the screen's centre, read back for `centerDepthSmooth`.
|
||||
center_depth: Option<super::pack_center_depth::CenterDepth>,
|
||||
/// `shaders.properties`' custom uniforms, after the frame's in `uniforms.values`.
|
||||
custom: Box<dyn horizon_render_pipeline::CustomUniforms>,
|
||||
custom: horizon_shaders::Custom,
|
||||
atlas: Option<wgpu::TextureView>,
|
||||
material_maps: Option<[wgpu::TextureView; 2]>,
|
||||
/// The shared atlas's normal and specular paged maps (`set_paged_maps`).
|
||||
@@ -883,13 +886,13 @@ fn fits(ty: &str, sample_type: wgpu::TextureSampleType) -> bool {
|
||||
/// `hz_depth_linear` for a program: 1 for each of shadowtex0, shadowtex1, `shadow` and
|
||||
/// `watershadow` whose depth filters (`linear`, bit n: shadowtex n). `shadow` is shadowtex1 in a
|
||||
/// program that also declares `watershadow` (6.3).
|
||||
fn depth_linear(interface: &horizon_render_pipeline::Interface, linear: u8) -> [f32; 4] {
|
||||
fn depth_linear(interface: &horizon_shaders::Interface, linear: u8) -> [f32; 4] {
|
||||
let watershadow = interface.samplers.iter().any(|s| s.name == "watershadow");
|
||||
[0, 1, usize::from(watershadow), 0].map(|n| f32::from(linear >> n & 1))
|
||||
}
|
||||
|
||||
fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool) -> Source {
|
||||
let shadow_color = horizon_render_pipeline::shadow_color(name);
|
||||
let shadow_color = horizon_shaders::shadow_color(name);
|
||||
let shadow_depth = match name {
|
||||
"shadowtex0" | "watershadow" | "shadowtex0HW" => Some(0),
|
||||
"shadowtex1" | "shadowtex1HW" => Some(1),
|
||||
@@ -908,7 +911,7 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
|
||||
};
|
||||
}
|
||||
}
|
||||
if shadow_space && horizon_render_pipeline::buffer_index(name).is_some() {
|
||||
if shadow_space && horizon_shaders::buffer_index(name).is_some() {
|
||||
// colortex buffers are not this pass's.
|
||||
return Source::Dummy;
|
||||
}
|
||||
@@ -921,8 +924,8 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
|
||||
"lightmap" => Source::Lightmap,
|
||||
"normals" => Source::Normals,
|
||||
"specular" => Source::Specular,
|
||||
horizon_render_pipeline::WATER_MASK => Source::WaterMask,
|
||||
n => horizon_render_pipeline::buffer_index(n).map_or(Source::Dummy, Source::Color),
|
||||
horizon_shaders::WATER_MASK => Source::WaterMask,
|
||||
n => horizon_shaders::buffer_index(n).map_or(Source::Dummy, Source::Color),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1503,7 +1506,7 @@ fn owned_buffers(
|
||||
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
|
||||
fn bindings(
|
||||
features: wgpu::Features,
|
||||
interface: &horizon_render_pipeline::Interface,
|
||||
interface: &horizon_shaders::Interface,
|
||||
name: &str,
|
||||
group: Group,
|
||||
buffers: &[u32],
|
||||
@@ -1574,12 +1577,12 @@ fn bindings(
|
||||
// pack of ours (#9064) bound it over `gaux2` and `noisetex` but not over names of its
|
||||
// own, where a raw texture was bound; this rule is a fit to those cases.
|
||||
let unbound = t.png
|
||||
&& t.stage != horizon_render_pipeline::textures::EVERY_STAGE
|
||||
&& t.stage != horizon_shaders::EVERY_STAGE
|
||||
&& source(&s.name, false, true, false) == Source::Dummy;
|
||||
!unbound
|
||||
&& (t.stage != pack_textures::stage(group)
|
||||
|| released.0 != t.stage
|
||||
|| horizon_render_pipeline::buffer_index(&t.name)
|
||||
|| horizon_shaders::buffer_index(&t.name)
|
||||
.is_none_or(|b| released.1 & 1 << b == 0))
|
||||
});
|
||||
let image = pack
|
||||
@@ -1826,98 +1829,6 @@ fn bindings(
|
||||
Some((entries, samplers, slots, reads, shadow_reads, images))
|
||||
}
|
||||
|
||||
/// Makes each storage image of `module` in group 0 declare the format its layout entry binds:
|
||||
/// a `layout(r11f_g11f_b10f)` image whose texture is the `RGBA16F` stand-in (`bindings`).
|
||||
fn storage_formats_of(module: &mut naga::Module, entries: &[wgpu::BindGroupLayoutEntry]) {
|
||||
let globals: Vec<_> = module.global_variables.iter().map(|(h, _)| h).collect();
|
||||
for handle in globals {
|
||||
let var = &module.global_variables[handle];
|
||||
let Some(naga::ResourceBinding { group: 0, binding }) = var.binding else {
|
||||
continue;
|
||||
};
|
||||
let naga::TypeInner::Image {
|
||||
dim,
|
||||
arrayed,
|
||||
class: naga::ImageClass::Storage { format, access },
|
||||
} = module.types[var.ty].inner
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let bound = entries.iter().find_map(|e| match e.ty {
|
||||
wgpu::BindingType::StorageTexture { format, .. } if e.binding == binding => {
|
||||
Some(format)
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
if format == naga::StorageFormat::Rg11b10Ufloat
|
||||
&& bound == Some(wgpu::TextureFormat::Rgba16Float)
|
||||
{
|
||||
let ty = module.types.insert(
|
||||
naga::Type {
|
||||
name: module.types[var.ty].name.clone(),
|
||||
inner: naga::TypeInner::Image {
|
||||
dim,
|
||||
arrayed,
|
||||
class: naga::ImageClass::Storage {
|
||||
format: naga::StorageFormat::Rgba16Float,
|
||||
access,
|
||||
},
|
||||
},
|
||||
},
|
||||
naga::Span::UNDEFINED,
|
||||
);
|
||||
module.global_variables[handle].ty = ty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The wgpu format of an image's format qualifier.
|
||||
fn image_format(qualifier: &str) -> Option<wgpu::TextureFormat> {
|
||||
use wgpu::TextureFormat as T;
|
||||
Some(match qualifier {
|
||||
"rgba32f" => T::Rgba32Float,
|
||||
"rgba16f" => T::Rgba16Float,
|
||||
"rg32f" => T::Rg32Float,
|
||||
"rg16f" => T::Rg16Float,
|
||||
"r11f_g11f_b10f" => T::Rg11b10Ufloat,
|
||||
"r32f" => T::R32Float,
|
||||
"r16f" => T::R16Float,
|
||||
"rgba16" => T::Rgba16Unorm,
|
||||
"rgb10_a2" => T::Rgb10a2Unorm,
|
||||
"rgba8" => T::Rgba8Unorm,
|
||||
"rg16" => T::Rg16Unorm,
|
||||
"rg8" => T::Rg8Unorm,
|
||||
"r16" => T::R16Unorm,
|
||||
"r8" => T::R8Unorm,
|
||||
"rgba16_snorm" => T::Rgba16Snorm,
|
||||
"rgba8_snorm" => T::Rgba8Snorm,
|
||||
"rg16_snorm" => T::Rg16Snorm,
|
||||
"rg8_snorm" => T::Rg8Snorm,
|
||||
"r16_snorm" => T::R16Snorm,
|
||||
"r8_snorm" => T::R8Snorm,
|
||||
"rgba32i" => T::Rgba32Sint,
|
||||
"rgba16i" => T::Rgba16Sint,
|
||||
"rgba8i" => T::Rgba8Sint,
|
||||
"rg32i" => T::Rg32Sint,
|
||||
"rg16i" => T::Rg16Sint,
|
||||
"rg8i" => T::Rg8Sint,
|
||||
"r32i" => T::R32Sint,
|
||||
"r16i" => T::R16Sint,
|
||||
"r8i" => T::R8Sint,
|
||||
"rgba32ui" => T::Rgba32Uint,
|
||||
"rgba16ui" => T::Rgba16Uint,
|
||||
"rgb10_a2ui" => T::Rgb10a2Uint,
|
||||
"rgba8ui" => T::Rgba8Uint,
|
||||
"rg32ui" => T::Rg32Uint,
|
||||
"rg16ui" => T::Rg16Uint,
|
||||
"rg8ui" => T::Rg8Uint,
|
||||
"r32ui" => T::R32Uint,
|
||||
"r16ui" => T::R16Uint,
|
||||
"r8ui" => T::R8Uint,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The value slot of the pack's custom uniform `name` for a program declaring it as GLSL type
|
||||
/// `ty`: after the frame's uniforms, in the order of `custom` (each with the type
|
||||
/// `shaders.properties` gives it). A `float`, `int`, `vec2`, `vec3` or `vec4` custom uniform
|
||||
@@ -2283,7 +2194,7 @@ fn build_compute(
|
||||
device: &wgpu::Device,
|
||||
name: String,
|
||||
group: Group,
|
||||
c: horizon_render_pipeline::ComputeProgram,
|
||||
c: horizon_shaders::ComputeProgram,
|
||||
formats: &[wgpu::TextureFormat; BUFFERS],
|
||||
shadow: Option<&[wgpu::TextureFormat; SHADOW_COLORS]>,
|
||||
notes: &mut Vec<String>,
|
||||
@@ -2521,22 +2432,24 @@ impl PackPost {
|
||||
let shadow_config = post.config.shadow.clone();
|
||||
let noise_size = post.config.noise_resolution.unwrap_or(256);
|
||||
let half_lives = post.config.half_lives;
|
||||
for (name, ticks) in horizon_render_pipeline::HALF_LIVES.iter().zip(half_lives) {
|
||||
for (name, ticks) in horizon_shaders::HALF_LIVES.iter().zip(half_lives) {
|
||||
if let Some(t) = ticks {
|
||||
notes.push(format!("`{name}` is {t} ticks"));
|
||||
}
|
||||
}
|
||||
// shadowcolor2 to 7 only for a pack that names `HIGHER_SHADOWCOLOR`.
|
||||
let shadow_colors = (1u16 << post.shadow_colors) - 1;
|
||||
let mut custom: Box<dyn horizon_render_pipeline::CustomUniforms> = Box::new(post.custom);
|
||||
let custom_names = custom.names();
|
||||
let mut custom = post.custom;
|
||||
let custom_names: Vec<(String, String)> = (custom.uniforms())
|
||||
.map(|(name, ty)| (name.to_owned(), ty.to_owned()))
|
||||
.collect();
|
||||
// A first run finds the names neither the frame nor the pack defines.
|
||||
custom.eval(0.0, &pack_uniforms::zero);
|
||||
// Its zeros are no start for `smooth`: the first frame's values are, as in Iris.
|
||||
custom.restart_smoothing();
|
||||
let custom_notes = custom.take_notes();
|
||||
if !custom_notes.is_empty() {
|
||||
notes.push(format!("custom uniforms: {}", custom_notes.join("; ")));
|
||||
if !custom.notes.is_empty() {
|
||||
notes.push(format!("custom uniforms: {}", custom.notes.join("; ")));
|
||||
custom.notes.clear();
|
||||
}
|
||||
let mask =
|
||||
|flips: &[(u32, bool)]| flips.iter().filter(|f| f.1).fold(0u16, |m, f| m | 1 << f.0);
|
||||
@@ -2803,10 +2716,21 @@ impl PackPost {
|
||||
.shadow
|
||||
.zip(shadow_geometry.as_ref())
|
||||
.and_then(|(p, g)| build(p, Some(g)));
|
||||
// A GPU that refuses translucent terrain's program with the boats' water patches gets it
|
||||
// without them (`Post::shadow_water_plain`), rather than no translucent shadows at all.
|
||||
let mut plain = post.shadow_water_plain;
|
||||
let shadow_water = post
|
||||
.shadow_water
|
||||
.zip(shadow_geometry.as_ref())
|
||||
.and_then(|(p, g)| build(p, Some(g)));
|
||||
let unpatched = shadow_water.is_none().then(|| plain.take()).flatten();
|
||||
let unpatched_name = unpatched.as_ref().map(|p| p.name.clone());
|
||||
let shadow_water = shadow_water.or_else(|| {
|
||||
unpatched
|
||||
.zip(shadow_geometry.as_ref())
|
||||
.and_then(|(p, g)| build(p, Some(g)))
|
||||
});
|
||||
let water_unpatched = unpatched_name.filter(|_| shadow_water.is_some());
|
||||
let (hides_sky, hides_celestial) = (post.sky.is_some(), post.sky_textured.is_some());
|
||||
let sky = (post.sky.is_some() || post.sky_textured.is_some()).then(|| {
|
||||
let geometry = PackSky::new(device);
|
||||
@@ -3363,6 +3287,17 @@ impl PackPost {
|
||||
let white = [255u8; 4];
|
||||
let black = [0u8; 4];
|
||||
let flat_normal = [128u8, 128, 255, 255];
|
||||
// Said as what it is, not as a program skipped.
|
||||
if let Some(name) = water_unpatched {
|
||||
let skipped = format!("{name}: skipped: ");
|
||||
if let Some(n) = notes.iter_mut().rev().find(|n| n.starts_with(&skipped)) {
|
||||
*n = format!(
|
||||
"{name}: translucent terrain without the boats' water patches, as the GPU \
|
||||
refused it with them ({})",
|
||||
&n[skipped.len()..]
|
||||
);
|
||||
}
|
||||
}
|
||||
let jobs = (pack.jobs.as_ref())
|
||||
.map(std::cell::RefCell::take)
|
||||
.unwrap_or_default();
|
||||
@@ -5392,7 +5327,7 @@ impl PackPost {
|
||||
self.uniforms.update(frame, inputs, size);
|
||||
// `smooth` starts on the first frame that knows the camera's biome.
|
||||
self.custom.world_frame(inputs.biome_known);
|
||||
self.uniforms.eval_custom(self.custom.as_mut());
|
||||
self.uniforms.eval_custom(&mut self.custom);
|
||||
if self.targets.is_none() {
|
||||
return;
|
||||
}
|
||||
@@ -6063,7 +5998,7 @@ mod tests {
|
||||
texture("composite", "tr", false),
|
||||
texture("composite", "gaux2", true),
|
||||
texture("composite", "noisetex", true),
|
||||
texture(horizon_render_pipeline::textures::EVERY_STAGE, "cp", true),
|
||||
texture(horizon_shaders::EVERY_STAGE, "cp", true),
|
||||
];
|
||||
let names = ["tp", "tr", "gaux2", "noisetex", "cp"];
|
||||
let interface = horizon_shaders::Interface {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
//! A shadow program writing as many vertex outputs as the GPU takes leaves no room for the boats'
|
||||
//! water patches (`GbuffersInputs::water_patches`, one output more): translucent terrain gets
|
||||
//! the program without them, not no shadow program. The programs here are original.
|
||||
use super::destination_tests::Fixture;
|
||||
use super::*;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
fn pack(outputs: u32) -> Post {
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut add = |name: &str, source: &str| {
|
||||
zip.start_file(
|
||||
format!("shaders/{name}"),
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
};
|
||||
let declared: String = (0..outputs)
|
||||
.map(|i| format!("varying vec4 v{i};\n"))
|
||||
.collect();
|
||||
let written: String = (0..outputs).map(|i| format!("v{i}=vec4({i}.0);")).collect();
|
||||
let read: String = (0..outputs).map(|i| format!("+v{i}")).collect();
|
||||
add(
|
||||
"shadow.vsh",
|
||||
&format!(
|
||||
"#version 120\n{declared}void main() {{ gl_Position=vec4(gl_Vertex.xy,0.5,1.0); {written} }}\n"
|
||||
),
|
||||
);
|
||||
add(
|
||||
"shadow.fsh",
|
||||
&format!("#version 120\n{declared}void main() {{ gl_FragData[0]=vec4(0.0){read}; }}\n"),
|
||||
);
|
||||
add(
|
||||
"final.vsh",
|
||||
"#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n",
|
||||
);
|
||||
add(
|
||||
"final.fsh",
|
||||
"#version 120\nconst int shadowMapResolution=16;\nuniform sampler2D shadowcolor0;\n\
|
||||
varying vec2 uv;\nvoid main() { gl_FragColor=texture2D(shadowcolor0,uv); }\n",
|
||||
);
|
||||
let pack = horizon_shaders::Pack::from_zip_bytes(
|
||||
"original-full-outputs",
|
||||
zip.finish().unwrap().into_inner(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let inputs = horizon_shaders::GameInputs {
|
||||
terrain: Some(horizon_shaders::GbuffersInputs {
|
||||
item_id_declared: false,
|
||||
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);\
|
||||
hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n"
|
||||
.into(),
|
||||
attributes: vec![],
|
||||
after: String::new(),
|
||||
alpha_test: None,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
horizon_shaders::load_post(&pack, "", &properties, ¯os, &inputs)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shadow_program_with_every_output_drops_the_boats_patches_not_its_water() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
const ATTRS: [wgpu::VertexAttribute; 1] = wgpu::vertex_attr_array![0=>Float32x3];
|
||||
let layouts = [Some(wgpu::VertexBufferLayout {
|
||||
array_stride: 12,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &ATTRS,
|
||||
})];
|
||||
let geometry = || {
|
||||
Some(Geometry {
|
||||
material_maps: false,
|
||||
buffers: &layouts,
|
||||
table: None,
|
||||
depth_write: true,
|
||||
atlas: None,
|
||||
ids: None,
|
||||
cull: true,
|
||||
})
|
||||
};
|
||||
let outputs = gpu.device.limits().max_inter_stage_shader_variables;
|
||||
let post = gpu.build_with_terrain(pack(outputs), geometry());
|
||||
assert!(post.has_shadow(), "{:?}", post.notes);
|
||||
assert!(post.shadow_water.is_some(), "{:?}", post.notes);
|
||||
assert!(
|
||||
post.notes
|
||||
.iter()
|
||||
.any(|n| n.contains("without the boats' water patches")),
|
||||
"{:?}",
|
||||
post.notes
|
||||
);
|
||||
// With room for them, the patches stay and nothing is said.
|
||||
let post = gpu.build_with_terrain(pack(outputs - 1), geometry());
|
||||
assert!(post.shadow_water.is_some(), "{:?}", post.notes);
|
||||
assert!(
|
||||
!post.notes.iter().any(|n| n.contains("water patches")),
|
||||
"{:?}",
|
||||
post.notes
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! Image formats: a storage image's format qualifier as wgpu's, and the stand-in a module's
|
||||
//! storage images declare.
|
||||
|
||||
/// Makes each storage image of `module` in group 0 declare the format its layout entry binds:
|
||||
/// a `layout(r11f_g11f_b10f)` image whose texture is the `RGBA16F` stand-in (`bindings`).
|
||||
pub(super) fn storage_formats_of(
|
||||
module: &mut naga::Module,
|
||||
entries: &[wgpu::BindGroupLayoutEntry],
|
||||
) {
|
||||
let globals: Vec<_> = module.global_variables.iter().map(|(h, _)| h).collect();
|
||||
for handle in globals {
|
||||
let var = &module.global_variables[handle];
|
||||
let Some(naga::ResourceBinding { group: 0, binding }) = var.binding else {
|
||||
continue;
|
||||
};
|
||||
let naga::TypeInner::Image {
|
||||
dim,
|
||||
arrayed,
|
||||
class: naga::ImageClass::Storage { format, access },
|
||||
} = module.types[var.ty].inner
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let bound = entries.iter().find_map(|e| match e.ty {
|
||||
wgpu::BindingType::StorageTexture { format, .. } if e.binding == binding => {
|
||||
Some(format)
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
if format == naga::StorageFormat::Rg11b10Ufloat
|
||||
&& bound == Some(wgpu::TextureFormat::Rgba16Float)
|
||||
{
|
||||
let ty = module.types.insert(
|
||||
naga::Type {
|
||||
name: module.types[var.ty].name.clone(),
|
||||
inner: naga::TypeInner::Image {
|
||||
dim,
|
||||
arrayed,
|
||||
class: naga::ImageClass::Storage {
|
||||
format: naga::StorageFormat::Rgba16Float,
|
||||
access,
|
||||
},
|
||||
},
|
||||
},
|
||||
naga::Span::UNDEFINED,
|
||||
);
|
||||
module.global_variables[handle].ty = ty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The wgpu format of an image's format qualifier.
|
||||
pub(super) fn image_format(qualifier: &str) -> Option<wgpu::TextureFormat> {
|
||||
use wgpu::TextureFormat as T;
|
||||
Some(match qualifier {
|
||||
"rgba32f" => T::Rgba32Float,
|
||||
"rgba16f" => T::Rgba16Float,
|
||||
"rg32f" => T::Rg32Float,
|
||||
"rg16f" => T::Rg16Float,
|
||||
"r11f_g11f_b10f" => T::Rg11b10Ufloat,
|
||||
"r32f" => T::R32Float,
|
||||
"r16f" => T::R16Float,
|
||||
"rgba16" => T::Rgba16Unorm,
|
||||
"rgb10_a2" => T::Rgb10a2Unorm,
|
||||
"rgba8" => T::Rgba8Unorm,
|
||||
"rg16" => T::Rg16Unorm,
|
||||
"rg8" => T::Rg8Unorm,
|
||||
"r16" => T::R16Unorm,
|
||||
"r8" => T::R8Unorm,
|
||||
"rgba16_snorm" => T::Rgba16Snorm,
|
||||
"rgba8_snorm" => T::Rgba8Snorm,
|
||||
"rg16_snorm" => T::Rg16Snorm,
|
||||
"rg8_snorm" => T::Rg8Snorm,
|
||||
"r16_snorm" => T::R16Snorm,
|
||||
"r8_snorm" => T::R8Snorm,
|
||||
"rgba32i" => T::Rgba32Sint,
|
||||
"rgba16i" => T::Rgba16Sint,
|
||||
"rgba8i" => T::Rgba8Sint,
|
||||
"rg32i" => T::Rg32Sint,
|
||||
"rg16i" => T::Rg16Sint,
|
||||
"rg8i" => T::Rg8Sint,
|
||||
"r32i" => T::R32Sint,
|
||||
"r16i" => T::R16Sint,
|
||||
"r8i" => T::R8Sint,
|
||||
"rgba32ui" => T::Rgba32Uint,
|
||||
"rgba16ui" => T::Rgba16Uint,
|
||||
"rgb10_a2ui" => T::Rgb10a2Uint,
|
||||
"rgba8ui" => T::Rgba8Uint,
|
||||
"rg32ui" => T::Rg32Uint,
|
||||
"rg16ui" => T::Rg16Uint,
|
||||
"rg8ui" => T::Rg8Uint,
|
||||
"r32ui" => T::R32Uint,
|
||||
"r16ui" => T::R16Uint,
|
||||
"r8ui" => T::R8Uint,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
//! a fit to one driver's values, not a rule Iris states. `RGBA4` and `RGBA2` read back the `RGBA8`
|
||||
//! bytes in the same probe, as Horizon's 8-bit buffers already do.
|
||||
|
||||
use horizon_render_pipeline::{Format, Kind};
|
||||
use horizon_shaders::{Format, Kind};
|
||||
use naga::{BinaryOperator, Expression, Literal, MathFunction, Statement, VectorSize};
|
||||
|
||||
/// The bits each channel keeps in a buffer of format `f`, for the formats that keep fewer than
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! with two sides that `shadowcomp` programs flip as composite programs flip colortex (6.1.6).
|
||||
//! Square, `shadowMapResolution` pixels.
|
||||
|
||||
use horizon_render_pipeline::SHADOW_COLORS;
|
||||
use horizon_shaders::SHADOW_COLORS;
|
||||
|
||||
type Ops<T> = wgpu::Operations<T>;
|
||||
type Color = wgpu::Color;
|
||||
@@ -97,11 +97,7 @@ pub(super) fn stand_in(device: &wgpu::Device) -> wgpu::TextureView {
|
||||
}
|
||||
|
||||
impl ShadowTargets {
|
||||
pub fn reset_history(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
shadow: &horizon_render_pipeline::Shadow,
|
||||
) {
|
||||
pub fn reset_history(&mut self, device: &wgpu::Device, shadow: &horizon_shaders::Shadow) {
|
||||
let mut colors = 0;
|
||||
let mut doubled = 0;
|
||||
let formats = std::array::from_fn(|n| match &self.color[n] {
|
||||
@@ -129,7 +125,7 @@ impl ShadowTargets {
|
||||
/// resolution is kept within the device's limit.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
shadow: &horizon_render_pipeline::Shadow,
|
||||
shadow: &horizon_shaders::Shadow,
|
||||
formats: [wgpu::TextureFormat; SHADOW_COLORS],
|
||||
colors: u16,
|
||||
doubled: u16,
|
||||
|
||||
@@ -58,7 +58,7 @@ pub(super) const ATTRIBUTES: [wgpu::VertexAttribute; 2] =
|
||||
wgpu::vertex_attr_array![0 => Float32x3, 1 => Uint32];
|
||||
|
||||
/// What `gbuffers_skybasic` reads from these vertices.
|
||||
pub fn inputs() -> horizon_render_pipeline::GbuffersInputs {
|
||||
pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
let declarations =
|
||||
"layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in uint hz_v_kind;\n\
|
||||
layout(set = 1, binding = 0) uniform HzSky { mat4 hz_sky_stars; vec4 hz_sky_color; \
|
||||
@@ -90,7 +90,7 @@ pub fn inputs() -> horizon_render_pipeline::GbuffersInputs {
|
||||
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n \
|
||||
hz_Normal = vec3(0.0, 0.0, 1.0);\n"
|
||||
);
|
||||
horizon_render_pipeline::GbuffersInputs {
|
||||
horizon_shaders::GbuffersInputs {
|
||||
declarations,
|
||||
body,
|
||||
attributes: vec![
|
||||
@@ -111,8 +111,8 @@ pub fn inputs() -> horizon_render_pipeline::GbuffersInputs {
|
||||
}
|
||||
|
||||
/// What `gbuffers_skytextured` reads from the sun and moon's vertices.
|
||||
pub fn celestial_inputs() -> horizon_render_pipeline::GbuffersInputs {
|
||||
horizon_render_pipeline::GbuffersInputs {
|
||||
pub fn celestial_inputs() -> horizon_shaders::GbuffersInputs {
|
||||
horizon_shaders::GbuffersInputs {
|
||||
declarations:
|
||||
"layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in vec2 hz_v_uv;\n\
|
||||
layout(location = 2) in vec4 hz_v_color;\n"
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
//! A shader pack's custom textures (`texture.<stage>.<name>`, `texture.noise`) on the GPU, and
|
||||
//! which of them a program's sampler reads.
|
||||
|
||||
use horizon_render_pipeline::Kind;
|
||||
use horizon_render_pipeline::textures::{
|
||||
CustomTexture, Dim as TextureDim, EVERY_STAGE, Texels, Texture, TextureSource,
|
||||
};
|
||||
use horizon_shaders::Group;
|
||||
use horizon_shaders::{CustomTexture, Group, Kind, Texels, Texture, TextureDim, TextureSource};
|
||||
|
||||
/// What a custom texture is once uploaded: an image of ours, or one the game already has.
|
||||
pub(super) enum Image {
|
||||
@@ -122,24 +118,24 @@ pub(super) fn find(textures: &[PackTexture], group: Group, ty: &str, name: &str)
|
||||
} else {
|
||||
wgpu::TextureViewDimension::D2
|
||||
};
|
||||
let buffer = horizon_render_pipeline::buffer_index(name);
|
||||
let buffer = horizon_shaders::buffer_index(name);
|
||||
let staged = textures.iter().position(|t| {
|
||||
t.stage == stage(group)
|
||||
&& t.dimension == dimension
|
||||
&& (t.name == name
|
||||
|| buffer.is_some() && horizon_render_pipeline::buffer_index(&t.name) == buffer)
|
||||
|| buffer.is_some() && horizon_shaders::buffer_index(&t.name) == buffer)
|
||||
});
|
||||
staged.or_else(|| {
|
||||
textures
|
||||
.iter()
|
||||
.position(|t| t.stage == EVERY_STAGE && t.dimension == dimension && t.name == name)
|
||||
textures.iter().position(|t| {
|
||||
t.stage == horizon_shaders::EVERY_STAGE && t.dimension == dimension && t.name == name
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The `shaders.properties` key a texture came from, for notes.
|
||||
fn key(stage: &str, name: &str) -> String {
|
||||
match stage {
|
||||
EVERY_STAGE => format!("customTexture.{name}"),
|
||||
horizon_shaders::EVERY_STAGE => format!("customTexture.{name}"),
|
||||
_ => format!("texture.{stage}.{name}"),
|
||||
}
|
||||
}
|
||||
@@ -354,7 +350,7 @@ mod tests {
|
||||
#[test]
|
||||
fn custom_textures_reach_every_program() {
|
||||
use wgpu::TextureViewDimension as D;
|
||||
let every = EVERY_STAGE;
|
||||
let every = horizon_shaders::EVERY_STAGE;
|
||||
let textures = [
|
||||
lightmap(every, "texClouds", D::D3),
|
||||
lightmap("composite", "texClouds", D::D3),
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
//! [-w, w], and the depth buffers packs read hold GL window depth.
|
||||
|
||||
use glam::{DMat4, DVec3, Mat4, Vec3, Vec4};
|
||||
use horizon_render_pipeline::{CustomUniforms, Shadow, Val};
|
||||
use horizon_shaders::Shadow;
|
||||
|
||||
use super::Frame;
|
||||
|
||||
@@ -440,7 +440,7 @@ pub struct FrameUniforms {
|
||||
center_depth: Option<f32>,
|
||||
/// The pack's shadow settings (7.1).
|
||||
pub shadow: Shadow,
|
||||
/// The pack's smoothing half-lives in ticks (`horizon_render_pipeline::HALF_LIVES`).
|
||||
/// The pack's smoothing half-lives in ticks (`horizon_shaders::HALF_LIVES`).
|
||||
pub half_lives: [Option<f32>; 4],
|
||||
}
|
||||
|
||||
@@ -1050,8 +1050,8 @@ impl FrameUniforms {
|
||||
|
||||
impl FrameUniforms {
|
||||
/// Evaluates the pack's custom uniforms with this frame's values and puts them after
|
||||
/// `NAMES`' values, in `custom.names()` order.
|
||||
pub fn eval_custom(&mut self, custom: &mut dyn CustomUniforms) {
|
||||
/// `NAMES`' values, in `custom.uniforms()` order.
|
||||
pub fn eval_custom(&mut self, custom: &mut horizon_shaders::Custom) {
|
||||
if let Some(tick) = self.custom_restart {
|
||||
custom.restart_at(tick);
|
||||
}
|
||||
@@ -1061,8 +1061,8 @@ impl FrameUniforms {
|
||||
let v = values.get(index(name)?)?;
|
||||
let n = components(name);
|
||||
Some(match *v {
|
||||
Value::F(f) => Val::vector(&f.map(f64::from)[..n]),
|
||||
Value::I(i) => Val::vector(&i.map(f64::from)[..n.min(3)]),
|
||||
Value::F(f) => horizon_shaders::Val::vector(&f.map(f64::from)[..n]),
|
||||
Value::I(i) => horizon_shaders::Val::vector(&i.map(f64::from)[..n.min(3)]),
|
||||
})
|
||||
};
|
||||
let dt = match index("frameTime").and_then(|i| values.get(i)) {
|
||||
@@ -1144,9 +1144,9 @@ fn components(name: &str) -> usize {
|
||||
|
||||
/// What `name` gives custom uniforms before the first frame: zeros, at its size, so their
|
||||
/// shapes check out as on a frame (#9086).
|
||||
pub fn zero(name: &str) -> Option<Val> {
|
||||
pub fn zero(name: &str) -> Option<horizon_shaders::Val> {
|
||||
index(name)?;
|
||||
Some(Val::vector(&[0.0; 16][..components(name)]))
|
||||
Some(horizon_shaders::Val::vector(&[0.0; 16][..components(name)]))
|
||||
}
|
||||
|
||||
/// The position of `name` in [`NAMES`].
|
||||
@@ -2717,7 +2717,7 @@ 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_render_pipeline::WATER_PATCHES).then_some(k)
|
||||
(k < 2 * horizon_shaders::WATER_PATCHES).then_some(k)
|
||||
}
|
||||
|
||||
/// The boats' water patches among the frame's entity quads (`WATER_MASK_OVERLAY`), the first
|
||||
@@ -2729,7 +2729,7 @@ pub(super) fn water_patches(entities: &[super::Vertex]) -> Vec<[[f32; 4]; 2]> {
|
||||
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_render_pipeline::WATER_PATCHES
|
||||
&& out.len() < horizon_shaders::WATER_PATCHES
|
||||
{
|
||||
out.push(b);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ use super::pack_post::Weather;
|
||||
/// What `gbuffers_weather` reads from a weather vertex: camera-relative position, the UV in
|
||||
/// repeats of the sprite, colour (with vanilla's fade) and light. Weather has no normal; the
|
||||
/// pack sees straight up.
|
||||
pub fn inputs() -> horizon_render_pipeline::GbuffersInputs {
|
||||
pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
let declarations =
|
||||
"layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in vec2 hz_v_uv;\n\
|
||||
layout(location = 2) in uint hz_v_overlay;\nlayout(location = 3) in vec4 hz_v_color;\n\
|
||||
@@ -31,7 +31,7 @@ pub fn inputs() -> horizon_render_pipeline::GbuffersInputs {
|
||||
("vaColor", "hz_Color"),
|
||||
("vaNormal", "hz_Normal"),
|
||||
];
|
||||
horizon_render_pipeline::GbuffersInputs {
|
||||
horizon_shaders::GbuffersInputs {
|
||||
declarations,
|
||||
body,
|
||||
attributes: attributes
|
||||
@@ -40,7 +40,7 @@ pub fn inputs() -> horizon_render_pipeline::GbuffersInputs {
|
||||
.collect(),
|
||||
after: String::new(),
|
||||
// Vanilla's weather drops texels below 0.1, as Horizon's own weather shader does.
|
||||
alpha_test: Some(horizon_render_pipeline::AlphaTest {
|
||||
alpha_test: Some(horizon_shaders::AlphaTest {
|
||||
op: ">",
|
||||
reference: 0.1,
|
||||
}),
|
||||
|
||||
@@ -158,7 +158,7 @@ impl Weather {
|
||||
match native {
|
||||
Some((pipeline, frame)) => {
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(horizon_render_pipeline::stages::GROUP, frame, &[]);
|
||||
pass.set_bind_group(horizon_shader_api::stages::GROUP, frame, &[]);
|
||||
}
|
||||
None => pass.set_pipeline(&self.pipeline),
|
||||
}
|
||||
@@ -225,7 +225,7 @@ struct HorizonWeatherOut {
|
||||
";
|
||||
|
||||
/// The shader of a native pack's weather stage `source`.
|
||||
fn native_source(source: &str, api: horizon_render_pipeline::Api) -> String {
|
||||
fn native_source(source: &str, api: horizon_shader_api::Api) -> String {
|
||||
format!(
|
||||
"{source}\n// Horizon's weather (entity.wgsl)\n{}{}{NATIVE_ENTRIES}",
|
||||
super::wgsl_prefix::prefixed(&super::entity_shader(), "horizon_entity_"),
|
||||
@@ -245,7 +245,7 @@ pub(super) fn native_pipeline(
|
||||
stage: &horizon_shader_api::check::CheckedStage,
|
||||
constants: &[(String, f64)],
|
||||
) -> Result<wgpu::RenderPipeline, String> {
|
||||
use horizon_render_pipeline::stages::GROUP;
|
||||
use horizon_shader_api::stages::GROUP;
|
||||
let text = native_source(&stage.source, frame.api);
|
||||
let module = naga::front::wgsl::parse_str(&text)
|
||||
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))?;
|
||||
@@ -322,7 +322,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_native_weather_stage_validates() {
|
||||
use horizon_render_pipeline::{Api, StageKind};
|
||||
use horizon_shader_api::{Api, stages::StageKind};
|
||||
for api in [Api::V0_1, Api::V0_2] {
|
||||
let source = StageKind::Weather.prelude(api)
|
||||
+ "fn weather(input: WeatherInput) -> vec4<f32> {\n \
|
||||
|
||||
@@ -50,6 +50,98 @@ struct TaaResources {
|
||||
uniform: wgpu::Buffer,
|
||||
}
|
||||
|
||||
/// One frame handed to an upscaler backend: the jittered world at `render_size` and what
|
||||
/// it needs to rebuild it at `output_size`. The motion is the camera's alone (`reproject`);
|
||||
/// objects that move on their own carry no motion vectors yet.
|
||||
// Only TAA reads it so far; a vendor backend reads the rest (#12918).
|
||||
#[allow(dead_code)]
|
||||
pub(super) struct UpscaleFrame<'a> {
|
||||
/// The world's colour, rendered with this frame's jitter.
|
||||
pub color: &'a wgpu::TextureView,
|
||||
/// The world's depth, at `render_size`.
|
||||
pub depth: &'a wgpu::TextureView,
|
||||
pub render_size: [u32; 2],
|
||||
pub output_size: [u32; 2],
|
||||
/// The jitter in clip space, as added to the projection.
|
||||
pub jitter_clip: [f32; 2],
|
||||
/// The same jitter in rendered pixels, y down.
|
||||
pub jitter_pixels: [f32; 2],
|
||||
/// This frame's unjittered clip space to the previous frame's.
|
||||
pub reproject: [[f32; 4]; 4],
|
||||
/// The history is unusable: a cut, a resize, a frame without the upscaler.
|
||||
pub reset: bool,
|
||||
/// Frames jittered so far.
|
||||
pub index: u32,
|
||||
}
|
||||
|
||||
/// Something that turns the jittered low-resolution world into the output picture.
|
||||
/// TAA (`TaaResources`) is the only backend; `Target` is the per-size state a backend
|
||||
/// keeps beside the world target (TAA's history textures).
|
||||
pub(super) trait UpscalerBackend {
|
||||
type Target;
|
||||
/// The name its GPU timing is shown under.
|
||||
fn name(&self) -> &'static str;
|
||||
/// Write this frame's output and return the bind group that shows it (`draw_onto`).
|
||||
fn resolve<'t>(
|
||||
&self,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
frame: &UpscaleFrame<'_>,
|
||||
target: &'t Self::Target,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) -> &'t wgpu::BindGroup;
|
||||
}
|
||||
|
||||
impl UpscalerBackend for TaaResources {
|
||||
type Target = History;
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"TAA"
|
||||
}
|
||||
|
||||
fn resolve<'t>(
|
||||
&self,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
frame: &UpscaleFrame<'_>,
|
||||
taa: &'t History,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) -> &'t wgpu::BindGroup {
|
||||
// The resolve bind group already samples `frame.color` and `frame.depth`.
|
||||
let i = (frame.index % 2) as usize;
|
||||
let uniform = TaaUniform {
|
||||
reproject: frame.reproject,
|
||||
jitter: [
|
||||
frame.jitter_clip[0],
|
||||
frame.jitter_clip[1],
|
||||
if frame.reset { 1.0 } else { 0.0 },
|
||||
0.0,
|
||||
],
|
||||
};
|
||||
queue.write_buffer(&self.uniform, 0, bytemuck::bytes_of(&uniform));
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &taa.history[i],
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
timestamp_writes: timing.and_then(|t| t.writes(self.name())),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &taa.resolve[i], &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
drop(pass);
|
||||
&taa.show[i]
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Post {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
sampler: wgpu::Sampler,
|
||||
@@ -563,33 +655,25 @@ impl Post {
|
||||
self.finish_with(encoder, screen, &target.group, timing.as_deref_mut());
|
||||
return;
|
||||
};
|
||||
let i = (self.taa_frame % 2) as usize;
|
||||
queue.write_buffer(&res.uniform, 0, bytemuck::bytes_of(&self.taa_this_frame));
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &taa.history[i],
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("TAA")),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&res.pipeline);
|
||||
pass.set_bind_group(0, &taa.resolve[i], &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
drop(pass);
|
||||
let size = self.size().unwrap_or([1, 1]);
|
||||
let frame = UpscaleFrame {
|
||||
color: &target.color,
|
||||
depth: &target.depth,
|
||||
render_size: size,
|
||||
output_size: taa.size,
|
||||
jitter_clip: [self.taa_this_frame.jitter[0], self.taa_this_frame.jitter[1]],
|
||||
jitter_pixels: self.jitter_pixels(),
|
||||
reproject: self.taa_this_frame.reproject,
|
||||
reset: self.taa_this_frame.jitter[2] > 0.5,
|
||||
index: self.taa_frame,
|
||||
};
|
||||
let shown = res.resolve(queue, encoder, &frame, taa, timing.as_deref_mut());
|
||||
let pipeline = if self.upscale > 1.0 {
|
||||
&self.sharpen
|
||||
} else {
|
||||
&self.copy
|
||||
};
|
||||
self.draw_onto(encoder, screen, &taa.show[i], pipeline, timing);
|
||||
self.draw_onto(encoder, screen, shown, pipeline, timing);
|
||||
}
|
||||
|
||||
/// Draw what `group` binds (`bind`) onto `screen`.
|
||||
|
||||
@@ -5,9 +5,23 @@
|
||||
use std::{fs, path::Path};
|
||||
|
||||
pub const FILE: &str = "horizon-graphics.txt";
|
||||
/// Written beside it before HDR output is switched on and removed once the first HDR frame is
|
||||
/// presented. Found at the next start, it means HDR output took the game down, so HDR starts off.
|
||||
/// Written beside it before a window's HDR output is switched on, naming the graphics driver it
|
||||
/// starts on ([`hdr_marker_text`]), and removed once the first HDR frame is presented. Found at
|
||||
/// the next start, it means HDR output took the game down there: HDR output is held back on
|
||||
/// that driver ([`Graphics::hdr_failed_start`]), and the mode stays as the player chose it.
|
||||
pub const HDR_MARKER: &str = "horizon-hdr-pending";
|
||||
/// The marker's text for a window whose HDR output starts on `driver`
|
||||
/// (`gfx::output::window_driver`).
|
||||
pub fn hdr_marker_text(driver: &str) -> String {
|
||||
format!("driver:{driver}\n")
|
||||
}
|
||||
/// The driver a marker names; empty for one written before markers named it.
|
||||
fn marker_driver(text: &str) -> &str {
|
||||
text.lines()
|
||||
.find_map(|line| line.strip_prefix("driver:"))
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
}
|
||||
|
||||
/// What the system reports for the display HDR output goes to, in nits in HDR output's
|
||||
/// scale: where its SDR white sits (the desktop's white) and the peak. Each renderer keeps its
|
||||
@@ -16,6 +30,22 @@ pub const HDR_MARKER: &str = "horizon-hdr-pending";
|
||||
pub struct Reported {
|
||||
pub paper_white: u32,
|
||||
pub peak: u32,
|
||||
/// The peak is the display's own claim (DXGI's `MaxLuminance`, mostly its EDID figure),
|
||||
/// which often runs high: Auto still uses it, but the HDR page asks to calibrate.
|
||||
pub peak_guessed: bool,
|
||||
}
|
||||
|
||||
/// Whether HDR is set up for the display, and whether the player kept the system's setup.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum HdrSetup {
|
||||
/// The system says nothing: the player calibrates.
|
||||
Unknown,
|
||||
/// The system says, but only guesses the peak: the player calibrates.
|
||||
Guessed,
|
||||
/// Peak Brightness and Paper White follow the system.
|
||||
Automatic,
|
||||
/// The system said, and the player changed them anyway.
|
||||
Changed,
|
||||
}
|
||||
|
||||
/// The top of Peak Brightness's scale: `peak_nits_at(PEAK_STEPS)` is PQ's 10 000 nits.
|
||||
@@ -38,6 +68,23 @@ pub fn peak_step_of(nits: f32) -> f32 {
|
||||
};
|
||||
step.clamp(0.0, PEAK_STEPS as f32)
|
||||
}
|
||||
/// The steps of Paper White's and UI Brightness's scale: Auto (0), then 10 to 480 nits by 10.
|
||||
pub const NITS_STEPS: u32 = 48;
|
||||
/// Paper White's or UI Brightness's setting at slider position `v`, 0 to 1 ([`NITS_STEPS`]).
|
||||
pub fn nits_at(v: f32) -> u32 {
|
||||
(v * NITS_STEPS as f32).round() as u32 * 10
|
||||
}
|
||||
/// Where a Paper White or UI Brightness setting sits on its slider, 0 to 1.
|
||||
pub fn nits_position(nits: u32) -> f32 {
|
||||
nits as f32 / (NITS_STEPS * 10) as f32
|
||||
}
|
||||
/// A saved Paper White or UI Brightness: Auto (0), or 10 to 480 nits.
|
||||
fn nits_setting(nits: u32) -> u32 {
|
||||
match nits {
|
||||
0 => 0,
|
||||
n => n.clamp(10, 480),
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple-level presets. `Custom` leaves every option as the player set it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
@@ -490,8 +537,6 @@ pub fn reduced_far_detail_suggested() -> bool {
|
||||
REDUCED_SUGGESTED.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The name the options screen waits on to bind `Graphics::hdr_key`.
|
||||
pub const HDR_KEY: &str = "horizon.hdrSettings";
|
||||
/// The Screenshot Quality choices, times the game's resolution.
|
||||
pub const SCREENSHOT_QUALITIES: [u32; 4] = [1, 2, 4, 8];
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
@@ -801,14 +846,17 @@ pub struct Graphics {
|
||||
/// 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.
|
||||
pub hdr_ui_brightness: u32,
|
||||
/// HDR brightness of the HUD and menus, in nits; 0 is Auto: at paper white
|
||||
/// ([`Graphics::ui_nits`]). The first-person hand stays at the world's.
|
||||
pub hdr_ui_nits: u32,
|
||||
/// HDR Lighting: Horizon's own HDR look, light sources, the sun and sunlight above paper
|
||||
/// white. Off, HDR output shows the same picture as SDR, for shader packs or the vanilla look.
|
||||
pub hdr_lighting: bool,
|
||||
/// A vanilla key name (`HDR_KEY`): opens HDR's settings from the game. Unbound by default.
|
||||
pub hdr_key: String,
|
||||
/// The graphics driver whose last start ended before HDR output showed a frame (the
|
||||
/// marker found, [`HDR_MARKER`]): a window on it stays SDR whatever the mode, until the
|
||||
/// mode is changed or the window starts on another driver ([`Graphics::hdr_held`]).
|
||||
/// Empty for a marker that named no driver: the first one seen holds it.
|
||||
pub hdr_failed_start: Option<String>,
|
||||
/// Battery mode: at most 60 frames per second in a world and 30 in menus
|
||||
/// or behind other windows, and half the worker threads. Off by default;
|
||||
/// it changes when frames are drawn, never what they show or the ticks.
|
||||
@@ -881,9 +929,9 @@ impl Default for Graphics {
|
||||
hdr_paper_white: 0,
|
||||
hdr_peak: 0,
|
||||
hdr_black_level: 0,
|
||||
hdr_ui_brightness: 100,
|
||||
hdr_ui_nits: 0,
|
||||
hdr_lighting: true,
|
||||
hdr_key: "key.keyboard.unknown".into(),
|
||||
hdr_failed_start: None,
|
||||
battery: false,
|
||||
shadow_cache: false,
|
||||
api: GraphicsApi::Auto,
|
||||
@@ -909,17 +957,54 @@ impl Graphics {
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
if fs::remove_file(dir.join(HDR_MARKER)).is_ok() && g.hdr != HdrMode::Off {
|
||||
g.hdr = HdrMode::Off;
|
||||
let marker = dir.join(HDR_MARKER);
|
||||
if let Ok(text) = fs::read_to_string(&marker)
|
||||
&& fs::remove_file(&marker).is_ok()
|
||||
&& g.hdr != HdrMode::Off
|
||||
{
|
||||
g.hdr_failed_start = Some(marker_driver(&text).to_owned());
|
||||
g.save(dir);
|
||||
log::warn!(
|
||||
"the last start ended before HDR output showed a frame; HDR is off now (graphics option hdr, in {FILE})"
|
||||
"the last start ended before HDR output showed a frame; HDR output stays off with that graphics driver until the HDR mode is changed (graphics option hdrFailedStart, in {FILE})"
|
||||
);
|
||||
}
|
||||
g
|
||||
}
|
||||
/// Whether HDR output is held back on a window whose graphics driver is `driver`
|
||||
/// (`gfx::output::window_driver`): the last start on it ended before HDR output
|
||||
/// showed a frame ([`Graphics::hdr_failed_start`]).
|
||||
pub fn hdr_held(&self, driver: &str) -> bool {
|
||||
self.hdr_failed_start
|
||||
.as_deref()
|
||||
.is_some_and(|failed| failed.is_empty() || failed == driver)
|
||||
}
|
||||
/// The window's renderer runs on `driver`: a failed start that named no driver is this
|
||||
/// one's now, and another driver's (a driver update, another GPU) is dropped, so HDR output
|
||||
/// is tried again. True when it changed.
|
||||
pub fn settle_failed_start(&mut self, driver: &str) -> bool {
|
||||
match self.hdr_failed_start.as_deref() {
|
||||
Some("") => self.hdr_failed_start = Some(driver.to_owned()),
|
||||
Some(failed) if failed != driver => self.hdr_failed_start = None,
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `before`, as Undo brings it back, but never the hold on a driver HDR output failed to
|
||||
/// start on: that stays as it is now, and goes when Undo changes the mode, as any change of
|
||||
/// the mode drops it.
|
||||
pub fn undone_to(&self, before: &Self) -> Self {
|
||||
Self {
|
||||
hdr_failed_start: self
|
||||
.hdr_failed_start
|
||||
.clone()
|
||||
.filter(|_| self.hdr == before.hdr),
|
||||
..before.clone()
|
||||
}
|
||||
}
|
||||
pub fn parse(text: &str) -> Self {
|
||||
let mut g = Self::default();
|
||||
// UI Brightness: `hdrUiNits`, else an older file's share of Paper White.
|
||||
let (mut ui_nits, mut ui_share) = (None, None);
|
||||
for line in text.lines() {
|
||||
let Some((key, value)) = line.split_once(':') else {
|
||||
continue;
|
||||
@@ -1009,12 +1094,9 @@ impl Graphics {
|
||||
"frameGeneration" => g.frame_generation = FrameGeneration::parse(value),
|
||||
"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) {
|
||||
0 => 0,
|
||||
n => n.clamp(10, 480),
|
||||
}
|
||||
}
|
||||
"hdrPaperWhite" => g.hdr_paper_white = nits_setting(number(g.hdr_paper_white)),
|
||||
"hdrUiNits" => ui_nits = value.parse().ok().map(nits_setting),
|
||||
"hdrUiBrightness" => ui_share = value.parse::<u32>().ok(),
|
||||
"hdrPeak" => {
|
||||
g.hdr_peak = match number(g.hdr_peak) {
|
||||
0 => 0,
|
||||
@@ -1022,10 +1104,7 @@ impl Graphics {
|
||||
}
|
||||
}
|
||||
"hdrBlackLevel" => g.hdr_black_level = number(g.hdr_black_level).min(100),
|
||||
"hdrUiBrightness" => {
|
||||
g.hdr_ui_brightness = number(g.hdr_ui_brightness).clamp(25, 100)
|
||||
}
|
||||
"hdrKey" if value.starts_with("key.") => g.hdr_key = value.to_owned(),
|
||||
"hdrFailedStart" => g.hdr_failed_start = Some(value.to_owned()),
|
||||
"batteryMode" => g.battery = flag(g.battery),
|
||||
"shadowCache" => g.shadow_cache = flag(g.shadow_cache),
|
||||
"batchTranslucent" => g.translucent_batching = flag(g.translucent_batching),
|
||||
@@ -1042,11 +1121,20 @@ impl Graphics {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// The share was 25 to 100% of Paper White. 100% is Auto, and so is any share of an
|
||||
// Auto Paper White, whose nits the file doesn't hold; another share of a Paper White set
|
||||
// by hand is its nits, to the nearest step.
|
||||
g.hdr_ui_nits = ui_nits.unwrap_or(match (ui_share, g.hdr_paper_white) {
|
||||
(Some(share), paper) if share < 100 && paper != 0 => {
|
||||
((paper * share.max(25) + 500) / 1000).max(1) * 10
|
||||
}
|
||||
_ => 0,
|
||||
});
|
||||
g
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
let mut text = format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nentityCullingPrecheck:{}\nparticleCulling:{}\nleafCulling:{}\nopaqueIce:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nupscalingSharpness:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrLighting:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\ngpuCulling:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nentityCullingPrecheck:{}\nparticleCulling:{}\nleafCulling:{}\nopaqueIce:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nupscalingSharpness:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiNits:{}\nhdrLighting:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\ngpuCulling:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
@@ -1093,9 +1181,8 @@ impl Graphics {
|
||||
self.hdr_paper_white,
|
||||
self.hdr_peak,
|
||||
self.hdr_black_level,
|
||||
self.hdr_ui_brightness,
|
||||
self.hdr_ui_nits,
|
||||
bool_text(self.hdr_lighting),
|
||||
self.hdr_key,
|
||||
bool_text(self.battery),
|
||||
bool_text(self.shadow_cache),
|
||||
self.api.name(),
|
||||
@@ -1104,17 +1191,12 @@ impl Graphics {
|
||||
bool_text(self.hdr_smooth_gradients),
|
||||
self.gpu_culling.key(),
|
||||
);
|
||||
if let Some(driver) = &self.hdr_failed_start {
|
||||
text += &format!("hdrFailedStart:{driver}\n");
|
||||
}
|
||||
text += &self.blue_map.serialize();
|
||||
text
|
||||
}
|
||||
/// Binds Horizon's graphics key `name` (`HDR_KEY`); false for another name.
|
||||
pub fn set_key(&mut self, name: &str, value: String) -> bool {
|
||||
match name {
|
||||
HDR_KEY => self.hdr_key = value,
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
pub fn save(&self, dir: &Path) {
|
||||
let _ = fs::write(dir.join(FILE), self.serialize());
|
||||
}
|
||||
@@ -1126,6 +1208,13 @@ impl Graphics {
|
||||
n => n,
|
||||
}
|
||||
}
|
||||
/// The HUD and menus' brightness, in nits: the player's, or on Auto paper white's.
|
||||
pub fn ui_nits(&self, reported: Option<Reported>) -> u32 {
|
||||
match self.hdr_ui_nits {
|
||||
0 => self.paper_white_nits(reported),
|
||||
n => n,
|
||||
}
|
||||
}
|
||||
/// The peak the world's highlights reach, in nits: the player's, or on Auto
|
||||
/// what the system `reported`, else 1000.
|
||||
pub fn peak_nits(&self, reported: Option<Reported>) -> u32 {
|
||||
@@ -1134,6 +1223,15 @@ impl Graphics {
|
||||
n => n,
|
||||
}
|
||||
}
|
||||
/// How HDR is set up for the display the system `reported`.
|
||||
pub fn hdr_setup(&self, reported: Option<Reported>) -> HdrSetup {
|
||||
match reported {
|
||||
None => HdrSetup::Unknown,
|
||||
Some(_) if self.hdr_peak != 0 || self.hdr_paper_white != 0 => HdrSetup::Changed,
|
||||
Some(r) if r.peak_guessed => HdrSetup::Guessed,
|
||||
Some(_) => HdrSetup::Automatic,
|
||||
}
|
||||
}
|
||||
/// 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 {
|
||||
@@ -1206,6 +1304,83 @@ pub fn apply_preset(preset: Preset, s: &mut crate::settings::Settings) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// UI Brightness is saved in nits, 0 for Auto. An older file's share of Paper White reads
|
||||
/// as Auto at 100% or with Paper White on Auto, else as that share of it to the nearest step.
|
||||
#[test]
|
||||
fn ui_brightness_round_trips_in_nits_and_reads_older_shares() {
|
||||
for (file, nits) in [
|
||||
("", 0),
|
||||
("hdrUiNits:0\n", 0),
|
||||
("hdrUiNits:160\n", 160),
|
||||
("hdrUiNits:5\n", 10),
|
||||
("hdrUiNits:9000\n", 480),
|
||||
("hdrUiNits:x\n", 0),
|
||||
// Older files: 100% is Auto,
|
||||
("hdrPaperWhite:200\nhdrUiBrightness:100\n", 0),
|
||||
// any share of Paper White's Auto too,
|
||||
("hdrUiBrightness:60\n", 0),
|
||||
("hdrPaperWhite:0\nhdrUiBrightness:25\n", 0),
|
||||
// and another share of a Paper White set by hand is its nits.
|
||||
("hdrPaperWhite:200\nhdrUiBrightness:60\n", 120),
|
||||
("hdrUiBrightness:50\nhdrPaperWhite:203\n", 100),
|
||||
("hdrPaperWhite:250\nhdrUiBrightness:75\n", 190),
|
||||
("hdrPaperWhite:10\nhdrUiBrightness:25\n", 10),
|
||||
("hdrPaperWhite:480\nhdrUiBrightness:10\n", 120),
|
||||
// The new key wins over the old one.
|
||||
(
|
||||
"hdrPaperWhite:200\nhdrUiBrightness:60\nhdrUiNits:300\n",
|
||||
300,
|
||||
),
|
||||
("hdrUiNits:0\nhdrPaperWhite:200\nhdrUiBrightness:60\n", 0),
|
||||
] {
|
||||
let g = Graphics::parse(file);
|
||||
assert_eq!(g.hdr_ui_nits, nits, "{file}");
|
||||
let saved = g.serialize();
|
||||
assert!(!saved.contains("hdrUiBrightness"), "{file}");
|
||||
assert!(saved.contains(&format!("\nhdrUiNits:{nits}\n")), "{file}");
|
||||
assert_eq!(Graphics::parse(&saved), g, "{file}");
|
||||
}
|
||||
let mut g = Graphics::default();
|
||||
assert_eq!(g.ui_nits(None), 203);
|
||||
let reported = Some(Reported {
|
||||
paper_white: 300,
|
||||
peak: 1000,
|
||||
peak_guessed: false,
|
||||
});
|
||||
assert_eq!(g.ui_nits(reported), 300);
|
||||
g.hdr_paper_white = 120;
|
||||
assert_eq!(g.ui_nits(reported), 120);
|
||||
g.hdr_ui_nits = 400;
|
||||
assert_eq!((g.ui_nits(None), g.ui_nits(reported)), (400, 400));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_setup_asks_to_calibrate_where_the_peak_is_guessed() {
|
||||
let wayland = Reported {
|
||||
paper_white: 203,
|
||||
peak: 1015,
|
||||
peak_guessed: false,
|
||||
};
|
||||
let windows = Reported {
|
||||
paper_white: 400,
|
||||
peak: 1000,
|
||||
peak_guessed: true,
|
||||
};
|
||||
let mut g = Graphics::default();
|
||||
assert_eq!(g.hdr_setup(None), HdrSetup::Unknown);
|
||||
assert_eq!(g.hdr_setup(Some(wayland)), HdrSetup::Automatic);
|
||||
assert_eq!(g.hdr_setup(Some(windows)), HdrSetup::Guessed);
|
||||
// Auto still takes the guess, over the 1000-nit default.
|
||||
assert_eq!(g.peak_nits(Some(windows)), 1000);
|
||||
assert_eq!(g.paper_white_nits(Some(windows)), 400);
|
||||
for (peak, paper_white) in [(800, 0), (0, 240)] {
|
||||
(g.hdr_peak, g.hdr_paper_white) = (peak, paper_white);
|
||||
assert_eq!(g.hdr_setup(Some(windows)), HdrSetup::Changed);
|
||||
assert_eq!(g.hdr_setup(Some(wayland)), HdrSetup::Changed);
|
||||
assert_eq!(g.hdr_setup(None), HdrSetup::Unknown);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_modes_keep_boolean_files_and_round_trip_auto() {
|
||||
assert_eq!(Graphics::parse("").hdr, HdrMode::Off);
|
||||
@@ -1243,6 +1418,85 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn scratch(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"horizon-{name}-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::thread::current().id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// A start that finds the marker (HDR output took the last one down) keeps the mode the
|
||||
/// player chose and saves the driver the marker names; HDR Off saves nothing.
|
||||
#[test]
|
||||
fn a_marker_keeps_the_mode_and_holds_the_driver_it_names() {
|
||||
let driver = "Vulkan 10de:2684 NVIDIA GeForce RTX 4090; NVIDIA; 581.57";
|
||||
for mode in HdrMode::ALL {
|
||||
let dir = scratch(&format!("hdr-marker-{mode:?}"));
|
||||
let saved = Graphics {
|
||||
hdr: mode,
|
||||
..Graphics::default()
|
||||
};
|
||||
saved.save(&dir);
|
||||
fs::write(dir.join(HDR_MARKER), hdr_marker_text(driver)).unwrap();
|
||||
let g = Graphics::load(&dir);
|
||||
let failed = (mode != HdrMode::Off).then(|| driver.to_owned());
|
||||
assert_eq!((g.hdr, &g.hdr_failed_start), (mode, &failed), "{mode:?}");
|
||||
assert!(!dir.join(HDR_MARKER).exists(), "{mode:?}");
|
||||
// Saved for good: the next start, without a marker, reads the same.
|
||||
let again = Graphics::load(&dir);
|
||||
assert_eq!((again.hdr, again.hdr_failed_start), (mode, failed));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// Today's marker names no driver: it holds whichever driver the window starts on first,
|
||||
/// and is that driver's from then on.
|
||||
#[test]
|
||||
fn a_marker_without_a_driver_holds_the_first_one_seen() {
|
||||
let dir = scratch("hdr-marker-legacy");
|
||||
Graphics {
|
||||
hdr: HdrMode::Auto,
|
||||
..Graphics::default()
|
||||
}
|
||||
.save(&dir);
|
||||
fs::write(dir.join(HDR_MARKER), "HDR output was being switched on\n").unwrap();
|
||||
let mut g = Graphics::load(&dir);
|
||||
assert_eq!(
|
||||
(g.hdr, g.hdr_failed_start.as_deref()),
|
||||
(HdrMode::Auto, Some(""))
|
||||
);
|
||||
let (first, second) = (
|
||||
"Vulkan 10de:2684 GPU; NVIDIA; 1",
|
||||
"Dx12 1002:744c GPU; AMD; 2",
|
||||
);
|
||||
assert!(g.hdr_held(first) && g.hdr_held(second));
|
||||
assert!(g.settle_failed_start(first));
|
||||
assert!(g.hdr_held(first) && !g.hdr_held(second));
|
||||
assert!(!g.settle_failed_start(first));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The window starting on another driver (an update, another GPU) drops the hold, so HDR
|
||||
/// output is tried again; on the same driver it stays.
|
||||
#[test]
|
||||
fn another_driver_drops_the_hold() {
|
||||
let held = "Vulkan 10de:2684 GPU; NVIDIA; 581.57";
|
||||
let mut g = Graphics {
|
||||
hdr: HdrMode::On,
|
||||
hdr_failed_start: Some(held.into()),
|
||||
..Graphics::default()
|
||||
};
|
||||
assert!(!g.settle_failed_start(held));
|
||||
assert!(g.hdr_held(held));
|
||||
assert!(g.settle_failed_start("Vulkan 10de:2684 GPU; NVIDIA; 582.02"));
|
||||
assert_eq!((g.hdr, g.hdr_failed_start), (HdrMode::On, None));
|
||||
assert!(!Graphics::default().hdr_held(held));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips() {
|
||||
let g = Graphics {
|
||||
@@ -1292,9 +1546,11 @@ mod tests {
|
||||
hdr_paper_white: 240,
|
||||
hdr_peak: 1400,
|
||||
hdr_black_level: 12,
|
||||
hdr_ui_brightness: 60,
|
||||
hdr_ui_nits: 160,
|
||||
hdr_lighting: false,
|
||||
hdr_key: "key.keyboard.h".into(),
|
||||
hdr_failed_start: Some(
|
||||
"Vulkan 10de:2684 NVIDIA GeForce RTX 4090; NVIDIA; 581.57".into(),
|
||||
),
|
||||
battery: true,
|
||||
shadow_cache: true,
|
||||
api: GraphicsApi::Dx12,
|
||||
@@ -1303,6 +1559,15 @@ mod tests {
|
||||
shader_pack: "Golden Hour".into(),
|
||||
};
|
||||
assert_eq!(Graphics::parse(&g.serialize()), g);
|
||||
// A legacy marker's hold, which names no driver, round-trips too; none writes no line.
|
||||
for failed in [Some(String::new()), None] {
|
||||
let graphics = Graphics {
|
||||
hdr_failed_start: failed,
|
||||
..Graphics::default()
|
||||
};
|
||||
assert_eq!(Graphics::parse(&graphics.serialize()), graphics);
|
||||
}
|
||||
assert!(!Graphics::default().serialize().contains("hdrFailedStart"));
|
||||
for mode in EntityCullingMode::ALL {
|
||||
let graphics = Graphics {
|
||||
entity_culling: mode,
|
||||
@@ -1424,6 +1689,10 @@ mod tests {
|
||||
Graphics::parse("shaderPack:Pack.zip\n"),
|
||||
Graphics::default()
|
||||
);
|
||||
// The HDR page's old shortcut key loads as nothing, and the next save drops it.
|
||||
let old = Graphics::parse("hdr:true\nhdrKey:key.keyboard.h\nhdrPeak:1000\n");
|
||||
assert_eq!((old.hdr, old.hdr_peak), (HdrMode::On, 1000));
|
||||
assert!(!old.serialize().contains("hdrKey"));
|
||||
assert_eq!(
|
||||
Graphics::parse(
|
||||
"workers:abc\nsky:maybe\ncullLeaves:true\nentityCulling:maybe\nleafCulling:maybe\n"
|
||||
|
||||
@@ -8,13 +8,14 @@ use crate::{
|
||||
gfx::EffectiveOutput,
|
||||
graphics::{
|
||||
EntityCullingMode, FarDetail, FrameGeneration, GpuCullingMode, GraphicsApi, HdrMode,
|
||||
LeafCullingMode, Overlay, Pacing, ParticleCullingMode, PresentMode, Preset, Upscaling,
|
||||
HdrSetup as Setup, 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, Binding, Group, Output, Row, Screen, Sheet, Value},
|
||||
settings_shell::{self as shell, Group, Output, Row, Screen, Sheet, Value},
|
||||
text::Text,
|
||||
};
|
||||
|
||||
@@ -106,8 +107,6 @@ pub enum H {
|
||||
FrameGeneration,
|
||||
Hdr,
|
||||
HdrBlackLevel,
|
||||
/// Binds `Graphics::hdr_key`.
|
||||
HdrKey,
|
||||
/// Starts HDR calibration (`crate::hdr_calibration`).
|
||||
HdrCalibrate,
|
||||
HdrPeak,
|
||||
@@ -614,7 +613,7 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
h(
|
||||
H::HdrUiBrightness,
|
||||
None,
|
||||
"How bright the HUD and menus are, against paper white.",
|
||||
"How bright the HUD and menus are. Auto: at paper white.",
|
||||
),
|
||||
h(
|
||||
H::HdrLighting,
|
||||
@@ -623,14 +622,6 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Shortcut",
|
||||
vec![h(
|
||||
H::HdrKey,
|
||||
None,
|
||||
"A key that opens this screen from the game. Not bound at first.",
|
||||
)],
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
@@ -685,29 +676,14 @@ const LOD_STEPS: [u32; 24] = [
|
||||
crate::lod::MAX_CHUNKS,
|
||||
];
|
||||
|
||||
/// Whether the system set HDR up for the display, and whether the player kept it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Setup {
|
||||
/// The system says nothing: the player calibrates.
|
||||
Unknown,
|
||||
/// Peak Brightness and Paper White follow the system.
|
||||
Automatic,
|
||||
/// The system said, and the player changed them anyway.
|
||||
Changed,
|
||||
}
|
||||
/// `out`: the output of the renderer the settings are shown for, with what its display reports.
|
||||
fn setup(s: &Settings, out: &EffectiveOutput) -> Setup {
|
||||
setup_of(&s.horizon, out.reported.is_some())
|
||||
}
|
||||
fn setup_of(g: &crate::graphics::Graphics, reported: bool) -> Setup {
|
||||
if !reported {
|
||||
Setup::Unknown
|
||||
} else if g.hdr_peak == 0 && g.hdr_paper_white == 0 {
|
||||
Setup::Automatic
|
||||
} else {
|
||||
Setup::Changed
|
||||
}
|
||||
s.horizon.hdr_setup(out.reported)
|
||||
}
|
||||
/// What the HDR page says while HDR output is held back on this window's graphics driver
|
||||
/// (`EffectiveOutput::held`): picking a mode tries again (`H::cycle`).
|
||||
const HDR_HELD: &str = "HDR output stopped the game the last time it started with this graphics driver. Choose a mode to try again.";
|
||||
|
||||
/// The description beside `item` where it depends on [`setup`]; `text` otherwise.
|
||||
fn description(
|
||||
item: Item,
|
||||
@@ -716,16 +692,23 @@ fn description(
|
||||
text: &'static str,
|
||||
) -> &'static str {
|
||||
match (item, setup(s, out)) {
|
||||
(Item::Horizon(H::Hdr), _) if out.held => HDR_HELD,
|
||||
(Item::Horizon(H::HdrCalibrate), Setup::Unknown) => {
|
||||
"Your display didn't say how bright it gets: calibrate to set it up."
|
||||
}
|
||||
(Item::Horizon(H::HdrCalibrate), Setup::Guessed) => {
|
||||
"Your display's reported peak is often too high: calibrate to set it up."
|
||||
}
|
||||
(Item::Horizon(H::HdrCalibrate), Setup::Automatic) => {
|
||||
"Already set up for your display. Only calibrate if HDR looks wrong."
|
||||
}
|
||||
(Item::Horizon(H::HdrCalibrate), Setup::Changed) => {
|
||||
"Puts Peak Brightness and Paper White back to your display's setup."
|
||||
}
|
||||
(Item::Horizon(H::HdrPeak | H::HdrPaperWhite), Setup::Automatic | Setup::Changed) => {
|
||||
// A guessed peak's Auto is what calibration replaced; paper white is the player's own.
|
||||
(Item::Horizon(h @ (H::HdrPeak | H::HdrPaperWhite)), Setup::Automatic | Setup::Changed)
|
||||
if h == H::HdrPaperWhite || out.reported.is_some_and(|r| !r.peak_guessed) =>
|
||||
{
|
||||
"Set up for your display. Leave on Auto unless it looks wrong."
|
||||
}
|
||||
(Item::Horizon(H::GpuCulling), _) if s.horizon.gpu_culling == GpuCullingMode::Auto => {
|
||||
@@ -814,14 +797,14 @@ impl H {
|
||||
H::ResolutionScale => Some((g.resolution_scale as f32 - 50.0) / 150.0),
|
||||
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),
|
||||
H::HdrPaperWhite => Some(crate::graphics::nits_position(g.hdr_paper_white)),
|
||||
H::HdrUiBrightness => Some(crate::graphics::nits_position(g.hdr_ui_nits)),
|
||||
// Auto, then the peak's scale.
|
||||
H::HdrPeak => Some(match g.hdr_peak {
|
||||
0 => 0.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),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -833,13 +816,26 @@ impl H {
|
||||
H::Workers => Some(max_workers()),
|
||||
H::ResolutionScale => Some(30),
|
||||
H::UpscalingSharpness => Some(20),
|
||||
H::HdrPaperWhite => Some(48),
|
||||
H::HdrPaperWhite | H::HdrUiBrightness => Some(crate::graphics::NITS_STEPS),
|
||||
H::HdrPeak => Some(PEAK_POSITIONS),
|
||||
H::HdrBlackLevel => Some(100),
|
||||
H::HdrUiBrightness => Some(15),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// "Paper White: Auto (203)" or "Paper White: 160": a setting in nits, 0 for Auto, which
|
||||
/// shows the nits Auto gives.
|
||||
fn nits_label(self, s: &Settings, out: &EffectiveOutput) -> Text {
|
||||
let (g, r) = (&s.horizon, out.reported);
|
||||
let (caption, set, auto) = match self {
|
||||
H::HdrPaperWhite => ("Paper White", g.hdr_paper_white, g.paper_white_nits(r)),
|
||||
H::HdrPeak => ("Peak Brightness", g.hdr_peak, g.peak_nits(r)),
|
||||
_ => ("UI Brightness", g.hdr_ui_nits, g.ui_nits(r)),
|
||||
};
|
||||
match set {
|
||||
0 => labelled(caption, lang::tr("Auto (%s)", &[&auto])),
|
||||
n => labelled(caption, n),
|
||||
}
|
||||
}
|
||||
fn label(self, s: &Settings, out: &EffectiveOutput) -> Text {
|
||||
let g = &s.horizon;
|
||||
match self {
|
||||
@@ -936,30 +932,13 @@ impl H {
|
||||
H::FrameGeneration => labelled("Frame Generation", g.frame_generation.label()),
|
||||
H::Hdr => labelled("HDR", g.hdr.label()),
|
||||
H::HdrCalibrate => Text::literal(lang::get(match setup(s, out) {
|
||||
Setup::Unknown => "Calibrate...",
|
||||
Setup::Unknown | Setup::Guessed => "Calibrate...",
|
||||
Setup::Automatic => "Calibrate Anyway...",
|
||||
Setup::Changed => "Use Automatic Setup",
|
||||
})),
|
||||
H::HdrPaperWhite => match g.hdr_paper_white {
|
||||
0 => labelled(
|
||||
"Paper White",
|
||||
lang::tr("Auto (%s)", &[&g.paper_white_nits(out.reported)]),
|
||||
),
|
||||
n => labelled("Paper White", n.to_string()),
|
||||
},
|
||||
H::HdrPeak => match g.hdr_peak {
|
||||
0 => labelled(
|
||||
"Peak Brightness",
|
||||
lang::tr("Auto (%s)", &[&g.peak_nits(out.reported)]),
|
||||
),
|
||||
n => labelled("Peak Brightness", n.to_string()),
|
||||
},
|
||||
H::HdrPaperWhite | H::HdrPeak | H::HdrUiBrightness => self.nits_label(s, out),
|
||||
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 => {
|
||||
Text::translate("Shortcut: %s", &[crate::keybinds::display_name(&g.hdr_key)])
|
||||
}
|
||||
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
|
||||
H::ShadowCache => labelled("Shadow Cache", on_off(g.shadow_cache)),
|
||||
// A press across the row, as Calibrate...: the list it opens shows the pick.
|
||||
@@ -1000,7 +979,8 @@ impl H {
|
||||
H::Workers => g.workers = (v * max_workers() as f32).round() as u32,
|
||||
H::ResolutionScale => g.resolution_scale = (50.0 + (v * 30.0).round() * 5.0) as u32,
|
||||
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::HdrPaperWhite => g.hdr_paper_white = crate::graphics::nits_at(v),
|
||||
H::HdrUiBrightness => g.hdr_ui_nits = crate::graphics::nits_at(v),
|
||||
H::HdrPeak => {
|
||||
g.hdr_peak = match (v * PEAK_POSITIONS as f32).round() as u32 {
|
||||
0 => 0,
|
||||
@@ -1008,7 +988,6 @@ impl H {
|
||||
}
|
||||
}
|
||||
H::HdrBlackLevel => g.hdr_black_level = (v * 100.0).round() as u32,
|
||||
H::HdrUiBrightness => g.hdr_ui_brightness = (25.0 + (v * 15.0).round() * 5.0) as u32,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -1086,7 +1065,9 @@ impl H {
|
||||
H::FrameGeneration => {
|
||||
g.frame_generation = step(&FrameGeneration::ALL, g.frame_generation, back)
|
||||
}
|
||||
// Any choice tries HDR output again on a driver a start failed on.
|
||||
H::Hdr => {
|
||||
g.hdr_failed_start = None;
|
||||
g.hdr = step(&HdrMode::ALL, g.hdr, back);
|
||||
if g.hdr == HdrMode::On && !out.hdr_offered() {
|
||||
g.hdr = step(&HdrMode::ALL, g.hdr, back);
|
||||
@@ -1155,7 +1136,11 @@ impl Item {
|
||||
| H::HdrUiBrightness
|
||||
| H::HdrLighting
|
||||
| H::HdrSmoothGradients,
|
||||
) if !out.requests_hdr(s.horizon.hdr) => Some("Turn HDR on to use it."),
|
||||
) if !out.requests_hdr(s.horizon.hdr) => Some(if out.held {
|
||||
HDR_HELD
|
||||
} else {
|
||||
"Turn HDR on to use it."
|
||||
}),
|
||||
Item::Horizon(H::TranslucentBatching)
|
||||
if !crate::graphics::translucent_batching_offered() =>
|
||||
{
|
||||
@@ -1178,10 +1163,7 @@ impl Item {
|
||||
fn steps(self, s: &Settings, out: &EffectiveOutput) -> bool {
|
||||
self.switch(s, out).is_none()
|
||||
&& self.slider(s).is_none()
|
||||
&& !matches!(
|
||||
self,
|
||||
Item::Horizon(H::HdrKey | H::HdrCalibrate | H::ShaderPacks)
|
||||
)
|
||||
&& !matches!(self, Item::Horizon(H::HdrCalibrate | H::ShaderPacks))
|
||||
}
|
||||
/// On/off settings and their value.
|
||||
pub fn switch(self, s: &Settings, out: &EffectiveOutput) -> Option<bool> {
|
||||
@@ -1336,12 +1318,6 @@ 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, out: &EffectiveOutput) -> (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::Binding`).
|
||||
if item == Item::Horizon(H::HdrKey) {
|
||||
let key = crate::keybinds::display_name(&s.horizon.hdr_key).plain();
|
||||
return (lang::get("Shortcut"), Some(key));
|
||||
}
|
||||
shell::name_and_value(&item.label(s, out).plain())
|
||||
}
|
||||
|
||||
@@ -1350,13 +1326,9 @@ fn parts(item: Item, s: &Settings, out: &EffectiveOutput) -> (String, Option<Str
|
||||
fn row(setting: Setting, s: &Settings, out: &EffectiveOutput) -> Row<Item> {
|
||||
let item = setting.item;
|
||||
let (name, value) = parts(item, s, out);
|
||||
let value = match (item, item.switch(s, out), item.slider(s)) {
|
||||
(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 {
|
||||
let value = match (item.switch(s, out), item.slider(s)) {
|
||||
(Some(on), _) => Value::Switch(on),
|
||||
(None, Some(position)) => Value::Slider {
|
||||
position,
|
||||
steps: item.slider_steps(),
|
||||
text: value.unwrap_or_default(),
|
||||
@@ -1522,7 +1494,7 @@ impl Screen for GraphicsScreen {
|
||||
/// 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) -> Vec<Output> {
|
||||
s.horizon = b.horizon.clone();
|
||||
s.horizon = s.horizon.undone_to(&b.horizon);
|
||||
s.render_distance = b.render_distance;
|
||||
s.simulation_distance = b.simulation_distance;
|
||||
s.gamma = b.gamma;
|
||||
@@ -1572,7 +1544,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
graphics_style as style,
|
||||
settings_shell::{Capture, Columns, Hosted, Shell, Target},
|
||||
settings_shell::{Columns, Hosted, Shell, Target},
|
||||
};
|
||||
use winit::keyboard::KeyCode as K;
|
||||
|
||||
@@ -2038,10 +2010,6 @@ mod tests {
|
||||
precheck.why.as_deref(),
|
||||
Some("Only applies to asynchronous entity culling.")
|
||||
);
|
||||
let key = find(&s, Item::Horizon(H::HdrKey));
|
||||
assert_eq!(key.name, "Shortcut");
|
||||
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);
|
||||
}
|
||||
@@ -2726,7 +2694,7 @@ mod tests {
|
||||
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,
|
||||
H::HdrUiBrightness => s.horizon.hdr_ui_nits,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let fresh = || {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! reports.
|
||||
use super::tests::{CHANGED, CLICK, DRAGGED, FB, NONE, SIZE, Ui, offering, press, row, ui};
|
||||
use super::*;
|
||||
use crate::settings_shell::{Capture, Columns, Hosted};
|
||||
use crate::settings_shell::{Columns, Hosted};
|
||||
use winit::keyboard::KeyCode as K;
|
||||
|
||||
#[test]
|
||||
@@ -64,17 +64,12 @@ fn hdr_auto_can_be_selected_before_querying_and_brightness_waits_for_output() {
|
||||
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();
|
||||
// Calibrate starts the calibration; the shortcut row waits for a key.
|
||||
// Calibrate starts the calibration.
|
||||
let calibrate = row(&mut ui, &s, Item::Horizon(H::HdrCalibrate));
|
||||
assert_eq!(
|
||||
press(&mut ui, &mut s, at(calibrate)),
|
||||
[CLICK, Output::Push(OptionsPage::HdrCalibration)]
|
||||
);
|
||||
let key = row(&mut ui, &s, Item::Horizon(H::HdrKey));
|
||||
assert_eq!(
|
||||
press(&mut ui, &mut s, at(key)),
|
||||
[CLICK, Output::Bind(Capture::Key(crate::graphics::HDR_KEY))]
|
||||
);
|
||||
assert!(ui.changed());
|
||||
}
|
||||
|
||||
@@ -125,12 +120,188 @@ fn auto_levels_and_setup_read_their_own_renderers_report() {
|
||||
assert_eq!(label(H::HdrCalibrate, &silent), "Calibrate...");
|
||||
}
|
||||
|
||||
/// The setup rows' label and description for each kind of report: none, a compositor's
|
||||
/// (Wayland), and Windows', whose peak is the display's own guess.
|
||||
#[test]
|
||||
fn hdr_setup_follows_what_the_system_reported() {
|
||||
let mut g = crate::graphics::Graphics::default();
|
||||
assert_eq!(setup_of(&g, false), Setup::Unknown);
|
||||
assert_eq!(setup_of(&g, true), Setup::Automatic);
|
||||
g.hdr_paper_white = 80;
|
||||
assert_eq!(setup_of(&g, true), Setup::Changed);
|
||||
assert_eq!(setup_of(&g, false), Setup::Unknown);
|
||||
fn the_setup_rows_ask_to_calibrate_unless_the_system_set_the_display_up() {
|
||||
let trusted = offering(true, Some(true));
|
||||
let mut guessed = trusted;
|
||||
guessed.reported = guessed.reported.map(|r| crate::graphics::Reported {
|
||||
peak_guessed: true,
|
||||
..r
|
||||
});
|
||||
let rows = |s: &Settings, out: &EffectiveOutput| {
|
||||
[H::HdrCalibrate, H::HdrPeak, H::HdrPaperWhite].map(|h| {
|
||||
let item = Item::Horizon(h);
|
||||
(item.label(s, out).plain(), description(item, s, out, "-"))
|
||||
})
|
||||
};
|
||||
let calibrate = "Calibrate...";
|
||||
let set_up = "Set up for your display. Leave on Auto unless it looks wrong.";
|
||||
let mut s = Settings::default();
|
||||
s.horizon.hdr = HdrMode::On;
|
||||
let none = (
|
||||
calibrate.into(),
|
||||
"Your display didn't say how bright it gets: calibrate to set it up.",
|
||||
);
|
||||
assert_eq!(
|
||||
rows(&s, &NONE),
|
||||
[
|
||||
none.clone(),
|
||||
("Peak Brightness: Auto (1000)".into(), "-"),
|
||||
("Paper White: Auto (203)".into(), "-"),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
rows(&s, &guessed),
|
||||
[
|
||||
(
|
||||
calibrate.into(),
|
||||
"Your display's reported peak is often too high: calibrate to set it up."
|
||||
),
|
||||
("Peak Brightness: Auto (1000)".into(), "-"),
|
||||
("Paper White: Auto (400)".into(), "-"),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
rows(&s, &trusted),
|
||||
[
|
||||
(
|
||||
"Calibrate Anyway...".into(),
|
||||
"Already set up for your display. Only calibrate if HDR looks wrong."
|
||||
),
|
||||
("Peak Brightness: Auto (1000)".into(), set_up),
|
||||
("Paper White: Auto (400)".into(), set_up),
|
||||
]
|
||||
);
|
||||
// Calibrated or set by hand, either report offers its automatic setup back.
|
||||
s.horizon.hdr_peak = 800;
|
||||
// Peak's Auto stays a guess on Windows: only Paper White is said to be set up there.
|
||||
let changed = |peak| {
|
||||
[
|
||||
(
|
||||
"Use Automatic Setup".into(),
|
||||
"Puts Peak Brightness and Paper White back to your display's setup.",
|
||||
),
|
||||
("Peak Brightness: 800".into(), peak),
|
||||
("Paper White: Auto (400)".into(), set_up),
|
||||
]
|
||||
};
|
||||
assert_eq!(rows(&s, &guessed), changed("-"));
|
||||
assert_eq!(rows(&s, &trusted), changed(set_up));
|
||||
assert_eq!(rows(&s, &NONE)[0], none);
|
||||
let mut ui = ui();
|
||||
ui.screen.output = guessed;
|
||||
let at = |[x, y, ..]: [f32; 4]| (x + 8.0, y + 4.0);
|
||||
let calibrate = row(&mut ui, &s, Item::Horizon(H::HdrCalibrate));
|
||||
assert_eq!(press(&mut ui, &mut s, at(calibrate)), [CLICK, CHANGED]);
|
||||
assert_eq!((s.horizon.hdr_peak, s.horizon.hdr_paper_white), (0, 0));
|
||||
assert_eq!(
|
||||
press(&mut ui, &mut s, at(calibrate)),
|
||||
[CLICK, Output::Push(OptionsPage::HdrCalibration)]
|
||||
);
|
||||
}
|
||||
|
||||
/// UI Brightness reads as Paper White does, in nits apart from it: Auto shows the paper white
|
||||
/// in use, and a value set by hand its own nits, whatever Paper White is. Its slider has Paper
|
||||
/// White's steps: Auto, then 10 to 480 nits by 10.
|
||||
#[test]
|
||||
fn ui_brightness_reads_in_nits_on_paper_whites_steps() {
|
||||
let mut s = Settings::default();
|
||||
s.horizon.hdr = HdrMode::On;
|
||||
let reporting = offering(true, Some(true));
|
||||
let label = |s: &Settings, out| Item::Horizon(H::HdrUiBrightness).label(s, out).plain();
|
||||
assert_eq!(label(&s, &reporting), "UI Brightness: Auto (400)");
|
||||
assert_eq!(label(&s, &NONE), "UI Brightness: Auto (203)");
|
||||
s.horizon.hdr_paper_white = 250;
|
||||
assert_eq!(label(&s, &reporting), "UI Brightness: Auto (250)");
|
||||
s.horizon.hdr_ui_nits = 160;
|
||||
for paper_white in [0, 80, 250] {
|
||||
s.horizon.hdr_paper_white = paper_white;
|
||||
assert_eq!(label(&s, &reporting), "UI Brightness: 160");
|
||||
}
|
||||
let item = Item::Horizon(H::HdrUiBrightness);
|
||||
assert_eq!(
|
||||
H::HdrUiBrightness.slider_steps(),
|
||||
H::HdrPaperWhite.slider_steps()
|
||||
);
|
||||
H::HdrUiBrightness.set_slider(&mut s, 0.0);
|
||||
let mut reached = vec![s.horizon.hdr_ui_nits];
|
||||
for _ in 0..H::HdrUiBrightness.slider_steps().unwrap() {
|
||||
item.step_slider(&mut s, true, 200.0, &reporting);
|
||||
reached.push(s.horizon.hdr_ui_nits);
|
||||
}
|
||||
let paper: Vec<u32> = std::iter::once(0).chain((10..=480).step_by(10)).collect();
|
||||
assert_eq!(reached, paper);
|
||||
// Each value sits where Paper White's same value does.
|
||||
for nits in paper {
|
||||
s.horizon.hdr_ui_nits = nits;
|
||||
s.horizon.hdr_paper_white = nits;
|
||||
assert_eq!(item.slider(&s), Item::Horizon(H::HdrPaperWhite).slider(&s));
|
||||
}
|
||||
}
|
||||
|
||||
/// While HDR output is held back on this window's driver, the HDR row says so and keeps
|
||||
/// the mode; any change of the mode drops the hold, and HDR is asked for again.
|
||||
#[test]
|
||||
fn a_held_hdr_start_is_said_on_the_page_and_a_mode_change_tries_again() {
|
||||
use crate::gfx::{HdrOutput, output::tests};
|
||||
let mut s = Settings::default();
|
||||
s.horizon.hdr = HdrMode::Auto;
|
||||
s.horizon.hdr_failed_start = Some("Vulkan 10de:2684 GPU; driver; 1.0".into());
|
||||
let (mut caps, reading) = tests::window(Some(HdrOutput::Pq), Some(true));
|
||||
caps.held = true;
|
||||
let out = tests::snapshot(&s.horizon, Default::default(), caps, reading);
|
||||
let hdr = Item::Horizon(H::Hdr);
|
||||
let setting = h(
|
||||
H::Hdr,
|
||||
Cost::None,
|
||||
"Lava, fire and sunlight shine brighter than white.",
|
||||
);
|
||||
let held = super::row(setting, &s, &out);
|
||||
assert_eq!(held.chosen.as_deref(), Some(HDR_HELD));
|
||||
assert_eq!(held.why, None);
|
||||
assert_eq!(hdr.label(&s, &out).plain(), "HDR: Auto");
|
||||
assert_eq!(Item::Horizon(H::HdrPeak).blocked(&s, &out), Some(HDR_HELD));
|
||||
// Not held, the row says what HDR does.
|
||||
caps.held = false;
|
||||
let free = tests::snapshot(&s.horizon, Default::default(), caps, reading);
|
||||
assert_eq!(super::row(setting, &s, &free).chosen, None);
|
||||
// Any step of the mode, either way, tries again.
|
||||
for back in [false, true] {
|
||||
let mut s = s.clone();
|
||||
hdr.cycle(&mut s, back, FB, &out);
|
||||
assert_ne!(s.horizon.hdr, HdrMode::Auto);
|
||||
assert_eq!(s.horizon.hdr_failed_start, None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Undo never brings the hold back: held Auto, changed to On (HDR tried again), then
|
||||
/// undone, is Auto without the hold. With the mode left alone, it keeps the hold as it is.
|
||||
#[test]
|
||||
fn undo_never_restores_a_held_hdr_start() {
|
||||
let mut screen = GraphicsScreen {
|
||||
framebuffer: FB,
|
||||
output: NONE,
|
||||
};
|
||||
let mut opened = Settings::default();
|
||||
opened.horizon.hdr = HdrMode::Auto;
|
||||
opened.horizon.hdr_failed_start = Some("Vulkan 10de:2684 GPU; driver; 1.0; os".into());
|
||||
let mut s = opened.clone();
|
||||
let out = offering(true, Some(true));
|
||||
Item::Horizon(H::Hdr).cycle(&mut s, true, FB, &out);
|
||||
assert_eq!(
|
||||
(s.horizon.hdr, &s.horizon.hdr_failed_start),
|
||||
(HdrMode::On, &None)
|
||||
);
|
||||
screen.undo(&mut s, &opened);
|
||||
assert_eq!(
|
||||
(s.horizon.hdr, &s.horizon.hdr_failed_start),
|
||||
(HdrMode::Auto, &None)
|
||||
);
|
||||
// Another row changed and undone: the hold stays.
|
||||
let mut s = opened.clone();
|
||||
s.horizon.fxaa = !s.horizon.fxaa;
|
||||
screen.undo(&mut s, &opened);
|
||||
assert_eq!(s.horizon, opened.horizon);
|
||||
}
|
||||
@@ -206,8 +206,9 @@ fn display(light: vec3<f32>) -> vec4<f32> {
|
||||
let shows = params.world > 0.5 && params.peak > 1.0
|
||||
&& (hand || (!covered && shows_world(f.rgb, w.rgb)));
|
||||
var light = lit(f.rgb, w.a, shows, true).light;
|
||||
// The HUD and menus at their own brightness, by how much they cover.
|
||||
light *= mix(1.0, params.ui, ui);
|
||||
// The HUD and menus at their own brightness, by how much they cover, no brighter than the
|
||||
// peak (as `fs_hdr_layered`): UI Brightness can pass paper white.
|
||||
light = min(light * mix(1.0, params.ui, ui), vec3(params.peak));
|
||||
return display(light);
|
||||
}
|
||||
|
||||
@@ -275,8 +276,8 @@ fn backdrop(w: vec3<f32>) -> vec3<f32> {
|
||||
|
||||
// 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.
|
||||
// (1 - c) of the world, light above white included. Where the world is at or below white, with
|
||||
// UI Brightness at paper white, 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 = textureLoad(hud, at, 0).rgb;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
//! Nothing changes until Save, which writes the three into Horizon's settings; Cancel
|
||||
//! or Escape leaves them as they were.
|
||||
use crate::{
|
||||
font::Font,
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
lang,
|
||||
settings::Settings,
|
||||
@@ -33,6 +34,10 @@ pub enum Step {
|
||||
}
|
||||
const STEPS: [Step; 3] = [Step::Black, Step::Peak, Step::PaperWhite];
|
||||
|
||||
/// A step's test picture for HDR output: the step, its nits and where the controls start (a
|
||||
/// share of the height).
|
||||
pub type Picture = (Step, f32, f32);
|
||||
|
||||
/// What the options screen does after an input here.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Outcome {
|
||||
@@ -43,6 +48,30 @@ pub enum Outcome {
|
||||
Cancel,
|
||||
}
|
||||
|
||||
/// The window as calibration found it: calibration turns it fullscreen for its test pictures,
|
||||
/// and gives it back as it was.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum WindowState {
|
||||
Fullscreen,
|
||||
Maximized,
|
||||
Windowed,
|
||||
}
|
||||
|
||||
impl WindowState {
|
||||
pub fn of(fullscreen: bool, maximized: bool) -> Self {
|
||||
match (fullscreen, maximized) {
|
||||
(true, _) => Self::Fullscreen,
|
||||
(false, true) => Self::Maximized,
|
||||
(false, false) => Self::Windowed,
|
||||
}
|
||||
}
|
||||
/// On leaving calibration: whether the window leaves fullscreen, and whether it is
|
||||
/// maximized again after (leaving fullscreen gives back its restored size on Windows).
|
||||
pub fn leaving(self) -> (bool, bool) {
|
||||
(self != Self::Fullscreen, self == Self::Maximized)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Calibration {
|
||||
step: usize,
|
||||
/// Nits, in the order of `STEPS`.
|
||||
@@ -53,8 +82,11 @@ pub struct Calibration {
|
||||
dragging: bool,
|
||||
}
|
||||
|
||||
/// Height of the band of controls at the bottom, in GUI pixels.
|
||||
/// Height of the band of controls at the bottom with a hint of one line, in GUI pixels.
|
||||
const BAND: f32 = 84.0;
|
||||
/// Each further line of the hint, which wraps within the window less this at each side.
|
||||
const HINT_LINE: f32 = 10.0;
|
||||
const HINT_MARGIN: f32 = 10.0;
|
||||
|
||||
impl Step {
|
||||
/// The slider's steps past its first: the settings' own.
|
||||
@@ -115,18 +147,24 @@ impl Step {
|
||||
|
||||
struct Layout {
|
||||
top: f32,
|
||||
hint: Vec<Text>,
|
||||
slider: (f32, f32, f32),
|
||||
cancel: (f32, f32, f32),
|
||||
back: (f32, f32, f32),
|
||||
next: (f32, f32, f32),
|
||||
}
|
||||
fn layout(w: f32, h: f32) -> Layout {
|
||||
let top = (h - BAND).floor();
|
||||
/// The band grows up by the lines `step`'s hint takes past its first.
|
||||
fn layout(font: &Font, step: Step, (w, h): (f32, f32)) -> Layout {
|
||||
let hint = Text::literal(lang::get(step.hint_key()));
|
||||
let hint = font.split(&hint, w - 2.0 * HINT_MARGIN);
|
||||
let more = hint.len().saturating_sub(1) as f32 * HINT_LINE;
|
||||
let top = (h - BAND - more).floor();
|
||||
let cx = (w / 2.0).floor();
|
||||
let by = top + 58.0;
|
||||
let by = top + more + 58.0;
|
||||
Layout {
|
||||
top,
|
||||
slider: (cx - 100.0, top + 32.0, 200.0),
|
||||
hint,
|
||||
slider: (cx - 100.0, top + more + 32.0, 200.0),
|
||||
cancel: (cx - 152.0, by, 98.0),
|
||||
back: (cx - 49.0, by, 98.0),
|
||||
next: (cx + 54.0, by, 98.0),
|
||||
@@ -159,6 +197,9 @@ impl Calibration {
|
||||
fn current(&self) -> Step {
|
||||
STEPS[self.step]
|
||||
}
|
||||
fn layout(&self, font: &Font, size: (f32, f32)) -> Layout {
|
||||
layout(font, self.current(), size)
|
||||
}
|
||||
/// The slider's position, 0..=1.
|
||||
fn position(&self) -> f32 {
|
||||
let step = self.current();
|
||||
@@ -184,18 +225,17 @@ impl Calibration {
|
||||
};
|
||||
Text::literal(format!("{}: {}", step.name(), value))
|
||||
}
|
||||
/// The step, its nits and where the controls start (a share of the height), for
|
||||
/// HDR output's test picture.
|
||||
pub fn picture(&self, h: f32) -> (Step, f32, f32) {
|
||||
/// The current step's test picture in a GUI of `size`.
|
||||
pub fn picture(&self, font: &Font, size: (f32, f32)) -> Picture {
|
||||
(
|
||||
self.current(),
|
||||
self.values[self.step],
|
||||
layout(0.0, h).top / h.max(1.0),
|
||||
self.layout(font, size).top / size.1.max(1.0),
|
||||
)
|
||||
}
|
||||
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, cursor: (f32, f32)) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
let l = layout(w, h);
|
||||
let l = self.layout(&atlas.font, (w, h));
|
||||
// Above the band HDR output draws the test picture over whatever is here.
|
||||
batch.fill(atlas, 0.0, 0.0, w, h, rgba(0, 0, 0, 255));
|
||||
batch.fill(atlas, 0.0, l.top, w, h - l.top, rgba(24, 24, 24, 255));
|
||||
@@ -205,16 +245,10 @@ impl Calibration {
|
||||
&[&(self.step + 1), &STEPS.len(), &step.name()],
|
||||
));
|
||||
ui::centered_text(batch, atlas, &title, 0.0, l.top + 6.0, w, u32::MAX);
|
||||
let hint = Text::literal(lang::get(step.hint_key()));
|
||||
ui::centered_text(
|
||||
batch,
|
||||
atlas,
|
||||
&hint,
|
||||
0.0,
|
||||
l.top + 18.0,
|
||||
w,
|
||||
rgba(160, 160, 160, 255),
|
||||
);
|
||||
for (i, line) in l.hint.iter().enumerate() {
|
||||
let y = l.top + 18.0 + i as f32 * HINT_LINE;
|
||||
ui::centered_text(batch, atlas, line, 0.0, y, w, rgba(160, 160, 160, 255));
|
||||
}
|
||||
let (x, y, sw) = l.slider;
|
||||
ui::slider(
|
||||
batch,
|
||||
@@ -261,8 +295,14 @@ impl Calibration {
|
||||
}
|
||||
}
|
||||
/// A press, or a drag with `drag`, at `cursor`.
|
||||
pub fn click(&mut self, cursor: (f32, f32), (w, h): (f32, f32), drag: bool) -> Outcome {
|
||||
let l = layout(w, h);
|
||||
pub fn click(
|
||||
&mut self,
|
||||
font: &Font,
|
||||
cursor: (f32, f32),
|
||||
size: (f32, f32),
|
||||
drag: bool,
|
||||
) -> Outcome {
|
||||
let l = self.layout(font, size);
|
||||
let (x, _, sw) = l.slider;
|
||||
if drag {
|
||||
if !self.dragging {
|
||||
@@ -336,6 +376,14 @@ mod tests {
|
||||
|
||||
const NONE: EffectiveOutput = EffectiveOutput::NONE;
|
||||
|
||||
fn font() -> Font {
|
||||
Font::metrics(std::path::Path::new("/nonexistent/horizon-test-assets"))
|
||||
}
|
||||
/// At the default GUI scale of the game's first window (854 x 480 at 2).
|
||||
fn picture(c: &Calibration) -> Picture {
|
||||
c.picture(&font(), (427.0, 240.0))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calibration_saves_only_on_save() {
|
||||
let mut s = Settings::default();
|
||||
@@ -344,14 +392,14 @@ mod tests {
|
||||
let mut c = Calibration::new(&s, &NONE);
|
||||
assert_eq!(c.key(KeyCode::ArrowLeft), Outcome::Changed);
|
||||
assert_eq!(c.key(KeyCode::ArrowLeft), Outcome::Changed);
|
||||
assert!((c.picture(100.0).1 - 0.03).abs() < 1e-6);
|
||||
assert!((picture(&c).1 - 0.03).abs() < 1e-6);
|
||||
assert_eq!(c.key(KeyCode::Escape), Outcome::Cancel);
|
||||
assert_eq!(s.horizon, before);
|
||||
// Black, then Peak (one step up), then Paper White, then Save.
|
||||
assert_eq!(c.key(KeyCode::Enter), Outcome::Click);
|
||||
assert_eq!(c.key(KeyCode::ArrowRight), Outcome::Changed);
|
||||
assert_eq!(c.key(KeyCode::Enter), Outcome::Click);
|
||||
assert_eq!(c.picture(100.0).0, Step::PaperWhite);
|
||||
assert_eq!(picture(&c).0, Step::PaperWhite);
|
||||
assert_eq!(c.key(KeyCode::Enter), Outcome::Save);
|
||||
c.save(&mut s);
|
||||
assert_eq!(s.horizon.hdr_black_level, 3);
|
||||
@@ -370,10 +418,10 @@ mod tests {
|
||||
for (output, peak, paper_white) in [(&reporting, 1000.0, 400.0), (&NONE, 1000.0, 203.0)] {
|
||||
let mut c = Calibration::new(&s, output);
|
||||
c.key(KeyCode::Enter);
|
||||
assert_eq!(c.picture(1.0).0, Step::Peak);
|
||||
assert_eq!(c.picture(1.0).1, peak);
|
||||
assert_eq!(picture(&c).0, Step::Peak);
|
||||
assert_eq!(picture(&c).1, peak);
|
||||
c.key(KeyCode::Enter);
|
||||
assert_eq!(c.picture(1.0).1, paper_white);
|
||||
assert_eq!(picture(&c).1, paper_white);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,14 +440,14 @@ mod tests {
|
||||
];
|
||||
let mut c = Calibration::new(&Settings::default(), &NONE);
|
||||
for (i, expected) in expected.iter().enumerate() {
|
||||
assert_eq!(c.picture(1.0).0, STEPS[i]);
|
||||
assert_eq!(picture(&c).0, STEPS[i]);
|
||||
for _ in 0..200 {
|
||||
c.key(KeyCode::ArrowLeft);
|
||||
}
|
||||
let mut seen = vec![c.picture(1.0).1];
|
||||
let mut seen = vec![picture(&c).1];
|
||||
for _ in 0..200 {
|
||||
c.key(KeyCode::ArrowRight);
|
||||
let v = c.picture(1.0).1;
|
||||
let v = picture(&c).1;
|
||||
if seen.last() != Some(&v) {
|
||||
seen.push(v);
|
||||
}
|
||||
@@ -422,7 +470,7 @@ mod tests {
|
||||
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, &NONE);
|
||||
assert!((c.picture(1.0).1 - 0.02).abs() < 1e-6);
|
||||
assert!((picture(&c).1 - 0.02).abs() < 1e-6);
|
||||
c.key(KeyCode::ArrowLeft);
|
||||
c.key(KeyCode::ArrowLeft);
|
||||
c.key(KeyCode::Enter);
|
||||
@@ -435,14 +483,89 @@ mod tests {
|
||||
assert_eq!(s.horizon.hdr_peak, 10_000);
|
||||
let mut c = Calibration::new(&s, &NONE);
|
||||
c.key(KeyCode::Enter);
|
||||
assert_eq!(c.picture(1.0).1, 10_000.0);
|
||||
assert_eq!(picture(&c).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);
|
||||
assert_eq!(picture(&c).1, 9500.0);
|
||||
s.horizon.hdr_peak = 2000;
|
||||
let mut c = Calibration::new(&s, &NONE);
|
||||
c.key(KeyCode::Enter);
|
||||
c.key(KeyCode::ArrowLeft);
|
||||
assert_eq!(c.picture(1.0).1, 1950.0);
|
||||
assert_eq!(picture(&c).1, 1950.0);
|
||||
}
|
||||
|
||||
/// A hint wider than the window less its margins wraps, the band grows up by the lines
|
||||
/// it adds, and the test picture ends where the band drawn starts.
|
||||
#[test]
|
||||
fn the_hint_wraps_and_the_band_grows_by_its_lines() {
|
||||
let font = font();
|
||||
let mut c = Calibration::new(&Settings::default(), &NONE);
|
||||
c.key(KeyCode::Enter);
|
||||
c.key(KeyCode::Enter);
|
||||
let hint = Text::literal(lang::get(Step::PaperWhite.hint_key()));
|
||||
let wide = font.width_text(&hint);
|
||||
let size = (wide + 2.0 * HINT_MARGIN, 240.0);
|
||||
let one = c.layout(&font, size);
|
||||
assert_eq!(one.hint.len(), 1);
|
||||
assert_eq!(one.top, 240.0 - BAND);
|
||||
let narrow = (wide * 0.6, 240.0);
|
||||
let wrapped = c.layout(&font, narrow);
|
||||
assert_eq!(wrapped.hint.len(), 2);
|
||||
assert!(
|
||||
wrapped
|
||||
.hint
|
||||
.iter()
|
||||
.all(|l| font.width_text(l) <= narrow.0 - 2.0 * HINT_MARGIN)
|
||||
);
|
||||
assert_eq!(wrapped.top, 240.0 - BAND - HINT_LINE);
|
||||
// Under the hint's lines, the controls keep their place in the band.
|
||||
assert_eq!(
|
||||
wrapped.slider.1 - wrapped.top,
|
||||
one.slider.1 - one.top + HINT_LINE
|
||||
);
|
||||
assert_eq!(
|
||||
wrapped.next.1 - wrapped.top,
|
||||
one.next.1 - one.top + HINT_LINE
|
||||
);
|
||||
for size in [size, narrow] {
|
||||
let (_, _, band) = c.picture(&font, size);
|
||||
assert_eq!(band * size.1, c.layout(&font, size).top);
|
||||
}
|
||||
}
|
||||
|
||||
/// The Peak step's patch (a square of a tenth of the screen, centred above the band,
|
||||
/// `hdr.wgsl`) stays above the band even on the narrowest GUI with every hint wrapped:
|
||||
/// its side is no more than the band's top.
|
||||
#[test]
|
||||
fn the_test_pictures_stay_above_a_grown_band() {
|
||||
let font = font();
|
||||
let mut c = Calibration::new(&Settings::default(), &NONE);
|
||||
for step in STEPS {
|
||||
for (w, h) in [(320.0, 240.0), (200.0, 240.0), (427.0, 240.0)] {
|
||||
let (shown, _, band) = c.picture(&font, (w, h));
|
||||
assert_eq!(shown, step);
|
||||
let side = (0.1 * w * h).sqrt();
|
||||
assert!(
|
||||
side <= band * h,
|
||||
"{step:?} at {w}x{h}: {side} > {}",
|
||||
band * h
|
||||
);
|
||||
}
|
||||
c.key(KeyCode::Enter);
|
||||
}
|
||||
}
|
||||
|
||||
/// Leaving calibration gives the window back as it found it.
|
||||
#[test]
|
||||
fn leaving_gives_the_window_back_as_it_was() {
|
||||
use WindowState as W;
|
||||
assert_eq!(W::of(true, false), W::Fullscreen);
|
||||
assert_eq!(W::of(true, true), W::Fullscreen);
|
||||
assert_eq!(W::of(false, true), W::Maximized);
|
||||
assert_eq!(W::of(false, false), W::Windowed);
|
||||
// (leave fullscreen, maximize again)
|
||||
assert_eq!(W::Fullscreen.leaving(), (false, false));
|
||||
assert_eq!(W::Maximized.leaving(), (true, true));
|
||||
assert_eq!(W::Windowed.leaving(), (true, false));
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use std::{
|
||||
};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
mod animate;
|
||||
pub mod cit_glint;
|
||||
mod pack_text;
|
||||
pub mod pictures;
|
||||
@@ -100,13 +101,13 @@ pub struct HudAtlas {
|
||||
pub glint_armor: (Vec<u8>, u32, u32),
|
||||
/// A resource pack's own `core/text` shader for the GUI's text, translated;
|
||||
/// none draws the text natively.
|
||||
pub text_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
|
||||
pub text_shader: Option<std::sync::Arc<crate::core_shader::CoreProgram>>,
|
||||
/// A resource pack's own `core/rendertype_lines` shader for the block outline, translated;
|
||||
/// none draws the outline natively.
|
||||
pub lines_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
|
||||
pub lines_shader: Option<std::sync::Arc<crate::core_shader::CoreProgram>>,
|
||||
/// The packs' `core/sky` and `core/stars`, when a pack replaces them.
|
||||
pub sky_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
|
||||
pub stars_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
|
||||
pub sky_shader: Option<std::sync::Arc<crate::core_shader::CoreProgram>>,
|
||||
pub stars_shader: Option<std::sync::Arc<crate::core_shader::CoreProgram>>,
|
||||
/// The packs' Polytone screen sprite modifiers.
|
||||
pub sprite_modifiers: std::sync::Arc<sprite_modifiers::SpriteModifiers>,
|
||||
/// The packs' OptiFine random entity textures, whose variants this atlas packs.
|
||||
@@ -1427,32 +1428,7 @@ impl HudAtlas {
|
||||
}
|
||||
if full {
|
||||
// Official Recipes artwork from Just Enough Items, under MIT.
|
||||
for (name, png) in [
|
||||
(
|
||||
"button_enabled_v2",
|
||||
include_bytes!("jei_recipes/button_enabled_v2.png").as_slice(),
|
||||
),
|
||||
(
|
||||
"button_disabled_v2",
|
||||
include_bytes!("jei_recipes/button_disabled_v2.png").as_slice(),
|
||||
),
|
||||
(
|
||||
"button_highlight_v2",
|
||||
include_bytes!("jei_recipes/button_highlight_v2.png").as_slice(),
|
||||
),
|
||||
(
|
||||
"icons/arrow_previous_v2",
|
||||
include_bytes!("jei_recipes/icons/arrow_previous_v2.png").as_slice(),
|
||||
),
|
||||
(
|
||||
"icons/arrow_next_v2",
|
||||
include_bytes!("jei_recipes/icons/arrow_next_v2.png").as_slice(),
|
||||
),
|
||||
(
|
||||
"search_background_v2",
|
||||
include_bytes!("jei_recipes/search_background_v2.png").as_slice(),
|
||||
),
|
||||
] {
|
||||
for (name, png) in crate::jei_sprites::SPRITES {
|
||||
let (width, height, rgba) = decode_bytes(png)?;
|
||||
images.push((format!("horizon/jei/{name}"), width, height, rgba));
|
||||
}
|
||||
@@ -2895,65 +2871,12 @@ impl HudRenderer {
|
||||
pub fn set_text_shader(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
shader: Option<&horizon_shaders::CoreProgram>,
|
||||
shader: Option<&crate::core_shader::CoreProgram>,
|
||||
) {
|
||||
self.pack_text = shader.and_then(|shader| {
|
||||
pack_text::PackText::new(device, self.format, &self.atlas_view, shader, self.coverage)
|
||||
});
|
||||
}
|
||||
pub fn animate_sprites(
|
||||
&mut self,
|
||||
uploads: &mut crate::gfx::upload::Uploads,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
tick: u64,
|
||||
) {
|
||||
let Some(texture) = &self.atlas_texture else {
|
||||
return;
|
||||
};
|
||||
for (rect, animation, previous) in &mut self.sprite_animations {
|
||||
// `SpriteContents.AnimationState`: the frame from the mcmeta's
|
||||
// list, blended into the next one by the sub-frame's thousandths
|
||||
// when the sprite interpolates.
|
||||
let blend = animation.blend(tick);
|
||||
let state = (blend.frame, blend.next, blend.millis);
|
||||
if state == *previous {
|
||||
continue;
|
||||
}
|
||||
let (w, h) = rect.texels((texture.width(), texture.height()));
|
||||
let pixels = if blend.millis == 0 {
|
||||
animation.resized_pixels(blend.frame, w, h)
|
||||
} else {
|
||||
let current = animation.resized_pixels(blend.frame, w, h);
|
||||
let next = animation.resized_pixels(blend.next, w, h);
|
||||
let millis = blend.millis;
|
||||
current
|
||||
.iter()
|
||||
.zip(next)
|
||||
.map(|(&a, b)| {
|
||||
((u32::from(a) * (1000 - millis) + u32::from(b) * millis + 500) / 1000)
|
||||
as u8
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
uploads.texture(
|
||||
encoder,
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture,
|
||||
mip_level: 0,
|
||||
origin: wgpu::Origin3d {
|
||||
x: (rect.u0 * texture.width() as f32).round() as u32,
|
||||
y: (rect.v0 * texture.height() as f32).round() as u32,
|
||||
z: 0,
|
||||
},
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
w,
|
||||
h,
|
||||
&pixels,
|
||||
);
|
||||
*previous = state;
|
||||
}
|
||||
}
|
||||
/// Stage `patches` into the shared atlas texture.
|
||||
pub fn upload_patches(
|
||||
&self,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! The GUI atlas's animated sprites, frame by frame (vanilla's or a Polytone `mode`'s).
|
||||
|
||||
impl super::HudRenderer {
|
||||
/// Shows each animated sprite's frame: its mcmeta's, or what its Polytone `mode` picks.
|
||||
pub(crate) fn animate(
|
||||
&mut self,
|
||||
uploads: &mut crate::gfx::upload::Uploads,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
tick: u64,
|
||||
animator: &crate::gfx::animate::SpriteAnimator,
|
||||
) {
|
||||
let inputs = animator.mode_inputs();
|
||||
let inputs = Some(&inputs);
|
||||
let Some(texture) = &self.atlas_texture else {
|
||||
return;
|
||||
};
|
||||
for (rect, animation, previous) in &mut self.sprite_animations {
|
||||
// `SpriteContents.AnimationState`: the frame from the mcmeta's
|
||||
// list, blended into the next one by the sub-frame's thousandths
|
||||
// when the sprite interpolates; or what a Polytone `mode` picks.
|
||||
let blend = animation.blend_with(tick, inputs);
|
||||
let state = (blend.frame, blend.next, blend.millis);
|
||||
if state == *previous {
|
||||
continue;
|
||||
}
|
||||
let (w, h) = rect.texels((texture.width(), texture.height()));
|
||||
let pixels = if blend.millis == 0 {
|
||||
animation.resized_pixels(blend.frame, w, h)
|
||||
} else {
|
||||
let current = animation.resized_pixels(blend.frame, w, h);
|
||||
let next = animation.resized_pixels(blend.next, w, h);
|
||||
let millis = blend.millis;
|
||||
current
|
||||
.iter()
|
||||
.zip(next)
|
||||
.map(|(&a, b)| {
|
||||
((u32::from(a) * (1000 - millis) + u32::from(b) * millis + 500) / 1000)
|
||||
as u8
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
uploads.texture(
|
||||
encoder,
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture,
|
||||
mip_level: 0,
|
||||
origin: wgpu::Origin3d {
|
||||
x: (rect.u0 * texture.width() as f32).round() as u32,
|
||||
y: (rect.v0 * texture.height() as f32).round() as u32,
|
||||
z: 0,
|
||||
},
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
w,
|
||||
h,
|
||||
&pixels,
|
||||
);
|
||||
*previous = state;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -393,7 +393,7 @@ fn coverage_uses_pack_texts_final_alpha_and_discard() {
|
||||
return;
|
||||
};
|
||||
// A translated-style struct result and early return; no dependency on game resources.
|
||||
let program = horizon_shaders::CoreProgram {
|
||||
let program = crate::core_shader::CoreProgram {
|
||||
vertex: naga::front::wgsl::parse_str("@vertex fn main(@location(0) p: vec2<f32>) -> @builtin(position) vec4<f32> { return vec4(p.x / 2.0 - 1.0, 1.0 - p.y, 0.0, 1.0); }").unwrap(),
|
||||
fragment: naga::front::wgsl::parse_str("struct Result { @location(0) color: vec4<f32> } @fragment fn main(@builtin(position) p: vec4<f32>) -> Result { if p.x > 2.0 { discard; } return Result(vec4(0.4, 0.2, 0.1, 0.5)); }").unwrap(),
|
||||
};
|
||||
@@ -414,7 +414,7 @@ fn a_pack_text_shader_without_a_colour_output_is_dropped_not_wrapped() {
|
||||
let Some(mut gpu) = Gpu::new() else {
|
||||
return;
|
||||
};
|
||||
let program = horizon_shaders::CoreProgram {
|
||||
let program = crate::core_shader::CoreProgram {
|
||||
vertex: naga::front::wgsl::parse_str("@vertex fn main(@location(0) p: vec2<f32>) -> @builtin(position) vec4<f32> { return vec4(p, 0.0, 1.0); }").unwrap(),
|
||||
fragment: naga::front::wgsl::parse_str("@fragment fn main() -> @location(1) vec4<f32> { return vec4(1.0); }").unwrap(),
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use horizon_shaders::{CoreProgram, Glue};
|
||||
use crate::core_shader::{CoreProgram, Glue};
|
||||
|
||||
use super::{HudBatch, HudVertex};
|
||||
use crate::gfx::hud_coverage::Pipeline;
|
||||
@@ -64,7 +64,7 @@ pub(super) fn load_core(
|
||||
let path = format!("assets/{namespace}/{path}");
|
||||
read(root, &path).or_else(|| read(defaults, &path))
|
||||
};
|
||||
match horizon_shaders::translate_core(&vertex, &fragment, defines, &include, glue) {
|
||||
match crate::core_shader::translate_core(&vertex, &fragment, defines, &include, glue) {
|
||||
Ok(program) => {
|
||||
log::info!("the resource pack's {name} shader is used");
|
||||
Some(Arc::new(program))
|
||||
@@ -132,7 +132,7 @@ impl PackText {
|
||||
count: None,
|
||||
})
|
||||
.collect();
|
||||
for (_, binding) in horizon_shaders::SAMPLERS {
|
||||
for (_, binding) in crate::core_shader::SAMPLERS {
|
||||
entries.push(wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility,
|
||||
@@ -175,7 +175,7 @@ impl PackText {
|
||||
}),
|
||||
})
|
||||
.collect();
|
||||
for (_, binding) in horizon_shaders::SAMPLERS {
|
||||
for (_, binding) in crate::core_shader::SAMPLERS {
|
||||
resources.push(wgpu::BindGroupEntry {
|
||||
binding,
|
||||
resource: wgpu::BindingResource::TextureView(atlas),
|
||||
|
||||
|
After Width: | Height: | Size: 90 B |
|
After Width: | Height: | Size: 103 B |
|
After Width: | Height: | Size: 147 B |
|
After Width: | Height: | Size: 172 B |
|
After Width: | Height: | Size: 133 B |
|
After Width: | Height: | Size: 142 B |
|
After Width: | Height: | Size: 103 B |
@@ -0,0 +1,55 @@
|
||||
//! The official Recipes viewer's artwork from Just Enough Items, under MIT (`jei_recipes/LICENSE.txt`,
|
||||
//! THIRD_PARTY_NOTICES.md): `HudAtlas` loads each as the sprite `horizon/jei/<name>`.
|
||||
|
||||
/// Each sprite's name and its PNG.
|
||||
pub(crate) const SPRITES: &[(&str, &[u8])] = &[
|
||||
(
|
||||
"button_enabled_v2",
|
||||
include_bytes!("jei_recipes/button_enabled_v2.png"),
|
||||
),
|
||||
(
|
||||
"button_disabled_v2",
|
||||
include_bytes!("jei_recipes/button_disabled_v2.png"),
|
||||
),
|
||||
(
|
||||
"button_highlight_v2",
|
||||
include_bytes!("jei_recipes/button_highlight_v2.png"),
|
||||
),
|
||||
(
|
||||
"icons/arrow_previous_v2",
|
||||
include_bytes!("jei_recipes/icons/arrow_previous_v2.png"),
|
||||
),
|
||||
(
|
||||
"icons/arrow_next_v2",
|
||||
include_bytes!("jei_recipes/icons/arrow_next_v2.png"),
|
||||
),
|
||||
(
|
||||
"search_background_v2",
|
||||
include_bytes!("jei_recipes/search_background_v2.png"),
|
||||
),
|
||||
("slot", include_bytes!("jei_recipes/slot.png")),
|
||||
(
|
||||
"icons/list_badge",
|
||||
include_bytes!("jei_recipes/icons/list_badge.png"),
|
||||
),
|
||||
(
|
||||
"interactive_ingredient_tooltip_background",
|
||||
include_bytes!("jei_recipes/interactive_ingredient_tooltip_background.png"),
|
||||
),
|
||||
(
|
||||
"recipe_preview_background_v2",
|
||||
include_bytes!("jei_recipes/recipe_preview_background_v2.png"),
|
||||
),
|
||||
(
|
||||
"ingredient_list_slot_background_v2",
|
||||
include_bytes!("jei_recipes/ingredient_list_slot_background_v2.png"),
|
||||
),
|
||||
(
|
||||
"scrollbar_background_v2",
|
||||
include_bytes!("jei_recipes/scrollbar_background_v2.png"),
|
||||
),
|
||||
(
|
||||
"scrollbar_marker_v2",
|
||||
include_bytes!("jei_recipes/scrollbar_marker_v2.png"),
|
||||
),
|
||||
];
|
||||
@@ -38,6 +38,7 @@ pub mod command_block;
|
||||
pub mod connected;
|
||||
pub mod containers;
|
||||
pub mod controller_ui;
|
||||
pub mod core_shader;
|
||||
pub mod crash;
|
||||
pub mod custom_sky;
|
||||
pub mod datapack;
|
||||
@@ -62,10 +63,11 @@ pub mod hud_layout;
|
||||
pub mod hud_overlays;
|
||||
pub mod inventory_effects;
|
||||
pub mod java_text;
|
||||
mod jei_sprites;
|
||||
pub mod jigsaw_block;
|
||||
pub mod keypad;
|
||||
pub mod terrain_model;
|
||||
pub use horizon_polytone::colors as polytone_colors;
|
||||
pub use horizon_polytone::creative_tabs as polytone_creative_tabs;
|
||||
pub use horizon_world::json_folder;
|
||||
pub mod level_loading;
|
||||
pub mod light;
|
||||
@@ -86,6 +88,8 @@ pub mod options_fixes;
|
||||
pub mod options_ui;
|
||||
pub mod overlay;
|
||||
pub mod pack_browser;
|
||||
pub mod pack_formats;
|
||||
pub mod pack_hooks;
|
||||
pub mod pack_options_ui;
|
||||
pub mod pack_palette;
|
||||
pub mod pack_screen;
|
||||
|
||||
@@ -2593,8 +2593,7 @@ impl Lod {
|
||||
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
|
||||
// dropped when it finishes, `finish_sample`).
|
||||
// Queued tiles the view left are not sampled; a running one is dropped (`finish_sample`).
|
||||
drop_queued_samples(&self.shared, &mut self.pending, |key| {
|
||||
self.plan.nodes.contains(key)
|
||||
});
|
||||
@@ -2680,6 +2679,7 @@ impl Lod {
|
||||
}
|
||||
if let Some((columns, mask)) = self.kept.take(&key) {
|
||||
self.ready.insert(key, columns);
|
||||
self.sync_sample(key);
|
||||
if let Some(mask) = mask {
|
||||
self.detail_masks.insert(key, mask);
|
||||
}
|
||||
|
||||
@@ -761,6 +761,49 @@ pub struct MeshTables {
|
||||
colormaps: Vec<Arc<crate::environment::Colormap>>,
|
||||
water_colormap: Option<u16>,
|
||||
}
|
||||
/// Vanilla's four biome colour functions (`BiomeColors`) with what the packs replace them by:
|
||||
/// `biome_colors` (a colormap or a flat colour), else the colormap a block modifier gives
|
||||
/// `grass_block`, `oak_leaves` or `leaf_litter`, or a fluid modifier gives water, which Polytone
|
||||
/// spreads to every block of that function. Each with the tints that read it.
|
||||
fn biome_functions(
|
||||
pack_root: &std::path::Path,
|
||||
modifiers: &[(bool, horizon_polytone::Targets, String)],
|
||||
) -> Vec<(&'static [Tint], crate::environment::Colormap)> {
|
||||
use crate::environment::Colormap;
|
||||
use horizon_polytone::colors::BiomeColor;
|
||||
const FUNCTIONS: [(&str, &str, &[Tint]); 4] = [
|
||||
(
|
||||
"grass",
|
||||
"minecraft:grass_block",
|
||||
&[
|
||||
Tint::Grass,
|
||||
Tint::GrassBlock,
|
||||
Tint::TallGrassUpper,
|
||||
Tint::SugarCane,
|
||||
],
|
||||
),
|
||||
("foliage", "minecraft:oak_leaves", &[Tint::Foliage]),
|
||||
("dry_foliage", "minecraft:leaf_litter", &[Tint::DryFoliage]),
|
||||
("water", "minecraft:water", &[Tint::Water]),
|
||||
];
|
||||
let colors = horizon_polytone::colors::PackColors::read(pack_root);
|
||||
FUNCTIONS
|
||||
.into_iter()
|
||||
.filter_map(|(function, block, tints)| {
|
||||
let colormap = match colors.biome_color(function) {
|
||||
Some(BiomeColor::Flat(rgb)) => Some(Colormap::flat(rgb)),
|
||||
Some(BiomeColor::Colormap(id)) => Colormap::load(pack_root, &id),
|
||||
None => modifiers
|
||||
.iter()
|
||||
.find(|(fluid, targets, _)| {
|
||||
*fluid == (function == "water") && targets.matches(block, |_| false)
|
||||
})
|
||||
.and_then(|(_, _, id)| Colormap::load(pack_root, id)),
|
||||
}?;
|
||||
Some((tints, colormap))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn stem_color(age: u8) -> u32 {
|
||||
let a = u32::from(age);
|
||||
(a * 32) << 16 | (255 - a * 8) << 8 | (a * 4)
|
||||
@@ -945,7 +988,8 @@ impl MeshTables {
|
||||
}
|
||||
/// 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.
|
||||
/// from the colormap, and its own tint where the colormap's pixel is transparent. Vanilla's
|
||||
/// four biome colour functions take the packs' `biome_colors` first (wiki "Custom Colors").
|
||||
#[must_use]
|
||||
pub fn with_polytone_colormaps(
|
||||
mut self,
|
||||
@@ -953,7 +997,8 @@ impl MeshTables {
|
||||
registry: &horizon_world::registry::BlockRegistry,
|
||||
) -> Self {
|
||||
let modifiers = crate::environment::polytone_colormap_modifiers(pack_root);
|
||||
if modifiers.is_empty() {
|
||||
let biome_functions = biome_functions(pack_root, &modifiers);
|
||||
if modifiers.is_empty() && biome_functions.is_empty() {
|
||||
return self;
|
||||
}
|
||||
let mut loaded: std::collections::HashMap<String, Option<u16>> = Default::default();
|
||||
@@ -964,7 +1009,24 @@ impl MeshTables {
|
||||
Some((colormaps.len() - 1) as u16)
|
||||
})
|
||||
};
|
||||
for (tints, colormap) in biome_functions {
|
||||
self.colormaps.push(Arc::new(colormap));
|
||||
let i = Some((self.colormaps.len() - 1) as u16);
|
||||
if tints == [Tint::Water] {
|
||||
self.water_colormap = i;
|
||||
}
|
||||
for info in &mut self.info {
|
||||
let own = &info.tints[..usize::from(info.tint_count).min(2)];
|
||||
if own.iter().any(|t| tints.contains(t)) {
|
||||
info.colormap = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
let water_function = self.water_colormap;
|
||||
for (fluid, targets, id) in &modifiers {
|
||||
if *fluid && water_function.is_some() {
|
||||
continue;
|
||||
}
|
||||
if *fluid {
|
||||
if targets.matches("minecraft:water", |_| false) {
|
||||
self.water_colormap = index(id, &mut self.colormaps);
|
||||
@@ -1495,9 +1557,9 @@ struct Tints<'a> {
|
||||
/// 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>>,
|
||||
sampled: crate::fast_hash::FxHashMap<(u16, [i32; 3]), Option<u32>>,
|
||||
/// Each colormap's own colour by block, before the blend averages them.
|
||||
cells: std::collections::HashMap<(u16, [i32; 3]), Option<u32>>,
|
||||
cells: crate::fast_hash::FxHashMap<(u16, [i32; 3]), Option<u32>>,
|
||||
}
|
||||
impl<'a> Tints<'a> {
|
||||
fn new(
|
||||
@@ -1518,8 +1580,8 @@ impl<'a> Tints<'a> {
|
||||
uniform: None,
|
||||
colormaps: &[],
|
||||
water_colormap: None,
|
||||
sampled: std::collections::HashMap::new(),
|
||||
cells: std::collections::HashMap::new(),
|
||||
sampled: Default::default(),
|
||||
cells: Default::default(),
|
||||
}
|
||||
}
|
||||
/// With the packs' colormaps of `tables`.
|
||||
@@ -1531,10 +1593,16 @@ impl<'a> Tints<'a> {
|
||||
/// 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> {
|
||||
let colormap = self.colormaps.get(usize::from(index))?.clone();
|
||||
// A colormap that ignores the height, over one biome: one colour per column.
|
||||
let y = if colormap.column && self.uniform_biome().is_some() {
|
||||
0
|
||||
} else {
|
||||
y
|
||||
};
|
||||
if let Some(&known) = self.sampled.get(&(index, [x, y, z])) {
|
||||
return known;
|
||||
}
|
||||
let colormap = self.colormaps.get(usize::from(index))?.clone();
|
||||
// ClientLevel.calculateBlockTint: the mean of the (2r+1)^2 colours around the block, each
|
||||
// channel summed and divided. A transparent pixel in the square leaves the block's own.
|
||||
let r = if colormap.biome_blend { self.radius } else { 0 };
|
||||
@@ -1573,7 +1641,11 @@ impl<'a> Tints<'a> {
|
||||
if let Some(&known) = self.cells.get(&(index, at)) {
|
||||
return known;
|
||||
}
|
||||
let id = Self::biome_id(&self.zoom, self.region, self.plains, self.origin, at);
|
||||
// One biome over the section and its blend margin: no zoom per block.
|
||||
let id = match self.uniform_biome() {
|
||||
Some(biome) => biome,
|
||||
None => Self::biome_id(&self.zoom, self.region, self.plains, self.origin, at),
|
||||
};
|
||||
let [cx, cz, base_y] = self.origin;
|
||||
let block = [cx * 16 + at[0], base_y + at[1], cz * 16 + at[2]];
|
||||
let mut host = crate::environment::BlockHost::new(block, self.biomes.get(id));
|
||||
|
||||
@@ -251,3 +251,117 @@ fn a_biome_colormap_blends_over_a_biome_edge_as_the_vanilla_water_tint_does() {
|
||||
}
|
||||
assert!(edge_differs, "the square must cross the edge somewhere");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn biome_colors_and_a_grass_block_colormap_replace_their_whole_biome_function() {
|
||||
let biomes = Biomes::from_jsons(&[(
|
||||
"minecraft:plains",
|
||||
serde_json::json!({"effects": {"water_color": 0x3F76E4}}),
|
||||
)]);
|
||||
// biome_colors gives water a flat green; a block modifier colours oak leaves with a map,
|
||||
// which Polytone spreads to the whole foliage function.
|
||||
let root = pack(&[
|
||||
(
|
||||
"assets/mypack/polytone/colors.json",
|
||||
br#"{"biome_colors": {"water": "0x00ff00"}}"#.to_vec(),
|
||||
),
|
||||
(
|
||||
"assets/mypack/polytone/colormaps/leaves.png",
|
||||
png_row(&[[0, 0, 255, 255]]),
|
||||
),
|
||||
(
|
||||
"assets/mypack/polytone/block_modifiers/oak.json",
|
||||
br#"{"targets": ["minecraft:oak_leaves"], "colormap": "mypack:leaves"}"#.to_vec(),
|
||||
),
|
||||
]);
|
||||
let modifiers = crate::environment::polytone_colormap_modifiers(&root);
|
||||
let functions = biome_functions(&root, &modifiers);
|
||||
let tints: Vec<_> = functions.iter().map(|(t, _)| *t).collect();
|
||||
assert_eq!(tints.len(), 2);
|
||||
assert!(tints[0] == [Tint::Foliage] && tints[1] == [Tint::Water]);
|
||||
let colormaps: Vec<_> = functions.into_iter().map(|(_, c)| Arc::new(c)).collect();
|
||||
let chunks: Vec<Vec<Chunk>> = (0..3)
|
||||
.map(|rz| (0..3).map(|rx| filled(rx - 1, rz - 1, 0)).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(1);
|
||||
assert_eq!(tints.water(3, 4, 5), 0x00_FF00);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_colormap_that_ignores_the_height_is_shared_up_a_column() {
|
||||
let root = pack(&[
|
||||
(
|
||||
"assets/mypack/polytone/colormaps/flat.png",
|
||||
png_row(&[[1, 2, 3, 255]]),
|
||||
),
|
||||
(
|
||||
"assets/mypack/polytone/colormaps/flat.json",
|
||||
br#"{"x_axis": "biome_id", "y_axis": "r.noise(o.x/60, o.z/60)"}"#.to_vec(),
|
||||
),
|
||||
(
|
||||
"assets/mypack/polytone/colormaps/tall.png",
|
||||
png_row(&[[1, 2, 3, 255]]),
|
||||
),
|
||||
(
|
||||
"assets/mypack/polytone/colormaps/tall.json",
|
||||
br#"{"y_axis": "y_level"}"#.to_vec(),
|
||||
),
|
||||
]);
|
||||
let load = |id| crate::environment::Colormap::load(&root, id).unwrap();
|
||||
assert!(load("mypack:flat").column);
|
||||
assert!(!load("mypack:tall").column);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
/// Times a section's colormap tints with the freepack's kind of grass colormap (a biome id
|
||||
/// column and a noise row) over the default Biome Blend: `cargo test --release ... -- --ignored
|
||||
/// --nocapture colormap_tint_cost`.
|
||||
#[test]
|
||||
#[ignore = "timing, not a check"]
|
||||
fn colormap_tint_cost() {
|
||||
const RADIUS: i32 = 2;
|
||||
const LAYERS: i32 = 16;
|
||||
let biomes = Biomes::from_jsons(&[(
|
||||
"minecraft:plains",
|
||||
serde_json::json!({"effects": {"water_color": 0x3F76E4}}),
|
||||
)]);
|
||||
let pixels: Vec<[u8; 4]> = (0..16).map(|i| [i * 16, 200, 64, 255]).collect();
|
||||
let root = pack(&[
|
||||
("assets/mypack/polytone/colormaps/grass.png", png_row(&pixels)),
|
||||
(
|
||||
"assets/mypack/polytone/colormaps/grass.json",
|
||||
br#"{"x_axis": "biome_id", "y_axis": "0.5+(r.noise(o.x/60,5+o.z/60)*0.55)", "biome_id_mapper": {"minecraft:plains": 1, "texture_size": 16}}"#
|
||||
.to_vec(),
|
||||
),
|
||||
]);
|
||||
let colormaps = vec![Arc::new(
|
||||
crate::environment::Colormap::load(&root, "mypack:grass").unwrap(),
|
||||
)];
|
||||
let chunks: Vec<Vec<Chunk>> = (0..3)
|
||||
.map(|rz| (0..3).map(|rx| filled(rx - 1, rz - 1, 0)).collect())
|
||||
.collect();
|
||||
let region: [[Option<&Chunk>; 3]; 3] =
|
||||
std::array::from_fn(|rz| std::array::from_fn(|rx| Some(&chunks[rz][rx])));
|
||||
let rounds = 50;
|
||||
let start = std::time::Instant::now();
|
||||
let mut check = 0u64;
|
||||
for _ in 0..rounds {
|
||||
let mut tints = Tints::new(®ion, &biomes, BiomeZoom::new(0), RADIUS, [0, 0, 64]);
|
||||
tints.colormaps = &colormaps;
|
||||
for y in 0..LAYERS {
|
||||
for z in 0..16 {
|
||||
for x in 0..16 {
|
||||
check += u64::from(tints.colormap(0, x, y, z).unwrap_or(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let per = start.elapsed() / rounds;
|
||||
println!("{LAYERS} layers of 256 blended colormap tints: {per:?} (checksum {check})");
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
@@ -8,6 +8,7 @@ pub use crate::settings::Opt;
|
||||
|
||||
use crate::{
|
||||
gamepad::{self, GlyphStyle},
|
||||
hdr_calibration::Picture,
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
keybinds::{self, CATEGORIES, KeyBinds, all_mappings},
|
||||
lang::{self, LanguageInfo},
|
||||
@@ -338,6 +339,7 @@ impl Opt {
|
||||
Opt::SharePresence => "options.sharePresence",
|
||||
Opt::DirectionalAudio => "options.directionalAudio",
|
||||
Opt::MusicToast => "options.musicToast",
|
||||
Opt::Page(page, row) => pages::slider_caption(page, row),
|
||||
}
|
||||
}
|
||||
pub(crate) fn kind(self, s: &Settings) -> Kind {
|
||||
@@ -401,6 +403,7 @@ impl Opt {
|
||||
Opt::PadLookDeadzone => unit(s.controller.look_deadzone, 0.0, 0.5),
|
||||
Opt::PadTriggerThreshold => unit(s.controller.trigger_threshold, 0.1, 0.9),
|
||||
Opt::SprintWindow => unit(s.sprint_window as f32, 0.0, 10.0),
|
||||
Opt::Page(page, row) => pages::slider_kind(page, row),
|
||||
_ => Kind::Cycle,
|
||||
}
|
||||
}
|
||||
@@ -877,6 +880,7 @@ impl Opt {
|
||||
gamepad::TextKeyboard::Horizon => "horizon.options.keyboard.horizon",
|
||||
gamepad::TextKeyboard::Steam => "horizon.options.keyboard.steam",
|
||||
}),
|
||||
Opt::Page(page, row) => pages::slider_text(page, row),
|
||||
_ => Text::empty(),
|
||||
}
|
||||
}
|
||||
@@ -951,6 +955,7 @@ impl Opt {
|
||||
Opt::PadLookDeadzone => s.controller.look_deadzone = step(0.0, 50.0) / 100.0,
|
||||
Opt::PadTriggerThreshold => s.controller.trigger_threshold = step(10.0, 90.0) / 100.0,
|
||||
Opt::SprintWindow => s.sprint_window = step(0.0, 10.0) as i32,
|
||||
Opt::Page(page, row) => pages::slide(page, row, v),
|
||||
_ => {}
|
||||
}
|
||||
if self.affects_preset() {
|
||||
@@ -1330,6 +1335,8 @@ pub enum Page {
|
||||
ModProject,
|
||||
/// A native mod's settings page: its index in `mods.installed`.
|
||||
NativeMod(usize),
|
||||
/// A page a crate registered (`horizon_hooks::pages`): its index in `OptionsUi::pages`.
|
||||
Added(usize),
|
||||
/// Horizon's shader packs, either kind (`shader_pack_list`).
|
||||
ShaderPacks,
|
||||
/// The picked pack's options: the shader screen (`shader_ui`) at the pack's main screen.
|
||||
@@ -1389,6 +1396,8 @@ enum Act {
|
||||
/// A native mod's key (mod, key) waits for a key; a button of its page.
|
||||
NativeKey(usize, usize),
|
||||
NativeButton(usize, usize),
|
||||
/// A registered page's row: (page, row).
|
||||
PageRow(usize, usize),
|
||||
OpenModFolder,
|
||||
/// Browse mods: the page of listing `.0` of `mod_browser`.
|
||||
ModProject(usize),
|
||||
@@ -1616,7 +1625,8 @@ pub struct OptionsUi {
|
||||
/// Key Binds: the mapping waiting for a key ([`NATIVE_KEY`]: a native
|
||||
/// mod's, `selected_native_key`).
|
||||
pub selected_key: Option<&'static str>,
|
||||
/// The native mod's key waiting: (mod, key) in `mods.installed`.
|
||||
/// The native mod's key waiting: (mod, key) in `mods.installed`, or a registered page's
|
||||
/// (page, row) ([`pages::PAGE_KEY`]).
|
||||
selected_native_key: Option<(usize, usize)>,
|
||||
/// Bindings: the action waiting for a pad button.
|
||||
pub selected_pad_action: Option<&'static str>,
|
||||
@@ -1682,8 +1692,8 @@ pub struct OptionsUi {
|
||||
pub split_profiles: crate::split_profiles::SplitProfiles,
|
||||
/// HDR calibration while its page is open.
|
||||
pub calibration: Option<crate::hdr_calibration::Calibration>,
|
||||
/// Whether the window was fullscreen before calibration made it so (the game keeps it).
|
||||
pub calibration_fullscreen: Option<bool>,
|
||||
/// The window as calibration found it before making it fullscreen (the game keeps it).
|
||||
pub calibration_window: Option<crate::hdr_calibration::WindowState>,
|
||||
pub split_setup: crate::split_ui::SplitUi,
|
||||
pub studio: crate::studio_ui::StudioUi,
|
||||
/// The AI Agent page's status lines (set by the game while it shows).
|
||||
@@ -1762,7 +1772,7 @@ impl Default for OptionsUi {
|
||||
pack_configs: Default::default(),
|
||||
split_profiles: Default::default(),
|
||||
calibration: None,
|
||||
calibration_fullscreen: None,
|
||||
calibration_window: None,
|
||||
split_setup: Default::default(),
|
||||
studio: Default::default(),
|
||||
agent_status: Vec::new(),
|
||||
@@ -1860,25 +1870,6 @@ fn wide_extras(s: &Settings, width: impl Fn(&str) -> f32) -> Vec<crate::extras::
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// What a button opening one of Horizon's pages says on hover.
|
||||
fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
|
||||
Some(match page {
|
||||
Page::Horizon => "horizon.pages.settings.tooltip",
|
||||
Page::Studio => "horizon.pages.studio.tooltip",
|
||||
Page::Graphics => "horizon.pages.graphics.tooltip",
|
||||
Page::GraphicsAdvanced => "horizon.pages.graphics_advanced.tooltip",
|
||||
Page::Controller => "horizon.pages.controller.tooltip",
|
||||
Page::Extras => "horizon.pages.extras.tooltip",
|
||||
Page::Pvp => "horizon.pages.pvp.tooltip",
|
||||
Page::Agent => "horizon.pages.agent.tooltip",
|
||||
Page::SplitProfiles => "horizon.pages.split_profiles.tooltip",
|
||||
Page::ShaderPacks => "horizon.pages.shader_packs.tooltip",
|
||||
Page::Mods => "horizon.pages.mods.tooltip",
|
||||
Page::SplitSetup => "horizon.pages.split_setup.tooltip",
|
||||
Page::Music => "horizon.pages.music.tooltip",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
/// Rows of an OptionsList: (big?, options) or a header.
|
||||
enum Row {
|
||||
Big(Opt),
|
||||
@@ -1898,14 +1889,17 @@ fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
|
||||
return None;
|
||||
};
|
||||
let key = opt.tooltip_key(s);
|
||||
return lang::has(&key).then(|| tr(&key));
|
||||
let Opt::Page(page, row) = opt else {
|
||||
return lang::has(&key).then(|| tr(&key));
|
||||
};
|
||||
return pages::tooltip(*page, *row);
|
||||
};
|
||||
let mut tip = None;
|
||||
if let Act::Extra(item) = act {
|
||||
tip = Some(Text::literal(item.description_text()));
|
||||
}
|
||||
if let Act::Push(page) = act
|
||||
&& let Some(text) = horizon_page_tooltip(*page)
|
||||
&& let Some(text) = pages::horizon_page_tooltip(*page)
|
||||
{
|
||||
tip = Some(tr(text));
|
||||
}
|
||||
@@ -1925,6 +1919,7 @@ fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
|
||||
}
|
||||
match act {
|
||||
Act::Cycle(opt) if !opt.available() => Some(tr("horizon.options.unavailable")),
|
||||
Act::PageRow(page, row) => pages::tooltip(*page, *row),
|
||||
Act::ToggleMod(i) => match s.mods.installed.get(*i) {
|
||||
Some(installed) => {
|
||||
let description = installed
|
||||
@@ -2366,6 +2361,7 @@ impl OptionsUi {
|
||||
Page::ModBrowser => "horizon.mods.browse.title",
|
||||
Page::ModProject => "horizon.mods.browse.project",
|
||||
Page::NativeMod(_) => "Mod Settings",
|
||||
Page::Added(i) => pages::title(i),
|
||||
Page::ShaderPacks => "horizon.pages.shader_packs",
|
||||
Page::ShaderOptions => "horizon.pages.shader_options",
|
||||
Page::PackOptions => "horizon.pack.options.title",
|
||||
@@ -2582,43 +2578,6 @@ impl OptionsUi {
|
||||
}
|
||||
rows
|
||||
}
|
||||
/// A native mod's page: its name, its keys as Key Binds rows (the
|
||||
/// player's choice, else the default), then its buttons.
|
||||
fn native_rows(&self, s: &Settings, i: usize) -> Vec<Row> {
|
||||
let Some(m) = s.mods.installed.get(i) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut rows = vec![Row::Note(m.title.clone())];
|
||||
let Some(page) = &m.native else {
|
||||
return rows;
|
||||
};
|
||||
let chosen = s.mods.keys.get(&m.id);
|
||||
for (k, [name, label, default]) in page.keys.iter().enumerate() {
|
||||
let key = chosen.and_then(|c| c.get(name)).unwrap_or(default);
|
||||
let mut message = keybinds::display_name(key);
|
||||
if self.selected_native_key == Some((i, k)) {
|
||||
message = Text::literal("> ")
|
||||
.append(message.with_color(text::WHITE))
|
||||
.append(" <")
|
||||
.with_color(text::YELLOW);
|
||||
}
|
||||
let caption = button(0.0, 0.0, 150.0, Text::literal(label.clone()), Act::None);
|
||||
let change = button(0.0, 0.0, 150.0, message, Act::NativeKey(i, k));
|
||||
rows.push(Row::Buttons(inactive(caption), Some(change)));
|
||||
}
|
||||
for (b, label) in page.buttons.iter().enumerate() {
|
||||
let widget = button(
|
||||
0.0,
|
||||
0.0,
|
||||
310.0,
|
||||
Text::literal(label.clone()),
|
||||
Act::NativeButton(i, b),
|
||||
);
|
||||
rows.push(Row::Buttons(widget, None));
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
fn list_rows(&self, s: &Settings) -> Vec<Row> {
|
||||
match self.page() {
|
||||
Page::SplitSetup => Vec::new(),
|
||||
@@ -2683,47 +2642,7 @@ impl OptionsUi {
|
||||
flush(&mut rows, &mut section);
|
||||
rows
|
||||
}
|
||||
Page::Horizon => {
|
||||
// Two to a row, as vanilla's OptionsList.addSmall lays out
|
||||
// buttons; each opens one of Horizon's settings pages. Each page has
|
||||
// one way in: graphics and shader packs are Video Settings' (the graphics
|
||||
// screen), the controller is Controls', mods are the Options screen's.
|
||||
let open =
|
||||
|key: &str, page: Page| button(0.0, 0.0, 150.0, tr(key), Act::Push(page));
|
||||
vec![
|
||||
Row::Buttons(
|
||||
open("horizon.pages.open.extras", Page::Extras),
|
||||
Some(open("horizon.pages.open.pvp", Page::Pvp)),
|
||||
),
|
||||
Row::Buttons(
|
||||
open(
|
||||
"horizon.pages.open.split_setup",
|
||||
if self.split_guest {
|
||||
Page::SplitProfiles
|
||||
} else {
|
||||
Page::SplitSetup
|
||||
},
|
||||
),
|
||||
Some(open("horizon.pages.open.studio", Page::Studio)),
|
||||
),
|
||||
// No AI hides the AI Agent page and everything AI.
|
||||
Row::Buttons(
|
||||
self.extra_button(s, crate::extras::Item::DiscordPresence),
|
||||
Some(if s.extras.no_ai {
|
||||
self.extra_button(s, crate::extras::Item::NoAi)
|
||||
} else {
|
||||
open("horizon.pages.open.agent", Page::Agent)
|
||||
}),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.chain(
|
||||
(!s.extras.no_ai).then(|| {
|
||||
Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)
|
||||
}),
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
Page::Horizon => self.horizon_rows(s),
|
||||
Page::Agent => {
|
||||
let mut rows = vec![Row::Buttons(
|
||||
self.extra_button(s, crate::extras::Item::AgentControl),
|
||||
@@ -3006,6 +2925,7 @@ impl OptionsUi {
|
||||
Page::ModBrowser => self.mod_browser_rows(),
|
||||
Page::ModProject => self.mod_project_rows(s),
|
||||
Page::NativeMod(i) => self.native_rows(s, i),
|
||||
Page::Added(i) => self.added_rows(i),
|
||||
_ => {
|
||||
let _ = s;
|
||||
Vec::new()
|
||||
@@ -4030,7 +3950,7 @@ impl OptionsUi {
|
||||
if right {
|
||||
return Vec::new();
|
||||
}
|
||||
let outcome = c.click((x, y), size, drag);
|
||||
let outcome = c.click(&atlas.font, (x, y), size, drag);
|
||||
return self.calibration_outcome(s, outcome);
|
||||
}
|
||||
if self.shell().is_some() {
|
||||
@@ -4272,11 +4192,9 @@ impl OptionsUi {
|
||||
self.push(Page::NativeMod(i));
|
||||
events.push(Event::NativeSettings(i));
|
||||
}
|
||||
Act::NativeKey(i, k) => {
|
||||
self.selected_key = Some(NATIVE_KEY);
|
||||
self.selected_native_key = Some((i, k));
|
||||
}
|
||||
Act::NativeKey(i, k) => self.wait_native_key(i, k),
|
||||
Act::NativeButton(i, b) => events.push(Event::NativeButton(i, b)),
|
||||
Act::PageRow(p, r) => events.extend(self.page_act(p, r, self.shift)),
|
||||
Act::OpenModFolder => events.push(Event::OpenModFolder),
|
||||
Act::ModProject(i) => {
|
||||
if let Some(listing) = self.mod_browser.listings.get(i) {
|
||||
@@ -4632,13 +4550,12 @@ impl OptionsUi {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// HDR calibration's test picture, while its page is open: the step, its nits and
|
||||
/// where the controls start (a share of the height `h`).
|
||||
pub fn calibration_picture(&self, h: f32) -> Option<(crate::hdr_calibration::Step, f32, f32)> {
|
||||
/// HDR calibration's test picture, while its page is open, in a GUI of `size`.
|
||||
pub fn calibration_picture(&self, atlas: &HudAtlas, size: (f32, f32)) -> Option<Picture> {
|
||||
self.calibration
|
||||
.as_ref()
|
||||
.filter(|_| self.page() == Page::HdrCalibration)
|
||||
.map(|c| c.picture(h))
|
||||
.map(|c| c.picture(&atlas.font, size))
|
||||
}
|
||||
pub fn back(&mut self, s: &mut Settings) -> Vec<Event> {
|
||||
let mut events = Vec::new();
|
||||
@@ -4781,19 +4698,10 @@ impl OptionsUi {
|
||||
let Some(name) = self.selected_key.take() else {
|
||||
return Vec::new();
|
||||
};
|
||||
if name == NATIVE_KEY {
|
||||
let (i, k) = self.selected_native_key.take().unwrap_or_default();
|
||||
let Some(m) = s.mods.installed.get(i) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some([key, ..]) = m.native.as_ref().and_then(|p| p.keys.get(k)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let keys = s.mods.keys.entry(m.id.clone()).or_default();
|
||||
keys.insert(key.clone(), value);
|
||||
return vec![Event::Changed];
|
||||
if name == NATIVE_KEY || name == pages::PAGE_KEY {
|
||||
return self.bind_native_key(s, name, value);
|
||||
}
|
||||
if !s.extras.set_key(name, value.clone()) && !s.horizon.set_key(name, value.clone()) {
|
||||
if !s.extras.set_key(name, value.clone()) {
|
||||
s.keys.set(name, &value);
|
||||
}
|
||||
vec![Event::Changed]
|
||||
@@ -5173,11 +5081,9 @@ impl OptionsUi {
|
||||
self.push(Page::NativeMod(i));
|
||||
events.push(Event::NativeSettings(i));
|
||||
}
|
||||
Act::NativeKey(i, k) => {
|
||||
self.selected_key = Some(NATIVE_KEY);
|
||||
self.selected_native_key = Some((i, k));
|
||||
}
|
||||
Act::NativeKey(i, k) => self.wait_native_key(i, k),
|
||||
Act::NativeButton(i, b) => events.push(Event::NativeButton(i, b)),
|
||||
Act::PageRow(p, r) => events.extend(self.page_act(p, r, shift)),
|
||||
Act::OpenModFolder => events.push(Event::OpenModFolder),
|
||||
Act::ModProject(i) => {
|
||||
if let Some(listing) = self.mod_browser.listings.get(i) {
|
||||
@@ -7348,7 +7254,7 @@ mod tests {
|
||||
MouseButton::Back,
|
||||
MouseButton::Forward,
|
||||
] {
|
||||
for name in [SORT_KEY, crate::graphics::HDR_KEY, "key.jump"] {
|
||||
for name in [SORT_KEY, "key.jump"] {
|
||||
let mut ui = OptionsUi::default();
|
||||
let mut s = Settings::default();
|
||||
ui.selected_key = Some(name);
|
||||
@@ -7357,7 +7263,6 @@ mod tests {
|
||||
let value = keybinds::mouse_name(button);
|
||||
match name {
|
||||
SORT_KEY => assert_eq!(s.extras.sort_key, value),
|
||||
crate::graphics::HDR_KEY => assert_eq!(s.horizon.hdr_key, value),
|
||||
_ => assert_eq!(s.keys.get(name), value),
|
||||
}
|
||||
assert_eq!(Extras::parse(&s.extras.serialize()), s.extras);
|
||||
@@ -8439,3 +8344,5 @@ mod tests {
|
||||
#[cfg(test)]
|
||||
#[path = "options_ui_graphics_tests.rs"]
|
||||
mod graphics_tests;
|
||||
#[path = "options_ui_pages.rs"]
|
||||
mod pages;
|
||||
@@ -143,3 +143,34 @@ fn the_graphics_screens_wheel_scrolls_even_over_a_slider_being_changed() {
|
||||
assert_eq!(s.horizon.chunk_updates, 16);
|
||||
assert!(ui.graphics.scroll_amount() > before);
|
||||
}
|
||||
|
||||
/// A window holding HDR output back (a start failed on its driver) shows it on the HDR page
|
||||
/// as soon as the graphics screen opens, with no setting applied: the row's line, and
|
||||
/// Calibrate blocked, where the same window not holding it back calibrates.
|
||||
#[test]
|
||||
fn a_held_hdr_start_shows_as_the_graphics_screen_opens() {
|
||||
use crate::gfx::output::tests::{snapshot, window};
|
||||
let atlas = atlas();
|
||||
let mut s = Settings::default();
|
||||
s.horizon.hdr = crate::graphics::HdrMode::Auto;
|
||||
s.horizon.hdr_failed_start = Some("Vulkan 10de:2684 GPU; driver; 1.0; os".into());
|
||||
let (mut caps, reading) = window(Some(crate::gfx::HdrOutput::Pq), Some(true));
|
||||
for held in [true, false] {
|
||||
caps.held = held;
|
||||
let mut ui = OptionsUi {
|
||||
size: (427.0, 240.0),
|
||||
..Default::default()
|
||||
};
|
||||
let output = snapshot(&s.horizon, Default::default(), caps, reading);
|
||||
ui.draw(
|
||||
&s,
|
||||
output,
|
||||
&mut HudBatch::new([427, 240]),
|
||||
&atlas,
|
||||
(-1.0, -1.0),
|
||||
);
|
||||
ui.show(Page::Graphics);
|
||||
assert!(calibrates(&mut ui, &mut s, &atlas) != held, "held {held}");
|
||||
assert_eq!(ui.graphics.screen.output.held, held);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
//! The pages crates add to the options screens: a native mod's settings page (its keys and
|
||||
//! buttons), and the pages crates above the engine register (`horizon_hooks::pages`, docs/layers.md
|
||||
//! "Platform registries") with their entries on Horizon Settings. Kept out of options_ui.rs, which
|
||||
//! may not grow (tools/check_file_size.py).
|
||||
use super::*;
|
||||
use horizon_hooks::pages as added;
|
||||
|
||||
/// `selected_key` while a registered page's key waits (`selected_native_key`: page, row).
|
||||
pub(super) const PAGE_KEY: &str = "horizon.pageKey";
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// A test's own pages, instead of the game's registered ones: each test runs on its thread.
|
||||
static TEST_PAGES: std::cell::Cell<Option<&'static [&'static dyn added::Page]>> =
|
||||
const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
/// The pages crates registered.
|
||||
fn registered() -> &'static [&'static dyn added::Page] {
|
||||
#[cfg(test)]
|
||||
if let Some(pages) = TEST_PAGES.get() {
|
||||
return pages;
|
||||
}
|
||||
added::engine::all()
|
||||
}
|
||||
|
||||
/// A registered page's title (a translation key).
|
||||
pub(super) fn title(page: usize) -> &'static str {
|
||||
registered().get(page).map_or("", |p| p.title())
|
||||
}
|
||||
|
||||
/// Row `r` of a registered page, as it is now.
|
||||
fn row(page: usize, r: usize) -> Option<added::Row> {
|
||||
let mut rows = Vec::new();
|
||||
registered().get(page)?.rows(&mut rows);
|
||||
rows.into_iter().nth(r)
|
||||
}
|
||||
|
||||
/// A registered page's slider ([`Opt::Page`]): its caption (a translation key).
|
||||
pub(super) fn slider_caption(page: usize, r: usize) -> &'static str {
|
||||
match row(page, r) {
|
||||
Some(added::Row::Slider { label, .. }) => label,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// A registered page's slider: its position.
|
||||
pub(super) fn slider_kind(page: usize, r: usize) -> Kind {
|
||||
match row(page, r) {
|
||||
Some(added::Row::Slider { value, .. }) => Kind::Slider(value.clamp(0.0, 1.0)),
|
||||
_ => Kind::Cycle,
|
||||
}
|
||||
}
|
||||
|
||||
/// A registered page's slider: the value its message shows after the caption.
|
||||
pub(super) fn slider_text(page: usize, r: usize) -> Text {
|
||||
match row(page, r) {
|
||||
Some(added::Row::Slider { text, .. }) => label_text(text),
|
||||
_ => Text::empty(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A registered page's slider moved.
|
||||
pub(super) fn slide(page: usize, r: usize, value: f32) {
|
||||
if let Some(registered) = registered().get(page) {
|
||||
registered.act(r, added::Act::Slide(value));
|
||||
}
|
||||
}
|
||||
|
||||
/// What a registered page's row says on hover.
|
||||
pub(super) fn tooltip(page: usize, r: usize) -> Option<Text> {
|
||||
registered().get(page)?.tooltip(r).map(label_text)
|
||||
}
|
||||
|
||||
/// What a button opening one of Horizon's pages says on hover.
|
||||
pub(super) fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
|
||||
Some(match page {
|
||||
Page::Horizon => "horizon.pages.settings.tooltip",
|
||||
Page::Studio => "horizon.pages.studio.tooltip",
|
||||
Page::Graphics => "horizon.pages.graphics.tooltip",
|
||||
Page::GraphicsAdvanced => "horizon.pages.graphics_advanced.tooltip",
|
||||
Page::Controller => "horizon.pages.controller.tooltip",
|
||||
Page::Extras => "horizon.pages.extras.tooltip",
|
||||
Page::Pvp => "horizon.pages.pvp.tooltip",
|
||||
Page::Agent => "horizon.pages.agent.tooltip",
|
||||
Page::SplitProfiles => "horizon.pages.split_profiles.tooltip",
|
||||
Page::ShaderPacks => "horizon.pages.shader_packs.tooltip",
|
||||
Page::Mods => "horizon.pages.mods.tooltip",
|
||||
Page::SplitSetup => "horizon.pages.split_setup.tooltip",
|
||||
Page::Music => "horizon.pages.music.tooltip",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn label_text(label: added::Label) -> Text {
|
||||
match label {
|
||||
added::Label::Key(key) => tr(key),
|
||||
added::Label::Value(value) => Text::literal(value),
|
||||
}
|
||||
}
|
||||
|
||||
impl OptionsUi {
|
||||
/// Horizon Settings: its pages, then the pages crates registered for it.
|
||||
pub(super) fn horizon_rows(&self, s: &Settings) -> Vec<Row> {
|
||||
// Two to a row, as vanilla's OptionsList.addSmall lays out
|
||||
// buttons; each opens one of Horizon's settings pages. Each page has
|
||||
// one way in: graphics and shader packs are Video Settings' (the graphics
|
||||
// screen), the controller is Controls', mods are the Options screen's.
|
||||
let open = |key: &str, page: Page| button(0.0, 0.0, 150.0, tr(key), Act::Push(page));
|
||||
let mut rows: Vec<Row> = vec![
|
||||
Row::Buttons(
|
||||
open("horizon.pages.open.extras", Page::Extras),
|
||||
Some(open("horizon.pages.open.pvp", Page::Pvp)),
|
||||
),
|
||||
Row::Buttons(
|
||||
open(
|
||||
"horizon.pages.open.split_setup",
|
||||
if self.split_guest {
|
||||
Page::SplitProfiles
|
||||
} else {
|
||||
Page::SplitSetup
|
||||
},
|
||||
),
|
||||
Some(open("horizon.pages.open.studio", Page::Studio)),
|
||||
),
|
||||
// No AI hides the AI Agent page and everything AI.
|
||||
Row::Buttons(
|
||||
self.extra_button(s, crate::extras::Item::DiscordPresence),
|
||||
Some(if s.extras.no_ai {
|
||||
self.extra_button(s, crate::extras::Item::NoAi)
|
||||
} else {
|
||||
open("horizon.pages.open.agent", Page::Agent)
|
||||
}),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.chain(
|
||||
(!s.extras.no_ai)
|
||||
.then(|| Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)),
|
||||
)
|
||||
.collect();
|
||||
self.added_entries(&mut rows);
|
||||
rows
|
||||
}
|
||||
|
||||
/// The entries of the pages registered for Horizon Settings, two to a row like the engine's.
|
||||
fn added_entries(&self, rows: &mut Vec<Row>) {
|
||||
let mut entries = registered()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, page)| page.place() == added::Place::HorizonSettings)
|
||||
.map(|(i, page)| button(0.0, 0.0, 150.0, tr(page.title()), Act::Push(Page::Added(i))));
|
||||
while let Some(first) = entries.next() {
|
||||
rows.push(Row::Buttons(first, entries.next()));
|
||||
}
|
||||
}
|
||||
|
||||
/// A registered page's rows, asked of it each time the page is drawn.
|
||||
pub(super) fn added_rows(&self, page: usize) -> Vec<Row> {
|
||||
let Some(registered) = registered().get(page) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut rows = Vec::new();
|
||||
registered.rows(&mut rows);
|
||||
rows.into_iter()
|
||||
.enumerate()
|
||||
.map(|(r, row)| self.added_row(page, r, row))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn added_row(&self, page: usize, r: usize, row: added::Row) -> Row {
|
||||
let act = Act::PageRow(page, r);
|
||||
let wide = |label: Text| Row::Buttons(button(0.0, 0.0, 310.0, label, act.clone()), None);
|
||||
match row {
|
||||
added::Row::Heading(key) => Row::Heading(key.to_owned()),
|
||||
added::Row::Note(text) => Row::Note(text),
|
||||
added::Row::Toggle { label, on } => wide(generic(label, on_off(on))),
|
||||
added::Row::Cycle { label, value } => wide(generic(label, label_text(value))),
|
||||
added::Row::Slider { .. } => Row::Big(Opt::Page(page, r)),
|
||||
added::Row::Button { label } => wide(tr(label)),
|
||||
added::Row::Key { label, key } => {
|
||||
let mut message = keybinds::display_name(&key);
|
||||
if self.selected_key == Some(PAGE_KEY)
|
||||
&& self.selected_native_key == Some((page, r))
|
||||
{
|
||||
message = Text::literal("> ")
|
||||
.append(message.with_color(text::WHITE))
|
||||
.append(" <")
|
||||
.with_color(text::YELLOW);
|
||||
}
|
||||
let caption = button(0.0, 0.0, 150.0, tr(label), Act::None);
|
||||
let change = button(0.0, 0.0, 150.0, message, act);
|
||||
Row::Buttons(inactive(caption), Some(change))
|
||||
}
|
||||
// A row kind newer than this engine: shown empty.
|
||||
_ => Row::Note(String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A registered page's row was pressed: a key row waits for a key, any other row hears it.
|
||||
pub(super) fn page_act(&mut self, page: usize, r: usize, shift: bool) -> Vec<Event> {
|
||||
let Some(registered) = registered().get(page) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut rows = Vec::new();
|
||||
registered.rows(&mut rows);
|
||||
match rows.get(r) {
|
||||
Some(added::Row::Key { .. }) => {
|
||||
self.selected_key = Some(PAGE_KEY);
|
||||
self.selected_native_key = Some((page, r));
|
||||
Vec::new()
|
||||
}
|
||||
Some(_) => {
|
||||
registered.act(
|
||||
r,
|
||||
if shift {
|
||||
added::Act::PressBack
|
||||
} else {
|
||||
added::Act::Press
|
||||
},
|
||||
);
|
||||
vec![Event::Changed]
|
||||
}
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A native mod's page: its name, its keys as Key Binds rows (the
|
||||
/// player's choice, else the default), then its buttons.
|
||||
pub(super) fn native_rows(&self, s: &Settings, i: usize) -> Vec<Row> {
|
||||
let Some(m) = s.mods.installed.get(i) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut rows = vec![Row::Note(m.title.clone())];
|
||||
let Some(page) = &m.native else {
|
||||
return rows;
|
||||
};
|
||||
let chosen = s.mods.keys.get(&m.id);
|
||||
for (k, [name, label, default]) in page.keys.iter().enumerate() {
|
||||
let key = chosen.and_then(|c| c.get(name)).unwrap_or(default);
|
||||
let mut message = keybinds::display_name(key);
|
||||
if self.selected_native_key == Some((i, k)) {
|
||||
message = Text::literal("> ")
|
||||
.append(message.with_color(text::WHITE))
|
||||
.append(" <")
|
||||
.with_color(text::YELLOW);
|
||||
}
|
||||
let caption = button(0.0, 0.0, 150.0, Text::literal(label.clone()), Act::None);
|
||||
let change = button(0.0, 0.0, 150.0, message, Act::NativeKey(i, k));
|
||||
rows.push(Row::Buttons(inactive(caption), Some(change)));
|
||||
}
|
||||
for (b, label) in page.buttons.iter().enumerate() {
|
||||
let widget = button(
|
||||
0.0,
|
||||
0.0,
|
||||
310.0,
|
||||
Text::literal(label.clone()),
|
||||
Act::NativeButton(i, b),
|
||||
);
|
||||
rows.push(Row::Buttons(widget, None));
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// A native mod's key waits for the next key or button pressed.
|
||||
pub(super) fn wait_native_key(&mut self, i: usize, k: usize) {
|
||||
self.selected_key = Some(NATIVE_KEY);
|
||||
self.selected_native_key = Some((i, k));
|
||||
}
|
||||
|
||||
/// The key pressed for the native mod's key, or the registered page's ([`PAGE_KEY`]), that
|
||||
/// waited for one.
|
||||
pub(super) fn bind_native_key(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
name: &str,
|
||||
value: String,
|
||||
) -> Vec<Event> {
|
||||
let (i, k) = self.selected_native_key.take().unwrap_or_default();
|
||||
if name == PAGE_KEY {
|
||||
if let Some(page) = registered().get(i) {
|
||||
page.act(k, added::Act::Bind(value));
|
||||
}
|
||||
return vec![Event::Changed];
|
||||
}
|
||||
let Some(m) = s.mods.installed.get(i) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some([key, ..]) = m.native.as_ref().and_then(|p| p.keys.get(k)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let keys = s.mods.keys.entry(m.id.clone()).or_default();
|
||||
keys.insert(key.clone(), value);
|
||||
vec![Event::Changed]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// A page with a heading, a switch, a number that steps either way, a key and a volume.
|
||||
struct Sample {
|
||||
on: Mutex<bool>,
|
||||
step: Mutex<i32>,
|
||||
key: Mutex<String>,
|
||||
volume: Mutex<f32>,
|
||||
}
|
||||
|
||||
impl added::Page for Sample {
|
||||
fn id(&self) -> &'static str {
|
||||
"test.sample"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"test.sample.title"
|
||||
}
|
||||
fn place(&self) -> added::Place {
|
||||
added::Place::HorizonSettings
|
||||
}
|
||||
fn rows(&self, rows: &mut Vec<added::Row>) {
|
||||
rows.push(added::Row::Heading("test.sample.heading"));
|
||||
rows.push(added::Row::Toggle {
|
||||
label: "test.sample.switch",
|
||||
on: *self.on.lock().unwrap(),
|
||||
});
|
||||
rows.push(added::Row::Cycle {
|
||||
label: "test.sample.step",
|
||||
value: added::Label::Value(self.step.lock().unwrap().to_string()),
|
||||
});
|
||||
rows.push(added::Row::Key {
|
||||
label: "test.sample.key",
|
||||
key: self.key.lock().unwrap().clone(),
|
||||
});
|
||||
let volume = *self.volume.lock().unwrap();
|
||||
rows.push(added::Row::Slider {
|
||||
label: "test.sample.volume",
|
||||
value: volume,
|
||||
text: added::Label::Value(format!("{}%", (volume * 100.0).round())),
|
||||
});
|
||||
}
|
||||
fn act(&self, row: usize, act: added::Act) {
|
||||
match (row, act) {
|
||||
(1, added::Act::Press) => {
|
||||
let mut on = self.on.lock().unwrap();
|
||||
*on = !*on;
|
||||
}
|
||||
(2, added::Act::Press) => *self.step.lock().unwrap() += 1,
|
||||
(2, added::Act::PressBack) => *self.step.lock().unwrap() -= 1,
|
||||
(3, added::Act::Bind(key)) => *self.key.lock().unwrap() = key,
|
||||
(4, added::Act::Slide(v)) => *self.volume.lock().unwrap() = v,
|
||||
other => panic!("row and act the page doesn't expect: {other:?}"),
|
||||
}
|
||||
}
|
||||
fn tooltip(&self, row: usize) -> Option<added::Label> {
|
||||
(row == 1).then_some(added::Label::Key("test.sample.switch.tooltip"))
|
||||
}
|
||||
}
|
||||
|
||||
static SAMPLE: Sample = Sample {
|
||||
on: Mutex::new(false),
|
||||
step: Mutex::new(0),
|
||||
key: Mutex::new(String::new()),
|
||||
volume: Mutex::new(0.0),
|
||||
};
|
||||
static PAGES: [&dyn added::Page; 1] = [&SAMPLE];
|
||||
|
||||
/// Clicks the active button that does `act`, with Shift held or not.
|
||||
fn press(ui: &mut OptionsUi, s: &mut Settings, act: Act, shift: bool) -> Vec<Event> {
|
||||
let size = (480.0, 240.0);
|
||||
let (widgets, ..) = ui.layout(s, size.0, size.1);
|
||||
let (x, y) = widgets
|
||||
.iter()
|
||||
.find_map(|w| match &w.kind {
|
||||
WKind::Button {
|
||||
act: a,
|
||||
active: true,
|
||||
..
|
||||
} if *a == act => Some((w.x + 1.0, w.y + 1.0)),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("no button does {act:?}"));
|
||||
ui.shift = shift;
|
||||
let atlas = super::super::tests::atlas();
|
||||
ui.click(s, &atlas, x, y, size, [480, 240], false, false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_registered_page_opens_from_horizon_settings_and_hears_its_rows() {
|
||||
let mut s = Settings::default();
|
||||
TEST_PAGES.set(Some(&PAGES));
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::Horizon);
|
||||
press(&mut ui, &mut s, Act::Push(Page::Added(0)), false);
|
||||
assert_eq!(ui.page(), Page::Added(0));
|
||||
// Rows: the heading (0), the switch (1), the step (2), the key (3).
|
||||
let events = press(&mut ui, &mut s, Act::PageRow(0, 1), false);
|
||||
assert_eq!(
|
||||
events.last(),
|
||||
Some(&Event::Changed),
|
||||
"the settings get saved"
|
||||
);
|
||||
assert!(*SAMPLE.on.lock().unwrap());
|
||||
press(&mut ui, &mut s, Act::PageRow(0, 2), false);
|
||||
press(&mut ui, &mut s, Act::PageRow(0, 2), true);
|
||||
press(&mut ui, &mut s, Act::PageRow(0, 2), true);
|
||||
assert_eq!(*SAMPLE.step.lock().unwrap(), -1, "Shift steps back");
|
||||
// A key row waits for the next key, as on Key Binds, and the page gets its name.
|
||||
press(&mut ui, &mut s, Act::PageRow(0, 3), false);
|
||||
let atlas = super::super::tests::atlas();
|
||||
let events = ui.key(&mut s, &atlas, winit::keyboard::KeyCode::KeyZ, None);
|
||||
assert_eq!(events, [Event::Changed]);
|
||||
assert_eq!(*SAMPLE.key.lock().unwrap(), "key.keyboard.z");
|
||||
assert_eq!(ui.selected_key, None);
|
||||
// The volume is one of the screen's own sliders: dragged to its middle, then let go.
|
||||
let size = (480.0, 240.0);
|
||||
let (widgets, ..) = ui.layout(&s, size.0, size.1);
|
||||
let slider = widgets
|
||||
.iter()
|
||||
.find(|w| {
|
||||
matches!(
|
||||
w.kind,
|
||||
WKind::Slider {
|
||||
opt: Opt::Page(0, 4),
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.expect("the volume's slider");
|
||||
let (x, y) = (slider.x + slider.w / 2.0, slider.y + 1.0);
|
||||
ui.click(&mut s, &atlas, x, y, size, [480, 240], false, false);
|
||||
let volume = *SAMPLE.volume.lock().unwrap();
|
||||
assert!((0.45..0.55).contains(&volume), "{volume}");
|
||||
assert!(ui.release().contains(&Event::Changed));
|
||||
// The slider shows the page's value once let go.
|
||||
let (widgets, ..) = ui.layout(&s, size.0, size.1);
|
||||
let shown = widgets.iter().find_map(|w| match w.kind {
|
||||
WKind::Slider {
|
||||
opt: Opt::Page(0, 4),
|
||||
value,
|
||||
..
|
||||
} => Some(value),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(shown, Some(volume));
|
||||
// The page's tooltip for its switch.
|
||||
let switch = widgets
|
||||
.iter()
|
||||
.find(|w| {
|
||||
matches!(
|
||||
&w.kind,
|
||||
WKind::Button {
|
||||
act: Act::PageRow(0, 1),
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.expect("the switch");
|
||||
assert_eq!(
|
||||
tooltip_text(&s, switch).map(|t| t.plain()),
|
||||
Some(tr("test.sample.switch.tooltip").plain())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Polytone's custom colours (`horizon_polytone::colors`, `colors.json` or `color.properties`)
|
||||
//! as the engine's pack colours ([`crate::pack_palette::PackColors`]).
|
||||
use std::path::Path;
|
||||
|
||||
use horizon_polytone::colors::PackColors;
|
||||
|
||||
use crate::block_entity_render::DYES;
|
||||
use crate::pack_palette::{self, Environment};
|
||||
|
||||
/// The colours of every namespace's file under the pack overlay at `root`.
|
||||
pub fn read(root: &Path) -> pack_palette::PackColors {
|
||||
let colors = PackColors::read(root);
|
||||
let by_dye = |f: &dyn Fn(&str) -> Option<u32>| std::array::from_fn(|i| f(DYES[i].0));
|
||||
let env = colors.env;
|
||||
pack_palette::PackColors {
|
||||
dye_diffuse: by_dye(&|name| colors.dye(name, "diffuse")),
|
||||
dye_text: by_dye(&|name| colors.dye(name, "text")),
|
||||
sheep: by_dye(&|name| colors.sheep(name)),
|
||||
redstone: std::array::from_fn(|i| colors.redstone(i as u8)),
|
||||
effects: colors.effects(),
|
||||
entity_shadows: colors.entity_shadows(),
|
||||
xp_bar: colors.xp_bar(),
|
||||
env: Environment {
|
||||
powder_snow_fog: env.powder_snow_fog,
|
||||
blindness_fog_distance: env.blindness_fog_distance,
|
||||
darkness_fog_distance: env.darkness_fog_distance,
|
||||
boss_fog_distance: env.boss_fog_distance,
|
||||
water_fog_brightening: env.water_fog_brightening,
|
||||
water_fog_brightening_time: env.water_fog_brightening_time,
|
||||
horizon_height: env.horizon_height,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::pack_palette::{diffuse, redstone, sheep_wool, text};
|
||||
|
||||
#[test]
|
||||
fn a_pack_recolours_only_what_it_names() {
|
||||
// Dyes no other test draws: the table is process-wide.
|
||||
let dir = std::env::temp_dir().join(format!("pack-dyes-{}", std::process::id()));
|
||||
let polytone = dir.join("assets/obs/polytone");
|
||||
std::fs::create_dir_all(&polytone).unwrap();
|
||||
std::fs::write(
|
||||
polytone.join("colors.json"),
|
||||
r#"{"dye": {"cyan": {"diffuse": "00ff00", "text": "0000ff"}}, "sheep": {"pink": "2040ff"}, "redstone": {"7": "0x123456"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
crate::pack_palette::set(&read(&dir));
|
||||
let (cyan, pink, purple) = (9, 6, 10);
|
||||
assert_eq!(diffuse(cyan), 0x00ff00);
|
||||
assert_eq!(text(cyan), 0x0000ff);
|
||||
assert_eq!(sheep_wool(pink as u8), 0x2040ff);
|
||||
// Wool reads vanilla's dye, not the pack's diffuse: 75% of 0x169C9C.
|
||||
assert_eq!(sheep_wool(cyan as u8), 0x107575);
|
||||
assert_eq!(diffuse(purple), 0x8932B8);
|
||||
assert_eq!(text(purple), 0xA020F0);
|
||||
assert_eq!((redstone(7), redstone(15)), (Some(0x123456), None));
|
||||
crate::pack_palette::set(&Default::default());
|
||||
assert_eq!(redstone(7), None);
|
||||
assert_eq!(diffuse(cyan), 0x169C9C);
|
||||
assert_eq!(sheep_wool(pink as u8), 0xB6687F);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
//! OptiFine custom skies (`horizon_optifine::sky`) on the engine's sky layers
|
||||
//! ([`crate::custom_sky`]): a pack's layers with their decoded textures, read once with the pack,
|
||||
//! and each frame's choice of layers, their brightness and rotation. A pack without
|
||||
//! `optifine/sky` has none, and the sky stays vanilla.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use glam::{Mat4, Vec3};
|
||||
use horizon_optifine::sky::{Blend, Conditions, Now, Skies, SkyLayer, Transition};
|
||||
|
||||
use crate::custom_sky::{self as engine, Draw, Image, SkyLayers, State};
|
||||
|
||||
/// The pack's layers, by world, with one texture per layer (`images[i]` for the `i`th layer
|
||||
/// over all worlds, in `worlds` order).
|
||||
#[derive(Default)]
|
||||
pub struct CustomSky {
|
||||
worlds: Vec<(i32, Vec<SkyLayer>)>,
|
||||
pub images: Vec<Image>,
|
||||
}
|
||||
|
||||
/// Textures past this many texels in all are left out (a skybox is a few megapixels at most).
|
||||
const TEXEL_BUDGET: u64 = 64 << 20;
|
||||
|
||||
impl CustomSky {
|
||||
/// The custom skies of the overlay at `root`. A layer whose texture does not decode is
|
||||
/// dropped with a warning.
|
||||
pub fn load(root: &Path) -> Self {
|
||||
let skies = Skies::scan(root);
|
||||
let mut out = Self::default();
|
||||
let mut texels = 0u64;
|
||||
for (world, sky) in skies.worlds {
|
||||
let mut layers = Vec::new();
|
||||
for layer in sky.layers {
|
||||
match crate::hud::decode(&root.join(&layer.source)) {
|
||||
Ok((width, height, rgba))
|
||||
if texels + u64::from(width) * u64::from(height) <= TEXEL_BUDGET =>
|
||||
{
|
||||
texels += u64::from(width) * u64::from(height);
|
||||
out.images.push(Image {
|
||||
width,
|
||||
height,
|
||||
rgba,
|
||||
});
|
||||
layers.push(layer);
|
||||
}
|
||||
Ok(_) => log::warn!("{}: past the custom sky's texture budget", layer.source),
|
||||
Err(e) => log::warn!("{}: {e:#}", layer.source),
|
||||
}
|
||||
}
|
||||
if !layers.is_empty() {
|
||||
out.worlds.push((world, layers));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.worlds.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// The engine's sky blend for OptiFine's.
|
||||
fn blend(blend: Blend) -> engine::Blend {
|
||||
match blend {
|
||||
Blend::Add => engine::Blend::Add,
|
||||
Blend::Subtract => engine::Blend::Subtract,
|
||||
Blend::Multiply => engine::Blend::Multiply,
|
||||
Blend::Dodge => engine::Blend::Dodge,
|
||||
Blend::Burn => engine::Blend::Burn,
|
||||
Blend::Screen => engine::Blend::Screen,
|
||||
Blend::Replace => engine::Blend::Replace,
|
||||
Blend::Overlay => engine::Blend::Overlay,
|
||||
Blend::Alpha => engine::Blend::Alpha,
|
||||
}
|
||||
}
|
||||
|
||||
impl SkyLayers for CustomSky {
|
||||
fn images(&self) -> &[Image] {
|
||||
&self.images
|
||||
}
|
||||
|
||||
/// Whether `world` has layers (`0` the Overworld, `1` the End).
|
||||
fn has(&self, world: i32) -> bool {
|
||||
self.worlds.iter().any(|(w, _)| *w == world)
|
||||
}
|
||||
|
||||
/// The layers whose conditions hold, eased over their `transition`, at the brightness of
|
||||
/// their fade times.
|
||||
fn draws(
|
||||
&self,
|
||||
world: i32,
|
||||
conditions: &engine::Conditions,
|
||||
sun_turns: f32,
|
||||
seconds: f32,
|
||||
state: &mut State,
|
||||
out: &mut Vec<Draw>,
|
||||
) {
|
||||
let conditions = &Conditions {
|
||||
day_time: conditions.day_time,
|
||||
weather: match conditions.weather {
|
||||
engine::Weather::Clear => Now::Clear,
|
||||
engine::Weather::Rain => Now::Rain,
|
||||
engine::Weather::Thunder => Now::Thunder,
|
||||
},
|
||||
biome: conditions.biome,
|
||||
y: conditions.y,
|
||||
};
|
||||
out.clear();
|
||||
let mut first = 0;
|
||||
let mut found = None;
|
||||
for (w, layers) in &self.worlds {
|
||||
if *w == world {
|
||||
found = Some(layers);
|
||||
break;
|
||||
}
|
||||
first += layers.len();
|
||||
}
|
||||
let Some(layers) = found else {
|
||||
state.world = None;
|
||||
return;
|
||||
};
|
||||
// A new world (or pack) starts each layer where its conditions put it: no fade in.
|
||||
let snap = state.world != Some(world) || state.eased.len() != layers.len();
|
||||
if snap {
|
||||
state.world = Some(world);
|
||||
state.eased.clear();
|
||||
state.eased.resize(layers.len(), 0.0);
|
||||
}
|
||||
for (i, (layer, eased)) in layers.iter().zip(&mut state.eased).enumerate() {
|
||||
let target = if layer.shows(conditions) { 1.0 } else { 0.0 };
|
||||
let shown = if snap {
|
||||
*eased = target;
|
||||
target
|
||||
} else {
|
||||
let mut t = Transition { value: *eased };
|
||||
*eased = t.step(target, seconds, layer.transition);
|
||||
*eased
|
||||
};
|
||||
let brightness = shown * layer.brightness(conditions.day_time);
|
||||
if brightness <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let turns = layer.turns(conditions.day_time, sun_turns);
|
||||
let rotation = if turns == 0.0 {
|
||||
Mat4::IDENTITY
|
||||
} else {
|
||||
Mat4::from_axis_angle(Vec3::from_array(layer.axis), turns * std::f32::consts::TAU)
|
||||
};
|
||||
out.push(Draw {
|
||||
image: (first + i) as u16,
|
||||
blend: blend(layer.blend),
|
||||
brightness,
|
||||
rotation,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::custom_sky::{Conditions, Weather};
|
||||
|
||||
fn pack(files: &[(&str, &str)]) -> (std::path::PathBuf, CustomSky) {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"horizon-custom-sky-{}-{}",
|
||||
std::process::id(),
|
||||
files.len()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let dir = root.join("assets/minecraft/optifine/sky/world0");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let png = {
|
||||
let mut bytes = Vec::new();
|
||||
let mut encoder = png::Encoder::new(&mut bytes, 6, 4);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().unwrap();
|
||||
writer
|
||||
.write_image_data(&[255, 0, 0, 255].repeat(24))
|
||||
.unwrap();
|
||||
writer.finish().unwrap();
|
||||
bytes
|
||||
};
|
||||
for (name, text) in files {
|
||||
std::fs::write(dir.join(format!("{name}.properties")), text).unwrap();
|
||||
std::fs::write(dir.join(format!("{name}.png")), &png).unwrap();
|
||||
}
|
||||
let sky = CustomSky::load(&root);
|
||||
(root, sky)
|
||||
}
|
||||
|
||||
fn noon(biome: &str) -> Conditions<'_> {
|
||||
Conditions {
|
||||
day_time: 6000,
|
||||
weather: Weather::Clear,
|
||||
biome,
|
||||
y: 64,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_ease_in_at_a_biome_border_but_not_on_arrival() {
|
||||
let (root, sky) = pack(&[
|
||||
("sky1", "biomes=desert\ntransition=2"),
|
||||
("sky2", "blend=alpha\nrotate=false"),
|
||||
]);
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
assert!(sky.has(0) && !sky.has(1));
|
||||
assert_eq!(sky.images[0].width, 6);
|
||||
let (mut state, mut out) = (State::default(), Vec::new());
|
||||
sky.draws(
|
||||
0,
|
||||
&noon("minecraft:desert"),
|
||||
0.0,
|
||||
0.05,
|
||||
&mut state,
|
||||
&mut out,
|
||||
);
|
||||
assert_eq!(
|
||||
out.iter().map(|d| d.brightness).collect::<Vec<_>>(),
|
||||
[1.0, 1.0]
|
||||
);
|
||||
// Out of the desert: the first layer fades over its two seconds.
|
||||
sky.draws(0, &noon("minecraft:plains"), 0.0, 0.5, &mut state, &mut out);
|
||||
assert_eq!(out[0].brightness, 0.75);
|
||||
assert_eq!(out[1].blend, engine::Blend::Alpha);
|
||||
sky.draws(0, &noon("minecraft:plains"), 0.0, 2.0, &mut state, &mut out);
|
||||
assert_eq!(out.len(), 1, "a dark layer is not drawn");
|
||||
assert_eq!(out[0].image, 1);
|
||||
// Into the End, which has no layers, and back: no fade.
|
||||
sky.draws(1, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
|
||||
assert!(out.is_empty());
|
||||
sky.draws(0, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
|
||||
assert_eq!(out.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_turns_west_like_the_sun() {
|
||||
let (root, sky) = pack(&[("sky1", "")]);
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
let (mut state, mut out) = (State::default(), Vec::new());
|
||||
// A quarter turn after noon (sunset): the skybox's zenith has gone down to the west.
|
||||
let c = Conditions {
|
||||
day_time: 12000,
|
||||
..noon("minecraft:plains")
|
||||
};
|
||||
sky.draws(0, &c, 0.25, 0.0, &mut state, &mut out);
|
||||
let up = out[0].rotation.transform_vector3(Vec3::Y);
|
||||
assert!((up - Vec3::NEG_X).length() < 1e-5, "{up}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Polytone's item modifiers' durability bar colours, on the engine's
|
||||
//! [`crate::pack_palette::ItemBars`].
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// An item modifier's `bar_color` of the `damage` type: a row of colours picked by the stack's
|
||||
/// damage, the first pixel undamaged, the last at full damage (measured on the real mod).
|
||||
struct DamageBar {
|
||||
targets: horizon_polytone::Targets,
|
||||
row: Vec<u32>,
|
||||
}
|
||||
|
||||
/// The packs' damage bars, the last read first.
|
||||
struct Bars(Vec<DamageBar>);
|
||||
|
||||
impl crate::pack_palette::ItemBars for Bars {
|
||||
fn color(&self, item: &str, damage: f32) -> Option<u32> {
|
||||
let bar = self
|
||||
.0
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|b| b.targets.matches(item, |_| false))?;
|
||||
let last = (bar.row.len() - 1) as f32;
|
||||
Some(bar.row[(damage.clamp(0.0, 1.0) * last).round() as usize])
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the packs' item modifiers' durability bar colours under a pack overlay root: an
|
||||
/// `<item>_bar.png` alone, or a json whose `bar_color` is `damage` with its `<name>_bar.png` (or
|
||||
/// `<name>.png`) beside it. Colormap ids and inline colormaps aren't read. `None` without any.
|
||||
pub fn read(root: &Path) -> Option<Arc<dyn crate::pack_palette::ItemBars>> {
|
||||
let mut bars = Vec::new();
|
||||
for namespace in std::fs::read_dir(root.join("assets"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
{
|
||||
let base = namespace.path().join("polytone/item_modifiers");
|
||||
let mut stack = vec![base.clone()];
|
||||
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);
|
||||
} else if let Some(bar) = item_bar(root, &base, &path) {
|
||||
bars.push(bar);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(!bars.is_empty()).then(|| Arc::new(Bars(bars)) as _)
|
||||
}
|
||||
|
||||
fn item_bar(root: &Path, base: &Path, path: &Path) -> Option<DamageBar> {
|
||||
let name = path.file_name()?.to_str()?;
|
||||
let row = |png: &Path| {
|
||||
let (width, _, rgba) = crate::sprites::read_rgba(png).ok()?;
|
||||
let row: Vec<u32> = rgba[..width as usize * 4]
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|p| u32::from(p[0]) << 16 | u32::from(p[1]) << 8 | u32::from(p[2]))
|
||||
.collect();
|
||||
(!row.is_empty()).then_some(row)
|
||||
};
|
||||
if let Some(stem) = name.strip_suffix("_bar.png") {
|
||||
// A json of the same name owns its png.
|
||||
if path.with_file_name(format!("{stem}.json")).exists() {
|
||||
return None;
|
||||
}
|
||||
let relative = path.parent()?.strip_prefix(base).ok()?.join(stem);
|
||||
let namespace = base.parent()?.parent()?.file_name()?.to_str()?;
|
||||
let id = format!("{namespace}:{}", relative.to_str()?.replace('\\', "/"));
|
||||
let targets = horizon_polytone::Targets::parse(None, Some(&id)).ok()?;
|
||||
return Some(DamageBar {
|
||||
targets,
|
||||
row: row(path)?,
|
||||
});
|
||||
}
|
||||
let stem = name.strip_suffix(".json")?;
|
||||
let json: serde_json::Value = horizon_world::json_file::read(path).ok()?;
|
||||
if json.get("bar_color")?.as_str()? != "damage" {
|
||||
return None;
|
||||
}
|
||||
let png = [format!("{stem}_bar.png"), format!("{stem}.png")]
|
||||
.into_iter()
|
||||
.map(|png| path.with_file_name(png))
|
||||
.find(|png| png.exists())?;
|
||||
let relative = path.strip_prefix(root).unwrap_or(path).to_string_lossy();
|
||||
let implicit = horizon_polytone::implicit_id(&relative, "item_modifiers");
|
||||
let targets =
|
||||
horizon_polytone::Targets::parse(json.get("targets"), implicit.as_deref()).ok()?;
|
||||
Some(DamageBar {
|
||||
targets,
|
||||
row: row(&png)?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn png_row(path: &Path, pixels: &[[u8; 3]]) {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let file = std::fs::File::create(path).unwrap();
|
||||
let mut encoder = png::Encoder::new(file, pixels.len() as u32, 1);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
let data: Vec<u8> = pixels
|
||||
.iter()
|
||||
.flat_map(|p| [p[0], p[1], p[2], 255])
|
||||
.collect();
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&data)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damage_bar_picks_its_pixel_by_the_stacks_damage() {
|
||||
let dir = std::env::temp_dir().join(format!("pack-bars-{}", std::process::id()));
|
||||
let modifiers = dir.join("assets/minecraft/polytone/item_modifiers");
|
||||
let rgby = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0]];
|
||||
png_row(&modifiers.join("diamond_pickaxe_bar.png"), &rgby);
|
||||
std::fs::write(
|
||||
modifiers.join("tools.json"),
|
||||
r#"{"targets": ["minecraft:iron_axe"], "bar_color": "damage"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
png_row(&modifiers.join("tools_bar.png"), &[[1, 2, 3], [4, 5, 6]]);
|
||||
let bars = read(&dir).unwrap();
|
||||
// The real mod: damage 200 of 1561 shows the first pixel, 1400 the last.
|
||||
assert_eq!(
|
||||
bars.color("minecraft:diamond_pickaxe", 200.0 / 1561.0),
|
||||
Some(0xFF0000)
|
||||
);
|
||||
assert_eq!(
|
||||
bars.color("minecraft:diamond_pickaxe", 1400.0 / 1561.0),
|
||||
Some(0xFFFF00)
|
||||
);
|
||||
assert_eq!(bars.color("minecraft:iron_axe", 0.9), Some(0x040506));
|
||||
assert_eq!(bars.color("minecraft:diamond_shovel", 0.5), None);
|
||||
assert!(read(&dir.join("none")).is_none());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! The resource pack formats vanilla doesn't read (OptiFine's, Polytone's) on the engine's
|
||||
//! extension points ([`crate::pack_hooks`]). This is platform code (docs/layers.md) kept in this
|
||||
//! crate until the platform's executable installs it: it uses only the crate's public API, so it
|
||||
//! moves to a platform crate unchanged, and nothing else in the crate names it.
|
||||
|
||||
mod colors;
|
||||
mod custom_sky;
|
||||
mod item_bars;
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::pack_hooks::PackFormats;
|
||||
|
||||
/// OptiFine's and Polytone's formats.
|
||||
pub struct Formats;
|
||||
|
||||
impl PackFormats for Formats {
|
||||
fn colors(&self, root: &Path) -> Option<crate::pack_palette::PackColors> {
|
||||
Some(colors::read(root))
|
||||
}
|
||||
|
||||
fn custom_sky(&self, root: &Path) -> Option<Arc<dyn crate::custom_sky::SkyLayers>> {
|
||||
let sky = custom_sky::CustomSky::load(root);
|
||||
(!sky.is_empty()).then(|| Arc::new(sky) as _)
|
||||
}
|
||||
|
||||
fn item_bars(&self, root: &Path) -> Option<Arc<dyn crate::pack_palette::ItemBars>> {
|
||||
item_bars::read(root)
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs these formats ([`crate::pack_hooks::install`]); the game does at startup.
|
||||
pub fn install() {
|
||||
crate::pack_hooks::install(&Formats);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//! The engine's extension points for resource pack formats vanilla doesn't read (OptiFine's,
|
||||
//! Polytone's: platform code, docs/layers.md). The engine asks [`formats`] once per pack load and
|
||||
//! gets engine types back (tables, or an object it calls at most once a frame), never a format's
|
||||
//! own types. The platform installs its formats at startup ([`install`]); with none installed,
|
||||
//! packs load as vanilla reads them.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
/// What the pack formats beyond vanilla's add to a pack. Each method reads one overlay of a pack
|
||||
/// (its folder at `root`); the default is "nothing in this format".
|
||||
pub trait PackFormats: Send + Sync {
|
||||
/// The colours the overlay replaces where vanilla hardcodes them.
|
||||
fn colors(&self, _root: &Path) -> Option<crate::pack_palette::PackColors> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The sky layers the overlay draws over the vanilla sky, if it has any.
|
||||
fn custom_sky(&self, _root: &Path) -> Option<Arc<dyn crate::custom_sky::SkyLayers>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The durability bar colours the overlay sets by item, if any.
|
||||
fn item_bars(&self, _root: &Path) -> Option<Arc<dyn crate::pack_palette::ItemBars>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// No format beyond vanilla's.
|
||||
struct Vanilla;
|
||||
|
||||
impl PackFormats for Vanilla {}
|
||||
|
||||
static FORMATS: OnceLock<&'static dyn PackFormats> = OnceLock::new();
|
||||
|
||||
/// Installs the pack formats, before the first pack loads. The first call wins: later ones are
|
||||
/// ignored, so each test or probe that needs the formats may install them.
|
||||
pub fn install(formats: &'static dyn PackFormats) {
|
||||
let _ = FORMATS.set(formats);
|
||||
}
|
||||
|
||||
/// The installed pack formats, or vanilla's alone.
|
||||
pub fn formats() -> &'static dyn PackFormats {
|
||||
FORMATS.get().copied().unwrap_or(&Vanilla)
|
||||
}
|
||||
@@ -1,12 +1,87 @@
|
||||
//! Polytone's dye, sheep and redstone colours (`dye.<name>.diffuse|text`, `sheep.<name>`,
|
||||
//! `redstone.<power>`), for drawing only: the pack recolours collars, tropical fish, banners, wool,
|
||||
//! sign text and redstone wire and dust, while gameplay (crafting, item data, the beacon) keeps
|
||||
//! vanilla's `DyeColor`. Lock-free: the draw paths and the mesher's workers read it per face.
|
||||
//! The colours a pack replaces where vanilla hardcodes them (Polytone's `colors.json`, read by the
|
||||
//! platform's pack formats, [`crate::pack_hooks`]): dye, sheep and redstone colours, for drawing
|
||||
//! only (the pack recolours collars, tropical fish, banners, wool, sign text and redstone wire and
|
||||
//! dust, while gameplay keeps vanilla's `DyeColor`), mob effect colours, the experience number's,
|
||||
//! entity shadows, the effect fogs' settings and durability bar colours. Lock-free: the draw paths
|
||||
//! and the mesher's workers read the dye, sheep and redstone tables per face.
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use crate::block_entity_render::DYES;
|
||||
use crate::signs::DYE_TEXT;
|
||||
use horizon_polytone::colors::PackColors;
|
||||
|
||||
/// The fog settings vanilla hardcodes, as the packs set them (vanilla's by default).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Environment {
|
||||
/// The fog colour inside powder snow (RGB).
|
||||
pub powder_snow_fog: Option<u32>,
|
||||
/// The fog ends of Blindness (5), Darkness (15) and bosses that add world fog (96).
|
||||
pub blindness_fog_distance: f32,
|
||||
pub darkness_fog_distance: f32,
|
||||
pub boss_fog_distance: f32,
|
||||
/// How much the underwater fog colour washes out (1 vanilla, 0 none).
|
||||
pub water_fog_brightening: f32,
|
||||
/// The ticks under water it takes (vanilla's 600).
|
||||
pub water_fog_brightening_time: f32,
|
||||
/// Below it the sky draws its dark disc (vanilla's 63; flat worlds keep their floor).
|
||||
pub horizon_height: f32,
|
||||
}
|
||||
|
||||
impl Default for Environment {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
powder_snow_fog: None,
|
||||
blindness_fog_distance: 5.0,
|
||||
darkness_fog_distance: 15.0,
|
||||
boss_fog_distance: 96.0,
|
||||
water_fog_brightening: 1.0,
|
||||
water_fog_brightening_time: 600.0,
|
||||
horizon_height: 63.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The packs' colours; `None` (and the defaults) where they keep vanilla's.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PackColors {
|
||||
/// By dye id (`DyeColor` order): `getTextureDiffuseColor`, `getTextColor`, the sheep's wool.
|
||||
pub dye_diffuse: [Option<u32>; 16],
|
||||
pub dye_text: [Option<u32>; 16],
|
||||
pub sheep: [Option<u32>; 16],
|
||||
/// `RedStoneWireBlock.COLORS` by power.
|
||||
pub redstone: [Option<u32>; 16],
|
||||
/// Mob effects' colours, by effect id without vanilla's namespace.
|
||||
pub effects: Vec<(String, u32)>,
|
||||
/// False hides every entity's round shadow, whatever the video option says.
|
||||
pub entity_shadows: bool,
|
||||
/// The experience level number's colour and outline.
|
||||
pub xp_bar: [Option<u32>; 2],
|
||||
/// The effect fogs' settings: the fog reads them every frame.
|
||||
pub env: Environment,
|
||||
}
|
||||
|
||||
impl Default for PackColors {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
dye_diffuse: [None; 16],
|
||||
dye_text: [None; 16],
|
||||
sheep: [None; 16],
|
||||
redstone: [None; 16],
|
||||
effects: Vec::new(),
|
||||
entity_shadows: true,
|
||||
xp_bar: [None; 2],
|
||||
env: Environment::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PackColors {
|
||||
/// Whether entities draw their round shadow, as far as the packs say.
|
||||
pub fn entity_shadows(&self) -> bool {
|
||||
self.entity_shadows
|
||||
}
|
||||
}
|
||||
|
||||
/// 0 is unset, else `1 << 32 | rgb`.
|
||||
static DIFFUSE: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
|
||||
@@ -14,6 +89,30 @@ static TEXT: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
|
||||
static SHEEP: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
|
||||
static REDSTONE: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
|
||||
|
||||
/// The colours of the pack overlay at `root`, from the installed pack formats; their dye, sheep
|
||||
/// and redstone colours go to the draw paths' tables ([`set`]).
|
||||
pub fn read(root: &Path) -> PackColors {
|
||||
let formats = crate::pack_hooks::formats();
|
||||
let colors = formats.colors(root).unwrap_or_default();
|
||||
set(&colors);
|
||||
*ITEM_BARS.write().unwrap_or_else(|e| e.into_inner()) = formats.item_bars(root);
|
||||
colors
|
||||
}
|
||||
|
||||
/// The packs' durability bar colours by item (Polytone's item modifiers' `bar_color`).
|
||||
pub trait ItemBars: Send + Sync {
|
||||
/// The bar colour (RGB) of `item` at this damage (0 to 1), when a pack sets one.
|
||||
fn color(&self, item: &str, damage: f32) -> Option<u32>;
|
||||
}
|
||||
|
||||
static ITEM_BARS: RwLock<Option<Arc<dyn ItemBars>>> = RwLock::new(None);
|
||||
|
||||
/// A pack's durability bar colour (RGB) for an item at this damage (0 to 1).
|
||||
pub fn item_bar_color(item: &str, damage: f32) -> Option<u32> {
|
||||
let bars = ITEM_BARS.read().unwrap_or_else(|e| e.into_inner());
|
||||
bars.as_ref()?.color(item, damage)
|
||||
}
|
||||
|
||||
/// Takes the colours of the packs just loaded (vanilla's where they set none).
|
||||
pub fn set(colors: &PackColors) {
|
||||
let store = |table: &[AtomicU64; 16], i: usize, color: Option<u32>| {
|
||||
@@ -22,39 +121,39 @@ pub fn set(colors: &PackColors) {
|
||||
Relaxed,
|
||||
);
|
||||
};
|
||||
for (i, (name, _)) in DYES.iter().enumerate() {
|
||||
store(&DIFFUSE, i, colors.dye(name, "diffuse"));
|
||||
store(&TEXT, i, colors.dye(name, "text"));
|
||||
store(&SHEEP, i, colors.sheep(name));
|
||||
store(&REDSTONE, i, colors.redstone(i as u8));
|
||||
for i in 0..16 {
|
||||
store(&DIFFUSE, i, colors.dye_diffuse[i]);
|
||||
store(&TEXT, i, colors.dye_text[i]);
|
||||
store(&SHEEP, i, colors.sheep[i]);
|
||||
store(&REDSTONE, i, colors.redstone[i]);
|
||||
}
|
||||
}
|
||||
|
||||
fn read(table: &[AtomicU64; 16], i: usize) -> Option<u32> {
|
||||
fn get(table: &[AtomicU64; 16], i: usize) -> Option<u32> {
|
||||
let v = table[i & 15].load(Relaxed);
|
||||
(v != 0).then_some(v as u32)
|
||||
}
|
||||
|
||||
/// `DyeColor.getTextureDiffuseColor` (RGB) as the packs draw it.
|
||||
pub fn diffuse(dye: usize) -> u32 {
|
||||
read(&DIFFUSE, dye).unwrap_or(DYES[dye & 15].1)
|
||||
get(&DIFFUSE, dye).unwrap_or(DYES[dye & 15].1)
|
||||
}
|
||||
|
||||
/// `DyeColor.getTextColor` (RGB) as the packs draw it.
|
||||
pub fn text(dye: usize) -> u32 {
|
||||
read(&TEXT, dye).unwrap_or(DYE_TEXT[dye & 15].1)
|
||||
get(&TEXT, dye).unwrap_or(DYE_TEXT[dye & 15].1)
|
||||
}
|
||||
|
||||
/// A pack's `RedStoneWireBlock.COLORS` entry for this power (RGB), when it sets one.
|
||||
pub fn redstone(power: u8) -> Option<u32> {
|
||||
read(&REDSTONE, usize::from(power))
|
||||
get(&REDSTONE, usize::from(power))
|
||||
}
|
||||
|
||||
/// `ColorLerper.Type.SHEEP` wool colour: a pack's `sheep.<name>`, else white 0xE6E6E6 and the
|
||||
/// others 75% of vanilla's dye (a pack's `dye.*.diffuse` doesn't reach wool).
|
||||
pub fn sheep_wool(dye: u8) -> u32 {
|
||||
let i = usize::from(dye & 15);
|
||||
if let Some(color) = read(&SHEEP, i) {
|
||||
if let Some(color) = get(&SHEEP, i) {
|
||||
return color;
|
||||
}
|
||||
if i == 0 {
|
||||
@@ -64,36 +163,3 @@ pub fn sheep_wool(dye: u8) -> u32 {
|
||||
let f = |c: u32| ((c as f32 * 0.75).floor() as u32).min(255);
|
||||
f(src >> 16 & 255) << 16 | f(src >> 8 & 255) << 8 | f(src & 255)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_pack_recolours_only_what_it_names() {
|
||||
// Dyes no other test draws: the table is process-wide.
|
||||
let dir = std::env::temp_dir().join(format!("pack-dyes-{}", std::process::id()));
|
||||
let polytone = dir.join("assets/obs/polytone");
|
||||
std::fs::create_dir_all(&polytone).unwrap();
|
||||
std::fs::write(
|
||||
polytone.join("colors.json"),
|
||||
r#"{"dye": {"cyan": {"diffuse": "00ff00", "text": "0000ff"}}, "sheep": {"pink": "2040ff"}, "redstone": {"7": "0x123456"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
set(&PackColors::read(&dir));
|
||||
let (cyan, pink, purple) = (9, 6, 10);
|
||||
assert_eq!(diffuse(cyan), 0x00ff00);
|
||||
assert_eq!(text(cyan), 0x0000ff);
|
||||
assert_eq!(sheep_wool(pink as u8), 0x2040ff);
|
||||
// Wool reads vanilla's dye, not the pack's diffuse: 75% of 0x169C9C.
|
||||
assert_eq!(sheep_wool(cyan as u8), 0x107575);
|
||||
assert_eq!(diffuse(purple), 0x8932B8);
|
||||
assert_eq!(text(purple), 0xA020F0);
|
||||
assert_eq!((redstone(7), redstone(15)), (Some(0x123456), None));
|
||||
set(&PackColors::default());
|
||||
assert_eq!(redstone(7), None);
|
||||
assert_eq!(diffuse(cyan), 0x169C9C);
|
||||
assert_eq!(sheep_wool(pink as u8), 0xB6687F);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ mod native;
|
||||
pub mod polytone;
|
||||
mod polytone_gpu;
|
||||
mod polytone_model;
|
||||
mod spawn;
|
||||
pub use native::{ParticleController, ParticleUpdate};
|
||||
|
||||
/// `ParticleOptions` with the parameters of the option classes used here.
|
||||
@@ -1222,8 +1223,9 @@ pub struct ParticleLevel<'a> {
|
||||
pub collision: &'a BlockCollisionRegistry<'a>,
|
||||
/// Local player feet position and vertical velocity (`PlayerCloudParticle`).
|
||||
pub player: Option<([f64; 3], f64)>,
|
||||
pub cached_chunk: std::cell::Cell<Option<((i32, i32), Option<&'a crate::chunk::Chunk>)>>,
|
||||
}
|
||||
impl ParticleLevel<'_> {
|
||||
impl<'a> ParticleLevel<'a> {
|
||||
fn collide(&self, bb: Aabb, d: [f64; 3]) -> [f64; 3] {
|
||||
let mut scene = SceneCollisions {
|
||||
world: self.world,
|
||||
@@ -1231,9 +1233,6 @@ impl ParticleLevel<'_> {
|
||||
subject: None,
|
||||
};
|
||||
let movement = Vec3::new(d[0], d[1], d[2]);
|
||||
// `Entity.collideBoundingBox(null, ...)`: no entity shapes, no border. Where no
|
||||
// block of the swept box's cells can collide, it finds no shape and the
|
||||
// movement stands, as the resolution below would return it.
|
||||
if scene.cells_without_collision(bb.expand_towards(movement)) {
|
||||
return d;
|
||||
}
|
||||
@@ -1249,38 +1248,46 @@ impl ParticleLevel<'_> {
|
||||
p[2].floor() as i32,
|
||||
]
|
||||
}
|
||||
#[inline]
|
||||
pub fn block(&self, x: i32, y: i32, z: i32) -> horizon_world::registry::BlockStateId {
|
||||
let (cx, cz) = (x >> 4, z >> 4);
|
||||
let chunk = match self.cached_chunk.get() {
|
||||
Some(((ccx, ccz), c)) if ccx == cx && ccz == cz => c,
|
||||
_ => {
|
||||
let c = self.world.chunks.get(&(cx, cz)).map(|c| c.as_ref());
|
||||
self.cached_chunk.set(Some(((cx, cz), c)));
|
||||
c
|
||||
}
|
||||
};
|
||||
chunk.map_or(self.world.air, |c| c.block(x & 15, y, z & 15))
|
||||
}
|
||||
/// The fluid at a block and `FluidState.getHeight`.
|
||||
fn fluid(&self, b: [i32; 3]) -> (Fluid, f64) {
|
||||
let s = self.facts.get(self.world.block(b[0], b[1], b[2]));
|
||||
let s = self.facts.get(self.block(b[0], b[1], b[2]));
|
||||
if s.fluid == Fluid::None {
|
||||
return (Fluid::None, 0.0);
|
||||
}
|
||||
let above = self.facts.get(self.world.block(b[0], b[1] + 1, b[2]));
|
||||
let height = if above.fluid == s.fluid {
|
||||
let above = self.facts.get(self.block(b[0], b[1] + 1, b[2]));
|
||||
let h = if above.fluid == s.fluid {
|
||||
1.0
|
||||
} else {
|
||||
f64::from(s.fluid_level) / 9.0
|
||||
};
|
||||
(s.fluid, height)
|
||||
(s.fluid, h)
|
||||
}
|
||||
/// The fluid at a block and whether it is a source (`FluidState.isSource`).
|
||||
pub(crate) fn fluid_source(&self, b: [i32; 3]) -> (Fluid, bool) {
|
||||
let s = self.facts.get(self.world.block(b[0], b[1], b[2]));
|
||||
let s = self.facts.get(self.block(b[0], b[1], b[2]));
|
||||
(s.fluid, s.fluid_level == 8 && !s.fluid_falling)
|
||||
}
|
||||
/// `getCollisionShape(...).max(Y, x, z)` for cached shapes.
|
||||
fn collision_top(&self, b: [i32; 3], fx: f64, fz: f64) -> f64 {
|
||||
let state = self.world.block(b[0], b[1], b[2]);
|
||||
let state = self.block(b[0], b[1], b[2]);
|
||||
match self.collision.cached_shapes().cached_shape(state) {
|
||||
Ok(Some(shape)) if !shape.is_empty() => shape.max_at(Axis::Y, fx, fz),
|
||||
Ok(Some(_)) => 0.0,
|
||||
_ => {
|
||||
if self.facts.get(state).full_collision {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
_ if self.facts.get(state).full_collision => 1.0,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2020,73 +2027,6 @@ impl ParticleEngine {
|
||||
(eye[0] - pos[0]).powi(2) + (eye[1] - pos[1]).powi(2) + (eye[2] - pos[2]).powi(2) <= 9.0
|
||||
})
|
||||
}
|
||||
/// `ClientPacketListener.handleParticleEvent` for `ServerLevel.sendParticles`.
|
||||
pub fn send_particles(
|
||||
&mut self,
|
||||
options: &ParticleOptions,
|
||||
at: [f64; 3],
|
||||
spread: [f64; 3],
|
||||
speed: f64,
|
||||
count: u32,
|
||||
) {
|
||||
if count == 0 {
|
||||
let v = [speed * spread[0], speed * spread[1], speed * spread[2]];
|
||||
self.add(options, at, v);
|
||||
return;
|
||||
}
|
||||
for _ in 0..count {
|
||||
let r = &mut self.random;
|
||||
let p = [
|
||||
at[0] + r.next_gaussian() * spread[0],
|
||||
at[1] + r.next_gaussian() * spread[1],
|
||||
at[2] + r.next_gaussian() * spread[2],
|
||||
];
|
||||
let v = [
|
||||
r.next_gaussian() * speed,
|
||||
r.next_gaussian() * speed,
|
||||
r.next_gaussian() * speed,
|
||||
];
|
||||
self.add(options, p, v);
|
||||
}
|
||||
}
|
||||
/// `ParticleEngine.createParticle`: always spawned, but `ParticleEngine.add`
|
||||
/// drops a spore blossom air particle once `ParticleLimit.SPORE_BLOSSOM` is full.
|
||||
pub fn create(&mut self, options: &ParticleOptions, p: [f64; 3], v: [f64; 3]) {
|
||||
if let Some(requests) = &mut self.created_requests {
|
||||
requests.push((options.clone(), p, v));
|
||||
return;
|
||||
}
|
||||
let seed = self.seeds.next_long();
|
||||
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, options.source.as_deref(), &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.
|
||||
if particle.ty == ParticleType::SporeBlossomAir
|
||||
&& self.particles.len() + self.pending.len() >= SPORE_BLOSSOM_LIMIT
|
||||
&& self
|
||||
.particles
|
||||
.iter()
|
||||
.chain(self.pending.iter())
|
||||
.filter(|q| q.ty == ParticleType::SporeBlossomAir)
|
||||
.count()
|
||||
>= SPORE_BLOSSOM_LIMIT
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.pending.push(particle);
|
||||
}
|
||||
}
|
||||
/// How many particles of each type were made so far.
|
||||
pub fn made(&self) -> &std::collections::BTreeMap<&'static str, u64> {
|
||||
&self.made
|
||||
@@ -4052,15 +3992,12 @@ impl ParticleEngine {
|
||||
}
|
||||
Behaviour::Lava => {
|
||||
p.base_tick(level);
|
||||
if !p.removed {
|
||||
let odds = p.age as f32 / p.lifetime as f32;
|
||||
if p.random.next_float() > odds {
|
||||
self.add(
|
||||
&ParticleOptions::simple(ParticleType::Smoke),
|
||||
p.position,
|
||||
p.velocity,
|
||||
);
|
||||
}
|
||||
if !p.removed && p.random.next_float() > p.age as f32 / p.lifetime as f32 {
|
||||
self.add(
|
||||
&ParticleOptions::simple(ParticleType::Smoke),
|
||||
p.position,
|
||||
p.velocity,
|
||||
);
|
||||
}
|
||||
}
|
||||
Behaviour::Crit => {
|
||||
@@ -4144,9 +4081,7 @@ impl ParticleEngine {
|
||||
*c *= f64::from(0.98f32);
|
||||
}
|
||||
if p.on_ground {
|
||||
if p.random.next_float() < 0.5 {
|
||||
p.removed = true;
|
||||
}
|
||||
p.removed |= p.random.next_float() < 0.5;
|
||||
p.velocity[0] *= f64::from(0.7f32);
|
||||
p.velocity[2] *= f64::from(0.7f32);
|
||||
}
|
||||
@@ -4186,13 +4121,10 @@ impl ParticleEngine {
|
||||
} => {
|
||||
p.base_tick(level);
|
||||
let (fluid, source) = level.fluid_source(ParticleLevel::block_at(p.position));
|
||||
if !(p.removed || (fluid == Fluid::Water && source)) {
|
||||
p.removed = true;
|
||||
}
|
||||
if !p.removed && p.position[1] >= y_end {
|
||||
p.removed = true;
|
||||
}
|
||||
if !p.removed && p.position[1] <= y_prev {
|
||||
if !(fluid == Fluid::Water && source)
|
||||
|| p.position[1] >= y_end
|
||||
|| p.position[1] <= y_prev
|
||||
{
|
||||
p.removed = true;
|
||||
}
|
||||
// randomHorizontalWiggling, then a second, horizontal move.
|
||||
@@ -4216,9 +4148,8 @@ impl ParticleEngine {
|
||||
p.base_tick(level);
|
||||
self.sprite_from_age(p);
|
||||
if p.age as f32 > fade_start {
|
||||
let frames_since_fade_out_start = p.age as f32 - fade_start;
|
||||
p.rgba[3] =
|
||||
(p.lifetime as f32 - frames_since_fade_out_start) / p.lifetime as f32;
|
||||
(p.lifetime as f32 - (p.age as f32 - fade_start)) / p.lifetime as f32;
|
||||
}
|
||||
}
|
||||
Behaviour::NoxiousGasCloud => {
|
||||
@@ -4682,7 +4613,7 @@ impl ParticleEngine {
|
||||
self.sprite_from_age(p);
|
||||
half(p);
|
||||
let b = ParticleLevel::block_at(p.position);
|
||||
if level.facts.is_air(level.world.block(b[0], b[1], b[2])) {
|
||||
if level.facts.is_air(level.block(b[0], b[1], b[2])) {
|
||||
p.velocity[1] -= f64::from(0.0074f32);
|
||||
}
|
||||
}
|
||||
@@ -4690,7 +4621,7 @@ impl ParticleEngine {
|
||||
Behaviour::Firefly => {
|
||||
p.base_tick(level);
|
||||
let b = ParticleLevel::block_at(p.position);
|
||||
if !level.facts.is_air(level.world.block(b[0], b[1], b[2])) {
|
||||
if !level.facts.is_air(level.block(b[0], b[1], b[2])) {
|
||||
p.removed = true;
|
||||
} else {
|
||||
p.rgba[3] = fade_amount(p.lifetime_progress(p.age as f32), 0.3, 0.5);
|
||||
@@ -5154,11 +5085,12 @@ fn hdr_tier(ty: ParticleType) -> u8 {
|
||||
}
|
||||
/// `Particle.LifetimeAlpha.currentAlphaForAge`.
|
||||
fn lifetime_alpha(a: [f32; 4], age: i32, lifetime: i32, partial: f32) -> f32 {
|
||||
if (a[0] - a[1]).abs() < 1.0e-5 {
|
||||
return a[0];
|
||||
}
|
||||
let t = ((age as f32 + partial) / lifetime as f32 - a[2]) / (a[3] - a[2]);
|
||||
a[0] + (a[1] - a[0]) * t.clamp(0.0, 1.0)
|
||||
if (a[0] - a[1]).abs() < 1.0e-5 {
|
||||
a[0]
|
||||
} else {
|
||||
a[0] + (a[1] - a[0]) * t.clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
struct Starter {
|
||||
@@ -5589,6 +5521,7 @@ mod tests {
|
||||
facts: &facts,
|
||||
collision: &collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
engine.set_sprites.push(vec![10, 11, 12]);
|
||||
@@ -5680,6 +5613,7 @@ mod tests {
|
||||
facts: &facts,
|
||||
collision: &collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
let mut particle = Particle::blank(ParticleType::Smoke, [0.0; 3]);
|
||||
|
||||
@@ -124,6 +124,7 @@ mod tests {
|
||||
facts: &facts,
|
||||
collision: &collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut e = engine();
|
||||
let mut p = e
|
||||
|
||||
@@ -490,14 +490,14 @@ enum Subject<'a> {
|
||||
}
|
||||
|
||||
/// What one expression runs against: its subject, the frame and the level.
|
||||
struct Ctx<'a> {
|
||||
struct Ctx<'a, 'l> {
|
||||
subject: Subject<'a>,
|
||||
random: &'a mut LegacyRandom,
|
||||
custom: &'a mut f64,
|
||||
customs: &'a mut BTreeMap<String, f64>,
|
||||
frame: &'a Frame,
|
||||
registry: &'a Registry,
|
||||
level: Option<&'a ParticleLevel<'a>>,
|
||||
level: Option<&'a ParticleLevel<'l>>,
|
||||
blocks: Vec<[i32; 3]>,
|
||||
}
|
||||
|
||||
@@ -515,7 +515,7 @@ fn axis(name: &str) -> usize {
|
||||
usize::from(name.as_bytes()[0] - b'x')
|
||||
}
|
||||
|
||||
impl Ctx<'_> {
|
||||
impl Ctx<'_, '_> {
|
||||
fn position(&self) -> [f64; 3] {
|
||||
match &self.subject {
|
||||
Subject::Particle(p) => p.position,
|
||||
@@ -683,7 +683,7 @@ impl Ctx<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
impl Host for Ctx<'_> {
|
||||
impl Host for Ctx<'_, '_> {
|
||||
fn root(&mut self, root: Root) -> V {
|
||||
match root {
|
||||
Root::Object => V::Object(SELF),
|
||||
@@ -1662,6 +1662,7 @@ mod tests {
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
// Air has no emitters; the torch fires its first emitter only.
|
||||
@@ -1701,6 +1702,7 @@ mod tests {
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
engine.polytone_animate(&level, [1, 64, 1]);
|
||||
@@ -1722,6 +1724,7 @@ mod tests {
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
engine.polytone_animate(&level, [1, 64, 1]);
|
||||
@@ -1767,6 +1770,7 @@ mod tests {
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let registry = Registry::read(&root, Default::default(), blocks);
|
||||
let frame = registry.frame.clone();
|
||||
@@ -1820,6 +1824,7 @@ mod tests {
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let registry = Registry::read(&root, Default::default(), blocks);
|
||||
let mut engine = engine(registry);
|
||||
@@ -1853,6 +1858,7 @@ mod tests {
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
let rect = crate::hud::Rect {
|
||||
@@ -1905,6 +1911,7 @@ mod tests {
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
let rect = crate::hud::Rect {
|
||||
@@ -1966,6 +1973,7 @@ mod tests {
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
engine.polytone_entity(&level, "minecraft:player", [1.0, 70.0, 1.0]);
|
||||
@@ -2014,6 +2022,7 @@ mod tests {
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
let rect = crate::hud::Rect {
|
||||
@@ -2080,6 +2089,7 @@ mod tests {
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let registry = Arc::new(Registry::read(&root, Default::default(), blocks));
|
||||
// The chunk's biome is id 0: plains, whose foliage is #336699.
|
||||
@@ -2198,6 +2208,7 @@ mod tests {
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
engine.polytone_entity(&level, "minecraft:player", [1.5, 70.0, 1.5]);
|
||||
@@ -2276,6 +2287,7 @@ mod tests {
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
// The test chunk's biome is the table's first: a cold one first, then a warm one.
|
||||
|
||||