mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-08 17:19:10 -10:00
Compare commits
755
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6d002a723 | ||
|
|
85ca681ff1 | ||
|
|
22287141a5 | ||
|
|
6aa8930d3f | ||
|
|
f4262df3a2 | ||
|
|
09d0f1e1d8 | ||
|
|
d40955d303 | ||
|
|
e02a4bf0d3 | ||
|
|
c6292de19c | ||
|
|
802a858cc1 | ||
|
|
753594d0a0 | ||
|
|
341675a110 | ||
|
|
5f86c3c505 | ||
|
|
7df108a7be | ||
|
|
ec1b4198fe | ||
|
|
5da4b13265 | ||
|
|
81403ce81c | ||
|
|
3f87ea235e | ||
|
|
b65ebc85e2 | ||
|
|
0d7046557d | ||
|
|
2c32f22313 | ||
|
|
2620806a43 | ||
|
|
d956b41093 | ||
|
|
9253f573d8 | ||
|
|
4a53264476 | ||
|
|
64b7852431 | ||
|
|
cc4abd50ea | ||
|
|
e676b097eb | ||
|
|
1432581c19 | ||
|
|
adf39cbeb2 | ||
|
|
c67f07bbdb | ||
|
|
8295846dfd | ||
|
|
4d6f818c3a | ||
|
|
b1d131e6e3 | ||
|
|
582dc76fbd | ||
|
|
45ffc8628d | ||
|
|
889f3eddfe | ||
|
|
7372b5842e | ||
|
|
8455585a36 | ||
|
|
98b205d5de | ||
|
|
f2d12697f6 | ||
|
|
11b31e9c09 | ||
|
|
af902896d4 | ||
|
|
4d855f9ac0 | ||
|
|
106eec903f | ||
|
|
43f632aec8 | ||
|
|
b0cf285a76 | ||
|
|
bba6a462e0 | ||
|
|
90d6546eca | ||
|
|
480c65877f | ||
|
|
3498cc6165 | ||
|
|
5096ebc1dd | ||
|
|
93a3d53b3e | ||
|
|
82ccc27493 | ||
|
|
5adf98a327 | ||
|
|
77f967a80e | ||
|
|
5146537ec6 | ||
|
|
4489732c79 | ||
|
|
c57bf04727 | ||
|
|
38afa7e7d4 | ||
|
|
d7ff6beecf | ||
|
|
c99bd1a44d | ||
|
|
e81ac01453 | ||
|
|
31fe010646 | ||
|
|
adc19a8a07 | ||
|
|
ba43b0c40f | ||
|
|
26ff2b5e34 | ||
|
|
3639259761 | ||
|
|
c5d0a1c935 | ||
|
|
26415620ea | ||
|
|
ff4b01cd07 | ||
|
|
559baffb0c | ||
|
|
e52114437e | ||
|
|
e0c325ca5a | ||
|
|
93f178d4dd | ||
|
|
4552a50849 | ||
|
|
15583061c1 | ||
|
|
149e80cff5 | ||
|
|
e81f9ad646 | ||
|
|
1645a251ec | ||
|
|
b15db5c782 | ||
|
|
e1d257e6f1 | ||
|
|
12c5dc6169 | ||
|
|
3ef68721a8 | ||
|
|
ae487a0658 | ||
|
|
d1e15acd9b | ||
|
|
9f8a8ac0e6 | ||
|
|
ef1bbc2c19 | ||
|
|
a65582a876 | ||
|
|
6dbd14d78e | ||
|
|
b7fe922e7c | ||
|
|
61ea2cbca0 | ||
|
|
473bd8eb0e | ||
|
|
9d911fbe3e | ||
|
|
5f7cadfff0 | ||
|
|
74dd0ed4c3 | ||
|
|
7ab41043fa | ||
|
|
074be79a6a | ||
|
|
b080361402 | ||
|
|
99bd965511 | ||
|
|
1f9719f9b2 | ||
|
|
42cfe492f3 | ||
|
|
2e2dd80a9b | ||
|
|
ab1f82cf2b | ||
|
|
e6bc883aac | ||
|
|
d8a25fcb16 | ||
|
|
228fbb7d99 | ||
|
|
75b5ac8ec1 | ||
|
|
48683b1757 | ||
|
|
f720c33d24 | ||
|
|
41d5e253ca | ||
|
|
3bb3899088 | ||
|
|
b5249678a2 | ||
|
|
7b8eb4e6f4 | ||
|
|
2ffc0b65ad | ||
|
|
0ae809ff1a | ||
|
|
3a5555a05e | ||
|
|
b51c4fd973 | ||
|
|
2a33624c1e | ||
|
|
9bbcb50aed | ||
|
|
4cc2d109bb | ||
|
|
e7052b5cf9 | ||
|
|
0395b7220e | ||
|
|
dd6d2d09a2 | ||
|
|
dd0a1e8c39 | ||
|
|
84f8a89fce | ||
|
|
bab9bd9eec | ||
|
|
f511bc7488 | ||
|
|
43f69216d3 | ||
|
|
9bde691abc | ||
|
|
425652fce4 | ||
|
|
6651840801 | ||
|
|
2d75ab2e8f | ||
|
|
953512487b | ||
|
|
a9307ea8b7 | ||
|
|
6bf6b1e2c8 | ||
|
|
33f1f52d62 | ||
|
|
5ad154d184 | ||
|
|
243066eeca | ||
|
|
08528c929d | ||
|
|
2863d031b9 | ||
|
|
f780545a63 | ||
|
|
adba2e3fd0 | ||
|
|
39f398091b | ||
|
|
e2c5f32ad4 | ||
|
|
6c6aa33d33 | ||
|
|
4aeaf7d69e | ||
|
|
3ad431c6a3 | ||
|
|
dd2aa4c808 | ||
|
|
cd225f801e | ||
|
|
7344d153b0 | ||
|
|
88b8be3eea | ||
|
|
3417c795ec | ||
|
|
2d708acee1 | ||
|
|
38a989a235 | ||
|
|
4803ac509a | ||
|
|
5919b2ee37 | ||
|
|
a3ea52c7ae | ||
|
|
f606cb820c | ||
|
|
76ef5f41ee | ||
|
|
645e685dc3 | ||
|
|
b5fd86ea95 | ||
|
|
5b01bbb736 | ||
|
|
d86f9b8ae1 | ||
|
|
5c90dcf106 | ||
|
|
6a9479bd4e | ||
|
|
f41546e06e | ||
|
|
d543e34821 | ||
|
|
bdc89c7247 | ||
|
|
652bb95f02 | ||
|
|
785b64e2ea | ||
|
|
e905e18569 | ||
|
|
56777de07b | ||
|
|
12da8060d9 | ||
|
|
42eecc5c6b | ||
|
|
1fb2b9d0e7 | ||
|
|
02756b92c4 | ||
|
|
3ae016d664 | ||
|
|
789b488061 | ||
|
|
d491259de9 | ||
|
|
22f64bbd46 | ||
|
|
81c62c63a3 | ||
|
|
de76c6cfc6 | ||
|
|
16d1d18ef1 | ||
|
|
36a9541fb7 | ||
|
|
e3d4a12cf4 | ||
|
|
edefee0edb | ||
|
|
1a7b54a71b | ||
|
|
af30750fe9 | ||
|
|
fdaf42a7ae | ||
|
|
b40792ba62 | ||
|
|
24e1f1b376 | ||
|
|
61aa03d1ab | ||
|
|
d0da18c651 | ||
|
|
5717940f26 | ||
|
|
a8a83423be | ||
|
|
75d834b5f0 | ||
|
|
c98a51a664 | ||
|
|
9d55121379 | ||
|
|
3a90aecb12 | ||
|
|
524543dfa8 | ||
|
|
53a993c972 | ||
|
|
36c8983d68 | ||
|
|
39c43b82bd | ||
|
|
0b0aded17c | ||
|
|
d978845087 | ||
|
|
957eb14a5e | ||
|
|
601772b4fa | ||
|
|
6a61fdbe28 | ||
|
|
be8b04774f | ||
|
|
0698e8d46d | ||
|
|
9afeda6be3 | ||
|
|
89bb9243ab | ||
|
|
981b36ef36 | ||
|
|
c3f2a9928e | ||
|
|
5a8e4ea92c | ||
|
|
3427559f65 | ||
|
|
ecf510cfc2 | ||
|
|
8201422dc0 | ||
|
|
dec609bb7c | ||
|
|
d67da2cf27 | ||
|
|
4aa63389e5 | ||
|
|
92be6a71c2 | ||
|
|
f1d2c12e29 | ||
|
|
4e5296d180 | ||
|
|
d6bce553cc | ||
|
|
085e9f6064 | ||
|
|
2d5d714788 | ||
|
|
8b55208237 | ||
|
|
a4e0fc8a75 | ||
|
|
854ab1726c | ||
|
|
3254251593 | ||
|
|
e6c80be2c4 | ||
|
|
fc633db30b | ||
|
|
2bb5bb2965 | ||
|
|
5088717918 | ||
|
|
711dad1923 | ||
|
|
7bdd8c9ae1 | ||
|
|
39e6679121 | ||
|
|
c01554de38 | ||
|
|
2f86a8c15f | ||
|
|
31f22dbe88 | ||
|
|
67d42261fc | ||
|
|
07c9753b76 | ||
|
|
5d97cf0158 | ||
|
|
b79fd522cb | ||
|
|
5f5b78fb47 | ||
|
|
7983faa6ce | ||
|
|
36f20dd0aa | ||
|
|
de5617664b | ||
|
|
e9d179e183 | ||
|
|
e790fdeead | ||
|
|
7e78e51558 | ||
|
|
ee2e77db35 | ||
|
|
c0e2fb0c99 | ||
|
|
4442aaed80 | ||
|
|
d2c801b26f | ||
|
|
cca1842d3c | ||
|
|
2e237b9aa1 | ||
|
|
2bac359308 | ||
|
|
c2aaa3d39f | ||
|
|
ef65ffc544 | ||
|
|
c7739935c3 | ||
|
|
a33d9e37c8 | ||
|
|
f496fff3af | ||
|
|
a36ab31067 | ||
|
|
6c68f82884 | ||
|
|
def33e3892 | ||
|
|
01ecc1c7b5 | ||
|
|
facc7f17ea | ||
|
|
0aebe90bbc | ||
|
|
c73b892e42 | ||
|
|
4273e513c0 | ||
|
|
c288428168 | ||
|
|
fa99fc98e6 | ||
|
|
5cc05d62f4 | ||
|
|
11102a5651 | ||
|
|
0cf6116d8d | ||
|
|
bf53e59fae | ||
|
|
affb4cbc98 | ||
|
|
ab6fe1802d | ||
|
|
0043afcd0c | ||
|
|
c23c62df76 | ||
|
|
67093685db | ||
|
|
012391f4aa | ||
|
|
4757e2ddca | ||
|
|
c1e8fc5d4b | ||
|
|
aa3d35ebc7 | ||
|
|
d178e80ea5 | ||
|
|
7f117f484e | ||
|
|
d3910293e0 | ||
|
|
c3d6c640ac | ||
|
|
37e9e82dfd | ||
|
|
f4eebc417e | ||
|
|
0e83631f9d | ||
|
|
a2140a9ef6 | ||
|
|
35b2938c47 | ||
|
|
236b13f290 | ||
|
|
838ae17615 | ||
|
|
5883c0a485 | ||
|
|
66ff309502 | ||
|
|
bdc8a23f21 | ||
|
|
cfeab5a7e9 | ||
|
|
087ce1708b | ||
|
|
61b7fa5b26 | ||
|
|
1df93f9739 | ||
|
|
17e2982614 | ||
|
|
eb74ad2a63 | ||
|
|
34aa927de4 | ||
|
|
491403897f | ||
|
|
3f7e6970ae | ||
|
|
d609f56d42 | ||
|
|
37ed06402d | ||
|
|
e5043bb090 | ||
|
|
1c1ad8ec32 | ||
|
|
d035060e66 | ||
|
|
afb2ce0f1e | ||
|
|
2ffc8b5f56 | ||
|
|
1241a139e9 | ||
|
|
b5aa2303fb | ||
|
|
616ba9f131 | ||
|
|
447e9d277d | ||
|
|
d0d9e050d3 | ||
|
|
678dfde14b | ||
|
|
32715a15da | ||
|
|
1597853de7 | ||
|
|
7d0f866a47 | ||
|
|
600fd5f04a | ||
|
|
2145f26929 | ||
|
|
36e962a58c | ||
|
|
3ce27e636b | ||
|
|
8480ab53d5 | ||
|
|
db2b5ff76b | ||
|
|
06fe2cc687 | ||
|
|
a9033e8c41 | ||
|
|
c3a1e1293f | ||
|
|
1e3e77270d | ||
|
|
138616afdb | ||
|
|
1863a04353 | ||
|
|
3fa4cbd9a8 | ||
|
|
8634b05990 | ||
|
|
f2719daa05 | ||
|
|
bf928658ef | ||
|
|
8c58505b3c | ||
|
|
d3151e0cc2 | ||
|
|
2017a3b977 | ||
|
|
a3b56d6218 | ||
|
|
30aee13f1d | ||
|
|
fec26a0044 | ||
|
|
e9aa68c3ed | ||
|
|
9fd2c8c339 | ||
|
|
3f58a9511b | ||
|
|
2d087e4826 | ||
|
|
d426f98ae2 | ||
|
|
48f02115ed | ||
|
|
06b5147e56 | ||
|
|
9fc6abb08b | ||
|
|
1a9663f21a | ||
|
|
8b4a95b099 | ||
|
|
eaca6fe37a | ||
|
|
c52446638a | ||
|
|
e8acedb5c2 | ||
|
|
a0d1a9f0e3 | ||
|
|
4b2cba530a | ||
|
|
4b99e9a701 | ||
|
|
4497f18b31 | ||
|
|
a978363647 | ||
|
|
e761e6394a | ||
|
|
bced9e4ecd | ||
|
|
c853168fb7 | ||
|
|
4dd22ecd6a | ||
|
|
ba041dc3bb | ||
|
|
861ac2b28d | ||
|
|
706aad56c0 | ||
|
|
564f031294 | ||
|
|
f18c761edc | ||
|
|
c2661a333d | ||
|
|
c09321478d | ||
|
|
519d9b6d97 | ||
|
|
fe3ebc3b8d | ||
|
|
98e37bbb84 | ||
|
|
7965bc432e | ||
|
|
50f53a5a2b | ||
|
|
113ac28e79 | ||
|
|
509deae34e | ||
|
|
156f335169 | ||
|
|
f4cbc6311e | ||
|
|
7a4012d00f | ||
|
|
dca8dbddd1 | ||
|
|
6410d3f446 | ||
|
|
059eb482c4 | ||
|
|
3835921949 | ||
|
|
f8be508301 | ||
|
|
8b06e4ba1e | ||
|
|
197a6ef7dc | ||
|
|
ba54ecff05 | ||
|
|
44a27421ca | ||
|
|
eb8f8e38b5 | ||
|
|
d276c60075 | ||
|
|
ee91d5907a | ||
|
|
1cec3b3751 | ||
|
|
04ead01cb4 | ||
|
|
447341b6a4 | ||
|
|
2bb7df1bf9 | ||
|
|
13c56dd065 | ||
|
|
cceab3b2b5 | ||
|
|
6e00e1bbf1 | ||
|
|
fc14c6fc28 | ||
|
|
d03fbd593a | ||
|
|
0335218910 | ||
|
|
3b76fcb951 | ||
|
|
a7f89f1c62 | ||
|
|
73b2d7434f | ||
|
|
e19018da1c | ||
|
|
5a94e4c188 | ||
|
|
6db9351111 | ||
|
|
4dd40471f8 | ||
|
|
7d2f225c85 | ||
|
|
f15f153836 | ||
|
|
c9f62a1572 | ||
|
|
067a6a1db1 | ||
|
|
7ba6ef0912 | ||
|
|
34c91f0c34 | ||
|
|
0f36e33ccf | ||
|
|
dcc73b1491 | ||
|
|
94e2918668 | ||
|
|
ecc9a21e18 | ||
|
|
00127a51c7 | ||
|
|
92fd45976d | ||
|
|
3bf71f9926 | ||
|
|
df92a7cbc5 | ||
|
|
b7215560ba | ||
|
|
79bd301973 | ||
|
|
ec73a1af84 | ||
|
|
bbbbfa6dd2 | ||
|
|
33facd006a | ||
|
|
0ad6f93762 | ||
|
|
1467568676 | ||
|
|
da428abd4d | ||
|
|
02deae8774 | ||
|
|
90267f9863 | ||
|
|
88dd9e8d1e | ||
|
|
8ce5ba0e52 | ||
|
|
9fb2965c3a | ||
|
|
5c616363b1 | ||
|
|
58140be02b | ||
|
|
4fab87e3ce | ||
|
|
d61235cb39 | ||
|
|
38d670c0ac | ||
|
|
e5cad94537 | ||
|
|
1c17df10b4 | ||
|
|
d1b5000930 | ||
|
|
83d9347f4b | ||
|
|
0dab8b30f5 | ||
|
|
5740311a36 | ||
|
|
17f3a0ff81 | ||
|
|
c0f9600544 | ||
|
|
f1be49b7df | ||
|
|
a1ecb7f6de | ||
|
|
6880103394 | ||
|
|
4d5de18e40 | ||
|
|
340d4d8b22 | ||
|
|
d3c183549e | ||
|
|
f711009645 | ||
|
|
9cde3d1f9a | ||
|
|
c7c143cbf1 | ||
|
|
cd313d031f | ||
|
|
9bf65b95b4 | ||
|
|
4efbd5ea91 | ||
|
|
09d0f6eb7c | ||
|
|
ebec6fdd1c | ||
|
|
dabd3c04cc | ||
|
|
cec040fef1 | ||
|
|
a2d300a03f | ||
|
|
b2e0010655 | ||
|
|
d0815a9d0b | ||
|
|
217affdc9e | ||
|
|
5674b63b57 | ||
|
|
255d45aa56 | ||
|
|
5f599617e6 | ||
|
|
668555dd33 | ||
|
|
db09adeca9 | ||
|
|
e46c586cec | ||
|
|
1bc5012f4f | ||
|
|
7976203343 | ||
|
|
aaf53aa0bd | ||
|
|
6ff8c950e4 | ||
|
|
f8f7d2574b | ||
|
|
0d27a2a38f | ||
|
|
728141ad4f | ||
|
|
329aae2ae3 | ||
|
|
db270fecca | ||
|
|
4bf7247bcb | ||
|
|
92fc757a73 | ||
|
|
8a964bbb79 | ||
|
|
8a84813b69 | ||
|
|
6fefa6ec54 | ||
|
|
362e77ba6c | ||
|
|
65f1235daf | ||
|
|
cdfe986e8c | ||
|
|
b56f598a71 | ||
|
|
b778599d5f | ||
|
|
518adb688e | ||
|
|
1699b82741 | ||
|
|
79723cb13e | ||
|
|
ee61f0ea71 | ||
|
|
84e22207fb | ||
|
|
83de5c24d6 | ||
|
|
2d22e6a7e3 | ||
|
|
e1315b7e55 | ||
|
|
dd8daeb619 | ||
|
|
6f856b2c0a | ||
|
|
b0ad9abd0c | ||
|
|
c5c53d4c6c | ||
|
|
3bca17f46a | ||
|
|
7e306e4b25 | ||
|
|
26d73e3f28 | ||
|
|
929cb85c85 | ||
|
|
e3966b586a | ||
|
|
7a1fe7f702 | ||
|
|
443f9b0642 | ||
|
|
79738505fb | ||
|
|
851bda5b5e | ||
|
|
ce322efe53 | ||
|
|
238bf31f77 | ||
|
|
aa1f9c5dc9 | ||
|
|
b4ed614faa | ||
|
|
e3082a0281 | ||
|
|
013fb324e6 | ||
|
|
0a871808fa | ||
|
|
0e6d23f59b | ||
|
|
170cfb468c | ||
|
|
0d0b58a960 | ||
|
|
92e3012389 | ||
|
|
a407637998 | ||
|
|
0427c41bde | ||
|
|
54bedee730 | ||
|
|
56b95a98b9 | ||
|
|
23bd1b7627 | ||
|
|
920346fb43 | ||
|
|
632d6cd4f4 | ||
|
|
98342f1bd0 | ||
|
|
d2cadb1c39 | ||
|
|
cab807c9d7 | ||
|
|
70e0a37264 | ||
|
|
58b2eaa55e | ||
|
|
104fe56b30 | ||
|
|
98d9f172bd | ||
|
|
b49f32e4ab | ||
|
|
32613f5637 | ||
|
|
3f604c3028 | ||
|
|
ed4077ec1d | ||
|
|
f9b605a934 | ||
|
|
fbd34a0343 | ||
|
|
e9208d8d86 | ||
|
|
e0081cb2ae | ||
|
|
9990881f3a | ||
|
|
24a3e509bd | ||
|
|
7dda0df3c0 | ||
|
|
6539a95ef6 | ||
|
|
4a1adf44fd | ||
|
|
4cf8cd0957 | ||
|
|
eb835292f5 | ||
|
|
ddaada5bb9 | ||
|
|
6b33c13380 | ||
|
|
b03828a569 | ||
|
|
99bd306532 | ||
|
|
2fae2a9101 | ||
|
|
6d72b0375e | ||
|
|
4f71890226 | ||
|
|
47a832db72 | ||
|
|
3627361d25 | ||
|
|
db1e3398ce | ||
|
|
5288273f01 | ||
|
|
e55b51a4b0 | ||
|
|
b7468c0196 | ||
|
|
282b7b3085 | ||
|
|
7d269ccea1 | ||
|
|
c072d21438 | ||
|
|
94bd299a9a | ||
|
|
bf6a7d910c | ||
|
|
944a3e1c80 | ||
|
|
f57866e72f | ||
|
|
8c8615875e | ||
|
|
dde2b2abff | ||
|
|
85b61b6f6e | ||
|
|
24c3cabed3 | ||
|
|
61d43cf005 | ||
|
|
b62553197b | ||
|
|
911bab7e0f | ||
|
|
030e7e6ff6 | ||
|
|
32cc9c56e0 | ||
|
|
02ff63cf4b | ||
|
|
1cccd4167d | ||
|
|
849c291561 | ||
|
|
ca441c3ea0 | ||
|
|
dba8453681 | ||
|
|
5ee77de8c8 | ||
|
|
ba5f08c10c | ||
|
|
35ab3f27b0 | ||
|
|
ed1d96c911 | ||
|
|
d7c560ec78 | ||
|
|
dc41d4bc93 | ||
|
|
826fdf6306 | ||
|
|
1c878671de | ||
|
|
b1a75237d0 | ||
|
|
414fcee500 | ||
|
|
f62c4b546e | ||
|
|
ff0ca9ba17 | ||
|
|
c60ed93aff | ||
|
|
0538f95dd8 | ||
|
|
8f6ef43b0d | ||
|
|
8ee2a1ab6a | ||
|
|
13217d3b4c | ||
|
|
82ed0f3ac8 | ||
|
|
96f8110001 | ||
|
|
89d7b8e65b | ||
|
|
0f69036171 | ||
|
|
2ed136b137 | ||
|
|
e299e931f5 | ||
|
|
4cbca57026 | ||
|
|
1835133113 | ||
|
|
1d533463ca | ||
|
|
87dfb297ef | ||
|
|
45d46d46f4 | ||
|
|
4635eed508 | ||
|
|
01c1e66168 | ||
|
|
c7fc1c994f | ||
|
|
4eba8a6991 | ||
|
|
01fcfc4849 | ||
|
|
391da75bf3 | ||
|
|
13dc865c9a | ||
|
|
80d929f7d6 | ||
|
|
75cd96be53 | ||
|
|
5bf7ce508d | ||
|
|
a869493ff4 | ||
|
|
ed656ba6cf | ||
|
|
1e743c812e | ||
|
|
7cec42ea0b | ||
|
|
75a2643501 | ||
|
|
6e4c79f33b | ||
|
|
65b2833bb9 | ||
|
|
0972bbea7b | ||
|
|
5452da616f | ||
|
|
c5b9f07b5d | ||
|
|
64821ce0bb | ||
|
|
eaceaeda73 | ||
|
|
e67b56eec6 | ||
|
|
f9b2840d07 | ||
|
|
0b30eba4e0 | ||
|
|
0270b0133d | ||
|
|
1b7cec46d9 | ||
|
|
6922878d94 | ||
|
|
9a442e5382 | ||
|
|
2294609404 | ||
|
|
092ae508ba | ||
|
|
44caa43238 | ||
|
|
325c627cdb | ||
|
|
837f0410ea | ||
|
|
b5dff95be8 | ||
|
|
5bea37b4be | ||
|
|
4f56dfc42b | ||
|
|
f1af021d5c | ||
|
|
fcd33abc9e | ||
|
|
8a218ed405 | ||
|
|
9b6074a363 | ||
|
|
8fd45d810e | ||
|
|
2183972c00 | ||
|
|
03df68c41d | ||
|
|
a77d401f53 | ||
|
|
1769fff834 | ||
|
|
e5d8856821 | ||
|
|
93bb7f3230 | ||
|
|
816d917134 | ||
|
|
aee1b8b81c | ||
|
|
2a33c013d7 | ||
|
|
b58576b523 | ||
|
|
31d39aec80 | ||
|
|
8f9e984d63 | ||
|
|
39701a9334 | ||
|
|
7339c9057a | ||
|
|
2979cf024d | ||
|
|
46c6bdc8ac | ||
|
|
9f0b1d99c1 | ||
|
|
08ce93b6f1 | ||
|
|
181ca3d833 | ||
|
|
a75f3169eb | ||
|
|
b2d87410fc | ||
|
|
753477f5b4 | ||
|
|
a191dcd2b2 | ||
|
|
1bf526ca7f | ||
|
|
635f14ea0e | ||
|
|
3dcb5cba92 | ||
|
|
4adf2afffb | ||
|
|
5440872e93 | ||
|
|
ce3a7a7587 | ||
|
|
9f6aa5f2f6 | ||
|
|
37db8a337e | ||
|
|
c3f5770bb9 | ||
|
|
5a07a7edfe | ||
|
|
3cbe777d44 | ||
|
|
28299018c7 | ||
|
|
7e8c7a27c1 | ||
|
|
08f2880b2a | ||
|
|
03d1f86f4a | ||
|
|
65d3e70f5d | ||
|
|
5547077993 | ||
|
|
14428f3233 | ||
|
|
4a697d3689 | ||
|
|
baab15d42d | ||
|
|
71c7dfaf97 | ||
|
|
d12d6262bb | ||
|
|
c36df3986f | ||
|
|
38f887eab2 | ||
|
|
71c1240617 | ||
|
|
14100456d1 | ||
|
|
32109b87b6 | ||
|
|
79fe52def4 | ||
|
|
66ecf65d4e | ||
|
|
1315c0e4db | ||
|
|
4169af3fa6 | ||
|
|
306f9425e8 | ||
|
|
b0047b5518 | ||
|
|
1990e4aecc | ||
|
|
16d77f41d4 | ||
|
|
7f21b95989 | ||
|
|
76123f5638 | ||
|
|
3b8ac89d2c | ||
|
|
f8aa8e0e0d | ||
|
|
6ec1865ac1 | ||
|
|
18cb5eb6d0 | ||
|
|
bb9e6d6ebf | ||
|
|
66931c6958 | ||
|
|
0ba45ef93e | ||
|
|
68e1549ebd | ||
|
|
b2c9470eb7 | ||
|
|
dc8f47b2e8 | ||
|
|
02dc69348d | ||
|
|
65e6b5ce95 | ||
|
|
3db0da6dd2 | ||
|
|
2aa319becc | ||
|
|
bca4a22e08 | ||
|
|
8bb5a572ec | ||
|
|
91b3a7410e | ||
|
|
b918309242 | ||
|
|
68f5909ef6 | ||
|
|
995a99b984 | ||
|
|
6a5fc04295 | ||
|
|
5dc59396d6 | ||
|
|
5c591b13a4 | ||
|
|
137486c83a | ||
|
|
fec7a5f9e6 | ||
|
|
41d699a458 | ||
|
|
160e997e4a | ||
|
|
d2cb5a272b |
No files matched your search
@@ -25,7 +25,8 @@
|
||||
|
||||
<!-- "none", or for each project you read or copied from: its link at the commit, its license (only MIT,
|
||||
BSD-2-Clause, BSD-3-Clause, Apache-2.0, ISC, zlib, 0BSD, MIT-0, Unlicense or CC0-1.0), and what you took.
|
||||
CONTRIBUTING.md "Only original code". -->
|
||||
CONTRIBUTING.md "Only original code". Shader packs: also the sections of docs/cleanroom/shader-packs-spec.md,
|
||||
the documentation pages and the packs you used. -->
|
||||
|
||||
## Self-check
|
||||
|
||||
|
||||
@@ -14,3 +14,4 @@
|
||||
84875247 Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
|
||||
86072227 levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
|
||||
87478350 Qwerner88880 # added by Ludovic, 2026-10-31 (Discord vladislav_hagen; native-mod hooks)
|
||||
169198624 itarqos5 # added by Ludovic, 2026-11-08 (Discord Literal; Horizon Team)
|
||||
@@ -291,6 +291,11 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo run --locked -p xtask -- package "$BIN/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
|
||||
# Windows x64: Microsoft's DXC, pinned and checksummed, which DirectX 12 uses on integrated GPUs
|
||||
# (gfx/dx12_compiler.rs), with its licence. ARM64 keeps FXC (THIRD_PARTY_NOTICES.md says why).
|
||||
if [[ "${{ matrix.name }}" == windows ]]; then
|
||||
python tools/ci/fetch_dxc.py "dist/${{ matrix.dir }}"
|
||||
fi
|
||||
cd dist
|
||||
rm -f "${{ matrix.asset }}"
|
||||
case "${{ matrix.asset }}" in
|
||||
|
||||
@@ -162,7 +162,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
| Player actions | game.rs (`fn use_hand`, `fn tick_mining`, `fn break_block`); G: `item_actions.rs` (`fn use_item`), `melee.rs` (`fn attack`), `hand.rs`, `combat.rs`, `consume.rs`, `using_item.rs` (an item in use, tick by tick), `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.rs` |
|
||||
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs`, `inventory_input.rs` (keys and clicks over a container), `inventory_slots.rs` (slot layout and drawing) |
|
||||
| Commands | parse: crate `horizon-command` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`; `horizon-client-core`: `nbt_path.rs`, `scoreboard.rs`; G: `functions.rs`, `command_blocks.rs` |
|
||||
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `loading_screen.rs` (the loading screen, the pre-game `App`), `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs`, `creative_screen.rs` |
|
||||
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `settings_shell.rs` (Horizon's settings screens on it: `extras_ui.rs`, `graphics_ui.rs`, `controller_ui.rs`), `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `hud_kit/` (Horizon overlays' `Room` and `Plate`, [docs/ui-kit.md](docs/ui-kit.md)), `pad_glyph.rs` (controller glyphs, by what the button does), `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `loading_screen.rs` (the loading screen, the pre-game `App`), `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs`, `creative_screen.rs` |
|
||||
| Multiplayer client | L: `net.rs` (network thread); G: `remote.rs` (`fn handle_packet`, `fn tick_remote`), `remote_*.rs` (mob data, objects, projectiles, vehicles, stations) |
|
||||
| LAN host / server side | L: `lan/` (`conn.rs` handshake + login threads, `hub.rs` + `hub/` play state, `chunks.rs`, `entity.rs`, `tracking.rs`, `guest.rs`, `playerdata.rs`); G: `lan.rs` (`LanHost`), `lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `lan_*.rs` per feature, `lan_entities.rs` (tracked entities) |
|
||||
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
|
||||
|
||||
+3
-1
@@ -76,7 +76,9 @@ another project's source may also be read and used, **only when that project's l
|
||||
|
||||
User documentation and file formats are fine to read, and packs and mods are fine to run and observe, whatever their
|
||||
license. Shader packs are a clean-room rewrite of the pack format, produced from a written description of it (see
|
||||
#1561); they carry no foreign source, and the rules above still apply to anything you read while working on them.
|
||||
#1561): the [specification and its provenance log](docs/cleanroom/README.md) are public. They carry no foreign
|
||||
source, and the rules above still apply to anything you read while working on them. A shader-pack pull request
|
||||
names its sources: the sections of that specification, the documentation pages and the packs it ran.
|
||||
|
||||
## Before asking for review
|
||||
|
||||
|
||||
Generated
+2
@@ -1588,6 +1588,7 @@ dependencies = [
|
||||
"horizon-mod-api-gen",
|
||||
"horizon-narrator",
|
||||
"horizon-optifine",
|
||||
"horizon-polytone",
|
||||
"horizon-protocol",
|
||||
"horizon-shader-api",
|
||||
"horizon-shaders",
|
||||
@@ -1775,6 +1776,7 @@ dependencies = [
|
||||
name = "horizon-polytone"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"regex",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
@@ -63,6 +63,32 @@ variable's array specifier does not change later variables.
|
||||
It also reads a struct member outside a constant expression through a pointer to the member,
|
||||
not out of a loaded copy of the whole struct, so a member an atomic function uses can be read.
|
||||
|
||||
## DirectX Shader Compiler
|
||||
|
||||
The Windows x64 game folder holds `dxcompiler.dll`, Microsoft's DirectX Shader Compiler
|
||||
[v1.9.2609](https://github.com/microsoft/DirectXShaderCompiler/releases/tag/v1.9.2609), unmodified: the
|
||||
`bin/x64/dxcompiler.dll` of `dxc_2026_09_29.zip` (zip SHA-256
|
||||
`ad31b1fc8443175d204f77a611fdb3ef2ec42759bdc2f1167368de24a4a7e7f1`, DLL SHA-256
|
||||
`0689e2fee0a1c9df8662a59680469f24e2a3ad7ce6bbbcc9284f7a83172bfc59`, 22,274,872 bytes). The release build downloads
|
||||
it and checks both checksums (`tools/ci/fetch_dxc.py`); it is not in this repository. DirectX 12 loads it only on
|
||||
integrated GPUs (`crates/horizon-client-render/src/gfx/dx12_compiler.rs`,
|
||||
[#8513](https://github.com/horizon-rust/horizon/issues/8513)); every other GPU compiles with FXC, part of Windows.
|
||||
|
||||
Copyright (c) Microsoft Corporation. It is not covered by Horizon's MIT license. It is distributed under the
|
||||
"Microsoft Software License Terms, Microsoft DirectX Shader Compiler" (`LICENSE-MS.txt` of that release). Its
|
||||
Distributable Code terms are the Windows SDK's: end users get terms that protect the code and Microsoft at least as
|
||||
much, and the distributor indemnifies Microsoft. The project accepted them on 2026-11-08. Its open-source part is also under the University of Illinois/NCSA license (`LICENSE-LLVM.txt`). Both
|
||||
files ship beside the DLL as `DXC-LICENSE-MS.txt` and `DXC-LICENSE-LLVM.txt`, with `DXC-NOTICE.txt`
|
||||
(`packaging/windows/`), which tells players those terms apply and that deleting the DLL brings back FXC.
|
||||
|
||||
- Only x64: the Windows SDK's list of distributable files
|
||||
([REDIST.TXT lists](https://learn.microsoft.com/en-us/legal/windows-sdk/redist), updated 2024-10-21) names
|
||||
`Redist\D3D\x64\dxcompiler.dll` and `dxil.dll` and their x86 copies, for classic Windows applications (Horizon is
|
||||
one), and no ARM64 copy. The release zip's own `LICENSE-MS.txt` refers to a list of distributable files that the zip
|
||||
doesn't contain, so the SDK's list is the one followed. The Windows ARM64 build ships no DXC and compiles with FXC.
|
||||
- No `dxil.dll`: since DXC 1.8.2505 the compiler signs shaders with its own validator and no longer loads it, and
|
||||
wgpu never does.
|
||||
|
||||
## libmimalloc-sys and mimalloc
|
||||
|
||||
`third_party/libmimalloc-sys` is [libmimalloc-sys](https://github.com/purpleprotocol/mimalloc_rust)
|
||||
|
||||
@@ -14,6 +14,8 @@ pub enum PistonPart {
|
||||
Entity,
|
||||
State,
|
||||
Properties,
|
||||
/// Any other block entity's whole compound (`BlockEntity.saveWithFullMetadata`).
|
||||
BlockEntity,
|
||||
}
|
||||
|
||||
struct Pretty {
|
||||
@@ -255,6 +257,20 @@ fn piston_hash_order(
|
||||
entries.sort_by_key(|(k, _)| (hash_bucket(c.len(), k), std::cmp::Reverse(*k)));
|
||||
}
|
||||
PistonPart::State => return java_hash_order(c),
|
||||
PistonPart::BlockEntity => {
|
||||
// saveAdditional's fields, then `components`, then saveMetadata's
|
||||
// id and position; the fields keep the default order among them.
|
||||
const METADATA: [&str; 5] = ["components", "id", "x", "y", "z"];
|
||||
let mut entries = java_hash_order(c);
|
||||
entries.sort_by_key(|(k, _)| {
|
||||
let key = string_of(k);
|
||||
(
|
||||
hash_bucket(c.len(), k),
|
||||
METADATA.iter().position(|m| *m == key).map_or(0, |i| i + 1),
|
||||
)
|
||||
});
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
entries
|
||||
}
|
||||
@@ -313,6 +329,14 @@ pub fn java_hash_order(
|
||||
"NoBasePlate",
|
||||
"Marker",
|
||||
"Pose",
|
||||
// Mannequin.addAdditionalSaveData.
|
||||
"profile",
|
||||
"hidden_layers",
|
||||
"main_hand",
|
||||
"pose",
|
||||
"immovable",
|
||||
"description",
|
||||
"hide_description",
|
||||
];
|
||||
// BlockEntity.saveMetadata writes the position after the entity's data.
|
||||
const AFTER: &[&str] = &["x", "y", "z"];
|
||||
@@ -397,6 +421,21 @@ mod tests {
|
||||
assert_eq!(pretty(&tag).plain(), ordinary);
|
||||
}
|
||||
#[test]
|
||||
fn mannequin_compound_orders_pose_before_immovable_in_shared_bucket() {
|
||||
let tag = parse_snbt(
|
||||
r#"{AbsorptionAmount:0.0f,Air:300s,Brain:{memories:{}},DeathTime:0s,FallFlying:0b,Fire:0s,Health:20.0f,HurtTime:0s,Invulnerable:0b,Motion:[0.0d,0.0d,0.0d],OnGround:0b,PortalCooldown:0,Pos:[2.5d,100.0d,2.5d],Rotation:[0.0f,0.0f],UUID:[I;369,0,0,1],attributes:[],current_impulse_context_reset_grace_time:0,description:{text:"Guard"},hide_description:1b,immovable:1b,pose:"crouching",profile:{name:"alex"}}"#,
|
||||
)
|
||||
.unwrap()
|
||||
.0;
|
||||
let rendered = pretty(&tag).plain();
|
||||
let pose_idx = rendered.find("pose: \"crouching\"").unwrap();
|
||||
let immovable_idx = rendered.find("immovable: 1b").unwrap();
|
||||
assert!(
|
||||
pose_idx < immovable_idx,
|
||||
"expected `pose` before `immovable` in bucket 5, got: {rendered}"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn compact_form_matches_vanilla() {
|
||||
assert_eq!(
|
||||
rt("{b:1b,a:\"x\",c:[1,2],d:1.5f,e:2d,f:3L,g:4s}"),
|
||||
@@ -441,4 +480,18 @@ mod tests {
|
||||
let long = parse_snbt("{l:[{a:1},{b:2}]}").unwrap().0;
|
||||
assert_eq!(pretty(&long).plain(), "{l: [{a: 1}, {b: 2}]}");
|
||||
}
|
||||
#[test]
|
||||
fn block_entity_metadata_follows_its_fields() {
|
||||
// A conduit's `/data get block`, as vanilla 26.3 printed it: `Target`
|
||||
// and `components` share a bucket, and saveAdditional wrote `Target` first.
|
||||
let tag = parse_snbt(
|
||||
r#"{id:"minecraft:conduit",x:4098,y:300,z:4098,components:{},Target:[I;1,2,3,4]}"#,
|
||||
)
|
||||
.unwrap()
|
||||
.0;
|
||||
assert_eq!(
|
||||
pretty_piston(&tag, PistonPart::BlockEntity).plain(),
|
||||
r#"{Target: [I; 1, 2, 3, 4], components: {}, x: 4098, y: 300, z: 4098, id: "minecraft:conduit"}"#,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -82,49 +82,6 @@ impl Button {
|
||||
fn bit(self) -> u32 {
|
||||
1 << self as u32
|
||||
}
|
||||
/// What is printed on the button for a pad family (`Auto` reads as Xbox).
|
||||
pub fn label(self, style: GlyphStyle) -> &'static str {
|
||||
let i = self as usize;
|
||||
match style {
|
||||
GlyphStyle::PlayStation => [
|
||||
"×", "○", "□", "△", "L1", "R1", "L2", "R2", "Create", "Options", "L3", "R3", "↑",
|
||||
"↓", "←", "→",
|
||||
][i],
|
||||
GlyphStyle::Nintendo => [
|
||||
"B", "A", "Y", "X", "L", "R", "ZL", "ZR", "−", "+", "LS", "RS", "↑", "↓", "←", "→",
|
||||
][i],
|
||||
GlyphStyle::Xbox | GlyphStyle::Auto => [
|
||||
"A", "B", "X", "Y", "LB", "RB", "LT", "RT", "View", "Menu", "LS", "RS", "↑", "↓",
|
||||
"←", "→",
|
||||
][i],
|
||||
}
|
||||
}
|
||||
/// The glyph's colour (RGB): face buttons in their family's colours,
|
||||
/// the rest white.
|
||||
pub fn color(self, style: GlyphStyle) -> [u8; 3] {
|
||||
let face = match self {
|
||||
Button::South => 0,
|
||||
Button::East => 1,
|
||||
Button::West => 2,
|
||||
Button::North => 3,
|
||||
_ => return [255, 255, 255],
|
||||
};
|
||||
match style {
|
||||
GlyphStyle::PlayStation => [
|
||||
[124, 178, 232],
|
||||
[235, 110, 110],
|
||||
[222, 145, 205],
|
||||
[92, 205, 170],
|
||||
][face],
|
||||
GlyphStyle::Nintendo => [255, 255, 255],
|
||||
GlyphStyle::Xbox | GlyphStyle::Auto => [
|
||||
[110, 200, 90],
|
||||
[235, 90, 80],
|
||||
[80, 150, 240],
|
||||
[240, 200, 60],
|
||||
][face],
|
||||
}
|
||||
}
|
||||
/// The face button that plays this one's part on a Nintendo pad going by its letters
|
||||
/// (`PadSettings::nintendo_letters`): the A it prints on the right is South, which confirms
|
||||
/// and jumps as A does on a Switch; B (bottom) is East, X (top) West, Y (left) North. It is
|
||||
@@ -427,6 +384,101 @@ impl HeldDrop {
|
||||
}
|
||||
}
|
||||
|
||||
/// A job a menu's fixed buttons do. Menus aren't rebindable: this is the one map from a job to
|
||||
/// its button, and a glyph for a menu asks for the job (`GlyphOf::Menu`), never the button.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MenuJob {
|
||||
/// A: press, take, type.
|
||||
Confirm,
|
||||
/// B: back, close.
|
||||
Back,
|
||||
/// X: the screen's second job (Apply, Options, half a stack, delete a letter).
|
||||
Alternate,
|
||||
/// Y: the screen's third job (the exit, a quick move, a space).
|
||||
Extra,
|
||||
/// LB: the page or tab before.
|
||||
PagePrev,
|
||||
/// RB: the page or tab after.
|
||||
PageNext,
|
||||
/// LT: the group before; held, the modifier (shift, direct craft).
|
||||
GroupPrev,
|
||||
/// RT: the group after.
|
||||
GroupNext,
|
||||
/// View: throw.
|
||||
View,
|
||||
/// Menu (Start): done, and cancelling a binding.
|
||||
Start,
|
||||
/// The left stick pressed in.
|
||||
LeftStick,
|
||||
}
|
||||
impl MenuJob {
|
||||
pub const ALL: [MenuJob; 11] = [
|
||||
MenuJob::Confirm,
|
||||
MenuJob::Back,
|
||||
MenuJob::Alternate,
|
||||
MenuJob::Extra,
|
||||
MenuJob::PagePrev,
|
||||
MenuJob::PageNext,
|
||||
MenuJob::GroupPrev,
|
||||
MenuJob::GroupNext,
|
||||
MenuJob::View,
|
||||
MenuJob::Start,
|
||||
MenuJob::LeftStick,
|
||||
];
|
||||
/// The part that does the job (by place; a pad going by its letters is read so the part
|
||||
/// still lands: `PadState::by_letter`).
|
||||
pub const fn button(self) -> Button {
|
||||
match self {
|
||||
MenuJob::Confirm => Button::South,
|
||||
MenuJob::Back => Button::East,
|
||||
MenuJob::Alternate => Button::West,
|
||||
MenuJob::Extra => Button::North,
|
||||
MenuJob::PagePrev => Button::LeftBumper,
|
||||
MenuJob::PageNext => Button::RightBumper,
|
||||
MenuJob::GroupPrev => Button::LeftTrigger,
|
||||
MenuJob::GroupNext => Button::RightTrigger,
|
||||
MenuJob::View => Button::Select,
|
||||
MenuJob::Start => Button::Start,
|
||||
MenuJob::LeftStick => Button::LeftStick,
|
||||
}
|
||||
}
|
||||
/// The job a part does in menus.
|
||||
pub fn of(button: Button) -> Option<MenuJob> {
|
||||
Self::ALL.into_iter().find(|job| job.button() == button)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a glyph stands for: what the button does, never the button. `pad_glyph::Glyph` is built
|
||||
/// from one, so a rebinding or a pad's letters reach every glyph.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum GlyphOf {
|
||||
/// A gameplay action (`key.jump`...), on the player's binding.
|
||||
Bound(&'static str),
|
||||
/// A menu's fixed job.
|
||||
Menu(MenuJob),
|
||||
}
|
||||
impl From<MenuJob> for GlyphOf {
|
||||
fn from(job: MenuJob) -> Self {
|
||||
GlyphOf::Menu(job)
|
||||
}
|
||||
}
|
||||
impl GlyphOf {
|
||||
/// The part that does it under these settings; none while the action is unbound.
|
||||
pub fn button(self, settings: &PadSettings) -> Option<Button> {
|
||||
match self {
|
||||
GlyphOf::Bound(action) => {
|
||||
// A misspelt action would quietly have no glyph.
|
||||
debug_assert!(
|
||||
ACTIONS.iter().any(|a| a.0 == action),
|
||||
"no pad action {action}"
|
||||
);
|
||||
settings.binding(action)
|
||||
}
|
||||
GlyphOf::Menu(job) => Some(job.button()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which button pictures prompts use.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum GlyphStyle {
|
||||
@@ -635,6 +687,10 @@ fn bool_text(v: bool) -> &'static str {
|
||||
if v { "true" } else { "false" }
|
||||
}
|
||||
impl PadSettings {
|
||||
/// A pad of `family` goes by its letters: only a Nintendo pad has letters to go by.
|
||||
pub fn by_letter(&self, family: GlyphStyle) -> bool {
|
||||
self.nintendo_letters && family == GlyphStyle::Nintendo
|
||||
}
|
||||
pub fn load(dir: &Path) -> Self {
|
||||
fs::read_to_string(dir.join(FILE))
|
||||
.map(|t| Self::parse(&t))
|
||||
@@ -1921,11 +1977,6 @@ bind_key.hotbar.2:north
|
||||
assert!(parts.held(Button::LeftBumper));
|
||||
assert_eq!(parts.left, [0.5, -0.25]);
|
||||
assert_eq!(parts.by_letter(), pad);
|
||||
// A part's glyph is the letter of the button playing it.
|
||||
assert_eq!(Button::South.by_letter().label(GlyphStyle::Nintendo), "A");
|
||||
assert_eq!(Button::East.by_letter().label(GlyphStyle::Nintendo), "B");
|
||||
assert_eq!(Button::West.by_letter().label(GlyphStyle::Nintendo), "X");
|
||||
assert_eq!(Button::North.by_letter().label(GlyphStyle::Nintendo), "Y");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2353,12 +2404,7 @@ bind_key.hotbar.2:north
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_and_conflicts() {
|
||||
assert_eq!(Button::South.label(GlyphStyle::Xbox), "A");
|
||||
assert_eq!(Button::South.label(GlyphStyle::PlayStation), "×");
|
||||
assert_eq!(Button::South.label(GlyphStyle::Nintendo), "B");
|
||||
assert_eq!(Button::RightTrigger.label(GlyphStyle::PlayStation), "R2");
|
||||
assert_eq!(Button::DPadLeft.label(GlyphStyle::Xbox), "←");
|
||||
fn styles_resolve_and_conflicts() {
|
||||
assert_eq!(
|
||||
GlyphStyle::Auto.resolve(GlyphStyle::PlayStation),
|
||||
GlyphStyle::PlayStation
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! and of an inventory it drives (Horizon's; vanilla has no gamepad input). The game reads the facts
|
||||
//! from its own hit results and capabilities (`game/pad/guide.rs`); this turns them into rows, and
|
||||
//! places the rows around what was drawn.
|
||||
use crate::gamepad::{Button, GuideDetail, PadSettings, Rect};
|
||||
use crate::gamepad::{Button, GlyphOf, GuideDetail, MenuJob, PadSettings, Rect};
|
||||
|
||||
/// What a use does, by what takes it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -100,26 +100,27 @@ pub struct Facts {
|
||||
pub off_hand_item: bool,
|
||||
}
|
||||
|
||||
/// One button's line: the button and the translation key of what it does.
|
||||
/// One button's line: what the button does (its glyph follows the binding and the pad's letters)
|
||||
/// and the translation key of the caption.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Row {
|
||||
/// Shown before `button`: held with it, or its other half (LB and RB for pages).
|
||||
pub with: Option<Button>,
|
||||
pub button: Button,
|
||||
/// Shown before `of`: held with it, or its other half (LB and RB for pages).
|
||||
pub with: Option<GlyphOf>,
|
||||
pub of: GlyphOf,
|
||||
pub caption: &'static str,
|
||||
}
|
||||
impl Row {
|
||||
fn new(button: Button, caption: &'static str) -> Self {
|
||||
fn new(of: impl Into<GlyphOf>, caption: &'static str) -> Self {
|
||||
Row {
|
||||
with: None,
|
||||
button,
|
||||
of: of.into(),
|
||||
caption,
|
||||
}
|
||||
}
|
||||
fn pair(with: Button, button: Button, caption: &'static str) -> Self {
|
||||
fn pair(with: MenuJob, job: MenuJob, caption: &'static str) -> Self {
|
||||
Row {
|
||||
with: Some(with),
|
||||
button,
|
||||
with: Some(GlyphOf::Menu(with)),
|
||||
of: GlyphOf::Menu(job),
|
||||
caption,
|
||||
}
|
||||
}
|
||||
@@ -155,11 +156,11 @@ impl Guide {
|
||||
/// The rows for these facts on these bindings. An unbound action has no row, and neither has one
|
||||
/// on Start, which is always pause.
|
||||
pub fn resolve(facts: &Facts, settings: &PadSettings) -> Guide {
|
||||
let row = |action: &str, caption: Option<&'static str>| {
|
||||
let button = settings
|
||||
let row = |action: &'static str, caption: Option<&'static str>| {
|
||||
settings
|
||||
.binding(action)
|
||||
.filter(|button| *button != Button::Start)?;
|
||||
Some(Row::new(button, caption?))
|
||||
Some(Row::new(GlyphOf::Bound(action), caption?))
|
||||
};
|
||||
let player = !facts.spectator;
|
||||
let any_item = facts.main_hand_item || facts.off_hand_item;
|
||||
@@ -308,10 +309,10 @@ pub struct MenuFacts {
|
||||
/// An inventory's rows, by what they ask of the player: at the bottom right one face button, at the
|
||||
/// bottom left anything else (a trigger held with a button, the bumpers, View, a stick click). What
|
||||
/// the crosshair is on comes first in its column, the screen's own rows after, except Close, which
|
||||
/// stays lowest so the rest don't move it. Menu buttons aren't rebindable, so these name them.
|
||||
/// stays lowest so the rest don't move it. Menu buttons aren't rebindable, so these name menu jobs.
|
||||
pub fn resolve_menu(f: &MenuFacts) -> Guide {
|
||||
use Button::{East, LeftBumper, LeftStick, LeftTrigger, North, RightBumper, RightTrigger};
|
||||
use Button::{Select, South, West};
|
||||
use MenuJob::{Alternate, Back, Confirm, Extra, GroupNext, GroupPrev};
|
||||
use MenuJob::{LeftStick, PageNext, PagePrev, View};
|
||||
const TAKE: &str = "horizon.controller.guide.take";
|
||||
const MOVE: &str = "horizon.controller.guide.quick_move";
|
||||
let under = match f.under {
|
||||
@@ -321,25 +322,25 @@ pub fn resolve_menu(f: &MenuFacts) -> Guide {
|
||||
result: true,
|
||||
same,
|
||||
..
|
||||
} if !f.carrying || same => vec![Row::new(South, TAKE), Row::new(North, MOVE)],
|
||||
} if !f.carrying || same => vec![Row::new(Confirm, TAKE), Row::new(Extra, MOVE)],
|
||||
Under::Slot { result: true, .. } => Vec::new(),
|
||||
Under::Slot { accepts: false, .. } if f.carrying => Vec::new(),
|
||||
Under::Slot { item, same, .. } if f.carrying => vec![
|
||||
Row::new(
|
||||
South,
|
||||
Confirm,
|
||||
if item && !same {
|
||||
"horizon.controller.guide.swap_stack"
|
||||
} else {
|
||||
"horizon.controller.guide.put"
|
||||
},
|
||||
),
|
||||
Row::new(West, "horizon.controller.guide.put_one"),
|
||||
Row::new(Alternate, "horizon.controller.guide.put_one"),
|
||||
],
|
||||
Under::Slot { item: true, .. } => vec![
|
||||
Row::new(South, TAKE),
|
||||
Row::new(West, "horizon.controller.guide.take_half"),
|
||||
Row::new(North, MOVE),
|
||||
Row::new(Select, "horizon.controller.guide.drop"),
|
||||
Row::new(Confirm, TAKE),
|
||||
Row::new(Alternate, "horizon.controller.guide.take_half"),
|
||||
Row::new(Extra, MOVE),
|
||||
Row::new(View, "horizon.controller.guide.drop"),
|
||||
Row::new(LeftStick, "horizon.controller.guide.drop_stack"),
|
||||
],
|
||||
Under::Nothing | Under::Slot { .. } => Vec::new(),
|
||||
@@ -347,43 +348,44 @@ pub fn resolve_menu(f: &MenuFacts) -> Guide {
|
||||
direct_craft,
|
||||
alternatives,
|
||||
} => [
|
||||
Some(Row::new(South, "horizon.controller.guide.place_recipe")),
|
||||
Some(Row::new(Confirm, "horizon.controller.guide.place_recipe")),
|
||||
direct_craft.then_some(Row::pair(
|
||||
LeftTrigger,
|
||||
South,
|
||||
GroupPrev,
|
||||
Confirm,
|
||||
"horizon.controller.direct_craft",
|
||||
)),
|
||||
Some(Row::new(North, "horizon.controller.guide.place_recipe_all")),
|
||||
alternatives.then_some(Row::new(West, "horizon.controller.guide.alternatives")),
|
||||
Some(Row::new(Extra, "horizon.controller.guide.place_recipe_all")),
|
||||
alternatives.then_some(Row::new(Alternate, "horizon.controller.guide.alternatives")),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect(),
|
||||
Under::Control => vec![Row::new(South, "horizon.controller.guide.select")],
|
||||
Under::Control => vec![Row::new(Confirm, "horizon.controller.guide.select")],
|
||||
};
|
||||
let screen = [
|
||||
f.book.then_some(Row::pair(
|
||||
LeftBumper,
|
||||
RightBumper,
|
||||
PagePrev,
|
||||
PageNext,
|
||||
"horizon.controller.guide.pages",
|
||||
)),
|
||||
f.book.then_some(Row::pair(
|
||||
LeftTrigger,
|
||||
RightTrigger,
|
||||
GroupPrev,
|
||||
GroupNext,
|
||||
"horizon.controller.guide.categories",
|
||||
)),
|
||||
(f.tabs && !f.book).then_some(Row::pair(
|
||||
LeftBumper,
|
||||
RightBumper,
|
||||
PagePrev,
|
||||
PageNext,
|
||||
"horizon.controller.guide.tabs",
|
||||
)),
|
||||
];
|
||||
let close = Row::new(East, "horizon.controller.guide.close");
|
||||
let close = Row::new(Back, "horizon.controller.guide.close");
|
||||
let rows = std::iter::once(close)
|
||||
.chain(under)
|
||||
.chain(screen.into_iter().flatten());
|
||||
let (right, left) =
|
||||
rows.partition(|r| r.with.is_none() && matches!(r.button, South | East | West | North));
|
||||
let (right, left) = rows.partition(|r| {
|
||||
r.with.is_none() && matches!(r.of, GlyphOf::Menu(Confirm | Back | Alternate | Extra))
|
||||
});
|
||||
Guide { left, right }
|
||||
}
|
||||
|
||||
@@ -495,8 +497,11 @@ mod tests {
|
||||
..f
|
||||
}
|
||||
}
|
||||
fn rows(rows: &[Row]) -> Vec<(Button, &'static str)> {
|
||||
rows.iter().map(|r| (r.button, r.caption)).collect()
|
||||
/// Each row's button on these settings, and its caption.
|
||||
fn rows(rows: &[Row], settings: &PadSettings) -> Vec<(Button, &'static str)> {
|
||||
rows.iter()
|
||||
.map(|r| (r.of.button(settings).unwrap(), r.caption))
|
||||
.collect()
|
||||
}
|
||||
fn captions(rows: &[Row]) -> Vec<&'static str> {
|
||||
rows.iter().map(|r| r.caption).collect()
|
||||
@@ -509,9 +514,10 @@ mod tests {
|
||||
item_use: Some(Verb::Place),
|
||||
..on_block(walking(), true, None)
|
||||
};
|
||||
let guide = resolve(&facts, &PadSettings::default());
|
||||
let settings = PadSettings::default();
|
||||
let guide = resolve(&facts, &settings);
|
||||
assert_eq!(
|
||||
rows(&guide.right),
|
||||
rows(&guide.right, &settings),
|
||||
[
|
||||
(Button::RightTrigger, "horizon.controller.guide.break"),
|
||||
(Button::LeftTrigger, "horizon.controller.guide.place"),
|
||||
@@ -519,7 +525,7 @@ mod tests {
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
rows(&guide.left),
|
||||
rows(&guide.left, &settings),
|
||||
[
|
||||
(Button::South, "key.jump"),
|
||||
(Button::RightStick, "key.sneak"),
|
||||
@@ -543,7 +549,7 @@ mod tests {
|
||||
};
|
||||
let guide = resolve(&facts, &settings);
|
||||
assert_eq!(
|
||||
rows(&guide.right),
|
||||
rows(&guide.right, &settings),
|
||||
[(Button::North, "horizon.controller.guide.break")]
|
||||
);
|
||||
// Start only ever pauses.
|
||||
@@ -845,8 +851,8 @@ mod tests {
|
||||
["close", "place_recipe", "place_recipe_all", "alternatives"]
|
||||
);
|
||||
assert_eq!(recipe.left.len(), 1);
|
||||
assert_eq!(recipe.left[0].with, Some(Button::LeftTrigger));
|
||||
assert_eq!(recipe.left[0].button, Button::South);
|
||||
assert_eq!(recipe.left[0].with, Some(GlyphOf::Menu(MenuJob::GroupPrev)));
|
||||
assert_eq!(recipe.left[0].of, GlyphOf::Menu(MenuJob::Confirm));
|
||||
let plain = on(
|
||||
Under::Recipe {
|
||||
direct_craft: false,
|
||||
@@ -892,8 +898,8 @@ mod detail_tests {
|
||||
|
||||
fn rows() -> Guide {
|
||||
Guide {
|
||||
left: vec![Row::new(Button::South, "left")],
|
||||
right: vec![Row::new(Button::East, "right")],
|
||||
left: vec![Row::new(MenuJob::Confirm, "left")],
|
||||
right: vec![Row::new(MenuJob::Back, "right")],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -927,7 +933,7 @@ mod detail_tests {
|
||||
mod slot_tests {
|
||||
use super::*;
|
||||
|
||||
/// The rows for the slot: South or West, whichever column they're in.
|
||||
/// The rows for the slot: Confirm or Alternate, whichever column they're in.
|
||||
fn rows(item: bool, same: bool, result: bool, accepts: bool, carrying: bool) -> usize {
|
||||
let guide = resolve_menu(&MenuFacts {
|
||||
under: Under::Slot {
|
||||
@@ -942,7 +948,7 @@ mod slot_tests {
|
||||
[guide.left, guide.right]
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|row| matches!(row.button, Button::South | Button::West))
|
||||
.filter(|row| matches!(row.of, GlyphOf::Menu(MenuJob::Confirm | MenuJob::Alternate)))
|
||||
.count()
|
||||
}
|
||||
|
||||
|
||||
@@ -97,8 +97,8 @@ rustix = { version = "1", default-features = false, features = ["std", "time", "
|
||||
# Use the registered URI handler without routing links through Explorer/cmd.
|
||||
open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
|
||||
# DirectX 12, where Vulkan has no adapter or device, or by choice (`graphicsApi`, graphics.rs).
|
||||
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or DXC when dxcompiler.dll is
|
||||
# on the PATH.
|
||||
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or on integrated GPUs with DXC
|
||||
# (dxcompiler.dll beside the game, gfx/dx12_compiler.rs).
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
|
||||
@@ -23,6 +23,7 @@ horizon-narrator = { path = "../horizon-narrator" }
|
||||
# OptiFine-format pack files: random entity textures (hud.rs), connected textures
|
||||
# (connected.rs) and custom item texture rules (cit.rs).
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
horizon-polytone = { path = "../horizon-polytone" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
//! A world warp (`native_world_warp`, docs/native-mods.md "World warps") through the real
|
||||
//! `Renderer`, offscreen: a wall of terrain and an entity quad drawn without a warp, with one,
|
||||
//! with one that is off, with one that cuts the wall, with a pixel stage of its own and with
|
||||
//! one that doesn't compile. Run with throwaway HORIZON_HOME, HORIZON_LAUNCHER_HOME,
|
||||
//! XDG_DATA_HOME and XDG_CONFIG_HOME (AGENTS.md):
|
||||
//! `cargo run -p horizon-client-render --example world_warp_check -- <output-dir>`
|
||||
//! `--release ... -- <output-dir> --bench`: instead, the time of a frame of rolling land 49 by 49
|
||||
//! chunks around the camera without a warp, and with one (nothing culled), at 1920 by 1080.
|
||||
use client::{
|
||||
gfx::{Frame, Renderer, ScreenshotRequest, native_render},
|
||||
graphics::GraphicsApi,
|
||||
hud::HudBatch,
|
||||
mesher::{SectionMesh, TerrainVertex, Vertex},
|
||||
};
|
||||
use horizon_client_render as client;
|
||||
use std::{
|
||||
path::Path,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use winit::{
|
||||
application::ApplicationHandler,
|
||||
event::WindowEvent,
|
||||
event_loop::{ActiveEventLoop, EventLoop},
|
||||
window::{Window, WindowId},
|
||||
};
|
||||
|
||||
/// What the mod below gives the renderer each frame.
|
||||
static WARP: Mutex<Option<native_render::WorldWarp>> = Mutex::new(None);
|
||||
struct Mod;
|
||||
impl native_render::RenderHooks for Mod {
|
||||
fn world_warp(&self, event: &mut native_render::WorldWarpEvent<'_>) {
|
||||
if let Some(warp) = WARP.lock().unwrap().as_ref() {
|
||||
*event.warp = warp.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lifts what is left of x = 8 by `params[0].x` and tints it red.
|
||||
const LIFT: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
|
||||
let here = pos.x + warp.camera.x + warp.camera_rest.x;
|
||||
if here < 8.0 { return Warped(pos + vec3(0.0, warp.params[0].x, 0.0), vec3(1.0, 0.0, 0.0)); }
|
||||
return Warped(pos, vec3(1.0));
|
||||
}
|
||||
";
|
||||
/// Cuts every face.
|
||||
const CUT: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
|
||||
return Warped(pos, vec3(-1.0));
|
||||
}
|
||||
";
|
||||
/// Paints the terrain green by its pixels and moves nothing.
|
||||
const PAINT: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
|
||||
return Warped(pos, vec3(1.0));
|
||||
}
|
||||
// [fragments]
|
||||
fn world_fragment(f: Fragment) -> Painted {
|
||||
if !f.front { return Painted(vec4(0.0, 0.0, 0.0, -1.0), 1.0); }
|
||||
return Painted(vec4(0.0, 1.0, 0.0, 1.0), 0.0);
|
||||
}
|
||||
";
|
||||
/// Moves entities ten blocks right: off the screen's middle.
|
||||
const SHIFT: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
|
||||
return Warped(pos + vec3(-10.0, 0.0, 0.0), vec3(1.0));
|
||||
}
|
||||
";
|
||||
|
||||
fn read(path: &Path) -> Vec<[u8; 4]> {
|
||||
let decoder = png::Decoder::new(std::io::BufReader::new(std::fs::File::open(path).unwrap()));
|
||||
let mut reader = decoder.read_info().unwrap();
|
||||
let mut bytes = vec![0; reader.output_buffer_size().unwrap()];
|
||||
let info = reader.next_frame(&mut bytes).unwrap();
|
||||
assert_eq!(info.color_type, png::ColorType::Rgba);
|
||||
bytes.truncate(info.buffer_size());
|
||||
bytes.as_chunks::<4>().0.to_vec()
|
||||
}
|
||||
|
||||
/// Pixels like `want` (within 40 a channel), of an image.
|
||||
fn count(pixels: &[[u8; 4]], want: [u8; 3]) -> usize {
|
||||
pixels
|
||||
.iter()
|
||||
.filter(|p| (0..3).all(|i| p[i].abs_diff(want[i]) < 40))
|
||||
.count()
|
||||
}
|
||||
|
||||
/// A wall across x 0..16, y 0..16 at z = 12 of the section at y 96, both windings.
|
||||
fn wall() -> SectionMesh {
|
||||
let mut mesh = SectionMesh::default();
|
||||
let corners = [
|
||||
[0., 0., 12.],
|
||||
[0., 16., 12.],
|
||||
[16., 16., 12.],
|
||||
[16., 0., 12.],
|
||||
];
|
||||
let uvs = [[0., 1.], [0., 0.], [1., 0.], [1., 1.]];
|
||||
for order in [[0, 1, 2, 3], [3, 2, 1, 0]] {
|
||||
for i in order {
|
||||
mesh.solid
|
||||
.push(TerrainVertex::new(corners[i], uvs[i], 0, u32::MAX, 0xf0f0));
|
||||
}
|
||||
}
|
||||
mesh.sort_solid();
|
||||
mesh
|
||||
}
|
||||
|
||||
fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let out = std::env::args().nth(1).ok_or("output directory required")?;
|
||||
let out = Path::new(&out);
|
||||
std::fs::create_dir_all(out)?;
|
||||
native_render::install(Box::new([&Mod])).map_err(|_| "mod already installed")?;
|
||||
let white = [255; 16 * 16 * 4];
|
||||
let zero = [0; 4];
|
||||
let mut renderer = pollster::block_on(Renderer::new(
|
||||
window,
|
||||
true,
|
||||
&white,
|
||||
16,
|
||||
16,
|
||||
1,
|
||||
&[],
|
||||
&[],
|
||||
(&white, 16, 16),
|
||||
(&zero, 1, 1),
|
||||
(&zero, 1, 1),
|
||||
(&zero, 1),
|
||||
Arc::new(client::gfx::panorama::Faces::colour([0, 0, 0, 255])),
|
||||
None,
|
||||
None,
|
||||
GraphicsApi::Vulkan,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
))?;
|
||||
renderer.resize(320, 180);
|
||||
renderer.fullbright = true;
|
||||
// (keys count sections from the bottom of the world, y -64: 10 is y 96 to 112)
|
||||
renderer.upload((0, 10, 0), &wall());
|
||||
// an entity quad in front of the wall, middle of the screen
|
||||
let entity: Vec<Vertex> = [
|
||||
[7.5, 103.5, 6.0],
|
||||
[7.5, 104.5, 6.0],
|
||||
[8.5, 104.5, 6.0],
|
||||
[8.5, 103.5, 6.0],
|
||||
]
|
||||
.into_iter()
|
||||
.zip([[0., 1.], [0., 0.], [1., 0.], [1., 1.]])
|
||||
.map(|(p, uv)| Vertex {
|
||||
pos: [p[0] - 8.0, p[1] - 104.0, p[2] - 0.5],
|
||||
uv,
|
||||
layer: 0,
|
||||
color: 0xff40_40ff,
|
||||
light: 0xf0,
|
||||
normal: [0, 0, 0, 0],
|
||||
glint_uv: [0.0; 2],
|
||||
})
|
||||
.collect();
|
||||
if std::env::args().any(|a| a == "--bench") {
|
||||
return bench(&mut renderer, out);
|
||||
}
|
||||
let hud = HudBatch::new([320, 180]);
|
||||
let mut frame = fixture_frame(&hud);
|
||||
frame.entities = &entity;
|
||||
let mut shot = |name: &str, warp: Option<native_render::WorldWarp>| {
|
||||
*WARP.lock().unwrap() = warp;
|
||||
let path = out.join(format!("{name}.png"));
|
||||
renderer.screenshot = Some(ScreenshotRequest {
|
||||
path: path.clone(),
|
||||
size: None,
|
||||
scaled: false,
|
||||
});
|
||||
// twice: the first frame of a new warp builds it
|
||||
renderer.render(&frame).unwrap();
|
||||
renderer.render(&frame).unwrap();
|
||||
read(&path)
|
||||
};
|
||||
let warp = |source: &str, on: bool, lift: f32| {
|
||||
let mut w = native_render::WorldWarp::default();
|
||||
w.source = Some(source.into());
|
||||
w.on = on;
|
||||
w.params[0] = lift;
|
||||
Some(w)
|
||||
};
|
||||
let plain = shot("plain", None);
|
||||
let wall_px = count(&plain, [255, 255, 255]);
|
||||
let entity_px = count(&plain, [255, 64, 64]);
|
||||
assert!(wall_px > 1000, "the wall shows without a warp: {wall_px}");
|
||||
assert!(
|
||||
entity_px > 50,
|
||||
"the entity shows without a warp: {entity_px}"
|
||||
);
|
||||
|
||||
let off = shot("off", warp(LIFT, false, 4.0));
|
||||
assert!(off == plain, "a warp that is off draws as no warp");
|
||||
|
||||
let lifted = shot("lift", warp(LIFT, true, 4.0));
|
||||
let red = count(&lifted, [255, 0, 0]);
|
||||
assert!(red > 500, "the warp tints the wall's left half red: {red}");
|
||||
assert!(lifted != plain);
|
||||
|
||||
let cut = shot("cut", warp(CUT, true, 0.0));
|
||||
let left = count(&cut, [255, 255, 255]);
|
||||
assert!(
|
||||
left < wall_px / 20,
|
||||
"a cut leaves the wall out: {left} of {wall_px}"
|
||||
);
|
||||
|
||||
let painted = shot("paint", warp(PAINT, true, 0.0));
|
||||
let green = count(&painted, [0, 255, 0]);
|
||||
assert!(
|
||||
green > wall_px / 2,
|
||||
"the warp's own pixels paint the wall: {green}"
|
||||
);
|
||||
|
||||
let shifted = shot("shift", warp(SHIFT, true, 0.0));
|
||||
let entity_left = count(&shifted[..], [255, 64, 64]);
|
||||
let mid = |p: &[[u8; 4]]| -> Vec<[u8; 4]> {
|
||||
(60..120)
|
||||
.flat_map(|y| p[y * 320 + 130..y * 320 + 190].to_vec())
|
||||
.collect()
|
||||
};
|
||||
assert!(
|
||||
count(&mid(&shifted), [255, 64, 64]) < count(&mid(&plain), [255, 64, 64]) / 4,
|
||||
"the warp moves the entity out of the middle ({entity_left} red-ish pixels left)"
|
||||
);
|
||||
|
||||
let broken = shot(
|
||||
"broken",
|
||||
warp("fn world_warp() -> f32 { return 1; }", true, 0.0),
|
||||
);
|
||||
assert!(broken == plain, "a warp that doesn't compile is left out");
|
||||
|
||||
let back = shot("after", None);
|
||||
assert!(back == plain, "with the warp gone the frame is as before");
|
||||
std::fs::write(
|
||||
out.join("result.json"),
|
||||
serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"wall_pixels": wall_px, "entity_pixels": entity_px, "lifted_red_pixels": red,
|
||||
"cut_wall_pixels_left": left, "painted_green_pixels": green,
|
||||
"off_equals_plain": true, "broken_equals_plain": true, "after_equals_plain": true,
|
||||
"graphics_adapter": client::crash::system_details("fixture").into_iter()
|
||||
.find(|(name, _)| name == "Graphics Adapter").map(|(_, detail)| detail),
|
||||
}))?,
|
||||
)?;
|
||||
println!(
|
||||
"world warp check passed: {}",
|
||||
out.join("result.json").display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rolling land: a column of a section's top faces and the sides they show, at y 64 to 80.
|
||||
fn hills(cx: i32, cz: i32) -> SectionMesh {
|
||||
let height = |x: i32, z: i32| {
|
||||
let (x, z) = (f64::from(cx * 16 + x), f64::from(cz * 16 + z));
|
||||
(8.0 + 4.0 * (x * 0.09).sin() + 3.0 * (z * 0.07 + x * 0.02).cos()).floor() as i32
|
||||
};
|
||||
let mut mesh = SectionMesh::default();
|
||||
let mut quad = |c: [[f32; 3]; 4]| {
|
||||
for (p, uv) in c.into_iter().zip([[0., 1.], [0., 0.], [1., 0.], [1., 1.]]) {
|
||||
mesh.solid
|
||||
.push(TerrainVertex::new(p, uv, 0, 0xff60_a060, 0xf0));
|
||||
}
|
||||
};
|
||||
for x in 0..16 {
|
||||
for z in 0..16 {
|
||||
let h = height(x, z);
|
||||
let (fx, fz, fh) = (x as f32, z as f32, h as f32);
|
||||
quad([
|
||||
[fx, fh, fz],
|
||||
[fx, fh, fz + 1.],
|
||||
[fx + 1., fh, fz + 1.],
|
||||
[fx + 1., fh, fz],
|
||||
]);
|
||||
// the step down to a lower neighbour, toward +x and +z
|
||||
for (dx, dz) in [(1, 0), (0, 1)] {
|
||||
let n = height(x + dx, z + dz);
|
||||
if n < h {
|
||||
let (lo, hi) = (n as f32, fh);
|
||||
if dx == 1 {
|
||||
quad([
|
||||
[fx + 1., lo, fz],
|
||||
[fx + 1., hi, fz],
|
||||
[fx + 1., hi, fz + 1.],
|
||||
[fx + 1., lo, fz + 1.],
|
||||
]);
|
||||
} else {
|
||||
quad([
|
||||
[fx, lo, fz + 1.],
|
||||
[fx + 1., lo, fz + 1.],
|
||||
[fx + 1., hi, fz + 1.],
|
||||
[fx, hi, fz + 1.],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
mesh.sort_solid();
|
||||
mesh
|
||||
}
|
||||
|
||||
/// `--bench`: milliseconds a frame without a warp and with one.
|
||||
fn bench(renderer: &mut Renderer, out: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
||||
const R: i32 = 24;
|
||||
renderer.resize(1920, 1080);
|
||||
let mut sections = 0;
|
||||
for cx in -R..=R {
|
||||
for cz in -R..=R {
|
||||
// (keys count sections from y -64: 8 is y 64 to 80)
|
||||
renderer.upload((cx, 8, cz), &hills(cx, cz));
|
||||
sections += 1;
|
||||
}
|
||||
}
|
||||
let hud = HudBatch::new([1920, 1080]);
|
||||
let mut frame = fixture_frame(&hud);
|
||||
frame.camera = glam::DVec3::new(8.0, 90.0, 8.0);
|
||||
frame.eye = frame.camera;
|
||||
frame.pitch = 15.0;
|
||||
frame.eye_rotation = [0.0, 15.0];
|
||||
frame.distance = 16.0 * R as f32;
|
||||
frame.detail_distance = frame.distance;
|
||||
frame.depth_far = 2000.0;
|
||||
frame.cave_culling = true;
|
||||
let mut time = |warp: Option<native_render::WorldWarp>| {
|
||||
*WARP.lock().unwrap() = warp;
|
||||
for _ in 0..30 {
|
||||
renderer.render(&frame).unwrap();
|
||||
}
|
||||
let started = std::time::Instant::now();
|
||||
const FRAMES: u32 = 240;
|
||||
for _ in 0..FRAMES {
|
||||
renderer.render(&frame).unwrap();
|
||||
}
|
||||
// the last frame's picture, so the GPU has caught up
|
||||
renderer.screenshot = Some(ScreenshotRequest {
|
||||
path: out.join("bench.png"),
|
||||
size: None,
|
||||
scaled: false,
|
||||
});
|
||||
renderer.render(&frame).unwrap();
|
||||
renderer.render(&frame).unwrap();
|
||||
started.elapsed().as_secs_f64() * 1000.0 / f64::from(FRAMES + 2)
|
||||
};
|
||||
let mut wave = native_render::WorldWarp::default();
|
||||
wave.source = Some(
|
||||
"fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
|
||||
let x = pos.x + warp.camera.x + warp.camera_rest.x;
|
||||
return Warped(pos + vec3(0.0, sin(x * 0.05) * 3.0, 0.0), vec3(1.0));
|
||||
}\n"
|
||||
.into(),
|
||||
);
|
||||
wave.on = true;
|
||||
let plain = time(None);
|
||||
let warped = time(Some(wave));
|
||||
let again = time(None);
|
||||
let result = serde_json::json!({
|
||||
"sections": sections, "frame_ms_without_warp": plain, "frame_ms_with_warp": warped,
|
||||
"frame_ms_without_warp_after": again,
|
||||
"graphics_adapter": client::crash::system_details("bench").into_iter()
|
||||
.find(|(name, _)| name == "Graphics Adapter").map(|(_, detail)| detail),
|
||||
});
|
||||
println!("{result}");
|
||||
std::fs::write(out.join("bench.json"), serde_json::to_vec_pretty(&result)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct App;
|
||||
impl ApplicationHandler for App {
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
let window = Arc::new(
|
||||
event_loop
|
||||
.create_window(Window::default_attributes().with_visible(false))
|
||||
.unwrap(),
|
||||
);
|
||||
if let Err(error) = fixture(window) {
|
||||
eprintln!("world warp check failed: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
event_loop.exit();
|
||||
}
|
||||
fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, _: WindowEvent) {}
|
||||
}
|
||||
fn main() {
|
||||
for key in [
|
||||
"HORIZON_HOME",
|
||||
"HORIZON_LAUNCHER_HOME",
|
||||
"XDG_DATA_HOME",
|
||||
"XDG_CONFIG_HOME",
|
||||
] {
|
||||
assert!(
|
||||
std::env::var_os(key).is_some(),
|
||||
"set throwaway {key} (AGENTS.md)"
|
||||
);
|
||||
}
|
||||
EventLoop::new().unwrap().run_app(&mut App).unwrap();
|
||||
}
|
||||
|
||||
fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
Frame {
|
||||
weather: &[],
|
||||
weather_time: client::weather::Time::new(0, 0.0),
|
||||
camera: glam::DVec3::new(8.0, 104.0, 0.5),
|
||||
yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
fov_degrees: 85.0,
|
||||
eye: glam::DVec3::new(8.0, 104.0, 0.5),
|
||||
eye_rotation: [0.0, 0.0],
|
||||
first_person: true,
|
||||
bob: glam::Mat4::IDENTITY,
|
||||
spin: glam::Mat4::IDENTITY,
|
||||
sky: client::environment::Sky::default(),
|
||||
shadow_scene_revision: (0, 0),
|
||||
shadow_cache: false,
|
||||
distance: 128.0,
|
||||
detail_distance: 128.0,
|
||||
depth_far: 1000.0,
|
||||
underwater: false,
|
||||
water_plane: None,
|
||||
cave_culling: false,
|
||||
title: false,
|
||||
sky_blocked: false,
|
||||
skybox: false,
|
||||
end_sky: false,
|
||||
end_sky_sprite: None,
|
||||
boss_world_fog: false,
|
||||
environment_fog: None,
|
||||
water_fog: [0.0; 3],
|
||||
fog: None,
|
||||
dark_disc: false,
|
||||
outline: Vec::new(),
|
||||
outline_after_terrain: false,
|
||||
outline_high_contrast: false,
|
||||
pvp: Default::default(),
|
||||
debug_lines: &[],
|
||||
debug_lines_on_top: &[],
|
||||
debug_crosshair: &[],
|
||||
hud,
|
||||
glint_alpha: 0.75,
|
||||
glint_ticks: 0.0,
|
||||
nether_lighting: false,
|
||||
entities: &[],
|
||||
static_block_entities: &[],
|
||||
visible_static_block_entity_sections: &[],
|
||||
static_block_entity_stats: Default::default(),
|
||||
entity_motion: &[],
|
||||
entity_motion_posed: &[],
|
||||
entity_spans: &[],
|
||||
skin: None,
|
||||
glowing: &[],
|
||||
dynamic_lights: &[],
|
||||
colored_lights: &[],
|
||||
emissive_layers: &[],
|
||||
clouds: None,
|
||||
lightning: Default::default(),
|
||||
additive: &[],
|
||||
see_through: &[],
|
||||
world_primitives: Default::default(),
|
||||
shadow_only: &[],
|
||||
celestial: &[],
|
||||
stars: None,
|
||||
hand: &[],
|
||||
hand_items: &[],
|
||||
hand_fov_degrees: 70.0,
|
||||
screen: &[],
|
||||
pack: Default::default(),
|
||||
border: None,
|
||||
game_time: 0.0,
|
||||
shader_game_time: 0.0,
|
||||
end_portal_sprites: [[0.0; 4]; 2],
|
||||
crumbling_sprite: [0.0; 4],
|
||||
ticks: 0,
|
||||
darkness: (0.0, 0.0),
|
||||
night_vision: 0.0,
|
||||
block_flicker: 0.0,
|
||||
boss_darken: 0.0,
|
||||
chunk_fade: 0.0,
|
||||
post_effects: client::post_chain::Request::none(),
|
||||
menu_blur: None,
|
||||
}
|
||||
}
|
||||
@@ -191,13 +191,18 @@ pub fn discovery_url() -> String {
|
||||
.unwrap_or_else(|| DEFAULT_DISCOVERY.to_owned())
|
||||
}
|
||||
|
||||
fn agent() -> ureq::Agent {
|
||||
pub(crate) fn agent() -> ureq::Agent {
|
||||
ureq::AgentBuilder::new()
|
||||
.timeout(Duration::from_secs(15))
|
||||
.user_agent(&format!("Horizon/{}", horizon_core::release::VERSION))
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Whether `HORIZON_API_DISCOVERY` names another services environment.
|
||||
pub fn discovery_overridden() -> bool {
|
||||
discovery_url() != DEFAULT_DISCOVERY
|
||||
}
|
||||
|
||||
/// The discovery document, fetched once per game (a failure is retried next time).
|
||||
pub(crate) fn discovery() -> Result<Arc<serde_json::Value>, JoinError> {
|
||||
static CACHE: Mutex<Option<Arc<serde_json::Value>>> = Mutex::new(None);
|
||||
|
||||
@@ -17,11 +17,15 @@ pub struct BanDetails {
|
||||
pub reason_message: Option<String>,
|
||||
}
|
||||
|
||||
/// authlib's `UserProperties`, as far as the title screen reads it.
|
||||
/// authlib's `UserProperties`, as far as the title screen and the friends service read it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct UserProperties {
|
||||
/// `UserFlag.SERVERS_ALLOWED` (`privileges.multiplayerServer.enabled`).
|
||||
pub servers_allowed: bool,
|
||||
/// `UserFlag.FRIENDS_ENABLED` (`friendsPreferences.friends`).
|
||||
pub friends_enabled: bool,
|
||||
/// `UserFlag.ACCEPT_FRIEND_INVITES` (`friendsPreferences.acceptInvites`).
|
||||
pub accept_friend_invites: bool,
|
||||
/// `bannedScopes.get("MULTIPLAYER")`.
|
||||
pub multiplayer_ban: Option<BanDetails>,
|
||||
}
|
||||
@@ -30,6 +34,8 @@ impl Default for UserProperties {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
servers_allowed: true,
|
||||
friends_enabled: false,
|
||||
accept_friend_invites: false,
|
||||
multiplayer_ban: None,
|
||||
}
|
||||
}
|
||||
@@ -209,6 +215,13 @@ pub fn parse_properties(body: &str) -> Option<UserProperties> {
|
||||
.pointer("/privileges/multiplayerServer/enabled")
|
||||
.and_then(|e| e.as_bool())
|
||||
.unwrap_or(false);
|
||||
let toggle = |name: &str| {
|
||||
v.pointer(&format!("/friendsPreferences/{name}"))
|
||||
.and_then(|f| f.as_str())
|
||||
.is_some_and(|f| f.eq_ignore_ascii_case("ENABLED"))
|
||||
};
|
||||
let friends_enabled = toggle("friends");
|
||||
let accept_friend_invites = toggle("acceptInvites");
|
||||
let multiplayer_ban = v
|
||||
.pointer("/banStatus/bannedScopes/MULTIPLAYER")
|
||||
.filter(|b| b.is_object())
|
||||
@@ -222,6 +235,8 @@ pub fn parse_properties(body: &str) -> Option<UserProperties> {
|
||||
});
|
||||
Some(UserProperties {
|
||||
servers_allowed,
|
||||
friends_enabled,
|
||||
accept_friend_invites,
|
||||
multiplayer_ban,
|
||||
})
|
||||
}
|
||||
@@ -275,6 +290,24 @@ mod tests {
|
||||
assert!(clean.servers_allowed && clean.multiplayer_ban.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn friends_preferences_read_their_two_flags() {
|
||||
let body = r#"{"privileges":{"multiplayerServer":{"enabled":true}},
|
||||
"friendsPreferences":{"friends":"ENABLED","acceptInvites":"DISABLED"}}"#;
|
||||
let p = parse_properties(body).unwrap();
|
||||
assert!(p.friends_enabled);
|
||||
assert!(!p.accept_friend_invites);
|
||||
let other = parse_properties(
|
||||
r#"{"friendsPreferences":{"friends":"DISABLED","acceptInvites":"ENABLED"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!other.friends_enabled);
|
||||
assert!(other.accept_friend_invites);
|
||||
// The offline fallback has neither flag, as `OFFLINE_PROPERTIES` does.
|
||||
assert!(!UserProperties::default().friends_enabled);
|
||||
assert!(!UserProperties::default().accept_friend_invites);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_profile_actions() {
|
||||
let a = parse_profile_actions(
|
||||
@@ -297,6 +330,7 @@ mod tests {
|
||||
properties: UserProperties {
|
||||
servers_allowed: true,
|
||||
multiplayer_ban: Some(ban.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
actions: ProfileActions {
|
||||
forced_name_change: true,
|
||||
@@ -317,7 +351,7 @@ mod tests {
|
||||
let no_servers = AccountStatus {
|
||||
properties: UserProperties {
|
||||
servers_allowed: false,
|
||||
multiplayer_ban: None,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -112,6 +112,9 @@ pub struct BiomeInfo {
|
||||
/// `BiomeGenerationSettings.features`: the placed feature ids of each
|
||||
/// `GenerationStep.Decoration` step, in order.
|
||||
pub features: Vec<Vec<String>>,
|
||||
/// The biome's environment attributes (`Biome.getAttributes`): its layer in the
|
||||
/// environment attribute stack.
|
||||
pub attributes: horizon_world::environment_attributes::AttributeMap,
|
||||
}
|
||||
impl BiomeInfo {
|
||||
fn ambient_particles(v: &Value) -> Vec<(ParticleType, f32)> {
|
||||
@@ -277,6 +280,11 @@ impl BiomeInfo {
|
||||
.collect()
|
||||
})
|
||||
.collect(),
|
||||
attributes: horizon_world::environment_attributes::AttributeMap::parse(attributes)
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("biome attributes: {e}");
|
||||
Default::default()
|
||||
}),
|
||||
name,
|
||||
}
|
||||
}
|
||||
@@ -393,6 +401,17 @@ impl Biomes {
|
||||
pub fn get(&self, id: u16) -> &BiomeInfo {
|
||||
&self.list[usize::from(id).min(self.list.len() - 1)]
|
||||
}
|
||||
/// The name of biome `id`, plains (as for an unknown name) when the id is
|
||||
/// past this table: a column a server sent under its earlier, longer
|
||||
/// registry (a proxy's previous server) keeps its ids until the next
|
||||
/// login replaces it. `get` clamps such an id to the last entry instead,
|
||||
/// so for that stale column the colours and this name can come from
|
||||
/// different biomes until the login.
|
||||
pub fn name(&self, id: u16) -> &str {
|
||||
self.list
|
||||
.get(usize::from(id))
|
||||
.map_or("minecraft:plains", |b| b.name.as_str())
|
||||
}
|
||||
pub fn grass(&self, id: u16, x: f64, z: f64) -> u32 {
|
||||
let b = self.get(id);
|
||||
match b.modifier {
|
||||
@@ -624,6 +643,49 @@ mod pack_tests {
|
||||
assert_eq!(other.get(2).water, 0x3F76E4);
|
||||
assert!(!other.has_packs());
|
||||
}
|
||||
|
||||
/// A column keeps the ids of the table it was decoded with. When a
|
||||
/// proxy's next server lacks a biome the last one added, that id is past
|
||||
/// the next table, and it names plains instead of indexing past the end
|
||||
/// (#12148: "the len is 67 but the index is 67").
|
||||
#[test]
|
||||
fn an_id_past_the_table_names_plains() {
|
||||
let next = builtin(&["minecraft:meadow", "minecraft:plains", "minecraft:river"]);
|
||||
let last = next.with_packs([("limbo:void", &json!({}))]);
|
||||
let stale = last.id("limbo:void");
|
||||
assert_eq!((stale, last.name(stale)), (3, "limbo:void"));
|
||||
assert_eq!(next.name(stale), "minecraft:plains");
|
||||
assert_eq!(next.name(u16::MAX), "minecraft:plains");
|
||||
assert_eq!(next.name(2), "minecraft:river");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Biomes {
|
||||
/// Biomes from their files' JSON, by name, in id order: for tests elsewhere.
|
||||
pub(crate) fn from_jsons(entries: &[(&str, Value)]) -> Self {
|
||||
let maps = Colormaps {
|
||||
grass: Vec::new(),
|
||||
foliage: Vec::new(),
|
||||
dry: Vec::new(),
|
||||
};
|
||||
let list: Vec<_> = entries
|
||||
.iter()
|
||||
.map(|(name, v)| BiomeInfo::from_json((*name).to_owned(), v, &maps))
|
||||
.collect();
|
||||
Self {
|
||||
builtin: Arc::new(list.clone()),
|
||||
index: list
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, b)| (b.name.clone(), i as u16))
|
||||
.collect(),
|
||||
list,
|
||||
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
|
||||
colormaps: Arc::new(maps),
|
||||
packs: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -37,6 +37,37 @@ pub fn potion_name_key(item: &str, potion: Option<&str>) -> String {
|
||||
let suffix = potion.and_then(self::potion).map_or("empty", |p| p.name);
|
||||
format!("item.minecraft.{prefix}.effect.{suffix}")
|
||||
}
|
||||
/// `PotionItem.getName` and `TippedArrowItem.getName`: a potion or a tipped
|
||||
/// arrow is named by its contents (`PotionContents.getName`: their own
|
||||
/// `custom_name`, else the potion's name, else `empty`), whatever its
|
||||
/// `item_name`. None for other items, and for a stack whose contents were
|
||||
/// taken off (`!minecraft:potion_contents`), which takes the item's name.
|
||||
pub fn contents_name_key(stack: &ItemStack) -> Option<String> {
|
||||
use horizon_world::nbt::Tag;
|
||||
let item = &*stack.item;
|
||||
let named = matches!(
|
||||
item,
|
||||
"minecraft:potion"
|
||||
| "minecraft:splash_potion"
|
||||
| "minecraft:lingering_potion"
|
||||
| "minecraft:tipped_arrow"
|
||||
);
|
||||
let data = stack.data();
|
||||
if !named || data.is_some_and(|d| d.other("!minecraft:potion_contents").is_some()) {
|
||||
return None;
|
||||
}
|
||||
let prefix = item.trim_start_matches("minecraft:");
|
||||
let own = data
|
||||
.and_then(|d| d.potion_extra.iter().find(|(k, _)| k == "custom_name"))
|
||||
.and_then(|(_, name)| match name {
|
||||
Tag::String(s) => Some(s.to_string_lossy()),
|
||||
_ => None,
|
||||
});
|
||||
Some(match own {
|
||||
Some(suffix) => format!("item.minecraft.{prefix}.effect.{suffix}"),
|
||||
None => potion_name_key(item, stack.potion()),
|
||||
})
|
||||
}
|
||||
/// `PotionContents.getAllEffects`: the potion's effects, then the stack's
|
||||
/// `custom_effects` (id, duration, amplifier). Unknown effect ids are dropped.
|
||||
pub fn contents_effects(stack: &ItemStack) -> Vec<(&'static str, i32, i32)> {
|
||||
@@ -469,6 +500,40 @@ mod tests {
|
||||
potion_name_key("minecraft:splash_potion", Some("minecraft:long_swiftness")),
|
||||
"item.minecraft.splash_potion.effect.swiftness"
|
||||
);
|
||||
// A potion or a tipped arrow is named by its contents, over its
|
||||
// item_name; one whose contents were taken off is not.
|
||||
use horizon_world::nbt::Tag;
|
||||
let mut swift =
|
||||
ItemStack::new("minecraft:potion", 1).with_potion("minecraft:long_swiftness");
|
||||
swift
|
||||
.data_mut()
|
||||
.set_other("minecraft:item_name", Some(Tag::String("Bottle".into())));
|
||||
assert_eq!(
|
||||
contents_name_key(&swift).as_deref(),
|
||||
Some("item.minecraft.potion.effect.swiftness")
|
||||
);
|
||||
assert_eq!(
|
||||
contents_name_key(&ItemStack::new("minecraft:splash_potion", 1)).as_deref(),
|
||||
Some("item.minecraft.splash_potion.effect.empty")
|
||||
);
|
||||
let mut own = ItemStack::new("minecraft:tipped_arrow", 1).with_potion("minecraft:poison");
|
||||
own.data_mut()
|
||||
.potion_extra
|
||||
.push(("custom_name".into(), Tag::String("luck".into())));
|
||||
assert_eq!(
|
||||
contents_name_key(&own).as_deref(),
|
||||
Some("item.minecraft.tipped_arrow.effect.luck")
|
||||
);
|
||||
let mut taken = ItemStack::new("minecraft:potion", 1);
|
||||
taken.data_mut().set_other(
|
||||
"!minecraft:potion_contents",
|
||||
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
|
||||
);
|
||||
assert_eq!(contents_name_key(&taken), None);
|
||||
assert_eq!(
|
||||
contents_name_key(&ItemStack::new("minecraft:stone", 1)),
|
||||
None
|
||||
);
|
||||
assert_eq!(format_duration(9600), "08:00");
|
||||
assert_eq!(format_duration(450), "00:22");
|
||||
assert_eq!(format_duration(140), "00:07");
|
||||
|
||||
@@ -357,6 +357,24 @@ impl Light {
|
||||
}
|
||||
/// The smallest form of packed light per block.
|
||||
pub fn packed(light: Box<[u8; 4096]>) -> Self {
|
||||
match Self::classify(&light) {
|
||||
(false, false, first) => Self::Uniform(first),
|
||||
(true, false, first) => Self::Sky(Self::pack_layer(&light, 4), first & 15),
|
||||
(false, true, first) => Self::Block(Self::pack_layer(&light, 0), first >> 4),
|
||||
(true, true, _) => Self::Full(light),
|
||||
}
|
||||
}
|
||||
/// The smallest form of packed light per block, allocating a 4 KiB box only
|
||||
/// when both sky and block light vary within the section.
|
||||
pub fn from_slice(light: &[u8; 4096]) -> Self {
|
||||
match Self::classify(light) {
|
||||
(false, false, first) => Self::Uniform(first),
|
||||
(true, false, first) => Self::Sky(Self::pack_layer(light, 4), first & 15),
|
||||
(false, true, first) => Self::Block(Self::pack_layer(light, 0), first >> 4),
|
||||
(true, true, _) => Self::Full(Box::new(*light)),
|
||||
}
|
||||
}
|
||||
fn classify(light: &[u8; 4096]) -> (bool, bool, u8) {
|
||||
let first = light[0];
|
||||
let (mut sky_varies, mut block_varies) = (false, false);
|
||||
for &v in light.iter() {
|
||||
@@ -364,19 +382,14 @@ impl Light {
|
||||
sky_varies |= d & 0xF0 != 0;
|
||||
block_varies |= d & 15 != 0;
|
||||
}
|
||||
let layer = |shift: u32| {
|
||||
let mut layer = Box::new([0u8; 2048]);
|
||||
for (out, pair) in layer.iter_mut().zip(light.as_chunks::<2>().0) {
|
||||
*out = (pair[0] >> shift & 15) | (pair[1] >> shift & 15) << 4;
|
||||
}
|
||||
layer
|
||||
};
|
||||
match (sky_varies, block_varies) {
|
||||
(false, false) => Self::Uniform(first),
|
||||
(true, false) => Self::Sky(layer(4), first & 15),
|
||||
(false, true) => Self::Block(layer(0), first >> 4),
|
||||
(true, true) => Self::Full(light),
|
||||
(sky_varies, block_varies, first)
|
||||
}
|
||||
fn pack_layer(light: &[u8; 4096], shift: u32) -> Box<[u8; 2048]> {
|
||||
let mut layer = Box::new([0u8; 2048]);
|
||||
for (out, pair) in layer.iter_mut().zip(light.as_chunks::<2>().0) {
|
||||
*out = (pair[0] >> shift & 15) | (pair[1] >> shift & 15) << 4;
|
||||
}
|
||||
layer
|
||||
}
|
||||
/// The packed light of blocks `at..at + out.len()`, into `out`.
|
||||
#[inline]
|
||||
|
||||
@@ -85,6 +85,11 @@ pub enum BlockEntity {
|
||||
DaylightDetector,
|
||||
/// `BellBlockEntity`: saved identity only; ringing state is kept separately.
|
||||
Bell,
|
||||
/// `ConduitBlockEntity`: the UUID of its `destroyTarget` (`Target`). Its
|
||||
/// shape and animation are kept separately (`block_entity_anim`).
|
||||
Conduit(Option<[i32; 4]>),
|
||||
/// `EnchantingTableBlockEntity`: its custom name is the world's block name.
|
||||
EnchantingTable,
|
||||
/// Suspicious sand and gravel: saved loot and the transient brushed face.
|
||||
Brushable(Box<Brushable>),
|
||||
/// A shelf holds three displayed item stacks.
|
||||
@@ -515,6 +520,11 @@ pub struct Info {
|
||||
pub pos: [f64; 3],
|
||||
pub source: Option<Who>,
|
||||
pub owner: Option<Who>,
|
||||
/// `uuid`: the source's saved identity (`source`), taken when the event
|
||||
/// was posted. `source` is the live entity and comes back `None` on load.
|
||||
pub source_uuid: Option<[i32; 4]>,
|
||||
/// `projectileOwnerUuid` (`projectile_owner`), likewise beside `owner`.
|
||||
pub owner_uuid: Option<[i32; 4]>,
|
||||
}
|
||||
/// `VibrationSelector`: the closest, then highest-frequency candidate of a tick.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
|
||||
@@ -356,6 +356,38 @@ where
|
||||
)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
/// `Level.loadedAndEntityCanStandOn(pos, projectile)` with the projectile's
|
||||
/// feet at `entity_y` (`loadedAndEntityCanStandOnFace` with `UP`):
|
||||
/// `Block.isFaceFull(state.getCollisionShape(level, pos,
|
||||
/// CollisionContext.of(projectile)), UP)`. A projectile is not living and never
|
||||
/// falls (it moves by `setPos`), so powder snow and liquids hold nothing up
|
||||
/// and only scaffolding reads the height. An unloaded or out-of-world block
|
||||
/// reads as air.
|
||||
pub fn projectile_can_stand_on(
|
||||
world: &World,
|
||||
binding: &BlockCollisionRegistry<'_>,
|
||||
pos: BlockPos,
|
||||
entity_y: f64,
|
||||
) -> bool {
|
||||
use horizon_core::geometry::{Direction, ShapeOp};
|
||||
let mut scene = SceneCollisions {
|
||||
world,
|
||||
binding,
|
||||
subject: None,
|
||||
};
|
||||
let state = world.block(pos.x, pos.y, pos.z);
|
||||
let context = CollisionContext::position(entity_y);
|
||||
binding
|
||||
.collision_shape_with_context(&mut scene, state, pos, &context)
|
||||
.is_ok_and(|shape| {
|
||||
shape.is_block()
|
||||
|| shape.face_shape(Direction::Up).is_ok_and(|face| {
|
||||
VoxelShape::block()
|
||||
.join_is_not_empty(&face, ShapeOp::XOR)
|
||||
.is_ok_and(|differs| !differs)
|
||||
})
|
||||
})
|
||||
}
|
||||
fn no_collision_in<W, E>(world: &mut W, bounds: Aabb, fluids: bool) -> bool
|
||||
where
|
||||
W: BlockCollisionWorld<Error = E> + EntityMovementWorld<Error = E>,
|
||||
|
||||
@@ -444,6 +444,12 @@ impl BlockEntity {
|
||||
if name == "minecraft:bell" {
|
||||
return Some(Self::Bell);
|
||||
}
|
||||
if name == "minecraft:conduit" {
|
||||
return Some(Self::Conduit(None));
|
||||
}
|
||||
if name == "minecraft:enchanting_table" {
|
||||
return Some(Self::EnchantingTable);
|
||||
}
|
||||
if name == "minecraft:daylight_detector" {
|
||||
return Some(Self::DaylightDetector);
|
||||
}
|
||||
@@ -540,6 +546,8 @@ impl BlockEntity {
|
||||
Self::EndGateway(_) => name == "minecraft:end_gateway",
|
||||
Self::DaylightDetector => name == "minecraft:daylight_detector",
|
||||
Self::Bell => name == "minecraft:bell",
|
||||
Self::Conduit(_) => name == "minecraft:conduit",
|
||||
Self::EnchantingTable => name == "minecraft:enchanting_table",
|
||||
Self::StructureBlock(_) => name == "minecraft:structure_block",
|
||||
Self::TestBlock(_) => name == "minecraft:test_block",
|
||||
Self::TestInstanceBlock(_) => name == "minecraft:test_instance_block",
|
||||
@@ -579,6 +587,8 @@ impl BlockEntity {
|
||||
Self::EndGateway(_) => "minecraft:end_gateway",
|
||||
Self::DaylightDetector => "minecraft:daylight_detector",
|
||||
Self::Bell => "minecraft:bell",
|
||||
Self::Conduit(_) => "minecraft:conduit",
|
||||
Self::EnchantingTable => "minecraft:enchanting_table",
|
||||
Self::Brushable(_) => "minecraft:brushable_block",
|
||||
Self::Campfire(_) => "minecraft:campfire",
|
||||
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
|
||||
@@ -614,6 +624,8 @@ impl BlockEntity {
|
||||
| Self::TestInstanceBlock(_)
|
||||
| Self::DaylightDetector
|
||||
| Self::Bell
|
||||
| Self::Conduit(_)
|
||||
| Self::EnchantingTable
|
||||
| Self::Brushable(_) => Vec::new(),
|
||||
Self::Mod(m) => m.items.iter().flatten().cloned().collect(),
|
||||
Self::Lectern(l) => l.book.iter().cloned().collect(),
|
||||
@@ -645,6 +657,8 @@ impl BlockEntity {
|
||||
| Self::TestInstanceBlock(_)
|
||||
| Self::DaylightDetector
|
||||
| Self::Bell
|
||||
| Self::Conduit(_)
|
||||
| Self::EnchantingTable
|
||||
| Self::Brushable(_) => &[],
|
||||
Self::Jukebox(j) => &j.items,
|
||||
Self::Crafter(c) => &c.items,
|
||||
@@ -681,6 +695,8 @@ impl BlockEntity {
|
||||
| Self::TestInstanceBlock(_)
|
||||
| Self::DaylightDetector
|
||||
| Self::Bell
|
||||
| Self::Conduit(_)
|
||||
| Self::EnchantingTable
|
||||
| Self::Brushable(_) => &mut [],
|
||||
Self::Jukebox(j) => &mut j.items,
|
||||
Self::Crafter(c) => &mut c.items,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -33,7 +33,7 @@ struct Globals {
|
||||
hdr_windows: array<vec4<f32>, 2>,
|
||||
// HDR lighting: x how strongly the sky lights the world now (`Sky::sky_light_factor`),
|
||||
// y what the sky and fog get: the same, but none under water.
|
||||
// w: this entire frame writes light, so alpha is coverage rather than a mark.
|
||||
// w: apply the no-pack HDR gain; HDR_WRITE_LIGHT independently keeps float alpha as coverage.
|
||||
hdr_light: vec4<f32>,
|
||||
hdr_settings: vec4<f32>,
|
||||
hdr_shares: vec4<f32>,
|
||||
@@ -175,7 +175,11 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
@vertex fn vs_entity(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
|
||||
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>,
|
||||
@location(5) normal: vec4<f32>, @location(6) glint_uv: vec2<f32>) -> Out {
|
||||
var out = entity_vertex(position, uv, overlay & 0xffffu, color, light, normal);
|
||||
// [world warp entity]
|
||||
// (a world warp's pipelines put the vertex through the mod's `world_warp`: gfx/world_warp.rs)
|
||||
let at = position;
|
||||
// [end world warp entity]
|
||||
var out = entity_vertex(at, uv, overlay & 0xffffu, color, light, normal);
|
||||
out.glint_uv = glint_uv;
|
||||
out.tier = (overlay >> 16u) & 0xffu;
|
||||
return out;
|
||||
@@ -279,10 +283,11 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
|
||||
}
|
||||
// CRUMBLING_UV_END
|
||||
@fragment fn fs_entity(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
return entity_written_color(in, front);
|
||||
return entity_written_color(in, entity_color(in, front));
|
||||
}
|
||||
fn entity_written_color(in: Out, front: bool) -> vec4<f32> {
|
||||
let color = entity_color(in, front);
|
||||
// Takes entity_color's result rather than calling it: FXC (DX12) fails with "argument pulled into
|
||||
// unrelated predicate" when entity_color's discards sit a function call below the entry point.
|
||||
fn entity_written_color(in: Out, color: vec4<f32>) -> vec4<f32> {
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 && in.overlay != 11u && in.overlay != 12u {
|
||||
var mark = hdr_entity_mark(in);
|
||||
if in.overlay == 4u { mark = hdr_scene_alpha(globals.hdr_light.y); }
|
||||
@@ -455,6 +460,7 @@ fn celestial_color(in: Out) -> vec4<f32> {
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(added, select(1.0, hdr_mark(tier, y), full), globals.hdr_settings, globals.hdr_shares), color.a);
|
||||
}
|
||||
if HDR_WRITE_LIGHT { return vec4(added, color.a); }
|
||||
if !full { return vec4(added, 0.0); }
|
||||
return vec4(added, 1.0 - hdr_mark(tier, y));
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -5,7 +5,7 @@ use crate::{
|
||||
lang,
|
||||
options_ui::{Event, Page},
|
||||
settings::Settings,
|
||||
settings_shell::{self as shell, Group, Output, Row, Screen, Sheet, Value},
|
||||
settings_shell::{self as shell, Binding, Group, Output, Row, Screen, Sheet, Value},
|
||||
};
|
||||
|
||||
const TITLE: &str = "horizon.pages.extras";
|
||||
@@ -36,7 +36,7 @@ pub(crate) enum ExtraRow {
|
||||
fn item_row(item: Item, s: &Settings) -> Row<ExtraRow> {
|
||||
let e = &s.extras;
|
||||
let value = match (item.key_name(), item.switch(e)) {
|
||||
(Some(key), _) => Value::Key(key, item.value(e)),
|
||||
(Some(key), _) => Value::Binding(Binding::key(key, item.value(e))),
|
||||
(None, Some(on)) => Value::Switch(on),
|
||||
(None, None) => Value::Choice(item.value(e)),
|
||||
};
|
||||
@@ -116,13 +116,10 @@ impl Screen for ExtrasScreen {
|
||||
}
|
||||
fn press(&mut self, s: &mut Settings, row: ExtraRow, back: bool) -> Vec<Output> {
|
||||
match row {
|
||||
ExtraRow::Item(item) => match item.key_name() {
|
||||
Some(key) => vec![Output::BindKey(key)],
|
||||
None => {
|
||||
item.cycle(&mut s.extras, back);
|
||||
vec![Output::Event(Event::Changed)]
|
||||
}
|
||||
},
|
||||
ExtraRow::Item(item) => {
|
||||
item.cycle(&mut s.extras, back);
|
||||
vec![Output::Event(Event::Changed)]
|
||||
}
|
||||
ExtraRow::Zoom(i) => {
|
||||
s.mods.toggle(i);
|
||||
vec![Output::Event(Event::Changed)]
|
||||
@@ -208,15 +205,21 @@ mod tests {
|
||||
/// The sort key binds instead of cycling; the wheel leaves it be.
|
||||
#[test]
|
||||
fn the_key_row_waits_for_a_key() {
|
||||
use crate::settings_shell::Capture;
|
||||
let mut s = Settings::default();
|
||||
let mut screen = ExtrasScreen;
|
||||
s.extras.sort = true;
|
||||
let mut shell = Shell::<ExtrasScreen>::default();
|
||||
let row = ExtraRow::Item(Item::SortKey);
|
||||
let key = s.extras.sort_key.clone();
|
||||
let at = shell.point_at(&s, SIZE, row);
|
||||
assert_eq!(
|
||||
screen.press(&mut s, row, false),
|
||||
vec![Output::BindKey(extras::SORT_KEY)]
|
||||
shell.click(&mut s, at, SIZE, false, false, false),
|
||||
vec![
|
||||
Output::Event(Event::ButtonClick),
|
||||
Output::Bind(Capture::Key(extras::SORT_KEY))
|
||||
]
|
||||
);
|
||||
screen.wheel_step(&mut s, row, true);
|
||||
ExtrasScreen.wheel_step(&mut s, row, true);
|
||||
assert_eq!(s.extras.sort_key, key);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -5,6 +5,9 @@
|
||||
//! (`Arena::translucent_indices`): their back-to-front list then stays in one
|
||||
//! page of each and draws in a few multi-draws.
|
||||
use std::collections::{BTreeMap, VecDeque};
|
||||
#[cfg(test)]
|
||||
#[path = "arena/source_tests.rs"]
|
||||
mod source_tests;
|
||||
|
||||
/// Ranges are rounded up to this many vertices to limit fragmentation, and
|
||||
/// start at multiples of it (`flat` names sections by these blocks).
|
||||
@@ -82,6 +85,8 @@ impl FreeList {
|
||||
/// One vertex buffer of the arena; only the newest page grows.
|
||||
struct Page {
|
||||
buffer: wgpu::Buffer,
|
||||
/// Optional shader-pack source records, indexed by the vertex index / 4.
|
||||
sources: Option<wgpu::Buffer>,
|
||||
/// Capacity in vertices.
|
||||
capacity: u64,
|
||||
free: FreeList,
|
||||
@@ -116,6 +121,7 @@ pub struct Arena {
|
||||
closing: Option<u32>,
|
||||
/// A page had to go to system memory (logged once).
|
||||
spilled: bool,
|
||||
sources_enabled: bool,
|
||||
/// Tests: GPU-local pages fail as if video memory were full.
|
||||
#[cfg(test)]
|
||||
video_memory_full: bool,
|
||||
@@ -200,6 +206,7 @@ impl Arena {
|
||||
usage,
|
||||
closing: None,
|
||||
spilled: false,
|
||||
sources_enabled: false,
|
||||
#[cfg(test)]
|
||||
video_memory_full: false,
|
||||
}
|
||||
@@ -249,7 +256,11 @@ impl Arena {
|
||||
}
|
||||
/// Bytes of every page's buffer.
|
||||
pub fn bytes(&self) -> u64 {
|
||||
self.pages.iter().flatten().map(|p| p.capacity).sum::<u64>() * self.stride
|
||||
self.pages
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|p| p.capacity * self.stride + p.sources.as_ref().map_or(0, wgpu::Buffer::size))
|
||||
.sum()
|
||||
}
|
||||
/// The most vertices a page holds.
|
||||
pub fn page_vertices(&self) -> u64 {
|
||||
@@ -281,6 +292,65 @@ impl Arena {
|
||||
.expect("live arena page")
|
||||
.buffer
|
||||
}
|
||||
|
||||
pub fn sources(&self, page: u32) -> Option<&wgpu::Buffer> {
|
||||
self.pages[page as usize].as_ref()?.sources.as_ref()
|
||||
}
|
||||
|
||||
fn source_buffer(device: &wgpu::Device, capacity: u64) -> Option<wgpu::Buffer> {
|
||||
if capacity / 4 * 8 > device.limits().max_storage_buffer_binding_size {
|
||||
return None;
|
||||
}
|
||||
try_create(
|
||||
device,
|
||||
"Terrain quad sources",
|
||||
capacity / 4 * 8,
|
||||
wgpu::BufferUsages::STORAGE
|
||||
| wgpu::BufferUsages::COPY_DST
|
||||
| wgpu::BufferUsages::COPY_SRC,
|
||||
)
|
||||
}
|
||||
|
||||
/// Native meshes allocate no sidecar. Once needed, existing pages also get a zeroed
|
||||
/// sidecar so moves can retain sources. Failure leaves geometry intact and attributes
|
||||
/// unknown; a later source upload may retry its page.
|
||||
pub fn write_sources(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
uploads: &mut super::upload::Uploads,
|
||||
page: u32,
|
||||
start: u64,
|
||||
vertices: u64,
|
||||
sources: &[crate::mesher::TerrainSource],
|
||||
) {
|
||||
if !sources.is_empty() && !self.sources_enabled {
|
||||
self.sources_enabled = true;
|
||||
for p in self.pages.iter_mut().flatten() {
|
||||
p.sources = Self::source_buffer(device, p.capacity);
|
||||
}
|
||||
}
|
||||
let p = self.pages[page as usize].as_mut().expect("live arena page");
|
||||
if p.sources.is_none() && !sources.is_empty() {
|
||||
p.sources = Self::source_buffer(device, p.capacity);
|
||||
}
|
||||
if let Some(buffer) = &p.sources {
|
||||
let bytes = vertices / 4 * 8;
|
||||
if bytes > 0 {
|
||||
// Recycled ranges must never keep another mesh's identity, even for a
|
||||
// missing or incomplete sidecar. Zero records have no valid bit.
|
||||
encoder.clear_buffer(buffer, start / 4 * 8, Some(bytes));
|
||||
if sources.len() as u64 == vertices / 4 {
|
||||
uploads.buffer(
|
||||
encoder,
|
||||
buffer,
|
||||
start / 4 * 8,
|
||||
bytemuck::cast_slice(sources),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Bytes of one vertex (or index).
|
||||
pub fn stride(&self) -> u64 {
|
||||
self.stride
|
||||
@@ -336,6 +406,10 @@ impl Arena {
|
||||
let start = free.take(len).expect("a new page fits one section");
|
||||
let page = Page {
|
||||
buffer,
|
||||
sources: self
|
||||
.sources_enabled
|
||||
.then(|| Self::source_buffer(device, capacity))
|
||||
.flatten(),
|
||||
capacity,
|
||||
free,
|
||||
host,
|
||||
@@ -451,6 +525,10 @@ impl Arena {
|
||||
let free = FreeList::new(capacity);
|
||||
self.pages.push(Some(Page {
|
||||
buffer,
|
||||
sources: self
|
||||
.sources_enabled
|
||||
.then(|| Self::source_buffer(device, capacity))
|
||||
.flatten(),
|
||||
capacity,
|
||||
free,
|
||||
host,
|
||||
@@ -483,6 +561,21 @@ impl Arena {
|
||||
to.1 * self.stride,
|
||||
vertices * self.stride,
|
||||
);
|
||||
if let Some(to_sources) = self.sources(to.0) {
|
||||
let bytes = vertices / 4 * 8;
|
||||
if bytes > 0 {
|
||||
match self.sources(from.0) {
|
||||
Some(from_sources) => encoder.copy_buffer_to_buffer(
|
||||
from_sources,
|
||||
from.1 / 4 * 8,
|
||||
to_sources,
|
||||
to.1 / 4 * 8,
|
||||
bytes,
|
||||
),
|
||||
None => encoder.clear_buffer(to_sources, to.1 / 4 * 8, Some(bytes)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn release(&mut self, page: u32, start: u64, vertices: u64) {
|
||||
let len = Self::span(vertices).min(self.page_limit);
|
||||
@@ -540,9 +633,17 @@ impl Arena {
|
||||
let Some((buffer, host)) = self.create(device, capacity, spill) else {
|
||||
return false;
|
||||
};
|
||||
let sources = self
|
||||
.sources_enabled
|
||||
.then(|| Self::source_buffer(device, capacity))
|
||||
.flatten();
|
||||
let old = self.pages[page].as_mut().expect("live arena page");
|
||||
// Copies recorded for the old buffer run before this one.
|
||||
encoder.copy_buffer_to_buffer(&old.buffer, 0, &buffer, 0, old.capacity * self.stride);
|
||||
if let (Some(from), Some(to)) = (&old.sources, &sources) {
|
||||
encoder.copy_buffer_to_buffer(from, 0, to, 0, from.size());
|
||||
}
|
||||
old.sources = sources;
|
||||
let previous = old.capacity;
|
||||
old.buffer = buffer;
|
||||
old.host = host;
|
||||
@@ -1447,6 +1548,7 @@ mod tests {
|
||||
let free = FreeList(BTreeMap::from([(used, capacity - used)]));
|
||||
let buffer = buffer.clone();
|
||||
arena.pages = vec![Some(Page {
|
||||
sources: None,
|
||||
buffer,
|
||||
capacity,
|
||||
free,
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Original sidecar lifetime controls, executed explicitly on an offscreen adapter.
|
||||
use super::*;
|
||||
use crate::mesher::{TerrainSource, TerrainVertex};
|
||||
|
||||
fn read(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
buffer: &wgpu::Buffer,
|
||||
offset: u64,
|
||||
size: u64,
|
||||
) -> Vec<u8> {
|
||||
let out = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
encoder.copy_buffer_to_buffer(buffer, offset, &out, 0, size);
|
||||
queue.submit([encoder.finish()]);
|
||||
out.slice(..).map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
out.slice(..).get_mapped_range().unwrap().to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "offscreen GPU control; run explicitly with an available adapter"]
|
||||
fn terrain_source_arena_gpu_grow_move_recycle_and_refusal() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default()))
|
||||
.expect("GPU control requires an adapter");
|
||||
eprintln!("TERRAIN_SOURCE_ARENA_ADAPTER {:?}", adapter.get_info());
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let mut arena = Arena::new(&device, 24, GRAIN, true);
|
||||
arena.page_limit = GRAIN * 2;
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let mut uploads = super::super::upload::Uploads::new(&device);
|
||||
let first = arena.alloc(&device, &mut encoder, 4).unwrap();
|
||||
assert!(
|
||||
arena.sources(first.0).is_none(),
|
||||
"default geometry allocates no source buffer"
|
||||
);
|
||||
let geometry = [TerrainVertex::new([2.25, 3.75, 4.5], [0.25, 0.75], 7, 0xff123456, 0x8070); 4];
|
||||
uploads.buffer(
|
||||
&mut encoder,
|
||||
arena.buffer(first.0),
|
||||
first.1 * 24,
|
||||
bytemuck::cast_slice(&geometry),
|
||||
);
|
||||
let source = TerrainSource::new(71, [2, 3, 4], 13, false);
|
||||
arena.write_sources(
|
||||
&device,
|
||||
&mut encoder,
|
||||
&mut uploads,
|
||||
first.0,
|
||||
first.1,
|
||||
4,
|
||||
&[source],
|
||||
);
|
||||
let second = arena.alloc(&device, &mut encoder, GRAIN).unwrap();
|
||||
assert_eq!(second, (first.0, GRAIN), "grew the first page");
|
||||
let third = arena.alloc(&device, &mut encoder, GRAIN).unwrap();
|
||||
assert_ne!(third.0, first.0, "move crosses pages");
|
||||
arena.copy(&mut encoder, first, third, 4);
|
||||
arena.release(first.0, first.1, 4);
|
||||
let reused = arena.alloc_existing(4).unwrap();
|
||||
assert_eq!(reused, first);
|
||||
arena.write_sources(
|
||||
&device,
|
||||
&mut encoder,
|
||||
&mut uploads,
|
||||
reused.0,
|
||||
reused.1,
|
||||
4,
|
||||
&[],
|
||||
);
|
||||
uploads.finish(&encoder);
|
||||
queue.submit([encoder.finish()]);
|
||||
assert_eq!(
|
||||
read(
|
||||
&device,
|
||||
&queue,
|
||||
arena.sources(third.0).unwrap(),
|
||||
third.1 / 4 * 8,
|
||||
8
|
||||
),
|
||||
bytemuck::bytes_of(&source)
|
||||
);
|
||||
assert_eq!(
|
||||
read(
|
||||
&device,
|
||||
&queue,
|
||||
arena.sources(first.0).unwrap(),
|
||||
first.1 / 4 * 8,
|
||||
8
|
||||
),
|
||||
[0; 8]
|
||||
);
|
||||
assert_eq!(
|
||||
read(&device, &queue, arena.buffer(third.0), third.1 * 24, 96),
|
||||
bytemuck::cast_slice::<_, u8>(&geometry)
|
||||
);
|
||||
|
||||
// A real requested binding limit refuses metadata/table allocation. The native
|
||||
// vertex allocation still fits; this is a controlled refusal, not a real OOM.
|
||||
let limits = wgpu::Limits {
|
||||
max_storage_buffer_binding_size: 256,
|
||||
..Default::default()
|
||||
};
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
required_limits: limits,
|
||||
..Default::default()
|
||||
}))
|
||||
.unwrap();
|
||||
let mut arena = Arena::translucent(&device, 24, GRAIN);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let mut uploads = super::super::upload::Uploads::new(&device);
|
||||
let at = arena.alloc(&device, &mut encoder, 4).unwrap();
|
||||
uploads.buffer(
|
||||
&mut encoder,
|
||||
arena.buffer(at.0),
|
||||
at.1 * 24,
|
||||
bytemuck::cast_slice(&geometry),
|
||||
);
|
||||
arena.write_sources(
|
||||
&device,
|
||||
&mut encoder,
|
||||
&mut uploads,
|
||||
at.0,
|
||||
at.1,
|
||||
4,
|
||||
&[source],
|
||||
);
|
||||
assert!(
|
||||
arena.sources(at.0).is_none(),
|
||||
"sidecar refused without losing vertices"
|
||||
);
|
||||
assert!(super::super::pack_post::block_ids(&device, &queue, &[0; 128], &[0, 1]).is_none());
|
||||
uploads.finish(&encoder);
|
||||
queue.submit([encoder.finish()]);
|
||||
assert_eq!(
|
||||
read(&device, &queue, arena.buffer(at.0), at.1 * 24, 96),
|
||||
bytemuck::cast_slice::<_, u8>(&geometry)
|
||||
);
|
||||
}
|
||||
@@ -64,8 +64,10 @@ impl Copies {
|
||||
fn copy(mesh: &SectionMesh) -> SectionMesh {
|
||||
SectionMesh {
|
||||
solid: mesh.solid.clone(),
|
||||
solid_sources: mesh.solid_sources.clone(),
|
||||
sides: mesh.sides,
|
||||
translucent: mesh.translucent.clone(),
|
||||
translucent_sources: mesh.translucent_sources.clone(),
|
||||
packed: mesh.packed.clone(),
|
||||
packed_far: mesh.packed_far,
|
||||
solid_tints: Vec::new(),
|
||||
|
||||
@@ -27,11 +27,13 @@
|
||||
//! as many draws as without culling: drivers that unroll an indirect
|
||||
//! multi-draw on the CPU (Mesa's on Intel Gen9) pay per entry. Phase 2's
|
||||
//! counts come from the GPU, which reads them from the draws themselves:
|
||||
//! Vulkan's native multi-draw, and Metal's, which wgpu replays as one
|
||||
//! indirect draw per entry. Translucent draws are not culled.
|
||||
//! Vulkan's native multi-draw, DirectX 12's `ExecuteIndirect`, and Metal's,
|
||||
//! which wgpu replays as one indirect draw per entry. Translucent draws are
|
||||
//! not culled.
|
||||
//!
|
||||
//! Metal cannot force the depth test before a fragment shader that writes
|
||||
//! storage (naga writes no `[[early_fragment_tests]]`), so there the boxes
|
||||
//! storage (naga writes no `[[early_fragment_tests]]`), nor can DirectX 12
|
||||
//! (`[earlydepthstencil]`, which wgpu doesn't offer there), so there the boxes
|
||||
//! are drawn in a pass of their own between the phases, which reads phase
|
||||
//! 1's depth as a texture and makes the same comparison in the shader: the
|
||||
//! fragment's depth against the stored one, greater or equal passing. The
|
||||
@@ -224,9 +226,9 @@ impl Cull {
|
||||
adapter.features() & wgpu::Features::SHADER_EARLY_DEPTH_TEST
|
||||
}
|
||||
/// Where the device can run the test, if it can: Vulkan with early
|
||||
/// depth tests, or Metal, both drawing an indirect multi-draw's entries
|
||||
/// with the instance counts the GPU wrote, with fragment shaders that
|
||||
/// write storage.
|
||||
/// depth tests, or Metal and DirectX 12 with the test in the shader, all
|
||||
/// drawing an indirect multi-draw's entries with the instance counts the
|
||||
/// GPU wrote, with fragment shaders that write storage.
|
||||
pub fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> Option<DepthTest> {
|
||||
let depth_test = match adapter.get_info().backend {
|
||||
wgpu::Backend::Vulkan | wgpu::Backend::Gl
|
||||
@@ -236,7 +238,9 @@ impl Cull {
|
||||
{
|
||||
DepthTest::Early
|
||||
}
|
||||
wgpu::Backend::Metal => DepthTest::InShader,
|
||||
// DirectX 12 has no forced early depth test in wgpu either: the test runs in the
|
||||
// shader there too.
|
||||
wgpu::Backend::Metal | wgpu::Backend::Dx12 => DepthTest::InShader,
|
||||
_ => return None,
|
||||
};
|
||||
(device
|
||||
|
||||
@@ -554,14 +554,22 @@ mod tests {
|
||||
});
|
||||
let view = target.create_view(&Default::default());
|
||||
// Independent scalar expectation: a sky mark of 206/255 gives this gamma gain.
|
||||
let gain = (1.0f32 + 3.0 * (1.0 - 206.0 / 255.0) / 0.24).powf(1.0 / 2.2);
|
||||
for (mode_index, mode, debug) in MODES
|
||||
let hdr_gain = (1.0f32 + 3.0 * (1.0 - 206.0 / 255.0) / 0.24).powf(1.0 / 2.2);
|
||||
for (mode_index, mode, debug, written) in MODES
|
||||
.iter()
|
||||
.copied()
|
||||
.enumerate()
|
||||
.map(|(i, m)| (i, m, false))
|
||||
.chain([(5, Blend::Screen, true)])
|
||||
.map(|(i, m)| (i, m, false, true))
|
||||
.chain([(5, Blend::Screen, true, true)])
|
||||
.chain(
|
||||
MODES
|
||||
.iter()
|
||||
.copied()
|
||||
.enumerate()
|
||||
.map(|(i, m)| (i, m, false, false)),
|
||||
)
|
||||
{
|
||||
let gain = if written { hdr_gain } else { 1.0 };
|
||||
let uniform = Uniform {
|
||||
clip_from_sky: Mat4::IDENTITY.to_cols_array_2d(),
|
||||
brightness: 0.7,
|
||||
@@ -569,7 +577,7 @@ mod tests {
|
||||
pad: [0; 2],
|
||||
hdr_settings: [5.0, 4.0, 1.0, f32::from(u8::from(debug))],
|
||||
hdr_shares: [0.2, 0.5, 0.6, 1.0],
|
||||
hdr_light: [0.8, 0.8, 1.0, 1.0],
|
||||
hdr_light: [0.8, 0.8, 1.0, f32::from(u8::from(written))],
|
||||
};
|
||||
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
@@ -704,6 +712,12 @@ mod tests {
|
||||
let data = readback.get_mapped_range(..).unwrap();
|
||||
let got = half::f16::from_le_bytes([data[0], data[1]]).to_f32();
|
||||
let (d, t, k) = (2.0, 0.8, 128.0 / 255.0 * 0.7);
|
||||
let alpha = half::f16::from_le_bytes([data[6], data[7]]).to_f32();
|
||||
let coverage = if mode == Blend::Alpha { k } else { 1.0 };
|
||||
assert!(
|
||||
(alpha - coverage).abs() < 0.001,
|
||||
"{mode:?}: alpha {alpha}, expected {coverage}"
|
||||
);
|
||||
let expected = if debug {
|
||||
let grey = (0.2126f32 * 0.8f32.powf(2.2)
|
||||
+ 0.7152 * 0.4f32.powf(2.2)
|
||||
@@ -728,6 +742,8 @@ mod tests {
|
||||
"{mode:?} debug {debug}: {got} expected {expected}"
|
||||
);
|
||||
}
|
||||
eprintln!("HDR custom sky GPU ran all nine modes and debug Screen over above-white light");
|
||||
eprintln!(
|
||||
"HDR custom sky GPU ran all nine modes with and without gain, plus debug Screen, checking RGB and coverage"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ use crate::mesher::TerrainVertex;
|
||||
|
||||
/// The solid quads the grid can draw a frame, and the sections they come from.
|
||||
const MAX_QUADS: u64 = 1 << 16;
|
||||
const MAX_SECTIONS: usize = 64;
|
||||
pub(crate) const MAX_SECTIONS: usize = 64;
|
||||
/// Bytes of one quad's corners.
|
||||
const QUAD_BYTES: u64 = 4 * std::mem::size_of::<TerrainVertex>() as u64;
|
||||
/// How far past the flat face the relief may reach, in blocks, for the view test.
|
||||
@@ -413,6 +413,8 @@ pub struct Displacement {
|
||||
pub shadow: Option<Picks>,
|
||||
/// Group 1's layout for the grid's pipelines.
|
||||
pub draw_layout: wgpu::BindGroupLayout,
|
||||
/// CPU-side section table reused between displacement preparation passes.
|
||||
table: Vec<Section>,
|
||||
/// Sections the pick ran over this frame (0: nothing to draw).
|
||||
pub drawn: usize,
|
||||
}
|
||||
@@ -598,6 +600,7 @@ impl Displacement {
|
||||
view,
|
||||
shadow,
|
||||
draw_layout,
|
||||
table: Vec::new(),
|
||||
drawn: 0,
|
||||
}
|
||||
}
|
||||
@@ -610,7 +613,8 @@ impl Displacement {
|
||||
near: &[Near<'_>],
|
||||
view_proj: glam::Mat4,
|
||||
) {
|
||||
let mut table = Vec::with_capacity(near.len().min(MAX_SECTIONS));
|
||||
self.table.clear();
|
||||
self.table.reserve(near.len().min(MAX_SECTIONS));
|
||||
let mut first = 0u64;
|
||||
let mut most = 0u32;
|
||||
for n in near.iter().take(MAX_SECTIONS) {
|
||||
@@ -626,7 +630,7 @@ impl Displacement {
|
||||
first * QUAD_BYTES,
|
||||
quads * QUAD_BYTES,
|
||||
);
|
||||
table.push(Section {
|
||||
self.table.push(Section {
|
||||
origin: [
|
||||
n.origin[0],
|
||||
n.origin[1],
|
||||
@@ -640,11 +644,11 @@ impl Displacement {
|
||||
first += quads;
|
||||
most = most.max(quads as u32);
|
||||
}
|
||||
self.drawn = table.len();
|
||||
if table.is_empty() {
|
||||
self.drawn = self.table.len();
|
||||
if self.table.is_empty() {
|
||||
return;
|
||||
}
|
||||
queue.write_buffer(&self.sections, 0, bytemuck::cast_slice(&table));
|
||||
queue.write_buffer(&self.sections, 0, bytemuck::cast_slice(&self.table));
|
||||
let lists = std::iter::once((&self.view, 0)).chain(self.shadow.iter().map(|s| (s, 1)));
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("Displacement pick"),
|
||||
@@ -655,7 +659,7 @@ impl Displacement {
|
||||
let params = Params {
|
||||
view_proj: view_proj.to_cols_array_2d(),
|
||||
distance: self.distance,
|
||||
sections: table.len() as u32,
|
||||
sections: self.table.len() as u32,
|
||||
margin: RELIEF_MARGIN,
|
||||
capacity: MAX_QUADS as u32,
|
||||
shadow,
|
||||
@@ -665,7 +669,7 @@ impl Displacement {
|
||||
let vertices = grid_vertices(picks.cells, picks.steps);
|
||||
queue.write_buffer(&picks.draw, 0, bytemuck::cast_slice(&[vertices, 0, 0, 0]));
|
||||
pass.set_bind_group(0, &picks.pick_group, &[]);
|
||||
pass.dispatch_workgroups(most.div_ceil(64), table.len() as u32, 1);
|
||||
pass.dispatch_workgroups(most.div_ceil(64), self.table.len() as u32, 1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Which compiler turns DirectX 12's shaders into bytecode: DXC on integrated
|
||||
//! GPUs, FXC on every other adapter (#8513).
|
||||
//!
|
||||
//! Measured on one laptop at render distance 32 (2560x1440, the same world and
|
||||
//! view): DXC took Intel Iris Xe from 44 to 65 FPS (22.1 to 14.7 ms of GPU
|
||||
//! time) and an NVIDIA RTX 3050 Ti from 159 down to 150 FPS. An RX 9070 gained
|
||||
//! about 9 % of GPU time at render distance 32 and lost 0.03 ms at 8. So
|
||||
//! discrete GPUs keep FXC (`d3dcompiler_47.dll`, part of Windows), and only
|
||||
//! integrated ones load DXC (`dxcompiler.dll`, which the Windows x64 zip puts
|
||||
//! beside the game: `tools/ci/fetch_dxc.py`).
|
||||
//!
|
||||
//! wgpu chooses the compiler for a whole instance, before any adapter is
|
||||
//! known. `open_backend` therefore asks for the adapter on an FXC instance and,
|
||||
//! when that adapter is an integrated GPU on DirectX 12, asks again on an
|
||||
//! instance with DXC. That happens once, when the device is opened; nothing
|
||||
//! here runs per frame. Without the DLL, or when it does not load, the game
|
||||
//! keeps the FXC instance and logs one line. `WGPU_DX12_COMPILER` (`fxc`,
|
||||
//! `dxc`, `auto`) overrides the choice: wgpu applies it to every instance.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The DLL's name, as the Windows zip ships it beside `horizon-client.exe`.
|
||||
pub(super) const DLL: &str = "dxcompiler.dll";
|
||||
|
||||
/// Whether `info`, opened on an FXC instance, should be opened again with DXC:
|
||||
/// an integrated GPU on DirectX 12, unless `forced` (`WGPU_DX12_COMPILER` is
|
||||
/// set, so its compiler is already the one every instance uses).
|
||||
pub(super) fn wants_dxc(info: &wgpu::AdapterInfo, forced: bool) -> bool {
|
||||
!forced
|
||||
&& info.backend == wgpu::Backend::Dx12
|
||||
&& info.device_type == wgpu::DeviceType::IntegratedGpu
|
||||
}
|
||||
|
||||
/// Whether `WGPU_DX12_COMPILER` names the compiler.
|
||||
pub(super) fn forced() -> bool {
|
||||
std::env::var_os("WGPU_DX12_COMPILER").is_some()
|
||||
}
|
||||
|
||||
/// `dxcompiler.dll` beside the running executable, if it is there. An absolute
|
||||
/// path, so a copy elsewhere on `PATH` (an older Windows SDK's) is never the
|
||||
/// one loaded.
|
||||
pub(super) fn bundled_dll() -> Option<PathBuf> {
|
||||
let dll = std::env::current_exe().ok()?.parent()?.join(DLL);
|
||||
dll.is_file().then_some(dll)
|
||||
}
|
||||
|
||||
/// The compiler setting that loads `dll`, and fails the instance (rather
|
||||
/// than falling back to FXC on its own) when it doesn't load: `open_backend`
|
||||
/// then keeps the FXC instance it already has.
|
||||
pub(super) fn dxc(dll: &std::path::Path) -> wgpu::Dx12Compiler {
|
||||
wgpu::Dx12Compiler::DynamicDxc {
|
||||
dxc_path: dll.to_string_lossy().into_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the DXC instance offered the adapter the FXC instance had: on a
|
||||
/// laptop with two GPUs, the same request should pick the same one, and the
|
||||
/// game must not switch GPUs because the compiler changed.
|
||||
pub(super) fn same_adapter(fxc: &wgpu::AdapterInfo, dxc: &wgpu::AdapterInfo) -> bool {
|
||||
fxc.backend == dxc.backend
|
||||
&& fxc.vendor == dxc.vendor
|
||||
&& fxc.device == dxc.device
|
||||
&& fxc.device_type == dxc.device_type
|
||||
&& fxc.device_pci_bus_id == dxc.device_pci_bus_id
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn adapter(backend: wgpu::Backend, device_type: wgpu::DeviceType) -> wgpu::AdapterInfo {
|
||||
wgpu::AdapterInfo {
|
||||
name: "Intel(R) Iris(R) Xe Graphics".into(),
|
||||
vendor: 0x8086,
|
||||
device: 0x9a49,
|
||||
device_type,
|
||||
device_pci_bus_id: "0000:00:02.0".into(),
|
||||
driver: String::new(),
|
||||
driver_info: String::new(),
|
||||
backend,
|
||||
subgroup_min_size: 0,
|
||||
subgroup_max_size: 0,
|
||||
transient_saves_memory: None,
|
||||
limit_bucket: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_integrated_gpus_on_directx_12_get_dxc() {
|
||||
use wgpu::{Backend, DeviceType};
|
||||
assert!(wants_dxc(
|
||||
&adapter(Backend::Dx12, DeviceType::IntegratedGpu),
|
||||
false
|
||||
));
|
||||
for kind in [
|
||||
DeviceType::DiscreteGpu,
|
||||
DeviceType::VirtualGpu,
|
||||
DeviceType::Cpu,
|
||||
DeviceType::Other,
|
||||
] {
|
||||
assert!(!wants_dxc(&adapter(Backend::Dx12, kind), false), "{kind:?}");
|
||||
}
|
||||
// Other backends have no shader compiler to choose.
|
||||
for backend in [Backend::Vulkan, Backend::Gl, Backend::Metal] {
|
||||
assert!(
|
||||
!wants_dxc(&adapter(backend, DeviceType::IntegratedGpu), false),
|
||||
"{backend:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wgpu_dx12_compiler_overrides_the_choice() {
|
||||
let igpu = adapter(wgpu::Backend::Dx12, wgpu::DeviceType::IntegratedGpu);
|
||||
assert!(!wants_dxc(&igpu, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_dxc_instance_must_offer_the_same_gpu() {
|
||||
let igpu = adapter(wgpu::Backend::Dx12, wgpu::DeviceType::IntegratedGpu);
|
||||
assert!(same_adapter(&igpu, &igpu.clone()));
|
||||
let dgpu = wgpu::AdapterInfo {
|
||||
name: "NVIDIA GeForce RTX 3050 Ti Laptop GPU".into(),
|
||||
vendor: 0x10de,
|
||||
device: 0x25a0,
|
||||
device_type: wgpu::DeviceType::DiscreteGpu,
|
||||
device_pci_bus_id: "0000:01:00.0".into(),
|
||||
..igpu.clone()
|
||||
};
|
||||
assert!(!same_adapter(&igpu, &dgpu));
|
||||
}
|
||||
}
|
||||
@@ -174,11 +174,22 @@ pub fn wanted(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
|
||||
|
||||
/// Whether frames without a shader pack's terrain draw flat: where the backend
|
||||
/// emulates multi-draw (Metal, past `MIN_SECTIONS`), on Intel Gen11/Gen12's Vulkan
|
||||
/// driver, or with `HORIZON_FLAT_DRAWS=1`. Elsewhere per-section draws stay.
|
||||
/// driver, on Qualcomm's Adreno under DirectX 12, or with `HORIZON_FLAT_DRAWS=1`.
|
||||
/// Elsewhere per-section draws stay.
|
||||
pub fn for_vanilla(info: &wgpu::AdapterInfo) -> bool {
|
||||
std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1")
|
||||
|| info.backend == wgpu::Backend::Metal
|
||||
|| is_broken_intel_vulkan(info)
|
||||
|| is_adreno_dx12(info)
|
||||
}
|
||||
|
||||
/// Qualcomm's Adreno under DirectX 12 (Windows on Snapdragon, where Auto picks DirectX 12),
|
||||
/// where wgpu's indirect multi-draw is `ExecuteIndirect`: drawn flat, an X1-45 took 3-11%
|
||||
/// less GPU time a frame at render distance 12 and 3-13% less at 32 (perf_bench's four
|
||||
/// scenes, held still), from 389 sections in view up, the same picture. DXGI names
|
||||
/// Qualcomm `QCOM` (0x4D4F4351); Vulkan, 0x5143.
|
||||
pub fn is_adreno_dx12(info: &wgpu::AdapterInfo) -> bool {
|
||||
info.backend == wgpu::Backend::Dx12 && matches!(info.vendor, 0x4d4f_4351 | 0x5143)
|
||||
}
|
||||
|
||||
/// Intel Gen11 (Ice Lake) and Gen12 (Tiger Lake, Rocket Lake, Alder Lake,
|
||||
@@ -1044,6 +1055,30 @@ impl Flat {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn adreno_draws_flat_under_dx12_only() {
|
||||
let mut info = wgpu::AdapterInfo {
|
||||
name: "Qualcomm(R) Adreno(TM) X1-45 GPU".into(),
|
||||
vendor: 0x4d4f_4351,
|
||||
device: 0,
|
||||
device_type: wgpu::DeviceType::IntegratedGpu,
|
||||
device_pci_bus_id: String::new(),
|
||||
driver: "31.0.128.0".into(),
|
||||
driver_info: String::new(),
|
||||
backend: wgpu::Backend::Dx12,
|
||||
subgroup_min_size: 0,
|
||||
subgroup_max_size: 0,
|
||||
transient_saves_memory: None,
|
||||
limit_bucket: None,
|
||||
};
|
||||
assert!(is_adreno_dx12(&info));
|
||||
info.backend = wgpu::Backend::Vulkan;
|
||||
assert!(!is_adreno_dx12(&info));
|
||||
info.backend = wgpu::Backend::Dx12;
|
||||
info.vendor = 0x10de;
|
||||
assert!(!is_adreno_dx12(&info));
|
||||
}
|
||||
|
||||
/// Draws as `render` writes them: solid (page, quads at a base vertex)
|
||||
/// first, then translucent (page, sorted page, count, first sorted index,
|
||||
/// base vertex).
|
||||
|
||||
@@ -2735,8 +2735,9 @@ mod tests {
|
||||
|
||||
/// `test` on each device here that draws counts with first instances:
|
||||
/// Vulkan, and DirectX 12 with each of its compilers. Players' DirectX 12
|
||||
/// compiles with FXC, part of Windows: wgpu takes DXC only where
|
||||
/// `dxcompiler.dll` loads, and the game ships none.
|
||||
/// compiles with FXC, part of Windows, and with DXC (`dxcompiler.dll`,
|
||||
/// beside the game in the Windows x64 zip) on integrated GPUs
|
||||
/// (`gfx/dx12_compiler.rs`).
|
||||
fn on_each_device(test: impl Fn(&wgpu::Device, &Queue)) {
|
||||
let mut tested = false;
|
||||
for (backends, compiler, name) in [
|
||||
|
||||
@@ -72,32 +72,28 @@ fn named(name: Option<&str>) -> Option<Output> {
|
||||
struct Targets {
|
||||
size: [u32; 2],
|
||||
frame: wgpu::Texture,
|
||||
/// The world as `keep_world` kept it; on the layered frame, the frame as `resolve` (the
|
||||
/// world and the hand marked it) or `tonemap` found it.
|
||||
/// The world as `keep_world` kept it; also the tone curve's input scratch on a layered frame.
|
||||
world: wgpu::Texture,
|
||||
coverage: Option<wgpu::Texture>,
|
||||
/// The layered frame's HUD target: what the HUD draws on.
|
||||
hud: Option<wgpu::Texture>,
|
||||
group: wgpu::BindGroup,
|
||||
/// The layered frame's passes' bindings: `resolve` (and `tonemap`), `keep_world` and
|
||||
/// `composite`.
|
||||
/// The layered frame's bindings for `tonemap`, `keep_world` and `composite`.
|
||||
passes: Option<[wgpu::BindGroup; 3]>,
|
||||
}
|
||||
|
||||
/// The float frame with HUD coverage (`hud_coverage::supported`, docs/design/hdr-linear-light.md
|
||||
/// "The HUD and menus"): the default world draws write light directly; marked fallback frames
|
||||
/// turn their marks into light after the world and the hand (`resolve`); the tone curve eases it into the peak after the post effects (`tonemap`); the
|
||||
/// "The HUD and menus"): the default world draws write light directly; the tone curve eases
|
||||
/// it into the peak after the post effects (`tonemap`); the
|
||||
/// camera overlays draw on that light; the HUD draws on a target of its own,
|
||||
/// the world clipped to white (`keep_world`); and the last pass composites the HUD over the
|
||||
/// world's light by its coverage (`fs_hdr_layered`), as the menu blur does before it blurs
|
||||
/// (`composite`).
|
||||
struct Layered {
|
||||
resolve: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
|
||||
/// Reads what `resolve` reads.
|
||||
tonemap: wgpu::RenderPipeline,
|
||||
tonemap: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
|
||||
keep: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
|
||||
composite: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
|
||||
/// What `resolve` and `composite` read, written before they run (`begin`).
|
||||
/// What `tonemap` and `composite` read, written before they run (`begin`).
|
||||
params: wgpu::Buffer,
|
||||
}
|
||||
|
||||
@@ -337,10 +333,8 @@ impl Hdr {
|
||||
let pipeline = pass(label, &layout, entry, format);
|
||||
(layout, pipeline)
|
||||
};
|
||||
let resolve = with("Horizon HDR marks to light", &[1], true, "fs_resolve");
|
||||
Layered {
|
||||
tonemap: pass("Horizon HDR tone curve", &resolve.0, "fs_tonemap", format),
|
||||
resolve,
|
||||
tonemap: with("Horizon HDR tone curve", &[1], true, "fs_tonemap"),
|
||||
keep: with("Horizon HDR HUD backdrop", &[0], false, "fs_keep"),
|
||||
composite: with("Horizon HDR composite", &[0, 4, 5], true, "fs_composite"),
|
||||
params: params(),
|
||||
@@ -492,7 +486,7 @@ impl Hdr {
|
||||
})
|
||||
};
|
||||
[
|
||||
group(&l.resolve.0, &[entry(1, &world), params.clone()]),
|
||||
group(&l.tonemap.0, &[entry(1, &world), params.clone()]),
|
||||
group(&l.keep.0, &[entry(0, &frame)]),
|
||||
group(
|
||||
&l.composite.0,
|
||||
@@ -529,7 +523,7 @@ impl Hdr {
|
||||
self.targets.as_ref()?.hud.as_ref()
|
||||
}
|
||||
|
||||
/// On the layered frame, before `resolve` and `composite`: what they read, for a frame that
|
||||
/// On the layered frame, before `tonemap` and `composite`: what they read, for a frame that
|
||||
/// drew a world (`world`) or not.
|
||||
pub fn begin(&self, queue: &wgpu::Queue, world: bool) {
|
||||
if let Some(l) = &self.layered {
|
||||
@@ -541,34 +535,12 @@ impl Hdr {
|
||||
}
|
||||
}
|
||||
|
||||
/// On the layered frame, after the world and the hand drew and marked it: the marks
|
||||
/// become light in the frame (`fs_resolve`).
|
||||
pub fn resolve(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
self.redraw(encoder, |l| &l.resolve.1, "HDR marks to light", timing);
|
||||
}
|
||||
|
||||
/// On the layered frame, after the post effects and before the camera overlays: the tone
|
||||
/// curve eases the light into the peak (`fs_tonemap`).
|
||||
pub fn tonemap(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
self.redraw(encoder, |l| &l.tonemap, "HDR tone curve", timing);
|
||||
}
|
||||
|
||||
/// The layered frame drawn again by `pipeline`, which reads it from a copy in the world
|
||||
/// target.
|
||||
fn redraw(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
pipeline: impl Fn(&Layered) -> &wgpu::RenderPipeline,
|
||||
label: &'static str,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let (Some(l), Some(t)) = (&self.layered, &self.targets) else {
|
||||
return;
|
||||
@@ -582,7 +554,14 @@ impl Hdr {
|
||||
t.frame.size(),
|
||||
);
|
||||
let view = t.frame.create_view(&Default::default());
|
||||
Self::draw(encoder, pipeline(l), group, &view, label, timing);
|
||||
Self::draw(
|
||||
encoder,
|
||||
&l.tonemap.1,
|
||||
group,
|
||||
&view,
|
||||
"HDR tone curve",
|
||||
timing,
|
||||
);
|
||||
}
|
||||
|
||||
/// On the layered frame: the picture so far (`fs_composite`), as the frame stores light,
|
||||
@@ -981,6 +960,10 @@ var<private> params: ReferenceParams;
|
||||
let actual = hdr_mark_light(color, mark, shows, eased,
|
||||
vec4(params.peak, params.scene, params.world, params.debug), params.share);
|
||||
errors[i] = select(0u, 1u, any(abs(expected.light - actual.light) > vec3(1e-6)) || expected.lifted != actual.lifted);
|
||||
// Gain stays above the tone knee until the separate tone curve runs.
|
||||
let gain = 1.0 + 3.0 * (1.0 - 194.0 / 255.0) / 0.24;
|
||||
let white = hdr_written(vec3(1.0), 0.76, vec4(5.0, 4.0, 1.0, 0.0), params.share);
|
||||
errors[i] |= select(0u, 4u, any(abs(pow(white, vec3(2.2)) - vec3(gain)) > vec3(1e-5)));
|
||||
let boundary = (f32(i % 255u) + 0.5) / 255.0;
|
||||
for (var step = -1; step <= 1; step++) {
|
||||
let stored = unpack2x16float(pack2x16float(vec2(boundary + f32(step) / 65536.0))).x;
|
||||
@@ -1133,7 +1116,14 @@ var<private> params: ReferenceParams;
|
||||
pub(super) fn gpu() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
eprintln!("HDR GPU {:?}", adapter.get_info());
|
||||
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
required_features: adapter.features()
|
||||
& (wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
|
||||
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES),
|
||||
..Default::default()
|
||||
}))
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Clears all of `texture` to `color`.
|
||||
@@ -1193,37 +1183,147 @@ var<private> params: ReferenceParams;
|
||||
std::array::from_fn(|i| half::f16::from_le_bytes([bytes[2 * i], bytes[2 * i + 1]]).to_f32())
|
||||
}
|
||||
|
||||
// Independent pre-refactor light arithmetic applied to a compatibility attachment.
|
||||
// Float targets in these tests only receive the resulting colour and coverage.
|
||||
pub(super) fn legacy_light(
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
hdr: &Hdr,
|
||||
source: &wgpu::Texture,
|
||||
target: &wgpu::Texture,
|
||||
) {
|
||||
use wgpu::util::DeviceExt;
|
||||
assert_eq!(source.format(), F::Rgba16Unorm);
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("legacy non-float light oracle"),
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
(r#"
|
||||
struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: vec4<f32> }
|
||||
@group(0) @binding(0) var<uniform> params: ReferenceParams;
|
||||
@group(0) @binding(1) var source: texture_2d<f32>;
|
||||
"#
|
||||
.to_owned()
|
||||
+ include_str!("hdr_light_reference.txt")
|
||||
+ r#"
|
||||
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
||||
return vec4(vec2(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0);
|
||||
}
|
||||
@fragment fn fs() -> @location(0) vec4<f32> {
|
||||
let c = textureLoad(source, vec2(0), 0);
|
||||
let mark = round(saturate(c.a) * 255.0) / 255.0;
|
||||
let light = reference_lit(c.rgb, mark, params.world > 0.5 && params.peak > 1.0, false).light;
|
||||
return vec4(pow(light, vec3(1.0 / 2.2)), 1.0);
|
||||
}
|
||||
"#)
|
||||
.into(),
|
||||
),
|
||||
});
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(F::Rgba16Float.into())],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let params = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytemuck::cast_slice(&[settings, shares]),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let view = source.create_view(&Default::default());
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: params.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(&view),
|
||||
},
|
||||
],
|
||||
});
|
||||
Hdr::draw(
|
||||
encoder,
|
||||
&pipeline,
|
||||
&group,
|
||||
&target.create_view(&Default::default()),
|
||||
"legacy oracle",
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_layered_frame_shows_the_world_as_the_last_pass_lifted_it() {
|
||||
fn direct_light_matches_the_legacy_non_float_display_below_the_tone_knee() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
return;
|
||||
};
|
||||
// What the last pass shows of a world pixel nothing covers, from today's frame
|
||||
// (`layered` false) or the layered one, whose marks turn into light before it, then
|
||||
// pass the tone curve.
|
||||
if !device.features().contains(
|
||||
wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
|
||||
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES,
|
||||
) {
|
||||
eprintln!("legacy display GPU skipped: 16-bit UNORM unavailable");
|
||||
return;
|
||||
}
|
||||
// Compare the compatibility display lift with direct colour under the layered HUD.
|
||||
// The frozen oracle supplies the writer's light from a non-float attachment.
|
||||
let shown = |layered: bool, world: [f64; 4]| {
|
||||
let mut hdr =
|
||||
Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, layered, None);
|
||||
let mut hdr = Hdr::with_debug(
|
||||
&device,
|
||||
&queue,
|
||||
if layered {
|
||||
F::Rgba16Float
|
||||
} else {
|
||||
F::Rgba16Unorm
|
||||
},
|
||||
Output::Pq,
|
||||
layered,
|
||||
None,
|
||||
);
|
||||
let frame = hdr.frame(&device, [1, 1]);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
fill(&mut encoder, &frame, world);
|
||||
if layered {
|
||||
let mut legacy =
|
||||
Hdr::with_debug(&device, &queue, F::Rgba16Unorm, Output::Pq, false, None);
|
||||
let marked = legacy.frame(&device, [1, 1]);
|
||||
fill(&mut encoder, &marked, world);
|
||||
if world[3] == 1.0 {
|
||||
fill(&mut encoder, &frame, world);
|
||||
} else {
|
||||
legacy_light(&device, &mut encoder, &hdr, &marked, &frame);
|
||||
}
|
||||
hdr.begin(&queue, true);
|
||||
hdr.resolve(&mut encoder, None);
|
||||
hdr.tonemap(&mut encoder, None);
|
||||
hdr.keep_world(&mut encoder, None);
|
||||
super::super::hud_coverage::clear(&mut encoder, &hdr.coverage_view().unwrap());
|
||||
} else {
|
||||
fill(&mut encoder, &frame, world);
|
||||
hdr.keep_world(&mut encoder, None);
|
||||
}
|
||||
let capture = hdr.capture(&device, &queue, &mut encoder, true).unwrap();
|
||||
texel(&device, &queue, encoder, &capture)
|
||||
};
|
||||
// Unmarked, below white and above it; HDR lighting; light sources of each tier: all up to
|
||||
// Unmarked, HDR lighting and light sources of each tier: all up to
|
||||
// the tone curve's knee, which the curve leaves as they are.
|
||||
for world in [
|
||||
[0.5, 0.4, 0.3, 1.0],
|
||||
[1.5, 1.2, 0.6, 1.0],
|
||||
[0.8, 0.7, 0.6, 0.88],
|
||||
[0.9, 0.8, 0.4, 0.05],
|
||||
[0.9, 0.8, 0.4, 0.2],
|
||||
@@ -1240,6 +1340,10 @@ var<private> params: ReferenceParams;
|
||||
assert!(layered[0] > shown(true, [world[0], world[1], world[2], 1.0])[0]);
|
||||
}
|
||||
}
|
||||
// Above-white unmarked colour belongs to float storage, not the UNORM oracle.
|
||||
let above = shown(true, [1.5, 1.2, 0.6, 1.0]);
|
||||
let white = shown(true, [1.0, 1.0, 0.6, 1.0]);
|
||||
assert!(above[0] > white[0] && above[1] > white[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1306,13 +1410,6 @@ var<private> params: ReferenceParams;
|
||||
hdr.peak = 150.0;
|
||||
let [r, g, b] = eased(&mut hdr, [1.5, 0.5, 0.2]);
|
||||
assert!(r == 1.0 && near(g, 0.5) && near(b, 0.2), "{r} {g} {b}");
|
||||
// HDR lighting's gain stays past the knee until the curve: the marks become light
|
||||
// without easing.
|
||||
hdr.peak = 1000.0;
|
||||
let resolve = |hdr: &Hdr, encoder: &mut wgpu::CommandEncoder| hdr.resolve(encoder, None);
|
||||
let [r, ..] = after(&mut hdr, [1.0, 1.0, 1.0, 0.76], &resolve);
|
||||
let gain = 1.0 + 3.0 * (1.0 - 194.0 / 255.0) / 0.24;
|
||||
assert!(near(r.powf(2.2), gain), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{
|
||||
Hdr, Output,
|
||||
tests::{gpu, texel},
|
||||
tests::{gpu, legacy_light, texel},
|
||||
};
|
||||
use wgpu::TextureFormat as F;
|
||||
use wgpu::util::DeviceExt;
|
||||
@@ -46,6 +46,29 @@ override TEST_OVERLAY: u32 = 0u;
|
||||
}
|
||||
"#;
|
||||
|
||||
const TERRAIN_EXTRA_VERTICES: &str = r#"
|
||||
@vertex fn test_model(@builtin(vertex_index) i: u32) -> NativeModelOut {
|
||||
let p = vec2(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
var o: NativeModelOut;
|
||||
o.clip = vec4(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
o.uv = vec2(0.5);
|
||||
o.color = vec4(0.8, 0.9, 0.7, 1.0);
|
||||
return o;
|
||||
}
|
||||
@vertex fn test_fluid(@builtin(vertex_index) i: u32) -> NativeFluidOut {
|
||||
let p = vec2(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
var o: NativeFluidOut;
|
||||
o.clip = vec4(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
o.uv = vec2(0.5);
|
||||
o.color = vec4(0.8, 0.9, 0.7, 1.0);
|
||||
return o;
|
||||
}
|
||||
@vertex fn test_sunrise(@builtin(vertex_index) i: u32) -> SunriseOut {
|
||||
let p = vec2(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
return SunriseOut(vec4(p * 2.0 - 1.0, 0.0, 1.0), 0.5);
|
||||
}
|
||||
"#;
|
||||
|
||||
struct Draw<'a> {
|
||||
device: &'a wgpu::Device,
|
||||
queue: &'a wgpu::Queue,
|
||||
@@ -54,6 +77,9 @@ struct Draw<'a> {
|
||||
layout: wgpu::PipelineLayout,
|
||||
bindings: wgpu::BindGroupLayout,
|
||||
group: wgpu::BindGroup,
|
||||
raw: bool,
|
||||
hdr_lighting: bool,
|
||||
material_frame: Option<super::super::native_post::NativeFrame>,
|
||||
}
|
||||
impl<'a> Draw<'a> {
|
||||
fn render_oit(
|
||||
@@ -316,7 +342,7 @@ impl<'a> Draw<'a> {
|
||||
alpha: u8,
|
||||
) -> Self {
|
||||
let source = if terrain {
|
||||
super::super::terrain_shader() + TERRAIN_VERTEX
|
||||
super::super::terrain_shader() + TERRAIN_VERTEX + TERRAIN_EXTRA_VERTICES
|
||||
} else {
|
||||
super::super::entity_shader() + ENTITY_VERTEX
|
||||
};
|
||||
@@ -432,8 +458,43 @@ impl<'a> Draw<'a> {
|
||||
layout,
|
||||
bindings,
|
||||
group,
|
||||
raw: false,
|
||||
hdr_lighting: true,
|
||||
material_frame: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn material(&mut self, api: horizon_shader_api::Api) {
|
||||
let source = super::super::native_material::coverage_test_shader(api);
|
||||
self.shader = self
|
||||
.device
|
||||
.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("actual assembled material wrappers"),
|
||||
source: wgpu::ShaderSource::Wgsl(source.into()),
|
||||
});
|
||||
let frame = super::super::native_post::NativeFrame::new(
|
||||
self.device,
|
||||
None,
|
||||
Vec::new(),
|
||||
Default::default(),
|
||||
None,
|
||||
api,
|
||||
);
|
||||
self.queue.write_buffer(
|
||||
&frame.buffer,
|
||||
0,
|
||||
bytemuck::bytes_of(&horizon_shader_api::Frame::default()),
|
||||
);
|
||||
self.layout = self
|
||||
.device
|
||||
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: None,
|
||||
bind_group_layouts: &[Some(&self.bindings), None, None, Some(&frame.layout)],
|
||||
..Default::default()
|
||||
});
|
||||
self.material_frame = Some(frame);
|
||||
self.raw = true;
|
||||
}
|
||||
fn render(
|
||||
&self,
|
||||
hdr: &mut Hdr,
|
||||
@@ -443,11 +504,50 @@ impl<'a> Draw<'a> {
|
||||
overlay: u32,
|
||||
background: [f64; 4],
|
||||
) -> [f32; 4] {
|
||||
let constants = [
|
||||
("HDR_WRITE_LIGHT", f64::from(u8::from(written))),
|
||||
let mut constants = vec![
|
||||
(
|
||||
if self.material_frame.is_some() {
|
||||
"horizon_terrain_HDR_WRITE_LIGHT"
|
||||
} else {
|
||||
"HDR_WRITE_LIGHT"
|
||||
},
|
||||
f64::from(u8::from(written)),
|
||||
),
|
||||
("TEST_TIER", f64::from(tier)),
|
||||
("TEST_OVERLAY", f64::from(overlay)),
|
||||
];
|
||||
if self.terrain {
|
||||
constants.push((
|
||||
if self.material_frame.is_some() {
|
||||
"horizon_terrain_HDR_LIGHTING"
|
||||
} else {
|
||||
"HDR_LIGHTING"
|
||||
},
|
||||
f64::from(u8::from(self.hdr_lighting)),
|
||||
));
|
||||
}
|
||||
if self.material_frame.is_some() {
|
||||
constants.push((
|
||||
"horizon_displace_distance",
|
||||
if entry == "horizon_material_fs_displaced" {
|
||||
10.0
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
));
|
||||
}
|
||||
let vertex = match entry {
|
||||
"fs_sky" | "fs_sky_base" => "vs_sky",
|
||||
"fs_sunrise" => "test_sunrise",
|
||||
"fs_native_model" => "test_model",
|
||||
"fs_native_fluid" => "test_fluid",
|
||||
_ => "test_vertex",
|
||||
};
|
||||
let format = if !written && !self.raw {
|
||||
F::Rgba16Unorm
|
||||
} else {
|
||||
F::Rgba16Float
|
||||
};
|
||||
let pipeline = |entry, mark| {
|
||||
self.device
|
||||
.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
@@ -455,7 +555,7 @@ impl<'a> Draw<'a> {
|
||||
layout: Some(&self.layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &self.shader,
|
||||
entry_point: Some("test_vertex"),
|
||||
entry_point: Some(vertex),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &constants,
|
||||
..Default::default()
|
||||
@@ -473,12 +573,14 @@ impl<'a> Draw<'a> {
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: F::Rgba16Float,
|
||||
blend: Some(if mark || (self.terrain && entry != "fs_translucent") {
|
||||
wgpu::BlendState::REPLACE
|
||||
} else {
|
||||
wgpu::BlendState::ALPHA_BLENDING
|
||||
}),
|
||||
format,
|
||||
blend: Some(
|
||||
if self.raw || mark || (self.terrain && entry != "fs_translucent") {
|
||||
wgpu::BlendState::REPLACE
|
||||
} else {
|
||||
wgpu::BlendState::ALPHA_BLENDING
|
||||
},
|
||||
),
|
||||
write_mask: if mark {
|
||||
wgpu::ColorWrites::ALPHA
|
||||
} else {
|
||||
@@ -491,7 +593,7 @@ impl<'a> Draw<'a> {
|
||||
})
|
||||
};
|
||||
let color = pipeline(entry, false);
|
||||
let mark = (!self.terrain && !written && entry == "fs_entity").then(|| {
|
||||
let mark = (!self.raw && !self.terrain && !written && entry == "fs_entity").then(|| {
|
||||
pipeline(
|
||||
if overlay == 4 {
|
||||
"fs_hdr_cloud_mark"
|
||||
@@ -501,7 +603,22 @@ impl<'a> Draw<'a> {
|
||||
true,
|
||||
)
|
||||
});
|
||||
let frame = hdr.frame(self.device, [1, 1]);
|
||||
let frame = self.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,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = frame.create_view(&Default::default());
|
||||
let mut encoder = self.device.create_command_encoder(&Default::default());
|
||||
{
|
||||
@@ -519,6 +636,9 @@ impl<'a> Draw<'a> {
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
if let Some(frame) = &self.material_frame {
|
||||
pass.set_bind_group(3, &frame.group, &[]);
|
||||
}
|
||||
pass.set_pipeline(&color);
|
||||
pass.draw(0..3, 0..1);
|
||||
if let Some(mark) = &mark {
|
||||
@@ -526,20 +646,266 @@ impl<'a> Draw<'a> {
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
if !written {
|
||||
hdr.begin(self.queue, true);
|
||||
hdr.resolve(&mut encoder, None);
|
||||
if !written && !self.raw {
|
||||
let light = hdr.frame(self.device, [1, 1]);
|
||||
legacy_light(self.device, &mut encoder, hdr, &frame, &light);
|
||||
return texel(self.device, self.queue, encoder, &light);
|
||||
}
|
||||
texel(self.device, self.queue, encoder, &frame)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_opaque_writers_match_marks_then_resolve() {
|
||||
fn float_plain_fragments_keep_rgb_and_write_only_coverage() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
eprintln!("plain float GPU unavailable");
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let mut cases = 0;
|
||||
for hdr_on in [false, true] {
|
||||
let globals = super::super::Globals {
|
||||
inverse_view: glam::Mat4::IDENTITY.to_cols_array_2d(),
|
||||
fog_color: [0.2, 0.3, 0.4, 1.0],
|
||||
sky_color: [0.7, 0.8, 0.9, 1.0],
|
||||
sunrise: [1.2, 0.7, 0.4, 0.6],
|
||||
fog: [0.0, 100.0, 0.0, 100.0],
|
||||
fog_ends: [100.0, 100.0, 0.0, 0.0],
|
||||
misc: [0.0, 1.0, 0.0, 0.0],
|
||||
filtering: [0.0, 1.0, 0.0, 0.0],
|
||||
hdr_windows: hdr.windows(),
|
||||
hdr_light: [0.8, 0.8, f32::from(u8::from(hdr_on)), 0.0],
|
||||
hdr_settings: settings,
|
||||
hdr_shares: shares,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
for terrain in [true, false] {
|
||||
let mut draw = Draw::new(&device, &queue, terrain, &globals, 180);
|
||||
draw.raw = true;
|
||||
let coverage = 180.0 / 255.0;
|
||||
let entries = if terrain {
|
||||
vec![
|
||||
("fs_opaque", 1.0),
|
||||
("fs_solid", 1.0),
|
||||
("fs_solid_mirror", 1.0),
|
||||
("fs_translucent", coverage),
|
||||
("fs_native_model", 1.0),
|
||||
("fs_native_fluid", coverage),
|
||||
("fs_sky", 1.0),
|
||||
("fs_sky_base", 1.0),
|
||||
("fs_sunrise", 0.3),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
("fs_entity", coverage),
|
||||
("fs_celestial", coverage),
|
||||
("fs_sky_celestial", coverage),
|
||||
]
|
||||
};
|
||||
for plain in [false, true] {
|
||||
draw.hdr_lighting = !plain;
|
||||
for &(entry, alpha) in &entries {
|
||||
for tier in [0, 3, 4] {
|
||||
for overlay in if terrain { vec![0, 1] } else { vec![0, 6] } {
|
||||
let old = draw.render(&mut hdr, false, entry, tier, overlay, [0.0; 4]);
|
||||
let float = draw.render(&mut hdr, true, entry, tier, overlay, [0.0; 4]);
|
||||
assert_eq!(
|
||||
&float[..3],
|
||||
&old[..3],
|
||||
"{hdr_on} {entry} {tier} {overlay} {plain}"
|
||||
);
|
||||
assert!(
|
||||
(float[3] - alpha).abs() < 0.001,
|
||||
"{entry}: {float:?}, expected alpha {alpha}"
|
||||
);
|
||||
assert!(
|
||||
float[..3].iter().any(|&c| c > 0.0),
|
||||
"empty fixture: {entry}"
|
||||
);
|
||||
cases += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("plain float GPU ran {cases} colour/coverage comparisons");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_stars_keep_the_original_rgb_with_brightness_as_coverage() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
// Frozen f030d9c80 fragment: the original written branch and its marked fallback.
|
||||
let source = include_str!("../hdr_light.wgsl").to_owned()
|
||||
+ include_str!("stars.wgsl")
|
||||
+ r#"
|
||||
@vertex fn fixture(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
||||
return vec4(vec2(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0);
|
||||
}
|
||||
@fragment fn reference() -> @location(0) vec4<f32> {
|
||||
let b = stars.color.a;
|
||||
if HDR_WRITE_LIGHT && stars.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_shares), b);
|
||||
}
|
||||
return vec4(stars.color.rgb * b, 0.175);
|
||||
}
|
||||
"#;
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("actual stars with original RGB reference"),
|
||||
source: wgpu::ShaderSource::Wgsl(source.into()),
|
||||
});
|
||||
for gain in [0.0, 1.0] {
|
||||
let mut data = [0.0_f32; 32];
|
||||
data[16..20].copy_from_slice(&[0.7, 0.8, 0.9, 0.35]);
|
||||
data[20..24].copy_from_slice(&settings);
|
||||
data[24..28].copy_from_slice(&shares);
|
||||
data[31] = gain;
|
||||
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytemuck::cast_slice(&data),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let mut draw = |entry| {
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("fixture"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some(entry),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &[("HDR_WRITE_LIGHT", 1.0)],
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: F::Rgba16Float,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: uniform.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
let frame = hdr.frame(&device, [1, 1]);
|
||||
let view = frame.create_view(&Default::default());
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
texel(&device, &queue, encoder, &frame)
|
||||
};
|
||||
let old = draw("reference");
|
||||
let float = draw("fs_stars");
|
||||
assert_eq!(&float[..3], &old[..3]);
|
||||
assert!((float[3] - 0.35).abs() < 0.001, "{float:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_material_wrappers_keep_rgb_and_coverage_including_displacement() {
|
||||
let instance = super::super::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
// The real material layout binds the terrain arrays and the pack's frame together.
|
||||
if adapter.limits().max_sampled_textures_per_shader_stage < 19 {
|
||||
return;
|
||||
}
|
||||
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
required_limits: wgpu::Limits {
|
||||
max_sampled_textures_per_shader_stage: 19,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})) else {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let globals = super::super::Globals {
|
||||
filtering: [0.0, 1.0, 0.0, 0.0],
|
||||
hdr_windows: hdr.windows(),
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
for api in [horizon_shader_api::Api::V0_1, horizon_shader_api::Api::V0_2] {
|
||||
let mut draw = Draw::new(&device, &queue, true, &globals, 255);
|
||||
draw.material(api);
|
||||
for entry in [
|
||||
"horizon_material_fs_solid",
|
||||
"horizon_material_fs_displaced",
|
||||
"horizon_material_fs_translucent",
|
||||
] {
|
||||
let old = draw.render(&mut hdr, false, entry, 3, 0, [0.0; 4]);
|
||||
let float = draw.render(&mut hdr, true, entry, 3, 0, [0.0; 4]);
|
||||
assert_eq!(&float[..3], &old[..3], "{api} {entry}");
|
||||
let alpha = if entry.ends_with("translucent") {
|
||||
0.7
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
assert!((float[3] - alpha).abs() < 0.001, "{api} {entry}: {float:?}");
|
||||
assert!(
|
||||
float[0] > 1.0,
|
||||
"{api} {entry}: clipping or an empty fixture: {float:?}"
|
||||
);
|
||||
if alpha == 1.0 {
|
||||
assert_ne!(
|
||||
old[3], float[3],
|
||||
"the legacy source mark must distinguish the paths"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("material GPU ran both API lines through solid, displaced and translucent wrappers");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_opaque_writers_match_the_legacy_non_float_oracle() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
eprintln!("HDR draw GPU unavailable");
|
||||
return;
|
||||
};
|
||||
if !device.features().contains(
|
||||
wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
|
||||
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES,
|
||||
) {
|
||||
eprintln!("opaque oracle GPU skipped: 16-bit UNORM unavailable");
|
||||
return;
|
||||
}
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
(hdr.paper_white, hdr.peak) = (203.0, 1000.0);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
@@ -576,8 +942,8 @@ fn actual_opaque_writers_match_marks_then_resolve() {
|
||||
draw.render(&mut hdr, false, entry, tier, overlay, [0.0; 4]);
|
||||
let written =
|
||||
draw.render(&mut hdr, true, entry, tier, overlay, [0.0; 4]);
|
||||
// The marked path stores half floats before conversion; the written
|
||||
// one stores after it. Allow two half-float ULPs in gamma light.
|
||||
// The oracle stores UNORM before conversion; the direct writer
|
||||
// stores half floats after it. Allow two half-float ULPs in gamma light.
|
||||
for i in 0..3 {
|
||||
assert!(
|
||||
(marked[i] - written[i]).abs() <= 0.002 * marked[i].max(1.0),
|
||||
@@ -630,6 +996,10 @@ fn actual_translucent_writers_blend_their_own_light() {
|
||||
let dst = [2.5, 1.8, 0.9, 1.0];
|
||||
let got = draw.render(&mut hdr, true, entry, tier, overlay, dst);
|
||||
let alpha = f32::from(texel_alpha) / 255.0 * tint_alpha;
|
||||
assert!(
|
||||
(src[3] - alpha).abs() < 0.001,
|
||||
"{entry}: {src:?}, coverage {alpha}"
|
||||
);
|
||||
if terrain {
|
||||
// Full block light leaves mark 194/255 whatever the texel's alpha, even when
|
||||
// vertex coverage is halved.
|
||||
@@ -657,6 +1027,13 @@ fn unmarked_entity_users_keep_their_source_gain() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
return;
|
||||
};
|
||||
if !device.features().contains(
|
||||
wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
|
||||
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES,
|
||||
) {
|
||||
eprintln!("legacy oracle GPU skipped: 16-bit UNORM unavailable");
|
||||
return;
|
||||
}
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let globals = super::super::Globals {
|
||||
@@ -694,6 +1071,13 @@ fn additive_texels_below_the_mark_cutoff_keep_their_gain() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
return;
|
||||
};
|
||||
if !device.features().contains(
|
||||
wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
|
||||
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES,
|
||||
) {
|
||||
eprintln!("legacy oracle GPU skipped: 16-bit UNORM unavailable");
|
||||
return;
|
||||
}
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let globals = super::super::Globals {
|
||||
|
||||
@@ -3,17 +3,11 @@
|
||||
pub(crate) const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::R8Unorm;
|
||||
|
||||
pub(crate) fn supported(
|
||||
lane: (bool, bool, bool),
|
||||
hdr_offered: bool,
|
||||
attachments: u32,
|
||||
flags: wgpu::DownlevelFlags,
|
||||
format: wgpu::TextureFormatFeatures,
|
||||
) -> bool {
|
||||
!lane.0
|
||||
&& lane.1
|
||||
&& lane.2
|
||||
&& hdr_offered
|
||||
&& attachments >= 2
|
||||
attachments >= 2
|
||||
&& flags.contains(wgpu::DownlevelFlags::INDEPENDENT_BLEND)
|
||||
&& format
|
||||
.allowed_usages
|
||||
@@ -345,37 +339,17 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn coverage_requires_the_no_pack_float_hdr_lane_and_independent_r8_blending() {
|
||||
fn coverage_requires_two_attachments_and_independent_r8_blending() {
|
||||
let flags = wgpu::DownlevelFlags::INDEPENDENT_BLEND;
|
||||
let format = wgpu::TextureFormatFeatures {
|
||||
allowed_usages: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
flags: wgpu::TextureFormatFeatureFlags::BLENDABLE,
|
||||
};
|
||||
let lane = (false, true, true);
|
||||
assert!(supported(lane, true, 2, flags, format));
|
||||
// Switch unset (including legacy 16-bit HDR and 0.1 packs), HDR off, a 0.2
|
||||
// pack's float frame, and unsupported devices never allocate coverage.
|
||||
for other in [
|
||||
(false, false, true),
|
||||
(true, false, true),
|
||||
(true, true, true),
|
||||
(false, true, false),
|
||||
] {
|
||||
assert!(!supported(other, true, 2, flags, format));
|
||||
}
|
||||
assert!(!supported(lane, false, 2, flags, format));
|
||||
assert!(!supported(lane, true, 1, flags, format));
|
||||
assert!(supported(2, flags, format));
|
||||
assert!(!supported(1, flags, format));
|
||||
assert!(!supported(2, wgpu::DownlevelFlags::empty(), format));
|
||||
assert!(!supported(
|
||||
lane,
|
||||
true,
|
||||
2,
|
||||
wgpu::DownlevelFlags::empty(),
|
||||
format
|
||||
));
|
||||
assert!(!supported(
|
||||
lane,
|
||||
true,
|
||||
2,
|
||||
flags,
|
||||
wgpu::TextureFormatFeatures {
|
||||
@@ -384,8 +358,6 @@ mod tests {
|
||||
}
|
||||
));
|
||||
assert!(!supported(
|
||||
lane,
|
||||
true,
|
||||
2,
|
||||
flags,
|
||||
wgpu::TextureFormatFeatures {
|
||||
@@ -393,6 +365,14 @@ mod tests {
|
||||
..format
|
||||
}
|
||||
));
|
||||
assert!(!supported(
|
||||
2,
|
||||
flags,
|
||||
wgpu::TextureFormatFeatures {
|
||||
allowed_usages: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
..format
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -149,6 +149,21 @@ fn composed() -> Vec<(&'static str, Vec<&'static str>, String)> {
|
||||
vec!["terrain.wgsl", "visbuffer.wgsl"],
|
||||
super::terrain_shader() + include_str!("../visbuffer.wgsl"),
|
||||
),
|
||||
// A native mod's world warp (gfx/world_warp.rs): its copies with the plainest warp.
|
||||
(
|
||||
"world warp terrain",
|
||||
vec![
|
||||
"terrain.wgsl",
|
||||
"world_warp.wgsl",
|
||||
"world_warp_fragment.wgsl",
|
||||
],
|
||||
super::world_warp::terrain_shader(super::world_warp::IDENTITY).unwrap(),
|
||||
),
|
||||
(
|
||||
"world warp entities",
|
||||
vec!["entity.wgsl", "static_entity.wgsl", "world_warp.wgsl"],
|
||||
super::world_warp::entity_shader(super::world_warp::IDENTITY).unwrap(),
|
||||
),
|
||||
(
|
||||
"improved transparency world border",
|
||||
vec!["border.wgsl", "oit.wgsl", "oit_border.wgsl"],
|
||||
|
||||
@@ -50,8 +50,10 @@ impl Pending {
|
||||
}
|
||||
}
|
||||
|
||||
/// Small, its meshes boxed: `draw_frame` walks every tile each frame (thousands at long far
|
||||
/// distances), and that walk is bound by the memory it reads.
|
||||
pub(super) struct Tile {
|
||||
pub mesh: SectionGpu,
|
||||
pub mesh: Box<SectionGpu>,
|
||||
bounds: Bounds,
|
||||
/// When it began to fade in; none while the rest of its job uploads.
|
||||
shown: Option<Instant>,
|
||||
@@ -62,7 +64,7 @@ pub(super) struct Tile {
|
||||
/// Its mesh before near chunks took some of its columns, drawn in its
|
||||
/// place until they have faded in (from the job's end, the instant), so
|
||||
/// no hole shows through them.
|
||||
previous: Option<(SectionGpu, Option<Instant>)>,
|
||||
previous: Option<Box<(SectionGpu, Option<Instant>)>>,
|
||||
/// Its slot in occlusion culling's results (`cull::TILE`), kept while
|
||||
/// meshes of the same tile replace one another.
|
||||
pub cull_slot: u32,
|
||||
@@ -155,10 +157,12 @@ pub(super) struct Visible {
|
||||
impl Tile {
|
||||
/// The mesh drawn this frame: the previous one while it stands in.
|
||||
pub fn drawn(&self) -> &SectionGpu {
|
||||
self.previous.as_ref().map_or(&self.mesh, |(mesh, _)| mesh)
|
||||
self.previous
|
||||
.as_deref()
|
||||
.map_or(&self.mesh, |(mesh, _)| mesh)
|
||||
}
|
||||
pub fn drawn_mut(&mut self) -> &mut SectionGpu {
|
||||
match &mut self.previous {
|
||||
match self.previous.as_deref_mut() {
|
||||
Some((mesh, _)) => mesh,
|
||||
None => &mut self.mesh,
|
||||
}
|
||||
@@ -177,7 +181,7 @@ impl Tile {
|
||||
}
|
||||
/// Both meshes it holds in the arena: its own and the stand-in, if any.
|
||||
pub fn meshes_mut(&mut self) -> impl Iterator<Item = &mut SectionGpu> {
|
||||
std::iter::once(&mut self.mesh).chain(self.previous.as_mut().map(|(mesh, _)| mesh))
|
||||
std::iter::once(&mut *self.mesh).chain(self.previous.as_deref_mut().map(|(mesh, _)| mesh))
|
||||
}
|
||||
pub fn visible(
|
||||
&self,
|
||||
@@ -214,12 +218,12 @@ impl Renderer {
|
||||
let (shown, previous) = match self.lods.remove(&key) {
|
||||
Some(old) if old.dying.is_none() => match old.previous {
|
||||
Some(previous) => {
|
||||
self.release_mesh(old.mesh);
|
||||
self.release_mesh(*old.mesh);
|
||||
(old.shown, Some(previous))
|
||||
}
|
||||
None if keep => (old.shown, Some((old.mesh, None))),
|
||||
None if keep => (old.shown, Some(Box::new((*old.mesh, None)))),
|
||||
None => {
|
||||
self.release_mesh(old.mesh);
|
||||
self.release_mesh(*old.mesh);
|
||||
(old.shown, None)
|
||||
}
|
||||
},
|
||||
@@ -240,7 +244,7 @@ impl Renderer {
|
||||
self.lods.insert(
|
||||
key,
|
||||
Tile {
|
||||
mesh,
|
||||
mesh: Box::new(mesh),
|
||||
bounds,
|
||||
shown,
|
||||
dying: None,
|
||||
@@ -251,7 +255,7 @@ impl Renderer {
|
||||
);
|
||||
}
|
||||
None => {
|
||||
if let Some((previous, _)) = previous {
|
||||
if let Some((previous, _)) = previous.map(|p| *p) {
|
||||
self.release_mesh(previous);
|
||||
}
|
||||
self.lod_cull_free.extend(old_slot);
|
||||
@@ -260,8 +264,8 @@ impl Renderer {
|
||||
}
|
||||
|
||||
fn release_tile(&mut self, tile: Tile) {
|
||||
self.release_mesh(tile.mesh);
|
||||
if let Some((previous, _)) = tile.previous {
|
||||
self.release_mesh(*tile.mesh);
|
||||
if let Some((previous, _)) = tile.previous.map(|p| *p) {
|
||||
self.release_mesh(previous);
|
||||
}
|
||||
}
|
||||
@@ -337,8 +341,10 @@ impl Renderer {
|
||||
let stood_in: Vec<_> = self
|
||||
.lods
|
||||
.values_mut()
|
||||
.filter_map(|tile| match tile.previous {
|
||||
Some((_, Some(since))) if start - since >= FADE => tile.previous.take(),
|
||||
.filter_map(|tile| match tile.previous.as_deref() {
|
||||
Some((_, Some(since))) if start - *since >= FADE => {
|
||||
tile.previous.take().map(|p| *p)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
@@ -440,7 +446,7 @@ impl Renderer {
|
||||
if tile.shown.is_none() {
|
||||
tile.shown = Some(if over_replaced(key) { start } else { whole });
|
||||
}
|
||||
if let Some((_, since)) = &mut tile.previous {
|
||||
if let Some((_, since)) = tile.previous.as_deref_mut() {
|
||||
since.get_or_insert(start);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +195,7 @@ struct HorizonMaterialSolid {
|
||||
// What `fs_solid` leaves in the frame's alpha for HDR output, from the sprite before the pack's
|
||||
// material: the pack decides the colour, Horizon still knows which pixels are light sources.
|
||||
fn horizon_material_mark(in: HorizonMaterialOut, texel: vec4<f32>) -> f32 {
|
||||
if horizon_terrain_HDR_WRITE_LIGHT { return 1.0; }
|
||||
let code = in.hdr_code & 15u;
|
||||
if code > 0u {
|
||||
let whole = code > 4u;
|
||||
@@ -432,6 +433,10 @@ impl NativeMaterial {
|
||||
];
|
||||
constants.extend(sprite_names.into_iter().zip(shape.sprites));
|
||||
constants.push(("horizon_material_fog", f64::from(u8::from(fog))));
|
||||
constants.push((
|
||||
"horizon_terrain_HDR_WRITE_LIGHT",
|
||||
f64::from(u8::from(shape.format == super::LINEAR_FRAME)),
|
||||
));
|
||||
constants.push((
|
||||
"horizon_displace_distance",
|
||||
displaced.map_or(0.0, |d| f64::from(d.distance)),
|
||||
@@ -580,6 +585,39 @@ impl NativeMaterial {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
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"
|
||||
);
|
||||
let material = "fn material(input: MaterialInput) -> Surface { return Surface(vec4<f32>(1.4, 0.3, 0.1, 0.7)); }\nfn displacement(input: DisplacementInput) -> f32 { return 0.0; }\n";
|
||||
let files = [(MANIFEST, manifest.as_str()), ("material.wgsl", material)]
|
||||
.map(|(n, text)| (n.to_owned(), text.as_bytes().to_vec()))
|
||||
.into();
|
||||
let report = check(&Pack::from_files(files).unwrap()).unwrap();
|
||||
let stage = report
|
||||
.stages
|
||||
.iter()
|
||||
.find(|s| s.kind == StageKind::Material)
|
||||
.unwrap();
|
||||
shader_source(&stage.source, &[], &[], (false, true), api)
|
||||
+ r#"
|
||||
override TEST_TIER: u32 = 0u;
|
||||
override TEST_OVERLAY: u32 = 0u;
|
||||
@vertex fn test_vertex(@builtin(vertex_index) i: u32) -> HorizonMaterialOut {
|
||||
var o: HorizonMaterialOut;
|
||||
o.clip = vec4(vec2(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0);
|
||||
o.uv = vec2(0.5);
|
||||
o.tint = vec4(1.0);
|
||||
o.lightmap = vec3(1.0);
|
||||
o.hdr_code = TEST_TIER;
|
||||
o.base = vec3(1.0);
|
||||
return o;
|
||||
}
|
||||
"#
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -7,14 +7,16 @@ use super::Vertex;
|
||||
use super::pack_draw::EntitySpan;
|
||||
use glam::{DVec3, Mat4};
|
||||
use std::sync::{
|
||||
OnceLock,
|
||||
Arc, OnceLock,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
mod generated {
|
||||
include!(concat!(env!("OUT_DIR"), "/native_render_generated.rs"));
|
||||
}
|
||||
pub use generated::{HandDrawnEvent, RenderHooks, RenderNeedsEvent, WorldDrawnEvent};
|
||||
pub use generated::{
|
||||
HandDrawnEvent, RenderHooks, RenderNeedsEvent, WorldDrawnEvent, WorldWarpEvent,
|
||||
};
|
||||
pub use wgpu;
|
||||
|
||||
static RENDERERS: OnceLock<Box<[&'static dyn RenderHooks]>> = OnceLock::new();
|
||||
@@ -178,6 +180,38 @@ pub fn render_needs() -> RenderNeeds {
|
||||
needs
|
||||
}
|
||||
|
||||
/// A world warp ([`native_world_warp`]): WGSL that every terrain vertex (far terrain's tiles too)
|
||||
/// and entity vertex goes through, so a mod can bend the drawn world itself: its real geometry,
|
||||
/// light and depth, seen from any side. docs/native-mods.md, "World warps", has the WGSL contract.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub struct WorldWarp {
|
||||
/// WGSL with `fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped`, and optionally,
|
||||
/// after a line `// [fragments]`, `fn world_fragment(f: Fragment) -> Painted` for every
|
||||
/// pixel of the terrain. `None`: no warp.
|
||||
pub source: Option<Arc<str>>,
|
||||
/// Whether this frame draws with the warp. A source given while this is false is still
|
||||
/// built, so a mod can pay for its pipelines before the frame that needs them.
|
||||
pub on: bool,
|
||||
/// What the warp reads as `warp.params`: four `vec4<f32>`.
|
||||
pub params: [f32; 16],
|
||||
}
|
||||
|
||||
/// Asked once a frame, before the world is drawn (not on the title screen's panorama): set
|
||||
/// [`WorldWarp`] to have the frame drawn through your warp. Mods are asked in order and one warp
|
||||
/// draws: the last one set. Keep it cheap: compare and reuse your `Arc<str>`.
|
||||
#[inline(never)]
|
||||
pub fn native_world_warp(warp: &mut WorldWarp) {
|
||||
generated::dispatch_world_warp(RENDERERS.get().map_or(&[], |renderers| renderers), warp);
|
||||
}
|
||||
|
||||
/// This frame's world warp ([`native_world_warp`]).
|
||||
pub fn world_warp() -> WorldWarp {
|
||||
let mut warp = WorldWarp::default();
|
||||
native_world_warp(&mut warp);
|
||||
warp
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -378,6 +412,33 @@ mod tests {
|
||||
assert_eq!(texels, expected);
|
||||
}
|
||||
|
||||
struct Bend(&'static str);
|
||||
impl RenderHooks for Bend {
|
||||
fn world_warp(&self, event: &mut WorldWarpEvent<'_>) {
|
||||
event.warp.source = Some(self.0.into());
|
||||
event.warp.on = true;
|
||||
event.warp.params[0] += 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_mod_to_set_a_warp_draws() {
|
||||
let mut warp = WorldWarp::default();
|
||||
generated::dispatch_world_warp(&[&Depth, &Bend("a"), &Bend("b")], &mut warp);
|
||||
assert_eq!(warp.source.as_deref(), Some("b"));
|
||||
assert!(warp.on);
|
||||
// each saw what the one before left
|
||||
assert_eq!(warp.params[0], 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_mods_there_is_no_warp() {
|
||||
let mut warp = WorldWarp::default();
|
||||
generated::dispatch_world_warp(&[], &mut warp);
|
||||
assert_eq!(warp, WorldWarp::default());
|
||||
assert!(warp.source.is_none() && !warp.on);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_accumulate_in_mod_order() {
|
||||
let mut needs = RenderNeeds::default();
|
||||
|
||||
@@ -630,13 +630,12 @@ impl PackAtlas {
|
||||
"layout(set = 1, binding = 0) readonly buffer HzBlockSlots { uint hz_block_slots[]; };\n\
|
||||
layout(set = 1, binding = 1) readonly buffer HzSlotOrigins { vec4 hz_slot_origins[]; };\n",
|
||||
);
|
||||
// Of each sprite layer, `mc_Entity` (id, then 1 for fluids or 0) and
|
||||
// `at_midBlock.w` (its block's light), the game's table; and the arena page's
|
||||
// vertices (`TerrainVertex`, six words each).
|
||||
// Source metadata follows each quad in its arena page. The immutable table
|
||||
// holds sprite rectangles and the current pack's ids of source states.
|
||||
declarations.push_str(
|
||||
"layout(set = 3, binding = 0) readonly buffer HzBlockIds { int hz_block_ids[]; };\n\
|
||||
"layout(set = 3, binding = 0) readonly buffer HzQuadSources { uvec2 hz_sources[]; };\n\
|
||||
layout(set = 3, binding = 1) readonly buffer HzVertices { uint hz_vertices[]; };\n\
|
||||
layout(set = 3, binding = 2) readonly buffer HzSpriteRects { uvec2 hz_sprite_rects[]; };\n",
|
||||
layout(set = 3, binding = 2) readonly buffer HzTerrainTables { int hz_tables[]; };\n",
|
||||
);
|
||||
"hz_slot_origins[hz_block_slots[uint(gl_VertexIndex) >> 8u]]"
|
||||
} else {
|
||||
@@ -649,7 +648,8 @@ impl PackAtlas {
|
||||
let columns = self.columns;
|
||||
let (cell, side, scale) = if flat {
|
||||
(
|
||||
"uvec2 hz_rect = hz_sprite_rects[hz_layer];\n \
|
||||
"uvec2 hz_rect = uvec2(0u, 1u);\n \
|
||||
if (2u * hz_layer + 1u < uint(hz_tables[0])) hz_rect = uvec2(hz_tables[2u + 2u * hz_layer], hz_tables[3u + 2u * hz_layer]);\n \
|
||||
hz_cell = vec2(float(hz_rect.x & 65535u), float(hz_rect.x >> 16u)) / float(hz_rect.y);\n "
|
||||
.to_owned(),
|
||||
"float(hz_rect.y)",
|
||||
@@ -701,20 +701,27 @@ impl PackAtlas {
|
||||
let mut body = body;
|
||||
if flat {
|
||||
body.push_str(FACE);
|
||||
body.push_str(
|
||||
" uint hz_quad = uint(gl_VertexIndex) / 4u;\n \
|
||||
uvec2 hz_source = uvec2(0u);\n \
|
||||
if (hz_quad < uint(hz_sources.length())) hz_source = hz_sources[hz_quad];\n \
|
||||
bool hz_known_source = (hz_source.y & 8192u) != 0u;\n \
|
||||
int hz_material = -1;\n \
|
||||
if (hz_known_source && hz_source.x < uint(hz_tables[1])) hz_material = hz_tables[2u + uint(hz_tables[0]) + 2u * hz_source.x + ((hz_source.y >> 12u) & 1u)];\n",
|
||||
);
|
||||
}
|
||||
declarations.push_str(
|
||||
"vec2 hz_cell; vec2 hz_scale; vec2 hz_mid; float hz_uv_scale; vec4 hz_tangent;\n",
|
||||
);
|
||||
let entity = if flat {
|
||||
"vec4(float(hz_block_ids[3u * hz_layer]), float(hz_block_ids[3u * hz_layer + 1u]), 0.0, 0.0)"
|
||||
"vec4(float(hz_material), hz_known_source ? float((hz_source.y >> 12u) & 1u) : 0.0, 0.0, 0.0)"
|
||||
} else {
|
||||
"vec4(-1.0, 0.0, 0.0, 0.0)"
|
||||
};
|
||||
// The block a face belongs to: half a block behind its centre (the quad's diagonal);
|
||||
// w, the light it gives (Iris's `BLOCK_EMISSION_ATTRIBUTE`, 0 to 15).
|
||||
// The mesher's owning cell, even for inset, offset, multipart or extended faces;
|
||||
// w is the source's emitted light, independent of sprite and smooth light.
|
||||
let mid_block = if flat {
|
||||
"vec4((floor((hz_p0 + hz_p2) * 0.5 - 0.5 * hz_Normal) + 0.5 - hz_v_pos) * 64.0, \
|
||||
float(hz_block_ids[3u * hz_layer + 2u]))"
|
||||
"(hz_known_source ? vec4((vec3(float(hz_source.y & 15u), float((hz_source.y >> 4u) & 15u), float((hz_source.y >> 8u) & 15u)) + 0.5 - hz_v_pos) * 64.0, float((hz_source.y >> 14u) & 15u)) : vec4(0.0))"
|
||||
} else {
|
||||
"vec4(0.0)"
|
||||
};
|
||||
@@ -1154,3 +1161,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "pack_atlas/source_ownership_test.rs"]
|
||||
mod source_ownership_test;
|
||||
@@ -49,12 +49,12 @@ fn terrain_fade_reaches_pack_pixels_through_real_instances() {
|
||||
std::env::temp_dir().join(format!("horizon-terrain-fade-{}", std::process::id())),
|
||||
);
|
||||
std::fs::create_dir_all(folder.0.join("shaders")).unwrap();
|
||||
// The guard, initializer, varying and integer output are independent probe code.
|
||||
// The initializer, varying and integer output are independent probe code. The program reads
|
||||
// `mc_chunkFade` unguarded: Horizon defines no `IRIS_FEATURE_*` (spec OQ-2).
|
||||
let vertex = "#version 330\nout vec4 observation;\n\
|
||||
#ifdef IRIS_FEATURE_FADE_VARIABLE\nfloat first = mc_chunkFade;\n#endif\n\
|
||||
void main() {\n#ifdef IRIS_FEATURE_FADE_VARIABLE\n\
|
||||
float first = mc_chunkFade;\n\
|
||||
void main() {\n\
|
||||
observation = vec4(mc_chunkFade, first, gl_Color.a, gl_MultiTexCoord0.x);\n\
|
||||
#else\nobservation = vec4(1.0, 1.0, gl_Color.a, gl_MultiTexCoord0.x);\n#endif\n\
|
||||
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);\n}";
|
||||
let fragment = "#version 330\nin vec4 observation;\nlayout(location = 0) out uvec4 pixel;\n\
|
||||
void main() { pixel = floatBitsToUint(observation); }";
|
||||
@@ -159,7 +159,8 @@ fn terrain_fade_reaches_pack_pixels_through_real_instances() {
|
||||
origin,
|
||||
0x80ffffff,
|
||||
);
|
||||
let fade = if requested { expected } else { 1.0 };
|
||||
// The fade reaches the program whether the pack requested it or not.
|
||||
let fade = expected;
|
||||
let expected = [
|
||||
fade,
|
||||
if geometry { 0.8125 } else { fade },
|
||||
@@ -307,9 +308,12 @@ fn sample(
|
||||
bytemuck::cast_slice(&vertices),
|
||||
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
|
||||
);
|
||||
let ids = buffer(bytemuck::bytes_of(&[0i32; 3]), wgpu::BufferUsages::STORAGE);
|
||||
let ids = buffer(bytemuck::bytes_of(&[0u32; 2]), wgpu::BufferUsages::STORAGE);
|
||||
let rects = buffer(
|
||||
bytemuck::cast_slice(&atlas.rects_table()),
|
||||
bytemuck::cast_slice(&super::super::pack_post::terrain_tables(
|
||||
&[],
|
||||
&atlas.rects_table(),
|
||||
)),
|
||||
wgpu::BufferUsages::STORAGE,
|
||||
);
|
||||
let binding = |n, storage| wgpu::BindGroupLayoutEntry {
|
||||
|
||||
@@ -406,13 +406,16 @@ fn sample_probe(
|
||||
bytemuck::bytes_of(&[0., 0., 0., scale]),
|
||||
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
|
||||
);
|
||||
let ids = buffer(bytemuck::bytes_of(&[0i32; 15]), wgpu::BufferUsages::STORAGE);
|
||||
let ids = buffer(bytemuck::bytes_of(&[0u32; 2]), wgpu::BufferUsages::STORAGE);
|
||||
let data = buffer(
|
||||
bytemuck::cast_slice(vertices),
|
||||
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
|
||||
);
|
||||
let rects = buffer(
|
||||
bytemuck::cast_slice(&atlas.rects_table()),
|
||||
bytemuck::cast_slice(&super::super::pack_post::terrain_tables(
|
||||
&[],
|
||||
&atlas.rects_table(),
|
||||
)),
|
||||
wgpu::BufferUsages::STORAGE,
|
||||
);
|
||||
let bindings = [
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
//! Original numeric controls for the production terrain attribute expressions.
|
||||
//! A compute invocation stands for a terrain vertex; it runs the actual input body.
|
||||
use super::*;
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
fn dispatch(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
pipeline: &wgpu::ComputePipeline,
|
||||
groups: &[wgpu::BindGroup],
|
||||
result: &wgpu::Buffer,
|
||||
) -> Vec<[f32; 4]> {
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: result.size(),
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_compute_pass(&Default::default());
|
||||
pass.set_pipeline(pipeline);
|
||||
for (i, g) in groups.iter().enumerate() {
|
||||
pass.set_bind_group(i as u32, g, &[]);
|
||||
}
|
||||
pass.dispatch_workgroups((result.size() / 32) as u32, 1, 1);
|
||||
}
|
||||
encoder.copy_buffer_to_buffer(result, 0, &read, 0, result.size());
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
bytemuck::cast_slice(&read.slice(..).get_mapped_range().unwrap()).to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "offscreen GPU control; run explicitly with an available adapter"]
|
||||
fn terrain_source_ownership_gpu() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default()))
|
||||
.expect("executed GPU control requires an adapter");
|
||||
eprintln!("TERRAIN_SOURCE_ADAPTER {:?}", adapter.get_info());
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let atlas = PackAtlas::new(&device, &mut encoder, &[texture], 1, 1, 1).unwrap();
|
||||
let inputs = atlas.terrain_inputs(true);
|
||||
// Inset faces of two source states sharing sprite zero, an extended multipart face,
|
||||
// and the second state's fluid face. Ownership is authored
|
||||
// fixture data, not inferred from normal, sprite, position or diagonal.
|
||||
let mut vertices = Vec::new();
|
||||
for (x, y) in [(2.0, 3.25), (6.0, 3.25), (7.25, 3.75), (6.0, 3.875)] {
|
||||
for [dx, dz] in [[0.0, 0.0], [0.0, 0.5], [0.5, 0.5], [0.5, 0.0]] {
|
||||
vertices.push(crate::mesher::TerrainVertex::new(
|
||||
[x + dx, y, 4.0 + dz],
|
||||
[0.0; 2],
|
||||
0,
|
||||
u32::MAX,
|
||||
0,
|
||||
));
|
||||
}
|
||||
}
|
||||
let declarations = inputs
|
||||
.declarations
|
||||
.lines()
|
||||
.filter(|l| !l.starts_with("layout(location"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let attribute = |name: &str| {
|
||||
inputs
|
||||
.attributes
|
||||
.iter()
|
||||
.find(|(n, _)| n == name)
|
||||
.unwrap()
|
||||
.1
|
||||
.as_str()
|
||||
};
|
||||
let source = format!(
|
||||
"#version 450\n{declarations}\n\
|
||||
layout(local_size_x=1) in;\n\
|
||||
layout(set=0,binding=0) buffer Results {{ vec4 result[]; }};\n\
|
||||
void main() {{\n\
|
||||
uint hz_index=gl_GlobalInvocationID.x;\n\
|
||||
vec3 hz_v_pos=uintBitsToFloat(uvec3(hz_vertices[6u*hz_index],hz_vertices[6u*hz_index+1u],hz_vertices[6u*hz_index+2u]));\n\
|
||||
uvec2 hz_v_uv=uvec2(0); uint hz_v_layer=0u; vec4 hz_v_color=vec4(1); uvec2 hz_v_light=uvec2(0);\n\
|
||||
vec4 hz_Vertex, hz_MultiTexCoord0, hz_MultiTexCoord1, hz_MultiTexCoord2, hz_Color; vec3 hz_Normal;\n\
|
||||
{}\nresult[2u*hz_index]={}; result[2u*hz_index+1u]={};\n}}",
|
||||
inputs.body.replace("gl_VertexIndex", "hz_index"),
|
||||
attribute("mc_Entity"),
|
||||
attribute("at_midBlock")
|
||||
);
|
||||
let mut frontend = naga::front::glsl::Frontend::default();
|
||||
let module = frontend
|
||||
.parse(
|
||||
&naga::front::glsl::Options::from(naga::ShaderStage::Compute),
|
||||
&source,
|
||||
)
|
||||
.unwrap();
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Original terrain numeric control"),
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
|
||||
});
|
||||
let layouts: Vec<_> = [1, 2, 0, 3]
|
||||
.into_iter()
|
||||
.map(|count| {
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: None,
|
||||
entries: &(0..count)
|
||||
.map(|binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: if count == 1 {
|
||||
wgpu::ShaderStages::COMPUTE
|
||||
} else {
|
||||
wgpu::ShaderStages::COMPUTE | wgpu::ShaderStages::VERTEX
|
||||
},
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Storage {
|
||||
read_only: binding != 0 || count != 1,
|
||||
},
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: None,
|
||||
bind_group_layouts: &layouts.iter().map(Some).collect::<Vec<_>>(),
|
||||
..Default::default()
|
||||
});
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: None,
|
||||
layout: Some(&layout),
|
||||
module: &shader,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let storage = |bytes: &[u8]| {
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytes,
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
})
|
||||
};
|
||||
let result = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: vertices.len() as u64 * 32,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let slots = storage(bytemuck::cast_slice(&[0u32]));
|
||||
let origins = storage(bytemuck::cast_slice(&[[128.0f32, -32.0, -256.0, -1.0]]));
|
||||
let ids = storage(bytemuck::cast_slice(&[
|
||||
crate::mesher::TerrainSource::new(0, [2, 3, 4], 0, false),
|
||||
crate::mesher::TerrainSource::new(1, [6, 3, 4], 13, false),
|
||||
crate::mesher::TerrainSource::new(1, [6, 3, 4], 13, false),
|
||||
crate::mesher::TerrainSource::new(1, [6, 3, 4], 13, true),
|
||||
]));
|
||||
let vertex_storage = storage(bytemuck::cast_slice(&vertices));
|
||||
let rects = storage(bytemuck::cast_slice(
|
||||
&super::super::pack_post::terrain_tables(&[31, -1, 47, 59], &[0, 1]),
|
||||
));
|
||||
let group = |set: u32, buffers: &[&wgpu::Buffer]| {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(set),
|
||||
entries: &buffers
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, b)| wgpu::BindGroupEntry {
|
||||
binding: i as u32,
|
||||
resource: b.as_entire_binding(),
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
})
|
||||
};
|
||||
let groups = [
|
||||
group(0, &[&result]),
|
||||
group(1, &[&slots, &origins]),
|
||||
group(2, &[]),
|
||||
group(3, &[&ids, &vertex_storage, &rects]),
|
||||
];
|
||||
queue.submit([encoder.finish()]);
|
||||
let actual = dispatch(&device, &queue, &pipeline, &groups, &result);
|
||||
let mut mismatches = Vec::new();
|
||||
for (i, vertex) in vertices.iter().enumerate() {
|
||||
let q = i / 4;
|
||||
let expected_id = [31.0, 47.0, 47.0, 59.0][q];
|
||||
let expected_light = [0.0, 13.0, 13.0, 13.0][q];
|
||||
let owner = [
|
||||
[2.5, 3.5, 4.5],
|
||||
[6.5, 3.5, 4.5],
|
||||
[6.5, 3.5, 4.5],
|
||||
[6.5, 3.5, 4.5],
|
||||
][q];
|
||||
let expected_mid = [
|
||||
(owner[0] - vertex.pos[0]) * 64.0,
|
||||
(owner[1] - vertex.pos[1]) * 64.0,
|
||||
(owner[2] - vertex.pos[2]) * 64.0,
|
||||
expected_light,
|
||||
];
|
||||
if actual[i * 2] != [expected_id, if q == 3 { 1.0 } else { 0.0 }, 0.0, 0.0]
|
||||
|| actual[i * 2 + 1] != expected_mid
|
||||
{
|
||||
mismatches.push((
|
||||
i,
|
||||
actual[i * 2],
|
||||
actual[i * 2 + 1],
|
||||
expected_id,
|
||||
expected_mid,
|
||||
));
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
mismatches.is_empty(),
|
||||
"terrain source mismatches: {mismatches:?}"
|
||||
);
|
||||
|
||||
// Rasterize the same native vertices and production input body, with a diagnostic
|
||||
// projection placing one face in each strip. The fragment reports id, emission and
|
||||
// reconstructed owner Y/X after a nonzero section translation; no texture color
|
||||
// can hide a wrong ownership value.
|
||||
let vertex_source = format!(
|
||||
"#version 450\n{}\nlayout(location=0) flat out vec4 control;\n\
|
||||
void main(){{vec4 hz_Vertex,hz_MultiTexCoord0,hz_MultiTexCoord1,hz_MultiTexCoord2,hz_Color;vec3 hz_Normal;\n\
|
||||
{}\nvec4 entity={};vec4 mid={};\n\
|
||||
control=vec4(entity.x,mid.w,hz_Vertex.y+mid.y/64.0,hz_Vertex.x+mid.x/64.0);\n\
|
||||
uint corner=uint(gl_VertexIndex)%4u;float q=float(uint(gl_VertexIndex)/4u);\n\
|
||||
gl_Position=vec4(-1.0+(q+float(corner>=2u))*0.5,(corner==1u||corner==2u)?1.0:-1.0,0.5,1.0);}}",
|
||||
inputs.declarations,
|
||||
inputs.body,
|
||||
attribute("mc_Entity"),
|
||||
attribute("at_midBlock")
|
||||
);
|
||||
let vertex_module = frontend
|
||||
.parse(
|
||||
&naga::front::glsl::Options::from(naga::ShaderStage::Vertex),
|
||||
&vertex_source,
|
||||
)
|
||||
.unwrap();
|
||||
let vertex_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(vertex_module)),
|
||||
});
|
||||
let fragment_shader=device.create_shader_module(wgpu::ShaderModuleDescriptor { label:None,source:wgpu::ShaderSource::Wgsl(
|
||||
"@fragment fn fs(@location(0) @interpolate(flat) control:vec4<f32>)->@location(0) vec4<f32>{return control;}".into()) });
|
||||
let raster = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: None,
|
||||
layout: Some(&layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &vertex_shader,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[Some(wgpu::VertexBufferLayout {
|
||||
array_stride: 24,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &super::super::TERRAIN_ATTRIBUTES,
|
||||
})],
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &fragment_shader,
|
||||
entry_point: Some("fs"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: wgpu::TextureFormat::Rgba32Float,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytemuck::cast_slice(&vertices),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
let indices: Vec<u32> = (0..4)
|
||||
.flat_map(|q| [4 * q, 4 * q + 1, 4 * q + 2, 4 * q, 4 * q + 2, 4 * q + 3])
|
||||
.collect();
|
||||
let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytemuck::cast_slice(&indices),
|
||||
usage: wgpu::BufferUsages::INDEX,
|
||||
});
|
||||
let target = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 16,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba32Float,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = target.create_view(&Default::default());
|
||||
let pixels = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 256,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&raster);
|
||||
for (i, g) in groups.iter().enumerate() {
|
||||
pass.set_bind_group(i as u32, g, &[]);
|
||||
}
|
||||
pass.set_vertex_buffer(0, vertex_buffer.slice(..));
|
||||
pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.draw_indexed(0..24, 0, 0..1);
|
||||
}
|
||||
encoder.copy_texture_to_buffer(
|
||||
target.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &pixels,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: Some(1),
|
||||
},
|
||||
},
|
||||
target.size(),
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
pixels
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let pixels = pixels.slice(..).get_mapped_range().unwrap();
|
||||
let pixels: &[[f32; 4]] = bytemuck::cast_slice(&pixels);
|
||||
for (i, pixel) in pixels.iter().enumerate() {
|
||||
assert_eq!(
|
||||
*pixel,
|
||||
[
|
||||
[31.0, 0.0, -28.5, 130.5],
|
||||
[47.0, 13.0, -28.5, 134.5],
|
||||
[47.0, 13.0, -28.5, 134.5],
|
||||
[59.0, 13.0, -28.5, 134.5]
|
||||
][i / 4],
|
||||
"diagnostic raster pixel {i}"
|
||||
);
|
||||
}
|
||||
|
||||
// Reload changes only the immutable mapping; the same raw state/owner records
|
||||
// resolve to new material ids without baking a previous pack's ids into a mesh.
|
||||
let changed = storage(bytemuck::cast_slice(
|
||||
&super::super::pack_post::terrain_tables(&[211, -1, 223, 59], &[0, 1]),
|
||||
));
|
||||
let changed_groups = [
|
||||
groups[0].clone(),
|
||||
groups[1].clone(),
|
||||
groups[2].clone(),
|
||||
group(3, &[&ids, &vertex_storage, &changed]),
|
||||
];
|
||||
let reloaded = dispatch(&device, &queue, &pipeline, &changed_groups, &result);
|
||||
for i in 0..vertices.len() {
|
||||
assert_eq!(
|
||||
reloaded[2 * i],
|
||||
[
|
||||
if i < 4 {
|
||||
211.0
|
||||
} else if i < 12 {
|
||||
223.0
|
||||
} else {
|
||||
59.0
|
||||
},
|
||||
if i >= 12 { 1.0 } else { 0.0 },
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
);
|
||||
assert_eq!(reloaded[2 * i + 1], actual[2 * i + 1]);
|
||||
}
|
||||
let missing = storage(&[0; 8]);
|
||||
let missing_groups = [
|
||||
groups[0].clone(),
|
||||
groups[1].clone(),
|
||||
groups[2].clone(),
|
||||
group(3, &[&missing, &vertex_storage, &rects]),
|
||||
];
|
||||
let neutral = dispatch(&device, &queue, &pipeline, &missing_groups, &result);
|
||||
for i in 0..vertices.len() {
|
||||
assert_eq!(neutral[2 * i], [-1.0, 0.0, 0.0, 0.0]);
|
||||
assert_eq!(neutral[2 * i + 1], [0.0; 4]);
|
||||
}
|
||||
}
|
||||
@@ -247,7 +247,7 @@ impl Casters {
|
||||
pub fn cast(&self, span: &EntitySpan, program: usize) -> bool {
|
||||
match program {
|
||||
ENTITY => self.entities && (!self.only_player || span.player),
|
||||
BLOCK => self.blocks && (!self.only_lit || span.emissive),
|
||||
BLOCK | BLOCK_TRANSLUCENT => self.blocks && (!self.only_lit || span.emissive),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -440,7 +440,7 @@ impl PackEntities {
|
||||
let posed = skin.map_or(0, |s| s.vertices);
|
||||
if let Some(skin) = skin {
|
||||
for (span, range, overlay) in skin_runs(skin) {
|
||||
let program = program_of(&span).expect("tagged skin stage");
|
||||
let program = material_program(&span, overlay, drawn).expect("tagged skin stage");
|
||||
let pack = drawn[program] && !span.hidden;
|
||||
let casts = shadow.cast(&span, program);
|
||||
if !pack && !casts {
|
||||
@@ -462,7 +462,7 @@ impl PackEntities {
|
||||
extend_without_portals(rest, &entities[..first.min(entities.len())]);
|
||||
let (mut other, mut portal_faces, mut beam_faces) = (Vec::new(), Vec::new(), Vec::new());
|
||||
let mut without_fluids = Vec::new();
|
||||
for (span, range) in runs(entities.len(), spans) {
|
||||
for (span, range, material) in material_runs(entities, spans, drawn) {
|
||||
let run_start = range.start as u32;
|
||||
let whole = &entities[range];
|
||||
// A native fluid carries sprite-array UVs, not shared-atlas UVs.
|
||||
@@ -490,7 +490,8 @@ impl PackEntities {
|
||||
// gateway: its cube and its beam): each goes to its own program.
|
||||
// Beacon beams come in a run of their own, at stage NONE (game.rs: no stage of 5.4
|
||||
// is theirs), for gbuffers_beaconbeam.
|
||||
let split = (program_of(&span) == Some(BLOCK) || span.stage == render_stage("NONE"))
|
||||
let split = (matches!(material, Some(BLOCK | BLOCK_TRANSLUCENT))
|
||||
|| span.stage == render_stage("NONE"))
|
||||
&& whole
|
||||
.chunks(4)
|
||||
.any(|q| matches!(q[0].layer & 0xffff, 6 | 9 | 10));
|
||||
@@ -520,7 +521,7 @@ impl PackEntities {
|
||||
let program = match part {
|
||||
Part::Portal => Some(BLOCK),
|
||||
Part::Beam => Some(BEAM),
|
||||
Part::Plain => program_of(&span).filter(|&p| {
|
||||
Part::Plain => material.filter(|&p| {
|
||||
plain(run)
|
||||
|| (p == DESTROY && run.iter().all(|v| matches!(v.layer, 11 | 12)))
|
||||
}),
|
||||
@@ -635,7 +636,7 @@ impl PackEntities {
|
||||
portal: bool,
|
||||
) {
|
||||
let (entity_id, block_id) = match program {
|
||||
BLOCK => (0, span.id),
|
||||
BLOCK | BLOCK_TRANSLUCENT => (0, span.id),
|
||||
_ => (span.id, 0),
|
||||
};
|
||||
let mut bytes = [0u8; SLOT as usize];
|
||||
@@ -782,7 +783,76 @@ pub const PARTICLE: usize = 3;
|
||||
pub const DESTROY: usize = 4;
|
||||
/// Beacon and End gateway beams (`gbuffers_beaconbeam`).
|
||||
pub const BEAM: usize = 5;
|
||||
pub const PROGRAMS: usize = 6;
|
||||
pub const BLOCK_TRANSLUCENT: usize = 6;
|
||||
pub const PARTICLE_TRANSLUCENT: usize = 7;
|
||||
pub const PROGRAMS: usize = 8;
|
||||
|
||||
/// Translucent blocks retain the ordinary block shadow program and blockEntityId.
|
||||
pub fn shadow_program(program: usize) -> usize {
|
||||
if program == BLOCK_TRANSLUCENT {
|
||||
BLOCK
|
||||
} else {
|
||||
program
|
||||
}
|
||||
}
|
||||
|
||||
fn material_program(span: &EntitySpan, layer: u32, drawn: [bool; PROGRAMS]) -> Option<usize> {
|
||||
Some(match program_of(span)? {
|
||||
BLOCK
|
||||
if drawn[BLOCK_TRANSLUCENT]
|
||||
&& (span.translucent
|
||||
|| layer & super::OIT_ENTITY != 0
|
||||
|| matches!(
|
||||
layer & 0xffff,
|
||||
super::TRANSLUCENT_ITEM_OVERLAY | super::TRANSLUCENT_GLINT_ITEM_OVERLAY
|
||||
)) =>
|
||||
{
|
||||
BLOCK_TRANSLUCENT
|
||||
}
|
||||
PARTICLE
|
||||
if drawn[PARTICLE_TRANSLUCENT]
|
||||
&& layer & 0xffff == super::TRANSLUCENT_PARTICLE_OVERLAY =>
|
||||
{
|
||||
PARTICLE_TRANSLUCENT
|
||||
}
|
||||
p => p,
|
||||
})
|
||||
}
|
||||
|
||||
/// Split only where an available variant changes a quad's program. Keep contiguous runs,
|
||||
/// their vertex order and the original single draw when no material variant is usable.
|
||||
fn material_runs<'a>(
|
||||
vertices: &'a [Vertex],
|
||||
spans: &'a [EntitySpan],
|
||||
drawn: [bool; PROGRAMS],
|
||||
) -> impl Iterator<Item = (EntitySpan, Range<usize>, Option<usize>)> + 'a {
|
||||
runs(vertices.len(), spans).flat_map(move |(span, range)| {
|
||||
let split = match program_of(&span) {
|
||||
Some(BLOCK) => drawn[BLOCK_TRANSLUCENT],
|
||||
Some(PARTICLE) => drawn[PARTICLE_TRANSLUCENT],
|
||||
_ => false,
|
||||
};
|
||||
let mut start = range.start;
|
||||
std::iter::from_fn(move || {
|
||||
if start >= range.end {
|
||||
return None;
|
||||
}
|
||||
let program = material_program(&span, vertices[start].layer, drawn);
|
||||
let end = if split {
|
||||
(start..range.end)
|
||||
.step_by(4)
|
||||
.skip(1)
|
||||
.find(|&i| material_program(&span, vertices[i].layer, drawn) != program)
|
||||
.unwrap_or(range.end)
|
||||
} else {
|
||||
range.end
|
||||
};
|
||||
let run = start..end;
|
||||
start = end;
|
||||
Some((span, run, program))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Which program draws `span`'s run, given which of them (or their fallbacks) the pack has;
|
||||
/// `None`: Horizon draws it.
|
||||
@@ -940,11 +1010,17 @@ mod tests {
|
||||
assert_eq!(drawn_by_pack(&spans[0], all), Some(ENTITY));
|
||||
assert_eq!(drawn_by_pack(&spans[1], all), Some(PARTICLE));
|
||||
assert_eq!(
|
||||
drawn_by_pack(&spans[1], [true, true, true, false, true, true]),
|
||||
drawn_by_pack(
|
||||
&spans[1],
|
||||
[true, true, true, false, true, true, false, false]
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
drawn_by_pack(&spans[2], [true, false, true, true, true, true]),
|
||||
drawn_by_pack(
|
||||
&spans[2],
|
||||
[true, false, true, true, true, true, false, false]
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(drawn_by_pack(&spans[2], all), Some(BLOCK));
|
||||
@@ -1676,3 +1752,6 @@ mod tier_translate_tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod material_route_tests;
|
||||
@@ -0,0 +1,62 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn optional_variants_split_contiguous_materials_without_guessing_from_alpha() {
|
||||
let mut vertices = Vec::new();
|
||||
for layer in [
|
||||
0,
|
||||
super::super::OIT_ENTITY,
|
||||
0,
|
||||
super::super::TRANSLUCENT_PARTICLE_OVERLAY,
|
||||
0,
|
||||
] {
|
||||
vertices.extend(
|
||||
[Vertex {
|
||||
layer: layer | 3 << 16,
|
||||
color: 0x40808080,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
}; 4],
|
||||
);
|
||||
}
|
||||
let spans = [
|
||||
EntitySpan {
|
||||
stage: render_stage("BLOCK_ENTITIES"),
|
||||
id: 71,
|
||||
..Default::default()
|
||||
},
|
||||
EntitySpan {
|
||||
start: 8,
|
||||
stage: render_stage("PARTICLES"),
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
let collect = |drawn| {
|
||||
material_runs(&vertices, &spans, drawn)
|
||||
.map(|(_, range, p)| (range, p))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let mut legacy = [true; PROGRAMS];
|
||||
legacy[BLOCK_TRANSLUCENT] = false;
|
||||
legacy[PARTICLE_TRANSLUCENT] = false;
|
||||
assert_eq!(
|
||||
collect(legacy),
|
||||
[(0..8, Some(BLOCK)), (8..20, Some(PARTICLE))]
|
||||
);
|
||||
assert_eq!(
|
||||
collect([true; PROGRAMS]),
|
||||
[
|
||||
(0..4, Some(BLOCK)),
|
||||
(4..8, Some(BLOCK_TRANSLUCENT)),
|
||||
(8..12, Some(PARTICLE)),
|
||||
(12..16, Some(PARTICLE_TRANSLUCENT)),
|
||||
(16..20, Some(PARTICLE)),
|
||||
]
|
||||
);
|
||||
let casters = Casters {
|
||||
blocks: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(casters.cast(&spans[0], BLOCK_TRANSLUCENT));
|
||||
assert_eq!(shadow_program(BLOCK_TRANSLUCENT), BLOCK);
|
||||
assert!(!casters.cast(&spans[1], PARTICLE_TRANSLUCENT));
|
||||
}
|
||||
@@ -533,8 +533,7 @@ pub(super) struct World<'a> {
|
||||
pub lightmap: wgpu::TextureView,
|
||||
/// Terrain's geometry, when the pack draws it.
|
||||
pub terrain: Option<Geometry<'a>>,
|
||||
/// Of each block sprite layer, `mc_Entity` (id, then 1 for fluids or 0) and `at_midBlock.w`
|
||||
/// (its block's light): three values a layer.
|
||||
/// Per source block state: its model material id, then its fluid material id.
|
||||
pub block_ids: &'a [i32],
|
||||
/// The atlas of the sun and moon's sprites.
|
||||
pub celestial: Option<wgpu::TextureView>,
|
||||
@@ -667,10 +666,10 @@ pub struct PackPost {
|
||||
down: HashMap<wgpu::TextureFormat, (wgpu::RenderPipeline, wgpu::BindGroupLayout)>,
|
||||
/// The per-draw uniforms of gbuffers programs.
|
||||
draws: DrawUniforms,
|
||||
/// Set 3 of flat terrain (`mc_Entity`'s table, then the sprites' places) and its bind group
|
||||
/// for each arena page, with the page buffer it was made for.
|
||||
/// Set 3 of flat terrain: an unknown-source fallback and immutable state/sprite tables.
|
||||
/// Each page's bind group tracks both its vertex buffer and optional source sidecar.
|
||||
block_ids: Option<(wgpu::BindGroupLayout, wgpu::Buffer, wgpu::Buffer)>,
|
||||
page_groups: HashMap<u32, (wgpu::Buffer, wgpu::BindGroup)>,
|
||||
page_groups: HashMap<u32, (wgpu::Buffer, Option<wgpu::Buffer>, wgpu::BindGroup)>,
|
||||
gui_size: [u32; 2],
|
||||
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
|
||||
pub timing: Option<super::pack_timing::PassTiming>,
|
||||
@@ -1210,30 +1209,44 @@ pub(super) struct ShadowView {
|
||||
pub depth_planes: bool,
|
||||
}
|
||||
|
||||
/// Set 3 of flat terrain, read by the vertex stage: the sprite layers' table (`mc_Entity`'s id
|
||||
/// and fluid, `at_midBlock.w`; -1, -1, 0 when empty), per arena page, the page's vertices
|
||||
/// (a vertex reads its quad's corners for its normal), and each sprite's place in a stitched
|
||||
/// atlas (`rects`; zeros for a grid, which doesn't read them).
|
||||
fn block_ids(
|
||||
/// An arena page's key, native vertices and optional per-quad source records.
|
||||
pub(super) type TerrainPage = (u32, wgpu::Buffer, Option<wgpu::Buffer>);
|
||||
|
||||
/// Header (sprite-rect words, state count), sprite rectangles, then model/fluid ids.
|
||||
/// Keeping immutable tables together leaves the existing three storage bindings intact.
|
||||
pub(super) fn terrain_tables(table: &[i32], rects: &[u32]) -> Vec<i32> {
|
||||
let rects = if rects.is_empty() { &[0, 0][..] } else { rects };
|
||||
[rects.len() as i32, (table.len() / 2) as i32]
|
||||
.into_iter()
|
||||
.chain(rects.iter().map(|&v| v as i32))
|
||||
.chain(table.iter().copied())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn block_ids(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
table: &[i32],
|
||||
rects: &[u32],
|
||||
) -> (wgpu::BindGroupLayout, wgpu::Buffer, wgpu::Buffer) {
|
||||
let empty = [-1, -1, 0];
|
||||
let table = if table.is_empty() { &empty[..] } else { table };
|
||||
) -> Option<(wgpu::BindGroupLayout, wgpu::Buffer, wgpu::Buffer)> {
|
||||
let buffer = |label, contents: &[u8]| {
|
||||
wgpu::util::DeviceExt::create_buffer_init(
|
||||
if contents.len() as u64 > device.limits().max_storage_buffer_binding_size {
|
||||
return None;
|
||||
}
|
||||
let buffer = super::arena::try_create(
|
||||
device,
|
||||
&wgpu::util::BufferInitDescriptor {
|
||||
label: Some(label),
|
||||
contents,
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
},
|
||||
)
|
||||
label,
|
||||
contents.len() as u64,
|
||||
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
)?;
|
||||
queue.write_buffer(&buffer, 0, contents);
|
||||
Some(buffer)
|
||||
};
|
||||
let rects = if rects.is_empty() { &[0, 0][..] } else { rects };
|
||||
let ids = buffer("Shader pack block ids", bytemuck::cast_slice(table));
|
||||
let rects = buffer("Shader pack sprite rects", bytemuck::cast_slice(rects));
|
||||
let ids = buffer("Unknown terrain sources", &[0; 8])?;
|
||||
let rects = buffer(
|
||||
"Shader pack terrain tables",
|
||||
bytemuck::cast_slice(&terrain_tables(table, rects)),
|
||||
)?;
|
||||
let storage = |binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::VERTEX,
|
||||
@@ -1248,7 +1261,7 @@ fn block_ids(
|
||||
label: Some("Shader pack terrain"),
|
||||
entries: &[storage(0), storage(1), storage(2)],
|
||||
});
|
||||
(layout, ids, rects)
|
||||
Some((layout, ids, rects))
|
||||
}
|
||||
|
||||
/// The game's geometry a gbuffers program draws: its vertex buffers and, for flat draws
|
||||
@@ -2464,6 +2477,12 @@ impl PackPost {
|
||||
let textures = pack_textures::upload(device, queue, post.textures, &mut notes);
|
||||
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_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 = post.custom;
|
||||
@@ -2577,6 +2596,20 @@ impl PackPost {
|
||||
.fold(0, |m, n| m | 1 << n),
|
||||
jobs: defer.then(Default::default),
|
||||
};
|
||||
// Flat terrain resolves each quad's source state against the current pack.
|
||||
let block_ids = world
|
||||
.terrain
|
||||
.as_ref()
|
||||
.filter(|g| g.table.is_some())
|
||||
.map(|_| {
|
||||
block_ids(device, queue, world.block_ids, world.sprite_rects).ok_or_else(|| {
|
||||
Refused {
|
||||
reason: "terrain source tables could not be allocated".into(),
|
||||
notes: notes.clone(),
|
||||
}
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
let mut build = |mut p: horizon_shaders::PostProgram, geometry: Option<&Geometry<'_>>| {
|
||||
// Not wanted any more: the rest is skipped and the build refused below.
|
||||
if cancel.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
@@ -2688,12 +2721,6 @@ impl PackPost {
|
||||
}
|
||||
Some(pass)
|
||||
};
|
||||
// Flat terrain reads `mc_Entity` from a table of the sprite layers.
|
||||
let block_ids = world
|
||||
.terrain
|
||||
.as_ref()
|
||||
.filter(|g| g.table.is_some())
|
||||
.map(|_| block_ids(device, world.block_ids, world.sprite_rects));
|
||||
let terrain_geometry = world.terrain.as_ref().map(|g| Geometry {
|
||||
buffers: g.buffers,
|
||||
table: g.table,
|
||||
@@ -2773,14 +2800,18 @@ impl PackPost {
|
||||
post.particles,
|
||||
post.damaged_block,
|
||||
post.beacon_beam,
|
||||
post.block_translucent,
|
||||
post.particles_translucent,
|
||||
];
|
||||
let late = [
|
||||
let mut late = [
|
||||
false,
|
||||
false,
|
||||
post.translucent_late,
|
||||
post.particles_late,
|
||||
false,
|
||||
true,
|
||||
post.translucent_late,
|
||||
true,
|
||||
];
|
||||
let shadow_only = (post.shadow_only_player, post.shadow_only_lit_blocks);
|
||||
let shadow_programs = [post.shadow_entities, post.shadow_block];
|
||||
@@ -2822,6 +2853,11 @@ impl PackPost {
|
||||
}
|
||||
})
|
||||
});
|
||||
// Translation alone is insufficient: a refused GPU pipeline must retain the
|
||||
// legacy single particle pass and its timing as well as its geometry grouping.
|
||||
if entity_passes[super::pack_entities::PARTICLE_TRANSLUCENT].is_some() {
|
||||
late[super::pack_entities::PARTICLE] = false;
|
||||
}
|
||||
let shadow_entities = shadow_programs.map(|p| {
|
||||
p.zip(entity_geometry.as_ref())
|
||||
.and_then(|(p, g)| paged(build(p, Some(g))))
|
||||
@@ -2845,10 +2881,12 @@ impl PackPost {
|
||||
cull: false,
|
||||
});
|
||||
let hides_clouds = post.clouds_off || post.clouds.is_some() && cloud_geometry.is_some();
|
||||
// Rain and snow test depth but never write it: Iris 1.11.7 left none of either in
|
||||
// `depthtex0`, whatever `rain.depth` said (probe packs of ours, #10492).
|
||||
let weather_geometry = world.weather.then(|| Geometry {
|
||||
buffers: &entity_buffers,
|
||||
table: None,
|
||||
depth_write: post.rain_depth,
|
||||
depth_write: false,
|
||||
atlas: None,
|
||||
material_maps: false,
|
||||
ids: None,
|
||||
@@ -3443,6 +3481,7 @@ impl PackPost {
|
||||
let mut u = FrameUniforms::default();
|
||||
u.atlas = world.atlas.map_or([0; 2], |a| a.1);
|
||||
u.shadow = shadow_config;
|
||||
u.half_lives = half_lives;
|
||||
u
|
||||
},
|
||||
shadow_cache: Default::default(),
|
||||
@@ -3931,7 +3970,7 @@ impl PackPost {
|
||||
}
|
||||
let (groups, ids) = (&self.page_groups, pass.ids);
|
||||
let page = |rp: &mut wgpu::RenderPass<'_>, page: u32| {
|
||||
if let Some((_, group)) = groups.get(&page).filter(|_| ids) {
|
||||
if let Some((_, _, group)) = groups.get(&page).filter(|_| ids) {
|
||||
rp.set_bind_group(3, group, &[]);
|
||||
}
|
||||
};
|
||||
@@ -4149,7 +4188,7 @@ impl PackPost {
|
||||
continue;
|
||||
};
|
||||
let mut draws = (geometry.draws.iter())
|
||||
.filter(|d| d.shadow && d.program == program)
|
||||
.filter(|d| d.shadow && super::pack_entities::shadow_program(d.program) == program)
|
||||
.peekable();
|
||||
if draws.peek().is_none() {
|
||||
continue;
|
||||
@@ -4161,7 +4200,11 @@ impl PackPost {
|
||||
encoder,
|
||||
pass,
|
||||
size,
|
||||
(geometry.draws.iter()).any(|d| d.shadow && d.program == program && d.portal),
|
||||
(geometry.draws.iter()).any(|d| {
|
||||
d.shadow
|
||||
&& super::pack_entities::shadow_program(d.program) == program
|
||||
&& d.portal
|
||||
}),
|
||||
);
|
||||
let mut on_portal = false;
|
||||
let mut rp = self.begin(encoder, pass);
|
||||
@@ -5146,7 +5189,7 @@ impl PackPost {
|
||||
inputs: &PackInputs,
|
||||
size: [u32; 2],
|
||||
window: [u32; 2],
|
||||
pages: &[(u32, wgpu::Buffer)],
|
||||
pages: &[TerrainPage],
|
||||
terrain: &mut TerrainDraw<'_>,
|
||||
) {
|
||||
self.resize(device, size);
|
||||
@@ -5182,8 +5225,8 @@ impl PackPost {
|
||||
if let Some((layout, ids, rects)) = &self.block_ids {
|
||||
// A page that grew has a new buffer.
|
||||
self.page_groups
|
||||
.retain(|p, (b, _)| pages.iter().any(|(q, c)| q == p && c == b));
|
||||
for (page, buffer) in pages {
|
||||
.retain(|p, (b, s, _)| pages.iter().any(|(q, c, t)| q == p && c == b && s == t));
|
||||
for (page, buffer, sources) in pages {
|
||||
self.page_groups.entry(*page).or_insert_with(|| {
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Shader pack terrain"),
|
||||
@@ -5191,7 +5234,7 @@ impl PackPost {
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: ids.as_entire_binding(),
|
||||
resource: sources.as_ref().unwrap_or(ids).as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
@@ -5203,7 +5246,7 @@ impl PackPost {
|
||||
},
|
||||
],
|
||||
});
|
||||
(buffer.clone(), group)
|
||||
(buffer.clone(), sources.clone(), group)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6406,3 +6449,6 @@ mod mipmap_box_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod colortex_mip_blend_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod material_route_tests;
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Original end-to-end material routing, timing and blending control; no pack assets.
|
||||
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
|
||||
use super::*;
|
||||
use crate::gfx::{OIT_ENTITY, TRANSLUCENT_PARTICLE_OVERLAY, Vertex, pack_draw::EntitySpan};
|
||||
use crate::hud::HudBatch;
|
||||
use horizon_shaders::render_stage;
|
||||
use wgpu::TextureFormat as T;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires an offscreen graphics adapter; run explicitly"]
|
||||
fn authored_material_programs_render_and_legacy_draws_stay_unsplit() {
|
||||
let gpu = Gpu::new().expect("an executed adapter is required for this control");
|
||||
let mut vertices = Vec::new();
|
||||
for (i, layer) in [0, OIT_ENTITY, 0, TRANSLUCENT_PARTICLE_OVERLAY]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let x = -1.0 + i as f32 * 0.5;
|
||||
for pos in [
|
||||
[x, -1.0, 0.0],
|
||||
[x + 0.5, -1.0, 0.0],
|
||||
[x + 0.5, 1.0, 0.0],
|
||||
[x, 1.0, 0.0],
|
||||
] {
|
||||
vertices.push(Vertex {
|
||||
pos,
|
||||
layer: layer | 2 << 16,
|
||||
color: 0xffff_ffff,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
});
|
||||
}
|
||||
}
|
||||
let spans = [
|
||||
EntitySpan {
|
||||
stage: render_stage("BLOCK_ENTITIES"),
|
||||
id: 17,
|
||||
..Default::default()
|
||||
},
|
||||
EntitySpan {
|
||||
start: 8,
|
||||
stage: render_stage("PARTICLES"),
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
for variants in [false, true] {
|
||||
let vertex = "#version 120\nvoid main(){gl_Position=gl_Vertex;}";
|
||||
let mut files = vec![
|
||||
("final.vsh".into(), VERTEX.into()),
|
||||
("final.fsh".into(), "#version 120\nuniform sampler2D colortex0; varying vec2 uv; void main(){gl_FragColor=vec4(texture2D(colortex0,uv).rgb,1.0);}".into()),
|
||||
("deferred.vsh".into(), VERTEX.into()),
|
||||
("deferred.fsh".into(), "#version 120\nuniform sampler2D colortex0; varying vec2 uv; void main(){gl_FragData[0]=vec4(texture2D(colortex0,uv).rgb*0.5,1.0);}".into()),
|
||||
("shaders.properties".into(), "particles.ordering=mixed\nseparateEntityDraws=true\nblend.gbuffers_block=off\nblend.gbuffers_particles=off\nblend.gbuffers_block_translucent=SRC_ALPHA ONE_MINUS_SRC_ALPHA ZERO ZERO\nblend.gbuffers_particles_translucent=SRC_ALPHA ONE_MINUS_SRC_ALPHA ZERO ZERO\n".into()),
|
||||
];
|
||||
let programs = [
|
||||
("gbuffers_block", "vec4(1.0,0.0,0.0,1.0)"),
|
||||
("gbuffers_particles", "vec4(0.0,0.0,1.0,1.0)"),
|
||||
("gbuffers_block_translucent", "vec4(0.0,1.0,0.0,0.5)"),
|
||||
("gbuffers_particles_translucent", "vec4(1.0,1.0,0.0,0.5)"),
|
||||
];
|
||||
for (name, color) in programs.into_iter().take(if variants { 4 } else { 2 }) {
|
||||
let expected_id = if name.starts_with("gbuffers_block") {
|
||||
17
|
||||
} else {
|
||||
0
|
||||
};
|
||||
files.push((format!("{name}.vsh"), vertex.into()));
|
||||
files.push((format!("{name}.fsh"), format!("#version 120\nuniform int blockEntityId; void main(){{gl_FragData[0]={color}; if (blockEntityId != {expected_id}) gl_FragData[0]=vec4(1.0);}}")));
|
||||
}
|
||||
let color = gpu.texture(T::Rgba8Unorm);
|
||||
let depth = gpu.texture(T::Depth32Float);
|
||||
let mut input = super::super::pack_entities::inputs();
|
||||
input.body = "hz_Vertex=vec4(hz_v_pos,1.0); hz_Color=vec4(1.0);".into();
|
||||
let mut post = gpu.load(
|
||||
files,
|
||||
World {
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap: color.clone(),
|
||||
terrain: None,
|
||||
block_ids: &[],
|
||||
sprite_rects: &[],
|
||||
celestial: None,
|
||||
gui_size: SIZE,
|
||||
clouds: None,
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
horizon_shaders::GameInputs {
|
||||
entities: Some(input),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let hud = HudBatch::new(SIZE);
|
||||
for frame in 0..2 {
|
||||
gpu.clear(&color, &depth, 0.0);
|
||||
let mut rest = Vec::new();
|
||||
post.prepare_entities(
|
||||
&gpu.device,
|
||||
&gpu.queue,
|
||||
&vertices,
|
||||
&spans,
|
||||
[0.; 4],
|
||||
[0.; 4],
|
||||
&mut rest,
|
||||
None,
|
||||
(&[], &[]),
|
||||
);
|
||||
assert!(rest.is_empty());
|
||||
let entities = post.entities.as_ref().unwrap();
|
||||
assert_eq!(entities.geometry.draws.len(), if variants { 4 } else { 2 });
|
||||
let pixels = gpu.render(
|
||||
&mut post,
|
||||
&color,
|
||||
&depth,
|
||||
&fixture_frame(&hud),
|
||||
&mut |_, _, _, _| {},
|
||||
);
|
||||
// Early opaque draws are halved by deferred. Late variants blend alpha 1/2
|
||||
// over world clear 1/16 halved by deferred, giving 1/64 + color/2.
|
||||
let expected = if variants {
|
||||
[[128, 0, 0], [4, 132, 4], [0, 0, 128], [132, 132, 4]]
|
||||
} else {
|
||||
[[128, 0, 0], [128, 0, 0], [0, 0, 255], [0, 0, 255]]
|
||||
};
|
||||
for (i, rgb) in expected.into_iter().enumerate() {
|
||||
let pixel = pixels[(SIZE[1] / 2 * SIZE[0] + i as u32 * 4 + 2) as usize];
|
||||
eprintln!(
|
||||
"MATERIAL_ROUTE variants={variants} frame={frame} strip={i} actual={pixel:?} expected={rgb:?}"
|
||||
);
|
||||
assert!(pixel[..3].iter().zip(rgb).all(|(&a, b)| a.abs_diff(b) <= 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use wgpu::util::DeviceExt;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a host GPU; missing adapter is a failure"]
|
||||
fn rain_depth_controls_writes_for_rain_snow_and_fallbacks() {
|
||||
fn rain_and_snow_leave_depth_alone_whatever_rain_depth_says() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
|
||||
eprintln!("Weather depth GPU: {:?}", adapter.get_info());
|
||||
@@ -16,14 +16,10 @@ fn rain_depth_controls_writes_for_rain_snow_and_fallbacks() {
|
||||
}))
|
||||
.unwrap();
|
||||
let mut depth_failures = Vec::new();
|
||||
// Iris 1.11.7 left no rain or snow in `depthtex0` with `rain.depth` absent, true or false,
|
||||
// drawn by `gbuffers_weather` or its fallback (probe packs of ours, #10492).
|
||||
for program in ["gbuffers_weather", "gbuffers_textured_lit"] {
|
||||
for (property, writes) in [
|
||||
("rain.depth=true", true),
|
||||
("rain.depth=false", false),
|
||||
// An evidence-limited compatibility choice: preserve accepted main here.
|
||||
// The permitted packs do not establish the external omitted-value default.
|
||||
("", true),
|
||||
] {
|
||||
for property in ["rain.depth=true", "rain.depth=false", ""] {
|
||||
let mut post = pack(&device, &queue, &adapter, program, property);
|
||||
// Retain the pipelines for another frame, then resize their targets.
|
||||
for size in [[6, 1], [6, 1], [12, 2]] {
|
||||
@@ -90,7 +86,7 @@ fn rain_depth_controls_writes_for_rain_snow_and_fallbacks() {
|
||||
}
|
||||
let depth =
|
||||
f32::from_le_bytes(depths[offset..offset + 4].try_into().unwrap());
|
||||
let expected_depth = if visible && writes { 0.25 } else { 0.5 };
|
||||
let expected_depth = 0.5;
|
||||
if depth != expected_depth {
|
||||
depth_failures.push(format!(
|
||||
"{program} {property:?} size={size:?} ({x},{y}): depth={depth}, expected={expected_depth}"
|
||||
|
||||
@@ -405,6 +405,8 @@ 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_shaders::HALF_LIVES`).
|
||||
pub half_lives: [Option<f32>; 4],
|
||||
}
|
||||
|
||||
impl Default for FrameUniforms {
|
||||
@@ -422,6 +424,7 @@ impl Default for FrameUniforms {
|
||||
center_depth_raw: None,
|
||||
center_depth: None,
|
||||
shadow: Shadow::default(),
|
||||
half_lives: [None; 4],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -734,6 +737,7 @@ impl FrameUniforms {
|
||||
*self = Self {
|
||||
atlas: self.atlas,
|
||||
shadow: std::mem::take(&mut self.shadow),
|
||||
half_lives: self.half_lives,
|
||||
..Self::default()
|
||||
};
|
||||
}
|
||||
@@ -798,10 +802,13 @@ impl FrameUniforms {
|
||||
// a tenth of a second a tick: Iris 1.11.7 smooths that slowly (measured frame by frame
|
||||
// with a probe pack: wetness 60 s rising, 20 s falling; eyeBrightnessSmooth 1 s;
|
||||
// centerDepthSmooth 0.1 s).
|
||||
// The pack's `wetnessHalflife`/`drynessHalflife` when it sets them (OptiFine's defaults
|
||||
// otherwise), as for `eyeBrightnessHalflife` and `centerDepthHalflife` below.
|
||||
let [wet, dry, eye_half, depth_half] = self.half_lives;
|
||||
let half_life = if inputs.rain > self.wetness {
|
||||
600.0
|
||||
wet.unwrap_or(600.0)
|
||||
} else {
|
||||
200.0
|
||||
dry.unwrap_or(200.0)
|
||||
};
|
||||
self.wetness = smooth(self.wetness, inputs.rain, half_life * SECONDS_A_TICK, dt);
|
||||
let eye = inputs.eye_light.map(|l| (l * 16) as f32);
|
||||
@@ -810,13 +817,18 @@ impl FrameUniforms {
|
||||
self.eye_smooth = eye;
|
||||
}
|
||||
for (smoothed, target) in self.eye_smooth.iter_mut().zip(eye) {
|
||||
*smoothed = smooth(*smoothed, target, 10.0 * SECONDS_A_TICK, dt);
|
||||
*smoothed = smooth(
|
||||
*smoothed,
|
||||
target,
|
||||
eye_half.unwrap_or(10.0) * SECONDS_A_TICK,
|
||||
dt,
|
||||
);
|
||||
}
|
||||
// `centerDepthHalflife`'s default, 1 tick (OptiFine's shaders.txt); the first value
|
||||
// read back is taken as it is.
|
||||
if let Some(raw) = self.center_depth_raw {
|
||||
self.center_depth = Some(match self.center_depth {
|
||||
Some(v) => smooth(v, raw, SECONDS_A_TICK, dt),
|
||||
Some(v) => smooth(v, raw, depth_half.unwrap_or(1.0) * SECONDS_A_TICK, dt),
|
||||
None => raw,
|
||||
});
|
||||
}
|
||||
@@ -1260,10 +1272,12 @@ mod tests {
|
||||
format!(r#"{{"attributes": {attributes}}}"#),
|
||||
)
|
||||
.unwrap();
|
||||
let environment =
|
||||
crate::environment::Environment::load_dimension(&root, name, false).unwrap();
|
||||
let environment = crate::environment::Environment::load_dimension(&root, name).unwrap();
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
environment.sample(6000, None, None)
|
||||
environment.sample(&crate::environment::Sample {
|
||||
day_time: 6000,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2218,22 +2232,33 @@ pub(super) mod initial_history_tests {
|
||||
let hud = HudBatch::new([800, 450]);
|
||||
let f = frame(&hud);
|
||||
let mut u = FrameUniforms::default();
|
||||
// The next frame comes `seconds` after the last (and a few microseconds of the test).
|
||||
let after = |u: &mut FrameUniforms, seconds: f32| {
|
||||
u.last = Some(std::time::Instant::now() - std::time::Duration::from_secs_f32(seconds));
|
||||
// A scripted clock: the next frame comes exactly `seconds` after the last, however long
|
||||
// the test itself takes (a wall-clock gap of a millisecond moved centerDepthSmooth by
|
||||
// more than the tolerance on a busy CI runner).
|
||||
let mut counter = 1;
|
||||
let mut elapsed = 0.0;
|
||||
let mut after = |input: &mut PackInputs, seconds: f32| {
|
||||
counter += 1;
|
||||
elapsed += seconds;
|
||||
input.frame_timing = Some(FrameTiming {
|
||||
counter,
|
||||
elapsed,
|
||||
delta: seconds,
|
||||
});
|
||||
};
|
||||
let mut input = PackInputs {
|
||||
eye_light: [0, 15],
|
||||
frame_timing: Some(FrameTiming::nth(1, 0.0)),
|
||||
..Default::default()
|
||||
};
|
||||
u.center_depth_raw = Some(1.0);
|
||||
u.update(&f, &input, [800, 450]);
|
||||
input.eye_light = [0, 0];
|
||||
u.center_depth_raw = Some(0.0);
|
||||
after(&mut u, 0.1);
|
||||
after(&mut input, 0.1);
|
||||
u.update(&f, &input, [800, 450]);
|
||||
assert!((float(&u, "centerDepthSmooth")[0] - 0.5).abs() < 1e-3);
|
||||
after(&mut u, 0.6);
|
||||
after(&mut input, 0.6);
|
||||
u.update(&f, &input, [800, 450]);
|
||||
// 240 falling toward 0 for 0.7 s: 147.8, read as 147.
|
||||
match u.values[index("eyeBrightnessSmooth").unwrap()] {
|
||||
@@ -2241,11 +2266,46 @@ pub(super) mod initial_history_tests {
|
||||
_ => panic!("integer uniform expected"),
|
||||
}
|
||||
input.rain = 1.0;
|
||||
after(&mut u, 60.0);
|
||||
after(&mut input, 60.0);
|
||||
u.update(&f, &input, [800, 450]);
|
||||
assert!((float(&u, "wetness")[0] - 0.5).abs() < 1e-3);
|
||||
input.rain = 0.0;
|
||||
after(&mut u, 20.0);
|
||||
after(&mut input, 20.0);
|
||||
u.update(&f, &input, [800, 450]);
|
||||
assert!((float(&u, "wetness")[0] - 0.25).abs() < 1e-3);
|
||||
}
|
||||
|
||||
/// A pack's `const float wetnessHalflife = 30.0;` (and its dryness) replace the defaults:
|
||||
/// wetness reaches half the rain in 3 s instead of 60 s.
|
||||
#[test]
|
||||
fn a_pack_s_half_lives_replace_the_defaults() {
|
||||
let hud = HudBatch::new([800, 450]);
|
||||
let f = frame(&hud);
|
||||
let mut u = FrameUniforms {
|
||||
half_lives: [Some(30.0), Some(10.0), None, None],
|
||||
..Default::default()
|
||||
};
|
||||
// A scripted clock, as in `smoothed_uniforms_take_iris_half_lives`: the wall clock's gap
|
||||
// between two updates is not exact on a busy runner.
|
||||
let mut input = PackInputs {
|
||||
frame_timing: Some(FrameTiming::nth(1, 0.0)),
|
||||
..Default::default()
|
||||
};
|
||||
u.update(&f, &input, [800, 450]);
|
||||
input.rain = 1.0;
|
||||
input.frame_timing = Some(FrameTiming {
|
||||
counter: 2,
|
||||
elapsed: 3.0,
|
||||
delta: 3.0,
|
||||
});
|
||||
u.update(&f, &input, [800, 450]);
|
||||
assert!((float(&u, "wetness")[0] - 0.5).abs() < 1e-3);
|
||||
input.rain = 0.0;
|
||||
input.frame_timing = Some(FrameTiming {
|
||||
counter: 3,
|
||||
elapsed: 4.0,
|
||||
delta: 1.0,
|
||||
});
|
||||
u.update(&f, &input, [800, 450]);
|
||||
assert!((float(&u, "wetness")[0] - 0.25).abs() < 1e-3);
|
||||
}
|
||||
|
||||
@@ -274,7 +274,13 @@ impl Panorama {
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_panorama"),
|
||||
compilation_options: Default::default(),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &[(
|
||||
"HDR_WRITE_LIGHT",
|
||||
f64::from(u8::from(format == super::LINEAR_FRAME)),
|
||||
)],
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: None,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// CubeMap's position-only cube and panorama.vsh/fsh: sample its untransformed direction.
|
||||
override HDR_WRITE_LIGHT: bool = false;
|
||||
struct Projection { view_proj: mat4x4<f32> }
|
||||
@group(0) @binding(0) var<uniform> projection: Projection;
|
||||
@group(0) @binding(1) var panorama: texture_cube<f32>;
|
||||
@@ -26,5 +27,6 @@ struct Out {
|
||||
return out;
|
||||
}
|
||||
@fragment fn fs_panorama(in: Out) -> @location(0) vec4<f32> {
|
||||
return textureSample(panorama, panorama_sampler, in.direction);
|
||||
let color = textureSample(panorama, panorama_sampler, in.direction);
|
||||
return vec4(color.rgb, select(color.a, 1.0, HDR_WRITE_LIGHT));
|
||||
}
|
||||
@@ -439,17 +439,18 @@ impl<T> Resident<T> {
|
||||
if list.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let min = origin((column.x, column.low, column.z), self.min_y, camera).as_vec3();
|
||||
let top = origin((column.x, column.high, column.z), self.min_y, camera).as_vec3();
|
||||
let max = top + Vec3::splat(16.0);
|
||||
let inside = frustum.contains(min, max);
|
||||
if !inside && !frustum.intersects(min, max) {
|
||||
continue;
|
||||
}
|
||||
let ox = f64::from(column.x * 16) - camera.x;
|
||||
let oz = f64::from(column.z * 16) - camera.z;
|
||||
let min_x = ox as f32;
|
||||
let min_z = oz as f32;
|
||||
let low_oy = (f64::from(self.min_y + column.low * 16) - camera.y) as f32;
|
||||
let high_oy = (f64::from(self.min_y + column.high * 16) - camera.y) as f32;
|
||||
let min = Vec3::new(min_x, low_oy, min_z);
|
||||
let max = Vec3::new(min_x + 16.0, high_oy + 16.0, min_z + 16.0);
|
||||
let inside = frustum.contains(min, max);
|
||||
if !inside && !frustum.intersects(min, max) {
|
||||
continue;
|
||||
}
|
||||
let dx8 = ox + 8.0;
|
||||
let dz8 = oz + 8.0;
|
||||
let xz_sq = dx8 * dx8 + dz8 * dz8;
|
||||
|
||||
@@ -22,5 +22,6 @@ struct Stars {
|
||||
if HDR_WRITE_LIGHT && stars.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_shares), b);
|
||||
}
|
||||
if HDR_WRITE_LIGHT { return vec4(stars.color.rgb * b, b); }
|
||||
return vec4(stars.color.rgb * b, 0.175);
|
||||
}
|
||||
@@ -79,7 +79,7 @@ pub struct Post {
|
||||
/// The last TAA frame's unjittered `view_proj` and camera, `None` once a frame ran without it.
|
||||
taa_previous: Option<(DMat4, DVec3)>,
|
||||
taa_this_frame: TaaUniform,
|
||||
light_key: Option<([f32; 4], [f32; 4], [[f32; 4]; 2])>,
|
||||
light_key: Option<(bool, [f32; 4], [f32; 4], [[f32; 4]; 2])>,
|
||||
}
|
||||
|
||||
impl Post {
|
||||
@@ -263,12 +263,21 @@ impl Post {
|
||||
}
|
||||
}
|
||||
|
||||
// History stores the baked gain as well as the picture. Reject it before jitter records
|
||||
// the next frame whenever that gain or the frame's representation changes.
|
||||
pub(super) fn light_representation(
|
||||
// History stores baked light, so a change must reject it before jitter records the next frame.
|
||||
pub(super) fn baked_light(
|
||||
&mut self,
|
||||
key: Option<([f32; 4], [f32; 4], [[f32; 4]; 2])>,
|
||||
gain: bool,
|
||||
settings: [f32; 4],
|
||||
shares: [f32; 4],
|
||||
windows: [[f32; 4]; 2],
|
||||
) {
|
||||
// These uniforms do not change baked colour without gain. Keep compatibility
|
||||
// history stable when only its final display settings change.
|
||||
let key = Some(if gain {
|
||||
(true, settings, shares, windows)
|
||||
} else {
|
||||
(false, [0.0; 4], [0.0; 4], [[0.0; 4]; 2])
|
||||
});
|
||||
if self.light_key != key {
|
||||
self.taa_previous = None;
|
||||
self.light_key = key;
|
||||
@@ -745,13 +754,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn written_light_changes_reject_history_before_the_next_jitter() {
|
||||
fn baked_light_changes_and_returning_from_a_pack_reject_taa_history() {
|
||||
let Some((device, _)) = test_device() else {
|
||||
eprintln!("TAA baked-light GPU skipped: no test device");
|
||||
return;
|
||||
};
|
||||
let mut post = Post::new(&device, wgpu::TextureFormat::Rgba16Float);
|
||||
post.taa = true;
|
||||
if !post.supports_taa() {
|
||||
eprintln!("TAA baked-light GPU skipped: TAA unsupported");
|
||||
return;
|
||||
}
|
||||
let key = (
|
||||
@@ -759,11 +770,11 @@ mod tests {
|
||||
[0.2, 0.5, 0.6, 1.0],
|
||||
[[0.1; 4], [0.4; 4]],
|
||||
);
|
||||
for key in [Some(key), None, Some(key)] {
|
||||
post.light_representation(key);
|
||||
for gain in [true, false, true] {
|
||||
post.baked_light(gain, key.0, key.1, key.2);
|
||||
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
|
||||
assert_eq!(post.taa_this_frame.jitter[2], 1.0);
|
||||
post.light_representation(key);
|
||||
post.baked_light(gain, key.0, key.1, key.2);
|
||||
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
|
||||
assert_eq!(post.taa_this_frame.jitter[2], 0.0);
|
||||
}
|
||||
@@ -774,10 +785,26 @@ mod tests {
|
||||
4..=7 => changed.1[field - 4] += 0.01,
|
||||
_ => changed.2[(field - 8) / 4][(field - 8) % 4] += 0.01,
|
||||
}
|
||||
post.light_representation(Some(changed));
|
||||
post.baked_light(true, changed.0, changed.1, changed.2);
|
||||
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
|
||||
assert_eq!(post.taa_this_frame.jitter[2], 1.0);
|
||||
}
|
||||
post.baked_light(false, key.0, key.1, key.2);
|
||||
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
|
||||
assert_eq!(post.taa_this_frame.jitter[2], 1.0);
|
||||
post.baked_light(false, [0.0; 4], [0.0; 4], [[0.0; 4]; 2]);
|
||||
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
|
||||
assert_eq!(post.taa_this_frame.jitter[2], 0.0);
|
||||
// A pack disables TAA even when the light settings stay the same. Its first
|
||||
// world frame after the pack is dropped must not reuse the pre-pack history.
|
||||
post.jitter([32, 32], false, Mat4::IDENTITY, DVec3::ZERO);
|
||||
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
|
||||
assert_eq!(post.taa_this_frame.jitter[2], 1.0);
|
||||
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
|
||||
assert_eq!(post.taa_this_frame.jitter[2], 0.0);
|
||||
eprintln!(
|
||||
"TAA baked-light GPU checked gain, all settings/shares/windows and the first frame after a pack"
|
||||
);
|
||||
}
|
||||
|
||||
fn test_device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
|
||||
@@ -0,0 +1,697 @@
|
||||
//! A native mod's world warp ([`native_world_warp`](super::native_render::native_world_warp),
|
||||
//! docs/native-mods.md "World warps"): WGSL that every terrain vertex, far terrain's tiles
|
||||
//! included, and entity vertex goes through, so a mod can bend the drawn world itself (its real
|
||||
//! blocks, light and depth, seen from any side) rather than a picture of it.
|
||||
//!
|
||||
//! The stock shaders hold only comment markers where a warp goes in (terrain.wgsl's
|
||||
//! `[world warp ...]`, entity.wgsl's and static_entity.wgsl's): a frame without a warp draws
|
||||
//! with exactly the shaders it always did. For a warp, copies of them get the markers filled,
|
||||
//! the declarations of `world_warp.wgsl`, the mod's WGSL and, for the terrain, the pixel stage of
|
||||
//! `world_warp_fragment.wgsl` with the mod's `world_fragment` or [`DEFAULT_FRAGMENT`]; the
|
||||
//! pipelines built from them read the warp's uniform at group 2.
|
||||
//!
|
||||
//! Those pipelines don't cull back faces (a warp may turn a face over), and a frame drawn with
|
||||
//! them leaves nothing out by where it was (`Renderer::render`): what is out of view, hidden or
|
||||
//! facing away may be brought into view. It also keeps to the per-section draws, which these
|
||||
//! pipelines are built for.
|
||||
use super::native_render::WorldWarp;
|
||||
use super::{SolidPipeline, TERRAIN_ATTRIBUTES, TerrainVertex, Vertex};
|
||||
use glam::DVec3;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The line in a warp's source after which the terrain's pixel stage begins.
|
||||
const FRAGMENTS: &str = "// [fragments]";
|
||||
/// The pixel stage of a warp without one: the terrain as the game draws it, less faces it cut.
|
||||
pub const DEFAULT_FRAGMENT: &str = "fn world_fragment(f: Fragment) -> Painted {
|
||||
if f.cut > 0.0 { return Painted(vec4(0.0, 0.0, 0.0, -1.0), 1.0); }
|
||||
return Painted(sprite(f.uv, f.layer) * f.color, 1.0);
|
||||
}
|
||||
";
|
||||
/// What terrain.wgsl's markers are filled with: the warp's two values in `TerrainOut`, and how
|
||||
/// they cross to the pixel stage in `TerrainWire`, after its five locations.
|
||||
const VARYINGS: &str = "warp_home: vec4<f32>,
|
||||
warp_at: vec3<f32>,";
|
||||
const WIRE: &str = "@location(5) warp_home: vec4<f32>,
|
||||
@location(6) warp_at: vec3<f32>,";
|
||||
const TO_WIRE: &str = "w.warp_home = o.warp_home;
|
||||
w.warp_at = o.warp_at;";
|
||||
const FROM_WIRE: &str = "o.warp_home = w.warp_home;
|
||||
o.warp_at = w.warp_at;";
|
||||
const VERTEX: &str = "let warp_home = position + origin;
|
||||
let warped = world_warp(warp_home, origin);
|
||||
let pos = warped.pos;";
|
||||
const VERTEX_OUT: &str = "out.color = out.color * vec4(max(warped.tint, vec3(0.0)), 1.0);
|
||||
out.warp_home = vec4(warp_home, select(0.0, 1.0, warped.tint.r < 0.0));
|
||||
out.warp_at = pos;";
|
||||
/// And entity.wgsl's and static_entity.wgsl's: an entity's vertex is its own `section`; a block
|
||||
/// entity's is its section's corner.
|
||||
const ENTITY: &str = "let at = world_warp(position, position).pos;";
|
||||
const STATIC_ENTITY: &str =
|
||||
"let section_corner = vec3<f32>(block_delta) - static_entity.camera_fraction.xyz;
|
||||
let camera_relative = world_warp(section_corner + position, section_corner).pos;";
|
||||
|
||||
/// The plainest warp, for the tests of the shaders it makes.
|
||||
#[cfg(test)]
|
||||
pub(super) const IDENTITY: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped { return Warped(pos, vec3(1.0)); }\n";
|
||||
|
||||
/// `shader` with what stands between the markers `// [name]` and `// [end name]` replaced.
|
||||
fn fill(shader: &str, name: &str, with: &str) -> Result<String, String> {
|
||||
let (from, to) = (format!("// [{name}]"), format!("// [end {name}]"));
|
||||
let a = shader
|
||||
.find(&from)
|
||||
.ok_or_else(|| format!("no `{from}` in the shader"))?;
|
||||
let b = shader[a..]
|
||||
.find(&to)
|
||||
.ok_or_else(|| format!("no `{to}` in the shader"))?
|
||||
+ a;
|
||||
Ok(format!("{}{with}{}", &shader[..a], &shader[b + to.len()..]))
|
||||
}
|
||||
|
||||
/// A warp's source split into what every warped shader takes and the terrain's pixel stage.
|
||||
fn split(source: &str) -> (&str, Option<&str>) {
|
||||
match source.find(FRAGMENTS) {
|
||||
Some(at) => (&source[..at], Some(&source[at + FRAGMENTS.len()..])),
|
||||
None => (source, None),
|
||||
}
|
||||
}
|
||||
|
||||
/// The warp's copy of terrain.wgsl.
|
||||
pub(super) fn terrain_shader(source: &str) -> Result<String, String> {
|
||||
let (vertex, fragment) = split(source);
|
||||
let fragment = fragment
|
||||
.filter(|f| f.contains("fn world_fragment"))
|
||||
.unwrap_or(DEFAULT_FRAGMENT);
|
||||
let shader = fill(&super::terrain_shader(), "world warp varyings", VARYINGS)?;
|
||||
let shader = fill(&shader, "world warp wire", WIRE)?;
|
||||
let shader = fill(&shader, "world warp to wire", TO_WIRE)?;
|
||||
let shader = fill(&shader, "world warp from wire", FROM_WIRE)?;
|
||||
let shader = fill(&shader, "world warp vertex", VERTEX)?;
|
||||
let shader = fill(&shader, "world warp out", VERTEX_OUT)?;
|
||||
Ok(format!(
|
||||
"{shader}\n{}\n{vertex}\n{}\n{fragment}",
|
||||
include_str!("world_warp.wgsl"),
|
||||
include_str!("world_warp_fragment.wgsl"),
|
||||
))
|
||||
}
|
||||
|
||||
/// The warp's copy of entity.wgsl with static_entity.wgsl, for every entity pipeline it draws.
|
||||
pub(super) fn entity_shader(source: &str) -> Result<String, String> {
|
||||
let (vertex, _) = split(source);
|
||||
let shader = fill(&super::static_entity_shader(), "world warp entity", ENTITY)?;
|
||||
let shader = fill(&shader, "world warp static entity", STATIC_ENTITY)?;
|
||||
Ok(format!(
|
||||
"{shader}\n{}\n{vertex}",
|
||||
include_str!("world_warp.wgsl")
|
||||
))
|
||||
}
|
||||
|
||||
fn validate(source: &str) -> Result<(), String> {
|
||||
let module = naga::front::wgsl::parse_str(source).map_err(|e| e.emit_to_string(source))?;
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.map_err(|e| e.emit_to_string(source))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The warp's uniform (`world_warp.wgsl`'s `WorldWarpUniform`): the mod's sixteen floats, then
|
||||
/// the camera split into f32 and what f32 left out of it, with the frame's partial tick.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct Uniform {
|
||||
params: [[f32; 4]; 4],
|
||||
camera: [f32; 4],
|
||||
camera_rest: [f32; 4],
|
||||
}
|
||||
|
||||
fn uniform(warp: &WorldWarp, camera: DVec3, partial_tick: f32) -> Uniform {
|
||||
let (params, _) = warp.params.as_chunks::<4>();
|
||||
let params: [[f32; 4]; 4] = params.try_into().expect("sixteen floats");
|
||||
let at = camera.as_vec3();
|
||||
let rest = camera - at.as_dvec3();
|
||||
Uniform {
|
||||
params,
|
||||
camera: [at.x, at.y, at.z, partial_tick],
|
||||
camera_rest: [rest.x as f32, rest.y as f32, rest.z as f32, 0.0],
|
||||
}
|
||||
}
|
||||
|
||||
/// What the warp's pipelines are built with: the renderer's own layouts and formats.
|
||||
pub(super) struct Kit {
|
||||
/// terrain.wgsl's group 0, and with flat draws the per-section draws' page group 1
|
||||
/// (`vs_section` reads its vertices from it).
|
||||
pub terrain: wgpu::BindGroupLayout,
|
||||
pub pages: Option<wgpu::BindGroupLayout>,
|
||||
/// entity.wgsl's group 0 and static_entity.wgsl's group 1.
|
||||
pub entity: wgpu::BindGroupLayout,
|
||||
pub static_entity: wgpu::BindGroupLayout,
|
||||
pub format: wgpu::TextureFormat,
|
||||
pub sprite_constants: [(&'static str, f64); 2],
|
||||
/// Whether the display offers HDR output: the solid terrain is built lit by it too.
|
||||
pub hdr_output: bool,
|
||||
}
|
||||
|
||||
/// The warp's pipelines, in place of the renderer's while it is on.
|
||||
pub(super) struct Pipelines {
|
||||
pub solid: SolidPipeline,
|
||||
pub translucent: wgpu::RenderPipeline,
|
||||
pub entities: wgpu::RenderPipeline,
|
||||
pub static_entities: wgpu::RenderPipeline,
|
||||
pub translucent_items: wgpu::RenderPipeline,
|
||||
pub text: wgpu::RenderPipeline,
|
||||
pub entity_shadows: wgpu::RenderPipeline,
|
||||
pub hdr_mark: wgpu::RenderPipeline,
|
||||
pub hdr_entity_mark: wgpu::RenderPipeline,
|
||||
}
|
||||
|
||||
/// The warp's uniform, and what binds it.
|
||||
struct Bound {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
buffer: wgpu::Buffer,
|
||||
group: wgpu::BindGroup,
|
||||
}
|
||||
|
||||
/// The renderer's world warp: nothing made until a mod gives one.
|
||||
pub(super) struct WorldWarps {
|
||||
kit: Kit,
|
||||
bound: Option<Bound>,
|
||||
built: Option<(Arc<str>, Pipelines)>,
|
||||
/// The last source that didn't compile, so it is tried once.
|
||||
refused: Option<Arc<str>>,
|
||||
/// Whether the log said a warp went undrawn under a shader pack drawing the terrain.
|
||||
told_undrawable: bool,
|
||||
}
|
||||
|
||||
impl WorldWarps {
|
||||
pub fn new(kit: Kit) -> Self {
|
||||
Self {
|
||||
kit,
|
||||
bound: None,
|
||||
built: None,
|
||||
refused: None,
|
||||
told_undrawable: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Readies this frame's warp: builds its pipelines when its source is new and writes its
|
||||
/// uniform. Whether the frame draws with it: not when it is off, didn't compile, or the
|
||||
/// frame can't take it (`drawable`: a shader pack draws the terrain).
|
||||
pub fn frame(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
uploads: &mut super::upload::Uploads,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
warp: &WorldWarp,
|
||||
camera: DVec3,
|
||||
partial_tick: f32,
|
||||
drawable: bool,
|
||||
) -> bool {
|
||||
let Some(source) = &warp.source else {
|
||||
return false;
|
||||
};
|
||||
let same = |s: &Arc<str>| Arc::ptr_eq(s, source) || **s == **source;
|
||||
if !self.built.as_ref().is_some_and(|(s, _)| same(s)) {
|
||||
if self.refused.as_ref().is_some_and(same) {
|
||||
return false;
|
||||
}
|
||||
match self.build(device, source) {
|
||||
Ok(pipelines) => self.built = Some((source.clone(), pipelines)),
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: a mod's world warp did not compile: {e}");
|
||||
self.refused = Some(source.clone());
|
||||
self.built = None;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !warp.on {
|
||||
return false;
|
||||
}
|
||||
if !drawable {
|
||||
if !self.told_undrawable {
|
||||
log::info!(
|
||||
"Horizon: a mod's world warp isn't drawn while a shader pack draws the terrain"
|
||||
);
|
||||
self.told_undrawable = true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
let bound = self.bound(device);
|
||||
uploads.buffer(
|
||||
encoder,
|
||||
&bound.buffer,
|
||||
0,
|
||||
bytemuck::bytes_of(&uniform(warp, camera, partial_tick)),
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
/// The pipelines of the warp `frame` readied.
|
||||
pub fn pipelines(&self) -> Option<&Pipelines> {
|
||||
self.built.as_ref().map(|(_, p)| p)
|
||||
}
|
||||
|
||||
/// The warp's group 2, once a frame has drawn with it.
|
||||
pub fn group(&self) -> Option<&wgpu::BindGroup> {
|
||||
self.bound.as_ref().map(|b| &b.group)
|
||||
}
|
||||
|
||||
fn bound(&mut self, device: &wgpu::Device) -> &Bound {
|
||||
self.bound.get_or_insert_with(|| bind(device))
|
||||
}
|
||||
|
||||
fn build(&mut self, device: &wgpu::Device, source: &str) -> Result<Pipelines, String> {
|
||||
let started = std::time::Instant::now();
|
||||
let terrain = terrain_shader(source)?;
|
||||
let entity = entity_shader(source)?;
|
||||
validate(&terrain)?;
|
||||
validate(&entity)?;
|
||||
// Validation failures above are the mod's; past here wgpu's are the game's.
|
||||
let warp = &self.bound.get_or_insert_with(|| bind(device)).layout;
|
||||
let kit = &self.kit;
|
||||
let layout = |label, groups: &[Option<&wgpu::BindGroupLayout>]| {
|
||||
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some(label),
|
||||
bind_group_layouts: groups,
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
let terrain_layout = layout(
|
||||
"Warped terrain layout",
|
||||
&[Some(&kit.terrain), None, Some(warp)],
|
||||
);
|
||||
let pages_layout = kit.pages.as_ref().map(|pages| {
|
||||
layout(
|
||||
"Warped terrain layout (pulled)",
|
||||
&[Some(&kit.terrain), Some(pages), Some(warp)],
|
||||
)
|
||||
});
|
||||
let entity_layout = layout(
|
||||
"Warped entity layout",
|
||||
&[Some(&kit.entity), None, Some(warp)],
|
||||
);
|
||||
let static_layout = layout(
|
||||
"Warped static block-entity layout",
|
||||
&[Some(&kit.entity), Some(&kit.static_entity), Some(warp)],
|
||||
);
|
||||
let module = |label, source: String| {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(label),
|
||||
source: wgpu::ShaderSource::Wgsl(source.into()),
|
||||
})
|
||||
};
|
||||
let terrain = module("Warped terrain", terrain);
|
||||
let entity = module("Warped entities", entity);
|
||||
|
||||
let instance_attributes = wgpu::vertex_attr_array![5=>Float32x4];
|
||||
let instances = wgpu::VertexBufferLayout {
|
||||
array_stride: 16,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &instance_attributes,
|
||||
};
|
||||
let vertices = wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<TerrainVertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &TERRAIN_ATTRIBUTES,
|
||||
};
|
||||
let buffered = [Some(vertices), Some(instances.clone())];
|
||||
let pulled = [None, Some(instances)];
|
||||
// As gfx.rs's: a float frame takes light where others take marks.
|
||||
let write_light = (
|
||||
"HDR_WRITE_LIGHT",
|
||||
f64::from(u8::from(kit.format == super::LINEAR_FRAME)),
|
||||
);
|
||||
let lit = [
|
||||
kit.sprite_constants[0],
|
||||
kit.sprite_constants[1],
|
||||
write_light,
|
||||
];
|
||||
let plain = [
|
||||
kit.sprite_constants[0],
|
||||
kit.sprite_constants[1],
|
||||
("HDR_LIGHTING", 0.0),
|
||||
write_light,
|
||||
];
|
||||
let depth = |write| wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(write),
|
||||
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
};
|
||||
// As gfx.rs's terrain pipelines, without back-face culling.
|
||||
let terrain_pipeline = |label,
|
||||
layout: &wgpu::PipelineLayout,
|
||||
vertex,
|
||||
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
|
||||
fragment,
|
||||
constants: &[(&str, f64)],
|
||||
blend| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &terrain,
|
||||
entry_point: Some(vertex),
|
||||
compilation_options: Default::default(),
|
||||
buffers,
|
||||
},
|
||||
primitive: wgpu::PrimitiveState::default(),
|
||||
depth_stencil: Some(depth(true)),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &terrain,
|
||||
entry_point: Some(fragment),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants,
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: kit.format,
|
||||
blend,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
// The per-section solid draws read the arena page with flat draws (`vs_section`), and
|
||||
// vertex buffers without.
|
||||
let (solid_layout, solid_vertex, solid_buffers): (_, _, &[_]) = match &pages_layout {
|
||||
Some(pages) => (pages, "vs_section", &pulled),
|
||||
None => (&terrain_layout, "vs_terrain", &buffered),
|
||||
};
|
||||
let solid = |constants: &[(&str, f64)]| {
|
||||
terrain_pipeline(
|
||||
"Warped terrain solid",
|
||||
solid_layout,
|
||||
solid_vertex,
|
||||
solid_buffers,
|
||||
"fs_warp_solid",
|
||||
constants,
|
||||
None,
|
||||
)
|
||||
};
|
||||
let solid = SolidPipeline {
|
||||
plain: solid(&plain),
|
||||
hdr: kit.hdr_output.then(|| solid(&lit)),
|
||||
};
|
||||
let translucent = terrain_pipeline(
|
||||
"Warped terrain translucent",
|
||||
&terrain_layout,
|
||||
"vs_terrain",
|
||||
&buffered,
|
||||
"fs_warp_translucent",
|
||||
&lit,
|
||||
Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
);
|
||||
|
||||
let entity_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
|
||||
let entity_vertices = wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &entity_attributes,
|
||||
};
|
||||
let origin_attributes = wgpu::vertex_attr_array![7=>Sint32x3];
|
||||
let static_buffers = [
|
||||
Some(entity_vertices.clone()),
|
||||
Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<[i32; 3]>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &origin_attributes,
|
||||
}),
|
||||
];
|
||||
let entity_buffers = [Some(entity_vertices)];
|
||||
// As gfx.rs's `entity_pipeline_with`, for the pipelines a warped frame swaps.
|
||||
let entity_pipeline = |look: &EntityLook| {
|
||||
let (layout, vertex, buffers): (_, _, &[_]) = if look.static_mesh {
|
||||
(&static_layout, "vs_static_entity", &static_buffers)
|
||||
} else {
|
||||
(&entity_layout, "vs_entity", &entity_buffers)
|
||||
};
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(look.label),
|
||||
layout: Some(layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &entity,
|
||||
entry_point: Some(vertex),
|
||||
compilation_options: Default::default(),
|
||||
buffers,
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: look.cull,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
bias: look.bias,
|
||||
..depth(look.depth_write)
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &entity,
|
||||
entry_point: Some(look.fragment),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &[write_light],
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: kit.format,
|
||||
blend: look.blend,
|
||||
write_mask: look.write_mask,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let pipelines = Pipelines {
|
||||
solid,
|
||||
translucent,
|
||||
entities: entity_pipeline(&ENTITIES),
|
||||
static_entities: entity_pipeline(&STATIC_ENTITIES),
|
||||
translucent_items: entity_pipeline(&TRANSLUCENT_ITEMS),
|
||||
text: entity_pipeline(&TEXT),
|
||||
entity_shadows: entity_pipeline(&ENTITY_SHADOWS),
|
||||
hdr_mark: entity_pipeline(&HDR_MARK),
|
||||
hdr_entity_mark: entity_pipeline(&HDR_ENTITY_MARK),
|
||||
};
|
||||
log::info!(
|
||||
"Horizon: a mod's world warp built in {} ms",
|
||||
started.elapsed().as_millis()
|
||||
);
|
||||
Ok(pipelines)
|
||||
}
|
||||
}
|
||||
|
||||
fn bind(device: &wgpu::Device) -> Bound {
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("World warp"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
});
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("World warp"),
|
||||
size: std::mem::size_of::<Uniform>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("World warp"),
|
||||
layout: &layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: buffer.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
Bound {
|
||||
layout,
|
||||
buffer,
|
||||
group,
|
||||
}
|
||||
}
|
||||
|
||||
/// How one of the entity pipelines a warped frame swaps draws: as gfx.rs builds the stock one
|
||||
/// of the same name.
|
||||
struct EntityLook {
|
||||
label: &'static str,
|
||||
static_mesh: bool,
|
||||
cull: Option<wgpu::Face>,
|
||||
depth_write: bool,
|
||||
bias: wgpu::DepthBiasState,
|
||||
blend: Option<wgpu::BlendState>,
|
||||
fragment: &'static str,
|
||||
write_mask: wgpu::ColorWrites,
|
||||
}
|
||||
const NO_BIAS: wgpu::DepthBiasState = wgpu::DepthBiasState {
|
||||
constant: 0,
|
||||
slope_scale: 0.0,
|
||||
clamp: 0.0,
|
||||
};
|
||||
const ENTITIES: EntityLook = EntityLook {
|
||||
label: "Warped entities",
|
||||
static_mesh: false,
|
||||
cull: None,
|
||||
depth_write: true,
|
||||
bias: wgpu::DepthBiasState {
|
||||
constant: 128,
|
||||
slope_scale: 0.0,
|
||||
clamp: 0.0,
|
||||
},
|
||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
fragment: "fs_entity",
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
};
|
||||
const STATIC_ENTITIES: EntityLook = EntityLook {
|
||||
label: "Warped static block entities",
|
||||
static_mesh: true,
|
||||
bias: NO_BIAS,
|
||||
blend: None,
|
||||
fragment: "fs_entity_unmarked",
|
||||
..ENTITIES
|
||||
};
|
||||
const TRANSLUCENT_ITEMS: EntityLook = EntityLook {
|
||||
label: "Warped translucent items",
|
||||
bias: NO_BIAS,
|
||||
..ENTITIES
|
||||
};
|
||||
const TEXT: EntityLook = EntityLook {
|
||||
label: "Warped polygon offset text",
|
||||
cull: Some(wgpu::Face::Back),
|
||||
bias: wgpu::DepthBiasState {
|
||||
constant: 10,
|
||||
slope_scale: 1.0,
|
||||
clamp: 0.0,
|
||||
},
|
||||
fragment: "fs_entity_unmarked",
|
||||
..ENTITIES
|
||||
};
|
||||
const ENTITY_SHADOWS: EntityLook = EntityLook {
|
||||
label: "Warped entity shadows",
|
||||
cull: Some(wgpu::Face::Back),
|
||||
depth_write: false,
|
||||
bias: wgpu::DepthBiasState {
|
||||
constant: 128,
|
||||
slope_scale: 0.015,
|
||||
clamp: 0.0,
|
||||
},
|
||||
fragment: "fs_entity_shadow",
|
||||
..ENTITIES
|
||||
};
|
||||
const HDR_MARK: EntityLook = EntityLook {
|
||||
label: "Warped HDR particle mark",
|
||||
depth_write: false,
|
||||
bias: NO_BIAS,
|
||||
blend: Some(wgpu::BlendState::REPLACE),
|
||||
fragment: "fs_hdr_mark",
|
||||
write_mask: wgpu::ColorWrites::ALPHA,
|
||||
..ENTITIES
|
||||
};
|
||||
/// `HDR_MARK` at the entities' depth bias, for what they drew.
|
||||
const HDR_ENTITY_MARK: EntityLook = EntityLook {
|
||||
label: "Warped HDR entity mark",
|
||||
bias: ENTITIES.bias,
|
||||
..HDR_MARK
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const BEND: &str = "fn world_warp(pos: vec3<f32>, section: vec3<f32>) -> Warped {
|
||||
let lift = sin(pos.x * 0.1 + warp.params[0].x) * warp.params[0].y + section.y * 0.0;
|
||||
return Warped(pos + vec3(0.0, lift, 0.0), vec3(1.0));
|
||||
}
|
||||
";
|
||||
|
||||
#[test]
|
||||
fn a_warp_without_fragments_draws_the_terrain_as_the_game_does() {
|
||||
let terrain = terrain_shader(BEND).unwrap();
|
||||
validate(&terrain).unwrap();
|
||||
assert!(terrain.contains(DEFAULT_FRAGMENT));
|
||||
let entity = entity_shader(BEND).unwrap();
|
||||
validate(&entity).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_warps_own_fragments_replace_the_default_and_stay_out_of_the_entities() {
|
||||
let source = format!(
|
||||
"{BEND}{FRAGMENTS}
|
||||
fn world_fragment(f: Fragment) -> Painted {{
|
||||
if !f.front || f.light.x < 1.0 {{ return Painted(vec4(0.0, 0.0, 0.0, -1.0), 1.0); }}
|
||||
let far = length(f.home - f.at) + f.cut;
|
||||
return Painted(sprite(f.uv * 2.0, f.layer) * f.color, 1.0 - min(far, 1.0));
|
||||
}}
|
||||
"
|
||||
);
|
||||
let terrain = terrain_shader(&source).unwrap();
|
||||
validate(&terrain).unwrap();
|
||||
assert!(!terrain.contains(DEFAULT_FRAGMENT));
|
||||
let entity = entity_shader(&source).unwrap();
|
||||
validate(&entity).unwrap();
|
||||
assert!(!entity.contains("world_fragment"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_stock_shaders_hold_nothing_of_a_warp() {
|
||||
for (name, shader) in [
|
||||
("terrain", super::super::terrain_shader()),
|
||||
("entity", super::super::static_entity_shader()),
|
||||
] {
|
||||
validate(&shader).unwrap();
|
||||
for word in ["world_warp(", "warp_home", "Warped", "group(2)"] {
|
||||
assert!(!shader.contains(word), "{name}.wgsl holds {word}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_marker_is_found() {
|
||||
let terrain = terrain_shader(BEND).unwrap();
|
||||
let entity = entity_shader(BEND).unwrap();
|
||||
for shader in [&terrain, &entity] {
|
||||
assert!(!shader.contains("// [world warp"), "a marker left unfilled");
|
||||
}
|
||||
assert!(terrain.contains("let warped = world_warp(warp_home, origin);"));
|
||||
assert!(entity.contains("let at = world_warp(position, position).pos;"));
|
||||
assert!(entity.contains("world_warp(section_corner + position, section_corner)"));
|
||||
assert_eq!(
|
||||
fill("a // [x] b // [end x] c", "x", "B"),
|
||||
Ok("a B c".to_owned())
|
||||
);
|
||||
assert!(fill("a // [x] b", "x", "B").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_warp_that_does_not_compile_is_refused_with_naga_s_reason() {
|
||||
let terrain =
|
||||
terrain_shader("fn world_warp(pos: vec3<f32>) -> Warped { return 1; }").unwrap();
|
||||
let e = validate(&terrain).unwrap_err();
|
||||
assert!(e.contains("world_warp"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_uniform_keeps_the_camera_exact_far_out() {
|
||||
let mut warp = WorldWarp::default();
|
||||
warp.params[5] = 2.0;
|
||||
let camera = DVec3::new(29_999_984.3, 70.25, -12_345_678.9);
|
||||
let u = uniform(&warp, camera, 0.25);
|
||||
assert_eq!(u.params[1][1], 2.0);
|
||||
assert_eq!(u.camera[3], 0.25);
|
||||
for i in 0..3 {
|
||||
let whole = f64::from(u.camera[i]) + f64::from(u.camera_rest[i]);
|
||||
assert!((whole - camera[i]).abs() < 1e-6, "{whole} {}", camera[i]);
|
||||
}
|
||||
assert_eq!(std::mem::size_of::<Uniform>(), 96);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// A native mod's world warp (gfx/world_warp.rs, docs/native-mods.md "World warps"): what the
|
||||
// game declares around the mod's WGSL in the warp's own copies of terrain.wgsl and entity.wgsl.
|
||||
// None of it is in the shaders a frame without a warp draws with.
|
||||
|
||||
// What `world_warp` makes of a vertex: where to draw it, from the camera, and what to multiply
|
||||
// its colour by (terrain only; red under 0 marks its faces cut: `Fragment.cut`).
|
||||
struct Warped {
|
||||
pos: vec3<f32>,
|
||||
tint: vec3<f32>,
|
||||
}
|
||||
struct WorldWarpUniform {
|
||||
// `WorldWarp::params`.
|
||||
params: array<vec4<f32>, 4>,
|
||||
// xyz: the camera in the world as f32; `camera_rest.xyz` what f32 left out of it, so
|
||||
// `(p - warp.camera.xyz) - warp.camera_rest.xyz` is the world's point `p` from the camera,
|
||||
// exact far from the world's origin. w: the frame's partial tick, 0 to 1, for motion
|
||||
// smoother than the ticks `params` are set in.
|
||||
camera: vec4<f32>,
|
||||
camera_rest: vec4<f32>,
|
||||
}
|
||||
@group(2) @binding(0) var<uniform> warp: WorldWarpUniform;
|
||||
@@ -0,0 +1,70 @@
|
||||
// A world warp's terrain pixels (gfx/world_warp.rs): added to the warp's copy of terrain.wgsl
|
||||
// only. `world_fragment` is the mod's if its source has one after `// [fragments]`, else
|
||||
// `DEFAULT_FRAGMENT` in gfx/world_warp.rs (the terrain as the game draws it).
|
||||
|
||||
// A pixel of the land as `world_fragment` gets it.
|
||||
struct Fragment {
|
||||
// Where this point of the world really is, from the camera: `world_warp`'s `pos`.
|
||||
home: vec3<f32>,
|
||||
// Where it is drawn, from the camera.
|
||||
at: vec3<f32>,
|
||||
// `sprite(uv, layer)` is the block's texture here.
|
||||
uv: vec2<f32>,
|
||||
layer: u32,
|
||||
// The vertices' colour: biome tint, light, ambient occlusion and the warp's tint.
|
||||
color: vec4<f32>,
|
||||
// Sky and block light, 0 to 15.
|
||||
light: vec2<f32>,
|
||||
// Over 0 where a vertex of the face had a tint with red under 0.
|
||||
cut: f32,
|
||||
// False on a face's back: the warp's pipelines don't cull, so both sides draw.
|
||||
front: bool,
|
||||
}
|
||||
// What `world_fragment` makes of it: a colour (alpha under 0 leaves the pixel out) and how much
|
||||
// of the game's fog it takes, 0 to 1.
|
||||
struct Painted {
|
||||
color: vec4<f32>,
|
||||
fog: f32,
|
||||
}
|
||||
fn warp_fragment(in: TerrainOut, front: bool) -> Fragment {
|
||||
return Fragment(in.warp_home.xyz, in.warp_at, in.uv, in.layer & 0x7fffu, in.color,
|
||||
in.scene.yx, in.warp_home.w, front);
|
||||
}
|
||||
// As `solid_color`, with the mod's colour in place of the texture's.
|
||||
@fragment fn fs_warp_solid(wire: TerrainWire, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
let in = from_wire(wire);
|
||||
let dx = dpdx(in.local);
|
||||
let dy = dpdy(in.local);
|
||||
if HDR_LIGHTING && globals.hdr_light.z != 0.0 {
|
||||
hdr_normal = normalize(cross(dy, dx));
|
||||
}
|
||||
// (far terrain's tiles and the sections replacing them still cross-fade)
|
||||
if faded(in.fade, in.local, dx, dy) { discard; }
|
||||
let p = world_fragment(warp_fragment(in, front));
|
||||
if p.color.a < 0.0 { discard; }
|
||||
let color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, p.color.a), p.color, in.visibility);
|
||||
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
|
||||
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
|
||||
let finished = solid_finish_at(in, color, color, dpdx(in.uv), dpdy(in.uv));
|
||||
return solid_light(vec4(mix(color.rgb, finished.rgb, p.fog), finished.a));
|
||||
}
|
||||
// As `fs_translucent`.
|
||||
@fragment fn fs_warp_translucent(wire: TerrainWire, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
let in = from_wire(wire);
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
|
||||
hdr_normal = normalize(cross(dpdy(in.local), dpdx(in.local)));
|
||||
}
|
||||
let p = world_fragment(warp_fragment(in, front));
|
||||
if p.color.a < 0.0 { discard; }
|
||||
let color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, p.color.a), p.color, in.visibility);
|
||||
if color.a < 0.01 { discard; }
|
||||
let fogged = apply_fog(color, in.spherical, in.cylindrical);
|
||||
let finished = vec4(mix(color.rgb, fogged.rgb, p.fog), color.a);
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
|
||||
let fog = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
|
||||
let mark = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()), globals.hdr_light.y, fog));
|
||||
return vec4(hdr_written(finished.rgb, mark, globals.hdr_settings, globals.hdr_shares), finished.a);
|
||||
}
|
||||
return finished;
|
||||
}
|
||||
@@ -13,7 +13,7 @@ use crate::{
|
||||
lang,
|
||||
options_ui::{Event, Page as OptionsPage},
|
||||
settings::{Opt, Settings},
|
||||
settings_shell::{self as shell, Group, Output, Row, Screen, Sheet, Value},
|
||||
settings_shell::{self as shell, Binding, Group, Output, Row, Screen, Sheet, Value},
|
||||
text::Text,
|
||||
};
|
||||
|
||||
@@ -1333,7 +1333,7 @@ fn shown(item: Item, s: &Settings) -> bool {
|
||||
/// A control's label as its name and its value ("Render Distance" and "12 chunks").
|
||||
fn parts(item: Item, s: &Settings) -> (String, Option<String>) {
|
||||
// The shortcut's label is one translated "Shortcut: %s"; the row shows its key apart, as
|
||||
// KeyBindsScreen does ("> key <" while it waits, `settings_shell::Value::Key`).
|
||||
// KeyBindsScreen does ("> key <" while it waits, `settings_shell::Value::Binding`).
|
||||
if item == Item::Horizon(H::HdrKey) {
|
||||
let key = crate::keybinds::display_name(&s.horizon.hdr_key).plain();
|
||||
return (lang::get("Shortcut"), Some(key));
|
||||
@@ -1347,9 +1347,10 @@ fn row(setting: Setting, s: &Settings) -> Row<Item> {
|
||||
let item = setting.item;
|
||||
let (name, value) = parts(item, s);
|
||||
let value = match (item, item.switch(s), item.slider(s)) {
|
||||
(Item::Horizon(H::HdrKey), ..) => {
|
||||
Value::Key(crate::graphics::HDR_KEY, value.unwrap_or_default())
|
||||
}
|
||||
(Item::Horizon(H::HdrKey), ..) => Value::Binding(Binding::key(
|
||||
crate::graphics::HDR_KEY,
|
||||
value.unwrap_or_default(),
|
||||
)),
|
||||
(_, Some(on), _) => Value::Switch(on),
|
||||
(_, None, Some(position)) => Value::Slider {
|
||||
position,
|
||||
@@ -1486,7 +1487,6 @@ impl Screen for GraphicsScreen {
|
||||
Output::Push(OptionsPage::ShaderPacks),
|
||||
]
|
||||
}
|
||||
Item::Horizon(H::HdrKey) => vec![Output::BindKey(crate::graphics::HDR_KEY)],
|
||||
_ => {
|
||||
item.cycle(s, back, self.framebuffer);
|
||||
vec![Output::Event(Event::Changed)]
|
||||
@@ -1564,7 +1564,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
graphics_style as style,
|
||||
settings_shell::{Columns, Hosted, Shell, Target},
|
||||
settings_shell::{Capture, Columns, Hosted, Shell, Target},
|
||||
};
|
||||
use winit::keyboard::KeyCode as K;
|
||||
|
||||
@@ -1868,7 +1868,7 @@ mod tests {
|
||||
let key = row(&mut ui, &s, Item::Horizon(H::HdrKey));
|
||||
assert_eq!(
|
||||
press(&mut ui, &mut s, at(key)),
|
||||
[CLICK, Output::BindKey(crate::graphics::HDR_KEY)]
|
||||
[CLICK, Output::Bind(Capture::Key(crate::graphics::HDR_KEY))]
|
||||
);
|
||||
assert!(ui.changed());
|
||||
}
|
||||
@@ -2092,7 +2092,8 @@ mod tests {
|
||||
);
|
||||
let key = find(&s, Item::Horizon(H::HdrKey));
|
||||
assert_eq!(key.name, "Shortcut");
|
||||
assert!(matches!(key.value, Value::Key(crate::graphics::HDR_KEY, _)));
|
||||
let waits = Capture::Key(crate::graphics::HDR_KEY);
|
||||
assert!(matches!(key.value, Value::Binding(ref b) if b.capture == waits));
|
||||
let calibrate = find(&s, Item::Horizon(H::HdrCalibrate));
|
||||
assert_eq!(calibrate.value, Value::Press);
|
||||
}
|
||||
@@ -2394,6 +2395,19 @@ mod tests {
|
||||
assert_eq!(s.horizon.entity_culling, EntityCullingMode::Off);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_and_right_step_a_focused_choice_back_and_on() {
|
||||
let mut s = Settings::default();
|
||||
let mut ui = ui();
|
||||
row(&mut ui, &s, Item::Horizon(H::EntityCulling));
|
||||
focus_on(&mut ui, Item::Horizon(H::EntityCulling));
|
||||
assert_eq!(key(&mut ui, &mut s, K::ArrowLeft), [CLICK, CHANGED]);
|
||||
assert_eq!(s.horizon.entity_culling, EntityCullingMode::Asynchronous);
|
||||
assert_eq!(key(&mut ui, &mut s, K::ArrowRight), [CLICK, CHANGED]);
|
||||
assert_eq!(s.horizon.entity_culling, EntityCullingMode::Off);
|
||||
assert_eq!(ui.focused_row(), Some(Item::Horizon(H::EntityCulling)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_culling_precheck_slider_is_only_live_for_async() {
|
||||
let item = Item::Horizon(H::EntityCullingPrecheck);
|
||||
|
||||
@@ -34,7 +34,7 @@ struct Params {
|
||||
// Horizon's logo, 1 inside, 0 outside, for calibration's test pictures.
|
||||
@group(0) @binding(3) var logo: texture_2d<f32>;
|
||||
// The float frame with HUD coverage (Hdr::layered): the frame keeps the world as light
|
||||
// (fs_resolve), the HUD draws on a picture of its own (`hud`, fs_keep), and `coverage` says how
|
||||
// directly from its writers; the HUD draws on a picture of its own (`hud`, fs_keep), and `coverage` says how
|
||||
// much of the world it hides there (gfx/hud_coverage.rs).
|
||||
@group(0) @binding(4) var coverage: texture_2d<f32>;
|
||||
@group(0) @binding(5) var hud: texture_2d<f32>;
|
||||
@@ -211,16 +211,6 @@ fn display(light: vec3<f32>) -> vec4<f32> {
|
||||
return display(light);
|
||||
}
|
||||
|
||||
// Hdr::resolve, after the world and the hand: the marks they left (`world` holds the frame as
|
||||
// they drew it) become light in the frame, in gamma as the frame stores it, alpha 1, past the
|
||||
// peak if they lift it there (the tone curve eases it in). What draws after it (camera overlays,
|
||||
// the menu blur) draws on light, and nothing is lifted later.
|
||||
@fragment fn fs_resolve(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let w = frame_texel(textureLoad(world, vec2<i32>(pos.xy), 0));
|
||||
let shows = params.world > 0.5 && params.peak > 1.0;
|
||||
return vec4(pow(lit(w.rgb, w.a, shows, false).light, vec3(1.0 / 2.2)), 1.0);
|
||||
}
|
||||
|
||||
// The tone curve's knee: up to it light stays as it is. Where HDR lighting eased into the peak
|
||||
// before the curve (`lit`), 60% of it, so bright textures keep their detail; at least paper white.
|
||||
fn tone_knee(headroom: f32) -> f32 {
|
||||
|
||||
@@ -1355,7 +1355,15 @@ impl HudAtlas {
|
||||
.filter(|&size| size != (w, h));
|
||||
let flags = mcmeta.as_deref().and_then(TextureFlags::from_mcmeta);
|
||||
let animated = frames && animation.is_some();
|
||||
let (gw, gh) = gui_size.unwrap_or((w, h));
|
||||
// The cap below keeps GUI textures small; a particle sprite isn't capped, as
|
||||
// vanilla's particle atlas stitches any size (packs draw light shafts and fog
|
||||
// banks from 1024-wide textures).
|
||||
let particle = name.starts_with("particle/") || name.contains(":particle/");
|
||||
let (gw, gh) = if particle {
|
||||
(0, 0)
|
||||
} else {
|
||||
gui_size.unwrap_or((w, h))
|
||||
};
|
||||
let maps = read_maps
|
||||
.then(|| {
|
||||
let stem = path.file_stem()?.to_str()?;
|
||||
@@ -3427,6 +3435,32 @@ mod tests {
|
||||
assert!(!atlas.sprites.contains_key("gems:item/ruby"));
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
/// A pack's particle sprite of any size packs whole, as vanilla's particle atlas stitches
|
||||
/// it (a light shaft drawn from a 1024-wide texture); the cap on GUI textures stays.
|
||||
#[test]
|
||||
fn large_particle_sprites_pack_and_large_gui_textures_dont() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-atlas-big-particle-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let rays = root.join("assets/mypack/textures/particle/light_rays");
|
||||
fs::create_dir_all(&rays).unwrap();
|
||||
write_strip(
|
||||
&rays.join("02.png"),
|
||||
1024,
|
||||
1024,
|
||||
&[[250, 240, 200, 255]],
|
||||
None,
|
||||
);
|
||||
let gui = root.join("assets/minecraft/textures/gui");
|
||||
fs::create_dir_all(&gui).unwrap();
|
||||
write_strip(&gui.join("huge.png"), 2048, 64, &[[1, 2, 3, 255]], None);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let rect = atlas.sprites["mypack:particle/light_rays/02"];
|
||||
assert_eq!(rect.texels((atlas.width, atlas.height)), (1024, 1024));
|
||||
assert_eq!(texel_at(&atlas, rect, 512, 512), [250, 240, 200, 255]);
|
||||
assert!(!atlas.sprites.contains_key("gui/huge"));
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
/// OptiFine random entity variants outside `textures/` pack under the names
|
||||
/// `RandomTextures` gives them; one beside its texture packs once, under its own.
|
||||
#[test]
|
||||
|
||||
@@ -12,8 +12,6 @@ impl Gpu {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
if !hud_coverage::supported(
|
||||
(false, true, true),
|
||||
true,
|
||||
adapter.limits().max_color_attachments,
|
||||
adapter.get_downlevel_capabilities().flags,
|
||||
adapter.get_texture_format_features(hud_coverage::FORMAT),
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
//! The HUD kit: small immediate-mode pieces Horizon's own overlays share, so they fit around
|
||||
//! vanilla's HUD and look alike (`docs/ui-kit.md`). Nothing here reaches gameplay; the game
|
||||
//! builds the inputs (`Game::hud_room`) and the overlays draw with them.
|
||||
//!
|
||||
//! - `room`: what of a view an overlay may use, clear of the boss bars.
|
||||
//! - `plate`: the box behind a notice's text.
|
||||
//!
|
||||
//! Vanilla's own display lists (`hud_layout`, `screen_draw`) never use the kit: they stay
|
||||
//! exactly vanilla's.
|
||||
pub mod plate;
|
||||
pub mod room;
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Plates: the boxes Horizon's HUD overlays draw behind their text, one place for each look so
|
||||
//! overlays that mean the same thing look the same.
|
||||
//!
|
||||
//! A plate is drawn first, then the overlay's text and glyphs over it. Only the kinds the
|
||||
//! overlays use today are here; each keeps the colours its overlay always had. Today that is
|
||||
//! one, shared by the controller notice and splitscreen's prompts.
|
||||
//!
|
||||
//! ```
|
||||
//! use horizon_client_render::hud_kit::plate::Plate;
|
||||
//!
|
||||
//! // A notice's box: a dark fill and a faint 1 px edge inside its area.
|
||||
//! assert_eq!(Plate::Notice.fill(), 0xD200_0000);
|
||||
//! assert_eq!(Plate::Notice.edge(), Some(0x5AFF_FFFF));
|
||||
//! ```
|
||||
//!
|
||||
//! In a draw: `plate::draw(batch, atlas, Plate::Notice, [x, y, w, h])`, the area in GUI px.
|
||||
|
||||
use crate::hud::{HudAtlas, HudBatch, rgba};
|
||||
|
||||
/// A plate's look.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Plate {
|
||||
/// Something to read now: controller notices and splitscreen's prompts. A dark fill with a
|
||||
/// faint edge.
|
||||
Notice,
|
||||
}
|
||||
|
||||
impl Plate {
|
||||
/// The fill colour as `0xAARRGGBB`.
|
||||
pub fn fill(self) -> u32 {
|
||||
match self {
|
||||
Self::Notice => 0xD200_0000,
|
||||
}
|
||||
}
|
||||
|
||||
/// The 1 GUI px edge's colour as `0xAARRGGBB`, drawn inside the area; `None` for none.
|
||||
pub fn edge(self) -> Option<u32> {
|
||||
match self {
|
||||
Self::Notice => Some(0x5AFF_FFFF),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `[a, r, g, b]` of `0xAARRGGBB` as the batch's colour.
|
||||
fn colour(argb: u32) -> u32 {
|
||||
let [a, r, g, b] = argb.to_be_bytes();
|
||||
rgba(r, g, b, a)
|
||||
}
|
||||
|
||||
/// Draws `plate` over `[x, y, w, h]` (GUI px): its fill, then its edge inside the area.
|
||||
pub fn draw(batch: &mut HudBatch, atlas: &HudAtlas, plate: Plate, [x, y, w, h]: [f32; 4]) {
|
||||
batch.fill(atlas, x, y, w, h, colour(plate.fill()));
|
||||
if let Some(edge) = plate.edge() {
|
||||
let edge = colour(edge);
|
||||
for [ex, ey, ew, eh] in [
|
||||
[x, y, w, 1.0],
|
||||
[x, y + h - 1.0, w, 1.0],
|
||||
[x, y, 1.0, h],
|
||||
[x + w - 1.0, y, 1.0, h],
|
||||
] {
|
||||
batch.fill(atlas, ex, ey, ew, eh, edge);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
//! Room: what of a view Horizon's own HUD overlays may use. Vanilla's HUD elements are
|
||||
//! keep-outs; an overlay asks the room where it fits instead of assuming a fixed spot, so it
|
||||
//! doesn't cover them. Today the only keep-out is the boss bars at the top centre.
|
||||
//!
|
||||
//! The room is built once a frame from what the HUD shows (`Game::hud_room` in gameplay). With
|
||||
//! nothing showing it is empty, and every overlay stays exactly where it always was.
|
||||
//!
|
||||
//! All sizes are GUI px, from the view's top left. An area is `[x, y, w, h]`.
|
||||
//!
|
||||
//! ```
|
||||
//! use horizon_client_render::hud_kit::room::{Keep, Room};
|
||||
//! use horizon_client_render::hud_overlays::boss_bars_bottom;
|
||||
//!
|
||||
//! // A centred notice wants y = 18.
|
||||
//! let empty = Room::new(320.0, 240.0);
|
||||
//! assert_eq!(empty.below_top(18.0), 18.0);
|
||||
//! // One boss bar ends at y 17: no overlap, so the notice stays.
|
||||
//! let one = Room::new(320.0, 240.0).with(Keep::BossBars, [69.0, 0.0, 182.0, 17.0]);
|
||||
//! assert_eq!(one.below_top(18.0), 18.0);
|
||||
//! // Two end at y 36: the notice goes below them, a gap past.
|
||||
//! let bars = boss_bars_bottom(240, 2) as f32;
|
||||
//! let two = Room::new(320.0, 240.0).with(Keep::BossBars, [69.0, 0.0, 182.0, bars]);
|
||||
//! assert_eq!(two.below_top(18.0), bars + 2.0);
|
||||
//! ```
|
||||
|
||||
/// An area in GUI px: `[x, y, w, h]` from the view's top left.
|
||||
pub type Area = [f32; 4];
|
||||
|
||||
/// The gap, in GUI px, an overlay leaves below a keep-out it moved past.
|
||||
pub const GAP: f32 = 2.0;
|
||||
|
||||
/// A HUD element Horizon's overlays keep clear of.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Keep {
|
||||
/// The boss bars and their names at the top centre (`hud_overlays::boss_bars_bottom`).
|
||||
BossBars,
|
||||
}
|
||||
|
||||
/// A view and the keep-outs its HUD shows this frame.
|
||||
///
|
||||
/// Invariants: the view's size is what the overlays draw in (the `HudBatch`'s size), and an
|
||||
/// empty keep-out (no width or height) is never stored, so a room built from hidden elements
|
||||
/// equals `Room::new`.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Room {
|
||||
width: f32,
|
||||
height: f32,
|
||||
keeps: Vec<(Keep, Area)>,
|
||||
}
|
||||
|
||||
impl Room {
|
||||
/// A `width` x `height` GUI px view with nothing to keep clear of.
|
||||
pub fn new(width: f32, height: f32) -> Self {
|
||||
Self {
|
||||
width,
|
||||
height,
|
||||
keeps: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// This room with `area` kept clear of as `keep`; an empty area is ignored.
|
||||
pub fn with(mut self, keep: Keep, area: Area) -> Self {
|
||||
self.add(keep, area);
|
||||
self
|
||||
}
|
||||
|
||||
/// Keeps clear of `area` as `keep`; an empty area is ignored.
|
||||
pub fn add(&mut self, keep: Keep, area: Area) {
|
||||
if area[2] > 0.0 && area[3] > 0.0 {
|
||||
self.keeps.push((keep, area));
|
||||
}
|
||||
}
|
||||
|
||||
/// The view's width in GUI px.
|
||||
pub fn width(&self) -> f32 {
|
||||
self.width
|
||||
}
|
||||
|
||||
/// The view's height in GUI px.
|
||||
pub fn height(&self) -> f32 {
|
||||
self.height
|
||||
}
|
||||
|
||||
/// The keep-outs, in the order they were added.
|
||||
pub fn keeps(&self) -> impl Iterator<Item = (Keep, Area)> + '_ {
|
||||
self.keeps.iter().copied()
|
||||
}
|
||||
|
||||
/// How far, in GUI px from the view's top, the keep-outs at the top reach down: the boss
|
||||
/// bars' bottom, or 0 with none.
|
||||
pub fn top(&self) -> f32 {
|
||||
self.keeps()
|
||||
.map(|(Keep::BossBars, [_, y, _, h])| y + h)
|
||||
.fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
/// Where a centred overlay that wants its top at `wanted` (GUI px) goes: at `wanted` unless
|
||||
/// a keep-out reaches past it (`top() > wanted`, a real overlap; touching isn't one), else
|
||||
/// `GAP` below that keep-out.
|
||||
pub fn below_top(&self, wanted: f32) -> f32 {
|
||||
let top = self.top();
|
||||
if top > wanted { top + GAP } else { wanted }
|
||||
}
|
||||
|
||||
/// The view below the top keep-outs, with `GAP` past them: where a centred overlay may go.
|
||||
/// The whole view when nothing is kept clear of.
|
||||
pub fn usable(&self) -> Area {
|
||||
let top = self.top();
|
||||
let top = if top > 0.0 { top + GAP } else { 0.0 };
|
||||
[0.0, top, self.width, (self.height - top).max(0.0)]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_empty_room_is_the_whole_view_and_moves_nothing() {
|
||||
let room = Room::new(320.0, 240.0).with(Keep::BossBars, [69.0, 0.0, 182.0, 0.0]);
|
||||
assert_eq!(room, Room::new(320.0, 240.0));
|
||||
assert_eq!(room.usable(), [0.0, 0.0, 320.0, 240.0]);
|
||||
assert_eq!(room.below_top(18.0), 18.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_overlay_moves_only_when_the_bars_reach_past_it() {
|
||||
let bars =
|
||||
|bottom: f32| Room::new(500.0, 300.0).with(Keep::BossBars, [159.0, 0.0, 182.0, bottom]);
|
||||
// Ending above or at its top: no overlap.
|
||||
assert_eq!(bars(17.0).below_top(18.0), 18.0);
|
||||
assert_eq!(bars(18.0).below_top(18.0), 18.0);
|
||||
// Past it: below, a gap away.
|
||||
assert_eq!(bars(36.0).below_top(18.0), 38.0);
|
||||
assert_eq!(bars(36.0).usable(), [0.0, 38.0, 500.0, 262.0]);
|
||||
}
|
||||
}
|
||||
@@ -50,10 +50,14 @@ pub fn text_with_backdrop(
|
||||
text(out, t, x, y, color, true);
|
||||
}
|
||||
|
||||
/// `Arrays.sort(T[], Comparator)` for the short arrays the HUD sorts: Java's
|
||||
/// TimSort below `MIN_MERGE` (a natural run, then binary insertion), which
|
||||
/// matters when the comparator is not a total order. Longer arrays fall back
|
||||
/// to a stable sort.
|
||||
/// `Arrays.sort(T[], Comparator)` for the short arrays the HUD sorts. Its Javadoc
|
||||
/// says it is adapted from Tim Peters's list sort for Python, and for fewer than 32
|
||||
/// elements that sort is one run plus binary insertion, as Tim Peters describes it
|
||||
/// in `listsort.txt` (CPython's `Objects/listsort.txt`): take the run at the start,
|
||||
/// reversed in place when it is strictly descending, then insert each remaining
|
||||
/// element by binary search after the elements equal to it, so the sort is stable.
|
||||
/// The order of the comparisons matters because vanilla's comparator here is not a
|
||||
/// total order. Longer arrays fall back to a stable sort.
|
||||
pub fn java_sort<T>(a: &mut [T], cmp: impl Fn(&T, &T) -> Ordering) {
|
||||
let n = a.len();
|
||||
if n < 2 {
|
||||
@@ -63,7 +67,7 @@ pub fn java_sort<T>(a: &mut [T], cmp: impl Fn(&T, &T) -> Ordering) {
|
||||
a.sort_by(cmp);
|
||||
return;
|
||||
}
|
||||
// countRunAndMakeAscending.
|
||||
// The run at the start: strictly descending (then reversed) or non-descending.
|
||||
let mut run = 2;
|
||||
if cmp(&a[1], &a[0]) == Ordering::Less {
|
||||
while run < n && cmp(&a[run], &a[run - 1]) == Ordering::Less {
|
||||
@@ -75,7 +79,7 @@ pub fn java_sort<T>(a: &mut [T], cmp: impl Fn(&T, &T) -> Ordering) {
|
||||
run += 1;
|
||||
}
|
||||
}
|
||||
// binarySort.
|
||||
// Binary insertion of the rest, each element after the last one not greater.
|
||||
for start in run..n {
|
||||
let (mut left, mut right) = (0, start);
|
||||
while left < right {
|
||||
@@ -571,6 +575,41 @@ pub fn boss_overlay(w: i32, h: i32, bars: &[BossBar], font: &Font) -> Vec<GuiDra
|
||||
out
|
||||
}
|
||||
|
||||
/// `BossHealthOverlay`'s first bar's top edge, in GUI px (its name sits 9 px above).
|
||||
pub const BOSS_BAR_TOP: i32 = 12;
|
||||
/// `BossHealthOverlay`'s step from one bar to the next, in GUI px (`10 + 9`).
|
||||
pub const BOSS_BAR_STEP: i32 = 10 + 9;
|
||||
/// A boss bar's sprite height, in GUI px.
|
||||
pub const BOSS_BAR_HEIGHT: i32 = 5;
|
||||
/// A boss bar's sprite width, in GUI px; it is centred on the view.
|
||||
pub const BOSS_BAR_WIDTH: i32 = 182;
|
||||
|
||||
/// How many of `n` boss bars `BossHealthOverlay.extractRenderState` draws on a view `h` GUI px
|
||||
/// tall: it stops once the next bar's top reaches a third of the height (integer division,
|
||||
/// as vanilla's `guiHeight() / 3`), but always draws the first.
|
||||
pub fn boss_bars_drawn(h: i32, n: usize) -> usize {
|
||||
let mut drawn = 0;
|
||||
let mut y = BOSS_BAR_TOP;
|
||||
while drawn < n {
|
||||
drawn += 1;
|
||||
y += BOSS_BAR_STEP;
|
||||
if y >= h / 3 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
drawn
|
||||
}
|
||||
|
||||
/// The bottom edge, in GUI px from the view's top, of the last of `n` boss bars a view `h` GUI
|
||||
/// px tall shows (`boss_overlay`'s layout); 0 when there are none. Names sit above their bar,
|
||||
/// so nothing of the overlay reaches below this.
|
||||
pub fn boss_bars_bottom(h: i32, n: usize) -> i32 {
|
||||
match boss_bars_drawn(h, n) {
|
||||
0 => 0,
|
||||
k => BOSS_BAR_TOP + BOSS_BAR_STEP * (k as i32 - 1) + BOSS_BAR_HEIGHT,
|
||||
}
|
||||
}
|
||||
|
||||
/// `JumpableVehicleBar.extractBackground`: `cooldown` is the mount's jump
|
||||
/// cooldown and `scale` the player's `getJumpRidingScale()`.
|
||||
pub fn jump_bar(w: i32, h: i32, cooldown: i32, scale: f32) -> Vec<GuiDraw> {
|
||||
@@ -1381,3 +1420,64 @@ mod text_position_tests {
|
||||
assert_eq!(relative, Some(-4));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod boss_bar_room_tests {
|
||||
use super::*;
|
||||
|
||||
/// `BossHealthOverlay.extractRenderState` worked by hand: bars at y 12, 31, 50, 69, 88...,
|
||||
/// each 5 tall, until the next top reaches `guiHeight() / 3`.
|
||||
#[test]
|
||||
fn boss_bars_end_where_vanillas_overlay_stops() {
|
||||
assert_eq!(boss_bars_bottom(300, 0), 0);
|
||||
assert_eq!(boss_bars_bottom(300, 1), 17);
|
||||
assert_eq!(boss_bars_bottom(300, 2), 36);
|
||||
assert_eq!(boss_bars_bottom(300, 3), 55);
|
||||
// A third of 300 is 100: the fifth bar is the last (its next top would be 107).
|
||||
assert_eq!(boss_bars_drawn(300, 9), 5);
|
||||
assert_eq!(boss_bars_bottom(300, 9), 93);
|
||||
// A short view still draws the first.
|
||||
assert_eq!(boss_bars_bottom(60, 3), 17);
|
||||
// `guiHeight() / 3` is integer division: 152 / 3 = 50 stops at two, 153 / 3 = 51 not.
|
||||
assert_eq!(boss_bars_drawn(152, 3), 2);
|
||||
assert_eq!(boss_bars_drawn(153, 3), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boss_bars_bottom_is_below_every_drawn_bar() {
|
||||
// The missing-glyph metrics: no game assets needed.
|
||||
let font = Font::load(
|
||||
std::path::Path::new("/nonexistent-horizon-hud-test-resources"),
|
||||
&std::collections::HashMap::new(),
|
||||
&[],
|
||||
(1, 1),
|
||||
(0, 0),
|
||||
crate::hud::Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 1,
|
||||
h: 1,
|
||||
},
|
||||
);
|
||||
let bars: Vec<BossBar> = (0..6)
|
||||
.map(|_| BossBar {
|
||||
name: Text::literal(""),
|
||||
progress: 1.0,
|
||||
color: 0,
|
||||
overlay: 0,
|
||||
})
|
||||
.collect();
|
||||
for h in [60, 120, 152, 153, 240, 300, 600] {
|
||||
let bottom = boss_overlay(400, h, &bars, &font)
|
||||
.iter()
|
||||
.filter_map(|d| match d {
|
||||
GuiDraw::Sprite { y, h, .. } => Some(y + h),
|
||||
_ => None,
|
||||
})
|
||||
.max();
|
||||
assert_eq!(bottom, Some(boss_bars_bottom(h, bars.len())), "height {h}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,7 @@ pub mod graphics_style;
|
||||
pub mod graphics_ui;
|
||||
pub mod hdr_calibration;
|
||||
pub mod hud;
|
||||
pub mod hud_kit;
|
||||
pub mod hud_layout;
|
||||
pub mod hud_overlays;
|
||||
pub mod inventory_effects;
|
||||
@@ -109,6 +110,7 @@ pub mod shader_pack_list;
|
||||
pub mod shader_ui;
|
||||
pub mod signs;
|
||||
pub mod skins;
|
||||
pub mod social;
|
||||
pub mod sound_engine;
|
||||
pub mod sound_events;
|
||||
pub mod sound_preview;
|
||||
|
||||
@@ -653,15 +653,17 @@ fn pack_lighting(
|
||||
sky_light: bool,
|
||||
) -> Vec<Light> {
|
||||
let open = if sky_light { 0xF0 } else { 0x00 };
|
||||
let mut data = [open; 4096];
|
||||
(0..sections)
|
||||
.map(|si| {
|
||||
if si * 16 >= h {
|
||||
return Light::Uniform(open);
|
||||
}
|
||||
let mut data = Box::new([open; 4096]);
|
||||
for ly in 0..16 {
|
||||
let y = si * 16 + ly;
|
||||
if y >= h {
|
||||
let out_row = ly << 8;
|
||||
data[out_row..out_row + 256].fill(open);
|
||||
continue;
|
||||
}
|
||||
let y_offset = y * (W * W);
|
||||
@@ -674,7 +676,7 @@ fn pack_lighting(
|
||||
}
|
||||
}
|
||||
}
|
||||
Light::packed(data)
|
||||
Light::from_slice(&data)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1281,7 +1283,7 @@ fn relight_layer(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use crate::chunk::{HEIGHT, MIN_Y, SECTIONS};
|
||||
|
||||
@@ -1310,13 +1312,13 @@ mod tests {
|
||||
assert_eq!(dirty(&[(0, 0, 0)]).len(), 8);
|
||||
}
|
||||
|
||||
const AIR: u32 = 0;
|
||||
pub(crate) const AIR: u32 = 0;
|
||||
const STONE: u32 = 1;
|
||||
const TORCH: u32 = 2;
|
||||
pub(crate) const TORCH: u32 = 2;
|
||||
const SLAB: u32 = 3;
|
||||
/// Air, stone, torch and a bottom slab (full bottom face, half sides,
|
||||
/// empty top), with a third face shape for the half sides.
|
||||
fn tables() -> LightTables {
|
||||
pub(crate) fn tables() -> LightTables {
|
||||
let cell = |dampening, emission, faces: [u16; 6]| Cell {
|
||||
dampening,
|
||||
emission,
|
||||
@@ -1335,7 +1337,7 @@ mod tests {
|
||||
3,
|
||||
)
|
||||
}
|
||||
fn world(edit: impl Fn(&mut Chunk)) -> HashMap<(i32, i32), Arc<Chunk>> {
|
||||
pub(crate) fn world(edit: impl Fn(&mut Chunk)) -> HashMap<(i32, i32), Arc<Chunk>> {
|
||||
world_of(crate::chunk::ColumnLayout::VANILLA, edit)
|
||||
}
|
||||
/// Lit chunks around (0, 0) of `layout`, stone up to y 0.
|
||||
|
||||
@@ -7,9 +7,11 @@ use crate::{
|
||||
mesher::SectionMesh,
|
||||
streaming::{LodSource, WorkerContext},
|
||||
};
|
||||
pub mod bake;
|
||||
pub mod cache;
|
||||
mod mesh;
|
||||
pub mod packed;
|
||||
mod samples;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
@@ -957,7 +959,7 @@ type Reduced = FxHashMap<(i32, i32), Arc<ReducedColumns>>;
|
||||
/// every tile's inputs from it (gathering on the render thread stalled the
|
||||
/// frame, tens of milliseconds for a large job on a 4-thread machine).
|
||||
struct Snapshot {
|
||||
samples: FxHashMap<TileKey, Arc<Vec<Column>>>,
|
||||
samples: samples::Samples,
|
||||
saved: Saved,
|
||||
reduced: Reduced,
|
||||
/// The plan's tiles, to tell terrain not drawn yet from terrain out of
|
||||
@@ -1267,6 +1269,8 @@ pub struct Lod {
|
||||
/// Stale tiles `refresh`ed again while sampled: that sample may be older.
|
||||
restale: FxHashSet<TileKey>,
|
||||
active: FxHashSet<TileKey>,
|
||||
/// The active tiles' `ready` samples, as mesh jobs read them (`sync_sample`).
|
||||
mesh_samples: samples::Samples,
|
||||
/// Tiles needing a mesh, with the revision their job must carry.
|
||||
dirty: FxHashMap<TileKey, u64>,
|
||||
/// The newest revision of each active tile's mesh.
|
||||
@@ -1657,6 +1661,7 @@ impl Lod {
|
||||
ready: FxHashMap::default(),
|
||||
complete: FxHashMap::default(),
|
||||
active: FxHashSet::default(),
|
||||
mesh_samples: samples::Samples::default(),
|
||||
dirty: FxHashMap::default(),
|
||||
mesh_version: FxHashMap::default(),
|
||||
shown_version: FxHashMap::default(),
|
||||
@@ -1935,9 +1940,11 @@ impl Lod {
|
||||
if active != was_active {
|
||||
if active {
|
||||
self.active.insert(node);
|
||||
self.sync_sample(node);
|
||||
self.removed_pending.remove(&node);
|
||||
} else {
|
||||
self.active.remove(&node);
|
||||
self.sync_sample(node);
|
||||
self.removed_pending.insert(node);
|
||||
self.cancel_mesh(node);
|
||||
self.mesh_version.remove(&node);
|
||||
@@ -2210,6 +2217,7 @@ impl Lod {
|
||||
}
|
||||
}
|
||||
for key in modified {
|
||||
self.sync_sample(key);
|
||||
self.mark_data_changed(key);
|
||||
}
|
||||
}
|
||||
@@ -2271,6 +2279,7 @@ impl Lod {
|
||||
return;
|
||||
}
|
||||
let existed = self.ready.insert(key, Arc::new(samples)).is_some();
|
||||
self.sync_sample(key);
|
||||
self.refresh_ready(key, existed);
|
||||
}
|
||||
}
|
||||
@@ -2505,6 +2514,8 @@ impl Lod {
|
||||
.collect();
|
||||
self.plan = plan;
|
||||
self.ready.retain(|key, _| self.plan.nodes.contains(key));
|
||||
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`).
|
||||
drop_queued_samples(&self.shared, &mut self.pending, |key| {
|
||||
@@ -2572,6 +2583,7 @@ impl Lod {
|
||||
.retain(|key| !self.active.contains(key));
|
||||
let changed: Vec<_> = old.symmetric_difference(&self.active).copied().collect();
|
||||
for &key in &changed {
|
||||
self.sync_sample(key);
|
||||
if old.contains(&key) && !self.active.contains(&key) {
|
||||
self.cancel_mesh(key);
|
||||
self.removed_pending.insert(key);
|
||||
@@ -2849,6 +2861,14 @@ impl Lod {
|
||||
if self.outstanding_meshes >= self.mesh_workers * 4 {
|
||||
return;
|
||||
}
|
||||
// As in `request_samples`: the far meshing threads run behind the render thread, so it
|
||||
// never waits for this lock. Busy now, the dirty tiles stay dirty for the next frame.
|
||||
let shared = self.shared.clone();
|
||||
let mut queued = match shared.mesh_jobs.try_lock() {
|
||||
Ok(queued) => queued,
|
||||
Err(std::sync::TryLockError::WouldBlock) => return,
|
||||
Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
|
||||
};
|
||||
let keys: Vec<(TileKey, u64)> = self
|
||||
.dirty
|
||||
.drain()
|
||||
@@ -2857,19 +2877,16 @@ impl Lod {
|
||||
if keys.is_empty() {
|
||||
return;
|
||||
}
|
||||
#[cfg(debug_assertions)]
|
||||
self.check_mesh_samples();
|
||||
let snapshot = Arc::new(Snapshot {
|
||||
samples: self
|
||||
.active
|
||||
.iter()
|
||||
.filter_map(|key| self.ready.get(key).map(|columns| (*key, columns.clone())))
|
||||
.collect(),
|
||||
samples: self.mesh_samples.clone(),
|
||||
saved: self.saved.clone(),
|
||||
reduced: self.reduced.clone(),
|
||||
nodes: self.plan.nodes.clone(),
|
||||
split: self.plan.split.clone(),
|
||||
});
|
||||
let covered = Arc::new(self.covered.clone());
|
||||
let mut queued = self.shared.mesh_jobs.lock().unwrap();
|
||||
let count = keys.len();
|
||||
for (key, version) in keys {
|
||||
let finer = self.finer_of(key);
|
||||
@@ -2895,6 +2912,31 @@ impl Lod {
|
||||
self.outstanding_meshes += count;
|
||||
self.shared.mesh_wake.notify_all();
|
||||
}
|
||||
/// Brings `mesh_samples` up to date for `key` after its `active` or `ready` changed.
|
||||
fn sync_sample(&mut self, key: TileKey) {
|
||||
let samples = self.ready.get(&key).filter(|_| self.active.contains(&key));
|
||||
self.mesh_samples.set(key, samples);
|
||||
}
|
||||
/// `mesh_samples` holds every active tile's ready samples and nothing else (debug builds).
|
||||
#[cfg(debug_assertions)]
|
||||
fn check_mesh_samples(&self) {
|
||||
let kept = self.mesh_samples.iter().count();
|
||||
let mut want = 0;
|
||||
for key in &self.active {
|
||||
if let Some(columns) = self.ready.get(key) {
|
||||
want += 1;
|
||||
let got = self.mesh_samples.get(key);
|
||||
assert!(
|
||||
got.is_some_and(|got| Arc::ptr_eq(got, columns)),
|
||||
"mesh samples of {key:?} out of date"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
kept, want,
|
||||
"mesh samples keep tiles no longer active or ready"
|
||||
);
|
||||
}
|
||||
fn finer_of(&self, key: TileKey) -> Vec<TileKey> {
|
||||
if !self.plan.split.contains(&key) {
|
||||
return Vec::new();
|
||||
@@ -2955,7 +2997,14 @@ impl Lod {
|
||||
self.wanted_stale = false;
|
||||
}
|
||||
let mut issued = 0;
|
||||
let mut jobs = self.shared.jobs.lock().unwrap();
|
||||
// The far sampling threads take this lock too, and they run behind the render thread
|
||||
// (`far_terrain_current`): one preempted while holding it kept a frame waiting for over
|
||||
// 100 ms on a busy machine. Busy now, the requests go out next frame.
|
||||
let mut jobs = match self.shared.jobs.try_lock() {
|
||||
Ok(jobs) => jobs,
|
||||
Err(std::sync::TryLockError::WouldBlock) => return,
|
||||
Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
|
||||
};
|
||||
while issued < free {
|
||||
let Some(std::cmp::Reverse(wanted)) = self.wanted.pop() else {
|
||||
break;
|
||||
@@ -4852,6 +4901,7 @@ mod tests {
|
||||
lod.active.insert(key);
|
||||
lod.ready
|
||||
.insert(key, Arc::new(vec![Column::default(); CELLS * CELLS]));
|
||||
lod.sync_sample(key);
|
||||
lod.mark_mesh(key);
|
||||
lod.set_near_loading(true);
|
||||
lod.dispatch_meshes([0, 0]);
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
//! Baked far terrain: the generator's far samples (`LodSampler::grid`), kept
|
||||
//! in the dimension's folder (`horizon_lod/`) so the next session draws them
|
||||
//! without sampling the noise again. The detail chunks' columns are kept by
|
||||
//! `lod::cache`.
|
||||
//!
|
||||
//! Only generator output is baked: saved chunks still override it
|
||||
//! (`save::SavedLodSampler`), so edits show as before. A fingerprint of the
|
||||
//! game build, the world's data packs, the block and biome registries and the
|
||||
//! level's shape guards the folder; when any of them changes it is emptied.
|
||||
//! Reduced far detail's approximate samples are kept apart from full ones.
|
||||
use super::{Column, Span, Surface};
|
||||
use crate::{chunk::LevelShape, fast_hash::FxHashMap};
|
||||
use flate2::{Compression, read::ZlibDecoder, write::ZlibEncoder};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
fs,
|
||||
io::{BufReader, Read, Seek, SeekFrom, Write},
|
||||
path::{Path, PathBuf},
|
||||
sync::Mutex,
|
||||
};
|
||||
|
||||
const FORMAT: &str = "horizon-lod-bake 3";
|
||||
const FOLDER: &str = "horizon_lod";
|
||||
const FINGERPRINT: &str = "fingerprint.txt";
|
||||
/// Tiles per bucket file side.
|
||||
const GRID_BUCKET: i32 = 8;
|
||||
/// The key written before each record: kind, x, z, stride, side.
|
||||
const KEY_BYTES: usize = 15;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
enum Key {
|
||||
/// `LodSampler::grid(x, z, stride, side)`, sampled approximately or not.
|
||||
Grid {
|
||||
approximate: bool,
|
||||
x: i32,
|
||||
z: i32,
|
||||
stride: i32,
|
||||
side: u16,
|
||||
},
|
||||
}
|
||||
impl Key {
|
||||
fn file(self) -> String {
|
||||
match self {
|
||||
Key::Grid {
|
||||
approximate,
|
||||
x,
|
||||
z,
|
||||
stride,
|
||||
side,
|
||||
} => {
|
||||
let span = stride * i32::from(side) * GRID_BUCKET;
|
||||
format!(
|
||||
"{}{stride}x{side}.{}.{}.bin",
|
||||
if approximate { "a" } else { "g" },
|
||||
x.div_euclid(span),
|
||||
z.div_euclid(span)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
fn bytes(self) -> [u8; KEY_BYTES] {
|
||||
let (kind, x, z, stride, side) = match self {
|
||||
Key::Grid {
|
||||
approximate,
|
||||
x,
|
||||
z,
|
||||
stride,
|
||||
side,
|
||||
} => (u8::from(approximate), x, z, stride, side),
|
||||
};
|
||||
let mut out = [0; KEY_BYTES];
|
||||
out[0] = kind;
|
||||
out[1..5].copy_from_slice(&x.to_le_bytes());
|
||||
out[5..9].copy_from_slice(&z.to_le_bytes());
|
||||
out[9..13].copy_from_slice(&stride.to_le_bytes());
|
||||
out[13..15].copy_from_slice(&side.to_le_bytes());
|
||||
out
|
||||
}
|
||||
fn from_bytes(b: &[u8; KEY_BYTES]) -> Option<Self> {
|
||||
let int = |at: usize| i32::from_le_bytes(b[at..at + 4].try_into().unwrap());
|
||||
match b[0] {
|
||||
kind @ (0 | 1) => Some(Key::Grid {
|
||||
approximate: kind == 1,
|
||||
x: int(1),
|
||||
z: int(5),
|
||||
stride: int(9),
|
||||
side: u16::from_le_bytes([b[13], b[14]]),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One append-only file of records: `[u32 length][key][zlib columns]`.
|
||||
struct Bucket {
|
||||
file: fs::File,
|
||||
/// Each record's compressed columns: offset and length.
|
||||
index: FxHashMap<Key, (u64, u32)>,
|
||||
end: u64,
|
||||
}
|
||||
impl Bucket {
|
||||
fn open(path: &Path) -> std::io::Result<Self> {
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(path)?;
|
||||
let mut index = FxHashMap::default();
|
||||
let mut end = 0u64;
|
||||
let len = file.metadata()?.len();
|
||||
{
|
||||
let mut reader = BufReader::new(&mut file);
|
||||
loop {
|
||||
let mut length = [0; 4];
|
||||
if reader.read_exact(&mut length).is_err() {
|
||||
break;
|
||||
}
|
||||
let length = u32::from_le_bytes(length);
|
||||
let mut key = [0; KEY_BYTES];
|
||||
if (length as usize) < KEY_BYTES || reader.read_exact(&mut key).is_err() {
|
||||
break;
|
||||
}
|
||||
let Some(key) = Key::from_bytes(&key) else {
|
||||
break;
|
||||
};
|
||||
let body = length - KEY_BYTES as u32;
|
||||
let start = end + 4 + KEY_BYTES as u64;
|
||||
// A record cut short by a crash ends the file there.
|
||||
if start + u64::from(body) > len || reader.seek_relative(i64::from(body)).is_err() {
|
||||
break;
|
||||
}
|
||||
index.insert(key, (start, body));
|
||||
end = start + u64::from(body);
|
||||
}
|
||||
}
|
||||
if len != end {
|
||||
file.set_len(end)?;
|
||||
}
|
||||
Ok(Self { file, index, end })
|
||||
}
|
||||
fn read(&mut self, key: Key) -> Option<Vec<u8>> {
|
||||
let &(offset, length) = self.index.get(&key)?;
|
||||
let mut body = vec![0; length as usize];
|
||||
self.file.seek(SeekFrom::Start(offset)).ok()?;
|
||||
self.file.read_exact(&mut body).ok()?;
|
||||
Some(body)
|
||||
}
|
||||
fn append(&mut self, key: Key, body: &[u8]) -> std::io::Result<()> {
|
||||
let length = (KEY_BYTES + body.len()) as u32;
|
||||
let mut record = Vec::with_capacity(4 + length as usize);
|
||||
record.extend_from_slice(&length.to_le_bytes());
|
||||
record.extend_from_slice(&key.bytes());
|
||||
record.extend_from_slice(body);
|
||||
self.file.seek(SeekFrom::Start(self.end))?;
|
||||
self.file.write_all(&record)?;
|
||||
let start = self.end + 4 + KEY_BYTES as u64;
|
||||
self.index.insert(key, (start, body.len() as u32));
|
||||
self.end = start + body.len() as u64;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A dimension's baked far terrain, shared by the far sampling threads.
|
||||
pub struct LodBake {
|
||||
dir: PathBuf,
|
||||
/// Open buckets by file name; `None` for one that failed to open.
|
||||
buckets: Mutex<FxHashMap<String, Option<Bucket>>>,
|
||||
}
|
||||
impl LodBake {
|
||||
/// Opens `dimension/horizon_lod`, emptying it when its fingerprint is not
|
||||
/// this build's, world's, level's and registries'. `None` (no baking) on
|
||||
/// an error.
|
||||
pub fn open(
|
||||
dimension: &Path,
|
||||
shape: LevelShape,
|
||||
block_states: usize,
|
||||
biomes: usize,
|
||||
) -> Option<Self> {
|
||||
let dir = dimension.join(FOLDER);
|
||||
let fingerprint = fingerprint(dimension, shape, block_states, biomes);
|
||||
let current = fs::read_to_string(dir.join(FINGERPRINT)).ok();
|
||||
if current.as_deref() != Some(fingerprint.as_str()) {
|
||||
if current.is_some() {
|
||||
log::info!("Horizon: baked far terrain is out of date; baking it again");
|
||||
}
|
||||
let reset = || -> std::io::Result<()> {
|
||||
if dir.exists() {
|
||||
fs::remove_dir_all(&dir)?;
|
||||
}
|
||||
fs::create_dir_all(&dir)?;
|
||||
fs::write(dir.join(FINGERPRINT), &fingerprint)
|
||||
};
|
||||
if let Err(e) = reset() {
|
||||
log::warn!(
|
||||
"Horizon: far terrain will not be baked ({}): {e}",
|
||||
dir.display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(Self {
|
||||
dir,
|
||||
buckets: Mutex::new(FxHashMap::default()),
|
||||
})
|
||||
}
|
||||
fn with_bucket<T>(&self, key: Key, f: impl FnOnce(&mut Bucket) -> Option<T>) -> Option<T> {
|
||||
let name = key.file();
|
||||
let mut buckets = self.buckets.lock().unwrap();
|
||||
let bucket = buckets
|
||||
.entry(name)
|
||||
.or_insert_with_key(|name| match Bucket::open(&self.dir.join(name)) {
|
||||
Ok(bucket) => Some(bucket),
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: baked far terrain {name}: {e}");
|
||||
None
|
||||
}
|
||||
})
|
||||
.as_mut()?;
|
||||
f(bucket)
|
||||
}
|
||||
fn get(&self, key: Key, count: usize) -> Option<Vec<Column>> {
|
||||
let body = self.with_bucket(key, |bucket| bucket.read(key))?;
|
||||
let mut raw = Vec::new();
|
||||
ZlibDecoder::new(&body[..]).read_to_end(&mut raw).ok()?;
|
||||
decode(&raw, count)
|
||||
}
|
||||
fn put(&self, key: Key, columns: &[Column]) {
|
||||
if self.with_bucket(key, |bucket| Some(bucket.index.contains_key(&key))) != Some(false) {
|
||||
return;
|
||||
}
|
||||
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::fast());
|
||||
let _ = encoder.write_all(&encode(columns));
|
||||
let Ok(body) = encoder.finish() else { return };
|
||||
self.with_bucket(key, |bucket| {
|
||||
if !bucket.index.contains_key(&key)
|
||||
&& let Err(e) = bucket.append(key, &body)
|
||||
{
|
||||
log::warn!("Horizon: baking far terrain: {e}");
|
||||
}
|
||||
Some(())
|
||||
});
|
||||
}
|
||||
/// The baked `LodSampler::grid` result, if any.
|
||||
pub fn grid(
|
||||
&self,
|
||||
approximate: bool,
|
||||
[x, z]: [i32; 2],
|
||||
stride: i32,
|
||||
side: usize,
|
||||
) -> Option<Vec<Column>> {
|
||||
let side16 = u16::try_from(side).ok()?;
|
||||
self.get(
|
||||
Key::Grid {
|
||||
approximate,
|
||||
x,
|
||||
z,
|
||||
stride,
|
||||
side: side16,
|
||||
},
|
||||
side * side,
|
||||
)
|
||||
}
|
||||
pub fn put_grid(
|
||||
&self,
|
||||
approximate: bool,
|
||||
[x, z]: [i32; 2],
|
||||
stride: i32,
|
||||
side: usize,
|
||||
columns: &[Column],
|
||||
) {
|
||||
// A failed direct sample comes back as empty columns: never keep it.
|
||||
let Ok(side) = u16::try_from(side) else {
|
||||
return;
|
||||
};
|
||||
if columns.iter().all(|c| *c == Column::default()) {
|
||||
return;
|
||||
}
|
||||
self.put(
|
||||
Key::Grid {
|
||||
approximate,
|
||||
x,
|
||||
z,
|
||||
stride,
|
||||
side,
|
||||
},
|
||||
columns,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// What baked columns depend on besides the world's seed (fixed per world).
|
||||
fn fingerprint(dimension: &Path, shape: LevelShape, block_states: usize, biomes: usize) -> String {
|
||||
let build = super::cache::build_key(block_states, biomes);
|
||||
let packs = world_packs_digest(dimension);
|
||||
format!(
|
||||
"{FORMAT}\nbuild {build:016x}\nlevel {} {}\ndatapacks {packs}\n",
|
||||
shape.min_y, shape.height,
|
||||
)
|
||||
}
|
||||
/// `files_digest` of the data packs of the world `dimension` is a folder of.
|
||||
pub fn world_packs_digest(dimension: &Path) -> String {
|
||||
// `dimensions/<namespace>/<path>` inside the world folder.
|
||||
dimension
|
||||
.ancestors()
|
||||
.nth(3)
|
||||
.map(|world| files_digest(&world.join("datapacks")))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
/// Every file under `root`: its path, size and change time.
|
||||
pub fn files_digest(root: &Path) -> String {
|
||||
let mut files = Vec::new();
|
||||
let mut stack = vec![root.to_path_buf()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let Ok(meta) = entry.metadata() else { continue };
|
||||
if meta.is_dir() {
|
||||
stack.push(path);
|
||||
} else {
|
||||
let modified = meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map_or(0, |d| d.as_nanos());
|
||||
let relative = path.strip_prefix(root).unwrap_or(&path);
|
||||
files.push(format!("{} {} {modified}", relative.display(), meta.len()));
|
||||
}
|
||||
}
|
||||
}
|
||||
files.sort_unstable();
|
||||
let mut hash = Sha256::new();
|
||||
for file in &files {
|
||||
hash.update(file.as_bytes());
|
||||
hash.update(b"\n");
|
||||
}
|
||||
hash.finalize().iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
fn encode(columns: &[Column]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(columns.len() * 24);
|
||||
out.extend_from_slice(&(columns.len() as u32).to_le_bytes());
|
||||
for column in columns {
|
||||
match column.surface {
|
||||
None => out.push(0),
|
||||
Some(s) => {
|
||||
out.push(if s.water.is_some() { 2 } else { 1 });
|
||||
out.extend_from_slice(&s.height.to_le_bytes());
|
||||
out.extend_from_slice(&s.state.0.to_le_bytes());
|
||||
out.extend_from_slice(&s.biome.to_le_bytes());
|
||||
if let Some((height, state)) = s.water {
|
||||
out.extend_from_slice(&height.to_le_bytes());
|
||||
out.extend_from_slice(&state.0.to_le_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
out.extend_from_slice(&(column.spans.len() as u16).to_le_bytes());
|
||||
for span in &column.spans {
|
||||
out.extend_from_slice(&span.min.to_le_bytes());
|
||||
out.extend_from_slice(&span.max.to_le_bytes());
|
||||
out.extend_from_slice(&span.state.0.to_le_bytes());
|
||||
out.extend_from_slice(&span.biome.to_le_bytes());
|
||||
out.extend_from_slice(&span.light.to_le_bytes());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
fn decode(mut b: &[u8], count: usize) -> Option<Vec<Column>> {
|
||||
fn take<const N: usize>(b: &mut &[u8]) -> Option<[u8; N]> {
|
||||
let (head, rest) = b.split_first_chunk::<N>()?;
|
||||
*b = rest;
|
||||
Some(*head)
|
||||
}
|
||||
let i32_ = |b: &mut &[u8]| take::<4>(b).map(i32::from_le_bytes);
|
||||
let u32_ = |b: &mut &[u8]| take::<4>(b).map(u32::from_le_bytes);
|
||||
let u16_ = |b: &mut &[u8]| take::<2>(b).map(u16::from_le_bytes);
|
||||
let i16_ = |b: &mut &[u8]| take::<2>(b).map(i16::from_le_bytes);
|
||||
if u32_(&mut b)? as usize != count {
|
||||
return None;
|
||||
}
|
||||
let mut columns = Vec::with_capacity(count);
|
||||
for _ in 0..count {
|
||||
let kind = take::<1>(&mut b)?[0];
|
||||
let surface = match kind {
|
||||
0 => None,
|
||||
1 | 2 => {
|
||||
let height = i32_(&mut b)?;
|
||||
let state = BlockStateId(u32_(&mut b)?);
|
||||
let biome = u16_(&mut b)?;
|
||||
let water = if kind == 2 {
|
||||
Some((i32_(&mut b)?, BlockStateId(u32_(&mut b)?)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Some(Surface {
|
||||
height,
|
||||
state,
|
||||
water,
|
||||
biome,
|
||||
})
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let spans = (0..u16_(&mut b)?)
|
||||
.map(|_| {
|
||||
Some(Span {
|
||||
min: i16_(&mut b)?,
|
||||
max: i16_(&mut b)?,
|
||||
state: BlockStateId(u32_(&mut b)?),
|
||||
biome: u16_(&mut b)?,
|
||||
light: u16_(&mut b)?,
|
||||
})
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
columns.push(Column {
|
||||
surface,
|
||||
spans: spans.into(),
|
||||
});
|
||||
}
|
||||
b.is_empty().then_some(columns)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn sample(seed: i32) -> Column {
|
||||
Column {
|
||||
surface: Some(Surface {
|
||||
height: 70 + seed,
|
||||
state: BlockStateId(9),
|
||||
water: (seed % 2 == 0).then_some((80, BlockStateId(86))),
|
||||
biome: 3,
|
||||
}),
|
||||
spans: vec![
|
||||
Span {
|
||||
min: -64,
|
||||
max: (70 + seed) as i16,
|
||||
state: BlockStateId(1),
|
||||
biome: 3,
|
||||
light: 0,
|
||||
},
|
||||
Span {
|
||||
min: (70 + seed) as i16,
|
||||
max: 80,
|
||||
state: BlockStateId(86),
|
||||
biome: 3,
|
||||
light: 240,
|
||||
},
|
||||
]
|
||||
.into(),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn columns_round_trip_and_survive_reopening() {
|
||||
let world = std::env::temp_dir().join(format!("horizon-lod-bake-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&world);
|
||||
let dimension = world.join("dimensions").join("minecraft").join("overworld");
|
||||
fs::create_dir_all(&dimension).unwrap();
|
||||
let grid: Vec<Column> = (0..256).map(sample).collect();
|
||||
let other: Vec<Column> = (1..257).map(sample).collect();
|
||||
let shape = LevelShape::OVERWORLD;
|
||||
{
|
||||
let bake = LodBake::open(&dimension, shape, 100, 10).unwrap();
|
||||
assert_eq!(bake.grid(false, [-64, 32], 4, 16), None);
|
||||
bake.put_grid(false, [-64, 32], 4, 16, &grid);
|
||||
// A second put of the same key is ignored, not appended.
|
||||
bake.put_grid(false, [-64, 32], 4, 16, &[sample(-1)]);
|
||||
assert_eq!(
|
||||
bake.grid(false, [-64, 32], 4, 16).as_deref(),
|
||||
Some(&grid[..])
|
||||
);
|
||||
// Reduced far detail's samples are kept apart from full ones.
|
||||
assert_eq!(bake.grid(true, [-64, 32], 4, 16), None);
|
||||
bake.put_grid(true, [-64, 32], 4, 16, &other);
|
||||
assert_eq!(
|
||||
bake.grid(true, [-64, 32], 4, 16).as_deref(),
|
||||
Some(&other[..])
|
||||
);
|
||||
}
|
||||
let bake = LodBake::open(&dimension, shape, 100, 10).unwrap();
|
||||
assert_eq!(
|
||||
bake.grid(false, [-64, 32], 4, 16).as_deref(),
|
||||
Some(&grid[..])
|
||||
);
|
||||
assert_eq!(bake.grid(false, [-64, 48], 4, 16), None);
|
||||
drop(bake);
|
||||
// A cut-off record (a crash mid-write) is dropped, the rest is kept:
|
||||
// (-128, 32) shares (-64, 32)'s bucket file.
|
||||
let key = Key::Grid {
|
||||
approximate: false,
|
||||
x: -64,
|
||||
z: 32,
|
||||
stride: 4,
|
||||
side: 16,
|
||||
};
|
||||
let file = dimension.join(FOLDER).join(key.file());
|
||||
let full = fs::metadata(&file).unwrap().len();
|
||||
let bake = LodBake::open(&dimension, shape, 100, 10).unwrap();
|
||||
bake.put_grid(false, [-128, 32], 4, 16, &other);
|
||||
drop(bake);
|
||||
let f = fs::OpenOptions::new().write(true).open(&file).unwrap();
|
||||
f.set_len(fs::metadata(&file).unwrap().len() - 5).unwrap();
|
||||
drop(f);
|
||||
let bake = LodBake::open(&dimension, shape, 100, 10).unwrap();
|
||||
assert_eq!(bake.grid(false, [-128, 32], 4, 16), None);
|
||||
assert_eq!(
|
||||
bake.grid(false, [-64, 32], 4, 16).as_deref(),
|
||||
Some(&grid[..])
|
||||
);
|
||||
assert_eq!(fs::metadata(&file).unwrap().len(), full);
|
||||
drop(bake);
|
||||
// Another registry (or data pack, build or level shape) empties the folder.
|
||||
let bake = LodBake::open(&dimension, shape, 101, 10).unwrap();
|
||||
assert_eq!(bake.grid(false, [-64, 32], 4, 16), None);
|
||||
drop(bake);
|
||||
fs::remove_dir_all(&world).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn empty_samples_are_not_baked() {
|
||||
let world = std::env::temp_dir().join(format!("horizon-lod-empty-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&world);
|
||||
let dimension = world.join("dimensions").join("minecraft").join("overworld");
|
||||
fs::create_dir_all(&dimension).unwrap();
|
||||
let bake = LodBake::open(&dimension, LevelShape::OVERWORLD, 1, 1).unwrap();
|
||||
bake.put_grid(false, [0, 0], 16, 16, &vec![Column::default(); 256]);
|
||||
assert_eq!(bake.grid(false, [0, 0], 16, 16), None);
|
||||
drop(bake);
|
||||
fs::remove_dir_all(&world).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//! The samples mesh jobs read (`Snapshot::samples`): the active tiles' ready columns, kept
|
||||
//! as the tiles change rather than gathered again for every batch of jobs. Kept in shards of
|
||||
//! nearby tiles behind `Arc`s: a batch's copy (`clone`) copies the shards' handles, and a
|
||||
//! change copies only its shard, and only while a job still reads it.
|
||||
use super::{Column, TileKey};
|
||||
use crate::fast_hash::FxHashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
type Shard = FxHashMap<TileKey, Arc<Vec<Column>>>;
|
||||
|
||||
/// 8 by 8 tiles of one level a shard.
|
||||
fn shard_of(key: TileKey) -> (i32, i32, u8) {
|
||||
(key.x >> 3, key.z >> 3, key.level)
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(super) struct Samples {
|
||||
shards: FxHashMap<(i32, i32, u8), Arc<Shard>>,
|
||||
}
|
||||
|
||||
impl Samples {
|
||||
pub fn get(&self, key: &TileKey) -> Option<&Arc<Vec<Column>>> {
|
||||
self.shards.get(&shard_of(*key))?.get(key)
|
||||
}
|
||||
/// Sets `key`'s samples (`None`: not active or not ready), copying its shard only when
|
||||
/// they change.
|
||||
pub fn set(&mut self, key: TileKey, samples: Option<&Arc<Vec<Column>>>) {
|
||||
let at = shard_of(key);
|
||||
match samples {
|
||||
Some(samples) => {
|
||||
let shard = self.shards.entry(at).or_default();
|
||||
if shard.get(&key).is_none_or(|s| !Arc::ptr_eq(s, samples)) {
|
||||
Arc::make_mut(shard).insert(key, samples.clone());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let Some(shard) = self.shards.get_mut(&at) else {
|
||||
return;
|
||||
};
|
||||
if shard.contains_key(&key) {
|
||||
Arc::make_mut(shard).remove(&key);
|
||||
if shard.is_empty() {
|
||||
self.shards.remove(&at);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Keeps the tiles `keep` accepts.
|
||||
pub fn retain(&mut self, mut keep: impl FnMut(&TileKey) -> bool) {
|
||||
self.shards.retain(|_, shard| {
|
||||
if !shard.keys().all(&mut keep) {
|
||||
Arc::make_mut(shard).retain(|key, _| keep(key));
|
||||
}
|
||||
!shard.is_empty()
|
||||
});
|
||||
}
|
||||
#[cfg(any(test, debug_assertions))]
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&TileKey, &Arc<Vec<Column>>)> {
|
||||
self.shards.values().flat_map(|shard| shard.iter())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromIterator<(TileKey, Arc<Vec<Column>>)> for Samples {
|
||||
fn from_iter<I: IntoIterator<Item = (TileKey, Arc<Vec<Column>>)>>(iter: I) -> Self {
|
||||
let mut samples = Self::default();
|
||||
for (key, columns) in iter {
|
||||
samples.set(key, Some(&columns));
|
||||
}
|
||||
samples
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn key(x: i32, z: i32, level: u8) -> TileKey {
|
||||
TileKey { x, z, level }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_change_leaves_a_batchs_copy_as_it_was() {
|
||||
let a = Arc::new(Vec::new());
|
||||
let b = Arc::new(Vec::new());
|
||||
let mut live: Samples = [(key(0, 0, 0), a.clone()), (key(40, -3, 1), a.clone())]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let batch = live.clone();
|
||||
live.set(key(0, 0, 0), Some(&b));
|
||||
live.set(key(40, -3, 1), None);
|
||||
live.set(key(-9, 2, 0), Some(&b));
|
||||
assert!(Arc::ptr_eq(batch.get(&key(0, 0, 0)).unwrap(), &a));
|
||||
assert!(batch.get(&key(40, -3, 1)).is_some());
|
||||
assert!(batch.get(&key(-9, 2, 0)).is_none());
|
||||
assert!(Arc::ptr_eq(live.get(&key(0, 0, 0)).unwrap(), &b));
|
||||
assert!(live.get(&key(40, -3, 1)).is_none());
|
||||
live.retain(|k| k.x < 0);
|
||||
assert_eq!(
|
||||
live.iter().map(|(k, _)| *k).collect::<Vec<_>>(),
|
||||
[key(-9, 2, 0)]
|
||||
);
|
||||
assert_eq!(batch.iter().count(), 2);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ mod leaf_culling;
|
||||
mod lighting;
|
||||
pub mod packed;
|
||||
mod sides;
|
||||
#[cfg(test)]
|
||||
#[path = "mesher/source_tests.rs"]
|
||||
mod source_tests;
|
||||
use crate::connected::{Pick, TileLayer};
|
||||
use crate::{
|
||||
assets::{Assets, RenderAssets},
|
||||
@@ -340,13 +343,41 @@ mod section_mesh_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// A shader terrain quad's unmodified source state and owning section cell. Two words
|
||||
/// per quad, kept beside (not in) the native 24-byte vertex format. Word 1 has cell XYZ
|
||||
/// in three nibbles, a fluid flag, a valid flag, then the emitted light (0..15).
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct TerrainSource {
|
||||
state: u32,
|
||||
cell: u32,
|
||||
}
|
||||
impl TerrainSource {
|
||||
pub(crate) fn new(state: u32, pos: [i32; 3], emission: u8, fluid: bool) -> Self {
|
||||
debug_assert!(pos.iter().all(|&v| (0..16).contains(&v)));
|
||||
Self {
|
||||
state,
|
||||
cell: pos[0] as u32
|
||||
| (pos[1] as u32) << 4
|
||||
| (pos[2] as u32) << 8
|
||||
| u32::from(fluid) << 12
|
||||
| 1 << 13
|
||||
| u32::from(emission.min(15)) << 14,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SectionMesh {
|
||||
pub solid: Vec<TerrainVertex>,
|
||||
/// One source per quad when a shader pack meshes terrain; independent of its sprite
|
||||
/// and geometry. Empty on native/default meshes and on distant terrain.
|
||||
pub solid_sources: Vec<TerrainSource>,
|
||||
/// The buckets `solid` is sorted in by the side its quads face: empty
|
||||
/// until `sort_solid`, which the meshing workers call; `solid` is in
|
||||
/// model order before.
|
||||
pub sides: Sides,
|
||||
pub translucent: Vec<TerrainVertex>,
|
||||
pub translucent_sources: Vec<TerrainSource>,
|
||||
/// Whether `packed` holds far tile records (`lod::packed`) rather than
|
||||
/// section records (`mesher::packed`): the two are read apart, by the base
|
||||
/// vertex their draws use.
|
||||
@@ -380,8 +411,10 @@ impl Default for SectionMesh {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
solid: Vec::new(),
|
||||
solid_sources: Vec::new(),
|
||||
sides: Sides::default(),
|
||||
translucent: Vec::new(),
|
||||
translucent_sources: Vec::new(),
|
||||
packed: Vec::new(),
|
||||
packed_far: false,
|
||||
solid_tints: Vec::new(),
|
||||
@@ -416,7 +449,9 @@ impl SectionMesh {
|
||||
/// Retain geometry capacity after an upload, but none of its old contents.
|
||||
pub fn clear(&mut self) {
|
||||
self.solid.clear();
|
||||
self.solid_sources.clear();
|
||||
self.translucent.clear();
|
||||
self.translucent_sources.clear();
|
||||
self.packed.clear();
|
||||
self.packed_far = false;
|
||||
self.solid_tints.clear();
|
||||
@@ -431,6 +466,8 @@ impl SectionMesh {
|
||||
pub(crate) fn capacity_bytes(&self) -> usize {
|
||||
self.solid.capacity() * std::mem::size_of::<TerrainVertex>()
|
||||
+ self.translucent.capacity() * std::mem::size_of::<TerrainVertex>()
|
||||
+ (self.solid_sources.capacity() + self.translucent_sources.capacity())
|
||||
* std::mem::size_of::<TerrainSource>()
|
||||
+ self.packed.capacity() * std::mem::size_of::<[u32; 6]>()
|
||||
+ self.solid_tints.capacity() * std::mem::size_of::<u32>()
|
||||
+ self.block_entities.capacity() * std::mem::size_of::<([i32; 3], u32)>()
|
||||
@@ -445,16 +482,34 @@ impl SectionMesh {
|
||||
if !self.solid_tints.is_empty() {
|
||||
self.solid_tints.resize(quads, packed::UNKNOWN_TINT);
|
||||
}
|
||||
self.sides = sides::sort_tinted(&mut self.solid, &mut self.solid_tints);
|
||||
self.sides = sides::sort_sourced(
|
||||
&mut self.solid,
|
||||
&mut self.solid_tints,
|
||||
&mut self.solid_sources,
|
||||
);
|
||||
self.bounds = self.compute_bounds();
|
||||
self.packed.clear();
|
||||
self.packed_far = false;
|
||||
if packed::packing() && quads > 0 && self.solid_tints.len() == quads {
|
||||
self.packed = packed::layout(&self.solid, &self.solid_tints);
|
||||
packed::layout_into(&self.solid, &self.solid_tints, &mut self.packed);
|
||||
}
|
||||
self.solid_tints.clear();
|
||||
}
|
||||
|
||||
/// Every face emitted since `start` belongs to this block, including multipart,
|
||||
/// connected-texture pieces, overlays and duplicated/back-facing fluid faces.
|
||||
fn record_source(&mut self, start: [usize; 2], source: TerrainSource) {
|
||||
for (vertices, sources, start) in [
|
||||
(&self.solid, &mut self.solid_sources, start[0]),
|
||||
(&self.translucent, &mut self.translucent_sources, start[1]),
|
||||
] {
|
||||
if vertices.len() > start {
|
||||
sources.resize(start / 4, TerrainSource::default());
|
||||
sources.resize(vertices.len() / 4, source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the tight axis-aligned bounding box [min, max] of actual terrain geometry vertices.
|
||||
pub fn compute_bounds(&self) -> Option<[glam::Vec3; 2]> {
|
||||
if self.solid.is_empty() && self.translucent.is_empty() {
|
||||
@@ -644,6 +699,8 @@ struct Info {
|
||||
block_entity: bool,
|
||||
tints: [Tint; 2],
|
||||
tint_count: u8,
|
||||
/// A resource pack's Polytone colormap over its tints (an index in `MeshTables::colormaps`).
|
||||
colormap: Option<u16>,
|
||||
/// `OffsetType` as (max horizontal, max vertical): vertical 0 for XZ.
|
||||
offset: Option<(f32, f32)>,
|
||||
/// `isSolid()` (legacy), for fluid corner heights.
|
||||
@@ -700,6 +757,9 @@ pub struct MeshTables {
|
||||
lava_still: u16,
|
||||
lava_flow: u16,
|
||||
animated_layers: Vec<bool>,
|
||||
/// Resource packs' Polytone block and fluid colormaps (#11486), and the water's.
|
||||
colormaps: Vec<Arc<crate::environment::Colormap>>,
|
||||
water_colormap: Option<u16>,
|
||||
}
|
||||
fn stem_color(age: u8) -> u32 {
|
||||
let a = u32::from(age);
|
||||
@@ -842,6 +902,7 @@ impl MeshTables {
|
||||
block_entity: s.block_entity,
|
||||
tints,
|
||||
tint_count,
|
||||
colormap: None,
|
||||
offset,
|
||||
legacy_solid: s.legacy_solid,
|
||||
overlay: s.has_class("HalfTransparentBlock") || s.has_class("LeavesBlock"),
|
||||
@@ -875,8 +936,52 @@ impl MeshTables {
|
||||
lava_still: layer("minecraft:block/lava_still")?,
|
||||
lava_flow: layer("minecraft:block/lava_flow")?,
|
||||
animated_layers,
|
||||
colormaps: Vec::new(),
|
||||
water_colormap: None,
|
||||
})
|
||||
}
|
||||
/// These tables with the colormaps of the Polytone block and fluid modifiers of the packs
|
||||
/// merged at `pack_root` (wiki "Colormaps"): a tinted block they target takes its colour
|
||||
/// from the colormap, and its own tint where the colormap's pixel is transparent.
|
||||
#[must_use]
|
||||
pub fn with_polytone_colormaps(
|
||||
mut self,
|
||||
pack_root: &std::path::Path,
|
||||
registry: &horizon_world::registry::BlockRegistry,
|
||||
) -> Self {
|
||||
let modifiers = crate::environment::polytone_colormap_modifiers(pack_root);
|
||||
if modifiers.is_empty() {
|
||||
return self;
|
||||
}
|
||||
let mut loaded: std::collections::HashMap<String, Option<u16>> = Default::default();
|
||||
let mut index = |id: &str, colormaps: &mut Vec<Arc<crate::environment::Colormap>>| {
|
||||
*loaded.entry(id.to_owned()).or_insert_with(|| {
|
||||
let colormap = crate::environment::Colormap::load(pack_root, id)?;
|
||||
colormaps.push(Arc::new(colormap));
|
||||
Some((colormaps.len() - 1) as u16)
|
||||
})
|
||||
};
|
||||
for (fluid, targets, id) in &modifiers {
|
||||
if *fluid {
|
||||
if targets.matches("minecraft:water", |_| false) {
|
||||
self.water_colormap = index(id, &mut self.colormaps);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(i) = index(id, &mut self.colormaps) else {
|
||||
continue;
|
||||
};
|
||||
for (state, info) in self.info.iter_mut().enumerate() {
|
||||
let Some(block) = registry.state(BlockStateId(state as u32)) else {
|
||||
continue;
|
||||
};
|
||||
if info.tint_count > 0 && targets.matches(&block.name, |_| false) {
|
||||
info.colormap = Some(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
/// Whether a terrain sprite layer has an animated clip.
|
||||
pub fn is_animated(&self, layer: u16) -> bool {
|
||||
self.animated_layers
|
||||
@@ -971,7 +1076,7 @@ impl MeshTables {
|
||||
}
|
||||
let [x, y, z] = pos;
|
||||
let origin = [x >> 4, z >> 4, y & !15];
|
||||
let mut tints = Tints::new(region, biomes, zoom, blend_radius, origin);
|
||||
let mut tints = Tints::new(region, biomes, zoom, blend_radius, origin).with_colormaps(self);
|
||||
Mesher::tint(info, 0, &mut tints, [x & 15, y & 15, z & 15])
|
||||
}
|
||||
/// `BlockColors` for each of the state's tint indices at `pos`, as the
|
||||
@@ -989,7 +1094,8 @@ impl MeshTables {
|
||||
let sources = self.info.get(state.0 as usize).map(|info| {
|
||||
let [x, y, z] = pos;
|
||||
let origin = [x >> 4, z >> 4, y & !15];
|
||||
let mut tints = Tints::new(region, biomes, zoom, blend_radius, origin);
|
||||
let mut tints =
|
||||
Tints::new(region, biomes, zoom, blend_radius, origin).with_colormaps(self);
|
||||
let local = [x & 15, y & 15, z & 15];
|
||||
let colors = [0, 1].map(|i| Mesher::tint(info, i, &mut tints, local));
|
||||
(colors, i32::from(info.tint_count))
|
||||
@@ -1382,6 +1488,11 @@ struct Tints<'a> {
|
||||
layers: [Option<Box<Layer>>; 16],
|
||||
/// `uniform_biome`, once computed.
|
||||
uniform: Option<Option<u16>>,
|
||||
/// The packs' colormaps (`MeshTables::colormaps`), the water's, and their colours so far
|
||||
/// by colormap and section-local block.
|
||||
colormaps: &'a [Arc<crate::environment::Colormap>],
|
||||
water_colormap: Option<u16>,
|
||||
sampled: std::collections::HashMap<(u16, [i32; 3]), Option<u32>>,
|
||||
}
|
||||
impl<'a> Tints<'a> {
|
||||
fn new(
|
||||
@@ -1400,8 +1511,41 @@ impl<'a> Tints<'a> {
|
||||
origin,
|
||||
layers: Default::default(),
|
||||
uniform: None,
|
||||
colormaps: &[],
|
||||
water_colormap: None,
|
||||
sampled: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
/// With the packs' colormaps of `tables`.
|
||||
fn with_colormaps(mut self, tables: &'a MeshTables) -> Self {
|
||||
self.colormaps = &tables.colormaps;
|
||||
self.water_colormap = tables.water_colormap;
|
||||
self
|
||||
}
|
||||
/// Colormap `index`'s colour at block `(x, y, z)` (section-local), `None` where its pixel
|
||||
/// is transparent.
|
||||
fn colormap(&mut self, index: u16, x: i32, y: i32, z: i32) -> Option<u32> {
|
||||
if let Some(&known) = self.sampled.get(&(index, [x, y, z])) {
|
||||
return known;
|
||||
}
|
||||
let colormap = self.colormaps.get(usize::from(index))?;
|
||||
let id = Self::biome_id(&self.zoom, self.region, self.plains, self.origin, [x, y, z]);
|
||||
let [cx, cz, base_y] = self.origin;
|
||||
let at = [cx * 16 + x, base_y + y, cz * 16 + z];
|
||||
let mut host = crate::environment::BlockHost::new(at, self.biomes.get(id));
|
||||
let colour = colormap.sample(&mut host).map(|c| c & 0xFF_FFFF);
|
||||
self.sampled.insert((index, [x, y, z]), colour);
|
||||
colour
|
||||
}
|
||||
/// The water's tint at block `(x, y, z)`: the packs' water colormap, else the biome's.
|
||||
fn water(&mut self, x: i32, y: i32, z: i32) -> u32 {
|
||||
if let Some(index) = self.water_colormap
|
||||
&& let Some(colour) = self.colormap(index, x, y, z)
|
||||
{
|
||||
return colour;
|
||||
}
|
||||
self.get(Resolver::Water, x, y, z)
|
||||
}
|
||||
/// The biome of block `(x, y, z)` (section-local x/z, y in 0..16), as `BiomeManager.getBiome`
|
||||
/// picks it.
|
||||
fn biome(&self, x: i32, y: i32, z: i32) -> &'a str {
|
||||
@@ -1645,7 +1789,8 @@ impl Mesher<'_> {
|
||||
self.zoom,
|
||||
self.blend_radius,
|
||||
[center.x, center.z, base_y],
|
||||
);
|
||||
)
|
||||
.with_colormaps(self.tables);
|
||||
let info = &self.tables.info;
|
||||
let full_faces = &self.tables.full_faces;
|
||||
let catalog = &self.render.model_catalog;
|
||||
@@ -1674,7 +1819,8 @@ impl Mesher<'_> {
|
||||
continue;
|
||||
}
|
||||
if state.fluid.kind() != FluidKind::Empty {
|
||||
let water = tints.get(Resolver::Water, x, y, z);
|
||||
let start = [out.solid.len(), out.translucent.len()];
|
||||
let water = tints.water(x, y, z);
|
||||
let shade = (
|
||||
self.nether_shading,
|
||||
self.shader_pack_lighting,
|
||||
@@ -1682,6 +1828,12 @@ impl Mesher<'_> {
|
||||
self.shader_pack,
|
||||
);
|
||||
fluid::tesselate(self.tables, shade, pad, water, &mut out, [x, y, z]);
|
||||
if self.shader_pack {
|
||||
out.record_source(
|
||||
start,
|
||||
TerrainSource::new(id, [x, y, z], state.emission, true),
|
||||
);
|
||||
}
|
||||
}
|
||||
if !state.model {
|
||||
continue;
|
||||
@@ -1754,6 +1906,7 @@ impl Mesher<'_> {
|
||||
.unwrap_or(true);
|
||||
}
|
||||
let connected = &self.render.connected;
|
||||
let start = [out.solid.len(), out.translucent.len()];
|
||||
for model in &models {
|
||||
for face in &model.faces {
|
||||
if face.quad.cull.is_some_and(|d| !visible[d as usize]) {
|
||||
@@ -1797,6 +1950,12 @@ impl Mesher<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
if self.shader_pack {
|
||||
out.record_source(
|
||||
start,
|
||||
TerrainSource::new(id, [x, y, z], state.emission, false),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1808,13 +1967,18 @@ impl Mesher<'_> {
|
||||
if index < 0 || index >= i32::from(state.tint_count) {
|
||||
return None;
|
||||
}
|
||||
if let Some(colormap) = state.colormap
|
||||
&& let Some(colour) = tints.colormap(colormap, x, y, z)
|
||||
{
|
||||
return Some(colour);
|
||||
}
|
||||
Some(match state.tints[(index as usize).min(1)] {
|
||||
Tint::None => return None,
|
||||
Tint::Grass | Tint::GrassBlock | Tint::SugarCane => tints.get(Resolver::Grass, x, y, z),
|
||||
Tint::TallGrassUpper => tints.get(Resolver::Grass, x, y, z),
|
||||
Tint::Foliage => tints.get(Resolver::Foliage, x, y, z),
|
||||
Tint::DryFoliage => tints.get(Resolver::Dry, x, y, z),
|
||||
Tint::Water => tints.get(Resolver::Water, x, y, z),
|
||||
Tint::Water => tints.water(x, y, z),
|
||||
Tint::Constant(c) => c,
|
||||
Tint::LilyPad => 0x20_8030,
|
||||
Tint::Redstone(p) => redstone_color(p),
|
||||
|
||||
@@ -340,12 +340,11 @@ pub(super) fn tesselate(
|
||||
} else {
|
||||
(tables.lava_still, tables.lava_flow, None)
|
||||
};
|
||||
let target = if water {
|
||||
&mut out.translucent
|
||||
let (target, sprite_layers) = if water {
|
||||
(&mut out.translucent, &mut out.sprite_layers)
|
||||
} else {
|
||||
&mut out.solid
|
||||
(&mut out.solid, &mut out.sprite_layers)
|
||||
};
|
||||
let mut sprite_layers = Vec::new();
|
||||
let mut quad = |v: [([f32; 3], [f32; 2]); 4],
|
||||
layer: u16,
|
||||
color: u32,
|
||||
@@ -497,7 +496,6 @@ pub(super) fn tesselate(
|
||||
d as usize,
|
||||
);
|
||||
}
|
||||
out.sprite_layers.extend(sprite_layers);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -335,6 +335,7 @@ mod tests {
|
||||
block_entity: false,
|
||||
tints: [super::super::Tint::None; 2],
|
||||
tint_count: 0,
|
||||
colormap: None,
|
||||
offset: None,
|
||||
legacy_solid: false,
|
||||
overlay: leaves,
|
||||
|
||||
@@ -199,16 +199,29 @@ pub fn packed_vertex(words: &[u32; 6], corner: usize) -> TerrainVertex {
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static RECORDS_SCRATCH: std::cell::RefCell<Vec<Option<[u32; 6]>>> =
|
||||
const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// The compact layout of sorted solid quads with their tints (`SectionMesh::packed`), or
|
||||
/// nothing when it would be no smaller than their vertices.
|
||||
pub fn layout(solid: &[TerrainVertex], tints: &[u32]) -> Vec<[u32; 6]> {
|
||||
let mut out = Vec::new();
|
||||
layout_into(solid, tints, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Writes the compact layout of sorted solid quads with their tints into `out`,
|
||||
/// reusing `out`'s allocation and a thread-local record scratch buffer.
|
||||
pub fn layout_into(solid: &[TerrainVertex], tints: &[u32], out: &mut Vec<[u32; 6]>) {
|
||||
let quads = solid.as_chunks::<4>().0;
|
||||
let records: Vec<Option<[u32; 6]>> = quads
|
||||
.iter()
|
||||
.zip(tints)
|
||||
.map(|(q, &t)| pack_quad(q, t))
|
||||
.collect();
|
||||
place(&records, quads)
|
||||
RECORDS_SCRATCH.with(|cell| {
|
||||
let mut records = cell.borrow_mut();
|
||||
records.clear();
|
||||
records.extend(quads.iter().zip(tints).map(|(q, &t)| pack_quad(q, t)));
|
||||
place_into(&records, quads, out);
|
||||
});
|
||||
}
|
||||
|
||||
/// A mesh's solid range from a record per quad, `None` where no record fits:
|
||||
@@ -220,6 +233,17 @@ pub fn layout(solid: &[TerrainVertex], tints: &[u32]) -> Vec<[u32; 6]> {
|
||||
/// Shared with `lod::packed`, whose records hold a far tile's quads instead but
|
||||
/// spill and pad the same way.
|
||||
pub fn place(records: &[Option<[u32; 6]>], quads: &[[TerrainVertex; 4]]) -> Vec<[u32; 6]> {
|
||||
let mut out = Vec::new();
|
||||
place_into(records, quads, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
pub fn place_into(
|
||||
records: &[Option<[u32; 6]>],
|
||||
quads: &[[TerrainVertex; 4]],
|
||||
out: &mut Vec<[u32; 6]>,
|
||||
) {
|
||||
out.clear();
|
||||
let n = quads.len();
|
||||
let packed = records.iter().filter(|r| r.is_some()).count();
|
||||
let full = n - packed;
|
||||
@@ -229,9 +253,9 @@ pub fn place(records: &[Option<[u32; 6]>], quads: &[[TerrainVertex; 4]]) -> Vec<
|
||||
0
|
||||
};
|
||||
if n + pad + 4 * full >= 4 * n {
|
||||
return Vec::new();
|
||||
return;
|
||||
}
|
||||
let mut out = Vec::with_capacity(n + pad + 4 * full);
|
||||
out.reserve(n + pad + 4 * full);
|
||||
let mut next = n + pad;
|
||||
for (i, record) in records.iter().enumerate() {
|
||||
out.push(record.unwrap_or_else(|| {
|
||||
@@ -249,7 +273,6 @@ pub fn place(records: &[Option<[u32; 6]>], quads: &[[TerrainVertex; 4]]) -> Vec<
|
||||
);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Quads `layout` gave a record of their own.
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
//! cutout ones, so one pass over all of them draws the runs it drew before.
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use super::{OPAQUE_LAYER, TerrainVertex};
|
||||
use super::{OPAQUE_LAYER, TerrainSource, TerrainVertex};
|
||||
|
||||
/// The buckets in mesh order: +X, +Z, other, +Y, -Z, -X, -Y. Seen from
|
||||
/// above (+Y, the commonest case), each pair of horizontal sides with a
|
||||
@@ -247,15 +247,29 @@ fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
|
||||
}
|
||||
|
||||
/// `sort`, moving each quad's tint in `tints` (one per quad, or none) with it.
|
||||
#[cfg(test)]
|
||||
pub fn sort_tinted(solid: &mut Vec<TerrainVertex>, tints: &mut Vec<u32>) -> Sides {
|
||||
sort_sourced(solid, tints, &mut Vec::new())
|
||||
}
|
||||
|
||||
pub(super) fn sort_sourced(
|
||||
solid: &mut Vec<TerrainVertex>,
|
||||
tints: &mut Vec<u32>,
|
||||
sources: &mut Vec<TerrainSource>,
|
||||
) -> Sides {
|
||||
if !sources.is_empty() {
|
||||
sources.resize(solid.len() / 4, TerrainSource::default());
|
||||
}
|
||||
let grouped = GROUPED.load(Ordering::Relaxed);
|
||||
SORT_SCRATCH.with_borrow_mut(|scratch| sort_with(solid, tints, grouped, scratch))
|
||||
SORT_SCRATCH.with_borrow_mut(|scratch| sort_with(solid, tints, sources, grouped, scratch))
|
||||
}
|
||||
|
||||
/// `sort`, with all the opaque parts first when `grouped`.
|
||||
#[cfg(test)]
|
||||
fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
|
||||
SORT_SCRATCH.with_borrow_mut(|scratch| sort_with(solid, &mut Vec::new(), grouped, scratch))
|
||||
SORT_SCRATCH.with_borrow_mut(|scratch| {
|
||||
sort_with(solid, &mut Vec::new(), &mut Vec::new(), grouped, scratch)
|
||||
})
|
||||
}
|
||||
|
||||
/// `sort`'s buffers, kept by each meshing thread so sorting a mesh allocates
|
||||
@@ -267,6 +281,7 @@ struct SortScratch {
|
||||
keys: Vec<u64>,
|
||||
sorted: Vec<[TerrainVertex; 4]>,
|
||||
tints: Vec<u32>,
|
||||
sources: Vec<TerrainSource>,
|
||||
}
|
||||
thread_local! {
|
||||
static SORT_SCRATCH: std::cell::RefCell<SortScratch> = Default::default();
|
||||
@@ -281,6 +296,7 @@ fn total_order_key(v: f32) -> u32 {
|
||||
fn sort_with(
|
||||
solid: &mut Vec<TerrainVertex>,
|
||||
tints: &mut Vec<u32>,
|
||||
sources: &mut Vec<TerrainSource>,
|
||||
grouped: bool,
|
||||
scratch: &mut SortScratch,
|
||||
) -> Sides {
|
||||
@@ -354,6 +370,13 @@ fn sort_with(
|
||||
sorted_tints.extend(keys.iter().map(|&k| tints[(k & 0xFF_FFFF) as usize]));
|
||||
std::mem::swap(tints, sorted_tints);
|
||||
}
|
||||
if sources.len() == quads_len {
|
||||
scratch.sources.clear();
|
||||
scratch
|
||||
.sources
|
||||
.extend(keys.iter().map(|&k| sources[(k & 0xFF_FFFF) as usize]));
|
||||
std::mem::swap(sources, &mut scratch.sources);
|
||||
}
|
||||
}
|
||||
Sides {
|
||||
ends: slot_ends,
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
//! Original synthetic block/model/world controls; no prepared game resources.
|
||||
use super::*;
|
||||
use crate::assets::{Properties, SpritePixels};
|
||||
use horizon_world::registry::BlockRegistry;
|
||||
use serde_json::json;
|
||||
use std::{collections::BTreeMap, path::PathBuf};
|
||||
|
||||
struct Scratch(PathBuf);
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
std::fs::remove_dir_all(&self.0).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture() -> (Scratch, Assets, RenderAssets, MeshTables, Biomes) {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"terrain-source-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&path).unwrap();
|
||||
let write = |name: &str, value: serde_json::Value| {
|
||||
let file = path.join(name);
|
||||
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
std::fs::write(file, value.to_string()).unwrap();
|
||||
};
|
||||
let registry = json!({"defaults":{"minecraft:air":0,"fixture:signal":1},"states":[
|
||||
["air",true,false,"", "minecraft:air"],
|
||||
["signal",false,false,"",{"id":"fixture:signal","properties":{"active":"false","waterlogged":"false"}}],
|
||||
["signal",false,true,"",{"id":"fixture:signal","properties":{"active":"true","waterlogged":"true"}}]
|
||||
]});
|
||||
let registry = Box::leak(Box::new(
|
||||
BlockRegistry::from_facts_json(registry.to_string().as_bytes()).unwrap(),
|
||||
));
|
||||
write(
|
||||
"assets/fixture/blockstates/signal.json",
|
||||
json!({"multipart":[
|
||||
{"apply":{"model":"fixture:block/low"}}, {"apply":{"model":"fixture:block/extended"}}
|
||||
]}),
|
||||
);
|
||||
for (name, from, to) in [
|
||||
("low", [0, 4, 0], [8, 4, 8]),
|
||||
("extended", [20, 12, 0], [28, 12, 8]),
|
||||
] {
|
||||
write(
|
||||
&format!("assets/fixture/models/block/{name}.json"),
|
||||
json!({
|
||||
"textures":{"all":"fixture:block/shared"},
|
||||
"elements":[{"from":from,"to":to,"faces":{"up":{"texture":"#all"}}}]
|
||||
}),
|
||||
);
|
||||
}
|
||||
// The same authored sprite is used by every part of both states.
|
||||
let png_path = path.join("assets/fixture/textures/block/shared.png");
|
||||
std::fs::create_dir_all(png_path.parent().unwrap()).unwrap();
|
||||
let encode = |file: PathBuf| {
|
||||
let mut encoder = png::Encoder::new(std::fs::File::create(file).unwrap(), 1, 1);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&[63, 127, 191, 255])
|
||||
.unwrap();
|
||||
};
|
||||
encode(png_path);
|
||||
let facts = BTreeMap::from([(
|
||||
"fixture:signal".into(),
|
||||
json!({
|
||||
"schema":{"active":{"named":["false","true"]},"waterlogged":{"named":["false","true"]}},
|
||||
"states":[{"active":"false","waterlogged":"false"},{"active":"true","waterlogged":"true"}]
|
||||
}),
|
||||
)]);
|
||||
let mut sprites = crate::sprites::Sprites::default();
|
||||
let catalog = crate::models::StateModels::from_facts(
|
||||
&path,
|
||||
registry,
|
||||
&["fixture:signal"],
|
||||
&mut sprites,
|
||||
&facts,
|
||||
)
|
||||
.unwrap();
|
||||
let render = RenderAssets {
|
||||
sprite_size: 1,
|
||||
sprite_minimum_size: 1,
|
||||
pixels: SpritePixels::new(vec![255; 4]),
|
||||
texture_layers: 1,
|
||||
model_catalog: catalog,
|
||||
model_library: Err("unused synthetic item models".into()),
|
||||
model_placements: vec![],
|
||||
model_gallery: vec![],
|
||||
animations: vec![],
|
||||
sprite_layers: BTreeMap::new(),
|
||||
emissive: vec![],
|
||||
translucent_layers: vec![false],
|
||||
sprite_mips: vec![],
|
||||
pbr_normals: vec![],
|
||||
pbr_specular: vec![],
|
||||
pbr_size: 1,
|
||||
connected: Default::default(),
|
||||
fusion: Default::default(),
|
||||
generation: 1,
|
||||
};
|
||||
let assets = Assets {
|
||||
registry,
|
||||
properties: Properties::for_tests(&[
|
||||
(true, false, false, false),
|
||||
(false, false, false, false),
|
||||
(false, true, false, false),
|
||||
]),
|
||||
material_states: [BlockStateId(0); 6],
|
||||
blocks_fluid_flow: Default::default(),
|
||||
};
|
||||
let air = Info {
|
||||
solid: false,
|
||||
full: false,
|
||||
permeable: true,
|
||||
shade: 1.0,
|
||||
emission: 0,
|
||||
tier: 0,
|
||||
emissive: false,
|
||||
fluid: FluidState::EMPTY,
|
||||
model: false,
|
||||
block_entity: false,
|
||||
tints: [Tint::None; 2],
|
||||
tint_count: 0,
|
||||
colormap: None,
|
||||
offset: None,
|
||||
legacy_solid: false,
|
||||
overlay: false,
|
||||
leaves: false,
|
||||
pane: false,
|
||||
opaque_cube: false,
|
||||
blocks_flow: false,
|
||||
ice: false,
|
||||
opaque_ice: false,
|
||||
sturdy: 0,
|
||||
face: [0; 6],
|
||||
cover: [0.0; 6],
|
||||
};
|
||||
let info = vec![
|
||||
air,
|
||||
Info { model: true, ..air },
|
||||
Info {
|
||||
model: true,
|
||||
emission: 13,
|
||||
fluid: FluidState::WATER,
|
||||
..air
|
||||
},
|
||||
];
|
||||
let tables = MeshTables {
|
||||
info,
|
||||
solid: vec![false; 3],
|
||||
full_faces: vec![0; 3],
|
||||
visibility: FaceVisibility::empty(),
|
||||
water_still: 0,
|
||||
water_flow: 0,
|
||||
water_overlay: 0,
|
||||
lava_still: 0,
|
||||
lava_flow: 0,
|
||||
animated_layers: vec![false],
|
||||
colormaps: Vec::new(),
|
||||
water_colormap: None,
|
||||
};
|
||||
let maps = path.join("assets/minecraft/textures/colormap");
|
||||
std::fs::create_dir_all(&maps).unwrap();
|
||||
for name in ["grass", "foliage", "dry_foliage"] {
|
||||
encode(maps.join(format!("{name}.png")));
|
||||
}
|
||||
write(
|
||||
"data/minecraft/worldgen/biome/plains.json",
|
||||
json!({"temperature":0.5,"downfall":0.5,"effects":{"water_color":4159204}}),
|
||||
);
|
||||
let biomes = Biomes::load(&path).unwrap();
|
||||
(Scratch(path), assets, render, tables, biomes)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terrain_source_cpu_multipart_and_waterlogged_faces_keep_their_owner() {
|
||||
let (_scratch, assets, render, tables, biomes) = fixture();
|
||||
let mut chunk = Chunk::with_layout(3, -2, crate::chunk::ColumnLayout::new(0, 16).unwrap());
|
||||
chunk.set(2, 3, 4, 1);
|
||||
chunk.set(6, 3, 4, 2);
|
||||
let region = [
|
||||
[None, None, None],
|
||||
[None, Some(&chunk), None],
|
||||
[None, None, None],
|
||||
];
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: false,
|
||||
shader_pack_lighting: false,
|
||||
separate_ao: false,
|
||||
shader_pack: true,
|
||||
assets: &assets,
|
||||
render: &render,
|
||||
tables: &tables,
|
||||
biomes: &biomes,
|
||||
visibility: FaceVisibility::empty(),
|
||||
zoom: BiomeZoom::new(0),
|
||||
blend_radius: 0,
|
||||
smooth_lighting: false,
|
||||
cutout_leaves: true,
|
||||
opaque_ice: false,
|
||||
translucent_layers: &[],
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
};
|
||||
let mut mesh = mesher.mesh(®ion, 0);
|
||||
assert_eq!(mesh.solid.len(), 16, "two parts per source state");
|
||||
assert!(
|
||||
!mesh.translucent.is_empty(),
|
||||
"waterlogged fluid geometry was emitted"
|
||||
);
|
||||
assert_eq!(
|
||||
mesh.solid_sources,
|
||||
[
|
||||
TerrainSource::new(1, [2, 3, 4], 0, false),
|
||||
TerrainSource::new(1, [2, 3, 4], 0, false),
|
||||
TerrainSource::new(2, [6, 3, 4], 13, false),
|
||||
TerrainSource::new(2, [6, 3, 4], 13, false)
|
||||
]
|
||||
);
|
||||
assert_eq!(mesh.translucent_sources.len(), mesh.translucent.len() / 4);
|
||||
assert!(
|
||||
mesh.translucent_sources
|
||||
.iter()
|
||||
.all(|s| *s == TerrainSource::new(2, [6, 3, 4], 13, true))
|
||||
);
|
||||
let before: Vec<_> = mesh
|
||||
.solid
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.zip(&mesh.solid_sources)
|
||||
.map(|(q, s)| (bytemuck::cast_slice::<_, u8>(q).to_vec(), *s))
|
||||
.collect();
|
||||
mesh.sort_solid();
|
||||
for (q, s) in mesh
|
||||
.solid
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.zip(&mesh.solid_sources)
|
||||
{
|
||||
assert!(
|
||||
before.contains(&(bytemuck::cast_slice::<_, u8>(q).to_vec(), *s)),
|
||||
"sort changed face ownership"
|
||||
);
|
||||
}
|
||||
// Reusing the same mesh for native/default rendering clears metadata and keeps
|
||||
// native model vertices byte-for-byte (fluid splitting already differs with packs).
|
||||
mesher.shader_pack = false;
|
||||
let native = mesher.mesh_reusing(®ion, 0, mesh, &mut MeshScratch::default());
|
||||
assert!(native.solid_sources.is_empty() && native.translucent_sources.is_empty());
|
||||
for q in native.solid.as_chunks::<4>().0.iter() {
|
||||
assert!(
|
||||
before
|
||||
.iter()
|
||||
.any(|(bytes, _)| bytes == bytemuck::cast_slice::<_, u8>(q)),
|
||||
"source tracking altered native vertices"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terrain_source_cpu_partial_metadata_never_inherits_a_previous_face() {
|
||||
let mut mesh = SectionMesh::default();
|
||||
let quad = |y| {
|
||||
[[0.0, y, 0.0], [0.0, y, 1.0], [1.0, y, 1.0], [1.0, y, 0.0]]
|
||||
.map(|pos| TerrainVertex::new(pos, [0.0; 2], 0, 0, 0))
|
||||
};
|
||||
mesh.solid.extend(quad(1.0));
|
||||
let start = [mesh.solid.len(), 0];
|
||||
mesh.solid.extend(quad(3.0));
|
||||
mesh.solid.extend(quad(2.0));
|
||||
mesh.record_source(start, TerrainSource::new(71, [15, 0, 8], 7, false));
|
||||
assert_eq!(mesh.solid_sources[0], TerrainSource::default());
|
||||
assert_eq!(
|
||||
bytemuck::cast_slice::<_, u32>(&mesh.solid_sources[1..]),
|
||||
[71, 124943, 71, 124943]
|
||||
);
|
||||
mesh.sort_solid();
|
||||
assert_eq!(
|
||||
mesh.solid
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|q| q[0].pos[1])
|
||||
.collect::<Vec<_>>(),
|
||||
[3.0, 2.0, 1.0]
|
||||
);
|
||||
assert_eq!(
|
||||
mesh.solid_sources,
|
||||
[
|
||||
TerrainSource::new(71, [15, 0, 8], 7, false),
|
||||
TerrainSource::new(71, [15, 0, 8], 7, false),
|
||||
TerrainSource::default()
|
||||
]
|
||||
);
|
||||
mesh.clear();
|
||||
assert!(mesh.solid_sources.is_empty());
|
||||
}
|
||||
@@ -112,3 +112,72 @@ fn one_biome_shortcut_blends_like_the_full_square() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A pack root holding `files` (path, bytes), for colormap tests.
|
||||
fn pack(files: &[(&str, Vec<u8>)]) -> std::path::PathBuf {
|
||||
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"horizon-mesher-colormap-{}-{n}",
|
||||
std::process::id()
|
||||
));
|
||||
for (path, bytes) in files {
|
||||
let path = root.join(path);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
root
|
||||
}
|
||||
|
||||
/// A one-row PNG of RGBA pixels.
|
||||
fn png_row(pixels: &[[u8; 4]]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut encoder = png::Encoder::new(&mut out, pixels.len() as u32, 1);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().unwrap();
|
||||
writer.write_image_data(&pixels.concat()).unwrap();
|
||||
writer.finish().unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_polytone_colormap_tints_by_biome_and_keeps_the_biome_colour_where_transparent() {
|
||||
let biomes = Biomes::from_jsons(&[
|
||||
(
|
||||
"minecraft:plains",
|
||||
serde_json::json!({"effects": {"water_color": 0x3F76E4}}),
|
||||
),
|
||||
(
|
||||
"minecraft:desert",
|
||||
serde_json::json!({"effects": {"water_color": 0x32A598}}),
|
||||
),
|
||||
]);
|
||||
// Left to right: transparent, red. Desert maps to column 1, plains (unnamed) to column 0.
|
||||
let root = pack(&[
|
||||
(
|
||||
"assets/mypack/polytone/colormaps/water.png",
|
||||
png_row(&[[0, 0, 0, 0], [255, 0, 0, 255]]),
|
||||
),
|
||||
(
|
||||
"assets/mypack/polytone/colormaps/water.json",
|
||||
br#"{"x_axis": "biome_id", "y_axis": 0, "biome_id_mapper": {"minecraft:desert": 1, "texture_size": 2}}"#
|
||||
.to_vec(),
|
||||
),
|
||||
]);
|
||||
let colormaps = vec![Arc::new(
|
||||
crate::environment::Colormap::load(&root, "mypack:water").unwrap(),
|
||||
)];
|
||||
for (biome, expected) in [(1, 0xFF_0000), (0, 0x3F_76E4)] {
|
||||
let chunks: Vec<Vec<Chunk>> = (0..3)
|
||||
.map(|rz| (0..3).map(|rx| filled(rx - 1, rz - 1, biome)).collect())
|
||||
.collect();
|
||||
let region: [[Option<&Chunk>; 3]; 3] =
|
||||
std::array::from_fn(|rz| std::array::from_fn(|rx| Some(&chunks[rz][rx])));
|
||||
let mut tints = Tints::new(®ion, &biomes, BiomeZoom::new(0), 0, [0, 0, 64]);
|
||||
tints.colormaps = &colormaps;
|
||||
tints.water_colormap = Some(0);
|
||||
assert_eq!(tints.water(3, 4, 5), expected, "biome {biome}");
|
||||
}
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
@@ -84,6 +84,7 @@ fn info(solid: bool) -> Info {
|
||||
block_entity: false,
|
||||
tints: [Tint::None; 2],
|
||||
tint_count: 0,
|
||||
colormap: None,
|
||||
offset: None,
|
||||
legacy_solid: false,
|
||||
overlay: false,
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
return oit_transmittance_fragment(entity_color(in, front).a, in.clip);
|
||||
}
|
||||
@fragment fn fs_oit_entity_accumulate(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
return oit_accumulate_fragment(entity_written_color(in, front), in.clip);
|
||||
return oit_accumulate_fragment(entity_written_color(in, entity_color(in, front)), in.clip);
|
||||
}
|
||||
// Text keeps its source gain, as fs_entity_unmarked does in the forward pass.
|
||||
@fragment fn fs_oit_text_accumulate(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
||||
|
||||
@@ -1612,11 +1612,9 @@ pub struct OptionsUi {
|
||||
selected_native_key: Option<(usize, usize)>,
|
||||
/// Bindings: the action waiting for a pad button.
|
||||
pub selected_pad_action: Option<&'static str>,
|
||||
/// The glyphs the binds list shows (the setting, else the pad's family).
|
||||
pub pad_style: GlyphStyle,
|
||||
/// The pad goes by its letters (`PadSettings::nintendo_letters`) and the glyphs are
|
||||
/// Nintendo's: each part shows the letter of the button playing it.
|
||||
pub pad_by_letter: bool,
|
||||
/// The glyphs menus show (`Glyphs::of`: the setting, else the pad's family, and a Nintendo
|
||||
/// pad's letters when it goes by them). The game sets it each frame.
|
||||
pub pad_glyphs: crate::pad_glyph::Glyphs,
|
||||
/// The pad's focus drives the page: its hidden pointer sits mid-widget, so a tooltip goes
|
||||
/// under the widget instead of beside the pointer.
|
||||
pub pad_driving: bool,
|
||||
@@ -1660,7 +1658,8 @@ pub struct OptionsUi {
|
||||
/// Scroll the selected language into the middle on the next layout.
|
||||
center_language: bool,
|
||||
graphics: shell::Shell<crate::graphics_ui::GraphicsScreen>,
|
||||
pub controller: crate::controller_ui::ControllerUi,
|
||||
/// Controller Settings, on the settings shell.
|
||||
controller: shell::Shell<crate::controller_ui::ControllerScreen>,
|
||||
/// Horizon Extras, on the settings shell.
|
||||
extras: shell::Shell<crate::extras_ui::ExtrasScreen>,
|
||||
pub shader: crate::shader_ui::ShaderUi,
|
||||
@@ -1715,8 +1714,7 @@ impl Default for OptionsUi {
|
||||
selected_key: None,
|
||||
selected_native_key: None,
|
||||
selected_pad_action: None,
|
||||
pad_style: GlyphStyle::Xbox,
|
||||
pad_by_letter: false,
|
||||
pad_glyphs: crate::pad_glyph::Glyphs::default(),
|
||||
pad_driving: false,
|
||||
languages: Vec::new(),
|
||||
language_choice: None,
|
||||
@@ -2100,7 +2098,6 @@ impl OptionsUi {
|
||||
.pack_screen
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.search_focused()),
|
||||
Page::Controller | Page::PadBinds => self.controller.typing(),
|
||||
_ if Self::shader_page(self.page()).is_some() => self.shader.typing(),
|
||||
_ => match self.shell() {
|
||||
Some(shell) => shell.typing(),
|
||||
@@ -2212,9 +2209,6 @@ impl OptionsUi {
|
||||
if page == Page::Packs {
|
||||
self.pack_screen = None;
|
||||
}
|
||||
if let Some(at) = Self::controller_page(page) {
|
||||
self.controller.open(at);
|
||||
}
|
||||
if let Some(at) = Self::shader_page(page) {
|
||||
self.shader.open(at);
|
||||
}
|
||||
@@ -2224,6 +2218,9 @@ impl OptionsUi {
|
||||
if let Some(at) = Self::graphics_page(page) {
|
||||
self.graphics.show(at);
|
||||
}
|
||||
if let Some(at) = Self::controller_page(page) {
|
||||
self.controller.show(at);
|
||||
}
|
||||
if page == Page::SplitSetup {
|
||||
self.split_setup.reset();
|
||||
}
|
||||
@@ -2241,6 +2238,7 @@ impl OptionsUi {
|
||||
match self.page() {
|
||||
Page::Extras => Some(&self.extras),
|
||||
page if Self::graphics_page(page).is_some() => Some(&self.graphics),
|
||||
page if Self::controller_page(page).is_some() => Some(&self.controller),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -2248,6 +2246,7 @@ impl OptionsUi {
|
||||
match self.page() {
|
||||
Page::Extras => Some(&mut self.extras),
|
||||
page if Self::graphics_page(page).is_some() => Some(&mut self.graphics),
|
||||
page if Self::controller_page(page).is_some() => Some(&mut self.controller),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -2256,10 +2255,20 @@ impl OptionsUi {
|
||||
pub fn reveal(&mut self, s: &Settings, size: (f32, f32), rect: [f32; 4]) -> Option<f32> {
|
||||
self.shell_mut()?.reveal(s, size, rect)
|
||||
}
|
||||
/// The window's size, for the graphics screen's vanilla rows that step through its
|
||||
/// resolutions: before each input that can press one.
|
||||
fn graphics_framebuffer(&mut self) {
|
||||
/// The window's size, for the graphics and controller screens' rows that step an option
|
||||
/// (`Opt::cycle`, which a fullscreen resolution needs): before each input that can press one.
|
||||
fn shell_framebuffers(&mut self) {
|
||||
self.graphics.screen.framebuffer = self.framebuffer;
|
||||
self.controller.screen.framebuffer = self.framebuffer;
|
||||
}
|
||||
/// Who and what Controller Settings is about: the game sets it before each draw.
|
||||
pub fn set_controller_context(&mut self, context: crate::controller_ui::Context) {
|
||||
self.controller.screen.context = context;
|
||||
}
|
||||
/// The binding the screen waits on: a controller button's, else a key's.
|
||||
fn waiting(&self) -> Option<shell::Capture> {
|
||||
let pad = self.selected_pad_action.map(shell::Capture::Pad);
|
||||
pad.or(self.selected_key.map(shell::Capture::Key))
|
||||
}
|
||||
/// What a shell screen's outputs do to these options.
|
||||
fn shell_events(&mut self, s: &mut Settings, outputs: Vec<shell::Output>) -> Vec<Event> {
|
||||
@@ -2267,7 +2276,10 @@ impl OptionsUi {
|
||||
for output in outputs {
|
||||
match output {
|
||||
shell::Output::Event(event) => events.push(event),
|
||||
shell::Output::BindKey(name) => self.selected_key = Some(name),
|
||||
shell::Output::Bind(shell::Capture::Key(name)) => self.selected_key = Some(name),
|
||||
shell::Output::Bind(shell::Capture::Pad(action)) => {
|
||||
self.selected_pad_action = Some(action)
|
||||
}
|
||||
shell::Output::Push(page) => {
|
||||
if page == Page::HdrCalibration {
|
||||
self.calibration = Some(crate::hdr_calibration::Calibration::new(s));
|
||||
@@ -2279,25 +2291,6 @@ impl OptionsUi {
|
||||
}
|
||||
events
|
||||
}
|
||||
/// What the controller screen's actions do to these options.
|
||||
fn controller_events(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
actions: Vec<crate::controller_ui::Action>,
|
||||
) -> Vec<Event> {
|
||||
use crate::controller_ui::Action;
|
||||
let mut events = Vec::new();
|
||||
for action in actions {
|
||||
match action {
|
||||
Action::ButtonClick => events.push(Event::ButtonClick),
|
||||
Action::Changed => events.push(Event::Changed),
|
||||
Action::SliderDrag => events.push(Event::SliderDrag),
|
||||
Action::BindPad(action) => self.selected_pad_action = Some(action),
|
||||
Action::Done => events.extend(self.back(s)),
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
/// What the shader screen's actions do to these options.
|
||||
fn shader_events(
|
||||
&mut self,
|
||||
@@ -3687,16 +3680,11 @@ impl OptionsUi {
|
||||
c.draw(batch, atlas, cursor);
|
||||
return;
|
||||
}
|
||||
if Self::controller_page(self.page()).is_some() {
|
||||
self.controller.binding = self.selected_pad_action;
|
||||
self.controller.draw(s, batch, atlas, cursor, self.in_world);
|
||||
return;
|
||||
}
|
||||
if Self::shader_page(self.page()).is_some() {
|
||||
self.shader.draw(s, batch, atlas, cursor, self.in_world);
|
||||
return;
|
||||
}
|
||||
let waiting = self.selected_key;
|
||||
let waiting = self.waiting();
|
||||
let in_world = self.in_world;
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
shell.draw(s, batch, atlas, cursor, in_world, waiting);
|
||||
@@ -3713,7 +3701,7 @@ impl OptionsUi {
|
||||
}
|
||||
if self.page() == Page::SplitProfiles {
|
||||
self.split_profiles
|
||||
.draw(batch, atlas, cursor, self.in_world, self.pad_style);
|
||||
.draw(batch, atlas, cursor, self.in_world, self.pad_glyphs);
|
||||
return;
|
||||
}
|
||||
if self.page() == Page::Studio {
|
||||
@@ -4050,6 +4038,10 @@ impl OptionsUi {
|
||||
return self.calibration_outcome(s, outcome);
|
||||
}
|
||||
if self.shell().is_some() {
|
||||
// Waiting on a controller button: a click cancels the wait.
|
||||
if !drag && self.selected_pad_action.take().is_some() {
|
||||
return Vec::new();
|
||||
}
|
||||
// Waiting on a key: a mouse button binds, as on Key Binds.
|
||||
if self.selected_key.is_some() && !drag {
|
||||
let button = if right {
|
||||
@@ -4061,22 +4053,12 @@ impl OptionsUi {
|
||||
}
|
||||
let shift = self.shift;
|
||||
self.framebuffer = framebuffer;
|
||||
self.graphics_framebuffer();
|
||||
self.shell_framebuffers();
|
||||
let outputs = self.shell_mut().map_or_else(Vec::new, |shell| {
|
||||
shell.click(s, (x, y), size, drag, right, shift)
|
||||
});
|
||||
return self.shell_events(s, outputs);
|
||||
}
|
||||
if Self::controller_page(self.page()).is_some() {
|
||||
// Waiting on a button: a click cancels the wait.
|
||||
if !drag && self.selected_pad_action.take().is_some() {
|
||||
return Vec::new();
|
||||
}
|
||||
let actions =
|
||||
self.controller
|
||||
.click(s, (x, y), size, framebuffer, drag, right, self.shift);
|
||||
return self.controller_events(s, actions);
|
||||
}
|
||||
if Self::shader_page(self.page()).is_some() {
|
||||
let actions = self.shader.click(s, (x, y), size, drag, right, self.shift);
|
||||
return self.shader_events(s, actions);
|
||||
@@ -4349,7 +4331,7 @@ impl OptionsUi {
|
||||
let slider = self.dragging.take().is_some();
|
||||
let (shader_slider, shader_pack) = self.shader.release();
|
||||
let shell_slider = self.shell_mut().is_some_and(|shell| shell.release());
|
||||
let graphics_slider = self.controller.release() | shader_slider | shell_slider;
|
||||
let graphics_slider = shader_slider | shell_slider;
|
||||
let calibration_slider = self.calibration.as_mut().is_some_and(|c| c.release());
|
||||
let split_drop = self.split_setup.release();
|
||||
if slider || graphics_slider || calibration_slider {
|
||||
@@ -4429,7 +4411,7 @@ impl OptionsUi {
|
||||
if self.resource_error.is_some() {
|
||||
return Vec::new();
|
||||
}
|
||||
self.graphics_framebuffer();
|
||||
self.shell_framebuffers();
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
let outputs = shell.wheel(s, size, cursor, amount);
|
||||
return self.shell_events(s, outputs);
|
||||
@@ -4438,10 +4420,6 @@ impl OptionsUi {
|
||||
self.studio.wheel(amount);
|
||||
return Vec::new();
|
||||
}
|
||||
if Self::controller_page(self.page()).is_some() {
|
||||
let actions = self.controller.wheel(s, size, cursor, framebuffer, amount);
|
||||
return self.controller_events(s, actions);
|
||||
}
|
||||
if Self::shader_page(self.page()).is_some() {
|
||||
self.shader.scroll(s, size, amount);
|
||||
return Vec::new();
|
||||
@@ -4529,10 +4507,6 @@ impl OptionsUi {
|
||||
self.studio.wheel(amount);
|
||||
return;
|
||||
}
|
||||
if Self::controller_page(self.page()).is_some() {
|
||||
self.controller.scroll(s, size, amount);
|
||||
return;
|
||||
}
|
||||
if Self::shader_page(self.page()).is_some() {
|
||||
self.shader.scroll(s, size, amount);
|
||||
return;
|
||||
@@ -4721,8 +4695,6 @@ impl OptionsUi {
|
||||
if shell.typing() {
|
||||
shell.type_text(text);
|
||||
}
|
||||
} else if Self::controller_page(self.page()).is_some() && self.controller.typing() {
|
||||
self.controller.type_text(text);
|
||||
} else if Self::shader_page(self.page()).is_some() && self.shader.typing() {
|
||||
self.shader.type_text(text);
|
||||
} else if self.settings_search_active() {
|
||||
@@ -4759,9 +4731,6 @@ impl OptionsUi {
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
return shell.shortcut(shortcut, clipboard);
|
||||
}
|
||||
if Self::controller_page(self.page()).is_some() {
|
||||
return self.controller.shortcut(shortcut, clipboard);
|
||||
}
|
||||
if Self::shader_page(self.page()).is_some() {
|
||||
return self.shader.shortcut(shortcut, clipboard);
|
||||
}
|
||||
@@ -4881,21 +4850,11 @@ impl OptionsUi {
|
||||
} else {
|
||||
MIN_KEY_GUI
|
||||
};
|
||||
self.graphics_framebuffer();
|
||||
self.shell_framebuffers();
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
let outputs = shell.key(s, key, text, shift, gui);
|
||||
return self.shell_events(s, outputs);
|
||||
}
|
||||
if Self::controller_page(self.page()).is_some() {
|
||||
let size = (self.size.0.max(480.0), self.size.1.max(240.0));
|
||||
let fb = [self.framebuffer[0].max(480), self.framebuffer[1].max(240)];
|
||||
let (actions, _) = self.controller.key_modifiers(s, key, text, shift, size, fb);
|
||||
let mut events = self.controller_events(s, actions);
|
||||
if key == K::Escape {
|
||||
events.extend(self.back(s));
|
||||
}
|
||||
return events;
|
||||
}
|
||||
if Self::shader_page(self.page()).is_some() {
|
||||
let size = (self.size.0.max(480.0), self.size.1.max(240.0));
|
||||
let (actions, handled) = self.shader.key_modifiers(s, key, text, shift, size);
|
||||
@@ -6644,6 +6603,42 @@ mod tests {
|
||||
assert_eq!(ui.page(), Page::Horizon);
|
||||
}
|
||||
|
||||
/// A Bindings row pressed waits for a controller button: the next click or key cancels the
|
||||
/// wait, Escape unbinds the action as on Key Binds, and none of them leaves the screen.
|
||||
#[test]
|
||||
fn a_controller_binding_waits_and_a_click_or_a_key_ends_the_wait() {
|
||||
use crate::controller_ui::Item;
|
||||
use winit::keyboard::KeyCode as K;
|
||||
let atlas = atlas();
|
||||
let mut s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::PadBinds);
|
||||
let size = (854.0, 480.0);
|
||||
ui.size = size;
|
||||
let jump = Item::Bind("key.jump");
|
||||
let (x, y) = ui.controller.point_at(&s, size, jump);
|
||||
let press = |ui: &mut OptionsUi, s: &mut Settings| {
|
||||
ui.click(s, &atlas, x, y, size, [1708, 960], false, false)
|
||||
};
|
||||
assert_eq!(press(&mut ui, &mut s), vec![Event::ButtonClick]);
|
||||
assert_eq!(ui.selected_pad_action, Some("key.jump"));
|
||||
assert!(press(&mut ui, &mut s).is_empty());
|
||||
assert_eq!(ui.selected_pad_action, None);
|
||||
press(&mut ui, &mut s);
|
||||
assert_eq!(ui.key(&mut s, &atlas, K::KeyQ, None), vec![Event::Changed]);
|
||||
assert_eq!(
|
||||
(ui.selected_pad_action, s.controller.is_default("key.jump")),
|
||||
(None, true)
|
||||
);
|
||||
press(&mut ui, &mut s);
|
||||
assert_eq!(
|
||||
ui.key(&mut s, &atlas, K::Escape, None),
|
||||
vec![Event::Changed]
|
||||
);
|
||||
assert_eq!(s.controller.binding("key.jump"), None);
|
||||
assert_eq!(ui.page(), Page::PadBinds);
|
||||
}
|
||||
|
||||
/// Every way a pad or a wheel scrolls a page on the shell moves the same list: Replay Studio's
|
||||
/// took the wheel and missed `scroll_at`, so a stick couldn't scroll it.
|
||||
#[test]
|
||||
|
||||
@@ -34,8 +34,11 @@ pub struct Pack {
|
||||
pub description_style: Style,
|
||||
pub compatible: bool,
|
||||
pub compatibility: crate::pack_screen::Compat,
|
||||
/// Overlay directories active for this game version, in `pack.mcmeta` order.
|
||||
/// Overlay directories active for this game version, in `pack.mcmeta` order: also those a
|
||||
/// Polytone `polytone_condition` turns on.
|
||||
pub overlays: Vec<String>,
|
||||
/// The values of the pack's Polytone config entries.
|
||||
pub polytone_configs: horizon_polytone::pack::Configs,
|
||||
/// `filter.block` entries (namespace and path patterns) hiding resources
|
||||
/// of the packs below this one.
|
||||
pub filter: Vec<Filter>,
|
||||
@@ -416,12 +419,12 @@ fn overlay_applies(entry: &serde_json::Value) -> bool {
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
fn overlays_of(meta: &serde_json::Value) -> Vec<String> {
|
||||
fn overlays_of(meta: &serde_json::Value, configs: &horizon_polytone::pack::Configs) -> Vec<String> {
|
||||
meta["overlays"]["entries"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|e| overlay_applies(e))
|
||||
.filter(|e| horizon_polytone::pack::overlay_active(e, configs, overlay_applies(e)))
|
||||
.filter_map(|e| e["directory"].as_str().map(str::to_owned))
|
||||
// OverlayMetadataSection's DIR_VALIDATOR: one folder of the pack, `[-_a-zA-Z0-9.]+`.
|
||||
.filter(|d| {
|
||||
@@ -468,12 +471,66 @@ pub(crate) fn parse_meta(bytes: &[u8]) -> Option<serde_json::Value> {
|
||||
.next()?
|
||||
.ok()
|
||||
}
|
||||
/// A pack's Polytone config entries (`assets/<namespace>/polytone/config_entries/<name>.json`,
|
||||
/// in the pack's base, not its overlays) at their default values.
|
||||
pub fn polytone_configs(source: &Source) -> horizon_polytone::pack::Configs {
|
||||
let mut configs = horizon_polytone::pack::Configs::default();
|
||||
let id_of = |name: &str| -> Option<String> {
|
||||
let rest = name.strip_prefix("assets/")?;
|
||||
let (namespace, rest) = rest.split_once('/')?;
|
||||
let entry = rest
|
||||
.strip_prefix("polytone/config_entries/")?
|
||||
.strip_suffix(".json")?;
|
||||
Some(format!("{namespace}:{entry}"))
|
||||
};
|
||||
match source {
|
||||
Source::Dir(d) => {
|
||||
let Ok(namespaces) = fs::read_dir(d.join("assets")) else {
|
||||
return configs;
|
||||
};
|
||||
for namespace in namespaces.flatten() {
|
||||
let folder = namespace.path().join("polytone/config_entries");
|
||||
for file in fs::read_dir(&folder).into_iter().flatten().flatten() {
|
||||
let name = format!(
|
||||
"assets/{}/polytone/config_entries/{}",
|
||||
namespace.file_name().to_string_lossy(),
|
||||
file.file_name().to_string_lossy()
|
||||
);
|
||||
if let (Some(id), Ok(bytes)) = (id_of(&name), fs::read(file.path()))
|
||||
&& let Ok(json) = serde_json::from_slice(&bytes)
|
||||
{
|
||||
configs.add_entry(id, &json);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Source::Zip(z) => {
|
||||
let Ok(zip) = Zip::open(z) else {
|
||||
return configs;
|
||||
};
|
||||
let names: Vec<String> = zip
|
||||
.names()
|
||||
.filter(|n| id_of(n).is_some())
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
for name in names {
|
||||
if let (Some(id), Ok(bytes)) = (id_of(&name), zip.read(&name))
|
||||
&& let Ok(json) = serde_json::from_slice(&bytes)
|
||||
{
|
||||
configs.add_entry(id, &json);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
configs
|
||||
}
|
||||
fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
|
||||
pack_for(id, title, source, RESOURCE_FORMAT)
|
||||
}
|
||||
/// A pack whose compatibility is against `format`.
|
||||
fn pack_for(id: String, title: Text, source: Source, format: (i64, i64)) -> Option<Pack> {
|
||||
let meta = read_meta(&source)?;
|
||||
let polytone_configs = polytone_configs(&source);
|
||||
let (raw_description, description_style) = component_root(&meta["pack"]["description"]);
|
||||
Some(Pack {
|
||||
id,
|
||||
@@ -483,7 +540,8 @@ fn pack_for(id: String, title: Text, source: Source, format: (i64, i64)) -> Opti
|
||||
description_style,
|
||||
compatible: compatible(&meta),
|
||||
compatibility: compatibility_for(&meta, format),
|
||||
overlays: overlays_of(&meta),
|
||||
overlays: overlays_of(&meta, &polytone_configs),
|
||||
polytone_configs,
|
||||
filter: filter_of(&meta),
|
||||
source,
|
||||
})
|
||||
@@ -1930,6 +1988,36 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
#[test]
|
||||
fn polytone_conditions_turn_overlays_on_from_the_packs_configs() {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("horizon-packs-polytone-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
let configs = dir.join("assets/mypack/polytone/config_entries");
|
||||
fs::create_dir_all(&configs).unwrap();
|
||||
fs::write(configs.join("fog.json"), r#"{"default_value": "ON"}"#).unwrap();
|
||||
fs::write(
|
||||
configs.join("birds.json"),
|
||||
r#"{"default_value": 0, "max": 300}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.join("pack.mcmeta"),
|
||||
r#"{"pack":{"description":"p","pack_format":97},"overlays":{"entries":[
|
||||
{"directory":"opt_fog","formats":[0,0],"polytone_condition":"config('mypack:fog') == 'ON'"},
|
||||
{"directory":"opt_birds","formats":[0,0],"polytone_condition":"config('mypack:birds') > 0"},
|
||||
{"directory":"opt_plain","formats":[0,0]}]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let pack = pack(
|
||||
"file/p".into(),
|
||||
Text::literal("p"),
|
||||
Source::Dir(dir.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(pack.overlays, ["opt_fog"]);
|
||||
let _ = fs::remove_dir_all(dir);
|
||||
}
|
||||
#[test]
|
||||
fn overlays_and_filters() {
|
||||
let v = |s: &str| serde_json::from_str::<serde_json::Value>(s).unwrap();
|
||||
let meta = v(r#"{"overlays":{"entries":[
|
||||
@@ -1937,7 +2025,7 @@ mod tests {
|
||||
{"formats":{"min_inclusive":97,"max_inclusive":97},"directory":"new"},
|
||||
{"min_format":97,"max_format":[97,5],"directory":"also"},
|
||||
{"formats":50,"directory":"no"}]}}"#);
|
||||
assert_eq!(overlays_of(&meta), ["new", "also"]);
|
||||
assert_eq!(overlays_of(&meta, &Default::default()), ["new", "also"]);
|
||||
// Only one plain folder of the pack, as vanilla's DIR_VALIDATOR has it.
|
||||
let meta = v(r#"{"overlays":{"entries":[
|
||||
{"formats":97,"directory":".."},
|
||||
@@ -1946,7 +2034,7 @@ mod tests {
|
||||
{"formats":97,"directory":"a/b"},
|
||||
{"formats":97,"directory":"C:"},
|
||||
{"formats":97,"directory":"ok_1.21-x"}]}}"#);
|
||||
assert_eq!(overlays_of(&meta), ["ok_1.21-x"]);
|
||||
assert_eq!(overlays_of(&meta, &Default::default()), ["ok_1.21-x"]);
|
||||
let base = std::env::temp_dir().join(format!("horizon-packs-ov-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&base);
|
||||
let vanilla = base.join("vanilla");
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
//! A controller button as the player sees it: Horizon's own art where the pad's family has one
|
||||
//! (`assets/horizon/textures/gui/controller/`, which a resource pack can replace), else the label
|
||||
//! printed on the pad, in its colour, on a dark key. Whatever shows a button draws and measures it
|
||||
//! here; `Button::label` stays for plain text and narration.
|
||||
use crate::gamepad::{Button, GlyphStyle};
|
||||
//! here, from a [`Glyph`]: what the button does (`GlyphOf`), in the player's [`Glyphs`]. A Nintendo
|
||||
//! pad going by its letters is part of `Glyphs`, which only the settings make, so no caller handles
|
||||
//! the letters, and nothing outside this module can turn a bare button into art or a label.
|
||||
use crate::gamepad::{Button, GlyphOf, GlyphStyle, MenuJob, PadSettings};
|
||||
use crate::hud::{HudAtlas, HudBatch, rgba};
|
||||
use crate::pad_cursor;
|
||||
|
||||
@@ -94,23 +96,167 @@ static ART: &[Art] = art![
|
||||
Nintendo DPadRight "nx_right",
|
||||
];
|
||||
|
||||
/// The glyphs a player sees: a pad family's art, and whether a Nintendo pad goes by its letters.
|
||||
/// Opaque: it is only made from the settings and the pad (`of`), or by the one named exception
|
||||
/// (`unheld`), so nothing can drop the letter setting. Letter mode is part of it, and callers
|
||||
/// never handle it: the game already reads such a pad as parts (`PadState::by_letter`), and a
|
||||
/// glyph shows the button that plays its part.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Glyphs {
|
||||
family: GlyphStyle,
|
||||
letters: bool,
|
||||
}
|
||||
|
||||
impl Default for Glyphs {
|
||||
/// The glyphs of the default settings for a pad of no known maker, before any pad is read.
|
||||
fn default() -> Self {
|
||||
Self::of(&PadSettings::default(), GlyphStyle::Auto)
|
||||
}
|
||||
}
|
||||
|
||||
impl Glyphs {
|
||||
/// The glyphs for a pad of `family` under the player's settings: the family they chose, else
|
||||
/// the pad's, going by letters when the pad does (`PadSettings::by_letter`) and the glyphs
|
||||
/// are Nintendo's.
|
||||
pub fn of(settings: &PadSettings, family: GlyphStyle) -> Self {
|
||||
let shown = settings.glyphs.resolve(family);
|
||||
Self {
|
||||
family: shown,
|
||||
letters: shown == GlyphStyle::Nintendo && settings.by_letter(family),
|
||||
}
|
||||
}
|
||||
/// The glyphs for a pad of `family` by the buttons' places, whatever the letter setting. Only
|
||||
/// for a question a controller nobody holds yet answers (splitscreen's join and
|
||||
/// lost-controller prompts): it isn't playing, so its letters aren't read yet
|
||||
/// (docs/controllers.md, Nintendo pads).
|
||||
pub fn unheld(settings: &PadSettings, family: GlyphStyle) -> Self {
|
||||
Self {
|
||||
family: settings.glyphs.resolve(family),
|
||||
letters: false,
|
||||
}
|
||||
}
|
||||
/// The family the art comes from, for naming it ("Glyphs in use").
|
||||
pub fn family(self) -> GlyphStyle {
|
||||
self.family
|
||||
}
|
||||
/// The family whose print shows `part`, and the button on the pad that plays it: the one
|
||||
/// place letter mode turns a part into a physical button (`Button::by_letter`, as input is
|
||||
/// read).
|
||||
fn on_pad(self, part: Button) -> (GlyphStyle, Button) {
|
||||
let button = if self.letters { part.by_letter() } else { part };
|
||||
(self.family, button)
|
||||
}
|
||||
}
|
||||
|
||||
/// A glyph ready to draw: the part a button plays, in a player's [`Glyphs`]. It is only built from
|
||||
/// what the button does (`GlyphOf`), so a rebinding or a pad's letters reach every glyph; there is
|
||||
/// no way to draw or name a bare button.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Glyph {
|
||||
part: Button,
|
||||
glyphs: Glyphs,
|
||||
}
|
||||
|
||||
impl Glyph {
|
||||
/// The glyph for `of` on these settings, in `glyphs`. None while a gameplay action is unbound.
|
||||
pub fn of(of: GlyphOf, settings: &PadSettings, glyphs: Glyphs) -> Option<Self> {
|
||||
let part = of.button(settings)?;
|
||||
Some(Self { part, glyphs })
|
||||
}
|
||||
/// A menu job's glyph: menus aren't rebindable, so it is always there.
|
||||
pub fn menu(job: MenuJob, glyphs: Glyphs) -> Self {
|
||||
Self {
|
||||
part: job.button(),
|
||||
glyphs,
|
||||
}
|
||||
}
|
||||
/// The part this glyph is for (what the pad reads, after any letters), for `pad status`.
|
||||
pub fn part(self) -> Button {
|
||||
self.part
|
||||
}
|
||||
/// What is printed on the button that plays the part: in letter mode, that button's letter.
|
||||
pub fn label(self) -> &'static str {
|
||||
let (family, button) = self.glyphs.on_pad(self.part);
|
||||
printed(button, family)
|
||||
}
|
||||
/// The colour (RGB) of the button that plays the part.
|
||||
pub fn color(self) -> [u8; 3] {
|
||||
let (family, button) = self.glyphs.on_pad(self.part);
|
||||
colour(button, family)
|
||||
}
|
||||
/// Its art, if the family has a picture of the button.
|
||||
fn art(self) -> Option<&'static Art> {
|
||||
let (family, button) = self.glyphs.on_pad(self.part);
|
||||
find(button, family)
|
||||
}
|
||||
}
|
||||
|
||||
/// What is printed on a button of a pad family (`Auto` reads as Xbox).
|
||||
fn printed(button: Button, style: GlyphStyle) -> &'static str {
|
||||
let i = button as usize;
|
||||
match style {
|
||||
GlyphStyle::PlayStation => [
|
||||
"×", "○", "□", "△", "L1", "R1", "L2", "R2", "Create", "Options", "L3", "R3", "↑", "↓",
|
||||
"←", "→",
|
||||
][i],
|
||||
GlyphStyle::Nintendo => [
|
||||
"B", "A", "Y", "X", "L", "R", "ZL", "ZR", "−", "+", "LS", "RS", "↑", "↓", "←", "→",
|
||||
][i],
|
||||
GlyphStyle::Xbox | GlyphStyle::Auto => [
|
||||
"A", "B", "X", "Y", "LB", "RB", "LT", "RT", "View", "Menu", "LS", "RS", "↑", "↓", "←",
|
||||
"→",
|
||||
][i],
|
||||
}
|
||||
}
|
||||
/// The glyph's colour (RGB): face buttons in their family's colours,
|
||||
/// the rest white.
|
||||
fn colour(button: Button, style: GlyphStyle) -> [u8; 3] {
|
||||
let face = match button {
|
||||
Button::South => 0,
|
||||
Button::East => 1,
|
||||
Button::West => 2,
|
||||
Button::North => 3,
|
||||
_ => return [255, 255, 255],
|
||||
};
|
||||
match style {
|
||||
GlyphStyle::PlayStation => [
|
||||
[124, 178, 232],
|
||||
[235, 110, 110],
|
||||
[222, 145, 205],
|
||||
[92, 205, 170],
|
||||
][face],
|
||||
GlyphStyle::Nintendo => [255, 255, 255],
|
||||
GlyphStyle::Xbox | GlyphStyle::Auto => [
|
||||
[110, 200, 90],
|
||||
[235, 90, 80],
|
||||
[80, 150, 240],
|
||||
[240, 200, 60],
|
||||
][face],
|
||||
}
|
||||
}
|
||||
|
||||
/// The picture of a button in a family.
|
||||
fn find(button: Button, style: GlyphStyle) -> Option<&'static Art> {
|
||||
let family = match style {
|
||||
GlyphStyle::Auto => GlyphStyle::Xbox,
|
||||
style => style,
|
||||
family => family,
|
||||
};
|
||||
ART.iter()
|
||||
.find(|art| art.family == family && art.button == button)
|
||||
}
|
||||
|
||||
/// The sprite of a button in a family, if it has one.
|
||||
pub fn sprite(button: Button, style: GlyphStyle) -> Option<&'static str> {
|
||||
/// The sprite of a button in a family, if it has one: the tests name the files by it.
|
||||
#[cfg(test)]
|
||||
fn sprite(button: Button, style: GlyphStyle) -> Option<&'static str> {
|
||||
find(button, style).map(|art| art.sprite)
|
||||
}
|
||||
|
||||
/// The sprite this atlas can draw for the button; none (the boot screen's atlas) means text.
|
||||
pub fn art(atlas: &HudAtlas, button: Button, style: GlyphStyle) -> Option<&'static str> {
|
||||
sprite(button, style).filter(|name| atlas.sprites.contains_key(*name))
|
||||
/// The sprite this atlas can draw for the glyph; none (the boot screen's atlas) means text.
|
||||
pub fn art(atlas: &HudAtlas, glyph: Glyph) -> Option<&'static str> {
|
||||
glyph
|
||||
.art()
|
||||
.map(|art| art.sprite)
|
||||
.filter(|name| atlas.sprites.contains_key(*name))
|
||||
}
|
||||
|
||||
/// How wide the art shows: half its texture (IHDR's width, in a PNG's first bytes) less its edge.
|
||||
@@ -120,31 +266,24 @@ fn art_width(art: &Art) -> f32 {
|
||||
texels as f32 / 2.0 - 2.0 * MARGIN
|
||||
}
|
||||
|
||||
/// How wide the glyph shows.
|
||||
pub fn width(atlas: &HudAtlas, button: Button, style: GlyphStyle) -> f32 {
|
||||
match find(button, style).filter(|a| atlas.sprites.contains_key(a.sprite)) {
|
||||
/// How wide the glyph shows, in GUI pixels.
|
||||
pub fn width(atlas: &HudAtlas, glyph: Glyph) -> f32 {
|
||||
match glyph.art().filter(|a| atlas.sprites.contains_key(a.sprite)) {
|
||||
Some(art) => art_width(art),
|
||||
None => HudBatch::text_width(atlas, button.label(style)).ceil() + 4.0,
|
||||
None => HudBatch::text_width(atlas, glyph.label()).ceil() + 4.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the glyph covers with its label's text at `(x, y)`: `[x, y, w, h]`.
|
||||
pub fn rect(atlas: &HudAtlas, button: Button, style: GlyphStyle, x: f32, y: f32) -> [f32; 4] {
|
||||
[x - 1.0, y - 2.0, width(atlas, button, style), HEIGHT]
|
||||
/// What the glyph covers with its label's text at `(x, y)`: `[x, y, w, h]` in GUI pixels.
|
||||
pub fn rect(atlas: &HudAtlas, glyph: Glyph, x: f32, y: f32) -> [f32; 4] {
|
||||
[x - 1.0, y - 2.0, width(atlas, glyph), HEIGHT]
|
||||
}
|
||||
|
||||
/// Draw the glyph over the 8 pixel text line at `(x, y)`, filling [`rect`]. A dark key backs text
|
||||
/// only: the art brings its own.
|
||||
pub fn draw(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
button: Button,
|
||||
style: GlyphStyle,
|
||||
x: f32,
|
||||
y: f32,
|
||||
) {
|
||||
let [left, top, wide, tall] = rect(atlas, button, style, x, y);
|
||||
if let Some(name) = art(atlas, button, style) {
|
||||
pub fn draw(batch: &mut HudBatch, atlas: &HudAtlas, glyph: Glyph, x: f32, y: f32) {
|
||||
let [left, top, wide, tall] = rect(atlas, glyph, x, y);
|
||||
if let Some(name) = art(atlas, glyph) {
|
||||
batch.sprite_scaled(
|
||||
atlas,
|
||||
name,
|
||||
@@ -155,16 +294,9 @@ pub fn draw(
|
||||
u32::MAX,
|
||||
);
|
||||
} else {
|
||||
let [r, g, b] = button.color(style);
|
||||
let [r, g, b] = glyph.color();
|
||||
batch.fill(atlas, left, top, wide, tall, rgba(40, 40, 40, 255));
|
||||
batch.text(
|
||||
atlas,
|
||||
button.label(style),
|
||||
x + 1.0,
|
||||
y,
|
||||
rgba(r, g, b, 255),
|
||||
false,
|
||||
);
|
||||
batch.text(atlas, glyph.label(), x + 1.0, y, rgba(r, g, b, 255), false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,6 +340,20 @@ mod tests {
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
|
||||
}
|
||||
|
||||
/// Settings as `horizon-controller.txt` has them, with Nintendo Buttons set as `letters` says.
|
||||
fn settings(letters: bool) -> PadSettings {
|
||||
PadSettings::parse(&format!("glyphs:auto\nnintendoLetters:{letters}\n"))
|
||||
}
|
||||
|
||||
/// A glyph for any part on a pad of `family` going by places: the tests reach the table
|
||||
/// directly.
|
||||
fn g(part: Button, family: GlyphStyle) -> Glyph {
|
||||
Glyph {
|
||||
part,
|
||||
glyphs: Glyphs::of(&settings(false), family),
|
||||
}
|
||||
}
|
||||
|
||||
fn file(button: Button, style: GlyphStyle) -> Option<&'static str> {
|
||||
sprite(button, style).and_then(|name| name.strip_prefix("horizon:gui/controller/"))
|
||||
}
|
||||
@@ -271,7 +417,9 @@ mod tests {
|
||||
assert_eq!(file(Button::East, style), Some("nx_a"));
|
||||
assert_eq!(file(Button::West, style), Some("nx_y"));
|
||||
assert_eq!(file(Button::North, style), Some("nx_x"));
|
||||
assert_eq!(file(Button::LeftTrigger, style), Some("nx_zl"));
|
||||
assert_eq!(file(Button::LeftBumper, style), Some("nx_l"));
|
||||
assert_eq!(file(Button::RightTrigger, style), Some("nx_zr"));
|
||||
assert_eq!(file(Button::Select, style), Some("nx_minus"));
|
||||
assert_eq!(file(Button::Start, style), Some("nx_plus"));
|
||||
}
|
||||
|
||||
@@ -283,8 +431,34 @@ mod tests {
|
||||
(GlyphStyle::Nintendo, "nx_"),
|
||||
] {
|
||||
for button in Button::ALL {
|
||||
let name = file(button, style).unwrap();
|
||||
assert!(name.starts_with(prefix), "{button:?} {name}");
|
||||
let name = file(button, style).unwrap_or_else(|| panic!("{button:?}"));
|
||||
assert!(name.starts_with(prefix), "{name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Each family's art is its own drawing: Nintendo's once was Xbox's files copied (LB for L, a red
|
||||
/// B), and View, Create and − were one plain minus.
|
||||
#[test]
|
||||
fn no_two_buttons_share_a_picture_across_families() {
|
||||
let differs = |a: &Art, b: &Art| a.button == b.button && a.family != b.family;
|
||||
// The sticks and the d-pad are the same part on every pad, so they may share a picture.
|
||||
let shared = [
|
||||
Button::LeftStick,
|
||||
Button::RightStick,
|
||||
Button::DPadUp,
|
||||
Button::DPadDown,
|
||||
Button::DPadLeft,
|
||||
Button::DPadRight,
|
||||
];
|
||||
for (i, art) in ART.iter().enumerate() {
|
||||
for other in ART[..i].iter().filter(|o| o.png == art.png) {
|
||||
assert!(
|
||||
differs(art, other) && shared.contains(&art.button),
|
||||
"{} is {}",
|
||||
art.sprite,
|
||||
other.sprite
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -319,20 +493,24 @@ mod tests {
|
||||
#[test]
|
||||
fn art_measures_from_its_shape_and_text_from_its_label() {
|
||||
let atlas = atlas();
|
||||
assert_eq!(width(&atlas, Button::South, GlyphStyle::Xbox), 12.0);
|
||||
assert_eq!(width(&atlas, Button::LeftBumper, GlyphStyle::Xbox), 20.0);
|
||||
assert_eq!(width(&atlas, g(Button::South, GlyphStyle::Xbox)), 12.0);
|
||||
assert_eq!(width(&atlas, g(Button::LeftBumper, GlyphStyle::Xbox)), 20.0);
|
||||
assert_eq!(
|
||||
width(&atlas, Button::LeftBumper, GlyphStyle::PlayStation),
|
||||
width(&atlas, g(Button::LeftBumper, GlyphStyle::PlayStation)),
|
||||
20.0
|
||||
);
|
||||
assert_eq!(width(&atlas, g(Button::DPadUp, GlyphStyle::Nintendo)), 12.0);
|
||||
assert_eq!(
|
||||
width(&atlas, g(Button::LeftTrigger, GlyphStyle::Nintendo)),
|
||||
20.0
|
||||
);
|
||||
assert_eq!(width(&atlas, Button::DPadUp, GlyphStyle::Nintendo), 12.0);
|
||||
let mut no_art = atlas;
|
||||
no_art
|
||||
.sprites
|
||||
.remove(sprite(Button::LeftTrigger, GlyphStyle::Nintendo).unwrap());
|
||||
let label = HudBatch::text_width(&no_art, "ZL").ceil() + 4.0;
|
||||
assert_eq!(
|
||||
width(&no_art, Button::LeftTrigger, GlyphStyle::Nintendo),
|
||||
width(&no_art, g(Button::LeftTrigger, GlyphStyle::Nintendo)),
|
||||
label
|
||||
);
|
||||
}
|
||||
@@ -343,9 +521,9 @@ mod tests {
|
||||
atlas
|
||||
.sprites
|
||||
.remove(sprite(Button::South, GlyphStyle::Xbox).unwrap());
|
||||
assert_eq!(art(&atlas, Button::South, GlyphStyle::Xbox), None);
|
||||
assert_eq!(art(&atlas, g(Button::South, GlyphStyle::Xbox)), None);
|
||||
let label = HudBatch::text_width(&atlas, "A").ceil() + 4.0;
|
||||
assert_eq!(width(&atlas, Button::South, GlyphStyle::Xbox), label);
|
||||
assert_eq!(width(&atlas, g(Button::South, GlyphStyle::Xbox)), label);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -355,8 +533,7 @@ mod tests {
|
||||
draw(
|
||||
&mut batch,
|
||||
&atlas,
|
||||
Button::South,
|
||||
GlyphStyle::Xbox,
|
||||
g(Button::South, GlyphStyle::Xbox),
|
||||
10.0,
|
||||
20.0,
|
||||
);
|
||||
@@ -365,8 +542,7 @@ mod tests {
|
||||
draw(
|
||||
&mut batch,
|
||||
&atlas,
|
||||
Button::LeftBumper,
|
||||
GlyphStyle::Xbox,
|
||||
g(Button::LeftBumper, GlyphStyle::Xbox),
|
||||
10.0,
|
||||
20.0,
|
||||
);
|
||||
@@ -379,8 +555,83 @@ mod tests {
|
||||
let style = GlyphStyle::Nintendo;
|
||||
atlas.sprites.remove(sprite(Button::South, style).unwrap());
|
||||
let mut batch = HudBatch::new([640, 480]);
|
||||
draw(&mut batch, &atlas, Button::South, style, 10.0, 20.0);
|
||||
let [x, y, w, h] = rect(&atlas, Button::South, style, 10.0, 20.0);
|
||||
draw(&mut batch, &atlas, g(Button::South, style), 10.0, 20.0);
|
||||
let [x, y, w, h] = rect(&atlas, g(Button::South, style), 10.0, 20.0);
|
||||
assert_eq!(quads(&batch)[0], [x, y, x + w, y + h]);
|
||||
}
|
||||
|
||||
/// The part a pad going by its letters reads when `button` is pressed (`PadState::by_letter`,
|
||||
/// as `Pads` reads the pad), so the expectation doesn't come from the glyph code.
|
||||
fn part_read_from(button: Button) -> Button {
|
||||
let mut pad = crate::gamepad::PadState::default();
|
||||
pad.set(button, true);
|
||||
let read = pad.by_letter();
|
||||
Button::ALL.into_iter().find(|b| read.held(*b)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn letter_mode_shows_the_button_that_plays_each_part() {
|
||||
let atlas = atlas();
|
||||
let letters = Glyphs::of(&settings(true), GlyphStyle::Nintendo);
|
||||
for button in Button::ALL {
|
||||
let shown = Glyph {
|
||||
part: part_read_from(button),
|
||||
glyphs: letters,
|
||||
};
|
||||
let printed = g(button, GlyphStyle::Nintendo);
|
||||
assert_eq!(shown.label(), printed.label(), "{button:?}");
|
||||
assert_eq!(art(&atlas, shown), art(&atlas, printed), "{button:?}");
|
||||
}
|
||||
// As a Switch: the A on the right confirms and jumps, B goes back, X and Y swap.
|
||||
let label = |job| Glyph::menu(job, letters).label();
|
||||
assert_eq!(label(MenuJob::Confirm), "A");
|
||||
assert_eq!(label(MenuJob::Back), "B");
|
||||
assert_eq!(label(MenuJob::Alternate), "X");
|
||||
assert_eq!(label(MenuJob::Extra), "Y");
|
||||
let confirm = art(&atlas, Glyph::menu(MenuJob::Confirm, letters));
|
||||
assert_eq!(confirm, Some("horizon:gui/controller/nx_a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_nintendo_pad_with_nintendo_glyphs_goes_by_letters() {
|
||||
let label = |settings: &PadSettings, family| {
|
||||
Glyph::menu(MenuJob::Confirm, Glyphs::of(settings, family)).label()
|
||||
};
|
||||
// By places, a Nintendo pad's confirm is the B at the bottom.
|
||||
assert_eq!(label(&settings(false), GlyphStyle::Nintendo), "B");
|
||||
// An Xbox pad has no letters to swap.
|
||||
assert_eq!(label(&settings(true), GlyphStyle::Xbox), "A");
|
||||
// A player who chose Xbox glyphs sees them, though the Nintendo pad is read by letters.
|
||||
let chosen = PadSettings::parse("glyphs:xbox\nnintendoLetters:true\n");
|
||||
assert_eq!(label(&chosen, GlyphStyle::Nintendo), "A");
|
||||
// A controller nobody holds yet is asked by places.
|
||||
let unheld = Glyphs::unheld(&settings(true), GlyphStyle::Nintendo);
|
||||
assert_eq!(Glyph::menu(MenuJob::Confirm, unheld).label(), "B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn position_mode_names_each_part_by_its_place() {
|
||||
assert_eq!(g(Button::South, GlyphStyle::Nintendo).label(), "B");
|
||||
assert_eq!(g(Button::South, GlyphStyle::Xbox).label(), "A");
|
||||
assert_eq!(g(Button::South, GlyphStyle::PlayStation).label(), "×");
|
||||
assert_eq!(
|
||||
g(Button::RightTrigger, GlyphStyle::PlayStation).label(),
|
||||
"R2"
|
||||
);
|
||||
assert_eq!(g(Button::DPadLeft, GlyphStyle::Xbox).label(), "←");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebinding_an_action_changes_its_glyph() {
|
||||
let mut settings = settings(false);
|
||||
let xbox = Glyphs::of(&settings, GlyphStyle::Xbox);
|
||||
let jump = GlyphOf::Bound("key.jump");
|
||||
let before = Glyph::of(jump, &settings, xbox).unwrap();
|
||||
settings.bind("key.jump", Some(Button::North));
|
||||
let after = Glyph::of(jump, &settings, xbox).unwrap();
|
||||
assert_ne!(before.label(), after.label());
|
||||
assert_eq!(after.label(), "Y");
|
||||
settings.bind("key.jump", None);
|
||||
assert_eq!(Glyph::of(jump, &settings, xbox), None);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ use std::{
|
||||
pub use horizon_client_core::particle_type::ParticleType;
|
||||
pub use horizon_items::firework::{DYE_FIREWORK_COLORS, FireworkExplosion, FireworkShape};
|
||||
mod native;
|
||||
pub mod polytone;
|
||||
mod polytone_gpu;
|
||||
pub use native::{ParticleController, ParticleUpdate};
|
||||
|
||||
/// `ParticleOptions` with the parameters of the option classes used here.
|
||||
@@ -64,6 +66,8 @@ pub struct ParticleOptions {
|
||||
/// `nextInt(40) + 10` after constructing the particle. Set by the client for an older
|
||||
/// server only, never read from the wire.
|
||||
pub legacy_trail_lifetime: bool,
|
||||
/// What the Polytone emitter that spawned this custom particle sets on it.
|
||||
pub polytone_seed: Option<std::sync::Arc<polytone::Seed>>,
|
||||
}
|
||||
impl ParticleOptions {
|
||||
pub fn simple(ty: ParticleType) -> Self {
|
||||
@@ -80,6 +84,7 @@ impl ParticleOptions {
|
||||
mod_particle: None,
|
||||
legacy_entity_effect: None,
|
||||
legacy_trail_lifetime: false,
|
||||
polytone_seed: None,
|
||||
}
|
||||
}
|
||||
/// A native mod's `SimpleParticleType`: the sprite set of
|
||||
@@ -522,6 +527,12 @@ pub struct Particle {
|
||||
/// `Particle.random`: the particle's own generator (`RandomSource.create()`).
|
||||
random: LegacyRandom,
|
||||
controller: Option<Box<dyn native::Controller>>,
|
||||
/// A resource pack's Polytone custom particle type, for its `limit`.
|
||||
polytone_type: Option<std::sync::Arc<str>>,
|
||||
/// How a Polytone custom particle's quad turns (`rotation_mode`).
|
||||
orientation: polytone_gpu::Orientation,
|
||||
/// Blended additively (a Polytone `additive_translucent` particle).
|
||||
additive: bool,
|
||||
}
|
||||
fn aabb_at(p: [f64; 3], w: f32, h: f32) -> Aabb {
|
||||
let w = f64::from(w) / 2.0;
|
||||
@@ -883,6 +894,9 @@ impl Particle {
|
||||
frame_ticks: 0,
|
||||
random: LegacyRandom::new(0),
|
||||
controller: None,
|
||||
polytone_type: None,
|
||||
orientation: polytone_gpu::Orientation::Camera,
|
||||
additive: false,
|
||||
}
|
||||
}
|
||||
/// The normal age/removal test and previous pose, without vanilla motion.
|
||||
@@ -1277,6 +1291,13 @@ pub struct ParticleEngine {
|
||||
/// A native mod's sprite sets, by namespaced particle name.
|
||||
mod_sets: HashMap<String, (u32, Option<ModBehaviour>)>,
|
||||
set_sprites: Vec<Vec<u32>>,
|
||||
/// Resource packs' Polytone custom particle types (#11486).
|
||||
polytone: Option<std::sync::Arc<polytone::Registry>>,
|
||||
/// The packs' GPU particles' records, one store per type, and the ticks they count in.
|
||||
polytone_gpu: Vec<polytone_gpu::Store>,
|
||||
polytone_ticks: u64,
|
||||
/// The last frame's additively blended quads (`take_additive`).
|
||||
additive: std::sync::Mutex<Vec<Vertex>>,
|
||||
/// `TrackingEmitter`s: particle type, entity feet position, width, height, ticks left.
|
||||
emitters: Vec<(ParticleOptions, [f64; 3], f64, f64, u32)>,
|
||||
/// Camera position for the 32-block spawn limiter.
|
||||
@@ -1469,6 +1490,10 @@ impl ParticleEngine {
|
||||
sets: HashMap::new(),
|
||||
mod_sets: HashMap::new(),
|
||||
set_sprites: Vec::new(),
|
||||
polytone: None,
|
||||
polytone_gpu: Vec::new(),
|
||||
polytone_ticks: 0,
|
||||
additive: std::sync::Mutex::default(),
|
||||
emitters: Vec::new(),
|
||||
starters: Vec::new(),
|
||||
tracked_explosions: Vec::new(),
|
||||
@@ -1689,6 +1714,28 @@ impl ParticleEngine {
|
||||
particle.controller = Some(Box::new(controller));
|
||||
Some(particle)
|
||||
}
|
||||
/// Sets (or clears) the resource packs' Polytone custom particle types.
|
||||
pub fn set_polytone(&mut self, registry: Option<std::sync::Arc<polytone::Registry>>) {
|
||||
let registry = registry.filter(|r| !r.is_empty());
|
||||
let same = match (&self.polytone, ®istry) {
|
||||
(Some(a), Some(b)) => std::sync::Arc::ptr_eq(a, b),
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
};
|
||||
if !same {
|
||||
self.polytone_gpu.clear();
|
||||
}
|
||||
self.polytone = registry;
|
||||
}
|
||||
/// The quads the last render blends additively (`RenderPipelines` additive, src alpha and
|
||||
/// one), for the frame's additive pass.
|
||||
pub fn take_additive(&self) -> Vec<Vertex> {
|
||||
std::mem::take(&mut *self.additive.lock().unwrap_or_else(|e| e.into_inner()))
|
||||
}
|
||||
/// Live GPU particle records (spawns, before `area` spreads them), for tests and status.
|
||||
pub fn polytone_gpu_records(&self) -> usize {
|
||||
self.polytone_gpu.iter().map(polytone_gpu::Store::len).sum()
|
||||
}
|
||||
/// Choose a frame from this particle's declared sprite set.
|
||||
pub fn set_sprite_frame(&self, particle: &mut Particle, frame: usize) {
|
||||
if let Some(sprites) = particle.set.and_then(|s| self.set_sprites.get(s as usize))
|
||||
@@ -2092,6 +2139,27 @@ impl ParticleEngine {
|
||||
let ty = o.ty;
|
||||
// A native mod's SimpleParticleType: vanilla's TextureSheetParticle with
|
||||
// the sprite set its particles/<name>.json names, no tint, 20..40 ticks.
|
||||
if let Some(name) = &o.mod_particle
|
||||
&& let Some(registry) = &self.polytone
|
||||
&& let Some(def) = registry.get(name)
|
||||
{
|
||||
let (registry, def) = (registry.clone(), def.clone());
|
||||
let particle = polytone::make(self, ®istry, name, &def, o, p, v, seed)?;
|
||||
if def.gpu.is_some() {
|
||||
// A GPU particle is only a record: it never becomes an engine particle.
|
||||
let now = self.polytone_ticks;
|
||||
let store = match self.polytone_gpu.iter().position(|s| s.is(&def)) {
|
||||
Some(i) => &mut self.polytone_gpu[i],
|
||||
None => {
|
||||
self.polytone_gpu.push(polytone_gpu::Store::new(&def)?);
|
||||
self.polytone_gpu.last_mut()?
|
||||
}
|
||||
};
|
||||
store.spawn(&particle, now, seed as u64);
|
||||
return None;
|
||||
}
|
||||
return Some(particle);
|
||||
}
|
||||
if let Some(name) = &o.mod_particle {
|
||||
let (set, behaviour) = *self.mod_sets.get(name)?;
|
||||
let mut r = LegacyRandom::new(seed);
|
||||
@@ -3726,6 +3794,14 @@ impl ParticleEngine {
|
||||
}
|
||||
}
|
||||
pub fn tick(&mut self, level: &ParticleLevel) {
|
||||
self.polytone_ticks += 1;
|
||||
for store in &mut self.polytone_gpu {
|
||||
store.expire(self.polytone_ticks);
|
||||
}
|
||||
// The sounds the packs' custom particles played last tick.
|
||||
if let Some(registry) = &self.polytone {
|
||||
self.sounds.extend(registry.take_sounds());
|
||||
}
|
||||
self.tick_starters();
|
||||
self.tick_explosions(level);
|
||||
for (options, feet, width, height, life) in std::mem::take(&mut self.emitters) {
|
||||
@@ -4718,6 +4794,8 @@ impl ParticleEngine {
|
||||
let base = Quat::from_rotation_y(std::f32::consts::PI - yaw.to_radians())
|
||||
* Quat::from_rotation_x(-pitch.to_radians());
|
||||
let mut light_cache = ParticleLightCache::default();
|
||||
let mut additive = self.additive.lock().unwrap_or_else(|e| e.into_inner());
|
||||
additive.clear();
|
||||
let mut section_cache =
|
||||
(CULL && frustum.is_some()).then(ParticleSectionFrustumCache::default);
|
||||
for p in &self.particles {
|
||||
@@ -4789,7 +4867,15 @@ impl ParticleEngine {
|
||||
) - camera;
|
||||
(pos.as_vec3(), p.quad_size(partial))
|
||||
});
|
||||
let rotation = if p.roll != 0.0 {
|
||||
let rotation = if p.orientation != polytone_gpu::Orientation::Camera {
|
||||
let velocity = FVec3::new(
|
||||
p.velocity[0] as f32,
|
||||
p.velocity[1] as f32,
|
||||
p.velocity[2] as f32,
|
||||
);
|
||||
let roll = p.o_roll + (p.roll - p.o_roll) * partial;
|
||||
polytone_gpu::orient(p.orientation, center, velocity, roll, base)
|
||||
} else if p.roll != 0.0 {
|
||||
base * Quat::from_rotation_z(p.o_roll + (p.roll - p.o_roll) * partial)
|
||||
} else {
|
||||
base
|
||||
@@ -4871,6 +4957,7 @@ impl ParticleEngine {
|
||||
r.u0 + du * p.uv[2],
|
||||
r.v0 + dv * p.uv[3],
|
||||
);
|
||||
let target: &mut Vec<Vertex> = if p.additive { &mut additive } else { &mut *out };
|
||||
for rotation in rotations.into_iter().flatten() {
|
||||
for (n, uv) in [
|
||||
((1.0, -1.0), [u1, v1]),
|
||||
@@ -4879,7 +4966,7 @@ impl ParticleEngine {
|
||||
((-1.0, -1.0), [u0, v1]),
|
||||
] {
|
||||
let v = center + rotation * FVec3::new(n.0, n.1, 0.0) * size;
|
||||
out.push(Vertex {
|
||||
target.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: v.to_array(),
|
||||
uv,
|
||||
@@ -4896,6 +4983,19 @@ impl ParticleEngine {
|
||||
}
|
||||
}
|
||||
}
|
||||
for store in &self.polytone_gpu {
|
||||
store.render(
|
||||
&self.atlas,
|
||||
camera,
|
||||
base,
|
||||
self.polytone_ticks,
|
||||
partial,
|
||||
&mut light_cache,
|
||||
light_at,
|
||||
out,
|
||||
&mut additive,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// How brightly particle `ty` shines in HDR output, as `mesher::hdr_tier`: flames,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,408 @@
|
||||
//! How resource packs' Polytone particles are drawn (#11486): the rotation modes of custom
|
||||
//! particles, and GPU particles (wiki "GPU Particles"). A GPU particle never ticks: its spawn is a
|
||||
//! record (position, velocity, spawn time, lifetime, seed, colour, size, roll), and where it is,
|
||||
//! how big, what colour and which frame are a function of that record and the time. Here the
|
||||
//! function runs when the frame's particle quads are built, beside the engine's own particles.
|
||||
use super::{ParticleLevel, ParticleLightCache};
|
||||
use crate::hud::Rect;
|
||||
use crate::mesher::Vertex;
|
||||
use glam::{DVec3, Mat3, Quat, Vec3 as FVec3};
|
||||
use horizon_polytone::particles::{CustomParticle, Gpu, RenderType};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// How a particle quad turns (`rotation_mode`).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(super) enum Orientation {
|
||||
/// `look_at_xyz`: faces the camera (what every other mode falls back to).
|
||||
#[default]
|
||||
Camera,
|
||||
/// `look_at_y`: turns around Y only.
|
||||
AroundY,
|
||||
/// `look_up`: lies flat, facing up.
|
||||
Flat,
|
||||
/// `movement_aligned`: stands along its velocity, facing the camera around it.
|
||||
Movement,
|
||||
}
|
||||
|
||||
impl Orientation {
|
||||
pub(super) fn parse(mode: &str) -> Self {
|
||||
match mode {
|
||||
"look_at_y" => Self::AroundY,
|
||||
"look_up" => Self::Flat,
|
||||
"movement_aligned" => Self::Movement,
|
||||
_ => Self::Camera,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The rotation of a quad (corners in its XY plane, facing +Z) at `center` (relative to the
|
||||
/// camera) moving along `velocity`, rolled by `roll`; `camera` is the camera-facing rotation.
|
||||
pub(super) fn orient(
|
||||
mode: Orientation,
|
||||
center: FVec3,
|
||||
velocity: FVec3,
|
||||
roll: f32,
|
||||
camera: Quat,
|
||||
) -> Quat {
|
||||
let rolled = |q: Quat| q * Quat::from_rotation_z(roll);
|
||||
match mode {
|
||||
Orientation::Camera => rolled(camera),
|
||||
Orientation::AroundY => rolled(Quat::from_rotation_y((-center.x).atan2(-center.z))),
|
||||
Orientation::Flat => rolled(Quat::from_rotation_x(-std::f32::consts::FRAC_PI_2)),
|
||||
Orientation::Movement => {
|
||||
let up = velocity.normalize_or_zero();
|
||||
let right = up.cross((-center).normalize_or_zero()).normalize_or_zero();
|
||||
if right == FVec3::ZERO {
|
||||
return rolled(camera);
|
||||
}
|
||||
Quat::from_mat3(&Mat3::from_cols(right, up, right.cross(up)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One GPU particle spawn.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Record {
|
||||
position: [f64; 3],
|
||||
velocity: [f64; 3],
|
||||
/// The engine's tick it spawned on, and its life in ticks.
|
||||
spawn: u64,
|
||||
lifetime: f32,
|
||||
seed: u64,
|
||||
rgba: [f32; 4],
|
||||
size: f32,
|
||||
roll: f32,
|
||||
}
|
||||
|
||||
/// A GPU particle type's records: a ring of `limit` slots, the oldest overwritten.
|
||||
pub(super) struct Store {
|
||||
def: Arc<CustomParticle>,
|
||||
gpu: Gpu,
|
||||
orientation: Orientation,
|
||||
/// The texture's sprite name in the particle atlas (`ns:particle/path`).
|
||||
sprite: String,
|
||||
records: Vec<Record>,
|
||||
next: usize,
|
||||
limit: usize,
|
||||
}
|
||||
|
||||
/// The atlas name of a full texture path: `ns:textures/particle/a/b.png` is `ns:particle/a/b`
|
||||
/// (`particle/a/b` in `minecraft`).
|
||||
fn sprite_name(texture: &str) -> String {
|
||||
let (namespace, path) = texture.split_once(':').unwrap_or(("minecraft", texture));
|
||||
let path = path
|
||||
.trim_start_matches("textures/")
|
||||
.trim_end_matches(".png");
|
||||
if namespace == "minecraft" {
|
||||
path.to_owned()
|
||||
} else {
|
||||
format!("{namespace}:{path}")
|
||||
}
|
||||
}
|
||||
|
||||
/// SplitMix64: a well-mixed number from `seed` and `i`, for each quad's own randoms.
|
||||
fn mix(seed: u64, i: u64) -> u64 {
|
||||
let mut z = seed ^ i.wrapping_mul(0x9E37_79B9_7F4A_7C15);
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
|
||||
/// A number in 0..1 from `mix`.
|
||||
fn unit(seed: u64, i: u64) -> f64 {
|
||||
(mix(seed, i) >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
|
||||
/// Where a record's particle is after `t` ticks, and how fast it moves: gravity pulls down,
|
||||
/// friction (`friction` kept per tick, as a continuous drag) slows it toward a terminal velocity.
|
||||
fn motion(gpu: &Gpu, position: [f64; 3], velocity: [f64; 3], t: f64) -> ([f64; 3], [f64; 3]) {
|
||||
let drag = if gpu.friction > 0.0 && gpu.friction < 1.0 {
|
||||
-gpu.friction.ln()
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let gravity = [0.0, -gpu.gravity, 0.0];
|
||||
let mut at = [0.0; 3];
|
||||
let mut moving = [0.0; 3];
|
||||
for i in 0..3 {
|
||||
let (v, g) = (velocity[i], gravity[i]);
|
||||
if drag > 1.0e-9 {
|
||||
let e = (-drag * t).exp();
|
||||
at[i] = position[i] + v * (1.0 - e) / drag + g * (t / drag - (1.0 - e) / (drag * drag));
|
||||
moving[i] = v * e + g * (1.0 - e) / drag;
|
||||
} else {
|
||||
at[i] = position[i] + v * t + 0.5 * g * t * t;
|
||||
moving[i] = v + g * t;
|
||||
}
|
||||
}
|
||||
(at, moving)
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub(super) fn new(def: &Arc<CustomParticle>) -> Option<Self> {
|
||||
let gpu = def.gpu.clone()?;
|
||||
Some(Self {
|
||||
def: def.clone(),
|
||||
orientation: Orientation::parse(&def.rotation_mode),
|
||||
sprite: sprite_name(&gpu.texture),
|
||||
gpu,
|
||||
records: Vec::new(),
|
||||
next: 0,
|
||||
// The wiki's default ring capacity.
|
||||
limit: def.limit.map_or(16_384, |l| l.max(1) as usize),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn is(&self, def: &Arc<CustomParticle>) -> bool {
|
||||
Arc::ptr_eq(&self.def, def)
|
||||
}
|
||||
|
||||
/// Records a spawn from a particle its initializer set up (position, motion, life, look).
|
||||
pub(super) fn spawn(&mut self, p: &super::Particle, now: u64, seed: u64) {
|
||||
if p.lifetime <= 0 {
|
||||
return;
|
||||
}
|
||||
let record = Record {
|
||||
position: p.position,
|
||||
velocity: p.velocity,
|
||||
spawn: now,
|
||||
lifetime: p.lifetime as f32,
|
||||
seed,
|
||||
rgba: p.rgba,
|
||||
size: p.size,
|
||||
roll: p.roll,
|
||||
};
|
||||
if self.records.len() < self.limit {
|
||||
self.records.push(record);
|
||||
} else {
|
||||
self.records[self.next] = record;
|
||||
self.next = (self.next + 1) % self.limit;
|
||||
}
|
||||
}
|
||||
|
||||
/// Forgets the records whose life is over.
|
||||
pub(super) fn expire(&mut self, now: u64) {
|
||||
let before = self.records.len();
|
||||
self.records
|
||||
.retain(|r| (now.saturating_sub(r.spawn) as f32) < r.lifetime);
|
||||
if self.records.len() != before {
|
||||
self.next = 0;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn len(&self) -> usize {
|
||||
self.records.len()
|
||||
}
|
||||
|
||||
/// The quads of every live record at `now + partial` ticks.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn render(
|
||||
&self,
|
||||
atlas: &HashMap<String, (Rect, bool)>,
|
||||
camera: DVec3,
|
||||
facing: Quat,
|
||||
now: u64,
|
||||
partial: f32,
|
||||
light_cache: &mut ParticleLightCache,
|
||||
light_at: &dyn Fn([i32; 3]) -> u8,
|
||||
out: &mut Vec<Vertex>,
|
||||
additive: &mut Vec<Vertex>,
|
||||
) {
|
||||
if self.def.render_type == RenderType::Invisible {
|
||||
return;
|
||||
}
|
||||
let Some(&(rect, _)) = atlas.get(&self.sprite) else {
|
||||
return;
|
||||
};
|
||||
let gpu = &self.gpu;
|
||||
let translucent = matches!(
|
||||
self.def.render_type,
|
||||
RenderType::Translucent | RenderType::AdditiveTranslucent
|
||||
);
|
||||
let blend_add = self.def.render_type == RenderType::AdditiveTranslucent;
|
||||
let target = if blend_add { additive } else { out };
|
||||
let layer = if translucent && !blend_add {
|
||||
crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let (count, area) = gpu.area.map_or((1, [0.0; 3]), |(size, n)| (n, size));
|
||||
let frames = gpu.frames.max(1);
|
||||
for r in &self.records {
|
||||
let t = (now.saturating_sub(r.spawn)) as f32 + partial;
|
||||
if t >= r.lifetime {
|
||||
continue;
|
||||
}
|
||||
let life = (t / r.lifetime).clamp(0.0, 1.0);
|
||||
let (at, moving) = motion(gpu, r.position, r.velocity, f64::from(t));
|
||||
// Fading in and out, and the colour and size toward their ends.
|
||||
let [fade_in, fade_out] = gpu.fade.map(|f| f as f32);
|
||||
let mut alpha = r.rgba[3];
|
||||
if fade_in > 0.0 {
|
||||
alpha *= (life / fade_in).min(1.0);
|
||||
}
|
||||
if fade_out > 0.0 {
|
||||
alpha *= ((1.0 - life) / fade_out).min(1.0);
|
||||
}
|
||||
let mut rgb = [r.rgba[0], r.rgba[1], r.rgba[2]];
|
||||
if let Some(end) = gpu.color_end {
|
||||
let channel = |shift: u32| ((end >> shift) & 0xff) as f32 / 255.0;
|
||||
let to = [channel(16), channel(8), channel(0)];
|
||||
for i in 0..3 {
|
||||
rgb[i] += (to[i] - rgb[i]) * life;
|
||||
}
|
||||
alpha += (alpha * channel(24) - alpha) * life;
|
||||
}
|
||||
let size = gpu
|
||||
.size_end
|
||||
.map_or(r.size, |end| r.size + (end as f32 - r.size) * life);
|
||||
let c = |v: f32| (v.clamp(0.0, 1.0) * 255.0) as u32;
|
||||
let color = c(alpha) << 24 | c(rgb[2]) << 16 | c(rgb[1]) << 8 | c(rgb[0]);
|
||||
let frame = if self.def.random_sprite {
|
||||
(mix(r.seed, 0) % u64::from(frames)) as u32
|
||||
} else {
|
||||
((life * frames as f32) as u32).min(frames - 1)
|
||||
};
|
||||
let (dv, v0) = ((rect.v1 - rect.v0) / frames as f32, rect.v0);
|
||||
let (u0, u1) = (rect.u0, rect.u1);
|
||||
let (v0, v1) = (v0 + dv * frame as f32, v0 + dv * (frame + 1) as f32);
|
||||
let velocity = FVec3::new(moving[0] as f32, moving[1] as f32, moving[2] as f32);
|
||||
for quad in 0..u64::from(count) {
|
||||
// Each quad of an area has its own place in the box, sway phase and roll.
|
||||
let offset: [f64; 3] = if count > 1 {
|
||||
std::array::from_fn(|i| (unit(r.seed, quad * 8 + i as u64 + 1) - 0.5) * area[i])
|
||||
} else {
|
||||
[0.0; 3]
|
||||
};
|
||||
let phase = unit(r.seed, quad * 8 + 4) * std::f64::consts::TAU;
|
||||
let sway = gpu.sway * (f64::from(t) * 0.1 + phase).sin();
|
||||
let sway_z = gpu.sway * (f64::from(t) * 0.07 + phase).cos();
|
||||
let world = [
|
||||
at[0] + offset[0] + sway,
|
||||
at[1] + offset[1],
|
||||
at[2] + offset[2] + sway_z,
|
||||
];
|
||||
let block = ParticleLevel::block_at(world);
|
||||
let packed = light_cache.get(block, light_at);
|
||||
// kill_below_heightmap: under the surface, where the sky doesn't reach.
|
||||
if gpu.kill_below_heightmap && packed >> 4 < 15 {
|
||||
continue;
|
||||
}
|
||||
let block_light = u32::from((packed & 15).max(self.def.light_level));
|
||||
let light = (u32::from(packed >> 4) * 16) | ((block_light * 16) << 8);
|
||||
let center = (DVec3::from_array(world) - camera).as_vec3();
|
||||
let roll = r.roll
|
||||
+ gpu.spin as f32 * t
|
||||
+ if count > 1 {
|
||||
(unit(r.seed, quad * 8 + 5) * std::f64::consts::TAU) as f32
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let rotation = orient(self.orientation, center, velocity, roll, facing);
|
||||
let half = FVec3::new(size, size * gpu.aspect as f32, 0.0);
|
||||
for (n, uv) in [
|
||||
((1.0, -1.0), [u1, v1]),
|
||||
((1.0, 1.0), [u1, v0]),
|
||||
((-1.0, 1.0), [u0, v0]),
|
||||
((-1.0, -1.0), [u0, v1]),
|
||||
] {
|
||||
let corner = FVec3::new(n.0 * half.x, n.1 * half.y, 0.0);
|
||||
target.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: (center + rotation * corner).to_array(),
|
||||
uv,
|
||||
layer,
|
||||
color,
|
||||
light,
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn gpu(gravity: f64, friction: f64) -> Gpu {
|
||||
Gpu {
|
||||
texture: String::new(),
|
||||
frames: 1,
|
||||
gravity,
|
||||
friction,
|
||||
size_end: None,
|
||||
color_end: None,
|
||||
fade: [0.0; 2],
|
||||
sway: 0.0,
|
||||
spin: 0.0,
|
||||
aspect: 1.0,
|
||||
area: None,
|
||||
kill_below_heightmap: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texture_paths_name_their_atlas_sprites() {
|
||||
assert_eq!(
|
||||
sprite_name("davidparticles:textures/particle/weather/rain.png"),
|
||||
"davidparticles:particle/weather/rain"
|
||||
);
|
||||
assert_eq!(
|
||||
sprite_name("minecraft:textures/particle/generic_7.png"),
|
||||
"particle/generic_7"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_follows_gravity_and_drag() {
|
||||
// No drag: a thrown body, x = v t, y = -g t^2 / 2.
|
||||
let (at, v) = motion(&gpu(0.1, 1.0), [0.0; 3], [1.0, 0.0, 0.0], 10.0);
|
||||
assert_eq!(at, [10.0, -5.0, 0.0]);
|
||||
assert!((v[1] + 1.0).abs() < 1e-12);
|
||||
// Drag: the fall speed tends to g / drag (terminal velocity).
|
||||
let friction = 0.9;
|
||||
let (_, v) = motion(&gpu(0.1, friction), [0.0; 3], [0.0; 3], 1000.0);
|
||||
assert!((v[1] + 0.1 / -f64::ln(friction)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orientations_turn_the_quad_normal() {
|
||||
let normal = |q: Quat| q * FVec3::Z;
|
||||
let close = |a: FVec3, b: FVec3| (a - b).length() < 1e-5;
|
||||
// Lying flat: facing up.
|
||||
assert!(close(
|
||||
normal(orient(
|
||||
Orientation::Flat,
|
||||
FVec3::ONE,
|
||||
FVec3::ZERO,
|
||||
0.0,
|
||||
Quat::IDENTITY
|
||||
)),
|
||||
FVec3::Y
|
||||
));
|
||||
// Around Y: facing the camera horizontally (the camera at the origin, the quad east).
|
||||
assert!(close(
|
||||
normal(orient(
|
||||
Orientation::AroundY,
|
||||
FVec3::new(5.0, 3.0, 0.0),
|
||||
FVec3::ZERO,
|
||||
0.0,
|
||||
Quat::IDENTITY
|
||||
)),
|
||||
-FVec3::X
|
||||
));
|
||||
// Along its motion: its up is the velocity, its face toward the camera.
|
||||
let q = orient(
|
||||
Orientation::Movement,
|
||||
FVec3::new(0.0, 0.0, -4.0),
|
||||
FVec3::new(0.0, -2.0, 0.0),
|
||||
0.0,
|
||||
Quat::IDENTITY,
|
||||
);
|
||||
assert!(close(q * FVec3::Y, -FVec3::Y));
|
||||
assert!(close(normal(q), FVec3::Z));
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,10 @@ use crate::{
|
||||
text::{Style, Text},
|
||||
ui::nine_slice,
|
||||
};
|
||||
use std::{sync::Mutex, time::Instant};
|
||||
use std::{
|
||||
sync::Mutex,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
/// `PreeditEvent`: the composition, its caret and the blocks of text the
|
||||
/// input method set apart (the one being converted is `focused_block`).
|
||||
@@ -122,16 +125,23 @@ struct Caret {
|
||||
input_height: f32,
|
||||
}
|
||||
|
||||
/// Grace window used to swallow a trailing `KeyboardInput` (such as `Enter` or
|
||||
/// `Escape`) emitted by the OS/windowing backend in the same key press right
|
||||
/// after `Ime::Preedit("", None)` or `Ime::Commit(...)` finishes composition.
|
||||
const POST_COMPOSITION_KEY_GRACE: Duration = Duration::from_millis(50);
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
/// `KeyboardHandler.lastPreeditEvent`, with when the overlay was created.
|
||||
preedit: Option<(PreeditEvent, Instant)>,
|
||||
last_ended: Option<Instant>,
|
||||
caret: Option<Caret>,
|
||||
area: Option<[f32; 4]>,
|
||||
}
|
||||
|
||||
static STATE: Mutex<State> = Mutex::new(State {
|
||||
preedit: None,
|
||||
last_ended: None,
|
||||
caret: None,
|
||||
area: None,
|
||||
});
|
||||
@@ -141,10 +151,32 @@ fn state() -> std::sync::MutexGuard<'static, State> {
|
||||
}
|
||||
|
||||
/// `KeyboardHandler.textEditing`: the input method's composition changed
|
||||
/// (`None`: it ended). A new `IMEPreeditOverlay` restarts the caret blink.
|
||||
/// (`None` or an empty string: it ended). A new `IMEPreeditOverlay` restarts
|
||||
/// the caret blink.
|
||||
pub fn set_preedit(event: Option<PreeditEvent>) {
|
||||
let mut s = state();
|
||||
s.preedit = event.map(|e| (e, Instant::now()));
|
||||
let now = Instant::now();
|
||||
let event = event.filter(|e| !e.full_text.is_empty());
|
||||
if s.preedit.is_some() && event.is_none() {
|
||||
s.last_ended = Some(now);
|
||||
}
|
||||
s.preedit = event.map(|e| (e, now));
|
||||
}
|
||||
|
||||
/// Record that an IME commit just occurred, clearing any active preedit and
|
||||
/// arming the post-composition key suppression window.
|
||||
pub fn mark_committed() {
|
||||
let mut s = state();
|
||||
s.preedit = None;
|
||||
s.last_ended = Some(Instant::now());
|
||||
}
|
||||
|
||||
/// Clear both active preedit and any pending post-composition suppression window
|
||||
/// (used when the text field itself loses focus or IME is disabled).
|
||||
pub fn reset() {
|
||||
let mut s = state();
|
||||
s.preedit = None;
|
||||
s.last_ended = None;
|
||||
}
|
||||
|
||||
/// Whether text is being composed.
|
||||
@@ -152,6 +184,22 @@ pub fn composing() -> bool {
|
||||
state().preedit.is_some()
|
||||
}
|
||||
|
||||
/// Returns `true` if a physical key press should be suppressed because IME
|
||||
/// composition is currently active or just finished in the same key press event
|
||||
/// sequence (consuming the post-composition grace token on first match).
|
||||
pub fn should_suppress_key() -> bool {
|
||||
let mut s = state();
|
||||
if s.preedit.is_some() {
|
||||
return true;
|
||||
}
|
||||
if let Some(ended) = s.last_ended.take()
|
||||
&& ended.elapsed() <= POST_COMPOSITION_KEY_GRACE
|
||||
{
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// What the focused field's `extractRenderState` ends with: its caret is at
|
||||
/// (`x`, `y`) in GUI pixels and `input_height` tall (a line plus one).
|
||||
/// Without a composition that is the area for the candidate window; with one
|
||||
@@ -301,4 +349,48 @@ mod tests {
|
||||
.all(|(_, s)| s.underlined == Some(true))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_and_clear_preedit() {
|
||||
reset();
|
||||
set_preedit(PreeditEvent::from_winit("nihongo", Some((0, 7))));
|
||||
assert!(composing());
|
||||
set_preedit(None);
|
||||
assert!(!composing());
|
||||
reset();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_preedit_clears_composing_and_suppresses_trailing_enter_once() {
|
||||
reset();
|
||||
set_preedit(PreeditEvent::from_winit("こんにちは", Some((0, 15))));
|
||||
assert!(composing());
|
||||
assert!(should_suppress_key());
|
||||
|
||||
// OS sends Ime::Preedit("", None) when user presses Enter to confirm conversion.
|
||||
set_preedit(Some(PreeditEvent {
|
||||
full_text: String::new(),
|
||||
caret_position: 0,
|
||||
blocks: Vec::new(),
|
||||
focused_block: 0,
|
||||
}));
|
||||
assert!(!composing());
|
||||
|
||||
// The first trailing KeyboardInput(Enter) in the same key press is suppressed,
|
||||
// while a subsequent Enter press is allowed through to submit chat.
|
||||
assert!(should_suppress_key());
|
||||
assert!(!should_suppress_key());
|
||||
reset();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_committed_suppresses_single_trailing_key() {
|
||||
reset();
|
||||
set_preedit(PreeditEvent::from_winit("漢字", Some((0, 6))));
|
||||
mark_committed();
|
||||
assert!(!composing());
|
||||
assert!(should_suppress_key());
|
||||
assert!(!should_suppress_key());
|
||||
reset();
|
||||
}
|
||||
}
|
||||
@@ -155,6 +155,10 @@ pub struct Context {
|
||||
pub source: Option<Who>,
|
||||
/// The projectile's owner, when the source is a projectile.
|
||||
pub owner: Option<Who>,
|
||||
/// The saved UUIDs of `source` and `owner` (`VibrationInfo`'s `uuid` and
|
||||
/// `projectileOwnerUuid`), when the game knows them for that entity.
|
||||
pub source_uuid: Option<[i32; 4]>,
|
||||
pub owner_uuid: Option<[i32; 4]>,
|
||||
/// `affectedState` is in `dampens_vibrations`.
|
||||
pub dampening_state: bool,
|
||||
/// The source is a spectator.
|
||||
@@ -309,14 +313,29 @@ impl Data {
|
||||
}
|
||||
}
|
||||
fn info_to_nbt(info: &Info) -> Tag {
|
||||
Tag::compound(vec![
|
||||
let mut fields = vec![
|
||||
("game_event", Tag::String(info.event.into())),
|
||||
("distance", Tag::Float(info.distance.to_bits())),
|
||||
(
|
||||
"pos",
|
||||
Tag::List(info.pos.iter().map(|v| Tag::Double(v.to_bits())).collect()),
|
||||
),
|
||||
])
|
||||
];
|
||||
// `VibrationInfo.CODEC`: each UUID an int array of four, absent for none.
|
||||
if let Some(u) = info.source_uuid {
|
||||
fields.push(("source", Tag::IntArray(u.to_vec())));
|
||||
}
|
||||
if let Some(u) = info.owner_uuid {
|
||||
fields.push(("projectile_owner", Tag::IntArray(u.to_vec())));
|
||||
}
|
||||
Tag::compound(fields)
|
||||
}
|
||||
/// A UUID in its saved int array form; anything else is no UUID.
|
||||
fn uuid_field(tag: &Tag, key: &str) -> Option<[i32; 4]> {
|
||||
match tag.get(key) {
|
||||
Some(Tag::IntArray(v)) => <[i32; 4]>::try_from(v.as_slice()).ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn info_from_nbt(tag: &Tag) -> Option<Info> {
|
||||
let event = intern(&horizon_client_core::nbt_value::text(
|
||||
@@ -342,8 +361,12 @@ fn info_from_nbt(tag: &Tag) -> Option<Info> {
|
||||
event,
|
||||
distance,
|
||||
pos,
|
||||
// The live entities are found again by UUID only when needed; a
|
||||
// loaded vibration names none of them.
|
||||
source: None,
|
||||
owner: None,
|
||||
source_uuid: uuid_field(tag, "source"),
|
||||
owner_uuid: uuid_field(tag, "projectile_owner"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -623,6 +646,8 @@ mod tests {
|
||||
pos: [0.0; 3],
|
||||
source: None,
|
||||
owner: None,
|
||||
source_uuid: None,
|
||||
owner_uuid: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -748,6 +773,106 @@ mod tests {
|
||||
assert_eq!(back.selector.current.map(|(_, t)| t), Some(41));
|
||||
}
|
||||
|
||||
fn identified(event: &'static str, distance: f32) -> Info {
|
||||
Info {
|
||||
source: Some(Who::Mob(7)),
|
||||
owner: Some(Who::Mob(8)),
|
||||
source_uuid: Some([100, 0, 0, 1]),
|
||||
owner_uuid: Some([100, 0, 0, 2]),
|
||||
..info(event, distance)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vibration_uuids_save_as_source_and_projectile_owner() {
|
||||
let tag = info_to_nbt(&identified("minecraft:step", 3.0));
|
||||
assert_eq!(tag.get("source"), Some(&Tag::IntArray(vec![100, 0, 0, 1])));
|
||||
assert_eq!(
|
||||
tag.get("projectile_owner"),
|
||||
Some(&Tag::IntArray(vec![100, 0, 0, 2]))
|
||||
);
|
||||
// Read back, the identities stay; the live entities do not.
|
||||
let back = info_from_nbt(&tag).unwrap();
|
||||
assert_eq!(back.source_uuid, Some([100, 0, 0, 1]));
|
||||
assert_eq!(back.owner_uuid, Some([100, 0, 0, 2]));
|
||||
assert_eq!((back.source, back.owner), (None, None));
|
||||
// A live entity with no saved identity writes nothing for it.
|
||||
let unnamed = Info {
|
||||
source: Some(Who::Mob(7)),
|
||||
..info("minecraft:step", 3.0)
|
||||
};
|
||||
let tag = info_to_nbt(&unnamed);
|
||||
assert!(tag.get("source").is_none() && tag.get("projectile_owner").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_loaded_vibration_keeps_only_well_formed_uuids() {
|
||||
let base = |extra: Vec<(&'static str, Tag)>| {
|
||||
let mut fields = vec![
|
||||
("game_event", Tag::String("minecraft:step".into())),
|
||||
("distance", Tag::Float(3.0f32.to_bits())),
|
||||
("pos", Tag::List(vec![Tag::Double(0f64.to_bits()); 3])),
|
||||
];
|
||||
fields.extend(extra);
|
||||
info_from_nbt(&Tag::compound(fields)).unwrap()
|
||||
};
|
||||
let absent = base(vec![]);
|
||||
assert_eq!((absent.source_uuid, absent.owner_uuid), (None, None));
|
||||
let malformed = base(vec![
|
||||
("source", Tag::IntArray(vec![1, 2, 3])),
|
||||
(
|
||||
"projectile_owner",
|
||||
Tag::String("00000064-0000-0000-0000-000000000002".into()),
|
||||
),
|
||||
]);
|
||||
assert_eq!((malformed.source_uuid, malformed.owner_uuid), (None, None));
|
||||
// Only the codec's fields are kept: an unknown key is not saved again.
|
||||
let extra = base(vec![("horizon:raw", Tag::Int(1))]);
|
||||
assert!(info_to_nbt(&extra).get("horizon:raw").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reloaded_listener_drops_stale_uuids() {
|
||||
let mut d = Data::default();
|
||||
d.schedule(identified("minecraft:step", 2.0), 40);
|
||||
d.tick(41, true, true, |_| 5);
|
||||
assert_eq!(d.current.and_then(|i| i.source_uuid), Some([100, 0, 0, 1]));
|
||||
// A listener saved with no identity replaces the old one, not merges.
|
||||
d = Data::from_nbt(
|
||||
&Data {
|
||||
current: Some(info("minecraft:step", 2.0)),
|
||||
..Data::default()
|
||||
}
|
||||
.to_nbt(),
|
||||
);
|
||||
assert_eq!(
|
||||
d.current.map(|i| (i.source_uuid, i.owner_uuid)),
|
||||
Some((None, None))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_chosen_candidate_carries_its_uuids_into_the_travelling_vibration() {
|
||||
let mut d = Data::default();
|
||||
d.schedule(identified("minecraft:step", 2.0), 40);
|
||||
let saved = Data::from_nbt(&d.to_nbt());
|
||||
assert_eq!(
|
||||
saved.selector.current.map(|(i, _)| i.source_uuid),
|
||||
Some(Some([100, 0, 0, 1]))
|
||||
);
|
||||
// The candidate read back is the one that starts travelling.
|
||||
let mut d = saved;
|
||||
let t = d.tick(41, true, true, |_| 5);
|
||||
let travelling = t.started.map(|(i, _)| i).unwrap();
|
||||
assert_eq!(travelling.source_uuid, Some([100, 0, 0, 1]));
|
||||
assert_eq!(travelling.owner_uuid, Some([100, 0, 0, 2]));
|
||||
let saved = Data::from_nbt(&d.to_nbt()).current.unwrap();
|
||||
assert_eq!(
|
||||
(saved.source_uuid, saved.owner_uuid),
|
||||
(Some([100, 0, 0, 1]), Some([100, 0, 0, 2]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wool_on_the_line_occludes() {
|
||||
let origin = [0.5, 0.5, 0.5];
|
||||
|
||||
@@ -390,6 +390,10 @@ impl Settings {
|
||||
// Horizon's own key bindings live in horizon-extras.txt, not in options.txt.
|
||||
let emote = settings.extras.emote_key.clone();
|
||||
settings.keys.set("key.emote", &emote);
|
||||
// `Options.load`: `sharePresence` decides what the friends service posts.
|
||||
crate::social::set_share_presence(crate::social::SharePresence::of(
|
||||
settings.file.int("sharePresence").clamp(0, 2) as u8,
|
||||
));
|
||||
settings.describe_for_crashes();
|
||||
// Options.load: the backend asked for at startup, what a restart is measured from.
|
||||
Self {
|
||||
|
||||
@@ -5,12 +5,13 @@
|
||||
//! A new screen implements [`Screen`], describing its rows from the settings, and [`Shell`] does
|
||||
//! everything else: drawing, clicks, one scroll path, a controller's focus and its declared
|
||||
//! buttons, keys and each row's description (`extras_ui` is the smallest; `graphics_ui` has
|
||||
//! pages, sliders, costs and Undo). `controller_ui` and `shader_ui` still drive the parts below
|
||||
//! by hand.
|
||||
//! pages, sliders, costs and Undo; `controller_ui` has bindings, [`Value::Binding`], whose wait
|
||||
//! for a button or a key the shell runs). `shader_ui` still drives the parts below by hand.
|
||||
use crate::{
|
||||
graphics_style as style,
|
||||
hud::{HudAtlas, HudBatch},
|
||||
lang,
|
||||
pad_glyph::{self, Glyph},
|
||||
settings::Settings,
|
||||
text::Text,
|
||||
ui,
|
||||
@@ -573,6 +574,225 @@ pub(crate) fn row(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, y: f32
|
||||
}
|
||||
}
|
||||
|
||||
/// A binding's reset button, at the end of its row at `y`.
|
||||
fn reset_rect(cols: &Columns, y: f32) -> [f32; 4] {
|
||||
let right = cols.list_x + cols.list_w - RESET_INSET;
|
||||
[right - RESET_W, y + RESET_INSET, RESET_W, style::CONTROL_H]
|
||||
}
|
||||
|
||||
/// What a row takes the focus and a press on: all of it but a binding's reset.
|
||||
fn row_rect<R>(row: &Row<R>, cols: &Columns, y: f32) -> [f32; 4] {
|
||||
let resets = row.binding().is_some_and(|b| b.reset.shown());
|
||||
short_of_reset(cols, y, resets)
|
||||
}
|
||||
/// The row at `y`, short of its reset button where it has one.
|
||||
fn short_of_reset(cols: &Columns, y: f32, resets: bool) -> [f32; 4] {
|
||||
let [x, y, w, h] = cols.row(y);
|
||||
let w = if resets { w - RESET_W - RESET_GAP } else { w };
|
||||
[x, y, w, h]
|
||||
}
|
||||
|
||||
/// A row's lit background while it is pointed at or focused.
|
||||
fn lit(batch: &mut HudBatch, atlas: &HudAtlas, [x, y, w, _]: [f32; 4]) {
|
||||
style::fill(
|
||||
batch,
|
||||
atlas,
|
||||
[x + 2.0, y + 1.0, w - 4.0, style::ROW - 2.0],
|
||||
style::HOVER,
|
||||
);
|
||||
}
|
||||
|
||||
/// A value to read ([`Value::Read`]) at `y`: its name, and the value at the right, cut short to
|
||||
/// fit. `(label, name, value)`; `(hover, focused)`.
|
||||
fn read_row(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
cols: &Columns,
|
||||
y: f32,
|
||||
(label, name, value): (&str, &str, &str),
|
||||
(hover, focused): (bool, bool),
|
||||
) {
|
||||
let rect = cols.row(y);
|
||||
crate::widgets::record(label, rect, true);
|
||||
crate::widgets::narratable(|| {
|
||||
crate::widgets::Narratable::button(label.to_owned(), rect, true)
|
||||
.hovered(hover)
|
||||
.focused(focused)
|
||||
});
|
||||
crate::widgets::target(rect);
|
||||
if hover || focused {
|
||||
lit(batch, atlas, rect);
|
||||
}
|
||||
let ty = y + ((style::ROW - 8.0) / 2.0).floor();
|
||||
let left = rect[0] + style::INSET;
|
||||
let name_w = atlas.font.width(name);
|
||||
let shown = Text::literal(name.to_owned());
|
||||
batch.text_styled(atlas, &shown, left, ty, style::WHITE, true);
|
||||
let room = rect[2] - 2.0 * style::INSET - name_w - 8.0;
|
||||
let value = style::fit(atlas, value, room);
|
||||
let vx = rect[0] + rect[2] - style::INSET - atlas.font.width(&value);
|
||||
batch.text_styled(atlas, &Text::literal(value), vx, ty, style::SOFT, true);
|
||||
if focused {
|
||||
style::ring(
|
||||
batch,
|
||||
atlas,
|
||||
[rect[0] + 1.0, y, rect[2] - 2.0, style::ROW],
|
||||
style::WHITE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A controller binding's row as the shell reads it ([`Shown::Pad`]).
|
||||
struct PadRow<'a> {
|
||||
/// The whole caption, for the narrator and the widget tree.
|
||||
label: &'a str,
|
||||
name: &'a str,
|
||||
/// What the strip says, for the narrator.
|
||||
hint: &'a str,
|
||||
glyph: Option<Glyph>,
|
||||
waits: bool,
|
||||
shared: bool,
|
||||
reset: Reset,
|
||||
live: bool,
|
||||
/// The row and its reset: pointed at, focused.
|
||||
hover: (bool, bool),
|
||||
focus: (bool, bool),
|
||||
}
|
||||
|
||||
/// A controller binding's row at `y`: its name, a chip with the bound button's art ("Not Bound"
|
||||
/// while nothing is), marked "> <" in yellow while it waits and "[ ]" while its button does
|
||||
/// something else too, and its reset at the end.
|
||||
fn pad_row(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, y: f32, view: &PadRow) {
|
||||
let &PadRow {
|
||||
label,
|
||||
name,
|
||||
hint,
|
||||
glyph,
|
||||
waits,
|
||||
shared,
|
||||
reset,
|
||||
live,
|
||||
hover: (hover, reset_hover),
|
||||
focus: (focused, reset_focused),
|
||||
} = view;
|
||||
let rect = short_of_reset(cols, y, reset.shown());
|
||||
if live && (hover || focused) {
|
||||
lit(batch, atlas, rect);
|
||||
}
|
||||
crate::widgets::widget_cursor(hover, live);
|
||||
crate::widgets::record(label, rect, live);
|
||||
crate::widgets::narratable(|| {
|
||||
let mut n = crate::widgets::Narratable::button(label.to_owned(), rect, live)
|
||||
.hovered(hover)
|
||||
.focused(focused);
|
||||
n.hint = Some(hint.to_owned());
|
||||
n
|
||||
});
|
||||
if live {
|
||||
crate::widgets::target(rect);
|
||||
}
|
||||
// The chip: the button's art, or its name where there is no art or nothing is bound.
|
||||
let art = glyph.filter(|g| pad_glyph::art(atlas, *g).is_some());
|
||||
let text = match glyph {
|
||||
Some(g) => g.label().to_owned(),
|
||||
None => lang::get(NOT_BOUND),
|
||||
};
|
||||
let inner = match art {
|
||||
Some(g) => pad_glyph::width(atlas, g),
|
||||
None => atlas.font.width(&text),
|
||||
};
|
||||
let marks = match (waits, shared) {
|
||||
(true, _) => Some((">", "<")),
|
||||
(false, true) => Some(("[", "]")),
|
||||
(false, false) => None,
|
||||
};
|
||||
let mark_w = marks.map_or(0.0, |(open, close)| {
|
||||
atlas.font.width(open) + atlas.font.width(close) + 2.0 * PAD_MARK_GAP
|
||||
});
|
||||
let chip_w = (inner + mark_w + PAD_CHIP_PAD).max(PAD_CHIP_W);
|
||||
let chip = [
|
||||
rect[0] + rect[2] - chip_w - 2.0,
|
||||
y + (style::ROW - style::CONTROL_H) / 2.0,
|
||||
chip_w,
|
||||
style::CONTROL_H,
|
||||
];
|
||||
// Text in the chip only where there is no art, yellow with its marks.
|
||||
let face = match (art, marks) {
|
||||
(Some(_), _) => Text::literal(String::new()),
|
||||
(None, Some(_)) => Text::literal(text).with_color(crate::text::YELLOW),
|
||||
(None, None) => Text::literal(text),
|
||||
};
|
||||
style::button(batch, atlas, chip, &face, (live && hover, live, false));
|
||||
let ty = y + ((style::ROW - 8.0) / 2.0).floor();
|
||||
let middle = chip[0] + chip[2] / 2.0;
|
||||
if let Some(g) = art {
|
||||
pad_glyph::draw(batch, atlas, g, (middle - inner / 2.0 + 1.0).floor(), ty);
|
||||
}
|
||||
if let Some((open, close)) = marks {
|
||||
let yellow = |mark: &str| Text::literal(mark.to_owned()).with_color(crate::text::YELLOW);
|
||||
let left = middle - inner / 2.0 - PAD_MARK_GAP - atlas.font.width(open);
|
||||
batch.text_styled(atlas, &yellow(open), left.floor(), ty, style::WHITE, true);
|
||||
let right = middle + inner / 2.0 + PAD_MARK_GAP;
|
||||
batch.text_styled(atlas, &yellow(close), right.ceil(), ty, style::WHITE, true);
|
||||
}
|
||||
let left = rect[0] + style::INSET;
|
||||
let color = if live { style::WHITE } else { style::MUTED };
|
||||
let shown = Text::literal(style::fit(atlas, name, chip[0] - 6.0 - left));
|
||||
batch.text_styled(atlas, &shown, left, ty, color, true);
|
||||
if focused {
|
||||
style::ring(batch, atlas, rect, style::WHITE);
|
||||
}
|
||||
if !reset.shown() {
|
||||
return;
|
||||
}
|
||||
// The reset: a target only where it would do something.
|
||||
let resets = live && reset == Reset::Changed;
|
||||
let reset = reset_rect(cols, y);
|
||||
let reset_label = lang::get("controls.reset");
|
||||
let reset_hover = resets && reset_hover;
|
||||
crate::widgets::widget_cursor(reset_hover, resets);
|
||||
crate::widgets::record(&reset_label, reset, resets);
|
||||
crate::widgets::narratable(|| {
|
||||
let what = lang::tr("narrator.controls.reset", &[&name]);
|
||||
crate::widgets::Narratable::button(what, reset, resets)
|
||||
.hovered(reset_hover)
|
||||
.focused(reset_focused)
|
||||
});
|
||||
if resets {
|
||||
crate::widgets::target(reset);
|
||||
}
|
||||
let shown = Text::literal(reset_label);
|
||||
style::button(batch, atlas, reset, &shown, (reset_hover, resets, false));
|
||||
if reset_focused {
|
||||
style::ring(batch, atlas, reset, style::WHITE);
|
||||
}
|
||||
}
|
||||
|
||||
/// Lines a screen shows under its page list (`Screen::side`), from the place of the `below`th page.
|
||||
fn side_lines(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
cols: &Columns,
|
||||
below: usize,
|
||||
lines: &[String],
|
||||
) {
|
||||
let [x, y, w, _] = cols.page(below);
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let rect = [
|
||||
x + SIDE_INSET,
|
||||
y + SIDE_INSET + i as f32 * SIDE_LINE_H,
|
||||
w - 2.0 * SIDE_INSET,
|
||||
8.0,
|
||||
];
|
||||
crate::widgets::record(line, rect, false);
|
||||
let shown = Text::literal(style::fit(atlas, line, rect[2]));
|
||||
batch.text_styled(atlas, &shown, rect[0], rect[1], style::MUTED, true);
|
||||
}
|
||||
}
|
||||
/// A line under the page list: its inset, and its height.
|
||||
const SIDE_INSET: f32 = 2.0;
|
||||
const SIDE_LINE_H: f32 = 10.0;
|
||||
|
||||
/// Vanilla's scroller, inside the panel's right edge, sized as `ui::Scroll` drags it.
|
||||
pub(crate) fn scroller(
|
||||
batch: &mut HudBatch,
|
||||
@@ -751,7 +971,8 @@ const SCROLL_RATE: f32 = 18.0;
|
||||
pub(crate) enum Value {
|
||||
/// A tick box.
|
||||
Switch(bool),
|
||||
/// A value stepped between `<` `>`, as it reads now.
|
||||
/// A value stepped between `<` `>`, as it reads now: a press steps it on, a Shift press back,
|
||||
/// and focused, Left and Right do the same.
|
||||
Choice(String),
|
||||
/// A slider: left and right step it (`steps`: its stops, else a pixel of the track), a press
|
||||
/// on the track sets it (a controller's focus does the same).
|
||||
@@ -760,14 +981,122 @@ pub(crate) enum Value {
|
||||
steps: Option<u32>,
|
||||
text: String,
|
||||
},
|
||||
/// A key binding (`.0`: what the host waits on), as it reads now.
|
||||
Key(&'static str, String),
|
||||
/// A binding: a press makes the host wait for a key or a controller button
|
||||
/// ([`Output::Bind`]), and the row shows what it is bound to now ([`Binding`]).
|
||||
Binding(Binding),
|
||||
/// A value to read beside the name, which the focus reaches (unlike `Note`), so the strip and
|
||||
/// the narrator say what it is; nothing presses it.
|
||||
Read(String),
|
||||
/// A button across the row.
|
||||
Press,
|
||||
/// A line to read: nothing presses it and no focus lands on it.
|
||||
/// A line to read, which no focus reaches (unlike `Read`) and nothing presses.
|
||||
Note,
|
||||
}
|
||||
|
||||
/// What a binding row waits for once pressed, by the name the host binds.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Capture {
|
||||
/// A key or a mouse button, as on Key Binds (`OptionsUi::selected_key`): Escape unbinds.
|
||||
Key(&'static str),
|
||||
/// A controller button for a gameplay action (`OptionsUi::selected_pad_action`): the game binds
|
||||
/// the next button pressed, Start cancels, and the keyboard's Escape unbinds, as on Key Binds.
|
||||
Pad(&'static str),
|
||||
}
|
||||
|
||||
/// What a binding row is bound to now.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum Shown {
|
||||
/// The key's name, as Key Binds shows it.
|
||||
Key(String),
|
||||
/// The controller button's glyph, drawn as its art; `None` while nothing is bound. `cancel`
|
||||
/// is the button that cancels the wait (`MenuJob::Start`), named in the strip meanwhile.
|
||||
Pad { glyph: Option<Glyph>, cancel: Glyph },
|
||||
}
|
||||
|
||||
/// A binding row's value ([`Value::Binding`]). The screen says what it binds and what it is bound
|
||||
/// to; the shell starts the wait, shows it ("> A <"), draws the button, says in the strip that
|
||||
/// it isn't bound or what else its button does, and draws the row's reset.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct Binding {
|
||||
pub(crate) capture: Capture,
|
||||
pub(crate) shown: Shown,
|
||||
/// What else the same button does, by name: shown in the strip, and the button in yellow.
|
||||
pub(crate) shared: Vec<String>,
|
||||
/// Its reset button at the row's end (`Screen::reset`).
|
||||
pub(crate) reset: Reset,
|
||||
}
|
||||
/// A binding row's reset button.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Reset {
|
||||
/// The row has none (a key, as Key Binds lists it).
|
||||
None,
|
||||
/// Greyed: the binding is its default already.
|
||||
Default,
|
||||
/// Live: the binding differs from its default.
|
||||
Changed,
|
||||
}
|
||||
impl Reset {
|
||||
/// The row has a reset button, live or not.
|
||||
fn shown(self) -> bool {
|
||||
self != Reset::None
|
||||
}
|
||||
}
|
||||
impl Binding {
|
||||
/// A key's binding, as Key Binds lists it: no reset of its own.
|
||||
pub(crate) fn key(name: &'static str, shown: String) -> Self {
|
||||
Self {
|
||||
capture: Capture::Key(name),
|
||||
shown: Shown::Key(shown),
|
||||
shared: Vec::new(),
|
||||
reset: Reset::None,
|
||||
}
|
||||
}
|
||||
/// What it reads as, for the narrator and the widget tree: a pad's shared button in brackets,
|
||||
/// as the row draws it in yellow.
|
||||
fn text(&self) -> String {
|
||||
match &self.shown {
|
||||
Shown::Key(text) => text.clone(),
|
||||
Shown::Pad { glyph, .. } => {
|
||||
let name = glyph.map_or_else(|| lang::get(NOT_BOUND), |g| g.label().to_owned());
|
||||
if self.shared.is_empty() {
|
||||
name
|
||||
} else {
|
||||
format!("[ {name} ]")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// What the strip says about it while `waiting`, else when it isn't bound or shares its
|
||||
/// button; `None` leaves the row's own description.
|
||||
fn note(&self, waiting: bool) -> Option<String> {
|
||||
match (&self.shown, waiting) {
|
||||
(Shown::Pad { cancel, .. }, true) => Some(lang::tr(WAITING_PAD, &[&cancel.label()])),
|
||||
(Shown::Pad { glyph: None, .. }, false) => Some(lang::get(UNBOUND)),
|
||||
_ if !self.shared.is_empty() => Some(lang::tr(SHARED, &[&self.shared.join(", ")])),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Vanilla's word for a key bound to nothing.
|
||||
const NOT_BOUND: &str = "key.keyboard.unknown";
|
||||
/// The strip while a controller button is awaited, naming the one that cancels.
|
||||
const WAITING_PAD: &str = "Press the button for this. %s cancels.";
|
||||
/// The strip over a binding with no button.
|
||||
const UNBOUND: &str = "Not bound: press this row, then the button that should do it.";
|
||||
/// The strip over a binding whose button does other things too.
|
||||
const SHARED: &str = "This button also does: %s";
|
||||
/// A pad binding's chip, at least: wide enough for most labels in vanilla's font.
|
||||
const PAD_CHIP_W: f32 = 56.0;
|
||||
/// A pad binding's chip around its art or text, and the room for the marks ("> <", "[ ]").
|
||||
const PAD_CHIP_PAD: f32 = 8.0;
|
||||
/// Clear of the art's edge, which its sprite draws past (`pad_glyph::rect`).
|
||||
const PAD_MARK_GAP: f32 = 5.0;
|
||||
/// A binding's reset button at the row's end, and the gap left before it.
|
||||
const RESET_W: f32 = 36.0;
|
||||
const RESET_GAP: f32 = 4.0;
|
||||
/// The reset button's inset from the list's right edge.
|
||||
const RESET_INSET: f32 = 2.0;
|
||||
|
||||
/// A row as a screen describes it, built afresh from the settings each time it is read.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Row<R> {
|
||||
@@ -840,12 +1169,20 @@ impl<R> Row<R> {
|
||||
} else {
|
||||
lang::get_or("Off", "Off")
|
||||
}),
|
||||
Value::Choice(text) | Value::Slider { text, .. } | Value::Key(_, text) => {
|
||||
Value::Choice(text) | Value::Slider { text, .. } | Value::Read(text) => {
|
||||
Some(text.clone())
|
||||
}
|
||||
Value::Binding(binding) => Some(binding.text()),
|
||||
Value::Press | Value::Note => None,
|
||||
}
|
||||
}
|
||||
/// Its binding, for a binding row.
|
||||
fn binding(&self) -> Option<&Binding> {
|
||||
match &self.value {
|
||||
Value::Binding(binding) => Some(binding),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rows under a group's name (a language key).
|
||||
@@ -868,8 +1205,8 @@ pub(crate) struct Sheet<P, R> {
|
||||
pub(crate) enum Output {
|
||||
/// Passed on to the game as it is.
|
||||
Event(crate::options_ui::Event),
|
||||
/// Wait for a key or a mouse button to bind to this name.
|
||||
BindKey(&'static str),
|
||||
/// Wait for what a binding row binds: a key or a mouse button, or a controller button.
|
||||
Bind(Capture),
|
||||
/// Open another options page over this one.
|
||||
Push(crate::options_ui::Page),
|
||||
/// Leave the screen.
|
||||
@@ -910,6 +1247,14 @@ pub(crate) trait Screen {
|
||||
) -> Option<Vec<Output>> {
|
||||
None
|
||||
}
|
||||
/// A binding row's reset was pressed: its binding goes back to its default.
|
||||
fn reset(&mut self, _s: &mut Settings, _row: Self::Row) -> Vec<Output> {
|
||||
Vec::new()
|
||||
}
|
||||
/// Lines under the page list: whose settings these are, say.
|
||||
fn side(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
/// Whether the screen has Undo, which puts back what it changed (`undo`) since it opened.
|
||||
fn undoes(&self) -> bool {
|
||||
false
|
||||
@@ -920,6 +1265,13 @@ pub(crate) trait Screen {
|
||||
fn changes(&self, outputs: &[Output]) -> bool {
|
||||
changes_settings(outputs)
|
||||
}
|
||||
/// Whether what the screen changes differs now from `before`, for a screen that can tell:
|
||||
/// Undo then shows exactly while it would do something, a binding the host made (the game
|
||||
/// binds a controller button) included, and a value stepped back isn't a change. `None`
|
||||
/// leaves it to `changes`.
|
||||
fn differs(&self, _s: &Settings, _before: &Settings) -> Option<bool> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `outputs` say a setting changed, or a key is being bound to one.
|
||||
@@ -928,7 +1280,7 @@ pub(crate) fn changes_settings(outputs: &[Output]) -> bool {
|
||||
let changed = |output: &Output| {
|
||||
matches!(
|
||||
output,
|
||||
Output::BindKey(_) | Output::Event(Event::Changed | Event::SliderDrag)
|
||||
Output::Bind(_) | Output::Event(Event::Changed | Event::SliderDrag)
|
||||
)
|
||||
};
|
||||
outputs.iter().any(changed)
|
||||
@@ -955,7 +1307,7 @@ pub(crate) fn stamp_of(parts: &impl std::fmt::Debug) -> u64 {
|
||||
pub(crate) trait Hosted {
|
||||
/// The screen opens, afresh.
|
||||
fn open(&mut self);
|
||||
/// `waiting`: the key binding the host waits on.
|
||||
/// `waiting`: the binding the host waits on.
|
||||
fn draw(
|
||||
&mut self,
|
||||
s: &Settings,
|
||||
@@ -963,7 +1315,7 @@ pub(crate) trait Hosted {
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
in_world: bool,
|
||||
waiting: Option<&'static str>,
|
||||
waiting: Option<Capture>,
|
||||
);
|
||||
/// A press (or a drag with `drag`) at `at`.
|
||||
fn click(
|
||||
@@ -992,7 +1344,7 @@ pub(crate) trait Hosted {
|
||||
/// The one way a list scrolls: the wheel, a right stick and a D-pad past the view's edge.
|
||||
fn scroll(&mut self, s: &Settings, size: (f32, f32), amount: f32);
|
||||
/// Typing in the search field, Tab and the arrows between controls, Left and Right on a
|
||||
/// slider, Enter or Space to press, Escape to leave.
|
||||
/// slider or a choice, Enter or Space to press, Escape to leave.
|
||||
fn key(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
@@ -1022,6 +1374,8 @@ pub(crate) trait Hosted {
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) enum Target<P, R> {
|
||||
Row(R),
|
||||
/// A binding row's reset button.
|
||||
Reset(R),
|
||||
Page(P),
|
||||
Search,
|
||||
Undo,
|
||||
@@ -1070,7 +1424,7 @@ pub(crate) struct Shell<S: Screen> {
|
||||
page: Option<S::Page>,
|
||||
/// While searching, the page last picked in the page list: current until the list scrolls.
|
||||
picked: Option<S::Page>,
|
||||
waiting: Option<&'static str>,
|
||||
waiting: Option<Capture>,
|
||||
/// The rows as last built, and what for.
|
||||
built: Option<(Built<S::Page, S::Row>, Rc<View<S::Page, S::Row>>)>,
|
||||
/// The settings as the screen found them, for Undo (`Screen::undoes`), and whether it
|
||||
@@ -1149,10 +1503,15 @@ impl<S: Screen> Shell<S> {
|
||||
self.changed = true;
|
||||
}
|
||||
}
|
||||
/// Remember the settings before the first change, for Undo.
|
||||
/// Remember the settings before the first change, for Undo, and ask a screen that can tell
|
||||
/// whether they differ now (`Screen::differs`).
|
||||
fn keep(&mut self, s: &Settings) {
|
||||
if self.screen.undoes() && self.before.is_none() {
|
||||
self.before = Some(Box::new(s.clone()));
|
||||
if !self.screen.undoes() {
|
||||
return;
|
||||
}
|
||||
let before = self.before.get_or_insert_with(|| Box::new(s.clone()));
|
||||
if let Some(differs) = self.screen.differs(s, before) {
|
||||
self.changed = differs;
|
||||
}
|
||||
}
|
||||
/// Undo, gone once it has put the settings back.
|
||||
@@ -1262,22 +1621,21 @@ impl<S: Screen> Shell<S> {
|
||||
let lines = view.lines.iter();
|
||||
lines.filter_map(move |l| Some((&view.rows[l.setting?], top + l.y - scroll)))
|
||||
}
|
||||
/// The row under the pointer, inside the list's view.
|
||||
/// The row under the pointer, inside the list's view, and where it is.
|
||||
fn row_at<'a>(
|
||||
&self,
|
||||
view: &'a View<S::Page, S::Row>,
|
||||
(x, y): (f32, f32),
|
||||
) -> Option<&'a Row<S::Row>> {
|
||||
) -> Option<(&'a Row<S::Row>, f32)> {
|
||||
let cols = &view.cols;
|
||||
if y < cols.top || y >= cols.bottom {
|
||||
return None;
|
||||
}
|
||||
let mut placed = self.placed(view);
|
||||
let hit = placed.find(|&(_, ry)| {
|
||||
placed.find(|&(_, ry)| {
|
||||
let [rx, ry, rw, rh] = cols.row(ry);
|
||||
ui::hovered((x, y), rx, ry, rw, rh)
|
||||
});
|
||||
hit.map(|(row, _)| row)
|
||||
})
|
||||
}
|
||||
/// Press `row`: `track` is where on its slider's track (`None` from the keyboard, or off it).
|
||||
fn activate(
|
||||
@@ -1288,7 +1646,14 @@ impl<S: Screen> Shell<S> {
|
||||
track: Option<f32>,
|
||||
) -> Vec<Output> {
|
||||
match row.value {
|
||||
Value::Note => Vec::new(),
|
||||
Value::Note | Value::Read(_) => Vec::new(),
|
||||
// The shell, not the screen, starts the wait.
|
||||
Value::Binding(ref binding) => {
|
||||
self.can_change_value = false;
|
||||
let outputs = vec![CLICK, Output::Bind(binding.capture)];
|
||||
self.did(&outputs);
|
||||
outputs
|
||||
}
|
||||
Value::Slider { .. } => {
|
||||
self.can_change_value = true;
|
||||
let Some(position) = track else {
|
||||
@@ -1321,13 +1686,18 @@ impl<S: Screen> Shell<S> {
|
||||
out.push((Target::Page(page), cols.page(i)));
|
||||
}
|
||||
}
|
||||
let rows: Vec<(S::Row, f32, bool)> = self
|
||||
let rows: Vec<(&Row<S::Row>, f32, bool)> = self
|
||||
.placed(view)
|
||||
.map(|(row, y)| (row.id, y, row.live))
|
||||
.map(|(row, y)| (row, y, row.live))
|
||||
.collect();
|
||||
for &(id, y, live) in &rows[reach(&rows, cols, near)] {
|
||||
if live {
|
||||
out.push((Target::Row(id), cols.row(y)));
|
||||
for &(row, y, live) in &rows[reach(&rows, cols, near)] {
|
||||
if !live {
|
||||
continue;
|
||||
}
|
||||
out.push((Target::Row(row.id), row_rect(row, cols, y)));
|
||||
// A binding's reset, right of it, only while it would do something.
|
||||
if row.binding().is_some_and(|b| b.reset == Reset::Changed) {
|
||||
out.push((Target::Reset(row.id), reset_rect(cols, y)));
|
||||
}
|
||||
}
|
||||
if self.changed {
|
||||
@@ -1362,17 +1732,37 @@ impl<S: Screen> Shell<S> {
|
||||
let row = |id| view.rows.iter().find(|r| r.id == id);
|
||||
self.can_change_value = matches!(target, Target::Row(id)
|
||||
if row(id).is_some_and(|r| matches!(r.value, Value::Slider { .. })));
|
||||
if matches!(target, Target::Row(_)) {
|
||||
if matches!(target, Target::Row(_) | Target::Reset(_)) {
|
||||
scroll_into_view(&mut self.scroll, rect, &view.cols, view.content);
|
||||
self.picked = None;
|
||||
}
|
||||
}
|
||||
/// A binding row's reset: the binding goes back to its default and, as the reset goes away
|
||||
/// with it, the focus to its row.
|
||||
fn reset(&mut self, s: &mut Settings, row: S::Row) -> Vec<Output> {
|
||||
let mut outputs = vec![CLICK];
|
||||
outputs.extend(self.screen.reset(s, row));
|
||||
self.did(&outputs);
|
||||
self.focus = Some(Target::Row(row));
|
||||
outputs
|
||||
}
|
||||
/// The search changed: the list starts over at its top.
|
||||
fn searched(&mut self) {
|
||||
self.scroll.amount = 0.0;
|
||||
self.picked = None;
|
||||
}
|
||||
/// A row of the list at `y`.
|
||||
/// What the strip and the narrator say about `row`: a binding's wait, its missing button or
|
||||
/// its shared one, else what the row does.
|
||||
fn strip_text(&self, row: &Row<S::Row>) -> String {
|
||||
let note = row.binding().and_then(|b| b.note(self.waits(row)));
|
||||
note.unwrap_or_else(|| row.described().to_owned())
|
||||
}
|
||||
/// Whether the host waits on `row`'s binding.
|
||||
fn waits(&self, row: &Row<S::Row>) -> bool {
|
||||
row.binding()
|
||||
.is_some_and(|b| self.waiting == Some(b.capture))
|
||||
}
|
||||
/// A row of the list at `y`; `pointer`: the cursor, while it is over the list.
|
||||
fn draw_row(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
@@ -1380,7 +1770,7 @@ impl<S: Screen> Shell<S> {
|
||||
cols: &Columns,
|
||||
row: &Row<S::Row>,
|
||||
y: f32,
|
||||
hover: bool,
|
||||
pointer: Option<(f32, f32)>,
|
||||
) {
|
||||
if row.value == Value::Note {
|
||||
let rect = cols.row(y);
|
||||
@@ -1391,11 +1781,39 @@ impl<S: Screen> Shell<S> {
|
||||
batch.text_styled(atlas, &name, rect[0] + style::INSET, ty, style::MUTED, true);
|
||||
return;
|
||||
}
|
||||
let waits = matches!(row.value, Value::Key(key, _) if self.waiting == Some(key));
|
||||
let over = |[x, y, w, h]: [f32; 4]| pointer.is_some_and(|at| ui::hovered(at, x, y, w, h));
|
||||
let hover = over(row_rect(row, cols, y));
|
||||
let waits = self.waits(row);
|
||||
let value = row
|
||||
.value_text()
|
||||
.map(|text| if waits { format!("> {text} <") } else { text });
|
||||
let label = row.label(value.as_deref());
|
||||
let focused = self.shows_focus(Target::Row(row.id));
|
||||
let hint = self.strip_text(row);
|
||||
if let Value::Read(value) = &row.value {
|
||||
let view = (label.as_str(), row.name.as_str(), value.as_str());
|
||||
read_row(batch, atlas, cols, y, view, (hover, focused));
|
||||
crate::widgets::last_narratable(|n| n.hint = Some(hint.clone()));
|
||||
return;
|
||||
}
|
||||
if let Some(binding) = row.binding()
|
||||
&& let Shown::Pad { glyph, .. } = binding.shown
|
||||
{
|
||||
let view = PadRow {
|
||||
label: &label,
|
||||
name: &row.name,
|
||||
hint: &hint,
|
||||
glyph,
|
||||
waits,
|
||||
shared: !binding.shared.is_empty(),
|
||||
reset: binding.reset,
|
||||
live: row.live,
|
||||
hover: (hover, over(reset_rect(cols, y))),
|
||||
focus: (focused, self.shows_focus(Target::Reset(row.id))),
|
||||
};
|
||||
pad_row(batch, atlas, cols, y, &view);
|
||||
return;
|
||||
}
|
||||
let view = RowView {
|
||||
label: &label,
|
||||
name: &row.name,
|
||||
@@ -1410,12 +1828,15 @@ impl<S: Screen> Shell<S> {
|
||||
},
|
||||
live: row.live,
|
||||
hover,
|
||||
focused: self.shows_focus(Target::Row(row.id)),
|
||||
focused,
|
||||
held: self.dragging == Some(row.id),
|
||||
key: matches!(row.value, Value::Key(..)).then_some(waits),
|
||||
key: row.binding().map(|_| waits),
|
||||
};
|
||||
self::row(batch, atlas, cols, y, &view);
|
||||
crate::widgets::last_narratable(|n| n.hint = Some(row.described().to_owned()));
|
||||
if row.live && matches!(row.value, Value::Choice(_)) {
|
||||
crate::widgets::steps_value(cols.row(y));
|
||||
}
|
||||
crate::widgets::last_narratable(|n| n.hint = Some(hint.clone()));
|
||||
}
|
||||
/// The strip under the plate: the hovered or focused row's name and what it does; with none,
|
||||
/// what the open page holds.
|
||||
@@ -1434,9 +1855,10 @@ impl<S: Screen> Shell<S> {
|
||||
style::panel(batch, atlas, cols.dock);
|
||||
let row = described.map(|i| &view.rows[i]);
|
||||
let why = row.and_then(|row| row.why.as_deref().filter(|_| !row.live));
|
||||
let (name, about) = match row {
|
||||
Some(row) => (row.name.as_str(), why.unwrap_or(row.described())),
|
||||
None => {
|
||||
let said = row.map(|row| self.strip_text(row));
|
||||
let (name, about) = match (row, &said) {
|
||||
(Some(row), Some(said)) => (row.name.as_str(), why.unwrap_or(said)),
|
||||
_ => {
|
||||
let Some(page) = view.pages.iter().find(|p| Some(p.0) == current) else {
|
||||
return;
|
||||
};
|
||||
@@ -1480,7 +1902,7 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
in_world: bool,
|
||||
waiting: Option<&'static str>,
|
||||
waiting: Option<Capture>,
|
||||
) {
|
||||
self.waiting = waiting;
|
||||
self.keep(s);
|
||||
@@ -1498,6 +1920,8 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
let name = &view.pages[i].1;
|
||||
page_tab(batch, atlas, cols.page(i), name, flags, cursor);
|
||||
}
|
||||
let below = self.tabs(&view).len();
|
||||
side_lines(batch, atlas, &cols, below, &self.screen.side());
|
||||
let focused = self.focus == Some(Target::Search);
|
||||
search_field(&mut self.search, batch, atlas, &cols, cursor, focused);
|
||||
let name_of = |page| view.pages.iter().find(|p| p.0 == page).map(|p| p.1.clone());
|
||||
@@ -1510,6 +1934,7 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
self::plate(batch, atlas, &cols, &heading);
|
||||
let over_list = cursor.1 >= cols.top && cursor.1 < cols.bottom;
|
||||
let mut described = None;
|
||||
let mut waiting = None;
|
||||
self::list(
|
||||
batch,
|
||||
atlas,
|
||||
@@ -1521,19 +1946,23 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
let row = &view.rows[i];
|
||||
let [rx, ry, rw, rh] = cols.row(y);
|
||||
let hover = over_list && ui::hovered(cursor, rx, ry, rw, rh);
|
||||
let focused = self.shows_focus(Target::Row(row.id));
|
||||
// The pointer's row wins over the keyboard's.
|
||||
let focused = self.shows_focus(Target::Row(row.id))
|
||||
|| self.shows_focus(Target::Reset(row.id));
|
||||
// A wait is what the strip says; then the pointer's row wins over the keyboard's.
|
||||
if self.waits(row) {
|
||||
waiting = Some(i);
|
||||
}
|
||||
if hover || self.dragging == Some(row.id) || (focused && described.is_none()) {
|
||||
described = Some(i);
|
||||
}
|
||||
self.draw_row(batch, atlas, &cols, row, y, hover);
|
||||
self.draw_row(batch, atlas, &cols, row, y, over_list.then_some(cursor));
|
||||
},
|
||||
);
|
||||
scroller(batch, atlas, &cols, view.content, self.scroll.amount);
|
||||
if let Some((_, row)) = self.first_row(&view) {
|
||||
crate::widgets::focus_link(cols.search(), true, row);
|
||||
}
|
||||
self.draw_dock(batch, atlas, &view, described, current);
|
||||
self.draw_dock(batch, atlas, &view, waiting.or(described), current);
|
||||
// Undo, over Done, only once there is something to undo.
|
||||
if self.changed {
|
||||
let undo = cols.button(1);
|
||||
@@ -1620,10 +2049,14 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
return vec![CLICK];
|
||||
}
|
||||
}
|
||||
let Some(row) = self.row_at(&view, (x, y)).filter(|row| row.live) else {
|
||||
let Some((row, row_y)) = self.row_at(&view, (x, y)).filter(|(row, _)| row.live) else {
|
||||
return Vec::new();
|
||||
};
|
||||
self.focus = Some(Target::Row(row.id));
|
||||
if row.binding().is_some_and(|b| b.reset == Reset::Changed) && at(reset_rect(&cols, row_y))
|
||||
{
|
||||
return self.reset(s, row.id);
|
||||
}
|
||||
// A press on the track sets the value there; on the name, only focuses.
|
||||
let track = (x >= cols.track().0).then(|| cols.slider_at(x));
|
||||
self.activate(s, row, shift, track)
|
||||
@@ -1657,7 +2090,7 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
) -> Vec<Output> {
|
||||
if amount != 0.0 && s.extras.wheel_cycles_settings {
|
||||
let view = self.view(s, size);
|
||||
if let Some(row) = self.row_at(&view, at).filter(|row| row.live)
|
||||
if let Some((row, _)) = self.row_at(&view, at).filter(|(row, _)| row.live)
|
||||
&& let Some(outputs) = self.screen.wheel_step(s, row.id, amount > 0.0)
|
||||
{
|
||||
self.did(&outputs);
|
||||
@@ -1738,6 +2171,14 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
self.did(&[DRAGGED]);
|
||||
return vec![DRAGGED];
|
||||
}
|
||||
// A choice's `<` `>`: Left steps it back and Right on, as a Shift press and a press.
|
||||
if matches!(key, K::ArrowLeft | K::ArrowRight)
|
||||
&& let Some(row) = focused
|
||||
&& row.live
|
||||
&& matches!(row.value, Value::Choice(_))
|
||||
{
|
||||
return self.activate(s, row, key == K::ArrowLeft, None);
|
||||
}
|
||||
if matches!(
|
||||
key,
|
||||
K::ArrowUp | K::ArrowDown | K::ArrowLeft | K::ArrowRight
|
||||
@@ -1771,6 +2212,7 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
Some(row) => self.activate(s, row, shift, None),
|
||||
None => Vec::new(),
|
||||
},
|
||||
Some(Target::Reset(row)) => self.reset(s, row),
|
||||
Some(Target::Undo) => {
|
||||
// Undo goes away with the change it undid: Done has the focus.
|
||||
self.focus = Some(Target::Done);
|
||||
@@ -1907,6 +2349,11 @@ impl<S: Screen> Shell<S> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The names of the rows the list has now.
|
||||
pub(crate) fn row_names(&mut self, s: &Settings) -> Vec<String> {
|
||||
let view = self.view(s, (854.0, 480.0));
|
||||
view.rows.iter().map(|r| r.name.clone()).collect()
|
||||
}
|
||||
/// The captions of the rows the list has now, as the widget tree reads them.
|
||||
pub(crate) fn row_labels(&mut self, s: &Settings) -> Vec<String> {
|
||||
let view = self.view(s, (854.0, 480.0));
|
||||
@@ -2303,6 +2750,43 @@ mod tests {
|
||||
assert_eq!(key(&mut shell, K::Escape), vec![Output::Done]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_and_right_step_a_focused_choice_and_leave_other_rows() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let mut key = |shell: &mut Shell<Probe>, k| shell.key(&mut s, k, None, false, SIZE);
|
||||
shell.set_focus(Some(Target::Row(CHOICE)));
|
||||
// Right is a press, Left a Shift press: the screen's own way back, and the focus stays.
|
||||
assert_eq!(key(&mut shell, K::ArrowRight), vec![CLICK, CHANGED]);
|
||||
assert_eq!(key(&mut shell, K::ArrowLeft), vec![CLICK, CHANGED]);
|
||||
assert_eq!(shell.screen.pressed, [(CHOICE, false), (CHOICE, true)]);
|
||||
assert_eq!(shell.focus, Some(Target::Row(CHOICE)));
|
||||
assert!(shell.changed);
|
||||
// A row that isn't a choice: Left goes to the page list, as before.
|
||||
shell.set_focus(Some(Target::Row(FIRST_FILLER)));
|
||||
assert!(key(&mut shell, K::ArrowLeft).is_empty());
|
||||
assert!(matches!(shell.focus, Some(Target::Page(_))));
|
||||
assert_eq!(shell.screen.pressed.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drawn_choice_tells_the_pad_its_arrows_step_it() {
|
||||
let atlas = atlas();
|
||||
let s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let mut batch = HudBatch::new([1708, 960]);
|
||||
shell.draw(&s, &mut batch, &atlas, (0.0, 0.0), false, None);
|
||||
crate::widgets::end_frame(u64::MAX);
|
||||
let view = shell.view(&s, (batch.width, batch.height));
|
||||
let rect_of = |id| {
|
||||
let mut placed = shell.placed(&view);
|
||||
view.cols.row(placed.find(|(r, _)| r.id == id).unwrap().1)
|
||||
};
|
||||
assert!(crate::widgets::last_steps_value(rect_of(CHOICE)));
|
||||
assert!(!crate::widgets::last_steps_value(rect_of(SLIDER)));
|
||||
assert!(!crate::widgets::last_steps_value(rect_of(FIRST_FILLER)));
|
||||
}
|
||||
|
||||
/// A controller presses with a click, then moves its own focus: only keys show this one.
|
||||
#[test]
|
||||
fn leaving_the_search_field_gives_the_keys_back() {
|
||||
|
||||
@@ -24,12 +24,43 @@ pub struct Textures {
|
||||
pub signed: bool,
|
||||
}
|
||||
|
||||
/// The `getTexture` endpoint's valid URIs from Minecraft services discovery.
|
||||
/// The `getTexture` endpoint's valid URIs in production's services discovery
|
||||
/// document (`MinecraftServicesEnvironment.PROD`).
|
||||
pub const TEXTURE_URIS: [&str; 2] = [
|
||||
"https://textures.minecraft.net/texture/{textureId}",
|
||||
"http://textures.minecraft.net/texture/{textureId}",
|
||||
];
|
||||
|
||||
/// `MinecraftServicesDiscoveryService`'s valid texture URIs: the discovery
|
||||
/// document's `profiles` / `getTexture` `validUris`, which
|
||||
/// `MinecraftServicesSessionService.isAllowedTextureDomain` checks texture URLs
|
||||
/// against. Production's document lists [`TEXTURE_URIS`], so they are used
|
||||
/// without asking it; another environment (`HORIZON_API_DISCOVERY`) is asked
|
||||
/// once per game, and keeps [`TEXTURE_URIS`] when its document lists none or
|
||||
/// can't be fetched.
|
||||
pub fn texture_uris() -> &'static [String] {
|
||||
static URIS: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
|
||||
URIS.get_or_init(|| {
|
||||
crate::auth::discovery_overridden()
|
||||
.then(|| crate::auth::discovery().ok())
|
||||
.flatten()
|
||||
.and_then(|doc| discovery_texture_uris(&doc))
|
||||
.unwrap_or_else(|| TEXTURE_URIS.map(str::to_owned).to_vec())
|
||||
})
|
||||
}
|
||||
|
||||
/// The `profiles` / `getTexture` endpoint's `validUris` in a discovery
|
||||
/// document, when it lists any.
|
||||
fn discovery_texture_uris(doc: &serde_json::Value) -> Option<Vec<String>> {
|
||||
let uris: Vec<String> = doc
|
||||
.pointer("/discovery/profiles/endpoints/getTexture/validUris")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.filter_map(|u| u.as_str().map(str::to_owned))
|
||||
.collect();
|
||||
(!uris.is_empty()).then_some(uris)
|
||||
}
|
||||
|
||||
/// `java.util.Base64.getDecoder().decode`.
|
||||
pub fn base64_decode(text: &str) -> Option<Vec<u8>> {
|
||||
use base64::{
|
||||
@@ -44,7 +75,7 @@ pub fn base64_decode(text: &str) -> Option<Vec<u8>> {
|
||||
|
||||
/// `unpackTextures(property)`: the textures of a `textures` property's
|
||||
/// base64 JSON payload.
|
||||
pub fn unpack(value: &str, signature: Signature, valid_uris: &[&str]) -> Textures {
|
||||
pub fn unpack(value: &str, signature: Signature, valid_uris: &[impl AsRef<str>]) -> Textures {
|
||||
let empty = Textures::default();
|
||||
let Some(json) = base64_decode(value)
|
||||
.map(|b| String::from_utf8_lossy(&b).into_owned())
|
||||
@@ -68,7 +99,7 @@ pub fn unpack(value: &str, signature: Signature, valid_uris: &[&str]) -> Texture
|
||||
};
|
||||
let allowed = valid_uris
|
||||
.iter()
|
||||
.any(|u| url.starts_with(&u.replace("{textureId}", "")));
|
||||
.any(|u| url.starts_with(&u.as_ref().replace("{textureId}", "")));
|
||||
if !allowed {
|
||||
return empty;
|
||||
}
|
||||
@@ -95,7 +126,7 @@ pub fn unpack(value: &str, signature: Signature, valid_uris: &[&str]) -> Texture
|
||||
/// those textures be unsigned (`getInsecureSkinInformation`).
|
||||
pub fn profile_skin_url(profile: &serde_json::Value) -> Option<String> {
|
||||
let (value, signature) = parse_profile(profile).textures?;
|
||||
unpack(&value, signature, &TEXTURE_URIS).skin
|
||||
unpack(&value, signature, texture_uris()).skin
|
||||
}
|
||||
|
||||
/// The textures of a Minecraft services `/minecraft/profile` answer: the
|
||||
@@ -114,7 +145,7 @@ pub fn services_profile_textures(profile: &serde_json::Value) -> Textures {
|
||||
.and_then(|t| t.get("url"))
|
||||
.and_then(|u| u.as_str())
|
||||
.filter(|url| {
|
||||
TEXTURE_URIS
|
||||
texture_uris()
|
||||
.iter()
|
||||
.any(|u| url.starts_with(&u.replace("{textureId}", "")))
|
||||
})
|
||||
@@ -127,7 +158,7 @@ pub fn services_profile_textures(profile: &serde_json::Value) -> Textures {
|
||||
.and_then(|t| t.get("url"))
|
||||
.and_then(|u| u.as_str())
|
||||
.filter(|url| {
|
||||
TEXTURE_URIS
|
||||
texture_uris()
|
||||
.iter()
|
||||
.any(|u| url.starts_with(&u.replace("{textureId}", "")))
|
||||
})
|
||||
@@ -155,7 +186,7 @@ pub fn profile_skin_url_nbt(profile: &horizon_world::nbt::Tag) -> Option<String>
|
||||
.then(|| nbt_text(p.get("value")))
|
||||
.flatten()
|
||||
})?;
|
||||
unpack(&value, Signature::Unsigned, &TEXTURE_URIS).skin
|
||||
unpack(&value, Signature::Unsigned, texture_uris()).skin
|
||||
}
|
||||
|
||||
/// `ResolvableProfile.skinPatch().body()` of a skull's `profile` NBT: the
|
||||
@@ -478,7 +509,7 @@ struct HeadProfile {
|
||||
fn prepare_head(profile: &serde_json::Value) -> HeadProfile {
|
||||
let info = parse_profile(profile);
|
||||
let skin = info.textures.as_ref().and_then(|(value, signature)| {
|
||||
let url = unpack(value, *signature, &TEXTURE_URIS).skin?;
|
||||
let url = unpack(value, *signature, texture_uris()).skin?;
|
||||
let location = skin_location(&texture_hash(&url)?);
|
||||
Some((url, location))
|
||||
});
|
||||
@@ -1362,6 +1393,33 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `MinecraftServicesDiscoveryService`: another environment's document
|
||||
/// names its own texture host; one that lists none names nothing.
|
||||
#[test]
|
||||
fn texture_uris_come_from_the_discovery_document() {
|
||||
use super::{Signature, discovery_texture_uris, unpack};
|
||||
let doc = serde_json::json!({"discovery": {"profiles": {"endpoints": {"getTexture": {
|
||||
"validUris": ["https://skins.example.org/t/{textureId}"]
|
||||
}}}}});
|
||||
let uris = discovery_texture_uris(&doc).unwrap();
|
||||
assert_eq!(uris, ["https://skins.example.org/t/{textureId}"]);
|
||||
// {"textures":{"SKIN":{"url":"https://skins.example.org/t/abc"}}}
|
||||
let ours =
|
||||
"eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHBzOi8vc2tpbnMuZXhhbXBsZS5vcmcvdC9hYmMifX19";
|
||||
assert_eq!(
|
||||
unpack(ours, Signature::Signed, &uris).skin.as_deref(),
|
||||
Some("https://skins.example.org/t/abc")
|
||||
);
|
||||
assert_eq!(
|
||||
unpack(ours, Signature::Signed, &super::TEXTURE_URIS).skin,
|
||||
None
|
||||
);
|
||||
let none = serde_json::json!({"discovery": {"profiles": {"endpoints": {}}}});
|
||||
assert_eq!(discovery_texture_uris(&none), None);
|
||||
let empty = serde_json::json!({"discovery": {"profiles": {"endpoints": {"getTexture": {"validUris": []}}}}});
|
||||
assert_eq!(discovery_texture_uris(&empty), None);
|
||||
}
|
||||
|
||||
/// `MinecraftProfileTexture.getMetadata("model")`: `slim` marks a slim
|
||||
/// skin; no metadata is the wide (classic) model.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,860 @@
|
||||
//! Vanilla's friends machinery without its screens: authlib's
|
||||
//! `MinecraftServicesFriendsService` (`getFriends`, `updateFriends`,
|
||||
//! `updateAttributes`, `updatePresence`), `RemoteFriendListUpdateHandler`'s worker and
|
||||
//! state, `PlayerSocialManager`'s flags, and `PresenceHandler`'s posting.
|
||||
//!
|
||||
//! `title::poll_account` starts this the way `Minecraft`'s constructor does: the
|
||||
//! account's `friendsPreferences` (`UserFlag.FRIENDS_ENABLED`,
|
||||
//! `UserFlag.ACCEPT_FRIEND_INVITES`) decide whether the worker reads the lists. No
|
||||
//! screen shows them yet; the overlay and the Online options are later parts.
|
||||
//!
|
||||
//! The requests keep authlib's cache rules: `If-None-Match` with the last `ETag`, a 304
|
||||
//! or an empty answer keeps the cached lists, `Retry-After` sets the poll interval, and
|
||||
//! `getFriendData` waits out a ten-second cooldown between reads. The presence statuses
|
||||
//! are `PresenceHandler.getPresenceStatus`'s, supplied by the client each tick.
|
||||
use crate::auth::{self, AuthSession};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
sync::{
|
||||
Arc, Mutex, OnceLock,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
/// authlib's `FriendsService.ResultCode`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ResultCode {
|
||||
Success,
|
||||
Error,
|
||||
ServiceNotAvailable,
|
||||
TooManyRequests,
|
||||
Forbidden,
|
||||
UpgradeNeeded,
|
||||
ConnectionIssue,
|
||||
TemporaryUnavailable,
|
||||
UnknownProfile,
|
||||
Unauthorized,
|
||||
GenericError,
|
||||
}
|
||||
|
||||
/// `FriendDto`: one friend or one pending request.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Friend {
|
||||
pub id: u128,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// authlib's `FriendData`: the three lists the service answers.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct FriendData {
|
||||
pub friends: Vec<Friend>,
|
||||
pub incoming: Vec<Friend>,
|
||||
pub outgoing: Vec<Friend>,
|
||||
}
|
||||
|
||||
/// authlib's `PresenceStatus`, as `updatePresence` stores it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PresenceStatus {
|
||||
Online,
|
||||
PlayingOffline,
|
||||
PlayingRealms,
|
||||
PlayingServer,
|
||||
PlayingHostedServer,
|
||||
Offline,
|
||||
}
|
||||
impl PresenceStatus {
|
||||
/// `PresenceStatus.name()`: the string `updatePresence` sends and
|
||||
/// `PresenceStatusDto.status` carries.
|
||||
fn wire(self) -> &'static str {
|
||||
match self {
|
||||
Self::Online => "ONLINE",
|
||||
Self::PlayingOffline => "PLAYING_OFFLINE",
|
||||
Self::PlayingRealms => "PLAYING_REALMS",
|
||||
Self::PlayingServer => "PLAYING_SERVER",
|
||||
Self::PlayingHostedServer => "PLAYING_HOSTED_SERVER",
|
||||
Self::Offline => "OFFLINE",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `PresenceStatusDto`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Presence {
|
||||
pub id: u128,
|
||||
pub status: PresenceStatus,
|
||||
}
|
||||
|
||||
/// `RemoteFriendListUpdateHandler.State`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum State {
|
||||
Loading,
|
||||
UpgradeNeeded,
|
||||
ConnectionIssue,
|
||||
UserMayLackActiveProfile,
|
||||
Unauthorized,
|
||||
TemporaryUnavailable,
|
||||
GenericError,
|
||||
Success,
|
||||
}
|
||||
impl State {
|
||||
/// `RemoteFriendListUpdateHandler.mapResultCodeToState`.
|
||||
fn of(code: ResultCode) -> Self {
|
||||
match code {
|
||||
ResultCode::Success => Self::Success,
|
||||
ResultCode::UpgradeNeeded => Self::UpgradeNeeded,
|
||||
ResultCode::ConnectionIssue => Self::ConnectionIssue,
|
||||
ResultCode::UnknownProfile => Self::UserMayLackActiveProfile,
|
||||
ResultCode::Unauthorized => Self::Unauthorized,
|
||||
ResultCode::TemporaryUnavailable
|
||||
| ResultCode::Forbidden
|
||||
| ResultCode::ServiceNotAvailable
|
||||
| ResultCode::TooManyRequests => Self::TemporaryUnavailable,
|
||||
ResultCode::Error | ResultCode::GenericError => Self::GenericError,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Options.sharePresence` (`PresenceSharing`): how much of the player the service sees.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SharePresence {
|
||||
None,
|
||||
Limited,
|
||||
All,
|
||||
}
|
||||
|
||||
impl SharePresence {
|
||||
/// `options.txt`'s `sharePresence` (0 none, 1 limited, 2 all; `Options` defaults to
|
||||
/// all).
|
||||
pub fn of(setting: u8) -> Self {
|
||||
match setting {
|
||||
0 => Self::None,
|
||||
1 => Self::Limited,
|
||||
_ => Self::All,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `MinecraftServicesFriendsService`: the HTTP client and its caches.
|
||||
#[derive(Default)]
|
||||
struct Service {
|
||||
friends_etag: Option<String>,
|
||||
presence_etag: Option<String>,
|
||||
friends_cache: FriendData,
|
||||
presence_cache: Vec<Presence>,
|
||||
/// `getFriendsPollInterval`/`getPresencePollInterval`: the service's `Retry-After`.
|
||||
friends_poll: Option<Duration>,
|
||||
presence_poll: Option<Duration>,
|
||||
/// `requestCooldown`: ten seconds between `getFriendData` requests.
|
||||
cooldown: Option<Instant>,
|
||||
/// `requestPending`: one list request at a time.
|
||||
pending: bool,
|
||||
}
|
||||
|
||||
impl Service {
|
||||
/// `canMakeRequest`.
|
||||
fn can_make_request(&self) -> bool {
|
||||
self.cooldown.is_none_or(|at| Instant::now() > at)
|
||||
}
|
||||
}
|
||||
|
||||
/// `RemoteFriendListUpdateHandler`.
|
||||
struct Handler {
|
||||
enabled: bool,
|
||||
state: State,
|
||||
latest: FriendData,
|
||||
/// `lastUpdateNanos`: a read at that moment is due; `None` reads at once.
|
||||
last_update: Option<Instant>,
|
||||
/// A forced read (`forceUpdate`) is due, whatever the interval.
|
||||
forced: bool,
|
||||
/// `getUpdateIntervalNanos`'s foreground: the friends overlay is open.
|
||||
foreground: bool,
|
||||
}
|
||||
|
||||
impl Default for Handler {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
state: State::Loading,
|
||||
latest: FriendData::default(),
|
||||
last_update: None,
|
||||
forced: false,
|
||||
foreground: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler {
|
||||
/// `getUpdateIntervalNanos`: the service's interval (a minute when it named none),
|
||||
/// five times as long in the background.
|
||||
fn interval(&self, service: &Service) -> Duration {
|
||||
let foreground = service.friends_poll.unwrap_or(Duration::from_secs(60));
|
||||
if self.foreground {
|
||||
foreground
|
||||
} else {
|
||||
foreground * 5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `PresenceHandler`.
|
||||
struct PresenceHandler {
|
||||
latest: Vec<Presence>,
|
||||
last_post: Instant,
|
||||
/// `updatePresence`: post at the next tick.
|
||||
update: bool,
|
||||
/// `PresenceHandler.getPresenceStatus`'s answer, from the client's state.
|
||||
playing: PresenceStatus,
|
||||
}
|
||||
|
||||
impl Default for PresenceHandler {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
latest: Vec::new(),
|
||||
// `lastPresencePost = Instant.now()`: the first post waits an interval.
|
||||
last_post: Instant::now(),
|
||||
// Its constructor sets `updatePresence = true`.
|
||||
update: true,
|
||||
playing: PresenceStatus::Online,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `PlayerSocialManager`'s flags.
|
||||
#[derive(Default)]
|
||||
struct Manager {
|
||||
friend_list_enabled: bool,
|
||||
allow_friend_requests: bool,
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
service: Service,
|
||||
handler: Handler,
|
||||
presence: PresenceHandler,
|
||||
manager: Manager,
|
||||
share: SharePresence,
|
||||
started: bool,
|
||||
}
|
||||
|
||||
impl Default for Inner {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
service: Service::default(),
|
||||
handler: Handler::default(),
|
||||
presence: PresenceHandler::default(),
|
||||
manager: Manager::default(),
|
||||
share: SharePresence::All,
|
||||
started: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inner() -> &'static Arc<Mutex<Inner>> {
|
||||
static INNER: OnceLock<Arc<Mutex<Inner>>> = OnceLock::new();
|
||||
INNER.get_or_init(|| Arc::new(Mutex::new(Inner::default())))
|
||||
}
|
||||
|
||||
fn lock() -> std::sync::MutexGuard<'static, Inner> {
|
||||
inner().lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// `Minecraft`'s constructor: make the social manager, whose `friendListEnabled` comes
|
||||
/// from the account's `friendsPreferences`, and start the updater when it is on.
|
||||
/// Called once; later calls leave the running manager alone.
|
||||
pub fn init(friends_enabled: bool, allow_requests: bool) {
|
||||
{
|
||||
let mut inner = lock();
|
||||
if inner.started {
|
||||
return;
|
||||
}
|
||||
inner.started = true;
|
||||
inner.manager.friend_list_enabled = friends_enabled;
|
||||
inner.manager.allow_friend_requests = allow_requests;
|
||||
inner.handler.enabled = friends_enabled;
|
||||
}
|
||||
spawn_updater();
|
||||
}
|
||||
|
||||
/// `PlayerSocialManager.setFriendListEnabled`: the Online options switch.
|
||||
pub fn set_friend_list_enabled(enabled: bool) {
|
||||
let mut inner = lock();
|
||||
inner.manager.friend_list_enabled = enabled;
|
||||
inner.handler.enabled = enabled;
|
||||
if enabled {
|
||||
inner.handler.last_update = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// `PlayerSocialManager.isFriendListEnabled`.
|
||||
pub fn is_friend_list_enabled() -> bool {
|
||||
lock().manager.friend_list_enabled
|
||||
}
|
||||
|
||||
/// `PlayerSocialManager.isAllowFriendRequests`.
|
||||
pub fn allow_friend_requests() -> bool {
|
||||
lock().manager.allow_friend_requests
|
||||
}
|
||||
|
||||
/// `PlayerSocialManager.setAllowFriendRequests`.
|
||||
pub fn set_allow_friend_requests(allow: bool) {
|
||||
lock().manager.allow_friend_requests = allow;
|
||||
}
|
||||
|
||||
/// `PlayerSocialManager.getFriends`.
|
||||
pub fn friends() -> Vec<Friend> {
|
||||
lock().handler.latest.friends.clone()
|
||||
}
|
||||
|
||||
/// `PlayerSocialManager.getIncomingRequests`.
|
||||
pub fn incoming_requests() -> Vec<Friend> {
|
||||
lock().handler.latest.incoming.clone()
|
||||
}
|
||||
|
||||
/// `PlayerSocialManager.getOutgoingRequests`.
|
||||
pub fn outgoing_requests() -> Vec<Friend> {
|
||||
lock().handler.latest.outgoing.clone()
|
||||
}
|
||||
|
||||
/// `PlayerSocialManager.getFriendListState`.
|
||||
pub fn friend_list_state() -> State {
|
||||
lock().handler.state
|
||||
}
|
||||
|
||||
/// `PresenceHandler.getLatestPresence`.
|
||||
pub fn latest_presence() -> Vec<Presence> {
|
||||
lock().presence.latest.clone()
|
||||
}
|
||||
|
||||
/// `RemoteFriendListUpdateHandler`'s foreground interval: the friends overlay is open.
|
||||
pub fn set_foreground(open: bool) {
|
||||
lock().handler.foreground = open;
|
||||
}
|
||||
|
||||
/// `Options.sharePresence`: what presence the handler posts.
|
||||
pub fn set_share_presence(share: SharePresence) {
|
||||
lock().share = share;
|
||||
}
|
||||
|
||||
/// `PresenceHandler.getPresenceStatus`, supplied by the client from its world state.
|
||||
pub fn set_presence_status(status: PresenceStatus) {
|
||||
lock().presence.playing = status;
|
||||
}
|
||||
|
||||
/// `Minecraft.runTick`: `PresenceHandler.tick`, once per client tick.
|
||||
pub fn tick() {
|
||||
let due = {
|
||||
let inner = lock();
|
||||
if !inner.manager.friend_list_enabled || inner.handler.latest.friends.is_empty() {
|
||||
return;
|
||||
}
|
||||
let interval = inner
|
||||
.service
|
||||
.presence_poll
|
||||
.unwrap_or(Duration::from_secs(60));
|
||||
let since = inner.presence.last_post.elapsed();
|
||||
(inner.presence.update && since >= interval) || since >= interval * 5
|
||||
};
|
||||
if due {
|
||||
update_presence();
|
||||
}
|
||||
}
|
||||
|
||||
/// `PresenceHandler.updatePresence`.
|
||||
fn update_presence() {
|
||||
let status = {
|
||||
let mut inner = lock();
|
||||
inner.presence.update = false;
|
||||
inner.presence.last_post = Instant::now();
|
||||
match inner.share {
|
||||
SharePresence::None => PresenceStatus::Offline,
|
||||
SharePresence::Limited => PresenceStatus::Online,
|
||||
SharePresence::All => inner.presence.playing,
|
||||
}
|
||||
};
|
||||
let _ = thread::Builder::new()
|
||||
.name("Friends presence".into())
|
||||
.spawn(move || {
|
||||
if let Some(presence) = post_presence(status) {
|
||||
lock().presence.latest = presence;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// `PresenceHandler.tryUpdatePresence`: the world was entered (or the overlay opened).
|
||||
pub fn try_update_presence() {
|
||||
lock().presence.update = true;
|
||||
}
|
||||
|
||||
/// `PresenceHandler.sendOfflinePresence`: the game is closing.
|
||||
pub fn send_offline_presence() {
|
||||
static SENT: AtomicBool = AtomicBool::new(false);
|
||||
{
|
||||
let inner = lock();
|
||||
if !inner.manager.friend_list_enabled || inner.handler.latest.friends.is_empty() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// `Minecraft.close` happens once; a second quit path must not post twice.
|
||||
if SENT.swap(true, Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let _ = thread::Builder::new()
|
||||
.name("Friends offline".into())
|
||||
.spawn(|| {
|
||||
let _ = post_presence(PresenceStatus::Offline);
|
||||
});
|
||||
}
|
||||
|
||||
/// `FriendsService.sendFriendRequest(name)`.
|
||||
pub fn send_friend_request(name: &str) -> ResultCode {
|
||||
friend_action(json!({"name": name, "updateType": "ADD"}))
|
||||
}
|
||||
|
||||
/// `FriendsService.removeFriend`/`declineIncomingFriendRequest`/
|
||||
/// `revokeOutgoingFriendRequest` (`updateType` REMOVE).
|
||||
pub fn remove_friend(id: u128) -> ResultCode {
|
||||
friend_action(json!({"profileId": uuid(id), "updateType": "REMOVE"}))
|
||||
}
|
||||
|
||||
/// `FriendsService.acceptIncomingFriendRequest`: ADD by profile id.
|
||||
pub fn accept_incoming_friend_request(id: u128) -> ResultCode {
|
||||
friend_action(json!({"profileId": uuid(id), "updateType": "ADD"}))
|
||||
}
|
||||
|
||||
/// `FriendsService.updateFriendSettings`: the account's flags, server-side.
|
||||
pub fn update_friend_settings(friends_enabled: bool, allow_requests: bool) -> ResultCode {
|
||||
let Some(session) = session() else {
|
||||
return ResultCode::Unauthorized;
|
||||
};
|
||||
let body = json!({
|
||||
"friendsPreferences": {
|
||||
"friends": toggle(friends_enabled),
|
||||
"acceptInvites": toggle(allow_requests),
|
||||
}
|
||||
});
|
||||
let Some(url) = url("updateAttributes") else {
|
||||
return ResultCode::ServiceNotAvailable;
|
||||
};
|
||||
match send_json(&session, "POST", &url, &body.to_string(), None) {
|
||||
Ok(_) => ResultCode::Success,
|
||||
Err(failure) => failure.code,
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle(on: bool) -> &'static str {
|
||||
if on { "ENABLED" } else { "DISABLED" }
|
||||
}
|
||||
|
||||
/// `MinecraftServicesFriendsService.putFriendAction`: the answer already is the new
|
||||
/// lists, as authlib caches it, and the handler reads again.
|
||||
fn friend_action(body: Value) -> ResultCode {
|
||||
let Some(session) = session() else {
|
||||
return ResultCode::Unauthorized;
|
||||
};
|
||||
let Some(url) = url("updateFriends") else {
|
||||
return ResultCode::ServiceNotAvailable;
|
||||
};
|
||||
let answer = match send_json(&session, "PUT", &url, &body.to_string(), None) {
|
||||
Ok(answer) => answer,
|
||||
Err(failure) => return failure.code,
|
||||
};
|
||||
let code = if answer.body.is_empty() {
|
||||
ResultCode::Success
|
||||
} else {
|
||||
match parse_friends(&answer.body) {
|
||||
Some(data) => {
|
||||
let mut inner = lock();
|
||||
inner.service.friends_cache = data;
|
||||
inner.service.friends_etag = None;
|
||||
inner.service.cooldown = Some(Instant::now() + Duration::from_secs(10));
|
||||
ResultCode::Success
|
||||
}
|
||||
None => ResultCode::Error,
|
||||
}
|
||||
};
|
||||
if code == ResultCode::Success {
|
||||
// `PlayerSocialManager.runAction` forces a read after a successful action.
|
||||
lock().handler.forced = true;
|
||||
}
|
||||
code
|
||||
}
|
||||
|
||||
/// `RemoteFriendListUpdateHandler.start`'s worker: one read per interval while the list
|
||||
/// is enabled, the first one as soon as it starts.
|
||||
fn spawn_updater() {
|
||||
static STARTED: AtomicBool = AtomicBool::new(false);
|
||||
if STARTED.swap(true, Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let _ = thread::Builder::new()
|
||||
.name("Friends List".into())
|
||||
.spawn(|| {
|
||||
loop {
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
let due = {
|
||||
let inner = lock();
|
||||
if !inner.handler.enabled {
|
||||
continue;
|
||||
}
|
||||
inner.handler.forced
|
||||
|| inner
|
||||
.handler
|
||||
.last_update
|
||||
.is_none_or(|at| at.elapsed() >= inner.handler.interval(&inner.service))
|
||||
};
|
||||
if due {
|
||||
update_friend_data();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// `RemoteFriendListUpdateHandler.runUpdateFriendDataInternal`.
|
||||
fn update_friend_data() {
|
||||
let etag = {
|
||||
let mut inner = lock();
|
||||
if inner.service.pending {
|
||||
// Another read is in flight; its answer is the cache's.
|
||||
return;
|
||||
}
|
||||
if !inner.service.can_make_request() {
|
||||
inner.handler.forced = false;
|
||||
inner.handler.state = State::Success;
|
||||
inner.handler.latest = inner.service.friends_cache.clone();
|
||||
inner.handler.last_update = Some(Instant::now());
|
||||
return;
|
||||
}
|
||||
inner.service.pending = true;
|
||||
inner.service.cooldown = Some(Instant::now() + Duration::from_secs(10));
|
||||
inner.service.friends_etag.clone()
|
||||
};
|
||||
log::debug!("Performing Friends List update");
|
||||
let result = match session() {
|
||||
Some(session) => fetch_friends(&session, etag.as_deref()),
|
||||
None => Err(Failure {
|
||||
code: ResultCode::Unauthorized,
|
||||
}),
|
||||
};
|
||||
let mut inner = lock();
|
||||
inner.service.pending = false;
|
||||
inner.handler.forced = false;
|
||||
inner.handler.last_update = Some(Instant::now());
|
||||
match result {
|
||||
Ok(answer) => {
|
||||
if let Some(retry) = answer.retry_after {
|
||||
inner.service.friends_poll = Some(retry);
|
||||
}
|
||||
if let Some(etag) = answer.etag {
|
||||
inner.service.friends_etag = Some(etag);
|
||||
}
|
||||
if answer.status != 304 && !answer.body.is_empty() {
|
||||
match parse_friends(&answer.body) {
|
||||
Some(data) => inner.service.friends_cache = data,
|
||||
None => {
|
||||
// authlib's parse failure is an ERROR result: the handler keeps
|
||||
// the cached lists and shows the generic error.
|
||||
inner.handler.latest = inner.service.friends_cache.clone();
|
||||
inner.handler.state = State::GenericError;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
inner.handler.latest = inner.service.friends_cache.clone();
|
||||
inner.handler.state = State::Success;
|
||||
}
|
||||
Err(failure) => {
|
||||
inner.handler.latest = inner.service.friends_cache.clone();
|
||||
inner.handler.state = State::of(failure.code);
|
||||
log::warn!(
|
||||
"Friends List update failed with result code: {:?}",
|
||||
failure.code
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `MinecraftServicesFriendsService.presence`: post one status, cache the answer.
|
||||
/// `None` means the answer said nothing new (304, an empty body or a failure); the
|
||||
/// handler keeps its last presence then.
|
||||
fn post_presence(status: PresenceStatus) -> Option<Vec<Presence>> {
|
||||
let session = session()?;
|
||||
let url = url("updatePresence")?;
|
||||
let body = json!({"status": status.wire()}).to_string();
|
||||
let etag = lock().service.presence_etag.clone();
|
||||
let answer = send_json(&session, "POST", &url, &body, etag.as_deref()).ok()?;
|
||||
let mut inner = lock();
|
||||
if let Some(retry) = answer.retry_after {
|
||||
inner.service.presence_poll = Some(retry);
|
||||
}
|
||||
if let Some(etag) = answer.etag {
|
||||
inner.service.presence_etag = Some(etag);
|
||||
}
|
||||
if answer.status == 304 || answer.body.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// `PresenceResponse`: a missing `presence` field is an empty list, replacing the
|
||||
// cached one; malformed JSON keeps the cache, as authlib's exception path does.
|
||||
if let Ok(value) = serde_json::from_str::<Value>(&answer.body) {
|
||||
let presence = presence_of(&value);
|
||||
inner.service.presence_cache = presence.clone();
|
||||
return Some(presence);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `MinecraftServicesFriendsService.getFriendData`'s request.
|
||||
fn fetch_friends(session: &AuthSession, etag: Option<&str>) -> Result<Answer, Failure> {
|
||||
let url = url("getFriends").ok_or(Failure {
|
||||
code: ResultCode::ServiceNotAvailable,
|
||||
})?;
|
||||
send_json(session, "GET", &url, "", etag)
|
||||
}
|
||||
|
||||
/// One HTTP answer: its status, cache headers and body.
|
||||
struct Answer {
|
||||
status: u16,
|
||||
etag: Option<String>,
|
||||
retry_after: Option<Duration>,
|
||||
body: String,
|
||||
}
|
||||
|
||||
struct Failure {
|
||||
code: ResultCode,
|
||||
}
|
||||
|
||||
/// The discovery document's `player` service endpoint.
|
||||
fn session() -> Option<Arc<AuthSession>> {
|
||||
auth::current().filter(|s| !s.expired())
|
||||
}
|
||||
|
||||
fn uuid(id: u128) -> String {
|
||||
let hex = format!("{id:032x}");
|
||||
format!(
|
||||
"{}-{}-{}-{}-{}",
|
||||
&hex[0..8],
|
||||
&hex[8..12],
|
||||
&hex[12..16],
|
||||
&hex[16..20],
|
||||
&hex[20..32]
|
||||
)
|
||||
}
|
||||
|
||||
fn url(name: &str) -> Option<String> {
|
||||
let doc = auth::discovery().ok()?;
|
||||
auth::endpoint(&doc, "player", name)
|
||||
}
|
||||
|
||||
/// authlib's `MinecraftClient`: the bearer token, `If-None-Match` when a cache has an
|
||||
/// `ETag`, and `Retry-After` back to the caller.
|
||||
fn send_json(
|
||||
session: &AuthSession,
|
||||
method: &str,
|
||||
url: &str,
|
||||
body: &str,
|
||||
etag: Option<&str>,
|
||||
) -> Result<Answer, Failure> {
|
||||
let bearer = format!("Bearer {}", session.access_token());
|
||||
let agent = auth::agent();
|
||||
let mut request = match method {
|
||||
"GET" => agent.get(url),
|
||||
"POST" => agent.post(url),
|
||||
"PUT" => agent.put(url),
|
||||
_ => {
|
||||
return Err(Failure {
|
||||
code: ResultCode::Error,
|
||||
});
|
||||
}
|
||||
};
|
||||
request = request.set("Authorization", &bearer);
|
||||
if let Some(etag) = etag {
|
||||
request = request.set("If-None-Match", etag);
|
||||
}
|
||||
let response = if method == "GET" {
|
||||
request.call()
|
||||
} else {
|
||||
request
|
||||
.set("Content-Type", "application/json; charset=utf-8")
|
||||
.send_string(body)
|
||||
}
|
||||
.map_err(http_error)?;
|
||||
let status = response.status();
|
||||
let etag = response.header("ETag").map(str::to_owned);
|
||||
let retry_after = response.header("Retry-After").and_then(parse_retry_after);
|
||||
if status == 304 {
|
||||
return Ok(Answer {
|
||||
status,
|
||||
etag,
|
||||
retry_after,
|
||||
body: String::new(),
|
||||
});
|
||||
}
|
||||
let body = response.into_string().map_err(|_| Failure {
|
||||
code: ResultCode::Error,
|
||||
})?;
|
||||
Ok(Answer {
|
||||
status,
|
||||
etag,
|
||||
retry_after,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
/// authlib's `MinecraftClient.parseRetryAfter`: whole seconds above zero.
|
||||
fn parse_retry_after(value: &str) -> Option<Duration> {
|
||||
value
|
||||
.trim()
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.filter(|seconds| *seconds > 0)
|
||||
.map(Duration::from_secs)
|
||||
}
|
||||
|
||||
/// `MinecraftServicesFriendsService.handleHttpError`.
|
||||
fn http_error(error: ureq::Error) -> Failure {
|
||||
let code = match error {
|
||||
ureq::Error::Status(status, _) => match status {
|
||||
429 => ResultCode::TooManyRequests,
|
||||
500.. => ResultCode::ServiceNotAvailable,
|
||||
403 => ResultCode::Forbidden,
|
||||
400 => ResultCode::UnknownProfile,
|
||||
401 => ResultCode::Unauthorized,
|
||||
_ => ResultCode::Error,
|
||||
},
|
||||
ureq::Error::Transport(_) => ResultCode::ConnectionIssue,
|
||||
};
|
||||
Failure { code }
|
||||
}
|
||||
|
||||
/// authlib's `FriendsListResponse` (`getFriends`/`updateFriends` body).
|
||||
pub fn parse_friends(body: &str) -> Option<FriendData> {
|
||||
let v: Value = serde_json::from_str(body).ok()?;
|
||||
Some(FriendData {
|
||||
friends: parse_friend_list(v.get("friends")),
|
||||
incoming: parse_friend_list(v.get("incomingRequests")),
|
||||
outgoing: parse_friend_list(v.get("outgoingRequests")),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_friend_list(value: Option<&Value>) -> Vec<Friend> {
|
||||
value
|
||||
.and_then(|v| v.as_array())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|friend| {
|
||||
Some(Friend {
|
||||
id: auth::parse_uuid(friend.get("profileId")?.as_str()?)?,
|
||||
name: friend.get("name")?.as_str()?.to_owned(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// authlib's `PresenceResponse`.
|
||||
pub fn parse_presence(body: &str) -> Option<Vec<Presence>> {
|
||||
let v: Value = serde_json::from_str(body).ok()?;
|
||||
Some(presence_of(&v))
|
||||
}
|
||||
|
||||
/// `PresenceResponse.presence`: a missing list reads as empty, as Gson's null does.
|
||||
fn presence_of(v: &Value) -> Vec<Presence> {
|
||||
v.get("presence")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|entry| {
|
||||
let status = entry.get("status")?.as_str()?;
|
||||
let status = match status {
|
||||
"ONLINE" => PresenceStatus::Online,
|
||||
"PLAYING_OFFLINE" => PresenceStatus::PlayingOffline,
|
||||
"PLAYING_REALMS" => PresenceStatus::PlayingRealms,
|
||||
"PLAYING_SERVER" => PresenceStatus::PlayingServer,
|
||||
"PLAYING_HOSTED_SERVER" => PresenceStatus::PlayingHostedServer,
|
||||
_ => PresenceStatus::Offline,
|
||||
};
|
||||
Some(Presence {
|
||||
id: auth::parse_uuid(entry.get("profileId")?.as_str()?)?,
|
||||
status,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_friends_answer_reads_its_three_lists() {
|
||||
let data = parse_friends(
|
||||
r#"{"friends":[{"profileId":"0123456789abcdef0123456789abcdef","name":"Alex"}],
|
||||
"incomingRequests":[],"outgoingRequests":[{"profileId":"fedcba9876543210fedcba9876543210","name":"Steve"}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
data.friends,
|
||||
vec![Friend {
|
||||
id: 0x0123_4567_89ab_cdef_0123_4567_89ab_cdef,
|
||||
name: "Alex".into()
|
||||
}]
|
||||
);
|
||||
assert!(data.incoming.is_empty());
|
||||
assert_eq!(data.outgoing[0].name, "Steve");
|
||||
// The service's answer to an account with no friends, as the live check
|
||||
// captured it: the three lists plus an `empty` flag the parser ignores.
|
||||
let empty = parse_friends(
|
||||
r#"{"empty":true,"friends":[],"incomingRequests":[],"outgoingRequests":[]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(empty, FriendData::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_presence_answer_reads_each_status() {
|
||||
let presence = parse_presence(
|
||||
r#"{"presence":[
|
||||
{"profileId":"0123456789abcdef0123456789abcdef","pmid":"00000000000000000000000000000000","status":"PLAYING_REALMS","lastUpdated":"2026-11-08T12:00:00Z"},
|
||||
{"profileId":"fedcba9876543210fedcba9876543210","pmid":"00000000000000000000000000000000","status":"OFFLINE","lastUpdated":"2026-11-08T12:00:00Z"}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(presence[0].status, PresenceStatus::PlayingRealms);
|
||||
assert_eq!(presence[1].status, PresenceStatus::Offline);
|
||||
// `PresenceResponse` without its list is empty, not missing.
|
||||
assert_eq!(parse_presence("{}"), Some(Vec::new()));
|
||||
assert_eq!(parse_presence("not json"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_codes_map_to_the_handlers_states() {
|
||||
assert_eq!(State::of(ResultCode::Success), State::Success);
|
||||
assert_eq!(
|
||||
State::of(ResultCode::TooManyRequests),
|
||||
State::TemporaryUnavailable
|
||||
);
|
||||
assert_eq!(
|
||||
State::of(ResultCode::UnknownProfile),
|
||||
State::UserMayLackActiveProfile
|
||||
);
|
||||
assert_eq!(State::of(ResultCode::UpgradeNeeded), State::UpgradeNeeded);
|
||||
assert_eq!(
|
||||
State::of(ResultCode::ConnectionIssue),
|
||||
State::ConnectionIssue
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_share_presence_option_maps_to_its_levels() {
|
||||
assert_eq!(SharePresence::of(0), SharePresence::None);
|
||||
assert_eq!(SharePresence::of(1), SharePresence::Limited);
|
||||
assert_eq!(SharePresence::of(2), SharePresence::All);
|
||||
assert_eq!(SharePresence::of(9), SharePresence::All);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_after_reads_whole_positive_seconds() {
|
||||
assert_eq!(parse_retry_after("30"), Some(Duration::from_secs(30)));
|
||||
assert_eq!(parse_retry_after(" 5 "), Some(Duration::from_secs(5)));
|
||||
assert_eq!(parse_retry_after("0"), None);
|
||||
assert_eq!(parse_retry_after("later"), None);
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,11 @@
|
||||
//! game fills the list when the page opens (`filled`), takes the choice, and says why a pick
|
||||
//! can't play (`refused`). Built from vanilla's widgets.
|
||||
use crate::{
|
||||
gamepad::{Button as PadButton, GlyphStyle},
|
||||
gamepad::MenuJob,
|
||||
graphics_style,
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
lang, pad_glyph,
|
||||
lang,
|
||||
pad_glyph::{self, Glyph, Glyphs},
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
@@ -243,7 +244,7 @@ impl SplitProfiles {
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
in_world: bool,
|
||||
style: GlyphStyle,
|
||||
glyphs: Glyphs,
|
||||
) {
|
||||
let (w, h) = (batch.width.ceil(), batch.height.ceil());
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
|
||||
@@ -306,7 +307,7 @@ impl SplitProfiles {
|
||||
None => tr("horizon.pages.split_profiles", "Who's playing?"),
|
||||
};
|
||||
graphics_style::title(batch, atlas, &Text::literal(title));
|
||||
self.draw_bumpers(batch, atlas, style);
|
||||
self.draw_bumpers(batch, atlas, glyphs);
|
||||
// Under Cancel: where offline profiles live.
|
||||
let y = buttons.last().map_or(0.0, |b| b.0[1] + 30.0);
|
||||
line(
|
||||
@@ -400,7 +401,7 @@ impl SplitProfiles {
|
||||
|
||||
/// Under the title: the bumpers as keys, as the prompts over a player's view draw them (the
|
||||
/// controller's own symbols, in its colours), then where they put the view.
|
||||
fn draw_bumpers(&self, batch: &mut HudBatch, atlas: &HudAtlas, style: GlyphStyle) {
|
||||
fn draw_bumpers(&self, batch: &mut HudBatch, atlas: &HudAtlas, glyphs: Glyphs) {
|
||||
let width = |text: &str| HudBatch::text_width(atlas, text).ceil();
|
||||
let words = match &self.place {
|
||||
Some(place) => tr(
|
||||
@@ -410,14 +411,14 @@ impl SplitProfiles {
|
||||
.replacen("%s", place, 1),
|
||||
None => tr("horizon.splitscreen.profiles.move", "Move where you play"),
|
||||
};
|
||||
let keys = [PadButton::LeftBumper, PadButton::RightBumper];
|
||||
let key_width = |b: PadButton| pad_glyph::width(atlas, b, style);
|
||||
let keys = [MenuJob::PagePrev, MenuJob::PageNext].map(|job| Glyph::menu(job, glyphs));
|
||||
let key_width = |b: Glyph| pad_glyph::width(atlas, b);
|
||||
let total = keys.iter().map(|&b| key_width(b) + 4.0).sum::<f32>() + 2.0 + width(&words);
|
||||
let (mut x, y) = (((batch.width - total) / 2.0).floor(), 30.0);
|
||||
for b in keys {
|
||||
let kw = key_width(b);
|
||||
// A light edge around a dark key: it stands out on the menu's dark background.
|
||||
if pad_glyph::art(atlas, b, style).is_none() {
|
||||
if pad_glyph::art(atlas, b).is_none() {
|
||||
batch.fill(
|
||||
atlas,
|
||||
x - 2.0,
|
||||
@@ -427,7 +428,7 @@ impl SplitProfiles {
|
||||
rgba(170, 170, 170, 255),
|
||||
);
|
||||
}
|
||||
pad_glyph::draw(batch, atlas, b, style, x, y);
|
||||
pad_glyph::draw(batch, atlas, b, x, y);
|
||||
x += kw + 4.0;
|
||||
}
|
||||
batch.text(atlas, &words, x + 2.0, y, rgba(240, 240, 240, 255), true);
|
||||
|
||||
@@ -721,7 +721,7 @@ impl Sprites {
|
||||
/// A PNG's width, height and pixels as RGBA. From the prepared data file
|
||||
/// when it holds the texture: no file opened per texture, nor for its absent
|
||||
/// LabPBR maps.
|
||||
fn read_rgba(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
|
||||
pub(crate) fn read_rgba(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
|
||||
let bytes = horizon_core::prepared_files::read(path)?;
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
|
||||
decoder.ignore_checksums(true);
|
||||
|
||||
@@ -12,7 +12,10 @@ struct StaticEntityUniform {
|
||||
@location(5) normal: vec4<f32>, @location(6) glint_uv: vec2<f32>,
|
||||
@location(7) section_origin: vec3<i32>) -> Out {
|
||||
let block_delta = section_origin - static_entity.camera_block.xyz;
|
||||
// [world warp static entity]
|
||||
// (a world warp's pipelines put the vertex through the mod's `world_warp`: gfx/world_warp.rs)
|
||||
let camera_relative = vec3<f32>(block_delta) + position - static_entity.camera_fraction.xyz;
|
||||
// [end world warp static entity]
|
||||
var out = entity_vertex(camera_relative,
|
||||
uv, overlay & 0xffffu, color, light, normal);
|
||||
out.glint_uv = glint_uv;
|
||||
|
||||
@@ -286,7 +286,7 @@ enum Job {
|
||||
Decorate(i32, i32, Box<[Proto; 9]>, Vec<(usize, Chunk)>),
|
||||
Finish(Proto),
|
||||
Light(i32, i32, Box<Region>, u64, bool),
|
||||
Mesh(i32, i32, i32, Box<Region>, u64, MeshSettings),
|
||||
Mesh(i32, i32, i32, Arc<Region>, u64, MeshSettings),
|
||||
/// Proto chunks checked out to be saved (`ChunkGenerator::save_protos`),
|
||||
/// off the main thread like vanilla's IOWorker.
|
||||
Save(Vec<((i32, i32), Proto)>),
|
||||
@@ -1185,13 +1185,12 @@ impl Streaming {
|
||||
/// The resident sections `LevelExtractor.allChanged` queues again. An
|
||||
/// unlit column will queue itself when its light job completes.
|
||||
fn mark_loaded_sections_dirty(world: &mut World, lit: &FxHashSet<(i32, i32)>) {
|
||||
let mask = SectionMask::below(world.layout().sections());
|
||||
for &(x, z) in world.chunks.keys() {
|
||||
if !lit.contains(&(x, z)) {
|
||||
continue;
|
||||
}
|
||||
for sy in 0..world.layout().sections() as i32 {
|
||||
world.dirty_sections.insert((x, sy, z));
|
||||
}
|
||||
world.dirty_sections.insert_column_mask((x, z), mask);
|
||||
}
|
||||
}
|
||||
/// A column received from a server, with the light it sent
|
||||
@@ -1204,10 +1203,12 @@ impl Streaming {
|
||||
world.chunks.insert(key, Arc::new(chunk));
|
||||
self.lit.insert(key);
|
||||
self.generated_total += 1;
|
||||
let mask = SectionMask::below(world.layout().sections());
|
||||
for dz in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
for sy in 0..world.layout().sections() as i32 {
|
||||
world.dirty_sections.insert((key.0 + dx, sy, key.1 + dz));
|
||||
let n = (key.0 + dx, key.1 + dz);
|
||||
if n == key || world.chunks.contains_key(&n) {
|
||||
world.dirty_sections.insert_column_mask(n, mask);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1525,7 +1526,7 @@ impl Streaming {
|
||||
z,
|
||||
)
|
||||
}
|
||||
fn region(world: &World, x: i32, z: i32) -> Option<Box<Region>> {
|
||||
fn region(world: &World, x: i32, z: i32) -> Option<Arc<Region>> {
|
||||
let mut region: Region = Default::default();
|
||||
for dz in 0..3 {
|
||||
for dx in 0..3 {
|
||||
@@ -1537,7 +1538,7 @@ impl Streaming {
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(Box::new(region))
|
||||
Some(Arc::new(region))
|
||||
}
|
||||
pub fn is_lit(&self, x: i32, z: i32) -> bool {
|
||||
self.lit.contains(&(x, z))
|
||||
@@ -1937,8 +1938,8 @@ impl Streaming {
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
let keys: Vec<(i32, i32)> =
|
||||
(0..9).map(|i| (x + (i % 3) - 1, z + (i / 3) - 1)).collect();
|
||||
let keys: [(i32, i32); 9] =
|
||||
std::array::from_fn(|i| (x + (i as i32 % 3) - 1, z + (i as i32 / 3) - 1));
|
||||
// A final neighbour cannot be generated again: a generator that
|
||||
// can decorates beside it (a chunk of an older world), writing
|
||||
// into it as vanilla's features do; the others skip decoration
|
||||
@@ -1947,40 +1948,33 @@ impl Streaming {
|
||||
if keys.iter().any(|k| self.decorating_finals.contains(k)) {
|
||||
continue;
|
||||
}
|
||||
let has_final = keys.iter().any(|k| world.chunks.contains_key(k));
|
||||
if has_final && !self.saves_beside_final {
|
||||
self.decorated.insert((x, z));
|
||||
waiting.borrow_mut().insert((x, z), false);
|
||||
continue;
|
||||
}
|
||||
if !keys.iter().all(|k| {
|
||||
world.chunks.contains_key(k) || matches!(self.protos.get(k), Some(Some(_)))
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
let finals: Vec<(usize, Chunk)> = keys
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, k)| Some((i, (**world.chunks.get(k)?).clone())))
|
||||
.collect();
|
||||
if !finals.is_empty() && !self.saves_beside_final {
|
||||
self.decorated.insert((x, z));
|
||||
waiting.borrow_mut().insert((x, z), false);
|
||||
continue;
|
||||
}
|
||||
if !keys.iter().enumerate().all(|(i, k)| {
|
||||
finals.iter().any(|(f, _)| *f == i)
|
||||
|| matches!(self.protos.get(k), Some(Some(_)))
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
let region: Vec<Proto> = keys
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, k)| {
|
||||
if finals.iter().any(|(f, _)| *f == i) {
|
||||
return Box::new(()) as Proto;
|
||||
}
|
||||
self.light_views.remove(k);
|
||||
self.protos
|
||||
.insert(*k, None)
|
||||
.flatten()
|
||||
.expect("present proto")
|
||||
})
|
||||
.collect();
|
||||
let region: Box<[Proto; 9]> = match region.try_into() {
|
||||
Ok(r) => Box::new(r),
|
||||
Err(_) => continue,
|
||||
};
|
||||
let region: Box<[Proto; 9]> = Box::new(std::array::from_fn(|i| {
|
||||
let k = &keys[i];
|
||||
if finals.iter().any(|(f, _)| *f == i) {
|
||||
return Box::new(()) as Proto;
|
||||
}
|
||||
self.light_views.remove(k);
|
||||
self.protos
|
||||
.insert(*k, None)
|
||||
.flatten()
|
||||
.expect("present proto")
|
||||
}));
|
||||
self.decorating += 1;
|
||||
self.decorating_finals
|
||||
.extend(finals.iter().map(|(i, _)| keys[*i]));
|
||||
@@ -2264,10 +2258,14 @@ impl Streaming {
|
||||
world.dirty_light.extend(columns);
|
||||
}
|
||||
/// Sections whose meshes sample a changed light value (faces and corners
|
||||
/// read light from neighbouring blocks).
|
||||
/// read light from neighbouring blocks). Every column whose stored light
|
||||
/// changed is saved again (`ChunkHolder.sectionLightChanged` marks the
|
||||
/// chunk unsaved): an edit's light reaches up to 15 blocks into columns
|
||||
/// the edit itself did not mark, which may already have been saved.
|
||||
fn mark_light_changes(world: &mut World, changed: &LightChanges) {
|
||||
if let Some(log) = &mut world.light_log {
|
||||
for (x, sy, z) in changed.sections() {
|
||||
for (x, sy, z) in changed.sections() {
|
||||
world.modified.insert((x, z));
|
||||
if let Some(log) = &mut world.light_log {
|
||||
log.entry((x, z)).or_default().insert(sy as usize);
|
||||
}
|
||||
}
|
||||
@@ -4369,6 +4367,38 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// An edit's light change in a neighbouring column marks that column
|
||||
/// unsaved too, or a column saved before the edit keeps its old light on
|
||||
/// disk.
|
||||
#[test]
|
||||
fn light_spilling_into_a_saved_neighbour_marks_it_unsaved() {
|
||||
use crate::light::tests::{AIR, TORCH, tables, world};
|
||||
let tables = tables();
|
||||
let mut w = World::new(horizon_world::registry::BlockStateId(0), 0);
|
||||
for (key, chunk) in world(|c| {
|
||||
c.set(14, 0, 8, TORCH);
|
||||
}) {
|
||||
w.chunks.insert(key, chunk);
|
||||
}
|
||||
assert!(w.modified.is_empty());
|
||||
Arc::make_mut(w.chunks.get_mut(&(0, 0)).unwrap()).set(14, 0, 8, AIR);
|
||||
let mut changed = crate::light::LightChanges::default();
|
||||
crate::light::relight(
|
||||
&mut w.chunks,
|
||||
&tables,
|
||||
true,
|
||||
&[(14, 0, 8)],
|
||||
false,
|
||||
&mut changed,
|
||||
);
|
||||
assert_eq!(w.chunks[&(1, 0)].light(0, 0, 8) & 15, 0); // was 12
|
||||
Streaming::mark_light_changes(&mut w, &changed);
|
||||
assert!(w.modified.contains(&(1, 0)));
|
||||
assert!(w.modified.contains(&(0, 0)));
|
||||
// The torch never reached x -1: that column's light is unchanged.
|
||||
assert!(!w.modified.contains(&(-1, 0)));
|
||||
}
|
||||
|
||||
fn resident_column(world: &mut World, light: u8) -> Chunk {
|
||||
let mut chunk = Chunk::empty(2, -3);
|
||||
chunk.set(1, 10, 1, 1);
|
||||
|
||||
@@ -40,7 +40,7 @@ struct Globals {
|
||||
hdr_windows: array<vec4<f32>, 2>,
|
||||
// HDR lighting: x how strongly the sky lights the world now (`Sky::sky_light_factor`),
|
||||
// y what the sky and fog get: the same, but none under water; z 1 with HDR output on.
|
||||
// w: this entire frame writes light, so alpha is coverage rather than a mark.
|
||||
// w: apply the no-pack HDR gain; HDR_WRITE_LIGHT independently keeps float alpha as coverage.
|
||||
hdr_light: vec4<f32>,
|
||||
hdr_settings: vec4<f32>,
|
||||
hdr_shares: vec4<f32>,
|
||||
@@ -224,6 +224,10 @@ struct TerrainOut {
|
||||
// The camera-relative height, for the water mirror's cut at the plane.
|
||||
height: f32,
|
||||
emissive_layer: u32,
|
||||
// [world warp varyings]
|
||||
// (a native mod's world warp adds its own here, and in `TerrainWire`, `to_wire` and
|
||||
// `from_wire`: gfx/world_warp.rs)
|
||||
// [end world warp varyings]
|
||||
}
|
||||
// `TerrainOut` in 5 interpolant locations instead of 12. A GPU's vertex stage pays for each
|
||||
// location it exports (AMD's parameter cache gives one a whole slot, however few of its four
|
||||
@@ -242,6 +246,8 @@ struct TerrainWire {
|
||||
@location(3) d: vec4<f32>,
|
||||
// `layer`, `emission`, `fade` and `emissive_layer`.
|
||||
@location(4) @interpolate(flat) e: vec4<u32>,
|
||||
// [world warp wire]
|
||||
// [end world warp wire]
|
||||
}
|
||||
fn to_wire(o: TerrainOut) -> TerrainWire {
|
||||
var w: TerrainWire;
|
||||
@@ -251,6 +257,8 @@ fn to_wire(o: TerrainOut) -> TerrainWire {
|
||||
w.c = vec4(o.uv, o.scene);
|
||||
w.d = vec4(o.spherical, o.cylindrical, o.height, 0.0);
|
||||
w.e = vec4(o.layer, o.emission, o.fade, o.emissive_layer);
|
||||
// [world warp to wire]
|
||||
// [end world warp to wire]
|
||||
return w;
|
||||
}
|
||||
fn from_wire(w: TerrainWire) -> TerrainOut {
|
||||
@@ -268,6 +276,8 @@ fn from_wire(w: TerrainWire) -> TerrainOut {
|
||||
o.emission = w.e.y;
|
||||
o.fade = w.e.z;
|
||||
o.emissive_layer = w.e.w;
|
||||
// [world warp from wire]
|
||||
// [end world warp from wire]
|
||||
return o;
|
||||
}
|
||||
// Whether a fading tile or section leaves this pixel out (gfx/lod.rs's
|
||||
@@ -662,7 +672,10 @@ fn hdr_facing() -> f32 {
|
||||
}
|
||||
fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
|
||||
light: vec2<u32>, origin: vec3<f32>, w: f32) -> TerrainOut {
|
||||
// [world warp vertex]
|
||||
// (a world warp's pipelines put the point through the mod's `world_warp`: gfx/world_warp.rs)
|
||||
let pos = position + origin;
|
||||
// [end world warp vertex]
|
||||
var out: TerrainOut;
|
||||
out.clip = fade_depth(globals.view_proj * vec4(pos, 1.0), w);
|
||||
out.local = position;
|
||||
@@ -695,6 +708,8 @@ fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f3
|
||||
const CHUNK_FULLY_VISIBLE_RANGE: f32 = 16.0;
|
||||
out.visibility = mix(1.0, instance_visibility(w),
|
||||
clamp((length(pos) - CHUNK_FULLY_VISIBLE_RANGE) / CHUNK_FULLY_VISIBLE_RANGE, 0.0, 1.0));
|
||||
// [world warp out]
|
||||
// [end world warp out]
|
||||
return out;
|
||||
}
|
||||
fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
|
||||
@@ -762,11 +777,14 @@ fn solid_color(in: TerrainOut) -> vec4<f32> {
|
||||
fn solid_finish(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>) -> vec4<f32> {
|
||||
return solid_light(solid_finish_at(in, texel, color, dpdx(in.uv), dpdy(in.uv)));
|
||||
}
|
||||
// A finished solid pixel as the frame keeps it: its mark in alpha, or on the written lane its
|
||||
// light, alpha 1.
|
||||
// A finished solid pixel as the frame keeps it: on the float frame its colour, alpha 1, lit by
|
||||
// HDR's gain where it applies (`hdr_light.w`); else its mark in alpha.
|
||||
fn solid_light(finished: vec4<f32>) -> vec4<f32> {
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(finished.rgb, finished.a, globals.hdr_settings, globals.hdr_shares), 1.0);
|
||||
if HDR_WRITE_LIGHT {
|
||||
if globals.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(finished.rgb, finished.a, globals.hdr_settings, globals.hdr_shares), 1.0);
|
||||
}
|
||||
return vec4(finished.rgb, 1.0);
|
||||
}
|
||||
return finished;
|
||||
}
|
||||
@@ -915,7 +933,7 @@ struct NativeModelOut {
|
||||
color = vec4(color.rgb * shade, color.a);
|
||||
}
|
||||
let finished = apply_fog(color, in.spherical, in.cylindrical);
|
||||
return vec4(finished.rgb, select(finished.a, 1.0, HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0));
|
||||
return vec4(finished.rgb, select(finished.a, 1.0, HDR_WRITE_LIGHT));
|
||||
}
|
||||
|
||||
// Sky (SkyRenderer): the 10-vertex sky disc fan at y = 16 (radius 512) in
|
||||
@@ -1050,8 +1068,11 @@ fn sky_along_ray_base(d: vec3<f32>) -> vec3<f32> {
|
||||
}
|
||||
fn sky_finished(color: vec3<f32>) -> vec4<f32> {
|
||||
let mark = 1.0 - 0.24 * globals.hdr_light.y;
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(color, mark, globals.hdr_settings, globals.hdr_shares), 1.0);
|
||||
if HDR_WRITE_LIGHT {
|
||||
if globals.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(color, mark, globals.hdr_settings, globals.hdr_shares), 1.0);
|
||||
}
|
||||
return vec4(color, 1.0);
|
||||
}
|
||||
return vec4(color, mark);
|
||||
}
|
||||
|
||||
@@ -1057,9 +1057,9 @@ pub fn tooltip(batch: &mut HudBatch, atlas: &HudAtlas, lines: &[Text], cursor: (
|
||||
}
|
||||
tooltip_box(batch, atlas, lines, x, y, w, h);
|
||||
}
|
||||
/// `WidgetTooltipHolder.createTooltipPositioner`'s focused branch
|
||||
/// (`MenuTooltipPositioner`): a widget with the keyboard focus keeps the
|
||||
/// pointer where it is, and its tooltip stays against the widget at `rect`.
|
||||
/// `WidgetTooltipHolder.createTooltipPositioner` for a hovered widget
|
||||
/// (`MenuTooltipPositioner`): the tooltip follows the pointer's x and keeps
|
||||
/// 3 to 5 px from the widget at `rect`.
|
||||
pub fn tooltip_menu(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
@@ -1132,14 +1132,16 @@ pub fn menu_tooltip_position(
|
||||
}
|
||||
pos
|
||||
}
|
||||
/// `MenuTooltipPositioner.getOffset`: how far the tooltip keeps from the
|
||||
/// widget's top (or bottom) edge, from `MAX_DISTANCE_TO_WIDGET` 5 to
|
||||
/// `MAX_OVERLAP_WITH_WIDGET` 3 as the pointer nears the edge.
|
||||
/// The distance over the height is an int division in vanilla, so the lerp
|
||||
/// is `height - 3` until the pointer is a whole height away, then 5.
|
||||
/// `MenuTooltipPositioner.getOffset`: how far the tooltip goes past the
|
||||
/// widget's top (or bottom) edge, `height - 3` (`MAX_OVERLAP_WITH_WIDGET`)
|
||||
/// with the pointer on the edge, down to `MAX_DISTANCE_TO_WIDGET` 5 a whole
|
||||
/// height away. The distance over the height is a float division (`i2f`,
|
||||
/// `fdiv` in 26.3's bytecode), so the offset moves smoothly with the pointer;
|
||||
/// then `Mth.lerp` and Java's float rounding.
|
||||
fn widget_offset(at: i32, edge: i32, height: i32) -> i32 {
|
||||
let d = ((at - edge).unsigned_abs().min(height as u32) / height as u32) as f32;
|
||||
((height as f32 - 3.0) + d * (5.0 - (height as f32 - 3.0))).round() as i32
|
||||
let distance = (at - edge).unsigned_abs().min(height as u32) as f32;
|
||||
let start = (height - 3) as f32;
|
||||
horizon_core::jmath::round_f32(start + distance / height as f32 * (5.0 - start))
|
||||
}
|
||||
fn tooltip_size(atlas: &HudAtlas, lines: &[Text]) -> (f32, f32) {
|
||||
let w = lines
|
||||
@@ -2487,16 +2489,21 @@ mod tests {
|
||||
let screen = (427, 240);
|
||||
let widget = [100, 100, 150, 20];
|
||||
let size = (80, 10);
|
||||
// Pointer at the widget's top edge: the tooltip drops 3 px, then
|
||||
// `getOffset`'s int division gives `height - 3` = 17 more.
|
||||
// Pointer at the widget's top edge: the tooltip drops 3 px to 103,
|
||||
// then `getOffset` adds round(lerp(3 / 20, 17, 5)) = round(15.2) = 15.
|
||||
assert_eq!(
|
||||
menu_tooltip_position(screen, (100, 100), widget, size),
|
||||
(112, 120)
|
||||
(112, 118)
|
||||
);
|
||||
// Near the right edge its left side mirrors to the pointer's left.
|
||||
assert_eq!(
|
||||
menu_tooltip_position(screen, (400, 100), widget, size),
|
||||
(308, 120)
|
||||
(308, 118)
|
||||
);
|
||||
// Halfway down the offset follows the pointer: 113 + round(9.2).
|
||||
assert_eq!(
|
||||
menu_tooltip_position(screen, (100, 110), widget, size),
|
||||
(112, 122)
|
||||
);
|
||||
// A whole widget height from the edge, the offset is 5.
|
||||
assert_eq!(
|
||||
@@ -2504,10 +2511,10 @@ mod tests {
|
||||
(112, 125)
|
||||
);
|
||||
// A widget too close to the bottom: the tooltip goes above it,
|
||||
// 203 - (10 + 6) - 17.
|
||||
// 203 - (10 + 6) - round(lerp(17 / 20, 17, 5)) = 203 - 16 - 7.
|
||||
assert_eq!(
|
||||
menu_tooltip_position(screen, (100, 200), [100, 200, 150, 20], size),
|
||||
(112, 170)
|
||||
(112, 180)
|
||||
);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 685 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user