mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-10 17:18:25 -10:00
Merge origin/main into revert-ram-limit (#6652)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
d571d80a06
298 files changed
+19275
-8702
No files matched your search
@@ -16,6 +16,8 @@ are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
|
||||
|
||||
The GLSL frontend also resets the common type for each comma-separated declarator so one
|
||||
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.
|
||||
|
||||
## libmimalloc-sys and mimalloc
|
||||
|
||||
|
||||
@@ -0,0 +1,870 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-04T13:00:15Z",
|
||||
"app": {
|
||||
"name": "horizon-client",
|
||||
"version": "nightly-2026-11-04-67dad0d",
|
||||
"commit": "67dad0d"
|
||||
},
|
||||
"hardware": {
|
||||
"os": "linux",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 24,
|
||||
"os_version": "NixOS 26.05 (Yarara) (kernel 6.18.57)",
|
||||
"cpu": "13th Gen Intel(R) Core(TM) i7-13700KF",
|
||||
"ram_mb": 31904,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 4080",
|
||||
"vendor": "nvidia",
|
||||
"driver": "NVIDIA 595.71.05",
|
||||
"backend": "vulkan",
|
||||
"kind": "discrete"
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 12,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 28.408,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 66658.183,
|
||||
"stdev": 61839.502,
|
||||
"min": 1827.42,
|
||||
"max": 124993.694,
|
||||
"ci95": 153630.237
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 74754.411,
|
||||
"stdev": 59189.587,
|
||||
"min": 13600.651,
|
||||
"max": 131761.773,
|
||||
"ci95": 147046.952
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 5571.499,
|
||||
"stdev": 1900.32,
|
||||
"min": 3378.38,
|
||||
"max": 6730.394,
|
||||
"ci95": 4721.037
|
||||
},
|
||||
"chunks.village.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 15287.0,
|
||||
"stdev": 527.501,
|
||||
"min": 14788.0,
|
||||
"max": 15839.0,
|
||||
"ci95": 1310.49
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2958.557,
|
||||
"stdev": 376.543,
|
||||
"min": 2526.453,
|
||||
"max": 3216.434,
|
||||
"ci95": 935.46
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 368.457,
|
||||
"stdev": 90.668,
|
||||
"min": 300.631,
|
||||
"max": 471.438,
|
||||
"ci95": 225.25
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 91.04,
|
||||
"stdev": 40.506,
|
||||
"min": 46.124,
|
||||
"max": 124.796,
|
||||
"ci95": 100.63
|
||||
},
|
||||
"memory.village.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 581.0,
|
||||
"stdev": 48.0,
|
||||
"min": 533.0,
|
||||
"max": 629.0,
|
||||
"ci95": 119.248
|
||||
},
|
||||
"memory.village.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1277.531,
|
||||
"stdev": 25.546,
|
||||
"min": 1253.879,
|
||||
"max": 1304.623,
|
||||
"ci95": 63.466
|
||||
},
|
||||
"tick.village.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.529,
|
||||
"stdev": 0.127,
|
||||
"min": 0.396,
|
||||
"max": 0.649,
|
||||
"ci95": 0.315
|
||||
},
|
||||
"cpu.village.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.185,
|
||||
"stdev": 0.034,
|
||||
"min": 0.162,
|
||||
"max": 0.224,
|
||||
"ci95": 0.085
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 3318.3,
|
||||
"stdev": 911.608,
|
||||
"min": 2266.627,
|
||||
"max": 3883.075,
|
||||
"ci95": 2264.743
|
||||
},
|
||||
"chunks.forest.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 16565.333,
|
||||
"stdev": 172.755,
|
||||
"min": 16367.0,
|
||||
"max": 16683.0,
|
||||
"ci95": 429.182
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2333.626,
|
||||
"stdev": 569.124,
|
||||
"min": 1745.365,
|
||||
"max": 2881.457,
|
||||
"ci95": 1413.897
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 336.615,
|
||||
"stdev": 185.666,
|
||||
"min": 208.133,
|
||||
"max": 549.487,
|
||||
"ci95": 461.257
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 160.734,
|
||||
"stdev": 127.586,
|
||||
"min": 39.777,
|
||||
"max": 294.05,
|
||||
"ci95": 316.967
|
||||
},
|
||||
"memory.forest.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 666.333,
|
||||
"stdev": 24.44,
|
||||
"min": 645.0,
|
||||
"max": 693.0,
|
||||
"ci95": 60.718
|
||||
},
|
||||
"memory.forest.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1346.816,
|
||||
"stdev": 42.279,
|
||||
"min": 1305.547,
|
||||
"max": 1390.037,
|
||||
"ci95": 105.035
|
||||
},
|
||||
"tick.forest.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.669,
|
||||
"stdev": 0.084,
|
||||
"min": 0.598,
|
||||
"max": 0.761,
|
||||
"ci95": 0.208
|
||||
},
|
||||
"cpu.forest.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.222,
|
||||
"stdev": 0.032,
|
||||
"min": 0.197,
|
||||
"max": 0.258,
|
||||
"ci95": 0.079
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 2463.434,
|
||||
"stdev": 1056.0,
|
||||
"min": 1527.516,
|
||||
"max": 3608.283,
|
||||
"ci95": 2623.462
|
||||
},
|
||||
"chunks.ocean.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 14226.667,
|
||||
"stdev": 307.077,
|
||||
"min": 14038.0,
|
||||
"max": 14581.0,
|
||||
"ci95": 762.883
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2192.874,
|
||||
"stdev": 1320.904,
|
||||
"min": 673.829,
|
||||
"max": 3071.415,
|
||||
"ci95": 3281.573
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 345.406,
|
||||
"stdev": 294.904,
|
||||
"min": 8.977,
|
||||
"max": 559.232,
|
||||
"ci95": 732.642
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 172.573,
|
||||
"stdev": 149.386,
|
||||
"min": 1.038,
|
||||
"max": 274.089,
|
||||
"ci95": 371.125
|
||||
},
|
||||
"memory.ocean.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 629.0,
|
||||
"stdev": 32.0,
|
||||
"min": 597.0,
|
||||
"max": 661.0,
|
||||
"ci95": 79.499
|
||||
},
|
||||
"memory.ocean.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1352.223,
|
||||
"stdev": 51.951,
|
||||
"min": 1301.492,
|
||||
"max": 1405.314,
|
||||
"ci95": 129.065
|
||||
},
|
||||
"tick.ocean.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.512,
|
||||
"stdev": 0.099,
|
||||
"min": 0.406,
|
||||
"max": 0.603,
|
||||
"ci95": 0.246
|
||||
},
|
||||
"cpu.ocean.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.178,
|
||||
"stdev": 0.038,
|
||||
"min": 0.135,
|
||||
"max": 0.206,
|
||||
"ci95": 0.094
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 6260.675,
|
||||
"stdev": 3077.348,
|
||||
"min": 2976.342,
|
||||
"max": 9077.55,
|
||||
"ci95": 7645.173
|
||||
},
|
||||
"chunks.mountain.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 16015.333,
|
||||
"stdev": 209.872,
|
||||
"min": 15826.0,
|
||||
"max": 16241.0,
|
||||
"ci95": 521.393
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1412.095,
|
||||
"stdev": 962.145,
|
||||
"min": 714.773,
|
||||
"max": 2509.775,
|
||||
"ci95": 2390.293
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 182.479,
|
||||
"stdev": 177.563,
|
||||
"min": 13.364,
|
||||
"max": 367.429,
|
||||
"ci95": 441.126
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 88.451,
|
||||
"stdev": 87.578,
|
||||
"min": 1.526,
|
||||
"max": 176.667,
|
||||
"ci95": 217.572
|
||||
},
|
||||
"memory.mountain.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 799.667,
|
||||
"stdev": 18.475,
|
||||
"min": 789.0,
|
||||
"max": 821.0,
|
||||
"ci95": 45.899
|
||||
},
|
||||
"memory.mountain.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1491.572,
|
||||
"stdev": 36.719,
|
||||
"min": 1459.775,
|
||||
"max": 1531.76,
|
||||
"ci95": 91.221
|
||||
},
|
||||
"tick.mountain.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.697,
|
||||
"stdev": 0.155,
|
||||
"min": 0.602,
|
||||
"max": 0.876,
|
||||
"ci95": 0.386
|
||||
},
|
||||
"cpu.mountain.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.325,
|
||||
"stdev": 0.115,
|
||||
"min": 0.239,
|
||||
"max": 0.456,
|
||||
"ci95": 0.286
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 1545.896,
|
||||
"stdev": 30.591,
|
||||
"min": 1524.965,
|
||||
"max": 1581.004,
|
||||
"ci95": 75.999
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1348.389,
|
||||
"stdev": 52.5,
|
||||
"min": 1296.172,
|
||||
"max": 1401.168,
|
||||
"ci95": 130.428
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 37594,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3132.783,
|
||||
"low_1pct_fps": 471.438,
|
||||
"low_01pct_fps": 124.796,
|
||||
"p50_ms": 0.271,
|
||||
"p95_ms": 0.494,
|
||||
"p99_ms": 1.127,
|
||||
"p999_ms": 2.15,
|
||||
"max_ms": 155.204,
|
||||
"cpu_p50_ms": 0.168,
|
||||
"gpu_p50_ms": 0.104,
|
||||
"histogram": [
|
||||
37151,
|
||||
394,
|
||||
30,
|
||||
8,
|
||||
5,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 38598,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3216.434,
|
||||
"low_1pct_fps": 300.631,
|
||||
"low_01pct_fps": 46.124,
|
||||
"p50_ms": 0.267,
|
||||
"p95_ms": 0.446,
|
||||
"p99_ms": 1.034,
|
||||
"p999_ms": 1.941,
|
||||
"max_ms": 493.993,
|
||||
"cpu_p50_ms": 0.162,
|
||||
"gpu_p50_ms": 0.104,
|
||||
"histogram": [
|
||||
38177,
|
||||
387,
|
||||
16,
|
||||
8,
|
||||
4,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 30318,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2526.453,
|
||||
"low_1pct_fps": 333.302,
|
||||
"low_01pct_fps": 102.199,
|
||||
"p50_ms": 0.305,
|
||||
"p95_ms": 0.757,
|
||||
"p99_ms": 1.69,
|
||||
"p999_ms": 3.539,
|
||||
"max_ms": 170.856,
|
||||
"cpu_p50_ms": 0.224,
|
||||
"gpu_p50_ms": 0.106,
|
||||
"histogram": [
|
||||
29368,
|
||||
759,
|
||||
139,
|
||||
35,
|
||||
11,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 34578,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2881.457,
|
||||
"low_1pct_fps": 549.487,
|
||||
"low_01pct_fps": 294.05,
|
||||
"p50_ms": 0.32,
|
||||
"p95_ms": 0.483,
|
||||
"p99_ms": 1.213,
|
||||
"p999_ms": 2.5,
|
||||
"max_ms": 5.358,
|
||||
"cpu_p50_ms": 0.197,
|
||||
"gpu_p50_ms": 0.154,
|
||||
"histogram": [
|
||||
34119,
|
||||
379,
|
||||
64,
|
||||
9,
|
||||
5,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 28489,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2374.055,
|
||||
"low_1pct_fps": 208.133,
|
||||
"low_01pct_fps": 39.777,
|
||||
"p50_ms": 0.313,
|
||||
"p95_ms": 0.937,
|
||||
"p99_ms": 1.863,
|
||||
"p999_ms": 4.318,
|
||||
"max_ms": 571.48,
|
||||
"cpu_p50_ms": 0.211,
|
||||
"gpu_p50_ms": 0.151,
|
||||
"histogram": [
|
||||
27193,
|
||||
1071,
|
||||
140,
|
||||
51,
|
||||
19,
|
||||
9,
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 20945,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1745.365,
|
||||
"low_1pct_fps": 252.226,
|
||||
"low_01pct_fps": 148.375,
|
||||
"p50_ms": 0.343,
|
||||
"p95_ms": 1.505,
|
||||
"p99_ms": 2.758,
|
||||
"p999_ms": 5.341,
|
||||
"max_ms": 11.642,
|
||||
"cpu_p50_ms": 0.258,
|
||||
"gpu_p50_ms": 0.162,
|
||||
"histogram": [
|
||||
18145,
|
||||
2293,
|
||||
337,
|
||||
106,
|
||||
38,
|
||||
13,
|
||||
6,
|
||||
5,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 36856,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3071.415,
|
||||
"low_1pct_fps": 559.232,
|
||||
"low_01pct_fps": 274.089,
|
||||
"p50_ms": 0.286,
|
||||
"p95_ms": 0.492,
|
||||
"p99_ms": 1.179,
|
||||
"p999_ms": 2.711,
|
||||
"max_ms": 6.718,
|
||||
"cpu_p50_ms": 0.135,
|
||||
"gpu_p50_ms": 0.127,
|
||||
"histogram": [
|
||||
36366,
|
||||
405,
|
||||
60,
|
||||
13,
|
||||
10,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 34001,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2833.377,
|
||||
"low_1pct_fps": 468.008,
|
||||
"low_01pct_fps": 242.591,
|
||||
"p50_ms": 0.295,
|
||||
"p95_ms": 0.589,
|
||||
"p99_ms": 1.41,
|
||||
"p999_ms": 3.048,
|
||||
"max_ms": 7.7,
|
||||
"cpu_p50_ms": 0.206,
|
||||
"gpu_p50_ms": 0.126,
|
||||
"histogram": [
|
||||
33314,
|
||||
544,
|
||||
106,
|
||||
23,
|
||||
10,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 8086,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 673.829,
|
||||
"low_1pct_fps": 8.977,
|
||||
"low_01pct_fps": 1.038,
|
||||
"p50_ms": 0.302,
|
||||
"p95_ms": 0.665,
|
||||
"p99_ms": 2.495,
|
||||
"p999_ms": 15.716,
|
||||
"max_ms": 4849.364,
|
||||
"cpu_p50_ms": 0.194,
|
||||
"gpu_p50_ms": 0.128,
|
||||
"histogram": [
|
||||
7808,
|
||||
166,
|
||||
50,
|
||||
26,
|
||||
8,
|
||||
2,
|
||||
3,
|
||||
5,
|
||||
2,
|
||||
5,
|
||||
1,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 12141,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1011.738,
|
||||
"low_1pct_fps": 166.643,
|
||||
"low_01pct_fps": 87.16,
|
||||
"p50_ms": 0.641,
|
||||
"p95_ms": 2.642,
|
||||
"p99_ms": 4.468,
|
||||
"p999_ms": 7.821,
|
||||
"max_ms": 22.536,
|
||||
"cpu_p50_ms": 0.456,
|
||||
"gpu_p50_ms": 0.138,
|
||||
"histogram": [
|
||||
7840,
|
||||
2996,
|
||||
866,
|
||||
278,
|
||||
87,
|
||||
38,
|
||||
16,
|
||||
11,
|
||||
2,
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 30118,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2509.775,
|
||||
"low_1pct_fps": 367.429,
|
||||
"low_01pct_fps": 176.667,
|
||||
"p50_ms": 0.316,
|
||||
"p95_ms": 0.771,
|
||||
"p99_ms": 1.931,
|
||||
"p999_ms": 4.2,
|
||||
"max_ms": 14.633,
|
||||
"cpu_p50_ms": 0.239,
|
||||
"gpu_p50_ms": 0.127,
|
||||
"histogram": [
|
||||
29101,
|
||||
775,
|
||||
173,
|
||||
38,
|
||||
16,
|
||||
8,
|
||||
4,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 8577,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 714.773,
|
||||
"low_1pct_fps": 13.364,
|
||||
"low_01pct_fps": 1.526,
|
||||
"p50_ms": 0.416,
|
||||
"p95_ms": 2.257,
|
||||
"p99_ms": 4.36,
|
||||
"p999_ms": 212.217,
|
||||
"max_ms": 1051.428,
|
||||
"cpu_p50_ms": 0.28,
|
||||
"gpu_p50_ms": 0.132,
|
||||
"histogram": [
|
||||
7017,
|
||||
978,
|
||||
377,
|
||||
111,
|
||||
39,
|
||||
24,
|
||||
10,
|
||||
6,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
4,
|
||||
2
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "horizon-desktop"
|
||||
}
|
||||
@@ -0,0 +1,870 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-04T14:47:19Z",
|
||||
"app": {
|
||||
"name": "horizon-client",
|
||||
"version": "nightly-2026-11-04-67dad0d",
|
||||
"commit": "67dad0d"
|
||||
},
|
||||
"hardware": {
|
||||
"os": "linux",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 24,
|
||||
"os_version": "NixOS 26.05 (Yarara) (kernel 6.18.57)",
|
||||
"cpu": "13th Gen Intel(R) Core(TM) i7-13700KF",
|
||||
"ram_mb": 31904,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 4080",
|
||||
"vendor": "nvidia",
|
||||
"driver": "NVIDIA 595.71.05",
|
||||
"backend": "vulkan",
|
||||
"kind": "discrete"
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 32,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 29.665,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 45386.16,
|
||||
"stdev": 38970.921,
|
||||
"min": 1077.276,
|
||||
"max": 74343.156,
|
||||
"ci95": 96816.948
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 86585.608,
|
||||
"stdev": 71331.593,
|
||||
"min": 11201.581,
|
||||
"max": 153020.143,
|
||||
"ci95": 177211.803
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 11082.587,
|
||||
"stdev": 5351.291,
|
||||
"min": 7620.454,
|
||||
"max": 17246.096,
|
||||
"ci95": 13294.417
|
||||
},
|
||||
"chunks.village.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 48440.333,
|
||||
"stdev": 656.16,
|
||||
"min": 48060.0,
|
||||
"max": 49198.0,
|
||||
"ci95": 1630.124
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 853.775,
|
||||
"stdev": 345.646,
|
||||
"min": 524.913,
|
||||
"max": 1214.059,
|
||||
"ci95": 858.701
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 130.235,
|
||||
"stdev": 117.39,
|
||||
"min": 9.024,
|
||||
"max": 243.388,
|
||||
"ci95": 291.635
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 70.65,
|
||||
"stdev": 68.628,
|
||||
"min": 1.125,
|
||||
"max": 138.345,
|
||||
"ci95": 170.496
|
||||
},
|
||||
"memory.village.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 2202.333,
|
||||
"stdev": 33.307,
|
||||
"min": 2165.0,
|
||||
"max": 2229.0,
|
||||
"ci95": 82.745
|
||||
},
|
||||
"memory.village.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1652.647,
|
||||
"stdev": 73.452,
|
||||
"min": 1568.063,
|
||||
"max": 1700.344,
|
||||
"ci95": 182.478
|
||||
},
|
||||
"tick.village.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.493,
|
||||
"stdev": 0.169,
|
||||
"min": 0.319,
|
||||
"max": 0.657,
|
||||
"ci95": 0.42
|
||||
},
|
||||
"cpu.village.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.692,
|
||||
"stdev": 0.207,
|
||||
"min": 0.531,
|
||||
"max": 0.925,
|
||||
"ci95": 0.513
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 15019.921,
|
||||
"stdev": 12855.4,
|
||||
"min": 5613.942,
|
||||
"max": 29668.11,
|
||||
"ci95": 31937.162
|
||||
},
|
||||
"chunks.forest.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 80224.667,
|
||||
"stdev": 678.709,
|
||||
"min": 79626.0,
|
||||
"max": 80962.0,
|
||||
"ci95": 1686.142
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 898.208,
|
||||
"stdev": 446.3,
|
||||
"min": 382.944,
|
||||
"max": 1163.61,
|
||||
"ci95": 1108.759
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 178.513,
|
||||
"stdev": 107.096,
|
||||
"min": 54.933,
|
||||
"max": 244.255,
|
||||
"ci95": 266.064
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 103.369,
|
||||
"stdev": 78.403,
|
||||
"min": 12.974,
|
||||
"max": 152.87,
|
||||
"ci95": 194.779
|
||||
},
|
||||
"memory.forest.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 2201.667,
|
||||
"stdev": 34.429,
|
||||
"min": 2163.0,
|
||||
"max": 2229.0,
|
||||
"ci95": 85.532
|
||||
},
|
||||
"memory.forest.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1809.063,
|
||||
"stdev": 93.103,
|
||||
"min": 1707.98,
|
||||
"max": 1891.305,
|
||||
"ci95": 231.3
|
||||
},
|
||||
"tick.forest.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.585,
|
||||
"stdev": 0.147,
|
||||
"min": 0.493,
|
||||
"max": 0.754,
|
||||
"ci95": 0.365
|
||||
},
|
||||
"cpu.forest.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.989,
|
||||
"stdev": 0.725,
|
||||
"min": 0.57,
|
||||
"max": 1.826,
|
||||
"ci95": 1.802
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 12399.047,
|
||||
"stdev": 15052.073,
|
||||
"min": 3174.115,
|
||||
"max": 29768.486,
|
||||
"ci95": 37394.441
|
||||
},
|
||||
"chunks.ocean.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 64737.0,
|
||||
"stdev": 16149.666,
|
||||
"min": 46089.0,
|
||||
"max": 74089.0,
|
||||
"ci95": 40121.232
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 735.22,
|
||||
"stdev": 601.75,
|
||||
"min": 108.108,
|
||||
"max": 1307.912,
|
||||
"ci95": 1494.952
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 134.654,
|
||||
"stdev": 147.391,
|
||||
"min": 1.252,
|
||||
"max": 292.881,
|
||||
"ci95": 366.169
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 62.062,
|
||||
"stdev": 66.294,
|
||||
"min": 0.202,
|
||||
"max": 132.042,
|
||||
"ci95": 164.697
|
||||
},
|
||||
"memory.ocean.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 1942.333,
|
||||
"stdev": 251.725,
|
||||
"min": 1657.0,
|
||||
"max": 2133.0,
|
||||
"ci95": 625.369
|
||||
},
|
||||
"memory.ocean.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1812.661,
|
||||
"stdev": 102.065,
|
||||
"min": 1697.912,
|
||||
"max": 1893.313,
|
||||
"ci95": 253.564
|
||||
},
|
||||
"tick.ocean.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.421,
|
||||
"stdev": 0.227,
|
||||
"min": 0.193,
|
||||
"max": 0.646,
|
||||
"ci95": 0.563
|
||||
},
|
||||
"cpu.ocean.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.513,
|
||||
"stdev": 0.194,
|
||||
"min": 0.303,
|
||||
"max": 0.686,
|
||||
"ci95": 0.483
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 31815.539,
|
||||
"stdev": 36758.245,
|
||||
"min": 9582.166,
|
||||
"max": 74243.988,
|
||||
"ci95": 91319.913
|
||||
},
|
||||
"chunks.mountain.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 90876.333,
|
||||
"stdev": 1525.588,
|
||||
"min": 89793.0,
|
||||
"max": 92621.0,
|
||||
"ci95": 3790.075
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 712.657,
|
||||
"stdev": 252.815,
|
||||
"min": 450.565,
|
||||
"max": 955.041,
|
||||
"ci95": 628.077
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 117.93,
|
||||
"stdev": 85.922,
|
||||
"min": 24.389,
|
||||
"max": 193.34,
|
||||
"ci95": 213.46
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 72.407,
|
||||
"stdev": 65.468,
|
||||
"min": 3.577,
|
||||
"max": 133.895,
|
||||
"ci95": 162.646
|
||||
},
|
||||
"memory.mountain.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 2783.667,
|
||||
"stdev": 48.881,
|
||||
"min": 2741.0,
|
||||
"max": 2837.0,
|
||||
"ci95": 121.436
|
||||
},
|
||||
"memory.mountain.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1931.359,
|
||||
"stdev": 106.681,
|
||||
"min": 1808.184,
|
||||
"max": 1994.242,
|
||||
"ci95": 265.031
|
||||
},
|
||||
"tick.mountain.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.03,
|
||||
"stdev": 0.685,
|
||||
"min": 0.564,
|
||||
"max": 1.817,
|
||||
"ci95": 1.702
|
||||
},
|
||||
"cpu.mountain.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.025,
|
||||
"stdev": 0.315,
|
||||
"min": 0.765,
|
||||
"max": 1.376,
|
||||
"ci95": 0.783
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2051.333,
|
||||
"stdev": 147.151,
|
||||
"min": 1884.176,
|
||||
"max": 2161.316,
|
||||
"ci95": 365.573
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1810.92,
|
||||
"stdev": 94.623,
|
||||
"min": 1707.342,
|
||||
"max": 1892.828,
|
||||
"ci95": 235.075
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 6301,
|
||||
"seconds": 12.004,
|
||||
"avg_fps": 524.913,
|
||||
"low_1pct_fps": 9.024,
|
||||
"low_01pct_fps": 1.125,
|
||||
"p50_ms": 0.555,
|
||||
"p95_ms": 2.072,
|
||||
"p99_ms": 4.067,
|
||||
"p999_ms": 282.455,
|
||||
"max_ms": 3525.74,
|
||||
"cpu_p50_ms": 0.531,
|
||||
"gpu_p50_ms": 0.218,
|
||||
"histogram": [
|
||||
4878,
|
||||
1060,
|
||||
217,
|
||||
86,
|
||||
19,
|
||||
15,
|
||||
6,
|
||||
2,
|
||||
5,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
4,
|
||||
3,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 9869,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 822.353,
|
||||
"low_1pct_fps": 138.293,
|
||||
"low_01pct_fps": 72.479,
|
||||
"p50_ms": 0.976,
|
||||
"p95_ms": 2.871,
|
||||
"p99_ms": 5.065,
|
||||
"p999_ms": 9.942,
|
||||
"max_ms": 23.337,
|
||||
"cpu_p50_ms": 0.925,
|
||||
"gpu_p50_ms": 0.245,
|
||||
"histogram": [
|
||||
5262,
|
||||
3404,
|
||||
771,
|
||||
248,
|
||||
81,
|
||||
49,
|
||||
23,
|
||||
11,
|
||||
12,
|
||||
3,
|
||||
0,
|
||||
3,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 14570,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1214.059,
|
||||
"low_1pct_fps": 243.388,
|
||||
"low_01pct_fps": 138.345,
|
||||
"p50_ms": 0.655,
|
||||
"p95_ms": 1.486,
|
||||
"p99_ms": 2.883,
|
||||
"p999_ms": 5.366,
|
||||
"max_ms": 26.145,
|
||||
"cpu_p50_ms": 0.62,
|
||||
"gpu_p50_ms": 0.239,
|
||||
"histogram": [
|
||||
11502,
|
||||
2683,
|
||||
257,
|
||||
77,
|
||||
25,
|
||||
23,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 4595,
|
||||
"seconds": 11.999,
|
||||
"avg_fps": 382.944,
|
||||
"low_1pct_fps": 54.933,
|
||||
"low_01pct_fps": 12.974,
|
||||
"p50_ms": 2.269,
|
||||
"p95_ms": 5.193,
|
||||
"p99_ms": 8.719,
|
||||
"p999_ms": 21.933,
|
||||
"max_ms": 243.247,
|
||||
"cpu_p50_ms": 1.826,
|
||||
"gpu_p50_ms": 0.502,
|
||||
"histogram": [
|
||||
273,
|
||||
1654,
|
||||
1395,
|
||||
836,
|
||||
172,
|
||||
122,
|
||||
52,
|
||||
38,
|
||||
26,
|
||||
10,
|
||||
7,
|
||||
5,
|
||||
0,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 13964,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1163.61,
|
||||
"low_1pct_fps": 244.255,
|
||||
"low_01pct_fps": 152.87,
|
||||
"p50_ms": 0.682,
|
||||
"p95_ms": 1.524,
|
||||
"p99_ms": 2.877,
|
||||
"p999_ms": 5.801,
|
||||
"max_ms": 7.977,
|
||||
"cpu_p50_ms": 0.57,
|
||||
"gpu_p50_ms": 0.467,
|
||||
"histogram": [
|
||||
11067,
|
||||
2532,
|
||||
235,
|
||||
82,
|
||||
23,
|
||||
16,
|
||||
6,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 13777,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1148.071,
|
||||
"low_1pct_fps": 236.351,
|
||||
"low_01pct_fps": 144.264,
|
||||
"p50_ms": 0.691,
|
||||
"p95_ms": 1.637,
|
||||
"p99_ms": 3.131,
|
||||
"p999_ms": 5.794,
|
||||
"max_ms": 9.301,
|
||||
"cpu_p50_ms": 0.57,
|
||||
"gpu_p50_ms": 0.465,
|
||||
"histogram": [
|
||||
10826,
|
||||
2511,
|
||||
284,
|
||||
106,
|
||||
23,
|
||||
16,
|
||||
5,
|
||||
5,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 1224,
|
||||
"seconds": 11.322,
|
||||
"avg_fps": 108.108,
|
||||
"low_1pct_fps": 1.252,
|
||||
"low_01pct_fps": 0.202,
|
||||
"p50_ms": 0.439,
|
||||
"p95_ms": 3.015,
|
||||
"p99_ms": 7.359,
|
||||
"p999_ms": 2880.23,
|
||||
"max_ms": 7005.436,
|
||||
"cpu_p50_ms": 0.303,
|
||||
"gpu_p50_ms": 0.258,
|
||||
"histogram": [
|
||||
1081,
|
||||
31,
|
||||
49,
|
||||
10,
|
||||
3,
|
||||
7,
|
||||
23,
|
||||
12,
|
||||
4,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 15695,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1307.912,
|
||||
"low_1pct_fps": 292.881,
|
||||
"low_01pct_fps": 132.042,
|
||||
"p50_ms": 0.624,
|
||||
"p95_ms": 1.219,
|
||||
"p99_ms": 2.173,
|
||||
"p999_ms": 4.841,
|
||||
"max_ms": 16.764,
|
||||
"cpu_p50_ms": 0.551,
|
||||
"gpu_p50_ms": 0.401,
|
||||
"histogram": [
|
||||
13558,
|
||||
1932,
|
||||
140,
|
||||
37,
|
||||
14,
|
||||
6,
|
||||
3,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 9476,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 789.641,
|
||||
"low_1pct_fps": 109.83,
|
||||
"low_01pct_fps": 53.941,
|
||||
"p50_ms": 0.802,
|
||||
"p95_ms": 3.406,
|
||||
"p99_ms": 5.844,
|
||||
"p999_ms": 14.977,
|
||||
"max_ms": 22.481,
|
||||
"cpu_p50_ms": 0.686,
|
||||
"gpu_p50_ms": 0.403,
|
||||
"histogram": [
|
||||
5819,
|
||||
2151,
|
||||
787,
|
||||
455,
|
||||
112,
|
||||
65,
|
||||
36,
|
||||
18,
|
||||
7,
|
||||
7,
|
||||
7,
|
||||
5,
|
||||
4,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 5352,
|
||||
"seconds": 11.878,
|
||||
"avg_fps": 450.565,
|
||||
"low_1pct_fps": 24.389,
|
||||
"low_01pct_fps": 3.577,
|
||||
"p50_ms": 1.431,
|
||||
"p95_ms": 4.548,
|
||||
"p99_ms": 7.601,
|
||||
"p999_ms": 96.086,
|
||||
"max_ms": 714.027,
|
||||
"cpu_p50_ms": 1.376,
|
||||
"gpu_p50_ms": 0.398,
|
||||
"histogram": [
|
||||
1412,
|
||||
2323,
|
||||
811,
|
||||
460,
|
||||
138,
|
||||
84,
|
||||
49,
|
||||
34,
|
||||
13,
|
||||
9,
|
||||
8,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 8789,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 732.365,
|
||||
"low_1pct_fps": 136.06,
|
||||
"low_01pct_fps": 79.749,
|
||||
"p50_ms": 0.972,
|
||||
"p95_ms": 3.329,
|
||||
"p99_ms": 5.68,
|
||||
"p999_ms": 9.7,
|
||||
"max_ms": 18.289,
|
||||
"cpu_p50_ms": 0.935,
|
||||
"gpu_p50_ms": 0.383,
|
||||
"histogram": [
|
||||
4529,
|
||||
3007,
|
||||
715,
|
||||
287,
|
||||
120,
|
||||
61,
|
||||
37,
|
||||
16,
|
||||
12,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 11461,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 955.041,
|
||||
"low_1pct_fps": 193.34,
|
||||
"low_01pct_fps": 133.895,
|
||||
"p50_ms": 0.787,
|
||||
"p95_ms": 2.026,
|
||||
"p99_ms": 3.953,
|
||||
"p999_ms": 6.511,
|
||||
"max_ms": 9.761,
|
||||
"cpu_p50_ms": 0.765,
|
||||
"gpu_p50_ms": 0.379,
|
||||
"histogram": [
|
||||
7754,
|
||||
3114,
|
||||
328,
|
||||
165,
|
||||
46,
|
||||
33,
|
||||
14,
|
||||
5,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "horizon-desktop"
|
||||
}
|
||||
@@ -0,0 +1,854 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-04T12:44:40Z",
|
||||
"app": {
|
||||
"name": "horizon-client",
|
||||
"version": "nightly-2026-11-04-67dad0d",
|
||||
"commit": "67dad0d"
|
||||
},
|
||||
"hardware": {
|
||||
"os": "macos",
|
||||
"arch": "aarch64",
|
||||
"cpu_threads": 14,
|
||||
"os_version": "27.0.1",
|
||||
"cpu": "Apple M4 Pro",
|
||||
"ram_mb": 65536,
|
||||
"gpu": {
|
||||
"name": "Apple M4 Pro",
|
||||
"vendor": "",
|
||||
"driver": "",
|
||||
"backend": "metal",
|
||||
"kind": "integrated"
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 12,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": true,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 17.39,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 1516.155,
|
||||
"stdev": 34.227,
|
||||
"min": 1476.782,
|
||||
"max": 1538.813,
|
||||
"ci95": 85.032
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 4080.9,
|
||||
"stdev": 748.712,
|
||||
"min": 3273.516,
|
||||
"max": 4752.301,
|
||||
"ci95": 1860.054
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 3363.749,
|
||||
"stdev": 224.552,
|
||||
"min": 3105.622,
|
||||
"max": 3514.059,
|
||||
"ci95": 557.864
|
||||
},
|
||||
"chunks.village.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 15473.667,
|
||||
"stdev": 339.602,
|
||||
"min": 15123.0,
|
||||
"max": 15801.0,
|
||||
"ci95": 843.686
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 999.753,
|
||||
"stdev": 41.901,
|
||||
"min": 970.527,
|
||||
"max": 1047.758,
|
||||
"ci95": 104.095
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 336.821,
|
||||
"stdev": 17.834,
|
||||
"min": 318.068,
|
||||
"max": 353.566,
|
||||
"ci95": 44.306
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 263.782,
|
||||
"stdev": 31.556,
|
||||
"min": 227.767,
|
||||
"max": 286.582,
|
||||
"ci95": 78.395
|
||||
},
|
||||
"memory.village.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1147.654,
|
||||
"stdev": 31.82,
|
||||
"min": 1116.875,
|
||||
"max": 1180.422,
|
||||
"ci95": 79.052
|
||||
},
|
||||
"tick.village.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.414,
|
||||
"stdev": 0.036,
|
||||
"min": 0.373,
|
||||
"max": 0.44,
|
||||
"ci95": 0.089
|
||||
},
|
||||
"cpu.village.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.52,
|
||||
"stdev": 0.071,
|
||||
"min": 0.44,
|
||||
"max": 0.577,
|
||||
"ci95": 0.177
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 3685.368,
|
||||
"stdev": 844.43,
|
||||
"min": 2932.117,
|
||||
"max": 4598.204,
|
||||
"ci95": 2097.85
|
||||
},
|
||||
"chunks.forest.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 16795.333,
|
||||
"stdev": 437.802,
|
||||
"min": 16325.0,
|
||||
"max": 17191.0,
|
||||
"ci95": 1087.648
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 592.615,
|
||||
"stdev": 20.339,
|
||||
"min": 580.401,
|
||||
"max": 616.094,
|
||||
"ci95": 50.529
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 250.069,
|
||||
"stdev": 31.227,
|
||||
"min": 217.065,
|
||||
"max": 279.149,
|
||||
"ci95": 77.58
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 151.684,
|
||||
"stdev": 77.064,
|
||||
"min": 64.033,
|
||||
"max": 208.811,
|
||||
"ci95": 191.454
|
||||
},
|
||||
"memory.forest.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1064.344,
|
||||
"stdev": 148.185,
|
||||
"min": 901.203,
|
||||
"max": 1190.609,
|
||||
"ci95": 368.142
|
||||
},
|
||||
"tick.forest.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.424,
|
||||
"stdev": 0.003,
|
||||
"min": 0.422,
|
||||
"max": 0.427,
|
||||
"ci95": 0.007
|
||||
},
|
||||
"cpu.forest.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.617,
|
||||
"stdev": 0.016,
|
||||
"min": 0.599,
|
||||
"max": 0.629,
|
||||
"ci95": 0.039
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 3594.311,
|
||||
"stdev": 1381.026,
|
||||
"min": 2011.153,
|
||||
"max": 4551.541,
|
||||
"ci95": 3430.936
|
||||
},
|
||||
"chunks.ocean.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 14527.0,
|
||||
"stdev": 319.629,
|
||||
"min": 14166.0,
|
||||
"max": 14774.0,
|
||||
"ci95": 794.068
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 789.003,
|
||||
"stdev": 25.076,
|
||||
"min": 766.83,
|
||||
"max": 816.216,
|
||||
"ci95": 62.297
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 276.887,
|
||||
"stdev": 51.785,
|
||||
"min": 217.373,
|
||||
"max": 311.665,
|
||||
"ci95": 128.651
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 169.278,
|
||||
"stdev": 29.119,
|
||||
"min": 137.156,
|
||||
"max": 193.944,
|
||||
"ci95": 72.341
|
||||
},
|
||||
"memory.ocean.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 982.995,
|
||||
"stdev": 287.819,
|
||||
"min": 651.109,
|
||||
"max": 1164.063,
|
||||
"ci95": 715.039
|
||||
},
|
||||
"tick.ocean.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.397,
|
||||
"stdev": 0.028,
|
||||
"min": 0.38,
|
||||
"max": 0.429,
|
||||
"ci95": 0.069
|
||||
},
|
||||
"cpu.ocean.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.569,
|
||||
"stdev": 0.045,
|
||||
"min": 0.533,
|
||||
"max": 0.62,
|
||||
"ci95": 0.112
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 5682.993,
|
||||
"stdev": 1272.063,
|
||||
"min": 4476.873,
|
||||
"max": 7012.069,
|
||||
"ci95": 3160.234
|
||||
},
|
||||
"chunks.mountain.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 15640.0,
|
||||
"stdev": 204.105,
|
||||
"min": 15507.0,
|
||||
"max": 15875.0,
|
||||
"ci95": 507.067
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 716.475,
|
||||
"stdev": 8.139,
|
||||
"min": 707.613,
|
||||
"max": 723.617,
|
||||
"ci95": 20.221
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 313.736,
|
||||
"stdev": 3.759,
|
||||
"min": 311.007,
|
||||
"max": 318.023,
|
||||
"ci95": 9.338
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 270.042,
|
||||
"stdev": 19.572,
|
||||
"min": 247.802,
|
||||
"max": 284.642,
|
||||
"ci95": 48.624
|
||||
},
|
||||
"memory.mountain.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 997.474,
|
||||
"stdev": 231.733,
|
||||
"min": 790.266,
|
||||
"max": 1247.703,
|
||||
"ci95": 575.704
|
||||
},
|
||||
"tick.mountain.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.385,
|
||||
"stdev": 0.023,
|
||||
"min": 0.359,
|
||||
"max": 0.401,
|
||||
"ci95": 0.057
|
||||
},
|
||||
"cpu.mountain.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.609,
|
||||
"stdev": 0.044,
|
||||
"min": 0.56,
|
||||
"max": 0.645,
|
||||
"ci95": 0.109
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 1213.255,
|
||||
"stdev": 81.897,
|
||||
"min": 1136.078,
|
||||
"max": 1299.172,
|
||||
"ci95": 203.461
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1034.279,
|
||||
"stdev": 188.802,
|
||||
"min": 816.453,
|
||||
"max": 1150.961,
|
||||
"ci95": 469.049
|
||||
},
|
||||
"memory.footprint_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2167.747,
|
||||
"stdev": 14.039,
|
||||
"min": 2153.721,
|
||||
"max": 2181.799,
|
||||
"ci95": 34.878
|
||||
},
|
||||
"memory.footprint_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1889.468,
|
||||
"stdev": 24.444,
|
||||
"min": 1862.799,
|
||||
"max": 1910.807,
|
||||
"ci95": 60.727
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 12573,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1047.758,
|
||||
"low_1pct_fps": 353.566,
|
||||
"low_01pct_fps": 286.582,
|
||||
"p50_ms": 0.921,
|
||||
"p95_ms": 1.077,
|
||||
"p99_ms": 2.675,
|
||||
"p999_ms": 2.946,
|
||||
"max_ms": 5.36,
|
||||
"cpu_p50_ms": 0.44,
|
||||
"gpu_p50_ms": 0.914,
|
||||
"histogram": [
|
||||
11056,
|
||||
1291,
|
||||
218,
|
||||
6,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 11646,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 970.527,
|
||||
"low_1pct_fps": 318.068,
|
||||
"low_01pct_fps": 227.767,
|
||||
"p50_ms": 0.973,
|
||||
"p95_ms": 1.264,
|
||||
"p99_ms": 2.805,
|
||||
"p999_ms": 3.808,
|
||||
"max_ms": 5.305,
|
||||
"cpu_p50_ms": 0.577,
|
||||
"gpu_p50_ms": 0.969,
|
||||
"histogram": [
|
||||
7035,
|
||||
4334,
|
||||
230,
|
||||
39,
|
||||
6,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 11771,
|
||||
"seconds": 11.999,
|
||||
"avg_fps": 980.973,
|
||||
"low_1pct_fps": 338.828,
|
||||
"low_01pct_fps": 276.996,
|
||||
"p50_ms": 0.985,
|
||||
"p95_ms": 1.157,
|
||||
"p99_ms": 2.779,
|
||||
"p999_ms": 3.068,
|
||||
"max_ms": 4.308,
|
||||
"cpu_p50_ms": 0.542,
|
||||
"gpu_p50_ms": 0.98,
|
||||
"histogram": [
|
||||
6880,
|
||||
4663,
|
||||
208,
|
||||
18,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 7394,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 616.094,
|
||||
"low_1pct_fps": 253.993,
|
||||
"low_01pct_fps": 182.207,
|
||||
"p50_ms": 1.565,
|
||||
"p95_ms": 1.915,
|
||||
"p99_ms": 3.329,
|
||||
"p999_ms": 4.661,
|
||||
"max_ms": 7.32,
|
||||
"cpu_p50_ms": 0.599,
|
||||
"gpu_p50_ms": 1.551,
|
||||
"histogram": [
|
||||
82,
|
||||
6988,
|
||||
144,
|
||||
162,
|
||||
14,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 6965,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 580.401,
|
||||
"low_1pct_fps": 217.065,
|
||||
"low_01pct_fps": 64.033,
|
||||
"p50_ms": 1.654,
|
||||
"p95_ms": 1.983,
|
||||
"p99_ms": 3.233,
|
||||
"p999_ms": 3.893,
|
||||
"max_ms": 81.863,
|
||||
"cpu_p50_ms": 0.629,
|
||||
"gpu_p50_ms": 1.645,
|
||||
"histogram": [
|
||||
11,
|
||||
6622,
|
||||
153,
|
||||
174,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 6977,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 581.35,
|
||||
"low_1pct_fps": 279.149,
|
||||
"low_01pct_fps": 208.811,
|
||||
"p50_ms": 1.659,
|
||||
"p95_ms": 2.006,
|
||||
"p99_ms": 3.273,
|
||||
"p999_ms": 3.982,
|
||||
"max_ms": 5.889,
|
||||
"cpu_p50_ms": 0.623,
|
||||
"gpu_p50_ms": 1.653,
|
||||
"histogram": [
|
||||
17,
|
||||
6606,
|
||||
164,
|
||||
186,
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 9793,
|
||||
"seconds": 11.998,
|
||||
"avg_fps": 816.216,
|
||||
"low_1pct_fps": 301.624,
|
||||
"low_01pct_fps": 176.734,
|
||||
"p50_ms": 1.171,
|
||||
"p95_ms": 1.499,
|
||||
"p99_ms": 2.928,
|
||||
"p999_ms": 3.333,
|
||||
"max_ms": 23.472,
|
||||
"cpu_p50_ms": 0.533,
|
||||
"gpu_p50_ms": 1.163,
|
||||
"histogram": [
|
||||
259,
|
||||
9293,
|
||||
182,
|
||||
56,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 9202,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 766.83,
|
||||
"low_1pct_fps": 217.373,
|
||||
"low_01pct_fps": 137.156,
|
||||
"p50_ms": 1.226,
|
||||
"p95_ms": 1.912,
|
||||
"p99_ms": 3.396,
|
||||
"p999_ms": 6.003,
|
||||
"max_ms": 13.562,
|
||||
"cpu_p50_ms": 0.62,
|
||||
"gpu_p50_ms": 1.219,
|
||||
"histogram": [
|
||||
635,
|
||||
8130,
|
||||
261,
|
||||
124,
|
||||
27,
|
||||
15,
|
||||
7,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 9408,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 783.964,
|
||||
"low_1pct_fps": 311.665,
|
||||
"low_01pct_fps": 193.944,
|
||||
"p50_ms": 1.22,
|
||||
"p95_ms": 1.541,
|
||||
"p99_ms": 2.865,
|
||||
"p999_ms": 3.298,
|
||||
"max_ms": 13.861,
|
||||
"cpu_p50_ms": 0.555,
|
||||
"gpu_p50_ms": 1.214,
|
||||
"histogram": [
|
||||
100,
|
||||
9066,
|
||||
204,
|
||||
34,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 8618,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 718.194,
|
||||
"low_1pct_fps": 312.177,
|
||||
"low_01pct_fps": 277.681,
|
||||
"p50_ms": 1.338,
|
||||
"p95_ms": 1.664,
|
||||
"p99_ms": 3.068,
|
||||
"p999_ms": 3.364,
|
||||
"max_ms": 4.008,
|
||||
"cpu_p50_ms": 0.622,
|
||||
"gpu_p50_ms": 1.329,
|
||||
"histogram": [
|
||||
62,
|
||||
8301,
|
||||
130,
|
||||
125,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 8492,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 707.613,
|
||||
"low_1pct_fps": 311.007,
|
||||
"low_01pct_fps": 284.642,
|
||||
"p50_ms": 1.35,
|
||||
"p95_ms": 1.707,
|
||||
"p99_ms": 3.08,
|
||||
"p999_ms": 3.329,
|
||||
"max_ms": 3.796,
|
||||
"cpu_p50_ms": 0.645,
|
||||
"gpu_p50_ms": 1.342,
|
||||
"histogram": [
|
||||
32,
|
||||
8216,
|
||||
111,
|
||||
133,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 8684,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 723.617,
|
||||
"low_1pct_fps": 318.023,
|
||||
"low_01pct_fps": 247.802,
|
||||
"p50_ms": 1.318,
|
||||
"p95_ms": 1.699,
|
||||
"p99_ms": 2.875,
|
||||
"p999_ms": 3.573,
|
||||
"max_ms": 5.186,
|
||||
"cpu_p50_ms": 0.56,
|
||||
"gpu_p50_ms": 1.306,
|
||||
"histogram": [
|
||||
46,
|
||||
8389,
|
||||
199,
|
||||
49,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "horizon-macmini"
|
||||
}
|
||||
@@ -0,0 +1,854 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-04T13:17:26Z",
|
||||
"app": {
|
||||
"name": "horizon-client",
|
||||
"version": "nightly-2026-11-04-67dad0d",
|
||||
"commit": "67dad0d"
|
||||
},
|
||||
"hardware": {
|
||||
"os": "macos",
|
||||
"arch": "aarch64",
|
||||
"cpu_threads": 14,
|
||||
"os_version": "27.0.1",
|
||||
"cpu": "Apple M4 Pro",
|
||||
"ram_mb": 65536,
|
||||
"gpu": {
|
||||
"name": "Apple M4 Pro",
|
||||
"vendor": "",
|
||||
"driver": "",
|
||||
"backend": "metal",
|
||||
"kind": "integrated"
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 32,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": true,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 17.712,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 1528.444,
|
||||
"stdev": 181.21,
|
||||
"min": 1319.58,
|
||||
"max": 1643.783,
|
||||
"ci95": 450.188
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 12368.716,
|
||||
"stdev": 6475.37,
|
||||
"min": 7168.429,
|
||||
"max": 19621.62,
|
||||
"ci95": 16087.01
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 6926.208,
|
||||
"stdev": 3712.691,
|
||||
"min": 4208.555,
|
||||
"max": 11156.419,
|
||||
"ci95": 9223.581
|
||||
},
|
||||
"chunks.village.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 46790.333,
|
||||
"stdev": 1381.89,
|
||||
"min": 45236.0,
|
||||
"max": 47880.0,
|
||||
"ci95": 3433.082
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 453.505,
|
||||
"stdev": 14.384,
|
||||
"min": 441.517,
|
||||
"max": 469.455,
|
||||
"ci95": 35.735
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 222.897,
|
||||
"stdev": 10.449,
|
||||
"min": 212.173,
|
||||
"max": 233.048,
|
||||
"ci95": 25.96
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 173.55,
|
||||
"stdev": 31.798,
|
||||
"min": 142.967,
|
||||
"max": 206.437,
|
||||
"ci95": 78.996
|
||||
},
|
||||
"memory.village.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1544.565,
|
||||
"stdev": 26.422,
|
||||
"min": 1516.75,
|
||||
"max": 1569.328,
|
||||
"ci95": 65.64
|
||||
},
|
||||
"tick.village.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.528,
|
||||
"stdev": 0.018,
|
||||
"min": 0.511,
|
||||
"max": 0.548,
|
||||
"ci95": 0.045
|
||||
},
|
||||
"cpu.village.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.496,
|
||||
"stdev": 0.049,
|
||||
"min": 1.453,
|
||||
"max": 1.549,
|
||||
"ci95": 0.121
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 8297.116,
|
||||
"stdev": 689.909,
|
||||
"min": 7716.447,
|
||||
"max": 9059.776,
|
||||
"ci95": 1713.966
|
||||
},
|
||||
"chunks.forest.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 78617.667,
|
||||
"stdev": 494.532,
|
||||
"min": 78228.0,
|
||||
"max": 79174.0,
|
||||
"ci95": 1228.586
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 216.265,
|
||||
"stdev": 6.372,
|
||||
"min": 209.501,
|
||||
"max": 222.156,
|
||||
"ci95": 15.831
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 137.027,
|
||||
"stdev": 3.787,
|
||||
"min": 133.07,
|
||||
"max": 140.617,
|
||||
"ci95": 9.408
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 114.683,
|
||||
"stdev": 7.985,
|
||||
"min": 106.585,
|
||||
"max": 122.551,
|
||||
"ci95": 19.839
|
||||
},
|
||||
"memory.forest.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1420.638,
|
||||
"stdev": 247.812,
|
||||
"min": 1266.047,
|
||||
"max": 1706.469,
|
||||
"ci95": 615.649
|
||||
},
|
||||
"tick.forest.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.423,
|
||||
"stdev": 0.059,
|
||||
"min": 0.355,
|
||||
"max": 0.458,
|
||||
"ci95": 0.148
|
||||
},
|
||||
"cpu.forest.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.605,
|
||||
"stdev": 0.269,
|
||||
"min": 1.295,
|
||||
"max": 1.779,
|
||||
"ci95": 0.668
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 8354.659,
|
||||
"stdev": 2877.749,
|
||||
"min": 5454.947,
|
||||
"max": 11209.93,
|
||||
"ci95": 7149.301
|
||||
},
|
||||
"chunks.ocean.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 74315.667,
|
||||
"stdev": 63.106,
|
||||
"min": 74246.0,
|
||||
"max": 74369.0,
|
||||
"ci95": 156.776
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 274.828,
|
||||
"stdev": 4.993,
|
||||
"min": 270.041,
|
||||
"max": 280.004,
|
||||
"ci95": 12.404
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 165.765,
|
||||
"stdev": 7.21,
|
||||
"min": 157.538,
|
||||
"max": 170.985,
|
||||
"ci95": 17.912
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 140.185,
|
||||
"stdev": 9.74,
|
||||
"min": 129.976,
|
||||
"max": 149.377,
|
||||
"ci95": 24.198
|
||||
},
|
||||
"memory.ocean.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1306.578,
|
||||
"stdev": 97.011,
|
||||
"min": 1199.094,
|
||||
"max": 1387.641,
|
||||
"ci95": 241.007
|
||||
},
|
||||
"tick.ocean.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.416,
|
||||
"stdev": 0.029,
|
||||
"min": 0.392,
|
||||
"max": 0.448,
|
||||
"ci95": 0.071
|
||||
},
|
||||
"cpu.ocean.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.636,
|
||||
"stdev": 0.121,
|
||||
"min": 1.496,
|
||||
"max": 1.708,
|
||||
"ci95": 0.301
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 17124.873,
|
||||
"stdev": 3461.08,
|
||||
"min": 13599.217,
|
||||
"max": 20517.544,
|
||||
"ci95": 8598.494
|
||||
},
|
||||
"chunks.mountain.meshed_count": {
|
||||
"n": 3,
|
||||
"mean": 91909.0,
|
||||
"stdev": 815.603,
|
||||
"min": 91157.0,
|
||||
"max": 92776.0,
|
||||
"ci95": 2026.235
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 242.657,
|
||||
"stdev": 3.405,
|
||||
"min": 240.66,
|
||||
"max": 246.588,
|
||||
"ci95": 8.459
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 110.95,
|
||||
"stdev": 14.622,
|
||||
"min": 99.907,
|
||||
"max": 127.532,
|
||||
"ci95": 36.325
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 51.329,
|
||||
"stdev": 28.253,
|
||||
"min": 30.391,
|
||||
"max": 83.465,
|
||||
"ci95": 70.191
|
||||
},
|
||||
"memory.mountain.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1451.688,
|
||||
"stdev": 127.471,
|
||||
"min": 1310.969,
|
||||
"max": 1559.43,
|
||||
"ci95": 316.681
|
||||
},
|
||||
"tick.mountain.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.614,
|
||||
"stdev": 0.017,
|
||||
"min": 0.594,
|
||||
"max": 0.627,
|
||||
"ci95": 0.043
|
||||
},
|
||||
"cpu.mountain.p50_ms": {
|
||||
"n": 3,
|
||||
"mean": 2.233,
|
||||
"stdev": 0.05,
|
||||
"min": 2.194,
|
||||
"max": 2.289,
|
||||
"ci95": 0.124
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 1697.984,
|
||||
"stdev": 29.735,
|
||||
"min": 1667.406,
|
||||
"max": 1726.797,
|
||||
"ci95": 73.871
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1428.19,
|
||||
"stdev": 120.658,
|
||||
"min": 1333.0,
|
||||
"max": 1563.891,
|
||||
"ci95": 299.756
|
||||
},
|
||||
"memory.footprint_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 4921.753,
|
||||
"stdev": 30.436,
|
||||
"min": 4890.971,
|
||||
"max": 4951.831,
|
||||
"ci95": 75.613
|
||||
},
|
||||
"memory.footprint_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 4079.43,
|
||||
"stdev": 10.978,
|
||||
"min": 4068.049,
|
||||
"max": 4089.956,
|
||||
"ci95": 27.273
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 5634,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 469.455,
|
||||
"low_1pct_fps": 223.469,
|
||||
"low_01pct_fps": 171.246,
|
||||
"p50_ms": 2.054,
|
||||
"p95_ms": 2.492,
|
||||
"p99_ms": 3.993,
|
||||
"p999_ms": 5.425,
|
||||
"max_ms": 6.645,
|
||||
"cpu_p50_ms": 1.453,
|
||||
"gpu_p50_ms": 1.965,
|
||||
"histogram": [
|
||||
0,
|
||||
2082,
|
||||
3324,
|
||||
193,
|
||||
24,
|
||||
9,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 5395,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 449.543,
|
||||
"low_1pct_fps": 233.048,
|
||||
"low_01pct_fps": 206.437,
|
||||
"p50_ms": 2.155,
|
||||
"p95_ms": 2.611,
|
||||
"p99_ms": 4.068,
|
||||
"p999_ms": 4.456,
|
||||
"max_ms": 5.305,
|
||||
"cpu_p50_ms": 1.549,
|
||||
"gpu_p50_ms": 2.043,
|
||||
"histogram": [
|
||||
0,
|
||||
902,
|
||||
4255,
|
||||
206,
|
||||
29,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 5298,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 441.517,
|
||||
"low_1pct_fps": 212.173,
|
||||
"low_01pct_fps": 142.967,
|
||||
"p50_ms": 2.171,
|
||||
"p95_ms": 3.249,
|
||||
"p99_ms": 4.138,
|
||||
"p999_ms": 5.591,
|
||||
"max_ms": 9.676,
|
||||
"cpu_p50_ms": 1.485,
|
||||
"gpu_p50_ms": 2.092,
|
||||
"histogram": [
|
||||
0,
|
||||
709,
|
||||
4314,
|
||||
229,
|
||||
36,
|
||||
6,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 2606,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 217.137,
|
||||
"low_1pct_fps": 140.617,
|
||||
"low_01pct_fps": 106.585,
|
||||
"p50_ms": 4.471,
|
||||
"p95_ms": 5.926,
|
||||
"p99_ms": 6.575,
|
||||
"p999_ms": 7.735,
|
||||
"max_ms": 12.187,
|
||||
"cpu_p50_ms": 1.74,
|
||||
"gpu_p50_ms": 4.238,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
221,
|
||||
2094,
|
||||
172,
|
||||
110,
|
||||
8,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 2514,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 209.501,
|
||||
"low_1pct_fps": 137.393,
|
||||
"low_01pct_fps": 122.551,
|
||||
"p50_ms": 4.627,
|
||||
"p95_ms": 6.304,
|
||||
"p99_ms": 6.93,
|
||||
"p999_ms": 7.971,
|
||||
"max_ms": 8.506,
|
||||
"cpu_p50_ms": 1.779,
|
||||
"gpu_p50_ms": 4.409,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
35,
|
||||
2097,
|
||||
200,
|
||||
162,
|
||||
17,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 2667,
|
||||
"seconds": 12.005,
|
||||
"avg_fps": 222.156,
|
||||
"low_1pct_fps": 133.07,
|
||||
"low_01pct_fps": 114.913,
|
||||
"p50_ms": 4.37,
|
||||
"p95_ms": 5.756,
|
||||
"p99_ms": 6.742,
|
||||
"p999_ms": 8.444,
|
||||
"max_ms": 8.984,
|
||||
"cpu_p50_ms": 1.295,
|
||||
"gpu_p50_ms": 4.152,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
440,
|
||||
1978,
|
||||
144,
|
||||
85,
|
||||
16,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 3294,
|
||||
"seconds": 12.003,
|
||||
"avg_fps": 274.44,
|
||||
"low_1pct_fps": 157.538,
|
||||
"low_01pct_fps": 129.976,
|
||||
"p50_ms": 3.521,
|
||||
"p95_ms": 4.814,
|
||||
"p99_ms": 5.592,
|
||||
"p999_ms": 7.491,
|
||||
"max_ms": 7.868,
|
||||
"cpu_p50_ms": 1.703,
|
||||
"gpu_p50_ms": 3.277,
|
||||
"histogram": [
|
||||
0,
|
||||
4,
|
||||
55,
|
||||
2967,
|
||||
130,
|
||||
122,
|
||||
10,
|
||||
6,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 3241,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 270.041,
|
||||
"low_1pct_fps": 168.771,
|
||||
"low_01pct_fps": 141.201,
|
||||
"p50_ms": 3.578,
|
||||
"p95_ms": 4.942,
|
||||
"p99_ms": 5.631,
|
||||
"p999_ms": 6.498,
|
||||
"max_ms": 7.601,
|
||||
"cpu_p50_ms": 1.708,
|
||||
"gpu_p50_ms": 3.323,
|
||||
"histogram": [
|
||||
0,
|
||||
2,
|
||||
8,
|
||||
2970,
|
||||
110,
|
||||
144,
|
||||
5,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 3360,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 280.004,
|
||||
"low_1pct_fps": 170.985,
|
||||
"low_01pct_fps": 149.377,
|
||||
"p50_ms": 3.477,
|
||||
"p95_ms": 4.678,
|
||||
"p99_ms": 5.462,
|
||||
"p999_ms": 6.626,
|
||||
"max_ms": 6.788,
|
||||
"cpu_p50_ms": 1.496,
|
||||
"gpu_p50_ms": 3.23,
|
||||
"histogram": [
|
||||
0,
|
||||
1,
|
||||
5,
|
||||
3125,
|
||||
129,
|
||||
90,
|
||||
10,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 2888,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 240.66,
|
||||
"low_1pct_fps": 127.532,
|
||||
"low_01pct_fps": 83.465,
|
||||
"p50_ms": 3.984,
|
||||
"p95_ms": 5.171,
|
||||
"p99_ms": 6.823,
|
||||
"p999_ms": 9.168,
|
||||
"max_ms": 15.341,
|
||||
"cpu_p50_ms": 2.194,
|
||||
"gpu_p50_ms": 3.786,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
2048,
|
||||
638,
|
||||
144,
|
||||
33,
|
||||
16,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 2889,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 240.722,
|
||||
"low_1pct_fps": 99.907,
|
||||
"low_01pct_fps": 40.132,
|
||||
"p50_ms": 3.961,
|
||||
"p95_ms": 5.706,
|
||||
"p99_ms": 7.364,
|
||||
"p999_ms": 12.597,
|
||||
"max_ms": 49.156,
|
||||
"cpu_p50_ms": 2.289,
|
||||
"gpu_p50_ms": 3.802,
|
||||
"histogram": [
|
||||
0,
|
||||
5,
|
||||
126,
|
||||
1859,
|
||||
599,
|
||||
194,
|
||||
72,
|
||||
22,
|
||||
8,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 2959,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 246.588,
|
||||
"low_1pct_fps": 105.411,
|
||||
"low_01pct_fps": 30.391,
|
||||
"p50_ms": 3.851,
|
||||
"p95_ms": 5.353,
|
||||
"p99_ms": 6.248,
|
||||
"p999_ms": 9.05,
|
||||
"max_ms": 54.378,
|
||||
"cpu_p50_ms": 2.216,
|
||||
"gpu_p50_ms": 3.667,
|
||||
"histogram": [
|
||||
0,
|
||||
2,
|
||||
58,
|
||||
2196,
|
||||
462,
|
||||
196,
|
||||
32,
|
||||
10,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "horizon-macmini"
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
# Horizon Nightly 2026-11-04 vs vanilla 26.3 on the Mac mini and the desktop (2026-11-04)
|
||||
|
||||
The runs behind playhorizon.dev/benchmarks as of 2026-11-04 (horizon-rust/horizon-site#58): `tools/perf_bench.py`
|
||||
reports, render distance 12 and 32, three games each, every game from a fresh world (seed 1234), 1280x720, VSync off. Files:
|
||||
`{horizon,vanilla}-macmini-rd{12,32}.json` and `{horizon,vanilla}-desktop-rd{12,32}.json` next to this page.
|
||||
|
||||
## Builds
|
||||
|
||||
- **Horizon:** the Nightly 2026-11-04 release bundle (67dad0d), as the launcher installs it: macOS arm64 on the Mac,
|
||||
Linux x86_64 on the desktop. Its resources come from the bundle's own `horizon-prepare`. perf_bench ran from main
|
||||
a55b9a2e52 and wrote that checkout's crate version and commit; `app.version` and `app.commit` were set to the bundle's by hand.
|
||||
- **Vanilla:** the 26.3 client jar with Fabric Loader and the driver mod, Java 25, the official launcher's JVM flags.
|
||||
|
||||
## Machines and conditions
|
||||
|
||||
- **Mac mini, Apple M4 Pro (14 cores, 20-core GPU), macOS.** The session was locked, so Horizon ran `--offscreen`: a locked
|
||||
session gives a window no frames, and windowed Horizon measured 0 FPS. Vanilla drew into its window. Offscreen skips
|
||||
presenting, which favours Horizon, so its averages aren't held at the 144 Hz display as they were on 2026-10-22. Each game
|
||||
started at a 1-minute load of 17-18 (the run waited for < 20); during the runs the load rose to 30-50.
|
||||
- **Desktop, RTX 4080 + i7-13700KF, NixOS, NVIDIA 595.** Both games drew into a headless sway rendering on the RTX 4080 (never
|
||||
on its screen), both through Wayland on Vulkan. Vanilla went through SDL's Wayland driver: through Xwayland each of its
|
||||
frames took about 2 s on this day. The machine also runs CI and agents' builds: each game started at a load of 28-30 on
|
||||
24 threads (the run waited for < 30) and the load rose to 40-70 during the runs. Vanilla at RD 32 crashed twice under that
|
||||
load (`GPU timeout attempting to acquire next frame`) before a third series finished. Start-up times are inflated by the
|
||||
load and by a cold disk on each series' first start (start → world up to 130-235 s).
|
||||
- **Order:** RD 12 ran Horizon then vanilla; RD 32 ran vanilla then Horizon.
|
||||
|
||||
## Results (average over the flights of the three games; FPS average / 1% low)
|
||||
|
||||
| Machine | RD | Horizon | Vanilla | Horizon ÷ vanilla (avg, 1% low) |
|
||||
|---|---|---|---|---|
|
||||
| Mac mini | 12 | 774 / 294 (offscreen) | 132 / 27 | 5.9x, 10.9x |
|
||||
| Mac mini | 32 | 297 / 159 (offscreen) | 68 / 16 | 4.4x, 9.9x |
|
||||
| Desktop | 12 | 2,224 / 308 | 1,824 / 201 | 1.2x, 1.5x |
|
||||
| Desktop | 32 | 800 / 140 | 696 / 64 | 1.15x, 2.2x |
|
||||
|
||||
Neither machine was idle, so these say how both games behave on busy machines. They are not clean ratios; for those, see
|
||||
`windows-9800x3d-5080-2026-10-29.md`. The scripts and logs are in horizon-parity `shots/site-bench-2026-11-04/`.
|
||||
@@ -0,0 +1,787 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-04T13:28:55Z",
|
||||
"app": {
|
||||
"name": "minecraft",
|
||||
"version": "26.3",
|
||||
"mods": [
|
||||
{
|
||||
"name": "fabric-loader",
|
||||
"version": "0.19.5"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"os": "linux",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 24,
|
||||
"os_version": "NixOS 26.05 (Yarara) (kernel 6.18.57)",
|
||||
"cpu": "13th Gen Intel(R) Core(TM) i7-13700KF",
|
||||
"ram_mb": 31904,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 4080",
|
||||
"vendor": "NVIDIA",
|
||||
"driver": "1.4.329 NVIDIA 595.71.05",
|
||||
"backend": "vulkan",
|
||||
"kind": ""
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 12,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 28.788,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 121075.467,
|
||||
"stdev": 30646.429,
|
||||
"min": 93272.034,
|
||||
"max": 153935.834,
|
||||
"ci95": 76136.094
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 191063.8,
|
||||
"stdev": 34368.16,
|
||||
"min": 153895.533,
|
||||
"max": 221692.034,
|
||||
"ci95": 85382.134
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 18724.322,
|
||||
"stdev": 9639.048,
|
||||
"min": 13107.549,
|
||||
"max": 29854.376,
|
||||
"ci95": 23946.655
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 2161.532,
|
||||
"stdev": 287.109,
|
||||
"min": 1830.069,
|
||||
"max": 2332.81,
|
||||
"ci95": 713.276
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 162.575,
|
||||
"stdev": 29.799,
|
||||
"min": 136.023,
|
||||
"max": 194.804,
|
||||
"ci95": 74.03
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 27.752,
|
||||
"stdev": 3.186,
|
||||
"min": 24.403,
|
||||
"max": 30.746,
|
||||
"ci95": 7.916
|
||||
},
|
||||
"memory.village.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 13.0,
|
||||
"stdev": 0.0,
|
||||
"min": 13.0,
|
||||
"max": 13.0,
|
||||
"ci95": 0.0
|
||||
},
|
||||
"memory.village.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 2902.054,
|
||||
"stdev": 158.708,
|
||||
"min": 2747.244,
|
||||
"max": 3064.393,
|
||||
"ci95": 394.285
|
||||
},
|
||||
"tick.village.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.691,
|
||||
"stdev": 0.097,
|
||||
"min": 0.601,
|
||||
"max": 0.794,
|
||||
"ci95": 0.241
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 8852.332,
|
||||
"stdev": 530.809,
|
||||
"min": 8350.811,
|
||||
"max": 9408.241,
|
||||
"ci95": 1318.709
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1841.581,
|
||||
"stdev": 1186.781,
|
||||
"min": 471.209,
|
||||
"max": 2529.847,
|
||||
"ci95": 2948.365
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 255.087,
|
||||
"stdev": 217.128,
|
||||
"min": 5.798,
|
||||
"max": 402.877,
|
||||
"ci95": 539.419
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 92.882,
|
||||
"stdev": 81.722,
|
||||
"min": 0.61,
|
||||
"max": 156.13,
|
||||
"ci95": 203.024
|
||||
},
|
||||
"memory.forest.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 13.0,
|
||||
"stdev": 0.0,
|
||||
"min": 13.0,
|
||||
"max": 13.0,
|
||||
"ci95": 0.0
|
||||
},
|
||||
"memory.forest.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 3008.178,
|
||||
"stdev": 149.07,
|
||||
"min": 2875.082,
|
||||
"max": 3169.256,
|
||||
"ci95": 370.34
|
||||
},
|
||||
"tick.forest.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.687,
|
||||
"stdev": 0.132,
|
||||
"min": 0.562,
|
||||
"max": 0.825,
|
||||
"ci95": 0.327
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 21215.745,
|
||||
"stdev": 22821.547,
|
||||
"min": 8034.077,
|
||||
"max": 47567.797,
|
||||
"ci95": 56696.441
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1525.483,
|
||||
"stdev": 1472.651,
|
||||
"min": 633.984,
|
||||
"max": 3225.275,
|
||||
"ci95": 3658.563
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 191.398,
|
||||
"stdev": 272.942,
|
||||
"min": 10.346,
|
||||
"max": 505.335,
|
||||
"ci95": 678.08
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 49.506,
|
||||
"stdev": 66.145,
|
||||
"min": 1.104,
|
||||
"max": 124.874,
|
||||
"ci95": 164.326
|
||||
},
|
||||
"memory.ocean.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 13.0,
|
||||
"stdev": 0.0,
|
||||
"min": 13.0,
|
||||
"max": 13.0,
|
||||
"ci95": 0.0
|
||||
},
|
||||
"memory.ocean.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 3062.136,
|
||||
"stdev": 128.594,
|
||||
"min": 2962.541,
|
||||
"max": 3207.313,
|
||||
"ci95": 319.472
|
||||
},
|
||||
"tick.ocean.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.029,
|
||||
"stdev": 0.573,
|
||||
"min": 0.595,
|
||||
"max": 1.678,
|
||||
"ci95": 1.423
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 33255.15,
|
||||
"stdev": 20451.99,
|
||||
"min": 16655.144,
|
||||
"max": 56102.104,
|
||||
"ci95": 50809.661
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1769.12,
|
||||
"stdev": 996.863,
|
||||
"min": 818.512,
|
||||
"max": 2806.557,
|
||||
"ci95": 2476.544
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 195.916,
|
||||
"stdev": 195.285,
|
||||
"min": 11.586,
|
||||
"max": 400.567,
|
||||
"ci95": 485.154
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 52.202,
|
||||
"stdev": 51.208,
|
||||
"min": 1.461,
|
||||
"max": 103.864,
|
||||
"ci95": 127.217
|
||||
},
|
||||
"memory.mountain.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 13.0,
|
||||
"stdev": 0.0,
|
||||
"min": 13.0,
|
||||
"max": 13.0,
|
||||
"ci95": 0.0
|
||||
},
|
||||
"memory.mountain.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 2957.948,
|
||||
"stdev": 340.935,
|
||||
"min": 2586.504,
|
||||
"max": 3256.625,
|
||||
"ci95": 846.998
|
||||
},
|
||||
"tick.mountain.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.436,
|
||||
"stdev": 1.406,
|
||||
"min": 0.462,
|
||||
"max": 3.048,
|
||||
"ci95": 3.494
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 3107.953,
|
||||
"stdev": 141.888,
|
||||
"min": 3020.105,
|
||||
"max": 3271.645,
|
||||
"ci95": 352.498
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 2985.317,
|
||||
"stdev": 210.327,
|
||||
"min": 2761.164,
|
||||
"max": 3178.35,
|
||||
"ci95": 522.523
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 21963,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 1830.069,
|
||||
"low_1pct_fps": 136.023,
|
||||
"low_01pct_fps": 30.746,
|
||||
"p50_ms": 0.331,
|
||||
"p95_ms": 1.153,
|
||||
"p99_ms": 3.001,
|
||||
"p999_ms": 9.997,
|
||||
"max_ms": 326.36,
|
||||
"histogram": [
|
||||
20336,
|
||||
1238,
|
||||
167,
|
||||
108,
|
||||
40,
|
||||
23,
|
||||
12,
|
||||
10,
|
||||
8,
|
||||
7,
|
||||
5,
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 27994,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2332.81,
|
||||
"low_1pct_fps": 194.804,
|
||||
"low_01pct_fps": 28.107,
|
||||
"p50_ms": 0.321,
|
||||
"p95_ms": 0.771,
|
||||
"p99_ms": 1.356,
|
||||
"p999_ms": 3.836,
|
||||
"max_ms": 331.931,
|
||||
"histogram": [
|
||||
27270,
|
||||
652,
|
||||
34,
|
||||
13,
|
||||
5,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 27861,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2321.717,
|
||||
"low_1pct_fps": 156.898,
|
||||
"low_01pct_fps": 24.403,
|
||||
"p50_ms": 0.281,
|
||||
"p95_ms": 0.852,
|
||||
"p99_ms": 1.73,
|
||||
"p999_ms": 6.331,
|
||||
"max_ms": 301.991,
|
||||
"histogram": [
|
||||
26932,
|
||||
726,
|
||||
124,
|
||||
42,
|
||||
4,
|
||||
4,
|
||||
3,
|
||||
2,
|
||||
9,
|
||||
2,
|
||||
3,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 30359,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2529.847,
|
||||
"low_1pct_fps": 356.586,
|
||||
"low_01pct_fps": 121.906,
|
||||
"p50_ms": 0.353,
|
||||
"p95_ms": 0.563,
|
||||
"p99_ms": 1.405,
|
||||
"p999_ms": 4.444,
|
||||
"max_ms": 20.302,
|
||||
"histogram": [
|
||||
29756,
|
||||
460,
|
||||
57,
|
||||
52,
|
||||
11,
|
||||
5,
|
||||
3,
|
||||
5,
|
||||
2,
|
||||
4,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 5781,
|
||||
"seconds": 12.268,
|
||||
"avg_fps": 471.209,
|
||||
"low_1pct_fps": 5.798,
|
||||
"low_01pct_fps": 0.61,
|
||||
"p50_ms": 0.359,
|
||||
"p95_ms": 0.77,
|
||||
"p99_ms": 1.78,
|
||||
"p999_ms": 20.826,
|
||||
"max_ms": 5834.37,
|
||||
"histogram": [
|
||||
5586,
|
||||
158,
|
||||
12,
|
||||
11,
|
||||
0,
|
||||
1,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 30285,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2523.687,
|
||||
"low_1pct_fps": 402.877,
|
||||
"low_01pct_fps": 156.13,
|
||||
"p50_ms": 0.351,
|
||||
"p95_ms": 0.539,
|
||||
"p99_ms": 1.574,
|
||||
"p999_ms": 3.683,
|
||||
"max_ms": 16.022,
|
||||
"histogram": [
|
||||
29666,
|
||||
490,
|
||||
74,
|
||||
35,
|
||||
7,
|
||||
2,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
5,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 11685,
|
||||
"seconds": 16.293,
|
||||
"avg_fps": 717.19,
|
||||
"low_1pct_fps": 10.346,
|
||||
"low_01pct_fps": 1.104,
|
||||
"p50_ms": 0.299,
|
||||
"p95_ms": 1.119,
|
||||
"p99_ms": 2.568,
|
||||
"p999_ms": 12.054,
|
||||
"max_ms": 5250.537,
|
||||
"histogram": [
|
||||
10993,
|
||||
502,
|
||||
102,
|
||||
39,
|
||||
14,
|
||||
8,
|
||||
6,
|
||||
4,
|
||||
4,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 38704,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 3225.275,
|
||||
"low_1pct_fps": 505.335,
|
||||
"low_01pct_fps": 124.874,
|
||||
"p50_ms": 0.283,
|
||||
"p95_ms": 0.36,
|
||||
"p99_ms": 0.909,
|
||||
"p999_ms": 2.015,
|
||||
"max_ms": 132.801,
|
||||
"histogram": [
|
||||
38385,
|
||||
279,
|
||||
25,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
2,
|
||||
4,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 7609,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 633.984,
|
||||
"low_1pct_fps": 58.513,
|
||||
"low_01pct_fps": 22.539,
|
||||
"p50_ms": 1.141,
|
||||
"p95_ms": 4.438,
|
||||
"p99_ms": 9.113,
|
||||
"p999_ms": 27.496,
|
||||
"max_ms": 64.424,
|
||||
"histogram": [
|
||||
3452,
|
||||
2398,
|
||||
956,
|
||||
368,
|
||||
143,
|
||||
96,
|
||||
61,
|
||||
46,
|
||||
20,
|
||||
29,
|
||||
6,
|
||||
10,
|
||||
6,
|
||||
7,
|
||||
7,
|
||||
0,
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 10168,
|
||||
"seconds": 12.423,
|
||||
"avg_fps": 818.512,
|
||||
"low_1pct_fps": 11.586,
|
||||
"low_01pct_fps": 1.461,
|
||||
"p50_ms": 0.267,
|
||||
"p95_ms": 0.892,
|
||||
"p99_ms": 3.176,
|
||||
"p999_ms": 198.757,
|
||||
"max_ms": 1882.64,
|
||||
"histogram": [
|
||||
9733,
|
||||
205,
|
||||
121,
|
||||
49,
|
||||
12,
|
||||
9,
|
||||
8,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
4,
|
||||
5,
|
||||
1,
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 33679,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 2806.557,
|
||||
"low_1pct_fps": 400.567,
|
||||
"low_01pct_fps": 103.864,
|
||||
"p50_ms": 0.324,
|
||||
"p95_ms": 0.411,
|
||||
"p99_ms": 1.167,
|
||||
"p999_ms": 3.508,
|
||||
"max_ms": 83.572,
|
||||
"histogram": [
|
||||
33272,
|
||||
315,
|
||||
46,
|
||||
22,
|
||||
2,
|
||||
1,
|
||||
4,
|
||||
2,
|
||||
9,
|
||||
1,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 20188,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1682.292,
|
||||
"low_1pct_fps": 175.594,
|
||||
"low_01pct_fps": 51.28,
|
||||
"p50_ms": 0.374,
|
||||
"p95_ms": 1.37,
|
||||
"p99_ms": 2.821,
|
||||
"p999_ms": 8.947,
|
||||
"max_ms": 53.494,
|
||||
"histogram": [
|
||||
18109,
|
||||
1592,
|
||||
314,
|
||||
83,
|
||||
36,
|
||||
19,
|
||||
7,
|
||||
6,
|
||||
6,
|
||||
1,
|
||||
3,
|
||||
1,
|
||||
4,
|
||||
2,
|
||||
3,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-desktop"
|
||||
}
|
||||
@@ -0,0 +1,787 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-04T15:46:25Z",
|
||||
"app": {
|
||||
"name": "minecraft",
|
||||
"version": "26.3",
|
||||
"mods": [
|
||||
{
|
||||
"name": "fabric-loader",
|
||||
"version": "0.19.5"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"os": "linux",
|
||||
"arch": "x86_64",
|
||||
"cpu_threads": 24,
|
||||
"os_version": "NixOS 26.05 (Yarara) (kernel 6.18.57)",
|
||||
"cpu": "13th Gen Intel(R) Core(TM) i7-13700KF",
|
||||
"ram_mb": 31904,
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 4080",
|
||||
"vendor": "NVIDIA",
|
||||
"driver": "1.4.329 NVIDIA 595.71.05",
|
||||
"backend": "vulkan",
|
||||
"kind": ""
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 32,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 28.799,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 112742.179,
|
||||
"stdev": 27885.804,
|
||||
"min": 81110.692,
|
||||
"max": 133773.748,
|
||||
"ci95": 69277.769
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 235049.179,
|
||||
"stdev": 31461.958,
|
||||
"min": 203486.748,
|
||||
"max": 266409.692,
|
||||
"ci95": 78162.144
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 79828.05,
|
||||
"stdev": 109107.365,
|
||||
"min": 16685.972,
|
||||
"max": 205814.266,
|
||||
"ci95": 271059.597
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 525.667,
|
||||
"stdev": 390.505,
|
||||
"min": 101.073,
|
||||
"max": 869.433,
|
||||
"ci95": 970.146
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 44.209,
|
||||
"stdev": 39.491,
|
||||
"min": 1.644,
|
||||
"max": 79.658,
|
||||
"ci95": 98.109
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 10.635,
|
||||
"stdev": 9.759,
|
||||
"min": 0.686,
|
||||
"max": 20.192,
|
||||
"ci95": 24.244
|
||||
},
|
||||
"memory.village.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 34.333,
|
||||
"stdev": 36.95,
|
||||
"min": 13.0,
|
||||
"max": 77.0,
|
||||
"ci95": 91.797
|
||||
},
|
||||
"memory.village.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 3296.229,
|
||||
"stdev": 296.464,
|
||||
"min": 2956.234,
|
||||
"max": 3500.773,
|
||||
"ci95": 736.517
|
||||
},
|
||||
"tick.village.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.23,
|
||||
"stdev": 0.449,
|
||||
"min": 0.897,
|
||||
"max": 1.74,
|
||||
"ci95": 1.114
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 61172.527,
|
||||
"stdev": 17434.762,
|
||||
"min": 46779.931,
|
||||
"max": 80559.429,
|
||||
"ci95": 43313.846
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 599.408,
|
||||
"stdev": 492.96,
|
||||
"min": 64.173,
|
||||
"max": 1034.813,
|
||||
"ci95": 1224.68
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 61.923,
|
||||
"stdev": 56.534,
|
||||
"min": 0.737,
|
||||
"max": 112.221,
|
||||
"ci95": 140.449
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 15.771,
|
||||
"stdev": 14.11,
|
||||
"min": 0.098,
|
||||
"max": 27.463,
|
||||
"ci95": 35.054
|
||||
},
|
||||
"memory.forest.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 34.333,
|
||||
"stdev": 36.95,
|
||||
"min": 13.0,
|
||||
"max": 77.0,
|
||||
"ci95": 91.797
|
||||
},
|
||||
"memory.forest.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 3278.964,
|
||||
"stdev": 301.465,
|
||||
"min": 2947.268,
|
||||
"max": 3536.27,
|
||||
"ci95": 748.941
|
||||
},
|
||||
"tick.forest.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 5.908,
|
||||
"stdev": 8.813,
|
||||
"min": 0.693,
|
||||
"max": 16.084,
|
||||
"ci95": 21.895
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 61762.098,
|
||||
"stdev": 3569.17,
|
||||
"min": 57919.097,
|
||||
"max": 64972.973,
|
||||
"ci95": 8867.026
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 1175.044,
|
||||
"stdev": 131.94,
|
||||
"min": 1093.801,
|
||||
"max": 1327.281,
|
||||
"ci95": 327.785
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 119.758,
|
||||
"stdev": 68.419,
|
||||
"min": 77.252,
|
||||
"max": 198.683,
|
||||
"ci95": 169.976
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 24.224,
|
||||
"stdev": 18.982,
|
||||
"min": 12.308,
|
||||
"max": 46.114,
|
||||
"ci95": 47.159
|
||||
},
|
||||
"memory.ocean.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 34.333,
|
||||
"stdev": 36.95,
|
||||
"min": 13.0,
|
||||
"max": 77.0,
|
||||
"ci95": 91.797
|
||||
},
|
||||
"memory.ocean.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 3275.349,
|
||||
"stdev": 317.864,
|
||||
"min": 2924.113,
|
||||
"max": 3543.23,
|
||||
"ci95": 789.681
|
||||
},
|
||||
"tick.ocean.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.654,
|
||||
"stdev": 0.066,
|
||||
"min": 0.579,
|
||||
"max": 0.699,
|
||||
"ci95": 0.163
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 113439.529,
|
||||
"stdev": 64746.162,
|
||||
"min": 55046.887,
|
||||
"max": 183068.386,
|
||||
"ci95": 160851.363
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 485.832,
|
||||
"stdev": 317.254,
|
||||
"min": 121.312,
|
||||
"max": 699.617,
|
||||
"ci95": 788.165
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 28.612,
|
||||
"stdev": 23.334,
|
||||
"min": 2.229,
|
||||
"max": 46.542,
|
||||
"ci95": 57.97
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 4.56,
|
||||
"stdev": 3.408,
|
||||
"min": 0.75,
|
||||
"max": 7.318,
|
||||
"ci95": 8.467
|
||||
},
|
||||
"memory.mountain.gpu_mb": {
|
||||
"n": 3,
|
||||
"mean": 34.333,
|
||||
"stdev": 36.95,
|
||||
"min": 13.0,
|
||||
"max": 77.0,
|
||||
"ci95": 91.797
|
||||
},
|
||||
"memory.mountain.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 3248.708,
|
||||
"stdev": 480.962,
|
||||
"min": 2700.645,
|
||||
"max": 3600.484,
|
||||
"ci95": 1194.873
|
||||
},
|
||||
"tick.mountain.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.832,
|
||||
"stdev": 2.076,
|
||||
"min": 0.618,
|
||||
"max": 4.229,
|
||||
"ci95": 5.157
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 3371.958,
|
||||
"stdev": 280.347,
|
||||
"min": 3059.926,
|
||||
"max": 3602.613,
|
||||
"ci95": 696.476
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 3291.249,
|
||||
"stdev": 316.441,
|
||||
"min": 2935.975,
|
||||
"max": 3542.848,
|
||||
"ci95": 786.147
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 1248,
|
||||
"seconds": 12.348,
|
||||
"avg_fps": 101.073,
|
||||
"low_1pct_fps": 1.644,
|
||||
"low_01pct_fps": 0.686,
|
||||
"p50_ms": 1.225,
|
||||
"p95_ms": 11.161,
|
||||
"p99_ms": 226.597,
|
||||
"p999_ms": 1457.605,
|
||||
"max_ms": 1457.874,
|
||||
"histogram": [
|
||||
548,
|
||||
232,
|
||||
97,
|
||||
118,
|
||||
51,
|
||||
49,
|
||||
24,
|
||||
39,
|
||||
16,
|
||||
17,
|
||||
13,
|
||||
6,
|
||||
3,
|
||||
2,
|
||||
5,
|
||||
3,
|
||||
2,
|
||||
2,
|
||||
4,
|
||||
2,
|
||||
3,
|
||||
8,
|
||||
1,
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 10435,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 869.433,
|
||||
"low_1pct_fps": 79.658,
|
||||
"low_01pct_fps": 20.192,
|
||||
"p50_ms": 0.813,
|
||||
"p95_ms": 2.537,
|
||||
"p99_ms": 5.685,
|
||||
"p999_ms": 13.292,
|
||||
"max_ms": 351.251,
|
||||
"histogram": [
|
||||
7598,
|
||||
2071,
|
||||
393,
|
||||
171,
|
||||
55,
|
||||
53,
|
||||
22,
|
||||
24,
|
||||
20,
|
||||
10,
|
||||
9,
|
||||
2,
|
||||
2,
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 7278,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 606.495,
|
||||
"low_1pct_fps": 51.326,
|
||||
"low_01pct_fps": 11.026,
|
||||
"p50_ms": 1.105,
|
||||
"p95_ms": 3.854,
|
||||
"p99_ms": 7.067,
|
||||
"p999_ms": 24.484,
|
||||
"max_ms": 443.95,
|
||||
"histogram": [
|
||||
3206,
|
||||
2621,
|
||||
800,
|
||||
360,
|
||||
112,
|
||||
71,
|
||||
34,
|
||||
26,
|
||||
12,
|
||||
13,
|
||||
3,
|
||||
5,
|
||||
2,
|
||||
7,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 975,
|
||||
"seconds": 15.193,
|
||||
"avg_fps": 64.173,
|
||||
"low_1pct_fps": 0.737,
|
||||
"low_01pct_fps": 0.098,
|
||||
"p50_ms": 0.864,
|
||||
"p95_ms": 5.083,
|
||||
"p99_ms": 77.689,
|
||||
"p999_ms": 10225.237,
|
||||
"max_ms": 10225.237,
|
||||
"histogram": [
|
||||
680,
|
||||
99,
|
||||
104,
|
||||
12,
|
||||
30,
|
||||
13,
|
||||
8,
|
||||
4,
|
||||
2,
|
||||
3,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 8391,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 699.237,
|
||||
"low_1pct_fps": 72.811,
|
||||
"low_01pct_fps": 19.752,
|
||||
"p50_ms": 1.087,
|
||||
"p95_ms": 2.927,
|
||||
"p99_ms": 5.909,
|
||||
"p999_ms": 15.268,
|
||||
"max_ms": 252.557,
|
||||
"histogram": [
|
||||
3342,
|
||||
3998,
|
||||
656,
|
||||
231,
|
||||
56,
|
||||
26,
|
||||
15,
|
||||
19,
|
||||
12,
|
||||
12,
|
||||
11,
|
||||
6,
|
||||
1,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 12418,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1034.813,
|
||||
"low_1pct_fps": 112.221,
|
||||
"low_01pct_fps": 27.463,
|
||||
"p50_ms": 0.837,
|
||||
"p95_ms": 1.394,
|
||||
"p99_ms": 3.491,
|
||||
"p999_ms": 13.217,
|
||||
"max_ms": 245.59,
|
||||
"histogram": [
|
||||
11155,
|
||||
1004,
|
||||
100,
|
||||
64,
|
||||
29,
|
||||
16,
|
||||
16,
|
||||
10,
|
||||
2,
|
||||
6,
|
||||
5,
|
||||
3,
|
||||
4,
|
||||
2,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 13126,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1093.801,
|
||||
"low_1pct_fps": 77.252,
|
||||
"low_01pct_fps": 12.308,
|
||||
"p50_ms": 0.714,
|
||||
"p95_ms": 1.402,
|
||||
"p99_ms": 2.875,
|
||||
"p999_ms": 12.067,
|
||||
"max_ms": 238.158,
|
||||
"histogram": [
|
||||
11605,
|
||||
1207,
|
||||
191,
|
||||
54,
|
||||
17,
|
||||
11,
|
||||
7,
|
||||
12,
|
||||
4,
|
||||
4,
|
||||
2,
|
||||
3,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 15928,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1327.281,
|
||||
"low_1pct_fps": 198.683,
|
||||
"low_01pct_fps": 46.114,
|
||||
"p50_ms": 0.67,
|
||||
"p95_ms": 1.172,
|
||||
"p99_ms": 2.111,
|
||||
"p999_ms": 7.067,
|
||||
"max_ms": 193.743,
|
||||
"histogram": [
|
||||
14891,
|
||||
856,
|
||||
102,
|
||||
42,
|
||||
7,
|
||||
6,
|
||||
8,
|
||||
6,
|
||||
0,
|
||||
5,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 13249,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 1104.051,
|
||||
"low_1pct_fps": 83.338,
|
||||
"low_01pct_fps": 14.249,
|
||||
"p50_ms": 0.711,
|
||||
"p95_ms": 1.403,
|
||||
"p99_ms": 3.046,
|
||||
"p999_ms": 10.625,
|
||||
"max_ms": 451.869,
|
||||
"histogram": [
|
||||
11824,
|
||||
1165,
|
||||
124,
|
||||
58,
|
||||
13,
|
||||
16,
|
||||
11,
|
||||
14,
|
||||
7,
|
||||
7,
|
||||
3,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 1469,
|
||||
"seconds": 12.109,
|
||||
"avg_fps": 121.312,
|
||||
"low_1pct_fps": 2.229,
|
||||
"low_01pct_fps": 0.75,
|
||||
"p50_ms": 2.12,
|
||||
"p95_ms": 13.173,
|
||||
"p99_ms": 125.189,
|
||||
"p999_ms": 727.045,
|
||||
"max_ms": 1939.723,
|
||||
"histogram": [
|
||||
196,
|
||||
440,
|
||||
438,
|
||||
136,
|
||||
50,
|
||||
36,
|
||||
24,
|
||||
25,
|
||||
22,
|
||||
17,
|
||||
17,
|
||||
13,
|
||||
6,
|
||||
11,
|
||||
8,
|
||||
4,
|
||||
1,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
3,
|
||||
7,
|
||||
2,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 8396,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 699.617,
|
||||
"low_1pct_fps": 46.542,
|
||||
"low_01pct_fps": 7.318,
|
||||
"p50_ms": 1.022,
|
||||
"p95_ms": 2.433,
|
||||
"p99_ms": 5.528,
|
||||
"p999_ms": 13.993,
|
||||
"max_ms": 305.464,
|
||||
"histogram": [
|
||||
3248,
|
||||
4509,
|
||||
361,
|
||||
113,
|
||||
52,
|
||||
37,
|
||||
21,
|
||||
26,
|
||||
14,
|
||||
4,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 7639,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 636.566,
|
||||
"low_1pct_fps": 37.064,
|
||||
"low_01pct_fps": 5.612,
|
||||
"p50_ms": 1.023,
|
||||
"p95_ms": 3.0,
|
||||
"p99_ms": 7.11,
|
||||
"p999_ms": 17.521,
|
||||
"max_ms": 268.877,
|
||||
"histogram": [
|
||||
3051,
|
||||
3794,
|
||||
413,
|
||||
158,
|
||||
66,
|
||||
43,
|
||||
35,
|
||||
21,
|
||||
31,
|
||||
12,
|
||||
3,
|
||||
4,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
2,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-desktop"
|
||||
}
|
||||
@@ -0,0 +1,771 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-04T12:54:29Z",
|
||||
"app": {
|
||||
"name": "minecraft",
|
||||
"version": "26.3",
|
||||
"mods": [
|
||||
{
|
||||
"name": "fabric-loader",
|
||||
"version": "0.19.5"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"os": "macos",
|
||||
"arch": "aarch64",
|
||||
"cpu_threads": 14,
|
||||
"os_version": "27.0.1",
|
||||
"cpu": "Apple M4 Pro",
|
||||
"ram_mb": 65536,
|
||||
"gpu": {
|
||||
"name": "Apple M4 Pro",
|
||||
"vendor": "Apple",
|
||||
"driver": "4.1 Metal - 91.7",
|
||||
"backend": "opengl",
|
||||
"kind": ""
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 12,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 17.628,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 12652.182,
|
||||
"stdev": 3231.342,
|
||||
"min": 9526.004,
|
||||
"max": 15979.309,
|
||||
"ci95": 8027.746
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 31329.516,
|
||||
"stdev": 10016.129,
|
||||
"min": 20777.004,
|
||||
"max": 40705.309,
|
||||
"ci95": 24883.451
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 12623.084,
|
||||
"stdev": 4958.724,
|
||||
"min": 7251.986,
|
||||
"max": 17026.894,
|
||||
"ci95": 12319.147
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 92.729,
|
||||
"stdev": 58.527,
|
||||
"min": 28.713,
|
||||
"max": 143.496,
|
||||
"ci95": 145.402
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 16.52,
|
||||
"stdev": 10.949,
|
||||
"min": 4.001,
|
||||
"max": 24.308,
|
||||
"ci95": 27.2
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 11.515,
|
||||
"stdev": 7.155,
|
||||
"min": 3.549,
|
||||
"max": 17.394,
|
||||
"ci95": 17.774
|
||||
},
|
||||
"memory.village.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1834.469,
|
||||
"stdev": 329.198,
|
||||
"min": 1455.688,
|
||||
"max": 2051.516,
|
||||
"ci95": 817.839
|
||||
},
|
||||
"tick.village.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.903,
|
||||
"stdev": 1.114,
|
||||
"min": 1.131,
|
||||
"max": 3.18,
|
||||
"ci95": 2.767
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 14590.694,
|
||||
"stdev": 3260.615,
|
||||
"min": 10942.613,
|
||||
"max": 17221.108,
|
||||
"ci95": 8100.47
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 106.817,
|
||||
"stdev": 104.558,
|
||||
"min": 43.857,
|
||||
"max": 227.513,
|
||||
"ci95": 259.759
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 23.0,
|
||||
"stdev": 26.394,
|
||||
"min": 7.338,
|
||||
"max": 53.473,
|
||||
"ci95": 65.57
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 14.841,
|
||||
"stdev": 15.773,
|
||||
"min": 5.157,
|
||||
"max": 33.042,
|
||||
"ci95": 39.186
|
||||
},
|
||||
"memory.forest.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1814.245,
|
||||
"stdev": 154.974,
|
||||
"min": 1635.453,
|
||||
"max": 1910.125,
|
||||
"ci95": 385.007
|
||||
},
|
||||
"tick.forest.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.452,
|
||||
"stdev": 0.542,
|
||||
"min": 0.826,
|
||||
"max": 1.777,
|
||||
"ci95": 1.346
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 13309.049,
|
||||
"stdev": 8088.157,
|
||||
"min": 6394.403,
|
||||
"max": 22203.194,
|
||||
"ci95": 20093.716
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 164.405,
|
||||
"stdev": 141.4,
|
||||
"min": 62.162,
|
||||
"max": 325.77,
|
||||
"ci95": 351.285
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 36.242,
|
||||
"stdev": 37.051,
|
||||
"min": 14.736,
|
||||
"max": 79.024,
|
||||
"ci95": 92.047
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 20.898,
|
||||
"stdev": 17.899,
|
||||
"min": 8.855,
|
||||
"max": 41.466,
|
||||
"ci95": 44.468
|
||||
},
|
||||
"memory.ocean.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1724.453,
|
||||
"stdev": 347.692,
|
||||
"min": 1323.5,
|
||||
"max": 1942.734,
|
||||
"ci95": 863.784
|
||||
},
|
||||
"tick.ocean.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.866,
|
||||
"stdev": 0.248,
|
||||
"min": 0.58,
|
||||
"max": 1.03,
|
||||
"ci95": 0.617
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 17989.43,
|
||||
"stdev": 3969.295,
|
||||
"min": 13444.423,
|
||||
"max": 20774.261,
|
||||
"ci95": 9861.07
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 162.084,
|
||||
"stdev": 160.057,
|
||||
"min": 68.44,
|
||||
"max": 346.896,
|
||||
"ci95": 397.635
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 33.74,
|
||||
"stdev": 36.146,
|
||||
"min": 11.158,
|
||||
"max": 75.429,
|
||||
"ci95": 89.798
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 17.194,
|
||||
"stdev": 14.232,
|
||||
"min": 7.903,
|
||||
"max": 33.579,
|
||||
"ci95": 35.357
|
||||
},
|
||||
"memory.mountain.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 1977.602,
|
||||
"stdev": 108.627,
|
||||
"min": 1872.516,
|
||||
"max": 2089.453,
|
||||
"ci95": 269.866
|
||||
},
|
||||
"tick.mountain.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.772,
|
||||
"stdev": 0.332,
|
||||
"min": 0.463,
|
||||
"max": 1.123,
|
||||
"ci95": 0.825
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2135.313,
|
||||
"stdev": 54.617,
|
||||
"min": 2072.344,
|
||||
"max": 2169.828,
|
||||
"ci95": 135.686
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 1836.44,
|
||||
"stdev": 223.694,
|
||||
"min": 1578.141,
|
||||
"max": 1965.781,
|
||||
"ci95": 555.732
|
||||
},
|
||||
"memory.footprint_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 3253.456,
|
||||
"stdev": 68.323,
|
||||
"min": 3178.409,
|
||||
"max": 3312.05,
|
||||
"ci95": 169.737
|
||||
},
|
||||
"memory.footprint_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 3144.363,
|
||||
"stdev": 69.868,
|
||||
"min": 3069.394,
|
||||
"max": 3207.66,
|
||||
"ci95": 173.576
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 1272,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 105.979,
|
||||
"low_1pct_fps": 21.25,
|
||||
"low_01pct_fps": 17.394,
|
||||
"p50_ms": 7.527,
|
||||
"p95_ms": 22.34,
|
||||
"p99_ms": 40.461,
|
||||
"p999_ms": 54.28,
|
||||
"max_ms": 60.7,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
56,
|
||||
161,
|
||||
170,
|
||||
180,
|
||||
167,
|
||||
184,
|
||||
118,
|
||||
71,
|
||||
61,
|
||||
26,
|
||||
25,
|
||||
28,
|
||||
12,
|
||||
10,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 1723,
|
||||
"seconds": 12.007,
|
||||
"avg_fps": 143.496,
|
||||
"low_1pct_fps": 24.308,
|
||||
"low_01pct_fps": 13.602,
|
||||
"p50_ms": 5.317,
|
||||
"p95_ms": 16.181,
|
||||
"p99_ms": 29.771,
|
||||
"p999_ms": 56.832,
|
||||
"max_ms": 90.209,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
63,
|
||||
413,
|
||||
271,
|
||||
273,
|
||||
185,
|
||||
148,
|
||||
112,
|
||||
89,
|
||||
47,
|
||||
43,
|
||||
28,
|
||||
22,
|
||||
18,
|
||||
4,
|
||||
5,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 346,
|
||||
"seconds": 12.05,
|
||||
"avg_fps": 28.713,
|
||||
"low_1pct_fps": 4.001,
|
||||
"low_01pct_fps": 3.549,
|
||||
"p50_ms": 23.684,
|
||||
"p95_ms": 110.057,
|
||||
"p99_ms": 225.236,
|
||||
"p999_ms": 281.776,
|
||||
"max_ms": 281.776,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
8,
|
||||
6,
|
||||
6,
|
||||
12,
|
||||
21,
|
||||
20,
|
||||
23,
|
||||
25,
|
||||
24,
|
||||
32,
|
||||
50,
|
||||
28,
|
||||
24,
|
||||
27,
|
||||
17,
|
||||
11,
|
||||
6,
|
||||
2,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 589,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 49.081,
|
||||
"low_1pct_fps": 8.19,
|
||||
"low_01pct_fps": 6.325,
|
||||
"p50_ms": 15.443,
|
||||
"p95_ms": 53.004,
|
||||
"p99_ms": 94.69,
|
||||
"p999_ms": 158.114,
|
||||
"max_ms": 158.114,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
7,
|
||||
20,
|
||||
30,
|
||||
46,
|
||||
53,
|
||||
48,
|
||||
56,
|
||||
62,
|
||||
61,
|
||||
66,
|
||||
63,
|
||||
18,
|
||||
22,
|
||||
20,
|
||||
10,
|
||||
4,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 2731,
|
||||
"seconds": 12.004,
|
||||
"avg_fps": 227.513,
|
||||
"low_1pct_fps": 53.473,
|
||||
"low_01pct_fps": 33.042,
|
||||
"p50_ms": 3.801,
|
||||
"p95_ms": 7.955,
|
||||
"p99_ms": 14.177,
|
||||
"p999_ms": 24.479,
|
||||
"max_ms": 38.218,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
526,
|
||||
1157,
|
||||
432,
|
||||
257,
|
||||
151,
|
||||
88,
|
||||
46,
|
||||
32,
|
||||
13,
|
||||
15,
|
||||
6,
|
||||
6,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 527,
|
||||
"seconds": 12.016,
|
||||
"avg_fps": 43.857,
|
||||
"low_1pct_fps": 7.338,
|
||||
"low_01pct_fps": 5.157,
|
||||
"p50_ms": 16.784,
|
||||
"p95_ms": 57.353,
|
||||
"p99_ms": 111.984,
|
||||
"p999_ms": 193.905,
|
||||
"max_ms": 193.905,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
9,
|
||||
16,
|
||||
29,
|
||||
40,
|
||||
61,
|
||||
50,
|
||||
54,
|
||||
52,
|
||||
62,
|
||||
57,
|
||||
35,
|
||||
28,
|
||||
11,
|
||||
13,
|
||||
5,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 746,
|
||||
"seconds": 12.001,
|
||||
"avg_fps": 62.162,
|
||||
"low_1pct_fps": 14.965,
|
||||
"low_01pct_fps": 12.372,
|
||||
"p50_ms": 13.76,
|
||||
"p95_ms": 35.144,
|
||||
"p99_ms": 57.63,
|
||||
"p999_ms": 80.827,
|
||||
"max_ms": 80.827,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
13,
|
||||
12,
|
||||
41,
|
||||
37,
|
||||
53,
|
||||
67,
|
||||
82,
|
||||
80,
|
||||
99,
|
||||
82,
|
||||
76,
|
||||
58,
|
||||
17,
|
||||
11,
|
||||
12,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 3910,
|
||||
"seconds": 12.002,
|
||||
"avg_fps": 325.77,
|
||||
"low_1pct_fps": 79.024,
|
||||
"low_01pct_fps": 41.466,
|
||||
"p50_ms": 2.685,
|
||||
"p95_ms": 5.125,
|
||||
"p99_ms": 8.342,
|
||||
"p999_ms": 20.782,
|
||||
"max_ms": 30.571,
|
||||
"histogram": [
|
||||
0,
|
||||
78,
|
||||
2526,
|
||||
892,
|
||||
203,
|
||||
95,
|
||||
49,
|
||||
27,
|
||||
13,
|
||||
11,
|
||||
7,
|
||||
3,
|
||||
2,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 1268,
|
||||
"seconds": 12.044,
|
||||
"avg_fps": 105.282,
|
||||
"low_1pct_fps": 14.736,
|
||||
"low_01pct_fps": 8.855,
|
||||
"p50_ms": 6.542,
|
||||
"p95_ms": 25.245,
|
||||
"p99_ms": 50.465,
|
||||
"p999_ms": 79.065,
|
||||
"max_ms": 146.809,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
171,
|
||||
190,
|
||||
169,
|
||||
167,
|
||||
133,
|
||||
105,
|
||||
93,
|
||||
53,
|
||||
43,
|
||||
35,
|
||||
41,
|
||||
26,
|
||||
13,
|
||||
11,
|
||||
10,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 822,
|
||||
"seconds": 12.011,
|
||||
"avg_fps": 68.44,
|
||||
"low_1pct_fps": 14.632,
|
||||
"low_01pct_fps": 10.101,
|
||||
"p50_ms": 11.396,
|
||||
"p95_ms": 35.387,
|
||||
"p99_ms": 55.24,
|
||||
"p999_ms": 99.005,
|
||||
"max_ms": 99.005,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
10,
|
||||
42,
|
||||
62,
|
||||
55,
|
||||
86,
|
||||
89,
|
||||
97,
|
||||
64,
|
||||
73,
|
||||
74,
|
||||
72,
|
||||
48,
|
||||
21,
|
||||
17,
|
||||
8,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 4165,
|
||||
"seconds": 12.006,
|
||||
"avg_fps": 346.896,
|
||||
"low_1pct_fps": 75.429,
|
||||
"low_01pct_fps": 33.579,
|
||||
"p50_ms": 2.541,
|
||||
"p95_ms": 4.838,
|
||||
"p99_ms": 7.998,
|
||||
"p999_ms": 20.209,
|
||||
"max_ms": 53.577,
|
||||
"histogram": [
|
||||
0,
|
||||
640,
|
||||
2307,
|
||||
855,
|
||||
178,
|
||||
93,
|
||||
34,
|
||||
22,
|
||||
15,
|
||||
3,
|
||||
3,
|
||||
8,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 851,
|
||||
"seconds": 12.0,
|
||||
"avg_fps": 70.916,
|
||||
"low_1pct_fps": 11.158,
|
||||
"low_01pct_fps": 7.903,
|
||||
"p50_ms": 10.128,
|
||||
"p95_ms": 36.498,
|
||||
"p99_ms": 68.434,
|
||||
"p999_ms": 126.541,
|
||||
"max_ms": 126.541,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
38,
|
||||
61,
|
||||
87,
|
||||
76,
|
||||
69,
|
||||
84,
|
||||
100,
|
||||
63,
|
||||
53,
|
||||
68,
|
||||
53,
|
||||
48,
|
||||
15,
|
||||
9,
|
||||
16,
|
||||
9,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-macmini"
|
||||
}
|
||||
@@ -0,0 +1,771 @@
|
||||
{
|
||||
"format": "horizon-benchmark-report",
|
||||
"version": 1,
|
||||
"kind": "game",
|
||||
"created": "2026-11-04T13:08:37Z",
|
||||
"app": {
|
||||
"name": "minecraft",
|
||||
"version": "26.3",
|
||||
"mods": [
|
||||
{
|
||||
"name": "fabric-loader",
|
||||
"version": "0.19.5"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hardware": {
|
||||
"os": "macos",
|
||||
"arch": "aarch64",
|
||||
"cpu_threads": 14,
|
||||
"os_version": "27.0.1",
|
||||
"cpu": "Apple M4 Pro",
|
||||
"ram_mb": 65536,
|
||||
"gpu": {
|
||||
"name": "Apple M4 Pro",
|
||||
"vendor": "Apple",
|
||||
"driver": "4.1 Metal - 91.7",
|
||||
"backend": "opengl",
|
||||
"kind": ""
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"scenario": "perf_bench:village,forest,ocean,mountain",
|
||||
"seed": "1234",
|
||||
"render_distance": 32,
|
||||
"simulation_distance": 12,
|
||||
"vsync": false,
|
||||
"offscreen": false,
|
||||
"repeats": 3,
|
||||
"warmup_s": 2.0,
|
||||
"load_avg_1m": 18.159,
|
||||
"window": [
|
||||
1280,
|
||||
720
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"startup.title_ms": {
|
||||
"n": 3,
|
||||
"mean": 8607.88,
|
||||
"stdev": 949.596,
|
||||
"min": 8010.323,
|
||||
"max": 9702.854,
|
||||
"ci95": 2359.119
|
||||
},
|
||||
"startup.world_ms": {
|
||||
"n": 3,
|
||||
"mean": 44182.88,
|
||||
"stdev": 4031.933,
|
||||
"min": 40172.323,
|
||||
"max": 48235.854,
|
||||
"ci95": 10016.685
|
||||
},
|
||||
"chunks.village.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 17227.171,
|
||||
"stdev": 5351.234,
|
||||
"min": 13892.279,
|
||||
"max": 23399.566,
|
||||
"ci95": 13294.274
|
||||
},
|
||||
"fps.village.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 76.237,
|
||||
"stdev": 45.661,
|
||||
"min": 28.922,
|
||||
"max": 120.041,
|
||||
"ci95": 113.437
|
||||
},
|
||||
"fps.village.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 17.319,
|
||||
"stdev": 12.99,
|
||||
"min": 5.593,
|
||||
"max": 31.283,
|
||||
"ci95": 32.272
|
||||
},
|
||||
"fps.village.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 12.642,
|
||||
"stdev": 9.463,
|
||||
"min": 4.308,
|
||||
"max": 22.93,
|
||||
"ci95": 23.51
|
||||
},
|
||||
"memory.village.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 2674.987,
|
||||
"stdev": 30.71,
|
||||
"min": 2641.352,
|
||||
"max": 2701.531,
|
||||
"ci95": 76.294
|
||||
},
|
||||
"tick.village.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.341,
|
||||
"stdev": 0.407,
|
||||
"min": 0.924,
|
||||
"max": 1.737,
|
||||
"ci95": 1.012
|
||||
},
|
||||
"chunks.forest.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 34112.389,
|
||||
"stdev": 19305.487,
|
||||
"min": 22633.094,
|
||||
"max": 56401.09,
|
||||
"ci95": 47961.36
|
||||
},
|
||||
"fps.forest.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 72.848,
|
||||
"stdev": 51.859,
|
||||
"min": 18.307,
|
||||
"max": 121.528,
|
||||
"ci95": 128.837
|
||||
},
|
||||
"fps.forest.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 22.096,
|
||||
"stdev": 20.299,
|
||||
"min": 3.973,
|
||||
"max": 44.03,
|
||||
"ci95": 50.429
|
||||
},
|
||||
"fps.forest.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 16.093,
|
||||
"stdev": 14.608,
|
||||
"min": 3.688,
|
||||
"max": 32.194,
|
||||
"ci95": 36.291
|
||||
},
|
||||
"memory.forest.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 2519.729,
|
||||
"stdev": 82.097,
|
||||
"min": 2426.297,
|
||||
"max": 2580.328,
|
||||
"ci95": 203.957
|
||||
},
|
||||
"tick.forest.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.115,
|
||||
"stdev": 0.411,
|
||||
"min": 0.726,
|
||||
"max": 1.545,
|
||||
"ci95": 1.02
|
||||
},
|
||||
"chunks.ocean.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 19066.516,
|
||||
"stdev": 9372.33,
|
||||
"min": 13163.781,
|
||||
"max": 29873.383,
|
||||
"ci95": 23284.039
|
||||
},
|
||||
"fps.ocean.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 69.735,
|
||||
"stdev": 46.656,
|
||||
"min": 16.524,
|
||||
"max": 103.635,
|
||||
"ci95": 115.909
|
||||
},
|
||||
"fps.ocean.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 17.636,
|
||||
"stdev": 13.616,
|
||||
"min": 3.179,
|
||||
"max": 30.217,
|
||||
"ci95": 33.827
|
||||
},
|
||||
"fps.ocean.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 12.115,
|
||||
"stdev": 10.798,
|
||||
"min": 2.584,
|
||||
"max": 23.842,
|
||||
"ci95": 26.825
|
||||
},
|
||||
"memory.ocean.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 2557.143,
|
||||
"stdev": 84.375,
|
||||
"min": 2463.781,
|
||||
"max": 2627.945,
|
||||
"ci95": 209.616
|
||||
},
|
||||
"tick.ocean.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 1.056,
|
||||
"stdev": 0.233,
|
||||
"min": 0.815,
|
||||
"max": 1.281,
|
||||
"ci95": 0.58
|
||||
},
|
||||
"chunks.mountain.load_ms": {
|
||||
"n": 3,
|
||||
"mean": 62214.712,
|
||||
"stdev": 4884.13,
|
||||
"min": 58937.045,
|
||||
"max": 67828.14,
|
||||
"ci95": 12133.83
|
||||
},
|
||||
"fps.mountain.avg_fps": {
|
||||
"n": 3,
|
||||
"mean": 51.809,
|
||||
"stdev": 7.118,
|
||||
"min": 45.235,
|
||||
"max": 59.369,
|
||||
"ci95": 17.685
|
||||
},
|
||||
"fps.mountain.low_1pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 6.336,
|
||||
"stdev": 1.097,
|
||||
"min": 5.545,
|
||||
"max": 7.588,
|
||||
"ci95": 2.725
|
||||
},
|
||||
"fps.mountain.low_01pct_fps": {
|
||||
"n": 3,
|
||||
"mean": 3.458,
|
||||
"stdev": 0.756,
|
||||
"min": 2.827,
|
||||
"max": 4.296,
|
||||
"ci95": 1.878
|
||||
},
|
||||
"memory.mountain.rss_mb": {
|
||||
"n": 3,
|
||||
"mean": 2604.227,
|
||||
"stdev": 15.043,
|
||||
"min": 2588.234,
|
||||
"max": 2618.094,
|
||||
"ci95": 37.371
|
||||
},
|
||||
"tick.mountain.client_ms": {
|
||||
"n": 3,
|
||||
"mean": 0.957,
|
||||
"stdev": 0.03,
|
||||
"min": 0.926,
|
||||
"max": 0.985,
|
||||
"ci95": 0.074
|
||||
},
|
||||
"memory.rss_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 2731.016,
|
||||
"stdev": 56.767,
|
||||
"min": 2688.516,
|
||||
"max": 2795.484,
|
||||
"ci95": 141.03
|
||||
},
|
||||
"memory.rss_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 2577.339,
|
||||
"stdev": 77.295,
|
||||
"min": 2488.203,
|
||||
"max": 2625.875,
|
||||
"ci95": 192.028
|
||||
},
|
||||
"memory.footprint_peak_mb": {
|
||||
"n": 3,
|
||||
"mean": 4612.571,
|
||||
"stdev": 20.245,
|
||||
"min": 4595.363,
|
||||
"max": 4634.879,
|
||||
"ci95": 50.296
|
||||
},
|
||||
"memory.footprint_steady_mb": {
|
||||
"n": 3,
|
||||
"mean": 4271.925,
|
||||
"stdev": 26.308,
|
||||
"min": 4246.207,
|
||||
"max": 4298.785,
|
||||
"ci95": 65.357
|
||||
}
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 0,
|
||||
"frames": 349,
|
||||
"seconds": 12.067,
|
||||
"avg_fps": 28.922,
|
||||
"low_1pct_fps": 5.593,
|
||||
"low_01pct_fps": 4.308,
|
||||
"p50_ms": 24.684,
|
||||
"p95_ms": 92.764,
|
||||
"p99_ms": 150.338,
|
||||
"p999_ms": 232.136,
|
||||
"max_ms": 232.136,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
3,
|
||||
13,
|
||||
17,
|
||||
26,
|
||||
31,
|
||||
40,
|
||||
46,
|
||||
50,
|
||||
32,
|
||||
25,
|
||||
20,
|
||||
30,
|
||||
10,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 1,
|
||||
"frames": 1441,
|
||||
"seconds": 12.004,
|
||||
"avg_fps": 120.041,
|
||||
"low_1pct_fps": 31.283,
|
||||
"low_01pct_fps": 22.93,
|
||||
"p50_ms": 7.267,
|
||||
"p95_ms": 13.622,
|
||||
"p99_ms": 23.449,
|
||||
"p999_ms": 42.564,
|
||||
"max_ms": 44.657,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
64,
|
||||
543,
|
||||
397,
|
||||
218,
|
||||
100,
|
||||
54,
|
||||
24,
|
||||
20,
|
||||
7,
|
||||
7,
|
||||
5,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "village",
|
||||
"repeat": 2,
|
||||
"frames": 958,
|
||||
"seconds": 12.013,
|
||||
"avg_fps": 79.749,
|
||||
"low_1pct_fps": 15.082,
|
||||
"low_01pct_fps": 10.689,
|
||||
"p50_ms": 9.138,
|
||||
"p95_ms": 28.848,
|
||||
"p99_ms": 52.247,
|
||||
"p999_ms": 93.55,
|
||||
"max_ms": 93.55,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
19,
|
||||
144,
|
||||
232,
|
||||
148,
|
||||
111,
|
||||
64,
|
||||
56,
|
||||
59,
|
||||
57,
|
||||
32,
|
||||
17,
|
||||
9,
|
||||
6,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 0,
|
||||
"frames": 220,
|
||||
"seconds": 12.017,
|
||||
"avg_fps": 18.307,
|
||||
"low_1pct_fps": 3.973,
|
||||
"low_01pct_fps": 3.688,
|
||||
"p50_ms": 40.635,
|
||||
"p95_ms": 157.363,
|
||||
"p99_ms": 235.253,
|
||||
"p999_ms": 271.153,
|
||||
"max_ms": 271.153,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
3,
|
||||
6,
|
||||
14,
|
||||
17,
|
||||
25,
|
||||
25,
|
||||
17,
|
||||
27,
|
||||
33,
|
||||
24,
|
||||
16,
|
||||
11,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 1,
|
||||
"frames": 1459,
|
||||
"seconds": 12.005,
|
||||
"avg_fps": 121.528,
|
||||
"low_1pct_fps": 44.03,
|
||||
"low_01pct_fps": 32.194,
|
||||
"p50_ms": 7.381,
|
||||
"p95_ms": 12.386,
|
||||
"p99_ms": 18.354,
|
||||
"p999_ms": 29.428,
|
||||
"max_ms": 32.695,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
357,
|
||||
642,
|
||||
300,
|
||||
80,
|
||||
28,
|
||||
28,
|
||||
14,
|
||||
7,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "forest",
|
||||
"repeat": 2,
|
||||
"frames": 945,
|
||||
"seconds": 12.006,
|
||||
"avg_fps": 78.709,
|
||||
"low_1pct_fps": 18.284,
|
||||
"low_01pct_fps": 12.397,
|
||||
"p50_ms": 9.554,
|
||||
"p95_ms": 27.657,
|
||||
"p99_ms": 43.253,
|
||||
"p999_ms": 80.663,
|
||||
"max_ms": 80.663,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
18,
|
||||
304,
|
||||
191,
|
||||
119,
|
||||
67,
|
||||
59,
|
||||
53,
|
||||
70,
|
||||
34,
|
||||
15,
|
||||
10,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 0,
|
||||
"frames": 201,
|
||||
"seconds": 12.164,
|
||||
"avg_fps": 16.524,
|
||||
"low_1pct_fps": 3.179,
|
||||
"low_01pct_fps": 2.584,
|
||||
"p50_ms": 44.933,
|
||||
"p95_ms": 155.019,
|
||||
"p99_ms": 263.919,
|
||||
"p999_ms": 387.044,
|
||||
"max_ms": 387.044,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
4,
|
||||
7,
|
||||
20,
|
||||
8,
|
||||
29,
|
||||
18,
|
||||
25,
|
||||
29,
|
||||
29,
|
||||
17,
|
||||
8,
|
||||
4,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 1,
|
||||
"frames": 1244,
|
||||
"seconds": 12.004,
|
||||
"avg_fps": 103.635,
|
||||
"low_1pct_fps": 30.217,
|
||||
"low_01pct_fps": 23.842,
|
||||
"p50_ms": 8.349,
|
||||
"p95_ms": 17.445,
|
||||
"p99_ms": 28.276,
|
||||
"p999_ms": 38.037,
|
||||
"max_ms": 45.85,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
162,
|
||||
453,
|
||||
312,
|
||||
129,
|
||||
71,
|
||||
47,
|
||||
32,
|
||||
18,
|
||||
16,
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "ocean",
|
||||
"repeat": 2,
|
||||
"frames": 1069,
|
||||
"seconds": 12.005,
|
||||
"avg_fps": 89.047,
|
||||
"low_1pct_fps": 19.512,
|
||||
"low_01pct_fps": 9.92,
|
||||
"p50_ms": 9.541,
|
||||
"p95_ms": 20.819,
|
||||
"p99_ms": 31.409,
|
||||
"p999_ms": 99.668,
|
||||
"max_ms": 101.942,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
64,
|
||||
266,
|
||||
283,
|
||||
160,
|
||||
112,
|
||||
74,
|
||||
51,
|
||||
33,
|
||||
17,
|
||||
4,
|
||||
1,
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 0,
|
||||
"frames": 551,
|
||||
"seconds": 12.181,
|
||||
"avg_fps": 45.235,
|
||||
"low_1pct_fps": 5.545,
|
||||
"low_01pct_fps": 3.252,
|
||||
"p50_ms": 14.921,
|
||||
"p95_ms": 50.78,
|
||||
"p99_ms": 105.154,
|
||||
"p999_ms": 307.476,
|
||||
"max_ms": 307.476,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
42,
|
||||
137,
|
||||
72,
|
||||
58,
|
||||
50,
|
||||
47,
|
||||
57,
|
||||
39,
|
||||
20,
|
||||
15,
|
||||
8,
|
||||
3,
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 1,
|
||||
"frames": 718,
|
||||
"seconds": 12.094,
|
||||
"avg_fps": 59.369,
|
||||
"low_1pct_fps": 7.588,
|
||||
"low_01pct_fps": 4.296,
|
||||
"p50_ms": 12.178,
|
||||
"p95_ms": 38.273,
|
||||
"p99_ms": 65.694,
|
||||
"p999_ms": 232.789,
|
||||
"max_ms": 232.789,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
140,
|
||||
202,
|
||||
115,
|
||||
72,
|
||||
48,
|
||||
47,
|
||||
43,
|
||||
19,
|
||||
19,
|
||||
6,
|
||||
4,
|
||||
0,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "mountain",
|
||||
"repeat": 2,
|
||||
"frames": 610,
|
||||
"seconds": 12.003,
|
||||
"avg_fps": 50.822,
|
||||
"low_1pct_fps": 5.875,
|
||||
"low_01pct_fps": 2.827,
|
||||
"p50_ms": 13.894,
|
||||
"p95_ms": 44.208,
|
||||
"p99_ms": 75.023,
|
||||
"p999_ms": 353.686,
|
||||
"max_ms": 353.686,
|
||||
"histogram": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
67,
|
||||
115,
|
||||
125,
|
||||
88,
|
||||
55,
|
||||
54,
|
||||
43,
|
||||
25,
|
||||
13,
|
||||
15,
|
||||
7,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"label": "vanilla-macmini"
|
||||
}
|
||||
@@ -68,7 +68,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
true,
|
||||
))?;
|
||||
assert!(!renderer.pack_entity_shadows(), "no selected pack");
|
||||
renderer.set_shader_pack(0, None, &[]);
|
||||
renderer.set_shader_pack(0, None, &[]).unwrap();
|
||||
assert!(
|
||||
!renderer.pack_entity_shadows(),
|
||||
"selected pack without a compiled post"
|
||||
@@ -165,6 +165,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
let (_, notes) = renderer
|
||||
.set_shader_pack(dimension, Some(post), &[])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
if break_pipeline {
|
||||
assert!(
|
||||
|
||||
@@ -190,8 +190,9 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
let one = [255u8; 4];
|
||||
let zero = [0u8; 4];
|
||||
let pixels = render.pixels.get();
|
||||
let prepared = PreparedTextures::new(
|
||||
&render.pixels,
|
||||
&pixels,
|
||||
render.sprite_size,
|
||||
render.texture_layers,
|
||||
&render.sprite_mips,
|
||||
@@ -206,7 +207,7 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut renderer = pollster::block_on(Renderer::new(
|
||||
window,
|
||||
true,
|
||||
&render.pixels,
|
||||
&pixels,
|
||||
render.sprite_size,
|
||||
render.sprite_minimum_size,
|
||||
render.texture_layers,
|
||||
@@ -224,6 +225,8 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
false,
|
||||
false,
|
||||
))?;
|
||||
drop(pixels);
|
||||
render.pixels.pack();
|
||||
let state = &scene.renderer;
|
||||
let [width, height] = o.size.unwrap_or(state.size);
|
||||
renderer.resize(width, height);
|
||||
|
||||
@@ -79,7 +79,10 @@ fn pack(renderer: &mut Renderer, folder: &Path, valid: bool) {
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let post = horizon_shaders::load_post(&pack, "", &properties, ¯os, &renderer.pack_inputs());
|
||||
assert_eq!(post.notes.is_empty(), valid, "{:?}", post.notes);
|
||||
let (_, notes) = renderer.set_shader_pack(0, Some(post), &[]).unwrap();
|
||||
let (_, notes) = renderer
|
||||
.set_shader_pack(0, Some(post), &[])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(
|
||||
!valid || notes.iter().all(|n| !n.contains("skipped")),
|
||||
"{notes:?}"
|
||||
|
||||
@@ -84,7 +84,9 @@ pub struct Assets {
|
||||
pub struct RenderAssets {
|
||||
pub sprite_size: u32,
|
||||
pub sprite_minimum_size: u32,
|
||||
pub pixels: Vec<u8>,
|
||||
/// The block sprites' RGBA, layer by layer: as built until a renderer has
|
||||
/// them, then kept compressed ([`SpritePixels`]).
|
||||
pub pixels: SpritePixels,
|
||||
pub texture_layers: u32,
|
||||
pub model_catalog: crate::models::StateModels,
|
||||
/// Shared parsed resources; item-only validation errors surface at item preparation.
|
||||
@@ -111,6 +113,76 @@ pub struct RenderAssets {
|
||||
/// sprites are when it changes (`native_block_sprites_version`).
|
||||
pub generation: u64,
|
||||
}
|
||||
/// The block sprites' pixels for a renderer to upload. Once a renderer has
|
||||
/// them ([`Self::pack`]) they are kept lz4-compressed: the GPU holds the
|
||||
/// texture array, and the copy only serves a renderer made again (another
|
||||
/// window, a lost device, another graphics API), which waits for
|
||||
/// [`Self::get`] to uncompress them. Shared by the window and the workers'
|
||||
/// `RenderAssets`, so the compressed form replaces the raw one in place.
|
||||
pub struct SpritePixels(Arc<PixelStore>);
|
||||
struct PixelStore {
|
||||
len: usize,
|
||||
/// Set by the first `pack`: later calls cost one atomic read.
|
||||
packing: std::sync::atomic::AtomicBool,
|
||||
/// As built; dropped once `packed` holds them.
|
||||
raw: std::sync::Mutex<Option<Arc<Vec<u8>>>>,
|
||||
packed: std::sync::OnceLock<Vec<u8>>,
|
||||
}
|
||||
impl SpritePixels {
|
||||
pub fn new(pixels: Vec<u8>) -> Self {
|
||||
Self(Arc::new(PixelStore {
|
||||
len: pixels.len(),
|
||||
packing: std::sync::atomic::AtomicBool::new(false),
|
||||
raw: std::sync::Mutex::new(Some(Arc::new(pixels))),
|
||||
packed: std::sync::OnceLock::new(),
|
||||
}))
|
||||
}
|
||||
/// Their size in bytes, uncompressed.
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.len
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.len == 0
|
||||
}
|
||||
/// The pixels as built, uncompressed again once packed.
|
||||
pub fn get(&self) -> Arc<Vec<u8>> {
|
||||
let raw = self.0.raw.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
raw.unwrap_or_else(|| {
|
||||
let packed = self.0.packed.get().map(Vec::as_slice).unwrap_or_default();
|
||||
Arc::new(
|
||||
lz4_flex::decompress_size_prepended(packed)
|
||||
.ok()
|
||||
.filter(|p| p.len() == self.0.len)
|
||||
.unwrap_or_else(|| vec![0; self.0.len]),
|
||||
)
|
||||
})
|
||||
}
|
||||
/// Keep them compressed from now on, once a renderer has uploaded them:
|
||||
/// compressed on a thread of their own, the raw copy dropped after.
|
||||
pub fn pack(&self) {
|
||||
use std::sync::atomic::Ordering;
|
||||
if self.0.len == 0 || self.0.packing.swap(true, Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let store = self.0.clone();
|
||||
let job = move || {
|
||||
let raw = store.raw.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
if let Some(raw) = raw {
|
||||
let _ = store.packed.set(lz4_flex::compress_prepend_size(&raw));
|
||||
// `get` reads the compressed copy from here on.
|
||||
*store.raw.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
}
|
||||
};
|
||||
let fallback = job.clone();
|
||||
if std::thread::Builder::new()
|
||||
.name("Sprite packing".into())
|
||||
.spawn(job)
|
||||
.is_err()
|
||||
{
|
||||
fallback();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The last `RenderAssets::generation` handed out.
|
||||
static GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
fn invalid(message: &str) -> io::Error {
|
||||
@@ -333,7 +405,7 @@ impl RenderAssets {
|
||||
sprite_size: sprites.size,
|
||||
sprite_minimum_size: sprites.minimum_size,
|
||||
texture_layers: u32::try_from(sprites.count())?,
|
||||
pixels: sprites.pixels,
|
||||
pixels: SpritePixels::new(sprites.pixels),
|
||||
model_catalog: models.catalog,
|
||||
model_library,
|
||||
model_placements: models.placements,
|
||||
@@ -431,4 +503,29 @@ mod tests {
|
||||
[ConsumeEffect::ApplyEffects { probability, .. }] if probability == 0.3
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprite_pixels_come_back_the_same_once_packed() {
|
||||
// Two layers of 16x16 RGBA with some structure, as sprites have.
|
||||
let pixels: Vec<u8> = (0..2 * 16 * 16 * 4).map(|i| (i * 7 % 251) as u8).collect();
|
||||
let stored = super::SpritePixels::new(pixels.clone());
|
||||
assert_eq!(*stored.get(), pixels);
|
||||
stored.pack();
|
||||
stored.pack();
|
||||
// The packing thread drops the raw copy once the compressed one is set.
|
||||
let started = std::time::Instant::now();
|
||||
while stored.0.raw.lock().unwrap().is_some() {
|
||||
assert!(started.elapsed().as_secs() < 10, "packing never finished");
|
||||
std::thread::yield_now();
|
||||
}
|
||||
assert!(
|
||||
stored
|
||||
.0
|
||||
.packed
|
||||
.get()
|
||||
.is_some_and(|p| p.len() < pixels.len())
|
||||
);
|
||||
assert_eq!(*stored.get(), pixels);
|
||||
assert_eq!(stored.len(), pixels.len());
|
||||
}
|
||||
}
|
||||
@@ -275,19 +275,34 @@ pub fn temp_folder() -> std::io::Result<PathBuf> {
|
||||
/// `PackSelectionScreen.onFilesDrop`'s `PackDetector`: a zip is a pack; a
|
||||
/// folder is one when it holds a `pack.mcmeta`, or exactly one folder that
|
||||
/// does (a folder zipped by the system, which adds a wrapper). Copied into
|
||||
/// `dir`; false when `path` is no pack.
|
||||
/// `dir`; false when `path` is no pack, or when part of it could not be copied.
|
||||
pub fn copy_pack(path: &Path, dir: &Path) -> bool {
|
||||
fn copy_dir(from: &Path, at: &Path) {
|
||||
let _ = std::fs::create_dir_all(at);
|
||||
fn copy_dir(from: &Path, at: &Path) -> bool {
|
||||
let mut whole = true;
|
||||
if let Err(e) = std::fs::create_dir_all(at) {
|
||||
log::warn!("Horizon: {} is not made: {e}", at.display());
|
||||
whole = false;
|
||||
}
|
||||
for entry in std::fs::read_dir(from).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
let to = at.join(entry.file_name());
|
||||
if path.is_dir() {
|
||||
copy_dir(&path, &to);
|
||||
let copied = if path.is_dir() {
|
||||
copy_dir(&path, &to)
|
||||
} else {
|
||||
let _ = std::fs::copy(&path, &to);
|
||||
}
|
||||
match std::fs::copy(&path, &to) {
|
||||
Ok(_) => true,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Horizon: {} is not copied into the pack: {e}",
|
||||
path.display()
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
};
|
||||
whole &= copied;
|
||||
}
|
||||
whole
|
||||
}
|
||||
let name = || {
|
||||
path.file_name()
|
||||
@@ -307,8 +322,7 @@ pub fn copy_pack(path: &Path, dir: &Path) -> bool {
|
||||
return false;
|
||||
}
|
||||
if path.join("pack.mcmeta").is_file() {
|
||||
copy_dir(path, &dir.join(name()));
|
||||
return true;
|
||||
return copy_dir(path, &dir.join(name()));
|
||||
}
|
||||
let own: Vec<PathBuf> = std::fs::read_dir(path)
|
||||
.into_iter()
|
||||
@@ -318,10 +332,7 @@ pub fn copy_pack(path: &Path, dir: &Path) -> bool {
|
||||
.filter(|p| p.is_dir() && p.join("pack.mcmeta").is_file())
|
||||
.collect();
|
||||
match own.as_slice() {
|
||||
[one] => {
|
||||
copy_dir(one, &dir.join(one.file_name().unwrap_or_default()));
|
||||
true
|
||||
}
|
||||
[one] => copy_dir(one, &dir.join(one.file_name().unwrap_or_default())),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -914,6 +925,24 @@ mod tests {
|
||||
assert_eq!(content_id("data/mymod/data_maps/item/fuel.txt"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pack_that_cannot_be_copied_is_reported() {
|
||||
let root = std::env::temp_dir().join(format!("hz-datapack-copy-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let pack = root.join("pack");
|
||||
std::fs::create_dir_all(pack.join("data")).unwrap();
|
||||
std::fs::write(pack.join("pack.mcmeta"), "{}").unwrap();
|
||||
std::fs::write(pack.join("data/functions.mcfunction"), "").unwrap();
|
||||
// A file where the pack's folder goes: every copy under it fails.
|
||||
let blocked = root.join("blocked");
|
||||
std::fs::write(&blocked, "").unwrap();
|
||||
assert!(!copy_pack(&pack, &blocked));
|
||||
let dir = root.join("packs");
|
||||
assert!(copy_pack(&pack, &dir));
|
||||
assert!(dir.join("pack/pack.mcmeta").is_file());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_packs_override_worldgen_in_order() {
|
||||
let dir = std::env::temp_dir().join(format!("hz-datapack-worldgen-{}", std::process::id()));
|
||||
|
||||
@@ -90,6 +90,20 @@ fn occlusion_culling_requested(info: &wgpu::AdapterInfo, env: Option<&str>) -> b
|
||||
env != Some("0") && (env.is_some() || info.device_type != wgpu::DeviceType::Cpu)
|
||||
}
|
||||
|
||||
/// `DrawableGizmoPrimitives.getGroup`: a line whose ARGB alpha is 255 joins the
|
||||
/// opaque group, drawn with `RenderTypes.lines` (depth written); the rest
|
||||
/// with `RenderTypes.linesTranslucentNoDepthWrite`.
|
||||
fn debug_line_opaque(line: &[f32; 8]) -> bool {
|
||||
line[6].to_bits() >> 24 == 0xFF
|
||||
}
|
||||
|
||||
/// The depth-tested gizmo lines in their upload order: the opaque group first,
|
||||
/// then the translucent one, each in submission order.
|
||||
fn debug_lines_opaque_first(lines: &[[f32; 8]]) -> impl Iterator<Item = &[f32; 8]> {
|
||||
let opaque = lines.iter().filter(|l| debug_line_opaque(l));
|
||||
opaque.chain(lines.iter().filter(|l| !debug_line_opaque(l)))
|
||||
}
|
||||
|
||||
/// Whether Translucent Batching is offered (`graphics::offer_translucent_batching`): where
|
||||
/// indirect multi-draws (`multi_draw`) take its batches, but not OpenGL's, which loop over
|
||||
/// their entries on the CPU (and without them every section is a call of its own anyway).
|
||||
@@ -510,7 +524,8 @@ impl SectionGpu {
|
||||
}
|
||||
}
|
||||
/// GPU culling's record of it at `key`, its quads within `bounds`
|
||||
/// (`SectionMesh::bounds`), `FADING` while it fades (`gpu_fading`).
|
||||
/// (`SectionMesh::bounds`) and in their buckets by facing side (`sides`),
|
||||
/// `FADING` while it fades (`gpu_fading`).
|
||||
fn record(&self, key: SectionKey, bounds: Option<[Vec3; 2]>) -> gpu_culling::Record {
|
||||
let boxed = gpu_culling::rounded_box(bounds);
|
||||
gpu_culling::Record::new(
|
||||
@@ -520,6 +535,7 @@ impl SectionGpu {
|
||||
self.solid,
|
||||
boxed,
|
||||
)
|
||||
.facing(&self.sides)
|
||||
.fading(self.gpu_fading)
|
||||
}
|
||||
/// Whether GPU frames' walk lists it within the render distance
|
||||
@@ -1094,8 +1110,10 @@ pub struct Renderer {
|
||||
lines_secondary: wgpu::RenderPipeline,
|
||||
/// A resource pack's `core/rendertype_lines`, which draws the block outline in their place.
|
||||
core_lines: Option<core_lines::CoreLines>,
|
||||
/// Gizmo lines (`RenderTypes.linesTranslucentNoDepthWrite`) and the
|
||||
/// Gizmo lines: opaque ones (`RenderTypes.lines`, which writes depth),
|
||||
/// translucent ones (`RenderTypes.linesTranslucentNoDepthWrite`) and the
|
||||
/// always-on-top ones.
|
||||
debug_lines_opaque: wgpu::RenderPipeline,
|
||||
debug_lines: wgpu::RenderPipeline,
|
||||
debug_lines_on_top: wgpu::RenderPipeline,
|
||||
debug_line_buffer: wgpu::Buffer,
|
||||
@@ -1219,6 +1237,10 @@ pub struct Renderer {
|
||||
/// Whether `lightmap` holds `lightmap_pixels` yet.
|
||||
lightmap_uploaded: bool,
|
||||
sprite_minimum_size: u32,
|
||||
/// Each sprite layer's own side, before the arrays scaled it up (`mipmap::Settings::native_size`).
|
||||
sprite_native: Vec<u32>,
|
||||
/// The Mipmap Levels option (`set_mipmap_levels`), which sets a shader pack atlas's padding.
|
||||
mipmap_option: u32,
|
||||
indices: wgpu::Buffer,
|
||||
index_quads: u32,
|
||||
instances: wgpu::Buffer,
|
||||
@@ -1338,6 +1360,8 @@ pub struct Renderer {
|
||||
/// The marker file of an HDR output that has not presented a frame yet
|
||||
/// (`graphics::HDR_MARKER`).
|
||||
hdr_marker: Option<std::path::PathBuf>,
|
||||
/// The HDR Lighting setting, kept for the HDR output made when HDR turns on.
|
||||
hdr_lighting_setting: bool,
|
||||
/// The shader pack's post-processing, and the dimension (legacy id) it was loaded for.
|
||||
pack_post: Option<(i32, Option<pack_post::PackPost>)>,
|
||||
/// The post passes of one of Horizon's own shader packs; never beside `pack_post`'s.
|
||||
@@ -1427,6 +1451,10 @@ fn requested_split() -> Option<u32> {
|
||||
.and_then(|v| v.parse().ok())
|
||||
.filter(|&n| (1..=SPRITE_SPLIT).contains(&n))
|
||||
}
|
||||
fn adapter_requested_split(info: &wgpu::AdapterInfo, requested: Option<u32>) -> Option<u32> {
|
||||
// Metal can fail to sample large mipmapped array textures despite advertising the layer limit.
|
||||
requested.or_else(|| (info.backend == wgpu::Backend::Metal).then_some(512))
|
||||
}
|
||||
/// The split the CPU mip chains are cut by before the adapter is known.
|
||||
fn sprite_split() -> u32 {
|
||||
requested_split().unwrap_or(SPRITE_SPLIT)
|
||||
@@ -1862,10 +1890,11 @@ fn describe_adapter(adapter: &wgpu::Adapter) {
|
||||
/// Whether an adapter's texture arrays hold the block textures Horizon puts
|
||||
/// in one array.
|
||||
fn check_layers(adapter: &wgpu::Adapter, layers: u32) -> Result<u32> {
|
||||
let info = adapter.get_info();
|
||||
Ok(layer_split(
|
||||
adapter.limits().max_texture_array_layers,
|
||||
adapter.get_info().backend == wgpu::Backend::Gl,
|
||||
requested_split(),
|
||||
info.backend == wgpu::Backend::Gl,
|
||||
adapter_requested_split(&info, requested_split()),
|
||||
layers,
|
||||
)?)
|
||||
}
|
||||
@@ -2888,6 +2917,19 @@ impl Renderer {
|
||||
true,
|
||||
Some(alpha_blend),
|
||||
);
|
||||
// RenderPipelines.LINES: DepthStencilState.DEFAULT, so an opaque hitbox or
|
||||
// chunk border keeps the water and clouds drawn after it behind it.
|
||||
let debug_lines_opaque = pipeline(
|
||||
"Debug lines opaque",
|
||||
"vs_debug_line",
|
||||
"fs_debug_line",
|
||||
&debug_line_buffers,
|
||||
wgpu::PrimitiveTopology::TriangleStrip,
|
||||
None,
|
||||
true,
|
||||
true,
|
||||
Some(alpha_blend),
|
||||
);
|
||||
let debug_lines = pipeline(
|
||||
"Debug lines",
|
||||
"vs_debug_line",
|
||||
@@ -4450,6 +4492,7 @@ impl Renderer {
|
||||
lines,
|
||||
lines_secondary,
|
||||
core_lines: None,
|
||||
debug_lines_opaque,
|
||||
debug_lines,
|
||||
debug_lines_on_top,
|
||||
debug_line_buffer,
|
||||
@@ -4541,6 +4584,8 @@ impl Renderer {
|
||||
shrink_gate: ShrinkGate::default(),
|
||||
lightmap_uploaded: false,
|
||||
sprite_minimum_size,
|
||||
sprite_native: sprite_mips.iter().map(|s| s.native_size).collect(),
|
||||
mipmap_option: 4,
|
||||
indices,
|
||||
index_quads,
|
||||
instances,
|
||||
@@ -4595,6 +4640,7 @@ impl Renderer {
|
||||
frame_gen: frame_gen::FrameGen::new(),
|
||||
entity_atlas: atlas_view.clone(),
|
||||
hdr: None,
|
||||
hdr_lighting_setting: true,
|
||||
emission_stats,
|
||||
hdr_output,
|
||||
frame_format,
|
||||
@@ -6065,6 +6111,13 @@ impl Renderer {
|
||||
None => "HDR: not offered by this display".into(),
|
||||
}
|
||||
}
|
||||
/// The HDR Lighting setting (`Hdr::look`).
|
||||
pub fn set_hdr_lighting(&mut self, on: bool) {
|
||||
self.hdr_lighting_setting = on;
|
||||
if let Some(hdr) = &mut self.hdr {
|
||||
hdr.look = on;
|
||||
}
|
||||
}
|
||||
/// HDR output (Horizon setting): HDR10 or scRGB at `paper_white` nits, the world's
|
||||
/// highlights expanded up to `peak` nits. Off where the surface can't present it.
|
||||
/// Offscreen (`hdr::offscreen_output`) the frame is drawn the same and its HDR screenshot
|
||||
@@ -6109,6 +6162,9 @@ impl Renderer {
|
||||
let format = self.frame_format;
|
||||
self.hdr =
|
||||
output.map(|output| hdr::Hdr::new(&self.device, &self.queue, format, output));
|
||||
if let Some(hdr) = &mut self.hdr {
|
||||
hdr.look = self.hdr_lighting_setting;
|
||||
}
|
||||
self.configure_surface();
|
||||
match output {
|
||||
Some(output) => log::info!(
|
||||
@@ -6207,13 +6263,14 @@ impl Renderer {
|
||||
/// Runs `post` over the world from now on (`None`: no pack), for the dimension `dimension`;
|
||||
/// `block_ids` gives each block sprite layer's `mc_Entity` (id, fluid) and `at_midBlock.w`
|
||||
/// (its block's light), three values a layer.
|
||||
/// Returns the programs that run and the notes of loading them.
|
||||
/// Returns the programs that run and the notes of loading them, or why the pack can't run:
|
||||
/// the dimension then runs without a pack.
|
||||
pub fn set_shader_pack(
|
||||
&mut self,
|
||||
dimension: i32,
|
||||
pack: Option<horizon_shaders::Post>,
|
||||
block_ids: &[i32],
|
||||
) -> Option<(Vec<String>, Vec<String>)> {
|
||||
) -> std::result::Result<Option<(Vec<String>, Vec<String>)>, pack_post::Refused> {
|
||||
if pack.is_none() {
|
||||
self.pack_atlas = None;
|
||||
self.pack_material_atlases = None;
|
||||
@@ -6231,9 +6288,14 @@ impl Renderer {
|
||||
};
|
||||
let buffers = [Some(vertex), Some(instances)];
|
||||
let flat = self.pack_terrain_flat;
|
||||
let sprite_rects = self
|
||||
.pack_atlas
|
||||
.as_ref()
|
||||
.map_or_else(Vec::new, |a| a.rects_table());
|
||||
let post = pack.map(|post| {
|
||||
let world = pack_post::World {
|
||||
atlas: self.pack_atlas.as_ref().map(|a| (a.view.clone(), a.size())),
|
||||
sprite_rects: &sprite_rects,
|
||||
material_maps: self
|
||||
.pack_material_atlases
|
||||
.as_ref()
|
||||
@@ -6272,6 +6334,15 @@ impl Renderer {
|
||||
world,
|
||||
)
|
||||
});
|
||||
let post = match post.transpose() {
|
||||
Ok(post) => post,
|
||||
Err(e) => {
|
||||
self.pack_post = Some((dimension, None));
|
||||
self.pack_atlas = None;
|
||||
self.pack_material_atlases = None;
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let report = post.as_ref().map(|p| {
|
||||
let programs = p.programs().into_iter().map(str::to_owned).collect();
|
||||
(programs, p.notes.clone())
|
||||
@@ -6280,7 +6351,7 @@ impl Renderer {
|
||||
if self.pack_post.as_ref().is_some_and(|p| p.1.is_some()) {
|
||||
self.native = None;
|
||||
}
|
||||
report
|
||||
Ok(report)
|
||||
}
|
||||
/// Drops the running shader pack, of either kind.
|
||||
pub fn drop_shader_packs(&mut self) {
|
||||
@@ -6375,6 +6446,13 @@ impl Renderer {
|
||||
}
|
||||
|
||||
fn pack_terrain_inputs(&mut self) -> Option<horizon_shaders::GbuffersInputs> {
|
||||
// Flat draws whenever the device has them (`render` keeps them on with a pack).
|
||||
let flat = self.flat.is_some() && self.flat_on;
|
||||
// Flat draws read each sprite's place from a table: the atlas is stitched as the game's.
|
||||
// Other draws need the grid.
|
||||
if !flat && self.pack_atlas.as_ref().is_some_and(|a| a.is_stitched()) {
|
||||
self.pack_atlas = None;
|
||||
}
|
||||
if self.pack_atlas.is_none() {
|
||||
let mut encoder = self.device.create_command_encoder(&Default::default());
|
||||
let layers = self
|
||||
@@ -6382,14 +6460,42 @@ impl Renderer {
|
||||
.iter()
|
||||
.map(|t| t.depth_or_array_layers())
|
||||
.sum();
|
||||
let mut atlas = pack_atlas::PackAtlas::new(
|
||||
&self.device,
|
||||
&mut encoder,
|
||||
&self.sprite_textures,
|
||||
self.sprite_split,
|
||||
layers,
|
||||
self.sprite_textures[0].width(),
|
||||
);
|
||||
let sprite = self.sprite_textures[0].width();
|
||||
// Vanilla's padding counts the anisotropy bit only with anisotropic filtering.
|
||||
let anisotropy_bit = if self.texture_filtering == 2 {
|
||||
u32::from(self.max_anisotropy).trailing_zeros()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let stitched = flat
|
||||
.then(|| {
|
||||
pack_atlas::PackAtlas::stitched(
|
||||
&self.device,
|
||||
&mut encoder,
|
||||
&self.sprite_textures,
|
||||
self.sprite_split,
|
||||
sprite,
|
||||
&self.sprite_native,
|
||||
self.mipmap_option,
|
||||
anisotropy_bit,
|
||||
)
|
||||
})
|
||||
.flatten();
|
||||
if flat && stitched.is_none() {
|
||||
log::warn!(
|
||||
"shader pack block atlas: the sprites don't stitch on this device, a grid instead"
|
||||
);
|
||||
}
|
||||
let mut atlas = stitched.or_else(|| {
|
||||
pack_atlas::PackAtlas::new(
|
||||
&self.device,
|
||||
&mut encoder,
|
||||
&self.sprite_textures,
|
||||
self.sprite_split,
|
||||
layers,
|
||||
sprite,
|
||||
)
|
||||
});
|
||||
if let Some(atlas) = &mut atlas {
|
||||
atlas.separate_ao = self.separate_ao;
|
||||
}
|
||||
@@ -6410,8 +6516,7 @@ impl Renderer {
|
||||
self.pack_atlas = atlas;
|
||||
self.queue.submit([encoder.finish()]);
|
||||
}
|
||||
// Flat draws whenever the device has them (`render` keeps them on with a pack).
|
||||
self.pack_terrain_flat = self.flat.is_some() && self.flat_on;
|
||||
self.pack_terrain_flat = flat;
|
||||
Some(
|
||||
self.pack_atlas
|
||||
.as_ref()?
|
||||
@@ -6524,6 +6629,7 @@ impl Renderer {
|
||||
/// The Mipmap Levels option: block textures sample at most `levels`
|
||||
/// reduced copies (0: none).
|
||||
pub fn set_mipmap_levels(&mut self, levels: u32) {
|
||||
self.mipmap_option = levels;
|
||||
let levels = crate::sprites::sprite_mipmap_limit(
|
||||
levels,
|
||||
self.sprite_textures[0].width(),
|
||||
@@ -6677,12 +6783,18 @@ impl Renderer {
|
||||
if let Some(atlases) = &self.pack_material_atlases {
|
||||
for kind in 0..2 {
|
||||
let arrays: Vec<_> = arrays.iter().map(|pair| pair[kind].clone()).collect();
|
||||
atlases[kind].copy(encoder, &arrays, self.animations.changed().iter().copied());
|
||||
atlases[kind].copy(
|
||||
&self.device,
|
||||
encoder,
|
||||
&arrays,
|
||||
self.animations.changed().iter().copied(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(atlas) = &self.pack_atlas {
|
||||
atlas.copy(
|
||||
&self.device,
|
||||
encoder,
|
||||
&self.sprite_textures,
|
||||
self.animations.changed().iter().copied(),
|
||||
@@ -7321,6 +7433,7 @@ impl Renderer {
|
||||
radius,
|
||||
frustum: &view,
|
||||
reach: self.occlusion.reach().filter(|_| culling),
|
||||
eye,
|
||||
};
|
||||
let prepared = crate::spans::time("gpu culling", || {
|
||||
gpu.prepare(
|
||||
@@ -7332,7 +7445,7 @@ impl Renderer {
|
||||
)
|
||||
});
|
||||
// A frame whose planes leave the test's integers (the camera millions of blocks
|
||||
// above or below the level) is the CPU's.
|
||||
// above or below the level), or whose eye is not a number, is the CPU's.
|
||||
gpu_frame = prepared == gpu_culling::Prepared::Gpu;
|
||||
if prepared == gpu_culling::Prepared::OutOfMemory {
|
||||
log::warn!("Horizon renderer: GPU culling off: its buffers do not fit");
|
||||
@@ -7854,14 +7967,14 @@ impl Renderer {
|
||||
self.translucency_cursor += budget;
|
||||
let translucent_draws = self.draws.len() as u32 - solid_draws;
|
||||
self.section_draws = self.draws.len();
|
||||
// The shadow camera's sections after the camera's: quads that can face the light, then
|
||||
// translucent ones (in their last sorted order; depth decides there).
|
||||
// The shadow camera's sections after the camera's: quads that can face the light (all of
|
||||
// them for `every_face`), then translucent ones (in their last sorted order; depth
|
||||
// decides there).
|
||||
let shadow_first = self.draws.len() as u32;
|
||||
let mut shadow_translucent = Vec::new();
|
||||
self.shadow_runs.clear();
|
||||
if let Some(view) = &shadow_view {
|
||||
let base = self.drawn_sections;
|
||||
let toward = view.light * 1.0e5;
|
||||
// How far below the nearest section to the light (squared, as bits, as `PageRuns`
|
||||
// takes distances): each band's draws go by page, a call per page and band.
|
||||
let height = |v: &resident::Visible| glam::Vec3::from(v.origin).dot(view.light);
|
||||
@@ -7893,13 +8006,7 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
let before = self.draws.len();
|
||||
// Every face of a section within the unculled distance.
|
||||
let unculled = view.unculled.is_some_and(|d| {
|
||||
let o = glam::Vec3::from(v.origin) + 8.0;
|
||||
o.x.abs().max(o.z.abs()) <= d + 8.0
|
||||
});
|
||||
let toward = (!unculled).then_some(toward);
|
||||
facing_runs(&s.sides, toward, v.origin, |first, quads| {
|
||||
shadow_runs(&s.sides, view, v.origin, |first, quads| {
|
||||
let start = s.solid_base() + first * 4;
|
||||
self.draws.push([quads * 6, 1, 0, start, instance]);
|
||||
self.draw_pages.push((s.page, None));
|
||||
@@ -7958,6 +8065,11 @@ impl Renderer {
|
||||
self.section_draw_calls += gpu.draw_calls();
|
||||
}
|
||||
let debug_depth = frame.debug_lines.len() as u32;
|
||||
let debug_opaque = frame
|
||||
.debug_lines
|
||||
.iter()
|
||||
.filter(|l| debug_line_opaque(l))
|
||||
.count() as u32;
|
||||
let debug_top = frame.debug_lines_on_top.len() as u32;
|
||||
let crosshair = frame.debug_crosshair.len() as u32;
|
||||
if debug_depth + debug_top + crosshair > 0 {
|
||||
@@ -7971,9 +8083,7 @@ impl Renderer {
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
}
|
||||
let all: Vec<[f32; 8]> = frame
|
||||
.debug_lines
|
||||
.iter()
|
||||
let all: Vec<[f32; 8]> = debug_lines_opaque_first(frame.debug_lines)
|
||||
.chain(frame.debug_lines_on_top)
|
||||
.chain(frame.debug_crosshair)
|
||||
.copied()
|
||||
@@ -8071,7 +8181,7 @@ impl Renderer {
|
||||
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
|
||||
&& std::env::var_os("HORIZON_HDR_LIGHTING").is_none_or(|v| v != "0");
|
||||
if let Some(hdr) = &mut self.hdr {
|
||||
hdr.lighting = hdr_lighting;
|
||||
hdr.lighting = hdr_lighting && hdr.look;
|
||||
}
|
||||
let oit_on = self.improved_transparency
|
||||
&& !frame.title
|
||||
@@ -9549,10 +9659,16 @@ impl Renderer {
|
||||
if outline_edges > 0 && !frame.outline_after_terrain && !pack_draws_outline {
|
||||
self.draw_outline(&mut pass, outline_edges as u32, frame.outline_high_contrast);
|
||||
}
|
||||
if debug_depth > 0 {
|
||||
// DrawableGizmoPrimitives' opaque group writes depth, the translucent one does not.
|
||||
if debug_opaque > 0 {
|
||||
pass.set_pipeline(&self.debug_lines_opaque);
|
||||
pass.set_vertex_buffer(0, self.debug_line_buffer.slice(..));
|
||||
pass.draw(0..4, 0..debug_opaque);
|
||||
}
|
||||
if debug_depth > debug_opaque {
|
||||
pass.set_pipeline(&self.debug_lines);
|
||||
pass.set_vertex_buffer(0, self.debug_line_buffer.slice(..));
|
||||
pass.draw(0..4, 0..debug_depth);
|
||||
pass.draw(0..4, debug_opaque..debug_depth);
|
||||
}
|
||||
if debug_top > 0 {
|
||||
pass.set_pipeline(&self.debug_lines_on_top);
|
||||
@@ -11086,6 +11202,22 @@ fn projection_center(view_proj: Mat4) -> Option<Vec3> {
|
||||
let b = DVec3::new(rows[0].w, rows[1].w, rows[2].w);
|
||||
Some((a.inverse() * -b).as_vec3())
|
||||
}
|
||||
/// The runs of a section's solid quads the shadow pass draws: every face within the unculled
|
||||
/// distance, or everywhere for `every_face`; else the quads that can face the light.
|
||||
fn shadow_runs(
|
||||
sides: &Sides,
|
||||
view: &pack_post::ShadowView,
|
||||
origin: [f32; 3],
|
||||
draw: impl FnMut(u32, u32),
|
||||
) {
|
||||
let unculled = view.unculled.is_some_and(|d| {
|
||||
let o = Vec3::from(origin) + 8.0;
|
||||
o.x.abs().max(o.z.abs()) <= d + 8.0
|
||||
});
|
||||
let toward = (!unculled && !view.every_face).then_some(view.light * 1.0e5);
|
||||
facing_runs(sides, toward, origin, draw);
|
||||
}
|
||||
|
||||
/// Calls `draw(first, quads)` for each run of a mesh's solid quads that can
|
||||
/// face `eye` (`projection_center`), the mesh at the camera-relative
|
||||
/// `origin`; all of them when there is no eye.
|
||||
@@ -11126,6 +11258,20 @@ fn frustum(m: Mat4) -> [Vec4; 6] {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn opaque_gizmo_lines_upload_first_for_the_depth_writing_pipeline() {
|
||||
let line = |argb: u32| [0.0, 0.0, 0.0, 1.0, 0.0, 0.0, f32::from_bits(argb), 2.5];
|
||||
// A hitbox's white, the chunk border's outer red columns (alpha 0x7F),
|
||||
// and the chunk border's yellow.
|
||||
let lines = [line(0xFFFF_FFFF), line(0x7FFF_0000), line(0xFFFF_FF00)];
|
||||
assert!(super::debug_line_opaque(&lines[0]));
|
||||
assert!(!super::debug_line_opaque(&lines[1]));
|
||||
assert!(!super::debug_line_opaque(&line(0xFE00_0000)));
|
||||
let order: Vec<u32> = super::debug_lines_opaque_first(&lines)
|
||||
.map(|l| l[6].to_bits())
|
||||
.collect();
|
||||
assert_eq!(order, [0xFFFF_FFFF, 0xFFFF_FF00, 0x7FFF_0000]);
|
||||
}
|
||||
use crate::gfx::static_entity_shader;
|
||||
|
||||
fn adapter(device_type: wgpu::DeviceType) -> wgpu::AdapterInfo {
|
||||
@@ -12435,6 +12581,30 @@ mod sprite_upload_tests {
|
||||
assert!(layer_split(512, false, None, 512 * SPRITE_ARRAYS + 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metal_uses_a_conservative_default_split_but_respects_overrides() {
|
||||
let mut info = wgpu::AdapterInfo {
|
||||
name: "test adapter".into(),
|
||||
vendor: 0,
|
||||
device: 0,
|
||||
device_type: wgpu::DeviceType::IntegratedGpu,
|
||||
device_pci_bus_id: String::new(),
|
||||
driver: String::new(),
|
||||
driver_info: String::new(),
|
||||
backend: wgpu::Backend::Vulkan,
|
||||
subgroup_min_size: 0,
|
||||
subgroup_max_size: 0,
|
||||
transient_saves_memory: None,
|
||||
limit_bucket: None,
|
||||
};
|
||||
info.backend = wgpu::Backend::Metal;
|
||||
assert_eq!(adapter_requested_split(&info, None), Some(512));
|
||||
assert_eq!(adapter_requested_split(&info, Some(1024)), Some(1024));
|
||||
|
||||
info.backend = wgpu::Backend::Vulkan;
|
||||
assert_eq!(adapter_requested_split(&info, None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mip_chain_allocates_exactly_its_size() {
|
||||
for size in [16, 64] {
|
||||
@@ -12632,3 +12802,62 @@ fn reported(l: horizon_hdr::wayland::Luminances) -> crate::graphics::Reported {
|
||||
black: hundredths_of_a_nit(l.target_min * 203.0 / l.reference.max(1.0)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod shadow_face_tests {
|
||||
use super::*;
|
||||
|
||||
/// The faces of one block at `at` (section-relative), each with its own layer:
|
||||
/// 0 +X, 1 -X, 2 +Y (up), 3 -Y (down), 4 +Z, 5 -Z; counter-clockwise from outside.
|
||||
fn block(at: [f32; 3]) -> Vec<TerrainVertex> {
|
||||
let [x, y, z] = at;
|
||||
let (a, b, c) = (x + 1.0, y + 1.0, z + 1.0);
|
||||
[
|
||||
[[a, y, z], [a, b, z], [a, b, c], [a, y, c]],
|
||||
[[x, y, z], [x, y, c], [x, b, c], [x, b, z]],
|
||||
[[x, b, z], [x, b, c], [a, b, c], [a, b, z]],
|
||||
[[x, y, z], [a, y, z], [a, y, c], [x, y, c]],
|
||||
[[x, y, c], [a, y, c], [a, b, c], [x, b, c]],
|
||||
[[x, y, z], [x, b, z], [a, b, z], [a, y, z]],
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.flat_map(|(layer, q)| q.map(|p| TerrainVertex::new(p, [0.0; 2], layer as u16, 0, 0)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The layers (faces) of the quads the shadow pass draws of `solid`.
|
||||
fn drawn(view: &pack_post::ShadowView, origin: [f32; 3]) -> Vec<u16> {
|
||||
let mut solid = block([7.0, 7.0, 7.0]);
|
||||
let sides = crate::mesher::sort_tinted(&mut solid, &mut Vec::new());
|
||||
let mut layers = Vec::new();
|
||||
shadow_runs(&sides, view, origin, |first, quads| {
|
||||
for q in first..first + quads {
|
||||
layers.push(solid[q as usize * 4].layer);
|
||||
}
|
||||
});
|
||||
layers.sort_unstable();
|
||||
layers
|
||||
}
|
||||
|
||||
fn view(every_face: bool) -> pack_post::ShadowView {
|
||||
pack_post::ShadowView {
|
||||
cull: Mat4::IDENTITY,
|
||||
light: Vec3::Y,
|
||||
distance: None,
|
||||
unculled: None,
|
||||
every_face,
|
||||
}
|
||||
}
|
||||
|
||||
/// Iris 1.11.7's shadow map holds faces turned away from the light (#9033): the block's
|
||||
/// bottom face, under a sun straight above, is drawn too.
|
||||
#[test]
|
||||
fn the_shadow_pass_draws_faces_turned_away_from_the_light() {
|
||||
// A section 32 blocks under the camera.
|
||||
let origin = [-8.0, -40.0, -8.0];
|
||||
assert_eq!(drawn(&view(true), origin), [0, 1, 2, 3, 4, 5]);
|
||||
// Horizon's own packs keep drawing only what can face the light: no bottom face.
|
||||
assert!(!drawn(&view(false), origin).contains(&3));
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,7 @@ struct Pipelines {
|
||||
luma_pyramid: wgpu::ComputePipeline,
|
||||
scd_histogram: wgpu::ComputePipeline,
|
||||
scd_divergence: wgpu::ComputePipeline,
|
||||
scd_finalize: wgpu::ComputePipeline,
|
||||
search: wgpu::ComputePipeline,
|
||||
filter: wgpu::ComputePipeline,
|
||||
scale: wgpu::ComputePipeline,
|
||||
@@ -84,6 +85,7 @@ pub struct OpticalFlow {
|
||||
frames: [Frame; 2],
|
||||
filter: [wgpu::BindGroup; LEVELS],
|
||||
scd_divergence: wgpu::BindGroup,
|
||||
scd_finalize: wgpu::BindGroup,
|
||||
scd_histogram: wgpu::Buffer,
|
||||
scd_previous_histogram: wgpu::Buffer,
|
||||
scd_temp: wgpu::Buffer,
|
||||
@@ -183,6 +185,7 @@ impl OpticalFlow {
|
||||
luma_pyramid: pipeline("luma_pyramid"),
|
||||
scd_histogram: pipeline("scd_histogram_pass"),
|
||||
scd_divergence: pipeline("scd_divergence_pass"),
|
||||
scd_finalize: pipeline("scd_finalize_pass"),
|
||||
search: pipeline("search_pass"),
|
||||
filter: pipeline("filter_flow"),
|
||||
scale: pipeline("scale_pass"),
|
||||
@@ -235,6 +238,7 @@ impl OpticalFlow {
|
||||
HISTOGRAM_WIDTH * 4,
|
||||
);
|
||||
let scd_temp = buffer(device, "Scene change temp", 12);
|
||||
let scd_next_histogram = buffer(device, "Next scene change histogram", HISTOGRAM_WIDTH * 4);
|
||||
let scd_output = buffer(device, "Scene change detection", 12);
|
||||
let frame = |current: usize| {
|
||||
let (cur, prev) = (&luma[current], &luma[1 - current]);
|
||||
@@ -298,11 +302,22 @@ impl OpticalFlow {
|
||||
let scd_divergence = bind(
|
||||
device,
|
||||
&pipelines.scd_divergence,
|
||||
&[
|
||||
(7, whole(&scd_histogram)),
|
||||
(8, whole(&scd_previous_histogram)),
|
||||
(9, whole(&scd_temp)),
|
||||
(11, whole(&scd_next_histogram)),
|
||||
],
|
||||
);
|
||||
let scd_finalize = bind(
|
||||
device,
|
||||
&pipelines.scd_finalize,
|
||||
&[
|
||||
(7, whole(&scd_histogram)),
|
||||
(8, whole(&scd_previous_histogram)),
|
||||
(9, whole(&scd_temp)),
|
||||
(10, whole(&scd_output)),
|
||||
(11, whole(&scd_next_histogram)),
|
||||
],
|
||||
);
|
||||
Self {
|
||||
@@ -312,6 +327,7 @@ impl OpticalFlow {
|
||||
frames,
|
||||
filter,
|
||||
scd_divergence,
|
||||
scd_finalize,
|
||||
scd_histogram,
|
||||
scd_previous_histogram,
|
||||
scd_temp,
|
||||
@@ -406,6 +422,7 @@ impl OpticalFlow {
|
||||
9,
|
||||
);
|
||||
run(&p.scd_divergence, &self.scd_divergence, 9, 3, 1);
|
||||
run(&p.scd_finalize, &self.scd_finalize, 9, 1, 1);
|
||||
for level in (0..LEVELS).rev() {
|
||||
let luma = self.display.map(|v| (v >> level).max(1));
|
||||
run(
|
||||
@@ -597,6 +614,22 @@ pub(super) mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn scd(device: &wgpu::Device, queue: &wgpu::Queue, flow: &OpticalFlow) -> u32 {
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 12,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_buffer_to_buffer(flow.scene_change(), 0, &buffer, 0, 12);
|
||||
queue.submit([encoder.finish()]);
|
||||
buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let bytes = buffer.slice(..).get_mapped_range().unwrap().to_vec();
|
||||
u32::from_le_bytes(bytes[4..8].try_into().unwrap())
|
||||
}
|
||||
|
||||
/// A frame moved right by 16 and down by 8: the blocks inside report where they were
|
||||
/// (-16, -8), and nothing reads as a scene change. A much darker frame then does.
|
||||
#[test]
|
||||
@@ -630,24 +663,40 @@ pub(super) mod tests {
|
||||
inside.len(),
|
||||
&inside[..8]
|
||||
);
|
||||
let scd = |device: &wgpu::Device, queue: &wgpu::Queue, flow: &OpticalFlow| {
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 12,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_buffer_to_buffer(flow.scene_change(), 0, &buffer, 0, 12);
|
||||
queue.submit([encoder.finish()]);
|
||||
buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let bytes = buffer.slice(..).get_mapped_range().unwrap().to_vec();
|
||||
u32::from_le_bytes(bytes[4..8].try_into().unwrap())
|
||||
};
|
||||
assert_eq!(scd(&device, &queue, &flow) & 0xf, 0);
|
||||
run(&device, &queue, &mut flow, &other, false);
|
||||
assert_eq!(scd(&device, &queue, &flow) & 1, 1);
|
||||
assert!(vectors(&device, &queue, &flow).iter().all(|v| *v == [0, 0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_cuts_and_stable_frames_update_the_history() {
|
||||
let Some((device, queue)) = device() else {
|
||||
return;
|
||||
};
|
||||
let size = [384, 216];
|
||||
let bright = upload(
|
||||
&device,
|
||||
&queue,
|
||||
size,
|
||||
&vec![255; (size[0] * size[1] * 4) as usize],
|
||||
);
|
||||
let dark_pixels: Vec<u8> = (0..size[0] * size[1])
|
||||
.flat_map(|_| [0, 0, 0, 255])
|
||||
.collect();
|
||||
let dark = upload(&device, &queue, size, &dark_pixels);
|
||||
let mut flow = OpticalFlow::new(&device, size);
|
||||
run(&device, &queue, &mut flow, &bright, true);
|
||||
for _ in 0..6 {
|
||||
run(&device, &queue, &mut flow, &bright, false);
|
||||
}
|
||||
for frame in 0..16 {
|
||||
let color = if frame % 2 == 0 { &dark } else { &bright };
|
||||
run(&device, &queue, &mut flow, color, false);
|
||||
assert_eq!(scd(&device, &queue, &flow) & 1, 1, "cut {frame}");
|
||||
assert!(vectors(&device, &queue, &flow).iter().all(|v| *v == [0, 0]));
|
||||
run(&device, &queue, &mut flow, color, false);
|
||||
assert_eq!(scd(&device, &queue, &flow) & 1, 0, "stable frame {frame}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,10 +53,10 @@ struct Params {
|
||||
@group(0) @binding(8) var<storage, read_write> scd_previous_histogram: array<f32>;
|
||||
@group(0) @binding(9) var<storage, read_write> scd_temp: array<atomic<u32>, 3>;
|
||||
@group(0) @binding(10) var<storage, read_write> scd_output: array<atomic<u32>, 3>;
|
||||
@group(0) @binding(11) var<storage, read_write> scd_next_histogram: array<f32>;
|
||||
|
||||
const SCD_OUTPUT_SCENE_CHANGE_SLOT: u32 = 0u;
|
||||
const SCD_OUTPUT_HISTORY_BITS_SLOT: u32 = 1u;
|
||||
const SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT: u32 = 2u;
|
||||
|
||||
fn load_luma(t: texture_2d<f32>, p: vec2<i32>) -> u32 {
|
||||
let size = vec2<i32>(textureDimensions(t));
|
||||
@@ -266,7 +266,6 @@ fn scd_divergence_pass(
|
||||
@builtin(local_invocation_index) local_index: u32,
|
||||
) {
|
||||
let factor = 1000000.0;
|
||||
let where_to_stop = 3u * 9u - 1u;
|
||||
let histogram_count = 9.0;
|
||||
let kernel = array(0.0088122291, 0.027143577, 0.065114059, 0.12164907, 0.17699835, 0.20056541);
|
||||
let li = i32(local_index);
|
||||
@@ -326,27 +325,32 @@ fn scd_divergence_pass(
|
||||
let res_float = 1.0 - exp(-(abs(sum.x) + abs(sum.y)));
|
||||
let res_uint = u32((res_float / histogram_count) * factor);
|
||||
atomicAdd(&scd_temp[gid.y], res_uint);
|
||||
let finished = atomicAdd(&scd_output[SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT], 1u);
|
||||
if finished == where_to_stop {
|
||||
let res0 = atomicLoad(&scd_temp[0]);
|
||||
let res1 = atomicLoad(&scd_temp[1]);
|
||||
let res2 = atomicLoad(&scd_temp[2]);
|
||||
let scene_change = f32(min(res0, min(res1, res2))) / factor;
|
||||
var history = atomicLoad(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT]) << 1u;
|
||||
if scene_change > 0.45 {
|
||||
history |= 1u;
|
||||
}
|
||||
atomicStore(&scd_output[SCD_OUTPUT_SCENE_CHANGE_SLOT], bitcast<u32>(scene_change));
|
||||
atomicStore(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT], history);
|
||||
atomicStore(&scd_output[SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT], 0u);
|
||||
atomicStore(&scd_temp[0], 0u);
|
||||
atomicStore(&scd_temp[1], 0u);
|
||||
atomicStore(&scd_temp[2], 0u);
|
||||
}
|
||||
}
|
||||
if gid.y == 1u {
|
||||
scd_previous_histogram[gid.x] = current;
|
||||
atomicStore(&scd_histogram[gid.x], 0u);
|
||||
scd_next_histogram[gid.x] = current;
|
||||
}
|
||||
}
|
||||
|
||||
// All divergence workgroups must finish reading both histograms before either is
|
||||
// replaced. A separate dispatch also makes every partial sum visible to the reducer.
|
||||
@compute @workgroup_size(256)
|
||||
fn scd_finalize_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
scd_previous_histogram[gid.x] = scd_next_histogram[gid.x];
|
||||
atomicStore(&scd_histogram[gid.x], 0u);
|
||||
if gid.x == 0u {
|
||||
let res0 = atomicLoad(&scd_temp[0]);
|
||||
let res1 = atomicLoad(&scd_temp[1]);
|
||||
let res2 = atomicLoad(&scd_temp[2]);
|
||||
let scene_change = f32(min(res0, min(res1, res2))) / 1000000.0;
|
||||
var history = atomicLoad(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT]) << 1u;
|
||||
if scene_change > 0.45 {
|
||||
history |= 1u;
|
||||
}
|
||||
atomicStore(&scd_output[SCD_OUTPUT_SCENE_CHANGE_SLOT], bitcast<u32>(scene_change));
|
||||
atomicStore(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT], history);
|
||||
atomicStore(&scd_temp[0], 0u);
|
||||
atomicStore(&scd_temp[1], 0u);
|
||||
atomicStore(&scd_temp[2], 0u);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,15 +1,18 @@
|
||||
// GPU culling (gfx/gpu_culling.rs). `apply` writes the records that changed.
|
||||
// `cull`: one invocation per section id keeps the sections whose column is
|
||||
// within the render distance, that cave culling's graph reaches (occlusion.rs)
|
||||
// and whose box is in the frustum, writes each one's origin and counts it in
|
||||
// its page, per block of 64 ids. `scan`: one workgroup per page turns the
|
||||
// blocks' counts into where each block's draws start, and the page's count.
|
||||
// `scatter`: each block writes its sections' draws there, in id order, so a
|
||||
// page's draws come in the same order every frame (atomics alone would order
|
||||
// them as the GPU schedules, and where faces of two sections meet at the same
|
||||
// depth the later draw keeps the pixel). Nothing here computes with floats: the
|
||||
// origins are the CPU's bits, copied, and the frustum test adds integers the
|
||||
// CPU computed. `cull` leaves sections fading in or out to the CPU (`FADING`).
|
||||
// and whose box is in the frustum, writes each one's origin and the runs of its
|
||||
// solid quads that can face the eye, a draw each, and counts them in its page,
|
||||
// per block of 64 ids. `scan`: one workgroup per page turns the blocks' counts
|
||||
// into where each block's draws start, and the page's count. `scatter`: each
|
||||
// block writes its sections' draws there, in id order and each section's in its
|
||||
// runs' order, so a page's draws come in the same order every frame (atomics
|
||||
// alone would order them as the GPU schedules, and where faces of two sections
|
||||
// meet at the same depth the later draw keeps the pixel). Nothing here computes
|
||||
// with floats: the origins are the CPU's bits, copied, the frustum test adds
|
||||
// integers the CPU computed, and facing compares keys that order as the floats
|
||||
// the CPU compares. `cull` leaves sections fading in or out to the CPU
|
||||
// (`FADING`).
|
||||
|
||||
struct Params {
|
||||
// Ids to test, records `apply` writes, the arena pages the counts and
|
||||
@@ -31,12 +34,13 @@ struct Params {
|
||||
// Each frustum plane's w term, its margin in, then 0, 0.
|
||||
w: array<vec4<i32>, 2>,
|
||||
// Cave culling's graph (occlusion.rs `Reach`): from x, from z, radius, lowest
|
||||
// section y; its height in sections and the level's; REACH and SKY; 0.
|
||||
// section y; its height in sections and the level's; REACH, SKY and EYE;
|
||||
// where the eye's keys start in `tables`.
|
||||
reach: vec4<i32>,
|
||||
reach_height: i32,
|
||||
reach_sections: i32,
|
||||
flags: u32,
|
||||
pad: u32,
|
||||
eyes: u32,
|
||||
}
|
||||
|
||||
// gpu_culling.rs `Record`.
|
||||
@@ -44,9 +48,16 @@ struct Section {
|
||||
// Section x, y (from the level's bottom), z, and its arena page (-1: a
|
||||
// free id).
|
||||
key: vec4<i32>,
|
||||
// The first vertex of its solid quads, its solid quads, its box
|
||||
// (gpu_culling.rs `rounded_box`), then its flags (`FADING`).
|
||||
// The first vertex of its solid quads, its solid quads (where its last
|
||||
// bucket by facing side ends), its box (gpu_culling.rs `rounded_box`), then
|
||||
// its flags (`FADING`).
|
||||
a: vec4<u32>,
|
||||
// Where its first six buckets end (mesher/sides.rs `BUCKETS`): +X, +Z, the
|
||||
// other bucket, +Y, -Z, -X.
|
||||
ends: array<u32, 6>,
|
||||
// What its buckets facing +X, +Z, +Y, -Z, -X and -Y compare the eye's keys
|
||||
// with (gpu_culling.rs `Record::facing`).
|
||||
keys: array<u32, 6>,
|
||||
}
|
||||
|
||||
// gpu_culling.rs `Update`: the record for id `id.x`.
|
||||
@@ -59,8 +70,9 @@ struct Update {
|
||||
@group(0) @binding(1) var<storage, read_write> sections: array<Section>;
|
||||
// gpu_culling.rs `tables`: the origins' f32 bits along x, y and z (n of them);
|
||||
// per frustum plane its terms for those origins, n each; per plane and axis its
|
||||
// terms for the 32 box codes; then each page's region of `draws` (its first
|
||||
// draw and its size).
|
||||
// terms for the 32 box codes; with an eye (`EYE`), the keys of the eye less
|
||||
// each origin, n of them; then each page's region of `draws` (its first draw
|
||||
// and its size).
|
||||
@group(0) @binding(2) var<storage, read> tables: array<u32>;
|
||||
@group(0) @binding(3) var<storage, read> updates: array<Update>;
|
||||
// Sections kept, draws, each page's draws, then per block of ids and page its
|
||||
@@ -70,7 +82,8 @@ struct Update {
|
||||
@group(0) @binding(5) var<storage, read_write> draws: array<u32>;
|
||||
// Per id the instance it is drawn with: origin and UV step, as f32 bits.
|
||||
@group(0) @binding(6) var<storage, read_write> origins: array<vec4<u32>>;
|
||||
// Per id its page + 1 when it is drawn this frame, else 0.
|
||||
// Per id `RUN_WORDS`: its page << PAGE_SHIFT | its draws when it is drawn this
|
||||
// frame, else 0; then each draw's first quad and quads.
|
||||
@group(0) @binding(7) var<storage, read_write> runs: array<u32>;
|
||||
// occlusion.rs `Reach::bits`: whether the search reached each cell of its box,
|
||||
// a bit each (32 a word, lowest first).
|
||||
@@ -93,6 +106,13 @@ const REACH: u32 = 1u;
|
||||
const SKY: u32 = 2u;
|
||||
// In a section's `a.w`: it fades in or out, and the CPU draws it with its fade.
|
||||
const FADING: u32 = 1u;
|
||||
// In `params.flags`: solid quads facing away from the eye are left out, its
|
||||
// keys in `tables` from `params.eyes`.
|
||||
const EYE: u32 = 4u;
|
||||
// Words per id in `runs`, and where its page starts in the first, its draws
|
||||
// (at most 4: gpu_culling.rs `RUNS`) below.
|
||||
const RUN_WORDS: u32 = 9u;
|
||||
const PAGE_SHIFT: u32 = 3u;
|
||||
|
||||
// The count of `block`'s draws in `page` (`cull`), then where they start (`scan`).
|
||||
fn block_count(block: u32, page: u32) -> u32 {
|
||||
@@ -170,6 +190,34 @@ fn in_frustum(c: vec3<u32>, b: u32) -> bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Where bucket `b` of section `id` ends (mesher/sides.rs `BUCKETS` order): its
|
||||
// solid quads for the last.
|
||||
fn bucket_end(id: u32, b: u32) -> u32 {
|
||||
if b == 6u {
|
||||
return sections[id].a.y;
|
||||
}
|
||||
return sections[id].ends[b];
|
||||
}
|
||||
|
||||
// mesher/sides.rs `Sides::visible_from` for bucket `b` of section `id` (+X, +Z,
|
||||
// the other bucket, +Y, -Z, -X, -Y), with `eye` the keys of the eye less its
|
||||
// origin: whether its quads can face the eye. -Y compares the eye's own y, as a
|
||||
// GPU frame has no water mirror. The keys order as the floats the CPU compares.
|
||||
fn seen(id: u32, b: u32, eye: vec3<u32>) -> bool {
|
||||
if b == 2u {
|
||||
return true;
|
||||
}
|
||||
let t = sections[id].keys[b - select(0u, 1u, b > 2u)];
|
||||
switch b {
|
||||
case 0u: { return eye.x > t; }
|
||||
case 1u: { return eye.z > t; }
|
||||
case 3u: { return eye.y > t; }
|
||||
case 4u: { return eye.z < t; }
|
||||
case 5u: { return eye.x < t; }
|
||||
default: { return eye.y < t; }
|
||||
}
|
||||
}
|
||||
|
||||
// One invocation per changed record; the CPU lists each id once.
|
||||
@compute @workgroup_size(64)
|
||||
fn apply(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
@@ -186,7 +234,8 @@ fn cull(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
if id >= params.ids {
|
||||
return;
|
||||
}
|
||||
runs[id] = 0u;
|
||||
let at = id * RUN_WORDS;
|
||||
runs[at] = 0u;
|
||||
let s = sections[id];
|
||||
if s.key.w < 0 || s.a.y == 0u || (s.a.w & FADING) != 0u || !near(s.key.x, s.key.z)
|
||||
|| !reached(s.key.xyz) {
|
||||
@@ -199,11 +248,52 @@ fn cull(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
if page >= params.pages || any(c >= params.cells.xyz) || !in_frustum(c, s.a.z) {
|
||||
return;
|
||||
}
|
||||
// Kept, whether or not a quad of it can face the eye.
|
||||
atomicAdd(&counts[0], 1u);
|
||||
let nx = params.cells.x;
|
||||
let ny = params.cells.y;
|
||||
origins[id] = vec4<u32>(tables[c.x], tables[nx + c.y], tables[nx + ny + c.z], STEP_BITS);
|
||||
runs[id] = page + 1u;
|
||||
atomicAdd(&counts[block_count(id / BLOCK, page)], 1u);
|
||||
// gfx.rs `facing_runs`: the runs of its buckets that can face the eye, an
|
||||
// empty bucket breaking none, a draw each; without an eye, its solid quads.
|
||||
var n = 0u;
|
||||
if (params.flags & EYE) == 0u {
|
||||
runs[at + 1u] = 0u;
|
||||
runs[at + 2u] = s.a.y;
|
||||
n = 1u;
|
||||
} else {
|
||||
let e = params.eyes;
|
||||
let eye = vec3<u32>(tables[e + c.x], tables[e + nx + c.y], tables[e + nx + ny + c.z]);
|
||||
var start = 0u;
|
||||
var first = 0u;
|
||||
var open = false;
|
||||
for (var b = 0u; b < 7u; b++) {
|
||||
let end = bucket_end(id, b);
|
||||
if end == start {
|
||||
continue;
|
||||
}
|
||||
let shown = seen(id, b, eye);
|
||||
if shown && !open {
|
||||
open = true;
|
||||
first = start;
|
||||
} else if !shown && open {
|
||||
runs[at + 1u + 2u * n] = first;
|
||||
runs[at + 2u + 2u * n] = start - first;
|
||||
n += 1u;
|
||||
open = false;
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
if open {
|
||||
runs[at + 1u + 2u * n] = first;
|
||||
runs[at + 2u + 2u * n] = start - first;
|
||||
n += 1u;
|
||||
}
|
||||
}
|
||||
if n == 0u {
|
||||
return;
|
||||
}
|
||||
runs[at] = (page << PAGE_SHIFT) | n;
|
||||
atomicAdd(&counts[block_count(id / BLOCK, page)], n);
|
||||
}
|
||||
|
||||
var<workgroup> partial: array<u32, 256>;
|
||||
@@ -242,49 +332,54 @@ fn scan(@builtin(workgroup_id) group: vec3<u32>, @builtin(local_invocation_index
|
||||
start += count;
|
||||
}
|
||||
if lane == 255u {
|
||||
// A region has room for every record of its page; were one short,
|
||||
// its draws past it go.
|
||||
// A region has room for every draw of its page's records; were one
|
||||
// short, its draws past it go.
|
||||
let total = min(partial[255], region(page).y);
|
||||
atomicStore(&counts[STATS + page], total);
|
||||
atomicAdd(&counts[0], partial[255]);
|
||||
atomicAdd(&counts[1], total);
|
||||
}
|
||||
}
|
||||
|
||||
var<workgroup> held: array<u32, 64>;
|
||||
|
||||
// One workgroup per block of ids: each section's draw goes after those of the
|
||||
// block's earlier sections in its page.
|
||||
// One workgroup per block of ids: each section's draws go after those of the
|
||||
// block's earlier sections in its page, in its runs' order.
|
||||
@compute @workgroup_size(64)
|
||||
fn scatter(@builtin(workgroup_id) group: vec3<u32>, @builtin(local_invocation_index) lane: u32) {
|
||||
let block = group.y * ROW + group.x;
|
||||
let id = block * BLOCK + lane;
|
||||
var mine = 0u;
|
||||
if id < params.ids {
|
||||
mine = runs[id];
|
||||
mine = runs[id * RUN_WORDS];
|
||||
}
|
||||
held[lane] = mine;
|
||||
workgroupBarrier();
|
||||
if mine == 0u {
|
||||
// Its draws below PAGE_SHIFT, its page above.
|
||||
let n = mine & 7u;
|
||||
if n == 0u {
|
||||
return;
|
||||
}
|
||||
let page = mine - 1u;
|
||||
let page = mine >> PAGE_SHIFT;
|
||||
var slot = atomicLoad(&counts[block_count(block, page)]);
|
||||
for (var other = 0u; other < lane; other++) {
|
||||
if held[other] == mine {
|
||||
slot += 1u;
|
||||
// A section not drawn holds 0: no draws.
|
||||
if (held[other] >> PAGE_SHIFT) == page {
|
||||
slot += held[other] & 7u;
|
||||
}
|
||||
}
|
||||
let r = region(page);
|
||||
if slot >= r.y {
|
||||
return;
|
||||
let at = id * RUN_WORDS;
|
||||
let base = sections[id].a.x;
|
||||
for (var k = 0u; k < n; k++) {
|
||||
if slot + k >= r.y {
|
||||
return;
|
||||
}
|
||||
// A run of its solid quads, from its first vertex, with its origin.
|
||||
let d = (r.x + slot + k) * 5u;
|
||||
draws[d] = runs[at + 2u + 2u * k] * 6u;
|
||||
draws[d + 1u] = 1u;
|
||||
draws[d + 2u] = 0u;
|
||||
draws[d + 3u] = base + runs[at + 1u + 2u * k] * 4u;
|
||||
draws[d + 4u] = id;
|
||||
}
|
||||
// All of its solid quads, from its first vertex, with its origin.
|
||||
let s = sections[id];
|
||||
let d = (r.x + slot) * 5u;
|
||||
draws[d] = s.a.y * 6u;
|
||||
draws[d + 1u] = 1u;
|
||||
draws[d + 2u] = 0u;
|
||||
draws[d + 3u] = s.a.x;
|
||||
draws[d + 4u] = id;
|
||||
}
|
||||
@@ -168,6 +168,9 @@ pub struct Hdr {
|
||||
/// HDR lighting: off while a shader pack's post or material stage runs, the pack's own, and
|
||||
/// with `HORIZON_HDR_LIGHTING=0`, to compare.
|
||||
pub lighting: bool,
|
||||
/// The HDR Lighting setting: off, nothing in the world is lifted, light sources and the
|
||||
/// sun included, so the picture is SDR's on the HDR canvas.
|
||||
pub look: bool,
|
||||
/// Calibration's test picture while it runs.
|
||||
pub calibration: Option<Calibration>,
|
||||
}
|
||||
@@ -313,6 +316,7 @@ impl Hdr {
|
||||
ui: 1.0,
|
||||
masks: MASKS,
|
||||
lighting: true,
|
||||
look: true,
|
||||
calibration: None,
|
||||
}
|
||||
}
|
||||
@@ -553,7 +557,7 @@ impl Hdr {
|
||||
params[..5].copy_from_slice(&[
|
||||
paper / 80.0,
|
||||
(self.peak / paper).max(1.0),
|
||||
f32::from(u8::from(world)),
|
||||
f32::from(u8::from(world && self.look)),
|
||||
f32::from(u8::from(self.debug)),
|
||||
f32::from(u8::from(pq)),
|
||||
]);
|
||||
|
||||
@@ -59,6 +59,25 @@ fn chosen_cells(
|
||||
chosen.unwrap_or(d.cells)
|
||||
}
|
||||
|
||||
/// `[shadow]` with the option values `constants`: none while its `option` is off, else the
|
||||
/// resolution and distance its `resolution_option` and `distance_option` pick.
|
||||
fn chosen_shadow(
|
||||
s: &horizon_shader_api::manifest::Shadow,
|
||||
options: &[horizon_shader_api::manifest::PackOption],
|
||||
constants: &[(String, f64)],
|
||||
) -> Option<horizon_shader_api::manifest::Shadow> {
|
||||
let number = |id: Option<&String>| {
|
||||
let option = options.iter().find(|o| Some(&o.id) == id)?;
|
||||
let (_, value) = constants.iter().find(|(k, _)| *k == option.id)?;
|
||||
option.kind.number(*value)
|
||||
};
|
||||
option_on(constants, s.option.as_ref()).then(|| horizon_shader_api::manifest::Shadow {
|
||||
resolution: number(s.resolution_option.as_ref()).map_or(s.resolution, |r| r as u32),
|
||||
distance: number(s.distance_option.as_ref()).map_or(s.distance, |d| d as f32),
|
||||
..s.clone()
|
||||
})
|
||||
}
|
||||
|
||||
impl NativePack {
|
||||
/// The pack of the checked stages `report` with the option values `constants`, drawing
|
||||
/// onto the world's targets as `terrain` describes; the sky reads the terrain's group 0.
|
||||
@@ -74,8 +93,12 @@ impl NativePack {
|
||||
) -> Result<(Self, Vec<String>), String> {
|
||||
let (globals, format) = (terrain.globals, terrain.format);
|
||||
let textures = super::native_post::pack_textures(device, queue, &report);
|
||||
let settings = report
|
||||
// `[shadow]`, unless its option is off, at the size its options pick.
|
||||
let chosen = report
|
||||
.shadow
|
||||
.as_ref()
|
||||
.and_then(|s| chosen_shadow(s, &report.manifest.options, constants));
|
||||
let settings = chosen
|
||||
.as_ref()
|
||||
.map(|settings| super::native_shadow::fitted(device, settings));
|
||||
let mut frame = NativeFrame::new(device, settings.as_ref(), textures, lights, terrain.pbr);
|
||||
@@ -103,8 +126,7 @@ impl NativePack {
|
||||
report.shadow.is_some(),
|
||||
)
|
||||
});
|
||||
let shadow = report
|
||||
.shadow
|
||||
let shadow = chosen
|
||||
.as_ref()
|
||||
.map(|s| {
|
||||
let vertex = report.stages.iter().find(|s| s.kind == StageKind::Vertex);
|
||||
@@ -240,3 +262,61 @@ impl NativePack {
|
||||
|| self.post.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::chosen_shadow;
|
||||
use horizon_shader_api::manifest::{OptionKind, PackOption, Shadow};
|
||||
|
||||
fn option(id: &str, kind: OptionKind) -> PackOption {
|
||||
PackOption {
|
||||
id: id.into(),
|
||||
name: id.into(),
|
||||
description: String::new(),
|
||||
kind,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shadow_map_follows_its_options() {
|
||||
let options = [
|
||||
option("shadows", OptionKind::Bool { default: true }),
|
||||
option(
|
||||
"quality",
|
||||
OptionKind::Choice {
|
||||
values: vec!["1024".into(), "4096".into()],
|
||||
default: 0,
|
||||
},
|
||||
),
|
||||
option(
|
||||
"reach",
|
||||
OptionKind::Int {
|
||||
default: 96,
|
||||
min: 32,
|
||||
max: 512,
|
||||
},
|
||||
),
|
||||
];
|
||||
let shadow = Shadow {
|
||||
option: Some("shadows".into()),
|
||||
resolution_option: Some("quality".into()),
|
||||
distance_option: Some("reach".into()),
|
||||
..Shadow::default()
|
||||
};
|
||||
let values = |on: f64, quality: f64, reach: f64| {
|
||||
[("shadows", on), ("quality", quality), ("reach", reach)].map(|(k, v)| (k.into(), v))
|
||||
};
|
||||
let size = |values: &[(String, f64)]| {
|
||||
chosen_shadow(&shadow, &options, values).map(|s| (s.resolution, s.distance))
|
||||
};
|
||||
assert_eq!(size(&values(1.0, 1.0, 200.0)), Some((4096, 200.0)));
|
||||
assert_eq!(size(&values(1.0, 0.0, 64.0)), Some((1024, 64.0)));
|
||||
assert_eq!(size(&values(0.0, 1.0, 200.0)), None);
|
||||
// Without the options' keys: the manifest's own numbers, always drawn.
|
||||
assert_eq!(
|
||||
chosen_shadow(&Shadow::default(), &options, &values(0.0, 1.0, 200.0))
|
||||
.map(|s| (s.resolution, s.distance)),
|
||||
Some((2048, 128.0))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -366,6 +366,7 @@ impl NativeFrame {
|
||||
// the sun's azimuth while the sun is low.
|
||||
distance: None,
|
||||
unculled: None,
|
||||
every_face: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -806,12 +806,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn what_the_camera_stands_on_is_inside_the_shadow_cameras_depth() {
|
||||
let settings = Settings {
|
||||
resolution: 2048,
|
||||
distance: 128.0,
|
||||
solid_materials: Vec::new(),
|
||||
};
|
||||
let shadow = camera(&settings);
|
||||
let shadow = camera(&Settings::default());
|
||||
let (view, projection) = super::super::pack_uniforms::shadow_camera(
|
||||
glam::Vec3::new(0.3, 0.9, 0.0).normalize(),
|
||||
glam::DVec3::new(-2064.5, 246.0, -4304.5),
|
||||
@@ -827,11 +822,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn the_centre_stays_near_the_player_on_both_sides_of_zero() {
|
||||
let shadow = camera(&Settings {
|
||||
resolution: 2048,
|
||||
distance: 128.0,
|
||||
solid_materials: Vec::new(),
|
||||
});
|
||||
let shadow = camera(&Settings::default());
|
||||
let light = glam::Vec3::Y;
|
||||
for x in [5.5, -5.5, -2064.5, 2064.5] {
|
||||
let camera = glam::DVec3::new(x, 64.0, x);
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
//! The block atlas of shader packs (spec 5.1, `atlasSize` in 5.2): packs sample one 2D atlas
|
||||
//! with atlas coordinates, Horizon keeps block sprites in texture arrays. With a pack, every
|
||||
//! layer is copied into a grid, row by row from the top left, and an animated layer again
|
||||
//! whenever its frame changes.
|
||||
//! layer is copied into the atlas, and an animated layer again whenever its frame changes.
|
||||
//!
|
||||
//! Flat draws, which read a table per sprite layer, get the game's own layout
|
||||
//! ([`PackAtlas::stitched`]): every sprite at its own size, placed by vanilla's `Stitcher` with its
|
||||
//! padding; this gives the `atlasSize` and spans our probes read in Iris 1.11.7. Other draws get
|
||||
//! a grid of array-sized cells ([`PackAtlas::new`]).
|
||||
|
||||
#[path = "pack_atlas/stitch.rs"]
|
||||
mod stitch;
|
||||
|
||||
pub(super) struct PackAtlas {
|
||||
texture: wgpu::Texture,
|
||||
@@ -14,6 +21,82 @@ pub(super) struct PackAtlas {
|
||||
split: u32,
|
||||
/// OptiFine/Iris separate ambient occlusion: pass vertex alpha to hz_Color.a.
|
||||
pub separate_ao: bool,
|
||||
/// The stitched layout, when the atlas has one.
|
||||
stitched: Option<Stitched>,
|
||||
}
|
||||
|
||||
/// A stitched atlas: where each layer's sprite is, and the pipeline that draws them in.
|
||||
struct Stitched {
|
||||
/// Each layer's sprite at the first level: its top left corner and its side.
|
||||
rects: Vec<[u32; 3]>,
|
||||
padding: u32,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
/// One per sprite array.
|
||||
groups: Vec<wgpu::BindGroup>,
|
||||
}
|
||||
|
||||
/// One sprite drawn into one level of a stitched atlas (`BLIT`'s instance).
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct BlitSprite {
|
||||
/// The sprite's top left corner, side and padding at that level.
|
||||
rect: [i32; 4],
|
||||
/// Its layer in its array, the array's level it comes from, the atlas level's size.
|
||||
source: [u32; 4],
|
||||
}
|
||||
|
||||
/// Draws sprites into a stitched atlas level, padding included: a pixel of the padding repeats
|
||||
/// the sprite's nearest edge pixel, as vanilla's `animate_sprite` shaders do with a clamped sampler.
|
||||
const BLIT: &str = r#"
|
||||
struct Out {
|
||||
@builtin(position) position: vec4<f32>,
|
||||
@location(0) @interpolate(flat) rect: vec4<i32>,
|
||||
@location(1) @interpolate(flat) source: vec2<u32>,
|
||||
}
|
||||
@group(0) @binding(0) var sprites: texture_2d_array<f32>;
|
||||
@vertex
|
||||
fn vs(@builtin(vertex_index) v: u32, @location(0) rect: vec4<i32>, @location(1) source: vec4<u32>) -> Out {
|
||||
var corners = array<vec2<f32>, 6>(vec2(0.0, 0.0), vec2(1.0, 0.0), vec2(0.0, 1.0),
|
||||
vec2(0.0, 1.0), vec2(1.0, 0.0), vec2(1.0, 1.0));
|
||||
let pixel = vec2<f32>(rect.xy - rect.w) + corners[v] * f32(rect.z + 2 * rect.w);
|
||||
let size = vec2<f32>(source.zw);
|
||||
var out: Out;
|
||||
out.position = vec4(pixel.x / size.x * 2.0 - 1.0, 1.0 - pixel.y / size.y * 2.0, 0.0, 1.0);
|
||||
out.rect = rect;
|
||||
out.source = source.xy;
|
||||
return out;
|
||||
}
|
||||
@fragment
|
||||
fn fs(in: Out) -> @location(0) vec4<f32> {
|
||||
let texel = clamp(vec2<i32>(floor(in.position.xy)) - in.rect.xy, vec2(0), vec2(in.rect.z - 1));
|
||||
return textureLoad(sprites, texel, i32(in.source.x), i32(in.source.y));
|
||||
}
|
||||
"#;
|
||||
|
||||
/// The side a layer of `sprite`-wide arrays has in a stitched atlas: its own, when the arrays
|
||||
/// hold it scaled up by a power of two (else the array's).
|
||||
fn atlas_side(native: u32, sprite: u32) -> u32 {
|
||||
if native == 0
|
||||
|| native > sprite
|
||||
|| !sprite.is_multiple_of(native)
|
||||
|| !(sprite / native).is_power_of_two()
|
||||
{
|
||||
sprite
|
||||
} else {
|
||||
native
|
||||
}
|
||||
}
|
||||
|
||||
/// `SpriteLoader.stitch`'s mip level: the option, lowered to fit the smallest sprite.
|
||||
fn stitch_mip_level(sides: &[u32], option: u32) -> u32 {
|
||||
let mut lowest = 1u32 << option.min(16);
|
||||
let mut smallest = u32::MAX;
|
||||
for &side in sides {
|
||||
smallest = smallest.min(side);
|
||||
lowest = lowest.min(side & side.wrapping_neg());
|
||||
}
|
||||
let level = smallest.min(lowest).max(1).ilog2();
|
||||
level.min(option)
|
||||
}
|
||||
|
||||
impl PackAtlas {
|
||||
@@ -91,11 +174,173 @@ impl PackAtlas {
|
||||
sprite,
|
||||
split,
|
||||
separate_ao: false,
|
||||
stitched: None,
|
||||
};
|
||||
atlas.copy(encoder, arrays, 0..layers);
|
||||
atlas.copy(device, encoder, arrays, 0..layers);
|
||||
Some(atlas)
|
||||
}
|
||||
|
||||
/// Every layer of `arrays` at its own size (`native`, its side before the arrays scaled it up
|
||||
/// to `sprite`), placed as vanilla's `Stitcher` places them with the padding of the Mipmap
|
||||
/// Levels option and the anisotropy bit; `None` when they don't fit the device.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn stitched(
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
arrays: &[wgpu::Texture],
|
||||
split: u32,
|
||||
sprite: u32,
|
||||
native: &[u32],
|
||||
mip_option: u32,
|
||||
anisotropy_bit: u32,
|
||||
) -> Option<Self> {
|
||||
let sides: Vec<u32> = if native.is_empty() {
|
||||
vec![sprite]
|
||||
} else {
|
||||
native.iter().map(|&n| atlas_side(n, sprite)).collect()
|
||||
};
|
||||
// A level of the atlas comes from the arrays' level as many halvings further down.
|
||||
let array_levels = arrays[0].mip_level_count();
|
||||
let deepest = sides
|
||||
.iter()
|
||||
.map(|&s| (sprite / s).ilog2())
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let mip =
|
||||
stitch_mip_level(&sides, mip_option).min(array_levels.saturating_sub(deepest + 1));
|
||||
let max = device.limits().max_texture_dimension_2d;
|
||||
let layout = stitch::stitch(&sides, mip, anisotropy_bit, max)?;
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Shader pack block atlas"),
|
||||
size: wgpu::Extent3d {
|
||||
width: layout.width.max(1),
|
||||
height: layout.height.max(1),
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: mip + 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_DST
|
||||
| wgpu::TextureUsages::COPY_SRC
|
||||
| wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = texture.create_view(&Default::default());
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Shader pack atlas sprites"),
|
||||
source: wgpu::ShaderSource::Wgsl(BLIT.into()),
|
||||
});
|
||||
let group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Shader pack atlas sprites"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2Array,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Shader pack atlas sprites"),
|
||||
bind_group_layouts: &[Some(&group_layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Shader pack atlas sprites"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("vs"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<BlitSprite>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &wgpu::vertex_attr_array![0 => Sint32x4, 1 => Uint32x4],
|
||||
})],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::TextureFormat::Rgba8Unorm.into())],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let groups = arrays
|
||||
.iter()
|
||||
.map(|array| {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Shader pack atlas sprites"),
|
||||
layout: &group_layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&array.create_view(
|
||||
&wgpu::TextureViewDescriptor {
|
||||
dimension: Some(wgpu::TextureViewDimension::D2Array),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
}],
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let rects = layout
|
||||
.holders
|
||||
.iter()
|
||||
.zip(&sides)
|
||||
.map(|(&[x, y], &side)| [x + layout.padding, y + layout.padding, side])
|
||||
.collect::<Vec<_>>();
|
||||
let layers = rects.len() as u32;
|
||||
let atlas = Self {
|
||||
texture,
|
||||
view,
|
||||
columns: 1,
|
||||
sprite,
|
||||
split,
|
||||
separate_ao: false,
|
||||
stitched: Some(Stitched {
|
||||
rects,
|
||||
padding: layout.padding,
|
||||
pipeline,
|
||||
groups,
|
||||
}),
|
||||
};
|
||||
atlas.copy(device, encoder, arrays, 0..layers);
|
||||
Some(atlas)
|
||||
}
|
||||
|
||||
/// Whether the sprites keep their own sizes ([`PackAtlas::stitched`]).
|
||||
pub fn is_stitched(&self) -> bool {
|
||||
self.stitched.is_some()
|
||||
}
|
||||
|
||||
/// The table flat terrain reads (`hz_sprite_rects`): per layer, its sprite's top left corner
|
||||
/// (x | y << 16) and side, in atlas pixels.
|
||||
pub fn rects_table(&self) -> Vec<u32> {
|
||||
if let Some(stitched) = &self.stitched {
|
||||
return stitched
|
||||
.rects
|
||||
.iter()
|
||||
.flat_map(|&[x, y, side]| [x | (y << 16), side])
|
||||
.collect();
|
||||
}
|
||||
(0..self.columns * self.texture.height() / self.sprite)
|
||||
.flat_map(|layer| {
|
||||
let (x, y) = (layer % self.columns, layer / self.columns);
|
||||
[(x * self.sprite) | ((y * self.sprite) << 16), self.sprite]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The atlas's size in pixels (`atlasSize`).
|
||||
pub fn size(&self) -> [u32; 2] {
|
||||
[self.texture.width(), self.texture.height()]
|
||||
@@ -104,10 +349,14 @@ impl PackAtlas {
|
||||
/// Copies `layers` from the sprite arrays again, every level.
|
||||
pub fn copy(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
arrays: &[wgpu::Texture],
|
||||
layers: impl IntoIterator<Item = u32>,
|
||||
) {
|
||||
if let Some(stitched) = &self.stitched {
|
||||
return self.draw_sprites(stitched, device, encoder, layers);
|
||||
}
|
||||
for layer in layers {
|
||||
let Some(array) = arrays.get((layer / self.split) as usize) else {
|
||||
continue;
|
||||
@@ -145,6 +394,91 @@ impl PackAtlas {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws `layers` into every level of a stitched atlas, from the arrays' level where each
|
||||
/// sprite has its own size.
|
||||
fn draw_sprites(
|
||||
&self,
|
||||
stitched: &Stitched,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
layers: impl IntoIterator<Item = u32>,
|
||||
) {
|
||||
let mut layers: Vec<u32> = layers
|
||||
.into_iter()
|
||||
.filter(|&l| (l as usize) < stitched.rects.len())
|
||||
.collect();
|
||||
if layers.is_empty() {
|
||||
return;
|
||||
}
|
||||
layers.sort_unstable_by_key(|&l| (l / self.split, l));
|
||||
let levels = self.texture.mip_level_count();
|
||||
let mut sprites = Vec::with_capacity(layers.len() * levels as usize);
|
||||
for level in 0..levels {
|
||||
let size = [
|
||||
self.texture.width() >> level,
|
||||
self.texture.height() >> level,
|
||||
];
|
||||
for &layer in &layers {
|
||||
let [x, y, side] = stitched.rects[layer as usize];
|
||||
sprites.push(BlitSprite {
|
||||
rect: [x, y, side, stitched.padding].map(|v| (v >> level) as i32),
|
||||
source: [
|
||||
layer % self.split,
|
||||
(self.sprite / side).ilog2() + level,
|
||||
size[0],
|
||||
size[1],
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
let buffer = wgpu::util::DeviceExt::create_buffer_init(
|
||||
device,
|
||||
&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Shader pack atlas sprites"),
|
||||
contents: bytemuck::cast_slice(&sprites),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
},
|
||||
);
|
||||
let per_level = layers.len() as u32;
|
||||
for level in 0..levels {
|
||||
let view = self.texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
base_mip_level: level,
|
||||
mip_level_count: Some(1),
|
||||
..Default::default()
|
||||
});
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Shader pack atlas sprites"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&stitched.pipeline);
|
||||
pass.set_vertex_buffer(0, buffer.slice(..));
|
||||
let first = level * per_level;
|
||||
let mut start = 0;
|
||||
while start < layers.len() {
|
||||
let array = layers[start] / self.split;
|
||||
let end = start
|
||||
+ layers[start..]
|
||||
.iter()
|
||||
.take_while(|&&l| l / self.split == array)
|
||||
.count();
|
||||
if let Some(group) = stitched.groups.get(array as usize) {
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.draw(0..6, first + start as u32..first + end as u32);
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `TerrainVertex` of flat draws' arena page (`hz_vertices`, six words each) into the vertex
|
||||
@@ -204,7 +538,8 @@ impl PackAtlas {
|
||||
// vertices (`TerrainVertex`, six words each).
|
||||
declarations.push_str(
|
||||
"layout(set = 3, binding = 0) readonly buffer HzBlockIds { int hz_block_ids[]; };\n\
|
||||
layout(set = 3, binding = 1) readonly buffer HzVertices { uint hz_vertices[]; };\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",
|
||||
);
|
||||
"hz_slot_origins[hz_block_slots[uint(gl_VertexIndex) >> 8u]]"
|
||||
} else {
|
||||
@@ -212,11 +547,29 @@ impl PackAtlas {
|
||||
"hz_v_origin"
|
||||
};
|
||||
let [width, height] = self.size();
|
||||
let scale = [
|
||||
self.sprite as f32 / width as f32,
|
||||
self.sprite as f32 / height as f32,
|
||||
];
|
||||
// A sprite's cell in its own sides, and the share of the atlas a side is: from the table
|
||||
// in flat draws (`rects_table`), from the grid in others.
|
||||
let columns = self.columns;
|
||||
let (cell, side, scale) = if flat {
|
||||
(
|
||||
"uvec2 hz_rect = hz_sprite_rects[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)",
|
||||
[1.0 / width as f32, 1.0 / height as f32],
|
||||
)
|
||||
} else {
|
||||
(
|
||||
format!(
|
||||
"hz_cell = vec2(float(hz_layer % {columns}u), float(hz_layer / {columns}u));\n "
|
||||
),
|
||||
"1.0",
|
||||
[
|
||||
self.sprite as f32 / width as f32,
|
||||
self.sprite as f32 / height as f32,
|
||||
],
|
||||
)
|
||||
};
|
||||
// UVs past a sprite (models up to 2, distant tiles) cannot repeat in an atlas: clamped.
|
||||
// The colour's alpha holds HDR's emission level (`mesher::EMISSION_BITS`): packs get 1 unless separate_ao.
|
||||
let color_a = if self.separate_ao {
|
||||
@@ -227,8 +580,8 @@ impl PackAtlas {
|
||||
let body = format!(
|
||||
" vec4 hz_origin = {origin};\n \
|
||||
uint hz_layer = hz_v_layer & 32767u;\n \
|
||||
hz_cell = vec2(float(hz_layer % {columns}u), float(hz_layer / {columns}u));\n \
|
||||
hz_scale = vec2({:?}, {:?});\n \
|
||||
{cell}\
|
||||
hz_scale = vec2({:?}, {:?}) * {side};\n \
|
||||
hz_Vertex = vec4(hz_v_pos + hz_origin.xyz, 1.0);\n \
|
||||
hz_uv_scale = hz_origin.w > 0.0 ? uintBitsToFloat(floatBitsToUint(hz_origin.w) & 0xff800000u) : fract(abs(hz_origin.w));\n \
|
||||
hz_mid = vec2(0.5);\n \
|
||||
@@ -369,6 +722,128 @@ mod tests {
|
||||
assert_eq!([red(1, 3), red(3, 2), red(5, 2)], [120, 160, 0]);
|
||||
}
|
||||
|
||||
/// An array of `sprite`-pixel layers (`sprite` the largest of `native`), as the game builds
|
||||
/// them: each layer scaled up by whole pixels at level 0, its own pixels from the level where
|
||||
/// it has its own size. Every pixel of a layer's own image differs: (layer, x, y, 255).
|
||||
pub(super) fn upscaled_array(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
native: &[u32],
|
||||
) -> wgpu::Texture {
|
||||
let sprite = native.iter().copied().max().unwrap();
|
||||
let levels = sprite.ilog2() + 1;
|
||||
let mut data = Vec::new();
|
||||
for (layer, &side) in native.iter().enumerate() {
|
||||
for level in 0..levels {
|
||||
let edge = sprite >> level;
|
||||
// Above its own size a level repeats each pixel; below, it shrinks to the first.
|
||||
let scale = (edge / side).max(1);
|
||||
for y in 0..edge {
|
||||
for x in 0..edge {
|
||||
let (x, y) = if edge >= side {
|
||||
(x / scale, y / scale)
|
||||
} else {
|
||||
(0, 0)
|
||||
};
|
||||
data.extend([layer as u8 * 50, x as u8 * 16, y as u8 * 16, 255]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
wgpu::util::DeviceExt::create_texture_with_data(
|
||||
device,
|
||||
queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: sprite,
|
||||
height: sprite,
|
||||
depth_or_array_layers: native.len() as u32,
|
||||
},
|
||||
mip_level_count: levels,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
&data,
|
||||
)
|
||||
}
|
||||
|
||||
/// Sprites of 2, 4 and 1 px in a 4 px array, no mipmaps (one pixel of padding). Worked through
|
||||
/// vanilla's `Stitcher` by hand: holders of 6, 4 and 3 px at (0, 0), (8, 0) and (8, 4) in a 16
|
||||
/// by 8 atlas. Each sprite has its own pixels, and its padding repeats its edge.
|
||||
#[test]
|
||||
fn stitched_sprites_keep_their_own_size_and_padding() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let (device, queue) =
|
||||
pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let array = upscaled_array(&device, &queue, &[2, 4, 1]);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let atlas =
|
||||
PackAtlas::stitched(&device, &mut encoder, &[array], 3, 4, &[2, 4, 1], 0, 0).unwrap();
|
||||
assert_eq!(atlas.size(), [16, 8]);
|
||||
assert_eq!(
|
||||
atlas.rects_table(),
|
||||
[9 | 1 << 16, 2, 1 | 1 << 16, 4, 9 | 5 << 16, 1]
|
||||
);
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 256 * 8,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
atlas.texture.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &read,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: 16,
|
||||
height: 8,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let data = read.slice(..).get_mapped_range().unwrap();
|
||||
let at = |x: usize, y: usize| {
|
||||
let i = y * 256 + x * 4;
|
||||
[data[i], data[i + 1], data[i + 2], data[i + 3]]
|
||||
};
|
||||
// Layer 0's own 2 px: (layer 0, x, y); its padding repeats the nearest edge pixel.
|
||||
assert_eq!(
|
||||
[at(9, 1), at(10, 1), at(10, 2)],
|
||||
[[0, 0, 0, 255], [0, 16, 0, 255], [0, 16, 16, 255]]
|
||||
);
|
||||
assert_eq!(
|
||||
[at(8, 0), at(11, 3), at(11, 1)],
|
||||
[[0, 0, 0, 255], [0, 16, 16, 255], [0, 16, 0, 255]]
|
||||
);
|
||||
// Layer 1 fills its 4 px, padded; layer 2 is one pixel in the middle of its padding.
|
||||
assert_eq!(
|
||||
[at(1, 1), at(4, 4), at(0, 5), at(5, 0)],
|
||||
[
|
||||
[50, 0, 0, 255],
|
||||
[50, 48, 48, 255],
|
||||
[50, 0, 48, 255],
|
||||
[50, 48, 0, 255]
|
||||
]
|
||||
);
|
||||
assert_eq!([at(9, 5), at(8, 4), at(10, 6)], [[100, 0, 0, 255]; 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terrain_inputs_respects_separate_ao() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
@@ -398,6 +873,7 @@ mod tests {
|
||||
sprite: 16,
|
||||
split: 1,
|
||||
separate_ao: false,
|
||||
stitched: None,
|
||||
};
|
||||
let inputs_default = atlas.terrain_inputs(false);
|
||||
assert!(
|
||||
|
||||
@@ -123,6 +123,54 @@ fn mid_tex_coord_follows_each_quads_mapped_corners() {
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
/// A stitched atlas (the one `stitched_sprites_keep_their_own_size_and_padding` works out: 16 by
|
||||
/// 8, sprites of 2, 4 and 1 px at (9, 1), (1, 1) and (9, 5)): flat draws map a quad's UVs onto its
|
||||
/// sprite's own pixels, so a corner is half the sprite's own side from `mc_midTexCoord`.
|
||||
#[test]
|
||||
fn flat_draws_map_uvs_onto_a_stitched_sprite() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let array = super::tests::upscaled_array(&device, &queue, &[2, 4, 1]);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let atlas =
|
||||
PackAtlas::stitched(&device, &mut encoder, &[array], 3, 4, &[2, 4, 1], 0, 0).unwrap();
|
||||
queue.submit([encoder.finish()]);
|
||||
let whole = [[0., 0.], [1., 0.], [1., 1.], [0., 1.]];
|
||||
let positions = [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.]];
|
||||
let vertices: Vec<_> = (0..3)
|
||||
.flat_map(|layer| {
|
||||
(0..4).map(move |k| {
|
||||
TerrainVertex::new(positions[k], whole[k], layer as u16 | 0x8000, u32::MAX, 0)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// Corner and side of each layer's sprite, in atlas pixels.
|
||||
let sprites = [([9., 1.], 2.), ([1., 1.], 4.), ([9., 5.], 1.)];
|
||||
let origin = f32::from_bits((1. / UV_STEPS).to_bits());
|
||||
let pixels = sample(&device, &queue, &atlas, &vertices, origin, [true, false]);
|
||||
let mut failures = Vec::new();
|
||||
for (layer, ([x, y], side)) in sprites.into_iter().enumerate() {
|
||||
let mid = [(x + side / 2.) / 16., (y + side / 2.) / 8.];
|
||||
for (corner, [u, v]) in whole.iter().enumerate() {
|
||||
let expected = [mid[0], mid[1], (x + u * side) / 16., (y + v * side) / 8.];
|
||||
let actual = pixels[4 * layer + corner];
|
||||
if actual
|
||||
.iter()
|
||||
.zip(expected)
|
||||
.any(|(a, b)| (a - b).abs() > 1e-6)
|
||||
{
|
||||
failures.push(format!(
|
||||
"layer {layer} corner {corner}: {actual:?} != {expected:?}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
fn sample(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
@@ -183,7 +231,7 @@ fn sample(
|
||||
}],
|
||||
vec![storage(0), storage(1)],
|
||||
vec![],
|
||||
vec![storage(0), storage(1)],
|
||||
vec![storage(0), storage(1), storage(2)],
|
||||
];
|
||||
let layouts: Vec<_> = entries
|
||||
.iter()
|
||||
@@ -268,11 +316,15 @@ fn sample(
|
||||
bytemuck::cast_slice(vertices),
|
||||
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
|
||||
);
|
||||
let rects = buffer(
|
||||
bytemuck::cast_slice(&atlas.rects_table()),
|
||||
wgpu::BufferUsages::STORAGE,
|
||||
);
|
||||
let bindings = [
|
||||
vec![(0, &uniform)],
|
||||
vec![(0, &slots), (1, &origin)],
|
||||
vec![],
|
||||
vec![(0, &ids), (1, &data)],
|
||||
vec![(0, &ids), (1, &data), (2, &rects)],
|
||||
];
|
||||
let groups: Vec<_> = bindings
|
||||
.iter()
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
//! Where a shader pack's block atlas puts each sprite: vanilla's `Stitcher`, so every sprite keeps
|
||||
//! its own size and the atlas comes out as large as the game's (`atlasSize`). Holders are sorted
|
||||
//! by height, then width, then (where vanilla compares names) sprite layer.
|
||||
|
||||
/// The atlas a set of sprites stitches into.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(super) struct Stitched {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Each sprite's padded holder: its top left corner. The sprite itself starts `padding` in.
|
||||
pub holders: Vec<[u32; 2]>,
|
||||
pub padding: u32,
|
||||
}
|
||||
|
||||
/// `Stitcher.smallestFittingMinTexel`.
|
||||
fn fit(input: u32, mip_level: u32) -> u32 {
|
||||
let step = 1 << mip_level;
|
||||
input.div_ceil(step) * step
|
||||
}
|
||||
|
||||
/// `Mth.smallestEncompassingPowerOfTwo` (0 stays 0).
|
||||
fn power_of_two(input: u32) -> u32 {
|
||||
if input == 0 {
|
||||
0
|
||||
} else {
|
||||
input.next_power_of_two()
|
||||
}
|
||||
}
|
||||
|
||||
/// `Stitcher.Region`.
|
||||
struct Region {
|
||||
x: u32,
|
||||
y: u32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
holder: bool,
|
||||
sub: Vec<Region>,
|
||||
}
|
||||
|
||||
impl Region {
|
||||
fn new(x: u32, y: u32, width: u32, height: u32) -> Self {
|
||||
Self {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
holder: false,
|
||||
sub: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `Region.add`: the holder's top left corner, when it fits.
|
||||
fn add(&mut self, [w, h]: [u32; 2]) -> Option<[u32; 2]> {
|
||||
if self.holder || w > self.width || h > self.height {
|
||||
return None;
|
||||
}
|
||||
if w == self.width && h == self.height {
|
||||
self.holder = true;
|
||||
return Some([self.x, self.y]);
|
||||
}
|
||||
if self.sub.is_empty() {
|
||||
let (x, y) = (self.x, self.y);
|
||||
self.sub.push(Region::new(x, y, w, h));
|
||||
let (spare_w, spare_h) = (self.width - w, self.height - h);
|
||||
if spare_h > 0 && spare_w > 0 {
|
||||
let right = self.height.max(spare_w);
|
||||
let bottom = self.width.max(spare_h);
|
||||
if right >= bottom {
|
||||
self.sub.push(Region::new(x, y + h, w, spare_h));
|
||||
self.sub.push(Region::new(x + w, y, spare_w, self.height));
|
||||
} else {
|
||||
self.sub.push(Region::new(x + w, y, spare_w, h));
|
||||
self.sub.push(Region::new(x, y + h, self.width, spare_h));
|
||||
}
|
||||
} else if spare_w == 0 {
|
||||
self.sub.push(Region::new(x, y + h, w, spare_h));
|
||||
} else if spare_h == 0 {
|
||||
self.sub.push(Region::new(x + w, y, spare_w, h));
|
||||
}
|
||||
}
|
||||
self.sub.iter_mut().find_map(|r| r.add([w, h]))
|
||||
}
|
||||
}
|
||||
|
||||
/// `Stitcher` over square sprites of `sides` pixels, with vanilla's padding for `mip_level` and
|
||||
/// `anisotropy_bit`; `None` when they don't fit in `max` by `max`.
|
||||
pub(super) fn stitch(
|
||||
sides: &[u32],
|
||||
mip_level: u32,
|
||||
anisotropy_bit: u32,
|
||||
max: u32,
|
||||
) -> Option<Stitched> {
|
||||
let padding = (1 << mip_level) << anisotropy_bit.saturating_sub(1).min(4);
|
||||
let mut order: Vec<usize> = (0..sides.len()).collect();
|
||||
let holder = |i: usize| fit(sides[i] + padding * 2, mip_level);
|
||||
order.sort_by_key(|&i| (std::cmp::Reverse(holder(i)), i));
|
||||
let (mut storage_x, mut storage_y) = (0u32, 0u32);
|
||||
let mut storage: Vec<Region> = Vec::new();
|
||||
let mut holders = vec![[0, 0]; sides.len()];
|
||||
for i in order {
|
||||
let size = [holder(i); 2];
|
||||
if let Some(at) = storage.iter_mut().find_map(|r| r.add(size)) {
|
||||
holders[i] = at;
|
||||
continue;
|
||||
}
|
||||
// `Stitcher.expand`.
|
||||
let (x_now, y_now) = (power_of_two(storage_x), power_of_two(storage_y));
|
||||
let (x_new, y_new) = (
|
||||
power_of_two(storage_x + size[0]),
|
||||
power_of_two(storage_y + size[1]),
|
||||
);
|
||||
let (x_can, y_can) = (x_new <= max, y_new <= max);
|
||||
if !x_can && !y_can {
|
||||
return None;
|
||||
}
|
||||
let (x_will, y_will) = (x_can && x_now != x_new, y_can && y_now != y_new);
|
||||
let grow_x = if x_will ^ y_will {
|
||||
x_will
|
||||
} else {
|
||||
x_can && x_now <= y_now
|
||||
};
|
||||
let mut slot = if grow_x {
|
||||
if storage_y == 0 {
|
||||
storage_y = y_new;
|
||||
}
|
||||
let slot = Region::new(storage_x, 0, x_new - storage_x, storage_y);
|
||||
storage_x = x_new;
|
||||
slot
|
||||
} else {
|
||||
let slot = Region::new(0, storage_y, storage_x, y_new - storage_y);
|
||||
storage_y = y_new;
|
||||
slot
|
||||
};
|
||||
holders[i] = slot.add(size)?;
|
||||
storage.push(slot);
|
||||
}
|
||||
Some(Stitched {
|
||||
width: storage_x,
|
||||
height: storage_y,
|
||||
holders,
|
||||
padding,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Five 16 px sprites with one pixel of padding (no mipmaps), worked through vanilla's
|
||||
/// `Stitcher` by hand: 18 px holders in a 64 by 64 atlas.
|
||||
#[test]
|
||||
fn equal_sprites_follow_vanillas_regions() {
|
||||
let stitched = stitch(&[16; 5], 0, 0, 1 << 14).unwrap();
|
||||
assert_eq!(
|
||||
(stitched.width, stitched.height, stitched.padding),
|
||||
(64, 64, 1)
|
||||
);
|
||||
assert_eq!(
|
||||
stitched.holders,
|
||||
[[0, 0], [32, 0], [0, 32], [18, 32], [36, 32]]
|
||||
);
|
||||
}
|
||||
|
||||
/// The largest sprite goes first; padding and holders round up to the mip step.
|
||||
#[test]
|
||||
fn larger_sprites_go_first_and_holders_round_to_the_mip_step() {
|
||||
// Mip level 2: 4 px padding, holders 24 (16 + 8) and 40 (32 + 8) px.
|
||||
let stitched = stitch(&[16, 32], 2, 0, 1 << 14).unwrap();
|
||||
assert_eq!(stitched.padding, 4);
|
||||
assert_eq!(stitched.holders[1], [0, 0]);
|
||||
// 40 px grows the atlas to 64 by 64; the 24 px holder fits below the first.
|
||||
assert_eq!((stitched.width, stitched.height), (64, 64));
|
||||
assert_eq!(stitched.holders[0], [0, 40]);
|
||||
// Anisotropy 16x (bit 4) multiplies the padding by 8.
|
||||
assert_eq!(stitch(&[16], 4, 4, 1 << 14).unwrap().padding, 128);
|
||||
assert_eq!(stitch(&[16; 2], 0, 0, 16), None);
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,7 @@ void main() {
|
||||
let post = horizon_shaders::load_post(&pack, "", &properties, ¯os, &inputs);
|
||||
assert!(post.terrain.is_some(), "{:?}", post.notes);
|
||||
let world = World {
|
||||
sprite_rects: &[],
|
||||
atlas: Some((atlases[2].view.clone(), atlases[2].size())),
|
||||
material_maps: Some([atlases[1].view.clone(), atlases[0].view.clone()]),
|
||||
lightmap: atlases[2].view.clone(),
|
||||
@@ -148,7 +149,8 @@ void main() {
|
||||
post,
|
||||
wgpu::TextureFormat::Rgba8Unorm,
|
||||
world,
|
||||
);
|
||||
)
|
||||
.expect("the pack runs");
|
||||
let mut pass = post
|
||||
.terrain
|
||||
.take()
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use glam::{DMat4, DVec3};
|
||||
use horizon_shaders::{Format, Group, Kind, Post, SHADOW_COLORS, Val};
|
||||
use horizon_shaders::{Format, Group, Kind, Post, SHADOW_COLORS};
|
||||
|
||||
use super::pack_draw::DrawUniforms;
|
||||
use super::pack_entities::{PROGRAMS, PackEntities};
|
||||
@@ -29,12 +29,17 @@ use super::pack_uniforms::{self, FrameUniforms, PackInputs};
|
||||
|
||||
const BUFFERS: usize = horizon_shaders::BUFFERS;
|
||||
|
||||
mod packed;
|
||||
|
||||
#[cfg(test)]
|
||||
mod world_target_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod higher_shadowcolor_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod shadow_faces_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod forced_flip_tests;
|
||||
|
||||
@@ -436,6 +441,9 @@ struct Targets {
|
||||
pub(super) struct World<'a> {
|
||||
/// The block atlas and its size.
|
||||
pub atlas: Option<(wgpu::TextureView, [u32; 2])>,
|
||||
/// Of a stitched block atlas, each sprite layer's place (`PackAtlas::rects_table`); empty
|
||||
/// for a grid.
|
||||
pub sprite_rects: &'a [u32],
|
||||
pub material_maps: Option<[wgpu::TextureView; 2]>,
|
||||
pub lightmap: wgpu::TextureView,
|
||||
/// Terrain's geometry, when the pack draws it.
|
||||
@@ -562,9 +570,9 @@ 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) and its bind group for each arena page,
|
||||
/// with the page buffer it was made for.
|
||||
block_ids: Option<(wgpu::BindGroupLayout, wgpu::Buffer)>,
|
||||
/// 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.
|
||||
block_ids: Option<(wgpu::BindGroupLayout, wgpu::Buffer, wgpu::Buffer)>,
|
||||
page_groups: HashMap<u32, (wgpu::Buffer, wgpu::BindGroup)>,
|
||||
gui_size: [u32; 2],
|
||||
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
|
||||
@@ -1030,22 +1038,36 @@ pub(super) struct ShadowView {
|
||||
/// Draw everything, every face, within this many blocks (`shadow.culling=false|reversed`):
|
||||
/// packs that voxelize the world in their shadow pass need all of it.
|
||||
pub unculled: Option<f32>,
|
||||
/// Every face of the drawn sections, not only those that can face the light. Iris 1.11.7's
|
||||
/// shadow map holds the faces turned away from the light too (#9033): under a hill at low sun
|
||||
/// a cave's ceiling, which faces away, is often all that shadows the cave.
|
||||
pub every_face: 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) and, per arena page, the page's vertices
|
||||
/// (a vertex reads its quad's corners for its normal).
|
||||
fn block_ids(device: &wgpu::Device, table: &[i32]) -> (wgpu::BindGroupLayout, wgpu::Buffer) {
|
||||
/// 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(
|
||||
device: &wgpu::Device,
|
||||
table: &[i32],
|
||||
rects: &[u32],
|
||||
) -> (wgpu::BindGroupLayout, wgpu::Buffer, wgpu::Buffer) {
|
||||
let empty = [-1, -1, 0];
|
||||
let table = if table.is_empty() { &empty[..] } else { table };
|
||||
let buffer = wgpu::util::DeviceExt::create_buffer_init(
|
||||
device,
|
||||
&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Shader pack block ids"),
|
||||
contents: bytemuck::cast_slice(table),
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
},
|
||||
);
|
||||
let buffer = |label, contents: &[u8]| {
|
||||
wgpu::util::DeviceExt::create_buffer_init(
|
||||
device,
|
||||
&wgpu::util::BufferInitDescriptor {
|
||||
label: Some(label),
|
||||
contents,
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
},
|
||||
)
|
||||
};
|
||||
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 storage = |binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::VERTEX,
|
||||
@@ -1058,9 +1080,9 @@ fn block_ids(device: &wgpu::Device, table: &[i32]) -> (wgpu::BindGroupLayout, wg
|
||||
};
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Shader pack terrain"),
|
||||
entries: &[storage(0), storage(1)],
|
||||
entries: &[storage(0), storage(1), storage(2)],
|
||||
});
|
||||
(layout, buffer)
|
||||
(layout, ids, rects)
|
||||
}
|
||||
|
||||
/// The game's geometry a gbuffers program draws: its vertex buffers and, for flat draws
|
||||
@@ -1094,6 +1116,8 @@ struct Resources<'a> {
|
||||
storable: u16,
|
||||
/// The buffers of a three-channel format (`rgb`): they have no alpha to write, and read 1.
|
||||
rgb: u16,
|
||||
/// The bits per channel a buffer of a small packed format keeps ([`packed::bits`]).
|
||||
packed: [Option<[u8; 4]>; BUFFERS],
|
||||
/// The stage (`texture.<stage>`) of the programs built so far, and the buffers one of them
|
||||
/// flipped: a `texture.<stage>.colortexN` no longer stands in for buffer N in the stage's
|
||||
/// later programs, which read what was written, as in Iris. `final` continues
|
||||
@@ -1178,9 +1202,18 @@ fn bindings(
|
||||
let released = pack.released.get();
|
||||
let custom = pack_textures::find(textures, group, &s.ty, &s.name).filter(|&i| {
|
||||
let t = &textures[i];
|
||||
t.stage != pack_textures::stage(group)
|
||||
|| released.0 != t.stage
|
||||
|| horizon_shaders::buffer_index(&t.name).is_none_or(|b| released.1 & 1 << b == 0)
|
||||
// A PNG under `texture.<stage>.<name>` whose name is no sampler of the game's
|
||||
// isn't bound: the sampler reads as if it had no texture. Iris 1.11.7 with a probe
|
||||
// pack of ours (#9064) bound it over `gaux2` and `noisetex` but not over names of its
|
||||
// own, where a raw texture was bound; this rule is a fit to those cases.
|
||||
let unbound = t.png
|
||||
&& t.stage != horizon_shaders::EVERY_STAGE
|
||||
&& source(&s.name, false, true, false) == Source::Dummy;
|
||||
!unbound
|
||||
&& (t.stage != pack_textures::stage(group)
|
||||
|| released.0 != t.stage
|
||||
|| horizon_shaders::buffer_index(&t.name)
|
||||
.is_none_or(|b| released.1 & 1 << b == 0))
|
||||
});
|
||||
let image = pack
|
||||
.images
|
||||
@@ -1490,6 +1523,8 @@ fn build_pass(
|
||||
let final_pass = p.group == Group::Final;
|
||||
let mut targets = Vec::new();
|
||||
let mut writes = Vec::new();
|
||||
// Outputs into a buffer of a small packed format, and the bits it keeps.
|
||||
let mut packed_outputs = Vec::new();
|
||||
for &location in &interface.outputs {
|
||||
let l = location as usize;
|
||||
if targets.len() <= l {
|
||||
@@ -1521,6 +1556,11 @@ fn build_pass(
|
||||
{
|
||||
t.blend = Some(blend_state(b));
|
||||
}
|
||||
if !p.group.shadow()
|
||||
&& let Some(bits) = pack.packed[buffer as usize]
|
||||
{
|
||||
packed_outputs.push((location, bits));
|
||||
}
|
||||
targets[l] = state;
|
||||
writes.push((location, buffer as usize));
|
||||
}
|
||||
@@ -1580,7 +1620,9 @@ fn build_pass(
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(m)),
|
||||
})
|
||||
};
|
||||
let (vs, fs) = (module(program.vertex), module(program.fragment));
|
||||
let mut fragment = program.fragment;
|
||||
packed::quantise_outputs(&mut fragment, &packed_outputs);
|
||||
let (vs, fs) = (module(program.vertex), module(fragment));
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(&p.name),
|
||||
layout: Some(&pipeline_layout),
|
||||
@@ -1826,8 +1868,16 @@ fn build_compute(
|
||||
})
|
||||
}
|
||||
|
||||
/// Why a pack can't run, and the notes of loading it until then (why programs were skipped).
|
||||
#[derive(Debug)]
|
||||
pub struct Refused {
|
||||
pub reason: String,
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
impl PackPost {
|
||||
/// Builds the pipelines of `post` for a screen of `screen` format.
|
||||
/// Builds the pipelines of `post` for a screen of `screen` format. An error when the pack
|
||||
/// can't show anything on the screen: the game turns it off, as a pack that fails to load.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
@@ -1835,7 +1885,7 @@ impl PackPost {
|
||||
mut post: Post,
|
||||
screen: wgpu::TextureFormat,
|
||||
world: World<'_>,
|
||||
) -> Self {
|
||||
) -> Result<Self, Refused> {
|
||||
// What the pack's shaders need of its storage buffers, before its parts move out.
|
||||
let minimums = post.buffer_minimums();
|
||||
let features = device.features();
|
||||
@@ -1851,6 +1901,10 @@ impl PackPost {
|
||||
let rgb = (0..BUFFERS)
|
||||
.filter(|&i| post.config.buffers[i].format.channels == 3)
|
||||
.fold(0u16, |m, i| m | 1 << i);
|
||||
// A small packed format's buffer reads back fewer bits, as in Iris: its clears and
|
||||
// what its passes write keep only those.
|
||||
let packed: [Option<[u8; 4]>; BUFFERS] =
|
||||
std::array::from_fn(|i| packed::bits(post.config.buffers[i].format));
|
||||
let clears = std::array::from_fn(|i| {
|
||||
let b = &post.config.buffers[i];
|
||||
b.clear.then(|| {
|
||||
@@ -1858,7 +1912,7 @@ impl PackPost {
|
||||
if rgb & 1 << i != 0 {
|
||||
c[3] = 1.0;
|
||||
}
|
||||
c
|
||||
packed[i].map_or(c, |bits| packed::quantise(c, bits))
|
||||
})
|
||||
});
|
||||
let mut notes = post.notes;
|
||||
@@ -1875,7 +1929,7 @@ impl PackPost {
|
||||
let mut custom = post.custom;
|
||||
let custom_names: Vec<String> = custom.uniforms().map(|u| u.0.to_owned()).collect();
|
||||
// A first run finds the names neither the frame nor the pack defines.
|
||||
custom.eval(0.0, &|n| pack_uniforms::index(n).map(|_| Val::scalar(0.0)));
|
||||
custom.eval(0.0, &pack_uniforms::zero);
|
||||
// Its zeros are no start for `smooth`: the first frame's values are, as in Iris.
|
||||
custom.restart_smoothing();
|
||||
if !custom.notes.is_empty() {
|
||||
@@ -1963,6 +2017,7 @@ impl PackPost {
|
||||
gpu,
|
||||
storable,
|
||||
rgb,
|
||||
packed,
|
||||
released: std::cell::Cell::new(("", 0)),
|
||||
stand_ins: &stand_ins,
|
||||
shadow_linear: if features.contains(wgpu::Features::FLOAT32_FILTERABLE) {
|
||||
@@ -2058,7 +2113,7 @@ impl PackPost {
|
||||
.terrain
|
||||
.as_ref()
|
||||
.filter(|g| g.table.is_some())
|
||||
.map(|_| block_ids(device, world.block_ids));
|
||||
.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,
|
||||
@@ -2076,13 +2131,24 @@ impl PackPost {
|
||||
.water
|
||||
.zip(terrain_geometry.as_ref())
|
||||
.and_then(|(p, g)| build(p, Some(g)));
|
||||
// The shadow pass draws terrain faces from both sides, as Iris 1.11.7 does (#9033): a
|
||||
// cave ceiling faces away from the sun, and is often all that shadows the cave.
|
||||
let shadow_geometry = terrain_geometry.as_ref().map(|g| Geometry {
|
||||
buffers: g.buffers,
|
||||
table: g.table,
|
||||
depth_write: g.depth_write,
|
||||
atlas: g.atlas.clone(),
|
||||
material_maps: g.material_maps,
|
||||
ids: g.ids,
|
||||
cull: false,
|
||||
});
|
||||
let shadow_terrain = post
|
||||
.shadow
|
||||
.zip(terrain_geometry.as_ref())
|
||||
.zip(shadow_geometry.as_ref())
|
||||
.and_then(|(p, g)| build(p, Some(g)));
|
||||
let shadow_water = post
|
||||
.shadow_water
|
||||
.zip(terrain_geometry.as_ref())
|
||||
.zip(shadow_geometry.as_ref())
|
||||
.and_then(|(p, g)| build(p, Some(g)));
|
||||
let (hides_sky, hides_celestial) = (post.sky.is_some(), post.sky_textured.is_some());
|
||||
let sky = (post.sky.is_some() || post.sky_textured.is_some()).then(|| {
|
||||
@@ -2242,6 +2308,22 @@ impl PackPost {
|
||||
.filter(|p| !skip.split(',').any(|s| s == p.name))
|
||||
.filter_map(|p| build(p, None))
|
||||
.collect();
|
||||
// The screen gets colortex0 through `final`, or else through Horizon's copy, which reads
|
||||
// floats: an integer colortex0 (`RGBA32UI`...) with no `final` left to draw can't reach
|
||||
// it (#9165).
|
||||
let shows = passes
|
||||
.iter()
|
||||
.any(|p| p.group == Group::Final && matches!(p.pipeline, Pipeline::Render(_)));
|
||||
if !shows && !holds_floats(formats[0]) {
|
||||
return Err(Refused {
|
||||
reason: format!(
|
||||
"colortex0 has an integer format ({:?}) and no `final` program draws it to \
|
||||
the screen",
|
||||
formats[0]
|
||||
),
|
||||
notes,
|
||||
});
|
||||
}
|
||||
let shadow_comp: Vec<Pass> = post
|
||||
.shadow_comp
|
||||
.into_iter()
|
||||
@@ -2538,7 +2620,7 @@ impl PackPost {
|
||||
let white = [255u8; 4];
|
||||
let black = [0u8; 4];
|
||||
let flat_normal = [128u8, 128, 255, 255];
|
||||
Self {
|
||||
Ok(Self {
|
||||
notes,
|
||||
setup_pending: true,
|
||||
passes,
|
||||
@@ -2701,7 +2783,7 @@ impl PackPost {
|
||||
u
|
||||
},
|
||||
shadow_cache: Default::default(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Writes `pass`'s uniforms for a target of `out` pixels; its bind group for the buffers'
|
||||
@@ -3723,6 +3805,7 @@ impl PackPost {
|
||||
horizon_shaders::ShadowCulling::Off => Some(f32::INFINITY),
|
||||
horizon_shaders::ShadowCulling::Reversed => Some(shadow.voxel_distance),
|
||||
},
|
||||
every_face: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4195,7 +4278,7 @@ impl PackPost {
|
||||
terrain: &mut TerrainDraw<'_>,
|
||||
) {
|
||||
self.resize(device, size);
|
||||
if let Some((layout, ids)) = &self.block_ids {
|
||||
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));
|
||||
@@ -4213,6 +4296,10 @@ impl PackPost {
|
||||
binding: 1,
|
||||
resource: buffer.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: rects.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
(buffer.clone(), group)
|
||||
@@ -4571,14 +4658,8 @@ impl PackPost {
|
||||
.as_ref()
|
||||
.map(|t| &t[main(m, i) ^ usize::from(alt)])
|
||||
};
|
||||
// An integer colortex0 has no colour to show: the screen is only cleared.
|
||||
if !shown && !holds_floats(self.formats[0]) {
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Shader pack colortex0"),
|
||||
color_attachments: &[attach(screen, overwrite)],
|
||||
..Default::default()
|
||||
});
|
||||
} else if !shown {
|
||||
// Without `final`, colortex0 holds floats (`new` refuses an integer one).
|
||||
if !shown {
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Shader pack colortex0"),
|
||||
layout: &self.copy.1,
|
||||
@@ -4726,6 +4807,7 @@ mod tests {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
blur: false,
|
||||
clamp: false,
|
||||
png: false,
|
||||
image: pack_textures::Image::Atlas,
|
||||
}];
|
||||
let interface = horizon_shaders::Interface {
|
||||
@@ -4758,6 +4840,7 @@ mod tests {
|
||||
gpu: Gpu::of(&adapter, device.features()),
|
||||
storable: 0,
|
||||
rgb: 0,
|
||||
packed: [None; BUFFERS],
|
||||
released: std::cell::Cell::new(("", 0)),
|
||||
stand_ins: &stand_ins,
|
||||
shadow_linear: 0,
|
||||
@@ -4786,6 +4869,102 @@ mod tests {
|
||||
assert!(reads(Group::Final) == Source::Color(0));
|
||||
}
|
||||
|
||||
/// Iris 1.11.7 (a probe pack of ours, #9064) didn't bind a PNG under
|
||||
/// `texture.<stage>.<name>` when `name` was a name of the pack's own, no sampler of the
|
||||
/// game's; a raw texture there, a PNG over `gaux2` or `noisetex`, and a `customTexture` PNG
|
||||
/// were bound.
|
||||
#[test]
|
||||
fn a_png_under_a_name_of_its_own_is_not_bound() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&Default::default()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let texture = |stage: &str, name: &str, png: bool| pack_textures::PackTexture {
|
||||
stage: stage.into(),
|
||||
name: name.into(),
|
||||
dimension: wgpu::TextureViewDimension::D2,
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
blur: !png,
|
||||
clamp: !png,
|
||||
png,
|
||||
image: pack_textures::Image::Atlas,
|
||||
};
|
||||
let textures = [
|
||||
texture("composite", "tp", true),
|
||||
texture("composite", "tr", false),
|
||||
texture("composite", "gaux2", true),
|
||||
texture("composite", "noisetex", true),
|
||||
texture(horizon_shaders::EVERY_STAGE, "cp", true),
|
||||
];
|
||||
let names = ["tp", "tr", "gaux2", "noisetex", "cp"];
|
||||
let interface = horizon_shaders::Interface {
|
||||
uniform_size: 48,
|
||||
uniforms: Vec::new(),
|
||||
samplers: names
|
||||
.iter()
|
||||
.zip(1..)
|
||||
.map(|(name, k)| horizon_shaders::Sampler {
|
||||
name: (*name).into(),
|
||||
ty: "sampler2D".into(),
|
||||
binding: 2 * k - 1,
|
||||
})
|
||||
.collect(),
|
||||
outputs: vec![0],
|
||||
draw: false,
|
||||
images: Vec::new(),
|
||||
buffers: Vec::new(),
|
||||
initial_values: vec![0; 48],
|
||||
};
|
||||
let buffers = pack_images::Buffers::new(
|
||||
&device,
|
||||
Default::default(),
|
||||
Default::default(),
|
||||
wgpu::ShaderStages::FRAGMENT,
|
||||
);
|
||||
let stand_ins = std::cell::RefCell::new(Vec::new());
|
||||
let pack = Resources {
|
||||
textures: &textures,
|
||||
images: &[],
|
||||
left_out: &[],
|
||||
buffers: &buffers,
|
||||
indices: None,
|
||||
gpu: Gpu::of(&adapter, device.features()),
|
||||
storable: 0,
|
||||
rgb: 0,
|
||||
released: std::cell::Cell::new(("", 0)),
|
||||
stand_ins: &stand_ins,
|
||||
shadow_linear: 0,
|
||||
packed: [None; BUFFERS],
|
||||
};
|
||||
let (_, samplers, ..) = bindings(
|
||||
device.features(),
|
||||
&interface,
|
||||
"final",
|
||||
Group::Final,
|
||||
&[0],
|
||||
None,
|
||||
&[T::Rgba8Unorm; BUFFERS],
|
||||
None,
|
||||
&mut Vec::new(),
|
||||
&pack,
|
||||
)
|
||||
.expect("the pass is kept");
|
||||
let read: Vec<Source> = samplers.iter().map(|b| b.source).collect();
|
||||
assert!(
|
||||
read == [
|
||||
Source::Dummy,
|
||||
Source::Atlas,
|
||||
Source::Atlas,
|
||||
Source::Atlas,
|
||||
Source::Atlas
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// Iris 1.11.7 runs a program whose image no `image.*` gives, and its loads read zero
|
||||
/// (observed with a probe pack). Its pass is kept, the image bound to a stand-in of its
|
||||
/// declared format, zero again before each pass even after one stored into it.
|
||||
@@ -4838,6 +5017,7 @@ mod tests {
|
||||
gpu: Gpu::of(&adapter, device.features()),
|
||||
storable: 0,
|
||||
rgb: 0,
|
||||
packed: [None; BUFFERS],
|
||||
released: std::cell::Cell::new(("", 0)),
|
||||
stand_ins: &stand_ins,
|
||||
shadow_linear: 0,
|
||||
@@ -4979,6 +5159,7 @@ mod tests {
|
||||
gpu: Gpu::of(&adapter, device.features()),
|
||||
storable: 0,
|
||||
rgb: 0,
|
||||
packed: [None; BUFFERS],
|
||||
released: std::cell::Cell::new(("", 0)),
|
||||
stand_ins: &stand_ins,
|
||||
shadow_linear: 0,
|
||||
@@ -5151,6 +5332,9 @@ mod material_tests;
|
||||
#[cfg(test)]
|
||||
mod rgb_alpha_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod packed_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod trinary_tests;
|
||||
|
||||
|
||||
@@ -83,7 +83,8 @@ impl Gpu {
|
||||
post,
|
||||
T::Rgba8Unorm,
|
||||
world,
|
||||
);
|
||||
)
|
||||
.expect("the pack runs");
|
||||
assert!(
|
||||
built.passes.iter().any(|p| p.group == Group::Final),
|
||||
"{:?}",
|
||||
@@ -334,6 +335,7 @@ fn check(sampler: &str, stage: Stage, scene: Scene, expected: [u8; 4]) {
|
||||
cull: false,
|
||||
}),
|
||||
block_ids: &[],
|
||||
sprite_rects: &[],
|
||||
celestial: None,
|
||||
gui_size: SIZE,
|
||||
clouds: None,
|
||||
@@ -707,6 +709,7 @@ fn shadow_depth_filtering(nearest: &str) -> Option<Vec<[u8; 4]>> {
|
||||
let mut post = gpu.load(
|
||||
files,
|
||||
World {
|
||||
sprite_rects: &[],
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap: color.clone(),
|
||||
|
||||
@@ -199,8 +199,33 @@ impl Fixture {
|
||||
}
|
||||
|
||||
pub(super) fn build(&self, post: Post) -> PackPost {
|
||||
let post = self.try_build(post).expect("the pack runs");
|
||||
assert!(
|
||||
post.notes.iter().all(|n| n.starts_with("shadow map ")),
|
||||
"{:?}",
|
||||
post.notes
|
||||
);
|
||||
post
|
||||
}
|
||||
|
||||
/// [`Self::build`] for a pack that may be refused.
|
||||
pub(super) fn try_build(&self, post: Post) -> Result<PackPost, Refused> {
|
||||
self.try_build_with_terrain(post, None)
|
||||
}
|
||||
|
||||
/// `build`, with the game's terrain geometry handed to the pack.
|
||||
pub(super) fn build_with_terrain(&self, post: Post, terrain: Option<Geometry<'_>>) -> PackPost {
|
||||
self.try_build_with_terrain(post, terrain)
|
||||
.expect("the pack runs")
|
||||
}
|
||||
|
||||
fn try_build_with_terrain(
|
||||
&self,
|
||||
post: Post,
|
||||
terrain: Option<Geometry<'_>>,
|
||||
) -> Result<PackPost, Refused> {
|
||||
let lightmap = self.texture([1, 1], T::Rgba8Unorm);
|
||||
let post = PackPost::new(
|
||||
PackPost::new(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
&self.adapter,
|
||||
@@ -210,24 +235,29 @@ impl Fixture {
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap,
|
||||
terrain: None,
|
||||
terrain,
|
||||
block_ids: &[],
|
||||
sprite_rects: &[],
|
||||
celestial: None,
|
||||
gui_size: [16, 16],
|
||||
clouds: None,
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
post.notes.iter().all(|n| n.starts_with("shadow map ")),
|
||||
"{:?}",
|
||||
post.notes
|
||||
);
|
||||
post
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn render(&self, post: &mut PackPost, size: [u32; 2]) -> Vec<[u8; 4]> {
|
||||
self.render_with_terrain(post, size, &mut |_, _, _, _| {})
|
||||
}
|
||||
|
||||
/// `render`, with `terrain` drawing the game's terrain lists.
|
||||
pub(super) fn render_with_terrain(
|
||||
&self,
|
||||
post: &mut PackPost,
|
||||
size: [u32; 2],
|
||||
terrain: &mut TerrainDraw<'_>,
|
||||
) -> Vec<[u8; 4]> {
|
||||
let world = self.texture(size, T::Rgba8Unorm);
|
||||
let depth = self.texture(size, T::Depth32Float);
|
||||
let screen = self.texture(size, T::Rgba8Unorm);
|
||||
@@ -252,7 +282,7 @@ impl Fixture {
|
||||
size,
|
||||
size,
|
||||
&[],
|
||||
&mut |_, _, _, _| {},
|
||||
terrain,
|
||||
);
|
||||
encoder.copy_texture_to_buffer(
|
||||
screen.texture().as_image_copy(),
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
//! A pack may give colortex0 an integer format (OptiFine's `shaders.txt`, "Texture Formats",
|
||||
//! lists `RGBA32UI`, as #8459 quotes it): the pack still loads, and reads back what it wrote
|
||||
//! there. #8459's probe; #8459 reports Iris 1.11.7 drawing (0, 200, 0) at every point
|
||||
//! sampled from it.
|
||||
//! sampled from it. Without a `final` that draws it, the pack is refused (#9165).
|
||||
use super::destination_tests::Fixture;
|
||||
use super::*;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
|
||||
|
||||
/// A `final` that shows the integer colortex0.
|
||||
const FINAL: &str = "#version 330 compatibility\nuniform usampler2D colortex0;\n\
|
||||
void main() { gl_FragColor = vec4(texelFetch(colortex0, ivec2(gl_FragCoord.xy), 0)) / 255.0; }\n";
|
||||
|
||||
/// A `final` that doesn't compile (it calls a function nobody declares), so it is skipped.
|
||||
const BROKEN_FINAL: &str =
|
||||
"#version 330 compatibility\nvoid main() { gl_FragColor = undeclared_function(); }\n";
|
||||
|
||||
/// `depth`: the composite reads depthtex0, so the blit copies the world's depth; `last`: the
|
||||
/// pack has a `final` that shows colortex0 (without, Horizon shows colortex0 itself).
|
||||
fn pack(depth: bool, last: bool) -> Post {
|
||||
/// pack's `final`, if it has one.
|
||||
fn pack(depth: bool, last: Option<&str>) -> Post {
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut add = |name: &str, source: &str| {
|
||||
zip.start_file(
|
||||
@@ -34,13 +42,9 @@ fn pack(depth: bool, last: bool) -> Post {
|
||||
uniform sampler2D depthtex0;\nlayout(location = 0) out uvec4 o;\nvoid main() {{ o = {value}; }}\n"
|
||||
),
|
||||
);
|
||||
if last {
|
||||
if let Some(source) = last {
|
||||
add("final.vsh", VERTEX);
|
||||
add(
|
||||
"final.fsh",
|
||||
"#version 330 compatibility\nuniform usampler2D colortex0;\n\
|
||||
void main() { gl_FragColor = vec4(texelFetch(colortex0, ivec2(gl_FragCoord.xy), 0)) / 255.0; }\n",
|
||||
);
|
||||
add("final.fsh", source);
|
||||
}
|
||||
let pack = horizon_shaders::Pack::from_zip_bytes(
|
||||
"original-integer-colortex0",
|
||||
@@ -49,21 +53,14 @@ fn pack(depth: bool, last: bool) -> Post {
|
||||
.unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let post = horizon_shaders::load_post(&pack, "", &properties, ¯os, &Default::default());
|
||||
assert_eq!(
|
||||
post.programs.len(),
|
||||
1 + usize::from(last),
|
||||
"{:?}",
|
||||
post.notes
|
||||
);
|
||||
post
|
||||
horizon_shaders::load_post(&pack, "", &properties, ¯os, &Default::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_integer_colortex0_keeps_what_the_pack_wrote() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
for depth in [false, true] {
|
||||
let mut post = gpu.build(pack(depth, true));
|
||||
let mut post = gpu.build(pack(depth, Some(FINAL)));
|
||||
// Twice: the second frame's world blit runs over a colortex0 the first frame wrote.
|
||||
for frame in 0..2 {
|
||||
let pixels = gpu.render(&mut post, [8, 8]);
|
||||
@@ -73,13 +70,20 @@ fn an_integer_colortex0_keeps_what_the_pack_wrote() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Without a `final`, there are no colours to show from an integer colortex0; what Iris shows
|
||||
/// then was not measured. The frame must still run.
|
||||
/// Without a `final` left to draw it (none, or one that is skipped), an integer colortex0 has
|
||||
/// no colours the screen can show: the pack is refused, so the game turns it off and draws
|
||||
/// without it instead of a black screen (#9165). The refusal keeps why `final` was skipped.
|
||||
#[test]
|
||||
fn an_integer_colortex0_without_final_still_runs() {
|
||||
fn an_integer_colortex0_without_a_working_final_is_refused() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
let mut post = gpu.build(pack(false, false));
|
||||
for _ in 0..2 {
|
||||
gpu.render(&mut post, [8, 8]);
|
||||
for last in [None, Some(BROKEN_FINAL)] {
|
||||
let Err(refused) = gpu.try_build(pack(false, last)) else {
|
||||
panic!("{last:?}: a pack that can't show colortex0 runs");
|
||||
};
|
||||
assert_eq!(
|
||||
refused.notes.iter().any(|n| n.starts_with("final")),
|
||||
last.is_some(),
|
||||
"{refused:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! The small packed colortex formats `RGB5_A1`, `RGB565` and `R3_G3_B2`, read back as in Iris
|
||||
//! 1.11.7. wgpu has no such formats, so these buffers are 8-bit (`texture_format`); what a pass
|
||||
//! writes into one and its clear colour go through [`quantise`] first.
|
||||
//!
|
||||
//! Probe pack p8106c of our own wrote 600 values to each format and read them back with
|
||||
//! `texelFetch` in Iris 1.11.7 (NVIDIA 615.78.08). Our probe's Iris values fit this, with no
|
||||
//! exception: each channel becomes the byte an `RGBA8` buffer stores for it, keeps that byte's
|
||||
//! top bits (5, 5, 5 and 1 for `RGB5_A1`; 5, 6 and 5 for `RGB565` and also for `R3_G3_B2`), and
|
||||
//! reads back those bits repeated to fill 8 (`0.3337` → byte 85 → 10 of 31 → 82 / 255). This is
|
||||
//! a fit to one driver's values, not a rule Iris states. `RGBA4` and `RGBA2` read back the `RGBA8`
|
||||
//! bytes in the same probe, as Horizon's 8-bit buffers already do.
|
||||
|
||||
use horizon_shaders::{Format, Kind};
|
||||
use naga::{BinaryOperator, Expression, Literal, MathFunction, Statement, VectorSize};
|
||||
|
||||
/// The bits each channel keeps in a buffer of format `f`, for the formats that keep fewer than
|
||||
/// 8; a three-channel format's alpha keeps 8 (it reads 1 anyway).
|
||||
pub(super) fn bits(f: Format) -> Option<[u8; 4]> {
|
||||
match (f.kind, f.channels, f.bits) {
|
||||
(Kind::Unorm, 4, 5) => Some([5, 5, 5, 1]),
|
||||
// `RGB565`, and `R3_G3_B2` (3 × 3 bits in `config.rs`), which our probe's Iris values
|
||||
// put in 5, 6 and 5 bits too.
|
||||
(Kind::Unorm, 3, 6 | 3) => Some([5, 6, 5, 8]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The value a buffer keeping `bits` per channel reads back after `v` is written or cleared.
|
||||
pub(super) fn quantise(v: [f32; 4], bits: [u8; 4]) -> [f32; 4] {
|
||||
std::array::from_fn(|i| {
|
||||
let byte = (v[i].clamp(0.0, 1.0) * 255.0 + 0.5).floor() as u32;
|
||||
let n = u32::from(bits[i]);
|
||||
let kept = byte >> (8 - n);
|
||||
// The kept bits, repeated until there are 8.
|
||||
let mut wide = 0;
|
||||
let mut have = 0;
|
||||
while have < 8 {
|
||||
wide = wide << n | kept;
|
||||
have += n;
|
||||
}
|
||||
(wide >> (have - 8)) as f32 / 255.0
|
||||
})
|
||||
}
|
||||
|
||||
/// The factors of the shader's [`quantise`] for a channel keeping `n` bits: the kept value is
|
||||
/// `floor(byte / step)`, and it reads back `kept × step + floor(kept × repeat)`.
|
||||
fn factors(n: u8) -> (f32, f32) {
|
||||
let step = f32::from(1u16 << (8 - n));
|
||||
// `kept × repeat` is the kept bits shifted down to fill what `kept × step` leaves: one bit
|
||||
// fills the other 7 (× 127); 5 or 6 bits fill 3 or 2 with their top bits.
|
||||
let repeat = match n {
|
||||
1 => 127.0,
|
||||
_ => 2f32.powi(8 - 2 * i32::from(n)),
|
||||
};
|
||||
(step, repeat)
|
||||
}
|
||||
|
||||
/// Makes the fragment entry point of `module` write each output location of `outputs` as
|
||||
/// [`quantise`] gives it for its bits. The translator's entry point ends in one `return` of its
|
||||
/// outputs; a module without one is left as it is.
|
||||
pub(super) fn quantise_outputs(module: &mut naga::Module, outputs: &[(u32, [u8; 4])]) {
|
||||
let Some(ep) =
|
||||
(module.entry_points.iter_mut()).find(|e| e.stage == naga::ShaderStage::Fragment)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(result) = &ep.function.result else {
|
||||
return;
|
||||
};
|
||||
let naga::TypeInner::Struct { members, .. } = &module.types[result.ty].inner else {
|
||||
return;
|
||||
};
|
||||
let vec4 = |ty: naga::Handle<naga::Type>| {
|
||||
matches!(
|
||||
module.types[ty].inner,
|
||||
naga::TypeInner::Vector {
|
||||
size: VectorSize::Quad,
|
||||
scalar: naga::Scalar::F32
|
||||
}
|
||||
)
|
||||
};
|
||||
// (member, bits, the member's type) of each output to change.
|
||||
let changed: Vec<(usize, [u8; 4], naga::Handle<naga::Type>)> = (members.iter().enumerate())
|
||||
.filter_map(|(i, m)| {
|
||||
let Some(naga::Binding::Location { location, .. }) = m.binding else {
|
||||
return None;
|
||||
};
|
||||
let bits = outputs.iter().find(|o| o.0 == location)?.1;
|
||||
vec4(m.ty).then_some((i, bits, m.ty))
|
||||
})
|
||||
.collect();
|
||||
if changed.is_empty() {
|
||||
return;
|
||||
}
|
||||
let struct_ty = result.ty;
|
||||
let f = &mut ep.function;
|
||||
let body = std::mem::take(&mut f.body);
|
||||
let mut out = naga::Block::with_capacity(body.len() + 1);
|
||||
for (statement, span) in body.span_into_iter() {
|
||||
let Statement::Return { value: Some(value) } = statement else {
|
||||
out.push(statement, span);
|
||||
continue;
|
||||
};
|
||||
let Expression::Compose { components, .. } = &f.expressions[value] else {
|
||||
out.push(statement, span);
|
||||
continue;
|
||||
};
|
||||
let mut components = components.clone();
|
||||
let e = &mut f.expressions;
|
||||
let lit = |e: &mut naga::Arena<Expression>, v: f32| {
|
||||
e.append(Expression::Literal(Literal::F32(v)), span)
|
||||
};
|
||||
// Literals need no emitting: they come before the range that is emitted.
|
||||
let scalars: Vec<_> = (changed.iter())
|
||||
.map(|&(_, bits, _)| {
|
||||
let per = bits.map(factors);
|
||||
(
|
||||
per.map(|(step, _)| lit(e, step)),
|
||||
per.map(|(step, _)| lit(e, 1.0 / step)),
|
||||
per.map(|(_, repeat)| lit(e, repeat)),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let (zero, one, max, half) = (lit(e, 0.0), lit(e, 1.0), lit(e, 255.0), lit(e, 0.5));
|
||||
let mut add = |x: Expression| e.append(x, span);
|
||||
let splat = |add: &mut dyn FnMut(Expression) -> _, value| {
|
||||
add(Expression::Splat {
|
||||
size: VectorSize::Quad,
|
||||
value,
|
||||
})
|
||||
};
|
||||
let (zero, one, max, half) = (
|
||||
splat(&mut add, zero),
|
||||
splat(&mut add, one),
|
||||
splat(&mut add, max),
|
||||
splat(&mut add, half),
|
||||
);
|
||||
let binary = |add: &mut dyn FnMut(Expression) -> _, op, left, right| {
|
||||
add(Expression::Binary { op, left, right })
|
||||
};
|
||||
let math = |add: &mut dyn FnMut(Expression) -> _, fun, arg, arg1, arg2| {
|
||||
add(Expression::Math {
|
||||
fun,
|
||||
arg,
|
||||
arg1,
|
||||
arg2,
|
||||
arg3: None,
|
||||
})
|
||||
};
|
||||
for (&(member, _, ty), (steps, inverse, repeats)) in changed.iter().zip(scalars) {
|
||||
let mut vector = |components: [_; 4]| {
|
||||
add(Expression::Compose {
|
||||
ty,
|
||||
components: components.to_vec(),
|
||||
})
|
||||
};
|
||||
let (step, inverse, repeat) = (vector(steps), vector(inverse), vector(repeats));
|
||||
let x = components[member];
|
||||
let clamped = math(&mut add, MathFunction::Clamp, x, Some(zero), Some(one));
|
||||
let scaled = binary(&mut add, BinaryOperator::Multiply, clamped, max);
|
||||
let rounded = binary(&mut add, BinaryOperator::Add, scaled, half);
|
||||
let byte = math(&mut add, MathFunction::Floor, rounded, None, None);
|
||||
let shifted = binary(&mut add, BinaryOperator::Multiply, byte, inverse);
|
||||
let kept = math(&mut add, MathFunction::Floor, shifted, None, None);
|
||||
let high = binary(&mut add, BinaryOperator::Multiply, kept, step);
|
||||
let low = binary(&mut add, BinaryOperator::Multiply, kept, repeat);
|
||||
let low = math(&mut add, MathFunction::Floor, low, None, None);
|
||||
let wide = binary(&mut add, BinaryOperator::Add, high, low);
|
||||
components[member] = binary(&mut add, BinaryOperator::Divide, wide, max);
|
||||
}
|
||||
let value = add(Expression::Compose {
|
||||
ty: struct_ty,
|
||||
components,
|
||||
});
|
||||
// Everything from the first splat on is new and not a literal.
|
||||
let range = naga::Range::new_from_bounds(zero, value);
|
||||
out.push(Statement::Emit(range), span);
|
||||
out.push(Statement::Return { value: Some(value) }, span);
|
||||
}
|
||||
f.body = out;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn clear_colours_keep_the_same_bits() {
|
||||
// The CPU side, for clear colours. Iris 1.11.7 read p8106d's clear colour back as below in
|
||||
// RGB5_A1 and RGB565, and p8106c's 0.3337 as 82/255 in the 5-bit channels and 85/255 in
|
||||
// the 6-bit one.
|
||||
let to_255 = |v: [f32; 4]| v.map(|c| (c * 255.0 * 1000.0).round() / 1000.0);
|
||||
let rgb5_a1 = bits(Format::parse("RGB5_A1").unwrap()).unwrap();
|
||||
let rgb565 = bits(Format::parse("RGB565").unwrap()).unwrap();
|
||||
let r3_g3_b2 = bits(Format::parse("R3_G3_B2").unwrap()).unwrap();
|
||||
let clear = [0.3337, 0.1, 0.6, 0.3];
|
||||
assert_eq!(to_255(quantise(clear, rgb5_a1)), [82.0, 24.0, 156.0, 0.0]);
|
||||
assert_eq!(to_255(quantise(clear, rgb565))[..3], [82.0, 24.0, 156.0]);
|
||||
assert_eq!(
|
||||
to_255(quantise([0.3337; 4], r3_g3_b2))[..3],
|
||||
[82.0, 85.0, 82.0]
|
||||
);
|
||||
assert!(bits(Format::parse("RGBA4").unwrap()).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! `RGB5_A1`, `RGB565` and `R3_G3_B2` buffers read back as in Iris 1.11.7. Expected values are
|
||||
//! those probe packs p8106c (written values) and p8106d (clear colours) read back in Iris (the PR
|
||||
//! has them); the programs here are the probes', cut down. Written values are ones whose byte
|
||||
//! (× 255) is not near a half: GPUs may round those either way.
|
||||
use super::destination_tests::Fixture;
|
||||
use super::*;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
|
||||
|
||||
fn pack() -> Post {
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut add = |name: &str, source: &str| {
|
||||
zip.start_file(
|
||||
format!("shaders/{name}"),
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
};
|
||||
add("composite.vsh", VERTEX);
|
||||
add(
|
||||
"composite.fsh",
|
||||
"#version 120\n/* RENDERTARGETS: 1,2,3 */\n\
|
||||
/*\nconst int colortex1Format = RGB5_A1;\nconst int colortex2Format = RGB565;\n\
|
||||
const int colortex3Format = R3_G3_B2;\nconst int colortex4Format = RGB5_A1;\n\
|
||||
const int colortex5Format = RGB565;\n*/\n\
|
||||
const vec4 colortex4ClearColor = vec4(0.3337, 0.1, 0.6, 0.3);\n\
|
||||
const vec4 colortex5ClearColor = vec4(0.3337, 0.1, 0.6, 0.3);\n\
|
||||
void main() {\n\
|
||||
gl_FragData[0] = vec4(175.0, 176.0, 766.5, 256.0) / 511.0;\n\
|
||||
gl_FragData[1] = vec4(400.0, 175.0, 44.0, 0.0) / 511.0;\n\
|
||||
gl_FragData[2] = vec4(-255.5, 176.0, 300.0, 0.0) / 511.0;\n}\n",
|
||||
);
|
||||
add("final.vsh", VERTEX);
|
||||
// Pixel column x shows component x % 4 of buffer 1 + x / 4, as the bytes of its bits.
|
||||
add(
|
||||
"final.fsh",
|
||||
"#version 330 compatibility\nuniform sampler2D colortex1;\nuniform sampler2D colortex2;\n\
|
||||
uniform sampler2D colortex3;\nuniform sampler2D colortex4;\nuniform sampler2D colortex5;\n\
|
||||
void main() {\n ivec2 p = ivec2(gl_FragCoord.xy);\n vec4 c = vec4(0.0);\n\
|
||||
switch (p.x / 4) {\n\
|
||||
case 0: c = texelFetch(colortex1, p, 0); break;\n\
|
||||
case 1: c = texelFetch(colortex2, p, 0); break;\n\
|
||||
case 2: c = texelFetch(colortex3, p, 0); break;\n\
|
||||
case 3: c = texelFetch(colortex4, p, 0); break;\n\
|
||||
case 4: c = texelFetch(colortex5, p, 0); break;\n\
|
||||
}\n uint u = floatBitsToUint(c[p.x % 4]);\n\
|
||||
gl_FragColor = vec4(uvec4(u >> 24u, u >> 16u, u >> 8u, u) & 255u) / 255.0;\n}\n",
|
||||
);
|
||||
let pack = horizon_shaders::Pack::from_zip_bytes(
|
||||
"original-packed",
|
||||
zip.finish().unwrap().into_inner(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let post = horizon_shaders::load_post(&pack, "", &properties, ¯os, &Default::default());
|
||||
assert_eq!(post.programs.len(), 2, "{:?}", post.notes);
|
||||
post
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_packed_buffers_keep_their_bits() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
let mut post = gpu.build(pack());
|
||||
// Twice: the second frame writes the buffers' other sides.
|
||||
for frame in 0..2 {
|
||||
let pixels = gpu.render(&mut post, [20, 1]);
|
||||
let read: Vec<f32> = pixels
|
||||
.iter()
|
||||
.map(|p| f32::from_bits(u32::from_be_bytes(*p)))
|
||||
.collect();
|
||||
// Iris 1.11.7, in 255ths: RGB5_A1 written (175, 176, 766.5, 256) / 511; RGB565 written
|
||||
// (400, 175, 44, 0) / 511; R3_G3_B2 written (-255.5, 176, 300, 0) / 511; RGB5_A1 and
|
||||
// RGB565 cleared to (0.3337, 0.1, 0.6, 0.3). Three-channel formats read alpha 1.
|
||||
let expected: [f32; 20] = [
|
||||
82.0, 90.0, 255.0, 255.0, //
|
||||
206.0, 85.0, 16.0, 255.0, //
|
||||
0.0, 89.0, 148.0, 255.0, //
|
||||
82.0, 24.0, 156.0, 0.0, //
|
||||
82.0, 24.0, 156.0, 255.0,
|
||||
];
|
||||
for (n, (got, want)) in read.iter().zip(expected).enumerate() {
|
||||
assert!(
|
||||
(got - want / 255.0).abs() <= 1e-6,
|
||||
"frame {frame}, buffer {} component {}: {}/255, expected {want}/255; all {:?}",
|
||||
1 + n / 4,
|
||||
n % 4,
|
||||
got * 255.0,
|
||||
read.iter().map(|v| v * 255.0).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
//! The shadow pass draws terrain faces from both sides, as Iris 1.11.7 does (#9033: a probe pack
|
||||
//! of ours writing `gl_FrontFacing` into `shadowcolor0` found back faces in Iris's shadow map and
|
||||
//! none in Horizon's). The programs here are original.
|
||||
use super::destination_tests::Fixture;
|
||||
use super::*;
|
||||
use std::io::{Cursor, Write};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
const FINAL_VSH: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
|
||||
|
||||
fn pack() -> Post {
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut add = |name: &str, source: &str| {
|
||||
zip.start_file(
|
||||
format!("shaders/{name}"),
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
};
|
||||
// The terrain's positions are already in clip space.
|
||||
add(
|
||||
"shadow.vsh",
|
||||
"#version 120\nvoid main() { gl_Position=vec4(gl_Vertex.xy,0.5,1.0); }\n",
|
||||
);
|
||||
// Red where a front face was drawn, blue where a back face was.
|
||||
add(
|
||||
"shadow.fsh",
|
||||
"#version 120\nvoid main() {\n\
|
||||
gl_FragData[0]=gl_FrontFacing ? vec4(1.0,0.0,0.0,1.0) : vec4(0.0,0.0,1.0,1.0);\n}\n",
|
||||
);
|
||||
add("final.vsh", FINAL_VSH);
|
||||
add(
|
||||
"final.fsh",
|
||||
"#version 120\nconst int shadowMapResolution=16;\nuniform sampler2D shadowcolor0;\n\
|
||||
varying vec2 uv;\nvoid main() { gl_FragColor=texture2D(shadowcolor0,uv); }\n",
|
||||
);
|
||||
let pack = horizon_shaders::Pack::from_zip_bytes(
|
||||
"original-shadow-faces",
|
||||
zip.finish().unwrap().into_inner(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let inputs = horizon_shaders::GameInputs {
|
||||
terrain: Some(horizon_shaders::GbuffersInputs {
|
||||
item_id_declared: false,
|
||||
declarations: "layout(location=0)in vec3 fixture_position;\n".into(),
|
||||
body: "hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);\
|
||||
hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);\
|
||||
hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n"
|
||||
.into(),
|
||||
attributes: vec![],
|
||||
after: String::new(),
|
||||
alpha_test: None,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
horizon_shaders::load_post(&pack, "", &properties, ¯os, &inputs)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_pass_draws_terrain_back_faces() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
const ATTRS: [wgpu::VertexAttribute; 1] = wgpu::vertex_attr_array![0=>Float32x3];
|
||||
let layouts = [Some(wgpu::VertexBufferLayout {
|
||||
array_stride: 12,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &ATTRS,
|
||||
})];
|
||||
// Culled as the game's own terrain is (`Renderer` hands packs `cull: true`).
|
||||
let mut post = gpu.build_with_terrain(
|
||||
pack(),
|
||||
Some(Geometry {
|
||||
material_maps: false,
|
||||
buffers: &layouts,
|
||||
table: None,
|
||||
depth_write: true,
|
||||
atlas: None,
|
||||
ids: None,
|
||||
cull: true,
|
||||
}),
|
||||
);
|
||||
assert!(post.has_shadow(), "{:?}", post.notes);
|
||||
// GL clip space: the left half counter-clockwise (a front face), the right half
|
||||
// clockwise (a back face).
|
||||
let positions: [[f32; 3]; 12] = [
|
||||
[-1., -1., 0.],
|
||||
[0., -1., 0.],
|
||||
[0., 1., 0.],
|
||||
[-1., -1., 0.],
|
||||
[0., 1., 0.],
|
||||
[-1., 1., 0.],
|
||||
[0., -1., 0.],
|
||||
[1., 1., 0.],
|
||||
[1., -1., 0.],
|
||||
[0., -1., 0.],
|
||||
[0., 1., 0.],
|
||||
[1., 1., 0.],
|
||||
];
|
||||
let vertices = gpu
|
||||
.device
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Original shadow faces geometry"),
|
||||
contents: bytemuck::cast_slice(&positions),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
let size = [8, 8];
|
||||
let pixels = gpu.render_with_terrain(&mut post, size, &mut |rp, list, _, _| {
|
||||
if list == TerrainList::ShadowSolid {
|
||||
rp.set_vertex_buffer(0, vertices.slice(..));
|
||||
rp.draw(0..12, 0..1);
|
||||
}
|
||||
});
|
||||
let near = |got: [u8; 4], want: [u8; 4]| got.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 1);
|
||||
for (i, got) in pixels.iter().enumerate() {
|
||||
let x = i as u32 % size[0];
|
||||
let want = if x < size[0] / 2 {
|
||||
[255, 0, 0, 255]
|
||||
} else {
|
||||
[0, 0, 255, 255]
|
||||
};
|
||||
assert!(
|
||||
near(*got, want),
|
||||
"pixel ({x}, {}): {got:?}, expected {want:?} (red: front face, blue: back face)",
|
||||
i as u32 / size[0]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -219,13 +219,15 @@ fn pack(
|
||||
lightmap: lightmap.clone(),
|
||||
terrain: None,
|
||||
block_ids: &[],
|
||||
sprite_rects: &[],
|
||||
celestial: None,
|
||||
gui_size: [1, 1],
|
||||
clouds: None,
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
);
|
||||
)
|
||||
.expect("the pack runs");
|
||||
assert!(post.notes.is_empty(), "{:?}", post.notes);
|
||||
post
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ pub(super) struct PackTexture {
|
||||
pub(super) sample_type: wgpu::TextureSampleType,
|
||||
pub(super) blur: bool,
|
||||
pub(super) clamp: bool,
|
||||
/// Decoded from a PNG, the pack's or the game's (not a raw dump, not a generated texture).
|
||||
pub(super) png: bool,
|
||||
pub(super) image: Image,
|
||||
}
|
||||
|
||||
@@ -30,7 +32,7 @@ pub(super) fn upload(
|
||||
let features = device.features();
|
||||
let mut out = Vec::new();
|
||||
for t in textures {
|
||||
let (image, dimension, sample_type, blur, clamp) = match t.source {
|
||||
let (image, dimension, sample_type, blur, clamp, png) = match t.source {
|
||||
TextureSource::Image(texture) => {
|
||||
let (view, sample_type) = match create(device, queue, &texture, features) {
|
||||
Ok(created) => created,
|
||||
@@ -50,6 +52,7 @@ pub(super) fn upload(
|
||||
sample_type,
|
||||
texture.blur,
|
||||
texture.clamp,
|
||||
!texture.raw,
|
||||
)
|
||||
}
|
||||
TextureSource::Resource(location) => {
|
||||
@@ -66,7 +69,14 @@ pub(super) fn upload(
|
||||
continue;
|
||||
};
|
||||
let float = wgpu::TextureSampleType::Float { filterable: true };
|
||||
(image, wgpu::TextureViewDimension::D2, float, true, true)
|
||||
(
|
||||
image,
|
||||
wgpu::TextureViewDimension::D2,
|
||||
float,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
)
|
||||
}
|
||||
};
|
||||
out.push(PackTexture {
|
||||
@@ -76,6 +86,7 @@ pub(super) fn upload(
|
||||
sample_type,
|
||||
blur,
|
||||
clamp,
|
||||
png,
|
||||
image,
|
||||
});
|
||||
}
|
||||
@@ -285,6 +296,7 @@ mod tests {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
blur: false,
|
||||
clamp: false,
|
||||
png: false,
|
||||
image: Image::Lightmap,
|
||||
}
|
||||
}
|
||||
@@ -297,6 +309,7 @@ mod tests {
|
||||
texels,
|
||||
blur: false,
|
||||
clamp: false,
|
||||
raw: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -718,6 +718,13 @@ fn components(name: &str) -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
/// What `name` gives custom uniforms before the first frame: zeros, at its size, so their
|
||||
/// shapes check out as on a frame (#9086).
|
||||
pub fn zero(name: &str) -> Option<horizon_shaders::Val> {
|
||||
index(name)?;
|
||||
Some(horizon_shaders::Val::vector(&[0.0; 16][..components(name)]))
|
||||
}
|
||||
|
||||
/// The position of `name` in [`NAMES`].
|
||||
pub fn index(name: &str) -> Option<usize> {
|
||||
NAMES.iter().position(|n| *n == name)
|
||||
@@ -752,6 +759,22 @@ mod tests {
|
||||
assert_eq!(out[60..64], [0; 4], "a value past the block is dropped");
|
||||
}
|
||||
|
||||
/// The load-time run of custom uniforms sees each built-in at its size: Iris 1.11.7 reads 2
|
||||
/// for the first line of our probes K and X (#9086) and 0 for the others.
|
||||
#[test]
|
||||
fn custom_uniforms_check_the_shapes_of_built_ins_before_the_first_frame() {
|
||||
let properties = horizon_shaders::Properties::parse(
|
||||
"uniform.vec3.a = skyColor * vec3(0.0, 0.0, 0.0) + vec3(2.0, 2.0, 2.0)\n\
|
||||
uniform.float.b = skyColor\n\
|
||||
uniform.float.c = gbufferModelView * 0.0 + 2.0\n",
|
||||
&horizon_shaders::Macros::standard(),
|
||||
);
|
||||
let mut custom = horizon_shaders::Custom::parse(&properties);
|
||||
custom.eval(0.0, &zero);
|
||||
let broken: Vec<&str> = custom.notes.iter().map(|n| &n[1..2]).collect();
|
||||
assert_eq!(broken, ["b", "c"], "{:?}", custom.notes);
|
||||
}
|
||||
|
||||
/// Iris's status uniforms as its documentation defines them (shaders.properties, "Status").
|
||||
#[test]
|
||||
fn status_uniforms_follow_iris() {
|
||||
@@ -1006,11 +1029,6 @@ mod initial_history_tests {
|
||||
#[test]
|
||||
#[ignore = "requires a host GPU; run with --ignored --nocapture"]
|
||||
fn core_matrix_inputs_render_provider_values() {
|
||||
use horizon_shaders::{
|
||||
GbuffersInputs, Macros, Pack, Target, preprocess, translate_gbuffers,
|
||||
};
|
||||
use std::io::Write;
|
||||
use wgpu::util::DeviceExt;
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
|
||||
eprintln!("Core matrix GPU: {:?}", adapter.get_info());
|
||||
@@ -1070,137 +1088,7 @@ mod initial_history_tests {
|
||||
vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);\n\
|
||||
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);\n}}"
|
||||
);
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (path, source) in [
|
||||
("shaders/gbuffers_line.vsh", vertex.as_str()),
|
||||
(
|
||||
"shaders/gbuffers_line.fsh",
|
||||
"#version 330\nin vec3 result; out vec4 color; void main() { color = vec4(result, 1.0); }",
|
||||
),
|
||||
] {
|
||||
zip.start_file(path, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
}
|
||||
let pack = Pack::from_zip_bytes("matrix-probe.zip", zip.finish().unwrap().into_inner())
|
||||
.unwrap();
|
||||
let macros = Macros::standard();
|
||||
let program = translate_gbuffers(
|
||||
&preprocess(&pack, "gbuffers_line.vsh", ¯os),
|
||||
&preprocess(&pack, "gbuffers_line.fsh", ¯os),
|
||||
Target::Buffers,
|
||||
&GbuffersInputs::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut bytes = vec![0; program.interface.uniform_size as usize];
|
||||
for uniform in &program.interface.uniforms {
|
||||
if let Some(i) = index(&uniform.name) {
|
||||
values.values[i].write(&uniform.ty, &mut bytes, uniform.offset as usize);
|
||||
}
|
||||
}
|
||||
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: &bytes,
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let module = |module| {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
|
||||
})
|
||||
};
|
||||
let vs = module(program.vertex);
|
||||
let fs = module(program.fragment);
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &vs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &fs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
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 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::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = texture.create_view(&Default::default());
|
||||
let read = 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,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
encoder.copy_texture_to_buffer(
|
||||
texture.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &read,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
texture.size(),
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |result| result.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let mapped = read.slice(..).get_mapped_range().unwrap();
|
||||
let mapped = draw_line_program(&device, &queue, &values, &vertex);
|
||||
// Independent result: translated point (1, .5, -2), perspective (aspect 2,
|
||||
// vertical FOV 90): clip (.5, .5, z, 2), hence (.25, .25, .5, 1).
|
||||
for (actual, expected) in mapped[..4].iter().zip(expected) {
|
||||
@@ -1213,6 +1101,203 @@ mod initial_history_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `normalMatrix` in a core-profile gbuffers program is the view's rotation; in the
|
||||
/// compatibility profile it stays zero (the issue's Iris 1.11.7 probes, #9136).
|
||||
#[test]
|
||||
#[ignore = "requires a host GPU; run with --ignored --nocapture"]
|
||||
fn core_normal_matrix_renders_the_view_rotation() {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
|
||||
eprintln!("Normal matrix GPU: {:?}", adapter.get_info());
|
||||
let (device, queue) =
|
||||
pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let hud = HudBatch::new([800, 400]);
|
||||
let mut f = frame(&hud);
|
||||
// Facing west (-X), level: view space looks down -Z with +X to the right (north).
|
||||
f.yaw = 90.0;
|
||||
f.pitch = 0.0;
|
||||
let mut values = FrameUniforms::default();
|
||||
values.update(&f, &PackInputs::default(), [800, 400]);
|
||||
for (profile, world, expected) in [
|
||||
// East (behind the camera) is view +Z.
|
||||
("330 core", "vec3(1.0, 0.0, 0.0)", [128u8, 128, 255, 255]),
|
||||
// South (to the left) is view -X; up stays up.
|
||||
("330 core", "vec3(0.0, 0.0, 1.0)", [0, 128, 128, 255]),
|
||||
("330 core", "vec3(0.0, 1.0, 0.0)", [128, 255, 128, 255]),
|
||||
(
|
||||
"330 compatibility",
|
||||
"vec3(1.0, 0.0, 0.0)",
|
||||
[128, 128, 128, 255],
|
||||
),
|
||||
] {
|
||||
let vertex = format!(
|
||||
"#version {profile}\nuniform mat3 normalMatrix;\nout vec3 result;\n\
|
||||
void main() {{\n\
|
||||
result = normalMatrix * {world} * 0.5 + 0.5;\n\
|
||||
vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);\n\
|
||||
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);\n}}"
|
||||
);
|
||||
let pixel = draw_line_program(&device, &queue, &values, &vertex);
|
||||
eprintln!("{profile} {world}: {pixel:?}");
|
||||
for (actual, expected) in pixel.iter().zip(expected) {
|
||||
assert!(
|
||||
actual.abs_diff(expected) <= 1,
|
||||
"{profile} {world}: {pixel:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws one fragment through a translated `gbuffers_line` program with `vertex` and the
|
||||
/// provider's `values`, and reads it back.
|
||||
fn draw_line_program(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
values: &FrameUniforms,
|
||||
vertex: &str,
|
||||
) -> [u8; 4] {
|
||||
use horizon_shaders::{
|
||||
GbuffersInputs, Macros, Pack, Target, preprocess, translate_gbuffers,
|
||||
};
|
||||
use std::io::Write;
|
||||
use wgpu::util::DeviceExt;
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (path, source) in [
|
||||
("shaders/gbuffers_line.vsh", vertex),
|
||||
(
|
||||
"shaders/gbuffers_line.fsh",
|
||||
"#version 330\nin vec3 result; out vec4 color; void main() { color = vec4(result, 1.0); }",
|
||||
),
|
||||
] {
|
||||
zip.start_file(path, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
}
|
||||
let pack =
|
||||
Pack::from_zip_bytes("matrix-probe.zip", zip.finish().unwrap().into_inner()).unwrap();
|
||||
let macros = Macros::standard();
|
||||
let program = translate_gbuffers(
|
||||
&preprocess(&pack, "gbuffers_line.vsh", ¯os),
|
||||
&preprocess(&pack, "gbuffers_line.fsh", ¯os),
|
||||
Target::Buffers,
|
||||
&GbuffersInputs::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut bytes = vec![0; program.interface.uniform_size as usize];
|
||||
for uniform in &program.interface.uniforms {
|
||||
if let Some(i) = index(&uniform.name) {
|
||||
values.values[i].write(&uniform.ty, &mut bytes, uniform.offset as usize);
|
||||
}
|
||||
}
|
||||
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: &bytes,
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let module = |module| {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
|
||||
})
|
||||
};
|
||||
let vs = module(program.vertex);
|
||||
let fs = module(program.fragment);
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &vs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &fs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
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 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::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = texture.create_view(&Default::default());
|
||||
let read = 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,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
encoder.copy_texture_to_buffer(
|
||||
texture.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &read,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
texture.size(),
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |result| result.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let mapped = read.slice(..).get_mapped_range().unwrap();
|
||||
let pixel = mapped[..4].try_into().unwrap();
|
||||
drop(mapped);
|
||||
read.unmap();
|
||||
pixel
|
||||
}
|
||||
|
||||
fn int(u: &FrameUniforms, at: usize) -> i32 {
|
||||
match u.values[at] {
|
||||
Value::I(v) => v[0],
|
||||
|
||||
@@ -676,6 +676,9 @@ pub struct Graphics {
|
||||
/// HDR brightness of the HUD and menus, in percent of paper white (25..=100); the
|
||||
/// first-person hand stays at the world's.
|
||||
pub hdr_ui_brightness: u32,
|
||||
/// HDR Lighting: Horizon's own HDR look, light sources, the sun and sunlight above paper
|
||||
/// white. Off, HDR output shows the same picture as SDR, for shader packs or the vanilla look.
|
||||
pub hdr_lighting: bool,
|
||||
/// A vanilla key name (`HDR_KEY`): opens HDR's settings from the game. Unbound by default.
|
||||
pub hdr_key: String,
|
||||
/// Battery mode: at most 60 frames per second in a world and 30 in menus
|
||||
@@ -747,6 +750,7 @@ impl Default for Graphics {
|
||||
hdr_peak: 0,
|
||||
hdr_black_level: 0,
|
||||
hdr_ui_brightness: 100,
|
||||
hdr_lighting: true,
|
||||
hdr_key: "key.keyboard.unknown".into(),
|
||||
battery: false,
|
||||
shadow_cache: false,
|
||||
@@ -864,6 +868,7 @@ impl Graphics {
|
||||
}
|
||||
"frameGeneration" => g.frame_generation = FrameGeneration::parse(value),
|
||||
"hdr" => g.hdr = flag(g.hdr),
|
||||
"hdrLighting" => g.hdr_lighting = flag(g.hdr_lighting),
|
||||
"hdrPaperWhite" => {
|
||||
g.hdr_paper_white = match number(g.hdr_paper_white) {
|
||||
0 => 0,
|
||||
@@ -901,7 +906,7 @@ impl Graphics {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
let mut text = format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nparticleCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nparticleCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrLighting:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
@@ -946,6 +951,7 @@ impl Graphics {
|
||||
self.hdr_peak,
|
||||
self.hdr_black_level,
|
||||
self.hdr_ui_brightness,
|
||||
bool_text(self.hdr_lighting),
|
||||
self.hdr_key,
|
||||
bool_text(self.battery),
|
||||
bool_text(self.shadow_cache),
|
||||
@@ -1124,6 +1130,7 @@ mod tests {
|
||||
hdr_peak: 1400,
|
||||
hdr_black_level: 12,
|
||||
hdr_ui_brightness: 60,
|
||||
hdr_lighting: false,
|
||||
hdr_key: "key.keyboard.h".into(),
|
||||
battery: true,
|
||||
shadow_cache: true,
|
||||
|
||||
@@ -100,6 +100,7 @@ pub enum H {
|
||||
HdrPeak,
|
||||
HdrPaperWhite,
|
||||
HdrUiBrightness,
|
||||
HdrLighting,
|
||||
BatteryMode,
|
||||
ShadowCache,
|
||||
GraphicsApi,
|
||||
@@ -532,6 +533,11 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
None,
|
||||
"How bright the HUD and menus are, against paper white.",
|
||||
),
|
||||
h(
|
||||
H::HdrLighting,
|
||||
None,
|
||||
"Light sources, the sun and sunlight shine past white. Off: just as SDR.",
|
||||
),
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -824,6 +830,7 @@ impl H {
|
||||
n => labelled("Black Level", format!("{:.2}", n as f32 / 100.0)),
|
||||
},
|
||||
H::HdrUiBrightness => labelled_percent("UI Brightness", g.hdr_ui_brightness),
|
||||
H::HdrLighting => labelled("HDR Lighting", on_off(g.hdr_lighting)),
|
||||
H::HdrKey => {
|
||||
Text::translate("Shortcut: %s", &[crate::keybinds::display_name(&g.hdr_key)])
|
||||
}
|
||||
@@ -948,6 +955,7 @@ impl H {
|
||||
g.frame_generation = step(&FrameGeneration::ALL, g.frame_generation, back)
|
||||
}
|
||||
H::Hdr => g.hdr = !g.hdr,
|
||||
H::HdrLighting => g.hdr_lighting = !g.hdr_lighting,
|
||||
H::BatteryMode => g.battery = !g.battery,
|
||||
H::ShadowCache => g.shadow_cache = !g.shadow_cache,
|
||||
H::GraphicsApi => g.api = step(GraphicsApi::choices(), g.api, back),
|
||||
@@ -998,6 +1006,7 @@ impl Item {
|
||||
| H::HdrPeak
|
||||
| H::HdrPaperWhite
|
||||
| H::HdrUiBrightness
|
||||
| H::HdrLighting
|
||||
| H::HdrSmoothGradients,
|
||||
) if !crate::graphics::hdr_offered() => {
|
||||
Some("Your display or system doesn't offer HDR output.")
|
||||
@@ -1013,6 +1022,7 @@ impl Item {
|
||||
| H::HdrPeak
|
||||
| H::HdrPaperWhite
|
||||
| H::HdrUiBrightness
|
||||
| H::HdrLighting
|
||||
| H::HdrSmoothGradients,
|
||||
) if !s.horizon.hdr => Some("Turn HDR on to use it."),
|
||||
Item::Horizon(H::TranslucentBatching)
|
||||
@@ -1048,6 +1058,7 @@ impl Item {
|
||||
Item::Horizon(H::AnimatedTextures) => g.animated_textures,
|
||||
Item::Horizon(H::DitherCutoutMips) => g.dither_cutout_mips,
|
||||
Item::Horizon(H::Hdr) => g.hdr,
|
||||
Item::Horizon(H::HdrLighting) => g.hdr_lighting,
|
||||
Item::Horizon(H::BatteryMode) => g.battery,
|
||||
Item::Horizon(H::ShadowCache) => g.shadow_cache,
|
||||
Item::Horizon(H::TranslucentBatching) => {
|
||||
|
||||
@@ -27,6 +27,8 @@ use horizon_world::{
|
||||
fluid::{FluidKind, FluidState},
|
||||
registry::BlockStateId,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use sides::sort_tinted;
|
||||
pub use sides::{Part, Sides, group_parts};
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
@@ -103,6 +103,22 @@ impl Sides {
|
||||
}
|
||||
}
|
||||
|
||||
/// A sort with these parts' `ends` and `planes`, for tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn from_parts(
|
||||
ends: [u32; 14],
|
||||
grouped: bool,
|
||||
split: bool,
|
||||
planes: [f32; 7],
|
||||
) -> Self {
|
||||
Self {
|
||||
ends,
|
||||
grouped,
|
||||
split,
|
||||
planes,
|
||||
}
|
||||
}
|
||||
|
||||
/// The number of solid quads the buckets hold.
|
||||
pub fn quads(&self) -> u32 {
|
||||
self.ends[13]
|
||||
@@ -128,6 +144,26 @@ impl Sides {
|
||||
self.runs(seen, part, draw);
|
||||
}
|
||||
|
||||
/// For GPU culling's copy of `visible_from` with every part (`gfx::gpu_culling`),
|
||||
/// which then only compares: where each bucket of a mesh sorted for one pass ends,
|
||||
/// in `BUCKETS` order, and what `visible_from` compares the eye with for each. That
|
||||
/// is the lowest plane less `MARGIN` for a bucket facing a positive direction, the
|
||||
/// highest plus `MARGIN` for one facing a negative direction, and 0 for the other
|
||||
/// bucket, which it never compares. `None` for a mesh sorted for two passes
|
||||
/// (`group_parts`), whose buckets lie in two stretches each.
|
||||
pub fn thresholds(&self) -> Option<([u32; 7], [f32; 7])> {
|
||||
if self.grouped {
|
||||
return None;
|
||||
}
|
||||
let t: [f32; 7] = std::array::from_fn(|i| match BUCKETS[i] {
|
||||
Bucket::Side(_, true) => self.planes[i] - MARGIN,
|
||||
Bucket::Side(_, false) => self.planes[i] + MARGIN,
|
||||
Bucket::Other => 0.0,
|
||||
});
|
||||
// A bucket's cutout quads end it (`slot`).
|
||||
Some((std::array::from_fn(|b| self.ends[b * 2 + 1]), t))
|
||||
}
|
||||
|
||||
/// The runs of `part`'s quads facing every way.
|
||||
pub fn every(&self, part: Part, draw: impl FnMut(u32, u32)) {
|
||||
self.runs(|_| true, part, draw);
|
||||
|
||||
@@ -659,6 +659,30 @@ pub fn around_self(
|
||||
}
|
||||
}
|
||||
|
||||
/// `LivingEntity.handleEntityEvent(46)`: 128 `ParticleTypes.PORTAL` samples
|
||||
/// from the entity's old position (`xo`, `yo`, `zo`) to its current one,
|
||||
/// spread across the bounding box (`getBbWidth() * 2` on x and z, `getBbHeight`
|
||||
/// on y). The speeds are `(nextFloat() - 0.5) * 0.2`.
|
||||
pub fn living_teleport_particles(
|
||||
random: &mut impl RandomSource,
|
||||
from: [f64; 3],
|
||||
to: [f64; 3],
|
||||
width: f64,
|
||||
height: f64,
|
||||
mut emit: impl FnMut([f64; 3], [f64; 3]),
|
||||
) {
|
||||
for i in 0..128 {
|
||||
let d = i as f64 / 127.0;
|
||||
let velocity = [0; 3].map(|_| f64::from((random.next_float() - 0.5) * 0.2));
|
||||
let position = [
|
||||
from[0] + d * (to[0] - from[0]) + (random.next_double() - 0.5) * width * 2.0,
|
||||
from[1] + d * (to[1] - from[1]) + random.next_double() * height,
|
||||
from[2] + d * (to[2] - from[2]) + (random.next_double() - 0.5) * width * 2.0,
|
||||
];
|
||||
emit(position, velocity);
|
||||
}
|
||||
}
|
||||
|
||||
/// ClientLevel.addDestroyBlockEffect placement and pre-constructor velocities.
|
||||
pub fn destroy_positions(
|
||||
pos: [i32; 3],
|
||||
@@ -5411,6 +5435,47 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
/// `LivingEntity.handleEntityEvent(46)`: 128 samples, the first on the old
|
||||
/// box and the last on the new one, with speeds inside ±0.1.
|
||||
#[test]
|
||||
fn living_teleport_particles_span_the_old_and_new_boxes() {
|
||||
let mut random = LegacyRandom::new(1);
|
||||
let mut samples = Vec::new();
|
||||
living_teleport_particles(
|
||||
&mut random,
|
||||
[0.0, 1.0, 0.0],
|
||||
[8.0, 1.0, 0.0],
|
||||
0.6,
|
||||
1.8,
|
||||
|p, v| samples.push((p, v)),
|
||||
);
|
||||
assert_eq!(samples.len(), 128);
|
||||
let first = samples[0].0;
|
||||
let last = samples[127].0;
|
||||
assert!(first[0].abs() <= 0.6, "{first:?}");
|
||||
assert!((1.0..2.8).contains(&first[1]), "{first:?}");
|
||||
assert!(first[2].abs() <= 0.6, "{first:?}");
|
||||
assert!((7.4..8.6).contains(&last[0]), "{last:?}");
|
||||
assert!((1.0..2.8).contains(&last[1]), "{last:?}");
|
||||
assert!(last[2].abs() <= 0.6, "{last:?}");
|
||||
// `(nextDouble - 0.5) * width * 2` reaches ±width. A spread of only
|
||||
// `width` (the factor of 2 dropped) stays inside 0.3 of the line.
|
||||
let edge = samples
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (p, _))| {
|
||||
let along = (i as f64 / 127.0) * 8.0;
|
||||
(p[0] - along).abs()
|
||||
})
|
||||
.fold(0.0_f64, f64::max);
|
||||
assert!(edge > 0.45, "box spread reached only {edge}");
|
||||
for (_, v) in &samples {
|
||||
for c in v {
|
||||
assert!((-0.1..0.1).contains(c), "{v:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn egg_teleport_unpacks_offsets_and_preserves_seeded_draw_order() {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
|
||||
@@ -101,6 +101,9 @@ pub struct World {
|
||||
/// Every block position written since the log was last drained, kept
|
||||
/// only while the world is open to the network (see `lan`).
|
||||
pub block_log: Option<Vec<(i32, i32, i32)>>,
|
||||
/// Writes of this tick's block events and block entities, held out of
|
||||
/// the log while it is sent: they go out with the next tick's.
|
||||
pub block_log_held: Vec<(i32, i32, i32)>,
|
||||
/// Every write since the mobs last ticked (position, old and new state),
|
||||
/// for `ServerLevel.sendBlockUpdated`'s path recomputing; kept only while
|
||||
/// the mobs of this world tick (`game/creatures.rs`).
|
||||
@@ -217,6 +220,7 @@ impl World {
|
||||
day_time: 1000,
|
||||
weather: Weather::default(),
|
||||
block_log: None,
|
||||
block_log_held: Vec::new(),
|
||||
path_writes: None,
|
||||
bulk_log: Default::default(),
|
||||
light_log: None,
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
//! An ordinary read of a struct or buffer-block member that an atomic function also uses gets
|
||||
//! the member's current value (#9081), on a host GPU through the translated program.
|
||||
|
||||
use horizon_shaders::{Macros, Pack, preprocess, translate_compute};
|
||||
|
||||
/// Runs compute program `source` once (one work group), with `probe_buffer` (set 4, binding 2)
|
||||
/// starting at `start`, and returns `probe_result.result` (set 4, binding 1).
|
||||
fn run(device: &wgpu::Device, queue: &wgpu::Queue, source: &str, start: [u32; 2]) -> u32 {
|
||||
use std::io::Write;
|
||||
use wgpu::util::DeviceExt;
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
zip.start_file(
|
||||
"shaders/deferred.csh",
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
let pack =
|
||||
Pack::from_zip_bytes("atomic-values.zip", zip.finish().unwrap().into_inner()).unwrap();
|
||||
let program = translate_compute(&preprocess(&pack, "deferred.csh", &Macros::standard()))
|
||||
.unwrap_or_else(|e| panic!("{e:?}"));
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(program.module)),
|
||||
});
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
module: &module,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let storage = wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC;
|
||||
let uniforms = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(program.interface.uniform_size.max(48)),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let result = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: &[0; 4],
|
||||
usage: storage,
|
||||
});
|
||||
let probe: Vec<u8> = start.iter().flat_map(|v| v.to_le_bytes()).collect();
|
||||
let probe = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: &probe,
|
||||
usage: storage,
|
||||
});
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 4,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let groups: Vec<wgpu::BindGroup> = (0..5)
|
||||
.map(|set| {
|
||||
let entries: Vec<wgpu::BindGroupEntry> = match set {
|
||||
0 => vec![wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: uniforms.as_entire_binding(),
|
||||
}],
|
||||
4 if source.contains("probe_buffer") => vec![
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: result.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: probe.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
4 => vec![wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: result.as_entire_binding(),
|
||||
}],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(set),
|
||||
entries: &entries,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_compute_pass(&Default::default());
|
||||
pass.set_pipeline(&pipeline);
|
||||
for (set, group) in groups.iter().enumerate() {
|
||||
pass.set_bind_group(set as u32, group, &[]);
|
||||
}
|
||||
pass.dispatch_workgroups(1, 1, 1);
|
||||
}
|
||||
encoder.copy_buffer_to_buffer(&result, 0, &read, 0, 4);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let bytes = read.slice(..).get_mapped_range().unwrap();
|
||||
u32::from_le_bytes(bytes[..4].try_into().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a host GPU; run with --ignored --nocapture"]
|
||||
fn reads_of_atomic_members_see_the_atomic_updates() {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
|
||||
eprintln!("Atomic member GPU: {}", adapter.get_info().name);
|
||||
// Pack buffers sit in bind group 4, as in the game.
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
required_limits: wgpu::Limits {
|
||||
max_bind_groups: 5,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}))
|
||||
.unwrap();
|
||||
let result = "layout(std430, binding = 1) buffer probe_result { uint result; };\n";
|
||||
// Eight invocations each add 1 to a zeroed shared member, then read it: 8.
|
||||
for (host, member) in [
|
||||
("shared struct { uint other; uint count; } sh;", "sh.count"),
|
||||
(
|
||||
"struct Counter { uint count; uint other; };\nshared Counter counters[2];",
|
||||
"counters[1].count",
|
||||
),
|
||||
] {
|
||||
for (read, expected) in [
|
||||
("result = M * 10u + 1u;", 81),
|
||||
("uint r = M; result = r;", 8),
|
||||
("result = uint(float(M) * 0.5);", 4),
|
||||
("result = min(M, 5u);", 5),
|
||||
] {
|
||||
let source = format!(
|
||||
"#version 460 core\nlayout(local_size_x = 8) in;\n{host}\n{result}\
|
||||
void main() {{\n {member} = 0u;\n barrier();\n \
|
||||
atomicAdd({member}, 1u);\n barrier();\n {}\n}}\n",
|
||||
read.replace('M', member)
|
||||
);
|
||||
assert_eq!(
|
||||
run(&device, &queue, &source, [0, 0]),
|
||||
expected,
|
||||
"{member}: {read}"
|
||||
);
|
||||
}
|
||||
}
|
||||
// One invocation adds 2 to a buffer member that starts at 5, then reads it: 7.
|
||||
for (read, expected) in [
|
||||
("result = pc.count * 10u + 1u;", 71),
|
||||
("uint r = pc.count; result = r;", 7),
|
||||
("result = uint(int(pc.count) - 10 + 100);", 97),
|
||||
] {
|
||||
let source = format!(
|
||||
"#version 460 core\nlayout(local_size_x = 1) in;\n\
|
||||
layout(std430, binding = 2) buffer probe_buffer {{ uint count; uint other; }} pc;\n\
|
||||
{result}void main() {{\n atomicAdd(pc.count, 2u);\n {read}\n}}\n"
|
||||
);
|
||||
assert_eq!(run(&device, &queue, &source, [5, 0]), expected, "{read}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
//! Synthetic rendering checks (two output shapes mirror RenderPearl v2.7.2 @86de79c, MIT; see the
|
||||
//! PR): a fragment output's `component` layout qualifier (GLSL 4.60, 4.4.2) picks the channels of
|
||||
//! its location that it writes.
|
||||
use horizon_shaders::{Origin, Preprocessed, Target, translate_composite};
|
||||
|
||||
fn source(code: &str) -> Preprocessed {
|
||||
Preprocessed {
|
||||
code: code.into(),
|
||||
files: vec!["components.glsl".into()],
|
||||
lines: code
|
||||
.lines()
|
||||
.enumerate()
|
||||
.map(|(i, _)| Origin {
|
||||
file: 0,
|
||||
line: i as u32 + 1,
|
||||
})
|
||||
.collect(),
|
||||
version: Some((440, None)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws `fragment` over a 1×1 target of `format` and reads back its 16 bytes.
|
||||
fn draw(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
fragment: &str,
|
||||
format: wgpu::TextureFormat,
|
||||
) -> [u8; 16] {
|
||||
let program = translate_composite(
|
||||
&source("void main() { gl_Position = ftransform(); }"),
|
||||
&source(fragment),
|
||||
Target::Buffers,
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("{fragment}: {e:?}"));
|
||||
let module = |module| {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
|
||||
})
|
||||
};
|
||||
let (vertex, fragment_module) = (module(program.vertex), module(program.fragment));
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &vertex,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleStrip,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &fragment_module,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let extent = wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
};
|
||||
let target = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: extent,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let readback = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 256,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let view = target.create_view(&Default::default());
|
||||
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,
|
||||
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);
|
||||
// The composite program's uniform block, if it kept one, reads zero.
|
||||
if program.interface.uniform_size > 0 {
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(program.interface.uniform_size).max(16),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: buffer.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
}
|
||||
pass.draw(0..4, 0..1);
|
||||
}
|
||||
encoder.copy_texture_to_buffer(
|
||||
target.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &readback,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: Some(1),
|
||||
},
|
||||
},
|
||||
extent,
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
readback
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let bytes = readback.slice(..).get_mapped_range().unwrap()[..16]
|
||||
.try_into()
|
||||
.unwrap();
|
||||
readback.unmap();
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a host GPU; run with --ignored --nocapture"]
|
||||
fn component_qualified_outputs_write_their_own_channels() {
|
||||
let instance =
|
||||
wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle().with_env());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default()))
|
||||
.expect("GPU adapter required; missing adapter is not a pass");
|
||||
eprintln!("Output components GPU: {:?}", adapter.get_info());
|
||||
assert_ne!(adapter.get_info().device_type, wgpu::DeviceType::Cpu);
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
// The channels no output writes keep the fill of a narrow output: 0, and 1 in alpha.
|
||||
for (name, fragment, expected) in [
|
||||
(
|
||||
"uvec3 at component 1",
|
||||
"layout(location = 0, component = 1) out uvec3 words;\n\
|
||||
void main() { words = uvec3(7u, 8u, 9u); }",
|
||||
[0u32, 7, 8, 9],
|
||||
),
|
||||
(
|
||||
"uint at component 2",
|
||||
"layout(location = 0, component = 2) out uint word;\n\
|
||||
void main() { word = 5u; }",
|
||||
[0, 0, 5, 1],
|
||||
),
|
||||
(
|
||||
"two outputs share location 0",
|
||||
"layout(location = 0, component = 0) out uint low;\n\
|
||||
layout(location = 0, component = 1) out uvec3 high;\n\
|
||||
void main() { low = 3u; high = uvec3(4u, 5u, 6u); }",
|
||||
[3, 4, 5, 6],
|
||||
),
|
||||
(
|
||||
"the component 1 output declared first",
|
||||
"layout(location = 0, component = 1) out uvec2 middle;\n\
|
||||
layout(location = 0) out uint low;\n\
|
||||
void main() { low = 11u; middle = uvec2(12u, 13u); }",
|
||||
[11, 12, 13, 1],
|
||||
),
|
||||
] {
|
||||
let bytes = draw(&device, &queue, fragment, wgpu::TextureFormat::Rgba32Uint);
|
||||
let actual: [u32; 4] = bytemuck::cast(bytes);
|
||||
eprintln!("{name}: {actual:?}");
|
||||
assert_eq!(actual, expected, "{name}");
|
||||
}
|
||||
let bytes = draw(
|
||||
&device,
|
||||
&queue,
|
||||
"layout(location = 0, component = 0) out float red;\n\
|
||||
layout(location = 0, component = 1) out vec3 rest;\n\
|
||||
void main() { red = 0.0; rest = vec3(1.0, 0.0, 1.0); }",
|
||||
wgpu::TextureFormat::Rgba32Float,
|
||||
);
|
||||
let actual: [f32; 4] = bytemuck::cast(bytes);
|
||||
eprintln!("float outputs share location 0: {actual:?}");
|
||||
assert_eq!(actual, [0.0, 1.0, 0.0, 1.0]);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
//! `textureMatrix` in a gbuffers program (#9080), through the translated program on a host GPU.
|
||||
//! Expected values: our probe packs K-texmat3 and K-texmat2 in Iris 1.11.7 read the identity in
|
||||
//! the terrain vertex stage and zero in the terrain fragment stage.
|
||||
|
||||
use horizon_shaders::{GbuffersInputs, Macros, Pack, Target, preprocess, translate_gbuffers};
|
||||
|
||||
/// Draws one pixel with `vertex` and `fragment` as `gbuffers_terrain`; returns its RGBA8.
|
||||
fn pixel(device: &wgpu::Device, queue: &wgpu::Queue, vertex: &str, fragment: &str) -> [u8; 4] {
|
||||
use std::io::Write;
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (path, source) in [
|
||||
("shaders/gbuffers_terrain.vsh", vertex),
|
||||
("shaders/gbuffers_terrain.fsh", fragment),
|
||||
] {
|
||||
zip.start_file(path, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
}
|
||||
let pack =
|
||||
Pack::from_zip_bytes("texture-matrix.zip", zip.finish().unwrap().into_inner()).unwrap();
|
||||
let macros = Macros::standard();
|
||||
let program = translate_gbuffers(
|
||||
&preprocess(&pack, "gbuffers_terrain.vsh", ¯os),
|
||||
&preprocess(&pack, "gbuffers_terrain.fsh", ¯os),
|
||||
Target::Buffers,
|
||||
&GbuffersInputs::default(),
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("{e:?}"));
|
||||
assert!(!program.interface.draw, "no per-draw block in this probe");
|
||||
use wgpu::util::DeviceExt;
|
||||
// The frame's uniforms are all zero: `textureMatrix` must not come from them.
|
||||
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: &vec![0; program.interface.uniform_size as usize],
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let module = |module| {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
|
||||
})
|
||||
};
|
||||
let (vs, fs) = (module(program.vertex), module(program.fragment));
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &vs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &fs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
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 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::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = texture.create_view(&Default::default());
|
||||
let read = 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,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
encoder.copy_texture_to_buffer(
|
||||
texture.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &read,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
texture.size(),
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |result| result.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let mapped = read.slice(..).get_mapped_range().unwrap();
|
||||
[mapped[0], mapped[1], mapped[2], mapped[3]]
|
||||
}
|
||||
|
||||
/// A full-screen triangle; `extra` declares and sets more.
|
||||
fn vertex(extra: &str, set: &str) -> String {
|
||||
format!(
|
||||
"#version 460 core\n{extra}\nvoid main() {{\n\
|
||||
vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);\n\
|
||||
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);\n{set}\n}}\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// Four values `v` as v / 4 + 0.5 (K-texmat3's encoding): 0 is 128, 1 is 191.
|
||||
const ENCODE: &str = "color = vec4(e) / 4.0 + 0.5;";
|
||||
|
||||
fn assert_close(actual: [u8; 4], expected: [u8; 4], what: &str) {
|
||||
assert!(
|
||||
actual.iter().zip(expected).all(|(a, e)| a.abs_diff(e) <= 1),
|
||||
"{what}: {actual:?}, expected {expected:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a host GPU; run with --ignored --nocapture"]
|
||||
fn texture_matrix_is_the_identity_in_the_vertex_stage_and_zero_in_the_fragment_stage() {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
|
||||
eprintln!("Texture matrix GPU: {:?}", adapter.get_info().name);
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let fragment_in = "#version 460 core\nflat in vec4 e;\nlayout(location = 0) out vec4 color;\n\
|
||||
void main() { ";
|
||||
// The vertex stage hands elements [0][0], [1][1], [3][0], [3][3] on (K-texmat3).
|
||||
let elements = pixel(
|
||||
&device,
|
||||
&queue,
|
||||
&vertex(
|
||||
"uniform mat4 textureMatrix;\nflat out vec4 e;",
|
||||
"e = vec4(textureMatrix[0][0], textureMatrix[1][1], textureMatrix[3][0], textureMatrix[3][3]);",
|
||||
),
|
||||
&format!("{fragment_in}{ENCODE} }}\n"),
|
||||
);
|
||||
assert_close(elements, [191, 191, 128, 191], "vertex-stage elements");
|
||||
// A UV through it is the UV (K-texmat's `mat2(m) * uv + m[3].xy`).
|
||||
let uv = pixel(
|
||||
&device,
|
||||
&queue,
|
||||
&vertex(
|
||||
"uniform mat4 textureMatrix;\nflat out vec4 e;",
|
||||
"vec2 uv = vec2(0.25, 0.75);\n\
|
||||
e = vec4(mat2(textureMatrix) * uv + textureMatrix[3].xy, (textureMatrix * vec4(uv, 0.0, 1.0)).xy);",
|
||||
),
|
||||
"#version 460 core\nflat in vec4 e;\nlayout(location = 0) out vec4 color;\n\
|
||||
void main() { color = e; }\n",
|
||||
);
|
||||
assert_close(uv, [64, 191, 64, 191], "transformed UV");
|
||||
// The fragment stage's own declaration reads zero, with or without the vertex stage's (K-texmat2).
|
||||
for vertex_declares in [false, true] {
|
||||
let v = if vertex_declares {
|
||||
vertex(
|
||||
"uniform mat4 textureMatrix;\nflat out vec4 e;",
|
||||
"e = textureMatrix[0];",
|
||||
)
|
||||
} else {
|
||||
vertex("flat out vec4 e;", "e = vec4(0.0);")
|
||||
};
|
||||
let f = "#version 460 core\nuniform mat4 textureMatrix;\nflat in vec4 e;\n\
|
||||
layout(location = 0) out vec4 color;\nvoid main() {\n\
|
||||
vec4 m = vec4(textureMatrix[0][0], textureMatrix[1][1], textureMatrix[3][0], textureMatrix[3][3]);\n\
|
||||
color = m / 4.0 + 0.5 + e * 0.0;\n}\n";
|
||||
assert_close(
|
||||
pixel(&device, &queue, &v, f),
|
||||
[128, 128, 128, 128],
|
||||
&format!("fragment stage (vertex declares: {vertex_declares})"),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,8 @@
|
||||
"Peak Brightness": "Peak Brightness",
|
||||
"Black Level": "Black Level",
|
||||
"UI Brightness": "UI Brightness",
|
||||
"HDR Lighting": "HDR Lighting",
|
||||
"Light sources, the sun and sunlight shine past white. Off: just as SDR.": "Light sources, the sun and sunlight shine past white. Off: just as SDR.",
|
||||
"Smooth Gradients": "Smooth Gradients",
|
||||
"16-bit frame: dark skies and shadows don't band. A bit more VRAM. Next start.": "16-bit frame: dark skies and shadows don't band. A bit more VRAM. Next start.",
|
||||
"Battery Mode": "Battery Mode",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
//! Chest inventory loading without unpacking loot, for the vanilla differential harness.
|
||||
use horizon_client::save;
|
||||
use std::{fs, io::Write};
|
||||
|
||||
pub fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = super::args().collect();
|
||||
let input = fs::read_to_string(&args[1])?;
|
||||
let mut out = fs::File::create(&args[2])?;
|
||||
for line in input.lines() {
|
||||
let tag = horizon_world::nbt::parse_snbt(line)?.0;
|
||||
let (_, chest) = save::decode_block_entity(&tag).ok_or("not a chest")?;
|
||||
let mut result = String::new();
|
||||
for (i, stack) in chest.slots().iter().enumerate() {
|
||||
if let Some(stack) = stack {
|
||||
use std::fmt::Write;
|
||||
write!(result, "{i}:{}:{};", stack.item, stack.count)?;
|
||||
}
|
||||
}
|
||||
writeln!(out, "{}", if result.is_empty() { "empty" } else { &result })?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -26,6 +26,7 @@ mod breaking_fixture;
|
||||
mod camera_probe;
|
||||
mod cauldron_fixture;
|
||||
mod chat_screen_fixture;
|
||||
mod chest_load_fixture;
|
||||
mod chest_pair_fixture;
|
||||
mod cloud_fixture;
|
||||
mod command_parse_fixture;
|
||||
@@ -128,6 +129,7 @@ probes! {
|
||||
cauldron_fixture,
|
||||
chat_screen_fixture,
|
||||
chest_pair_fixture,
|
||||
chest_load_fixture,
|
||||
cloud_fixture,
|
||||
command_parse_fixture,
|
||||
container_screen_probe,
|
||||
|
||||
@@ -12,7 +12,7 @@ use std::{
|
||||
|
||||
fn digest(render: &RenderAssets) -> u64 {
|
||||
let mut h = DefaultHasher::new();
|
||||
render.pixels.hash(&mut h);
|
||||
render.pixels.get().hash(&mut h);
|
||||
(
|
||||
render.sprite_size,
|
||||
render.sprite_minimum_size,
|
||||
|
||||
@@ -583,6 +583,24 @@ pub enum EntityHit {
|
||||
/// flies on and falls to the ground.
|
||||
Dodged,
|
||||
}
|
||||
/// `ProjectileDeflection.REVERSE` with power `0.2`: each axis of the movement
|
||||
/// is multiplied by `-0.2 * 0.5` (`-0.1`), and the yaw turns `170 + nextFloat * 20`
|
||||
/// degrees. No hit sound. One left almost still (`lengthSqr < 1e-7`) drops its
|
||||
/// pickup when that is ALLOWED, then is discarded.
|
||||
pub fn deflect_reverse(velocity: &mut Vec3, yaw: &mut f32, st: &mut ArrowState) {
|
||||
let power = 0.2;
|
||||
let rotation = 170.0 + st.random.next_float() * 20.0;
|
||||
let v = *velocity;
|
||||
let k = -power * 0.5;
|
||||
*velocity = Vec3::new(v.x * k, v.y * k, v.z * k);
|
||||
*yaw += rotation;
|
||||
if velocity.length_squared() < 1.0e-7 {
|
||||
if st.pickup == Pickup::Allowed {
|
||||
st.events.push(ArrowEvent::Drop);
|
||||
}
|
||||
st.removed = true;
|
||||
}
|
||||
}
|
||||
/// The rest of the tick of an arrow that hit an entity: whether to keep it.
|
||||
pub fn finish_entity_hit(p: &mut Projectile, scene: &ArrowScene, outcome: EntityHit) -> bool {
|
||||
let Some(mut st) = p.arrow.take() else {
|
||||
@@ -981,18 +999,7 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
/// a half turn give or take ten degrees at a tenth of the speed; one
|
||||
/// left almost still drops its pickup (ALLOWED) and is discarded.
|
||||
fn turn_back(&mut self) {
|
||||
let power = 0.2;
|
||||
let rotation = 170.0 + self.st.random.next_float() * 20.0;
|
||||
let v = self.p.velocity;
|
||||
let k = -power * 0.5;
|
||||
self.p.velocity = Vec3::new(v.x * k, v.y * k, v.z * k);
|
||||
self.p.yaw += rotation;
|
||||
if self.p.velocity.length_squared() < 1.0e-7 {
|
||||
if self.st.pickup == Pickup::Allowed {
|
||||
self.st.events.push(ArrowEvent::Drop);
|
||||
}
|
||||
self.st.removed = true;
|
||||
}
|
||||
deflect_reverse(&mut self.p.velocity, &mut self.p.yaw, self.st);
|
||||
}
|
||||
/// `AbstractArrow.onHitBlock`: back off the face by 0.05 along each axis
|
||||
/// it moved on, stop, and stick.
|
||||
|
||||
@@ -92,8 +92,112 @@ fn slime_at(world: &World, facts: &BlockFacts, x: i32, y: i32, z: i32) -> bool {
|
||||
mod fluids;
|
||||
pub use fluids::update_entity_fluids;
|
||||
|
||||
#[cfg(test)]
|
||||
mod drop_motion_tests {
|
||||
use super::drop_motion;
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
|
||||
/// Vanilla's own `Mth` and `LegacyRandomSource(7)` running
|
||||
/// `createItemStackToDrop`'s arithmetic: three throws along the view,
|
||||
/// then two scattered drops, bit for bit, then the random's next int.
|
||||
#[test]
|
||||
fn drops_match_vanillas_motion_bit_for_bit() {
|
||||
let mut r = LegacyRandom::new(7);
|
||||
let bits = |v: [f64; 3]| v.map(|c| c.to_bits() as i64);
|
||||
let thrown = [
|
||||
(
|
||||
0.0,
|
||||
0.0,
|
||||
[
|
||||
-4658604453255681268,
|
||||
4595435376155295744,
|
||||
4598847571894135436,
|
||||
],
|
||||
),
|
||||
(
|
||||
37.5,
|
||||
-21.25,
|
||||
[
|
||||
-4627867408251048793,
|
||||
4597245257657090048,
|
||||
4597448471913462163,
|
||||
],
|
||||
),
|
||||
(
|
||||
-123.4,
|
||||
60.0,
|
||||
[
|
||||
4593476083220988603,
|
||||
-4626282285467959296,
|
||||
-4631965182103987462,
|
||||
],
|
||||
),
|
||||
];
|
||||
for (yaw, pitch, want) in thrown {
|
||||
assert_eq!(
|
||||
bits(drop_motion(&mut r, yaw, pitch, false)),
|
||||
want,
|
||||
"{yaw} {pitch}"
|
||||
);
|
||||
}
|
||||
let scattered = [
|
||||
[
|
||||
-4634512480041697280,
|
||||
4596373779801702400,
|
||||
-4661085178024689664,
|
||||
],
|
||||
[
|
||||
4600899508711194624,
|
||||
4596373779801702400,
|
||||
-4629159495333838848,
|
||||
],
|
||||
];
|
||||
for want in scattered {
|
||||
assert_eq!(bits(drop_motion(&mut r, 0.0, 0.0, true)), want);
|
||||
}
|
||||
assert_eq!(r.next_int(), 357425922);
|
||||
}
|
||||
}
|
||||
|
||||
/// `LivingEntity.createItemStackToDrop`'s motion for a stack thrown by an
|
||||
/// entity facing `yaw` and `pitch` (degrees), from that entity's random:
|
||||
/// `randomly` scatters it in any direction (a death's drops), else it goes
|
||||
/// along the view (the drop key).
|
||||
pub fn drop_motion(
|
||||
r: &mut impl horizon_core::random::RandomSource,
|
||||
yaw: f32,
|
||||
pitch: f32,
|
||||
randomly: bool,
|
||||
) -> [f64; 3] {
|
||||
use horizon_core::{
|
||||
geometry::trig::{DEG_TO_RAD, cos, sin},
|
||||
jmath,
|
||||
};
|
||||
let tau = std::f32::consts::TAU;
|
||||
if randomly {
|
||||
let pow = r.next_float() * 0.5;
|
||||
let dir = f64::from(r.next_float() * tau);
|
||||
return [
|
||||
f64::from(-sin(dir) * pow),
|
||||
f64::from(0.2f32),
|
||||
f64::from(cos(dir) * pow),
|
||||
];
|
||||
}
|
||||
let (x, y) = (f64::from(pitch * DEG_TO_RAD), f64::from(yaw * DEG_TO_RAD));
|
||||
let (sin_x, cos_x, sin_y, cos_y) = (sin(x), cos(x), sin(y), cos(y));
|
||||
let dir = f64::from(r.next_float() * tau);
|
||||
let pow = f64::from(0.02 * r.next_float());
|
||||
[
|
||||
f64::from(-sin_y * cos_x * 0.3) + jmath::cos(dir) * pow,
|
||||
f64::from(-sin_x * 0.3 + 0.1 + (r.next_float() - r.next_float()) * 0.1),
|
||||
f64::from(cos_y * cos_x * 0.3) + jmath::sin(dir) * pow,
|
||||
]
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ItemEntity {
|
||||
/// `Entity.yRot` and `xRot` (`/tp`, `/data get ... Rotation`).
|
||||
pub rotation: [f32; 2],
|
||||
/// When it joined its list ([`crate::join_seq`]); set by the list, not by the spawn site.
|
||||
pub join: crate::join_seq::JoinSeq,
|
||||
pub health: i32,
|
||||
@@ -275,6 +379,7 @@ impl ItemEntity {
|
||||
fluids: &crate::motion::FluidResources,
|
||||
entity_boxes: &[Aabb],
|
||||
border: Option<&BorderGeometry>,
|
||||
blocks: Option<&horizon_world::movement_properties::BoundMovementProperties>,
|
||||
) -> bool {
|
||||
let (water, lava) = self.update_fluids(world, fluids);
|
||||
self.water_entry(water > 0.0, self.ticks == 0, &mut |_| {});
|
||||
@@ -286,6 +391,7 @@ impl ItemEntity {
|
||||
(water, lava),
|
||||
entity_boxes,
|
||||
border,
|
||||
blocks,
|
||||
);
|
||||
crate::motion::bubble_column_push(
|
||||
world,
|
||||
@@ -453,6 +559,7 @@ impl ItemEntity {
|
||||
(water_height, lava_height): (f64, f64),
|
||||
entity_boxes: &[Aabb],
|
||||
border: Option<&BorderGeometry>,
|
||||
blocks: Option<&horizon_world::movement_properties::BoundMovementProperties>,
|
||||
) -> ItemStep {
|
||||
self.ticks += 1;
|
||||
self.previous = self.position;
|
||||
@@ -550,6 +657,15 @@ impl ItemEntity {
|
||||
slime,
|
||||
);
|
||||
}
|
||||
// Entity.move's last step: soul sand and honey slow the item's
|
||||
// horizontal speed. A move through blocks (noPhysics) returns
|
||||
// before it. `blocks` is None only for the isolated unit tests.
|
||||
if !no_physics && let Some(blocks) = blocks {
|
||||
let factor =
|
||||
crate::motion::block_speed_factor_at(world, blocks, self.position, None);
|
||||
self.velocity =
|
||||
horizon_world::entity_move::scale_by_block_speed(self.velocity, factor);
|
||||
}
|
||||
// Entity.move -> applyEffectsFromBlocks: honey then bubble columns
|
||||
// (before air drag), matching ItemEntity.tick.
|
||||
self.apply_honey_slide(world, facts);
|
||||
@@ -951,6 +1067,8 @@ pub const ORB_QUAD_SCALE: f32 = 0.3;
|
||||
/// ExperienceOrb: gravity 0.03, homing towards the player within 8 blocks.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ExperienceOrb {
|
||||
/// `Entity.yRot` and `xRot` (`/tp`, `/data get ... Rotation`).
|
||||
pub rotation: [f32; 2],
|
||||
/// When it joined its list ([`crate::join_seq`]); set by the list, not by the spawn site.
|
||||
pub join: crate::join_seq::JoinSeq,
|
||||
/// The entity id servers track it by.
|
||||
@@ -1210,6 +1328,7 @@ impl ExperienceOrb {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.moveTowardsClosestSpace` from the middle of an entity's box
|
||||
/// (`bounds`, its feet at `position`): the velocity pushed towards the
|
||||
/// nearest side of its block (north, south, west, east or up) without a full
|
||||
@@ -1464,6 +1583,8 @@ impl ItemFrame {
|
||||
/// An armor stand (`ArmorStand`).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArmorStand {
|
||||
/// `Entity.xRot`: kept for `/tp` and `Rotation`; the kind turns only in yaw.
|
||||
pub pitch: f32,
|
||||
/// When it joined its list ([`crate::join_seq`]); set by the list, not by the spawn site.
|
||||
pub join: crate::join_seq::JoinSeq,
|
||||
pub fire_ticks: i32,
|
||||
@@ -1663,6 +1784,7 @@ impl ArmorStand {
|
||||
}
|
||||
pub fn new(id: u32, position: Vec3, yaw: f32) -> Self {
|
||||
Self {
|
||||
pitch: 0.0,
|
||||
join: Default::default(),
|
||||
fire_ticks: 0,
|
||||
health: 20.0,
|
||||
@@ -2037,6 +2159,7 @@ mod fire_environment_tests {
|
||||
)
|
||||
.unwrap();
|
||||
let make = |id: &str| ItemEntity {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: 1,
|
||||
@@ -2171,6 +2294,7 @@ mod destroyed_bundle_environment_tests {
|
||||
ItemStack::new("minecraft:diamond", 1),
|
||||
];
|
||||
let mut bundle = ItemEntity {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: 1,
|
||||
@@ -2269,6 +2393,7 @@ mod merge_tests {
|
||||
fn item(id: &str, count: u32, x: f64, age: i32) -> ItemEntity {
|
||||
let at = Vec3::new(x, 64.0, 0.5);
|
||||
ItemEntity {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: 0,
|
||||
@@ -2453,6 +2578,64 @@ mod merge_tests {
|
||||
mod world_border_tests {
|
||||
use super::*;
|
||||
|
||||
/// `Entity.move`'s last step, `getBlockSpeedFactor`: a dropped item sliding
|
||||
/// over soul sand or honey keeps 40% of the horizontal speed it keeps over
|
||||
/// stone, in the same tick. Skips quietly without the vanilla data (CI has none).
|
||||
#[test]
|
||||
fn a_dropped_item_on_soul_sand_or_honey_is_slowed_by_the_move() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let fluids = crate::motion::FluidResources::new(&assets, &facts).unwrap();
|
||||
let collision = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let motion = crate::motion::MotionResources::load(&root, &assets).unwrap();
|
||||
let mut after = Vec::new();
|
||||
// The tops of the blocks: stone 1, soul sand 7/8, honey 15/16.
|
||||
for (block, top) in [
|
||||
("minecraft:stone", 4.0),
|
||||
("minecraft:soul_sand", 3.875),
|
||||
("minecraft:honey_block", 3.9375),
|
||||
] {
|
||||
let mut world = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let id = assets.registry.default_state(block).unwrap();
|
||||
for x in 18..=23 {
|
||||
for z in 19..=21 {
|
||||
world.set(x, 3, z, id).unwrap();
|
||||
}
|
||||
}
|
||||
let mut item = super::honey_slide_tests::falling_item(19.5, top, 20.5, 0.2, 0.0, 0.0);
|
||||
item.on_ground = true;
|
||||
item.tick(
|
||||
&world,
|
||||
assets.registry,
|
||||
&facts,
|
||||
&collision,
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
None,
|
||||
Some(&motion.blocks),
|
||||
);
|
||||
after.push(item.velocity.x);
|
||||
}
|
||||
let (stone, soul, honey) = (after[0], after[1], after[2]);
|
||||
assert!(stone > 0.0);
|
||||
assert!(
|
||||
(soul - stone * f64::from(0.4_f32)).abs() < 1.0e-9,
|
||||
"soul sand {soul} vs stone {stone}"
|
||||
);
|
||||
assert!(
|
||||
(honey - stone * f64::from(0.4_f32)).abs() < 1.0e-9,
|
||||
"honey {honey} vs stone {stone}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `Entity.collectColliders` (#7080): the world border's wall stops an
|
||||
/// item and an experience orb as it stops the player. A border of size
|
||||
/// 10 around (20, 20) has its east wall at x = 25; each body starts at
|
||||
@@ -2484,8 +2667,10 @@ mod world_border_tests {
|
||||
&fluids,
|
||||
&[],
|
||||
border,
|
||||
None,
|
||||
);
|
||||
let mut orb = ExperienceOrb {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: 2,
|
||||
@@ -2569,6 +2754,7 @@ mod honey_slide_tests {
|
||||
&fluids,
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
);
|
||||
ys.push(item.velocity.y);
|
||||
}
|
||||
@@ -2634,6 +2820,7 @@ mod honey_slide_tests {
|
||||
&fluids,
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
@@ -2694,6 +2881,7 @@ mod honey_slide_tests {
|
||||
pub(super) fn falling_item(x: f64, y: f64, z: f64, vx: f64, vy: f64, vz: f64) -> ItemEntity {
|
||||
let at = Vec3::new(x, y, z);
|
||||
ItemEntity {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: 1,
|
||||
@@ -2844,6 +3032,7 @@ mod slime_bounce_tests {
|
||||
fn grounded_item() -> ItemEntity {
|
||||
let at = Vec3::new(0.5, 2.0, 0.5);
|
||||
ItemEntity {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: 1,
|
||||
@@ -2935,6 +3124,7 @@ mod slime_bounce_tests {
|
||||
&fluids,
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(falling.position.y, 4.0);
|
||||
assert!(falling.velocity.y > 0.0);
|
||||
@@ -2954,6 +3144,7 @@ mod slime_bounce_tests {
|
||||
&fluids,
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert!(!falling.needs_sync);
|
||||
}
|
||||
@@ -3047,6 +3238,7 @@ mod entity_collision_tests {
|
||||
&fluids,
|
||||
boxes,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
}
|
||||
item.position.y
|
||||
@@ -3096,6 +3288,7 @@ mod entity_collision_tests {
|
||||
&fluids,
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
);
|
||||
}
|
||||
item.position.x
|
||||
@@ -3551,6 +3744,7 @@ mod item_destroyed_tests {
|
||||
.map(|(id, count)| ItemStack::new(id, *count))
|
||||
.collect();
|
||||
ItemEntity {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: 1,
|
||||
@@ -3644,6 +3838,7 @@ mod render_light_tests {
|
||||
|
||||
fn orb(y: f64) -> ExperienceOrb {
|
||||
ExperienceOrb {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: 1,
|
||||
|
||||
@@ -242,6 +242,7 @@ mod tests {
|
||||
}
|
||||
let at = Vec3::new(20.5, 4.0, 20.5);
|
||||
let item = ItemEntity {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: 1,
|
||||
@@ -278,6 +279,7 @@ mod tests {
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
None,
|
||||
None
|
||||
));
|
||||
assert_eq!(still.position.x, at.x);
|
||||
@@ -292,6 +294,7 @@ mod tests {
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
None,
|
||||
None
|
||||
));
|
||||
// First push is damped by buoyancy before motion, second follows drag.
|
||||
@@ -307,6 +310,7 @@ mod tests {
|
||||
&fluids,
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
);
|
||||
}
|
||||
assert!(carried.position.x > at.x + 0.5);
|
||||
@@ -342,6 +346,7 @@ mod tests {
|
||||
}
|
||||
let at = Vec3::new(20.5, 4.0, 20.5);
|
||||
let orb = ExperienceOrb {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: 1,
|
||||
|
||||
@@ -2139,7 +2139,7 @@ pub fn item_entity_to_nbt(e: &ItemEntity, uuid: [i32; 4], raw: Option<&Tag>) ->
|
||||
uuid,
|
||||
position: e.position,
|
||||
motion: e.velocity,
|
||||
rotation: [0.0, 0.0],
|
||||
rotation: e.rotation,
|
||||
on_ground: e.on_ground,
|
||||
fall_distance: 0.0,
|
||||
};
|
||||
@@ -2193,6 +2193,7 @@ pub fn tnt_to_nbt(
|
||||
fuse: i32,
|
||||
power: f32,
|
||||
block_state: Tag,
|
||||
rotation: [f32; 2],
|
||||
uuid: [i32; 4],
|
||||
raw: Option<&Tag>,
|
||||
) -> Tag {
|
||||
@@ -2201,7 +2202,7 @@ pub fn tnt_to_nbt(
|
||||
uuid,
|
||||
position,
|
||||
motion,
|
||||
rotation: [0.0, 0.0],
|
||||
rotation,
|
||||
on_ground,
|
||||
fall_distance: 0.0,
|
||||
}
|
||||
@@ -2245,6 +2246,7 @@ pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemE
|
||||
let stack = item_from_nbt(tag.get("Item")?)?;
|
||||
let position = position(tag)?;
|
||||
Some(ItemEntity {
|
||||
rotation: rotation(tag),
|
||||
join: Default::default(),
|
||||
no_gravity: flag(tag, "NoGravity"),
|
||||
health: number(tag.get("Health")).unwrap_or(5) as i32,
|
||||
@@ -2324,7 +2326,7 @@ pub fn orb_to_nbt(o: &ExperienceOrb, uuid: [i32; 4]) -> Tag {
|
||||
uuid,
|
||||
position: o.position,
|
||||
motion: o.velocity,
|
||||
rotation: [0.0, 0.0],
|
||||
rotation: o.rotation,
|
||||
on_ground: o.on_ground,
|
||||
fall_distance: 0.0,
|
||||
};
|
||||
@@ -2350,6 +2352,7 @@ pub fn orbs_from_nbt(tag: &Tag) -> Vec<ExperienceOrb> {
|
||||
};
|
||||
let count = number(tag.get("Count")).unwrap_or(1).max(1) as u32;
|
||||
let orb = ExperienceOrb {
|
||||
rotation: rotation(tag),
|
||||
join: Default::default(),
|
||||
no_gravity: flag(tag, "NoGravity"),
|
||||
id: 0,
|
||||
@@ -2803,6 +2806,7 @@ pub fn firework_rocket_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<R
|
||||
/// What `FireworkRocketEntity.addAdditionalSaveData` writes, over `Projectile`'s
|
||||
/// Owner and LeftOwner.
|
||||
pub struct RocketSave<'a> {
|
||||
pub rotation: [f32; 2],
|
||||
pub position: Vec3,
|
||||
pub motion: Vec3,
|
||||
pub life: i32,
|
||||
@@ -2818,7 +2822,7 @@ pub fn firework_rocket_to_nbt(r: &RocketSave, uuid: [i32; 4], raw: Option<&Tag>)
|
||||
uuid,
|
||||
position: r.position,
|
||||
motion: r.motion,
|
||||
rotation: [0.0, 0.0],
|
||||
rotation: r.rotation,
|
||||
on_ground: false,
|
||||
fall_distance: 0.0,
|
||||
}
|
||||
@@ -2944,6 +2948,7 @@ pub fn falling_block_to_nbt(
|
||||
drop_item: bool,
|
||||
damage: crate::falling_block::Damage,
|
||||
fall_distance: f64,
|
||||
rotation: [f32; 2],
|
||||
uuid: [i32; 4],
|
||||
) -> Tag {
|
||||
let base = Base {
|
||||
@@ -2951,7 +2956,7 @@ pub fn falling_block_to_nbt(
|
||||
uuid,
|
||||
position,
|
||||
motion,
|
||||
rotation: [0.0, 0.0],
|
||||
rotation,
|
||||
on_ground: false,
|
||||
fall_distance,
|
||||
};
|
||||
@@ -3159,7 +3164,7 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
|
||||
uuid,
|
||||
position: a.position,
|
||||
motion: a.velocity,
|
||||
rotation: [a.yaw, 0.0],
|
||||
rotation: [a.yaw, a.pitch],
|
||||
on_ground: a.on_ground,
|
||||
fall_distance: 0.0,
|
||||
};
|
||||
@@ -3302,7 +3307,7 @@ pub fn boat_to_nbt(
|
||||
id: &str,
|
||||
position: Vec3,
|
||||
motion: Vec3,
|
||||
yaw: f32,
|
||||
rotation: [f32; 2],
|
||||
uuid: [i32; 4],
|
||||
raw: Option<&Tag>,
|
||||
items: Option<&[Option<crate::items::ItemStack>]>,
|
||||
@@ -3312,7 +3317,7 @@ pub fn boat_to_nbt(
|
||||
uuid,
|
||||
position,
|
||||
motion,
|
||||
rotation: [yaw, 0.0],
|
||||
rotation,
|
||||
on_ground: false,
|
||||
fall_distance: 0.0,
|
||||
}
|
||||
@@ -3327,7 +3332,7 @@ pub fn vehicle_to_nbt(
|
||||
id: &str,
|
||||
position: Vec3,
|
||||
motion: Vec3,
|
||||
yaw: f32,
|
||||
rotation: [f32; 2],
|
||||
uuid: [i32; 4],
|
||||
raw: Option<&Tag>,
|
||||
) -> Tag {
|
||||
@@ -3336,7 +3341,7 @@ pub fn vehicle_to_nbt(
|
||||
uuid,
|
||||
position,
|
||||
motion,
|
||||
rotation: [yaw, 0.0],
|
||||
rotation,
|
||||
on_ground: false,
|
||||
fall_distance: 0.0,
|
||||
}
|
||||
@@ -3565,6 +3570,7 @@ mod tnt_power_tests {
|
||||
20,
|
||||
10.0,
|
||||
string("minecraft:tnt"),
|
||||
[0.0, 0.0],
|
||||
[1, 2, 3, 4],
|
||||
None,
|
||||
);
|
||||
@@ -3576,6 +3582,7 @@ mod tnt_power_tests {
|
||||
20,
|
||||
4.0,
|
||||
string("minecraft:tnt"),
|
||||
[0.0, 0.0],
|
||||
[1, 2, 3, 4],
|
||||
None,
|
||||
);
|
||||
@@ -3911,6 +3918,7 @@ mod tests {
|
||||
let item = ItemStack::new("minecraft:firework_rocket", 1);
|
||||
let tag = firework_rocket_to_nbt(
|
||||
&RocketSave {
|
||||
rotation: [0.0, 0.0],
|
||||
position: Vec3::new(1.5, 70.0, -3.25),
|
||||
motion: Vec3::new(0.0, 0.5, 0.0),
|
||||
life: 7,
|
||||
@@ -4065,6 +4073,7 @@ mod tests {
|
||||
false,
|
||||
damage,
|
||||
19.75,
|
||||
[0.0, 0.0],
|
||||
[1, 2, 3, 4],
|
||||
);
|
||||
let (loaded_state, pos, motion, age, distance) = falling_block_from_nbt(&tag).unwrap();
|
||||
@@ -4110,6 +4119,7 @@ mod tests {
|
||||
&fluids,
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
};
|
||||
let fresh = Tag::compound([
|
||||
@@ -4178,6 +4188,7 @@ mod tests {
|
||||
#[test]
|
||||
fn item_entities_round_trip() {
|
||||
let e = ItemEntity {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
health: 2,
|
||||
@@ -4282,11 +4293,46 @@ mod tests {
|
||||
assert_eq!(text(tag.get("CustomName")).as_deref(), Some("x"));
|
||||
assert_eq!(number(tag.get("Health")), Some(5));
|
||||
}
|
||||
#[test]
|
||||
fn saved_rotation_is_the_entitys_own() {
|
||||
// `/tp <entity> x y z 90 10` sets the entity's yaw and pitch; the save
|
||||
// (`/data get ... Rotation`) writes them, not [0, 0].
|
||||
let tnt = tnt_to_nbt(
|
||||
Vec3::ZERO,
|
||||
Vec3::ZERO,
|
||||
false,
|
||||
20,
|
||||
4.0,
|
||||
string("minecraft:tnt"),
|
||||
[90.0, 10.0],
|
||||
[1; 4],
|
||||
None,
|
||||
);
|
||||
assert_eq!(rotation(&tnt), [90.0, 10.0]);
|
||||
let mut stand = ArmorStand::new(1, Vec3::ZERO, 0.0);
|
||||
stand.yaw = 90.0;
|
||||
stand.pitch = 10.0;
|
||||
assert_eq!(
|
||||
rotation(&armor_stand_to_nbt(&stand, [1; 4], None)),
|
||||
[90.0, 10.0]
|
||||
);
|
||||
let cart = vehicle_to_nbt(
|
||||
"minecraft:minecart",
|
||||
Vec3::ZERO,
|
||||
Vec3::ZERO,
|
||||
[90.0, 10.0],
|
||||
[1; 4],
|
||||
None,
|
||||
);
|
||||
assert_eq!(rotation(&cart), [90.0, 10.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entities_without_fire_save_zero_and_a_mob_saves_its_speed_instance() {
|
||||
// Entity.remainingFireTicks starts at 0; LivingEntity.removeFrost makes
|
||||
// the movement_speed instance AttributeMap.pack then saves.
|
||||
let orb = ExperienceOrb {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: 0,
|
||||
@@ -4349,6 +4395,7 @@ mod tests {
|
||||
#[test]
|
||||
fn orbs_keep_their_count() {
|
||||
let o = ExperienceOrb {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: 0,
|
||||
|
||||
@@ -67,6 +67,7 @@ mod block_entity_anims;
|
||||
mod block_entity_ticks;
|
||||
mod block_items;
|
||||
mod blockctl;
|
||||
mod boarding;
|
||||
mod boat_authority_fixture;
|
||||
mod boat_leads;
|
||||
mod boat_passengers;
|
||||
@@ -343,6 +344,7 @@ mod tutorial;
|
||||
mod tweaks;
|
||||
mod ui_control;
|
||||
mod vault;
|
||||
mod vehicle_teleport;
|
||||
mod villagers;
|
||||
mod wandering_trader;
|
||||
mod win;
|
||||
@@ -1840,6 +1842,8 @@ impl LevelAccess for GameLevel<'_> {
|
||||
/// FallingBlockEntity carrying a block state.
|
||||
#[derive(Clone, Debug)]
|
||||
struct FallingBlock {
|
||||
/// `Entity.yRot` and `xRot` (`/tp`, `/data get ... Rotation`).
|
||||
pub rotation: [f32; 2],
|
||||
/// When it joined its list (`horizon_client::join_seq`); set by the list.
|
||||
pub(super) join: horizon_client::join_seq::JoinSeq,
|
||||
/// Its entity id: from the entity counter on the server, the server's on
|
||||
@@ -2866,6 +2870,13 @@ struct Game<'a> {
|
||||
cart_models: minecarts::CartModels,
|
||||
/// The boat the player rides.
|
||||
riding: Option<u32>,
|
||||
/// `Entity.boardingCooldown` of the acting player, and its vehicle at the
|
||||
/// last tick (`game/boarding.rs`).
|
||||
boarding_cooldown: i32,
|
||||
riding_at_tick: Option<u32>,
|
||||
/// A rotation-only write (`/rotate`, `Entity.forceSetRotation`) to the player of this
|
||||
/// turn: `leave_guest` sends it as `ClientboundPlayerRotationPacket`, not a teleport.
|
||||
rotation_forced: bool,
|
||||
/// The mount the last `position_rider` put the player on, to tell the tick
|
||||
/// a seat is taken (`Entity.moveTo`, no camera sweep) from the ones that
|
||||
/// follow it (`positionRider`, interpolated as usual).
|
||||
@@ -3960,9 +3971,7 @@ impl Game<'_> {
|
||||
stacks.retain(|stack| stack.count > 0 && &*stack.item != "minecraft:air");
|
||||
for stack in stacks {
|
||||
let r = &mut self.world.random;
|
||||
let strength = f64::from(r.next_float()) * 0.5;
|
||||
let angle = f64::from(r.next_float()) * std::f64::consts::TAU;
|
||||
let v = [-angle.sin() * strength, 0.2, angle.cos() * strength];
|
||||
let v = horizon_client::entities::drop_motion(r, self.yaw, self.pitch, true);
|
||||
self.spawn_item(stack, at, v, 40);
|
||||
}
|
||||
}
|
||||
@@ -3998,6 +4007,7 @@ impl Game<'_> {
|
||||
// ItemEntity.bobOffs comes from the entity's own random, not the level's.
|
||||
let bob = self.particles.random().next_float() * std::f32::consts::PI * 2.0;
|
||||
self.dropped.push(ItemEntity {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
health: 5,
|
||||
@@ -4146,19 +4156,11 @@ impl Game<'_> {
|
||||
/// drop key) names the player as the thrower; `drop(stack, false)`, for
|
||||
/// what no longer fits in the inventory, does not.
|
||||
fn throw_from_eyes(&mut self, thrown: ItemStack, eye: [f64; 3], thrown_from_hand: bool) {
|
||||
let (yaw, pitch) = (
|
||||
f64::from(self.yaw).to_radians(),
|
||||
f64::from(self.pitch).to_radians(),
|
||||
);
|
||||
let r = &mut self.world.random;
|
||||
let spread = f64::from(r.next_float()) * std::f64::consts::TAU;
|
||||
let strength = 0.02 * f64::from(r.next_float());
|
||||
let v = [
|
||||
-yaw.sin() * pitch.cos() * 0.3 + spread.cos() * strength,
|
||||
-pitch.sin() * 0.3 + 0.1 + f64::from(r.next_float() - r.next_float()) * 0.1,
|
||||
yaw.cos() * pitch.cos() * 0.3 + spread.sin() * strength,
|
||||
];
|
||||
self.spawn_item(thrown, [eye[0], eye[1] - 0.3, eye[2]], v, 40);
|
||||
let v = horizon_client::entities::drop_motion(r, self.yaw, self.pitch, false);
|
||||
// `getEyeY() - 0.3F`.
|
||||
let y = eye[1] - f64::from(0.3f32);
|
||||
self.spawn_item(thrown, [eye[0], y, eye[2]], v, 40);
|
||||
// A native mod can keep the item out of the level (native_entity_join_level).
|
||||
if thrown_from_hand {
|
||||
let uuid = self.acting_player_uuid();
|
||||
@@ -4286,6 +4288,7 @@ impl Game<'_> {
|
||||
no_physics,
|
||||
&nearby,
|
||||
Some(self.border.geometry()),
|
||||
Some(&self.res.motion().blocks),
|
||||
&mut |splash| entered.push(splash),
|
||||
);
|
||||
// Entity.doWaterSplashEffect: the server plays the sound (broadcast
|
||||
@@ -4892,7 +4895,10 @@ impl Game<'_> {
|
||||
}
|
||||
/// ServerLevel.tick block phases: scheduled ticks, random ticks in the
|
||||
/// chunks the tickets simulate, block events and moving pistons.
|
||||
fn tick_blocks(&mut self) -> Result<()> {
|
||||
/// The length the block log had after the changed blocks went out
|
||||
/// (`ServerChunkCache.tick`'s broadcast), before the block events and the
|
||||
/// block entities ran.
|
||||
fn tick_blocks(&mut self) -> Result<Option<usize>> {
|
||||
// ServerChunkCache.tick: the tickets that time out.
|
||||
self.tickets.tick_portal();
|
||||
self.sync_tick_chunks();
|
||||
@@ -5127,6 +5133,9 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
let sent = self.lan_broadcast_changes();
|
||||
self.with_level(|b, level, ticks| {
|
||||
b.run_block_events(level, ticks);
|
||||
b.tick_block_entities(level, ticks);
|
||||
});
|
||||
@@ -5136,7 +5145,7 @@ impl Game<'_> {
|
||||
self.push_piston_entities(pushes);
|
||||
self.process_level_events()?;
|
||||
self.sync_moving_pistons();
|
||||
Ok(())
|
||||
Ok(sent)
|
||||
}
|
||||
/// Copies the moving-piston block entities into the world, where the
|
||||
/// collision queries and the renderer read them.
|
||||
@@ -5533,6 +5542,7 @@ impl Game<'_> {
|
||||
);
|
||||
self.next_entity += 1;
|
||||
self.falling.push(FallingBlock {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
id: self.next_entity,
|
||||
state,
|
||||
@@ -7708,9 +7718,10 @@ impl Game<'_> {
|
||||
// here too, before falling-block schedules run (#4510).
|
||||
horizon_client::spans::time("tick_local_hand", || self.tick_local_hand())?;
|
||||
horizon_client::spans::time("postprocess_chunks", || self.postprocess_chunks())?;
|
||||
let mut sent = None;
|
||||
if running {
|
||||
horizon_client::spans::time("tick_lan_packets", || self.tick_lan_packets());
|
||||
horizon_client::spans::time("tick_blocks", || self.tick_blocks())?;
|
||||
sent = horizon_client::spans::time("tick_blocks", || self.tick_blocks())?;
|
||||
// What the blocks spawned (a dispensed minecart, a lit TNT, a
|
||||
// placed boat) joins before the entities tick.
|
||||
self.native_joins_flush(false, 0);
|
||||
@@ -7787,7 +7798,20 @@ impl Game<'_> {
|
||||
// The rest of the tick's spawns join, and what left is told of.
|
||||
self.native_joins_flush(false, 0);
|
||||
self.native_leaves_flush();
|
||||
// ServerLevel.tick broadcasts the changed blocks after the scheduled and
|
||||
// random ticks (`tick_blocks`), before the block events, the entities
|
||||
// and the block entities: what those change goes out with the next
|
||||
// tick's. A piston's moved block reaches the clients once their own
|
||||
// copy of it finished moving, so a slow tick rate shows it slide
|
||||
// (#8430).
|
||||
if let (Some(sent), Some(log)) = (sent, self.world.block_log.as_mut()) {
|
||||
self.world.block_log_held = log.split_off(sent.min(log.len()));
|
||||
}
|
||||
horizon_client::spans::time("tick_lan", || self.tick_lan(false));
|
||||
let held = std::mem::take(&mut self.world.block_log_held);
|
||||
if let Some(log) = self.world.block_log.as_mut() {
|
||||
log.extend(held);
|
||||
}
|
||||
// Everything that has joined has had its spawn packet.
|
||||
self.ordered.announced = horizon_client::join_seq::JoinSeq::upcoming();
|
||||
self.tick_friends();
|
||||
@@ -13725,6 +13749,7 @@ impl Game<'_> {
|
||||
let p = Vec3::new(at[0], at[1], at[2]);
|
||||
self.next_entity += 1;
|
||||
self.orbs.push(ExperienceOrb {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: self.next_entity,
|
||||
@@ -14776,6 +14801,8 @@ impl Game<'_> {
|
||||
let Some(at) = self.sleeping_position(head) else {
|
||||
return;
|
||||
};
|
||||
// LivingEntity.startSleeping: stopRiding once the bed is valid.
|
||||
self.stop_riding();
|
||||
self.stop_mining();
|
||||
self.left = false;
|
||||
self.right = false;
|
||||
@@ -15622,6 +15649,7 @@ impl Game<'_> {
|
||||
// compressed (a no-op once it is).
|
||||
if self.renderer.is_some() {
|
||||
self.gui.atlas.pack_pixels();
|
||||
self.pack.render.pixels.pack();
|
||||
}
|
||||
// Before any text of this frame: a full glyph region starts over.
|
||||
self.gui.atlas.font.begin_frame();
|
||||
@@ -16917,6 +16945,18 @@ impl Game<'_> {
|
||||
self.all_sections_changed();
|
||||
}
|
||||
self.prepare_lod();
|
||||
// The columns the single-player server changed in bulk without sending them, which far terrain still drew
|
||||
// as this window last saw them: summarized again (taken every tick, far terrain on or off, so none waits).
|
||||
if self.remote.is_some() {
|
||||
let changed = horizon_client::lan::handoff::take_changed();
|
||||
if let Some(lod) = &mut self.lod {
|
||||
for chunk in changed {
|
||||
if !self.world.chunks.contains_key(&(chunk.x, chunk.z)) {
|
||||
lod.observe(chunk, self.res.facts());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.lod.is_some() {
|
||||
for &((x, _, z), _, _) in &self.world.light_edits {
|
||||
self.lod_observe.insert((x >> 4, z >> 4));
|
||||
@@ -19234,6 +19274,9 @@ impl<'a> Game<'a> {
|
||||
trim_slots: Vec::new(),
|
||||
cart_models: prepared.cart_models,
|
||||
riding: None,
|
||||
boarding_cooldown: 0,
|
||||
riding_at_tick: None,
|
||||
rotation_forced: false,
|
||||
posed_rider: None,
|
||||
pending_vehicle: None,
|
||||
ender_items: vec![None; 27],
|
||||
@@ -20674,7 +20717,7 @@ fn load_world_rest(
|
||||
fn prepare_renderer_textures(pack: &PackResources) -> horizon_client::gfx::PreparedTextures {
|
||||
let assets = &pack.render;
|
||||
horizon_client::gfx::PreparedTextures::new(
|
||||
&assets.pixels,
|
||||
&assets.pixels.get(),
|
||||
assets.sprite_size,
|
||||
assets.texture_layers,
|
||||
&assets.sprite_mips,
|
||||
@@ -20720,7 +20763,7 @@ fn build_renderer(
|
||||
let mut renderer = pollster::block_on(Renderer::new(
|
||||
window,
|
||||
offscreen,
|
||||
&assets.pixels,
|
||||
&assets.pixels.get(),
|
||||
assets.sprite_size,
|
||||
assets.sprite_minimum_size,
|
||||
assets.texture_layers,
|
||||
@@ -21704,11 +21747,13 @@ pub fn run_with() -> Result<()> {
|
||||
// horizon-ctl drives: those start in a source checkout and wrote the list there.
|
||||
if !options.offscreen && !synchronous_startup(&options) {
|
||||
horizon_client::heartbeat::start();
|
||||
if let Err(e) =
|
||||
horizon_client::multiplayer::add_official_server(&horizon_client::logging::player_dir())
|
||||
{
|
||||
let player_dir = horizon_client::logging::player_dir();
|
||||
if let Err(e) = horizon_client::multiplayer::add_official_server(&player_dir) {
|
||||
log::warn!("could not add the official server to servers.dat: {e}");
|
||||
}
|
||||
if let Err(e) = horizon_client::multiplayer::add_partner_servers(&player_dir) {
|
||||
log::warn!("could not add the partner servers to servers.dat: {e}");
|
||||
}
|
||||
}
|
||||
mods::register_components(&vanilla_root)?;
|
||||
mods::register_standalone_models();
|
||||
@@ -22413,6 +22458,7 @@ mod tests {
|
||||
fn a_falling_block_keeps_its_model_offset() {
|
||||
let at = Vec3::new(3.5, 70.25, -1.5);
|
||||
let falling = FallingBlock {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
id: 0,
|
||||
state: BlockStateId(7),
|
||||
|
||||
@@ -348,6 +348,11 @@ impl Game<'_> {
|
||||
{
|
||||
v["sign"] = json!({ "front": sign.front.lines, "back": sign.back.lines });
|
||||
}
|
||||
// A block a piston moves, as this client animates it: its own
|
||||
// PistonMovingBlockEntity, which a block event's moveBlocks made.
|
||||
if let Some((_, piston)) = self.world.moving_pistons.iter().find(|(p, _)| *p == pos) {
|
||||
v["moving"] = json!({ "progress": piston.progress, "extending": piston.extending });
|
||||
}
|
||||
v
|
||||
}
|
||||
pub(super) fn stack_value(&self, s: &ItemStack) -> Value {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
containers::BlockEntity,
|
||||
first_person::Hand,
|
||||
items::ItemStack,
|
||||
mobs::{Species, allay},
|
||||
};
|
||||
@@ -291,7 +292,8 @@ impl Game<'_> {
|
||||
self.consume_held();
|
||||
return true;
|
||||
}
|
||||
if holds && item.is_none() {
|
||||
// Allay.mobInteract takes the item back only through an empty main hand.
|
||||
if holds && item.is_none() && self.hand == Hand::Main {
|
||||
let m = &mut self.mobs[index];
|
||||
// The hand's stack keeps the components (a potion) the items came with.
|
||||
let held = m
|
||||
@@ -305,6 +307,8 @@ impl Game<'_> {
|
||||
m.extra.owner = None;
|
||||
self.audio
|
||||
.play("minecraft:entity.allay.item_taken", Some(at), 2.0, 1.0);
|
||||
// Allay.mobInteract ends with swing(MAIN_HAND, ...).
|
||||
self.mobs[index].swing();
|
||||
if let Some(stack) = carried {
|
||||
self.spawn_item(stack, at, [0.0, 0.2, 0.0], 40);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
//! The player's `Entity.boardingCooldown`: 60 ticks after leaving a vehicle in
|
||||
//! which a non-forced `startRiding` (`Entity.canRide`) is refused.
|
||||
//! Mobs keep theirs in `extra.boarding_cooldown`; a server's guests keep theirs
|
||||
//! in `Guest::boarding_cooldown`, swapped in for their turn like `riding`.
|
||||
use super::{Game, boat_passengers::BOARDING_COOLDOWN};
|
||||
|
||||
/// `Entity.baseTick`: the cooldown counts down while positive.
|
||||
pub(super) fn tick_down(cooldown: i32) -> i32 {
|
||||
(cooldown - 1).max(0)
|
||||
}
|
||||
|
||||
/// `Entity.removePassenger`: the passenger that left sets its cooldown to 60.
|
||||
/// `was` and `now` are its vehicle before and after.
|
||||
pub(super) fn after_leaving(cooldown: i32, was: Option<u32>, now: Option<u32>) -> i32 {
|
||||
if was.is_some() && now.is_none() {
|
||||
BOARDING_COOLDOWN
|
||||
} else {
|
||||
cooldown
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Entity.canRide` for the player acting now (the host's, or the guest
|
||||
/// whose turn it is): the cooldown has elapsed, and the player did not
|
||||
/// leave a vehicle since the tick began (the tick that starts the
|
||||
/// cooldown has not run yet).
|
||||
pub(super) fn may_board(&self) -> bool {
|
||||
self.boarding_cooldown <= 0 && !(self.riding_at_tick.is_some() && self.riding.is_none())
|
||||
}
|
||||
/// The host's own cooldown, once per tick.
|
||||
pub(super) fn tick_boarding(&mut self) {
|
||||
self.boarding_cooldown = tick_down(self.boarding_cooldown);
|
||||
self.boarding_cooldown =
|
||||
after_leaving(self.boarding_cooldown, self.riding_at_tick, self.riding);
|
||||
self.riding_at_tick = self.riding;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn leaving_sets_sixty_and_boarding_waits_it_out() {
|
||||
let mut cd = after_leaving(0, Some(7), None);
|
||||
assert_eq!(cd, 60);
|
||||
for _ in 0..59 {
|
||||
cd = tick_down(cd);
|
||||
assert!(cd > 0, "still refused");
|
||||
}
|
||||
cd = tick_down(cd);
|
||||
assert_eq!(cd, 0, "allowed on the sixtieth tick");
|
||||
}
|
||||
#[test]
|
||||
fn staying_or_boarding_does_not_set_it() {
|
||||
assert_eq!(after_leaving(0, Some(7), Some(7)), 0);
|
||||
assert_eq!(after_leaving(0, None, Some(7)), 0);
|
||||
assert_eq!(after_leaving(0, None, None), 0);
|
||||
}
|
||||
#[test]
|
||||
fn never_negative() {
|
||||
assert_eq!(tick_down(0), 0);
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,8 @@ pub(super) enum Status {
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct Boat {
|
||||
/// `Entity.xRot`: kept for `/tp` and `Rotation`; the kind turns only in yaw.
|
||||
pub pitch: f32,
|
||||
/// When it joined its list (`horizon_client::join_seq`); set by the list.
|
||||
pub(super) join: horizon_client::join_seq::JoinSeq,
|
||||
pub id: u32,
|
||||
@@ -157,6 +159,7 @@ impl Boat {
|
||||
|
||||
pub fn new(id: u32, wood: Arc<str>, position: Vec3, yaw: f32) -> Self {
|
||||
Self {
|
||||
pitch: 0.0,
|
||||
join: Default::default(),
|
||||
id,
|
||||
wood,
|
||||
@@ -1375,6 +1378,7 @@ impl Game<'_> {
|
||||
if sneaking
|
||||
|| self.boats[i].out_of_control >= 60.0
|
||||
|| self.riding.is_some()
|
||||
|| !self.may_board()
|
||||
|| self.ridden_by_guest(self.boats[i].id)
|
||||
|| !self.boat_has_room(i)
|
||||
{
|
||||
|
||||
@@ -1511,6 +1511,7 @@ impl Game<'_> {
|
||||
let _ = self.in_target_level(t, |g| g.teleport_to_in_level(t, pos, rotation));
|
||||
}
|
||||
fn teleport_to_in_level(&mut self, t: Target, pos: [f64; 3], rotation: Option<[f32; 2]>) {
|
||||
use super::entity::Entity;
|
||||
let from = self.target_position(t);
|
||||
let v = Vec3::new(pos[0], pos[1], pos[2]);
|
||||
// PositionMoveRotation.calculateAbsolute clamps pitch before setXRot.
|
||||
@@ -1590,26 +1591,35 @@ impl Game<'_> {
|
||||
if let Some(i) = self.dropped.iter_mut().find(|i| i.id == id) {
|
||||
i.position = v;
|
||||
i.previous = v;
|
||||
if let Some(r) = rotation {
|
||||
Entity::set_rotation(i, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
Target::Orb(id) => {
|
||||
if let Some(o) = self.orbs.iter_mut().find(|o| o.id == id) {
|
||||
o.position = v;
|
||||
o.previous = v;
|
||||
if let Some(r) = rotation {
|
||||
Entity::set_rotation(o, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
Target::Projectile(id) => {
|
||||
if let Some(a) = self.creatures.projectiles.iter_mut().find(|a| a.id == id) {
|
||||
a.position = v;
|
||||
a.previous = v;
|
||||
if let Some(r) = rotation {
|
||||
Entity::set_rotation(a, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
Target::Stand(id) => {
|
||||
if let Some(a) = self.armor_stands.iter_mut().find(|a| a.id == id) {
|
||||
a.position = v;
|
||||
a.previous = v;
|
||||
if let Some([yaw, _]) = rotation.filter(|r| r[0].is_finite()) {
|
||||
a.yaw = yaw;
|
||||
if let Some(r) = rotation {
|
||||
Entity::set_rotation(a, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1617,8 +1627,8 @@ impl Game<'_> {
|
||||
if let Some(b) = self.boats.iter_mut().find(|b| b.id == id) {
|
||||
b.position = v;
|
||||
b.previous = v;
|
||||
if let Some([yaw, _]) = rotation.filter(|r| r[0].is_finite()) {
|
||||
b.yaw = yaw;
|
||||
if let Some(r) = rotation {
|
||||
Entity::set_rotation(b, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1626,8 +1636,8 @@ impl Game<'_> {
|
||||
if let Some(c) = self.carts.iter_mut().find(|c| c.id == id) {
|
||||
c.position = v;
|
||||
c.previous = v;
|
||||
if let Some([yaw, _]) = rotation.filter(|r| r[0].is_finite()) {
|
||||
c.yaw = yaw;
|
||||
if let Some(r) = rotation {
|
||||
Entity::set_rotation(c, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1667,24 +1677,36 @@ impl Game<'_> {
|
||||
if let Some(t) = self.tnts.iter_mut().find(|t| t.id == id) {
|
||||
t.position = v;
|
||||
t.previous = v;
|
||||
if let Some(r) = rotation {
|
||||
Entity::set_rotation(t, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
Target::Falling(id) => {
|
||||
if let Some(f) = self.falling.iter_mut().find(|f| f.id == id) {
|
||||
f.position = v;
|
||||
f.previous = v;
|
||||
if let Some(r) = rotation {
|
||||
Entity::set_rotation(f, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
Target::Rocket(id) => {
|
||||
if let Some(r) = self.rockets.iter_mut().find(|r| r.id == id) {
|
||||
r.position = v;
|
||||
r.previous = v;
|
||||
if let Some(rot) = rotation {
|
||||
Entity::set_rotation(r, rot);
|
||||
}
|
||||
}
|
||||
}
|
||||
Target::Trident(id) => {
|
||||
if let Some(t) = self.tridents.iter_mut().find(|t| t.id == id) {
|
||||
t.position = v;
|
||||
t.previous = v;
|
||||
if let Some(r) = rotation {
|
||||
Entity::set_rotation(t, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
Target::Spawner(id) => {
|
||||
@@ -1700,6 +1722,10 @@ impl Game<'_> {
|
||||
// Wall-mounted entities do not move.
|
||||
Target::Frame(_) | Target::Painting(_) => {}
|
||||
}
|
||||
// Entity.teleportPassengers: the riders go with their vehicle at once.
|
||||
if let (Some(from), Some(to)) = (from, self.target_position(t)) {
|
||||
self.teleport_passengers(t, from, to);
|
||||
}
|
||||
if self.options.dedicated.is_some()
|
||||
&& let (Some(entity), Some(from), Some(to)) =
|
||||
(self.network_entity_id(t), from, self.target_position(t))
|
||||
@@ -2337,6 +2363,7 @@ impl Game<'_> {
|
||||
let at = self.unstuck_orb(pos);
|
||||
self.next_entity += 1;
|
||||
self.orbs.push(horizon_client::entities::ExperienceOrb {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: self.next_entity,
|
||||
|
||||
@@ -137,7 +137,7 @@ impl Game<'_> {
|
||||
&b.entity_type(),
|
||||
b.position,
|
||||
b.velocity,
|
||||
b.yaw,
|
||||
[b.yaw, b.pitch],
|
||||
uuid,
|
||||
self.persist.raw.get(&id),
|
||||
b.chest.then_some(&b.items[..]),
|
||||
@@ -206,6 +206,7 @@ impl Game<'_> {
|
||||
t.fuse,
|
||||
t.power,
|
||||
t.block_state_nbt(self.pack.assets.registry),
|
||||
t.rotation,
|
||||
uuid,
|
||||
self.persist.raw.get(&id),
|
||||
)
|
||||
@@ -224,6 +225,7 @@ impl Game<'_> {
|
||||
f.drop_item,
|
||||
f.damage,
|
||||
f.fall_distance,
|
||||
f.rotation,
|
||||
uuid,
|
||||
);
|
||||
crate::game::persist::with_raw(tag, raw)
|
||||
|
||||
@@ -424,7 +424,8 @@ impl Game<'_> {
|
||||
look_at_target = Some((anchored_eyes, to, faced_entity));
|
||||
look_at(from, to)
|
||||
};
|
||||
self.teleport_to(t, pos, Some(rot));
|
||||
// RotateCommand: Entity.forceSetRotation, not a teleport.
|
||||
self.set_rotation(t, rot);
|
||||
if let Some((eyes, to, entity)) = look_at_target {
|
||||
self.send_player_look_at(t, eyes, to, entity);
|
||||
}
|
||||
@@ -867,8 +868,24 @@ impl Game<'_> {
|
||||
let Some(Arg::Resource(attribute)) = ctx.get("attribute").cloned() else {
|
||||
return Ok(0);
|
||||
};
|
||||
// An unbounded selector reaches every loaded level, so the target may
|
||||
// be in another one than the command's (EntitySelector.findEntities,
|
||||
// AttributeCommand acting on the entity itself).
|
||||
self.in_target_level(target, |g| {
|
||||
g.attribute_in_level(id, ctx, target, &attribute)
|
||||
})
|
||||
.unwrap_or(Ok(0))
|
||||
}
|
||||
/// `/attribute` on a target, with the target's own level in place.
|
||||
fn attribute_in_level(
|
||||
&mut self,
|
||||
id: &str,
|
||||
ctx: &Context,
|
||||
target: Target,
|
||||
attribute: &str,
|
||||
) -> R<i32> {
|
||||
let name = self.target_name(target);
|
||||
let description = Self::attribute_description(&attribute);
|
||||
let description = Self::attribute_description(attribute);
|
||||
let short = attribute.trim_start_matches("minecraft:").to_owned();
|
||||
let no_attribute = |name: &Text, d: &Text| {
|
||||
text_error(
|
||||
@@ -893,7 +910,7 @@ impl Game<'_> {
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
return Ok(0);
|
||||
};
|
||||
let Some(key) = player.attributes.key(&attribute) else {
|
||||
let Some(key) = player.attributes.key(attribute) else {
|
||||
return Err(no_attribute(&name, &description));
|
||||
};
|
||||
let unavailable = |_| no_attribute(&name, &description);
|
||||
@@ -981,7 +998,7 @@ impl Game<'_> {
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "add_value".into());
|
||||
player
|
||||
.set_modifier(&attribute, &modifier_id, value_arg, &op, true)
|
||||
.set_modifier(attribute, &modifier_id, value_arg, &op, true)
|
||||
.map_err(|_| no_attribute(&name, &description))?;
|
||||
self.feedback(Text::translate(
|
||||
"commands.attribute.modifier.add.success",
|
||||
@@ -1005,7 +1022,7 @@ impl Game<'_> {
|
||||
));
|
||||
}
|
||||
let _ =
|
||||
player.set_modifier(&attribute, &modifier_id, 0.0, "add_value", false);
|
||||
player.set_modifier(attribute, &modifier_id, 0.0, "add_value", false);
|
||||
self.feedback(Text::translate(
|
||||
"commands.attribute.modifier.remove.success",
|
||||
&[Text::literal(modifier_id), description, name],
|
||||
@@ -1063,7 +1080,7 @@ impl Game<'_> {
|
||||
self.generic_attribute(
|
||||
target,
|
||||
id,
|
||||
&attribute,
|
||||
attribute,
|
||||
(&name, &description),
|
||||
(value_arg, scale, &modifier_id, &op),
|
||||
)
|
||||
|
||||
@@ -190,7 +190,9 @@ impl Game<'_> {
|
||||
} else {
|
||||
"minecraft:block.composter.fill"
|
||||
};
|
||||
self.audio.play(sound, Some(center), 1.0, 1.0);
|
||||
// `handleFill` is `playLocalSound`: the client that receives event 1500
|
||||
// plays it. Capturing it here would also send a sound packet.
|
||||
self.audio.play_unshared(sound, Some(center), 1.0, 1.0);
|
||||
// getShape(..).max(Y, 0.5, 0.5): the compost surface (SHAPES[8] = SHAPES[7]).
|
||||
let level = self.composter_level([pos.x, pos.y, pos.z]).unwrap_or(0);
|
||||
let height = f64::from((1 + level.min(7) * 2).clamp(2, 16)) / 16.0 + 0.03125;
|
||||
|
||||
@@ -184,7 +184,13 @@ impl Game<'_> {
|
||||
}
|
||||
// The position packet moves the old position along (absolute xyz).
|
||||
self.previous_position = at;
|
||||
self.portal_particles();
|
||||
// `broadcastEntityEvent(46)` after the teleport. A server-thread window
|
||||
// is a remote client, so the packet is what draws the particles there.
|
||||
if let Some(id) = self.lan_acting_entity {
|
||||
self.lan_entity_event(id as i32, 46, [at.x, at.y, at.z]);
|
||||
} else {
|
||||
self.portal_particles();
|
||||
}
|
||||
true
|
||||
}
|
||||
fn state_in_tag(&self, tag: &str, state: horizon_world::registry::BlockStateId) -> bool {
|
||||
@@ -195,27 +201,70 @@ impl Game<'_> {
|
||||
.get(tag)
|
||||
.is_some_and(|s| s.contains(&**name))
|
||||
}
|
||||
/// `LivingEntity.handleEntityEvent(46)`: 128 portal particles from the old
|
||||
/// to the new position, from the client player's random (the particle
|
||||
/// engine's here).
|
||||
/// `LivingEntity.handleEntityEvent(46)` for this client: 128 portal
|
||||
/// particles from the old position to the new one. The local player's
|
||||
/// random is the particle engine's.
|
||||
fn portal_particles(&mut self) {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
let (from, to, width, height) = {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
(
|
||||
[
|
||||
self.previous_position.x,
|
||||
self.previous_position.y,
|
||||
self.previous_position.z,
|
||||
],
|
||||
[p.position.x, p.position.y, p.position.z],
|
||||
f64::from(p.dimensions.width),
|
||||
f64::from(p.dimensions.height),
|
||||
)
|
||||
};
|
||||
let (from, to) = (self.previous_position, p.position);
|
||||
let (width, height) = (
|
||||
f64::from(p.dimensions.width),
|
||||
f64::from(p.dimensions.height),
|
||||
self.emit_living_teleport(from, to, width, height);
|
||||
}
|
||||
/// `LivingEntity.handleEntityEvent(46)` for the entity the packet names:
|
||||
/// this player, or a mob the client is drawing.
|
||||
pub(super) fn entity_teleport_particles(&mut self, id: i32) {
|
||||
if self.remote.as_ref().is_some_and(|r| r.entity_id == id) {
|
||||
self.portal_particles();
|
||||
return;
|
||||
}
|
||||
let spawned = {
|
||||
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) else {
|
||||
return;
|
||||
};
|
||||
let from = [m.previous.x, m.previous.y, m.previous.z];
|
||||
let to = [m.position.x, m.position.y, m.position.z];
|
||||
let (width, height) = (f64::from(m.width()), f64::from(m.height()));
|
||||
let mut spawned = Vec::with_capacity(128);
|
||||
horizon_client::particles::living_teleport_particles(
|
||||
&mut m.random,
|
||||
from,
|
||||
to,
|
||||
width,
|
||||
height,
|
||||
|p, v| spawned.push((p, v)),
|
||||
);
|
||||
spawned
|
||||
};
|
||||
let options = ParticleOptions::simple(ParticleType::Portal);
|
||||
for (p, v) in spawned {
|
||||
self.particles.add(&options, p, v);
|
||||
}
|
||||
}
|
||||
fn emit_living_teleport(&mut self, from: [f64; 3], to: [f64; 3], width: f64, height: f64) {
|
||||
let mut spawned = Vec::with_capacity(128);
|
||||
horizon_client::particles::living_teleport_particles(
|
||||
self.particles.random(),
|
||||
from,
|
||||
to,
|
||||
width,
|
||||
height,
|
||||
|p, v| spawned.push((p, v)),
|
||||
);
|
||||
let options = ParticleOptions::simple(ParticleType::Portal);
|
||||
for i in 0..128 {
|
||||
let d = f64::from(i) / 127.0;
|
||||
let r = self.particles.random();
|
||||
let v = [0; 3].map(|_| f64::from((r.next_float() - 0.5) * 0.2));
|
||||
let x = from.x + d * (to.x - from.x) + (r.next_double() - 0.5) * width * 2.0;
|
||||
let y = from.y + d * (to.y - from.y) + r.next_double() * height;
|
||||
let z = from.z + d * (to.z - from.z) + (r.next_double() - 0.5) * width * 2.0;
|
||||
self.particles.add(&options, [x, y, z], v);
|
||||
for (p, v) in spawned {
|
||||
self.particles.add(&options, p, v);
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.stopRiding`.
|
||||
|
||||
@@ -34,6 +34,18 @@ use horizon_core::{
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// `AbstractArrow.onHitEntity` when `hurtOrSimulate` returned false.
|
||||
/// An enderman that teleported (`projectileReceivesSideEffectsOnHit` is
|
||||
/// false) leaves the arrow in flight. Every other refusal, including
|
||||
/// `Shulker.hurtServer` while `isClosed()`, is
|
||||
/// `deflect(ProjectileDeflection.REVERSE, entity, owner, false, 0.2)`.
|
||||
fn arrow_miss(dodged: bool) -> EntityHit {
|
||||
if dodged {
|
||||
EntityHit::Dodged
|
||||
} else {
|
||||
EntityHit::Refused
|
||||
}
|
||||
}
|
||||
/// The world impact sound after successful arrow damage, including self-shots.
|
||||
/// `AbstractArrow.onHitEntity`'s damage: the crit bonus comes from the
|
||||
/// arrow's own random.
|
||||
@@ -1020,7 +1032,7 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.map(super::boat_leads::boat_state)
|
||||
.collect();
|
||||
let base = self.mob_world_base(&[]);
|
||||
let mut base = self.mob_world_base(&[]);
|
||||
let path_changes = horizon_client::mobs::collision_changes(&base, &path_writes);
|
||||
// Each mob keeps its snapshot slot all tick; the first slot of an
|
||||
// id answers, as a scan would.
|
||||
@@ -1032,16 +1044,33 @@ impl Game<'_> {
|
||||
villager_slots.push(i as u32);
|
||||
}
|
||||
}
|
||||
let mut cached_difficulty: Option<((i32, i32), spawning::LocalDifficulty)> = None;
|
||||
for i in 0..mobs.len() {
|
||||
let mut w = MobWorld {
|
||||
others: &snapshot,
|
||||
others_grid: Some(&grid),
|
||||
others_index: Some(&index),
|
||||
others_villagers: Some(&villager_slots),
|
||||
..base.clone()
|
||||
};
|
||||
let m = &mut mobs[i];
|
||||
w.controlling_player_movement = horizon_client::mobs::equine::controlled(m)
|
||||
let (bx, bz) = (m.position.x.floor() as i32, m.position.z.floor() as i32);
|
||||
let seen_guest = if guests.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let bound = bindings.get(&m.id).copied();
|
||||
let conn = super::lan_mobs::player_for(m, base.player.as_ref(), &guests, bound);
|
||||
match conn {
|
||||
Some(conn) => {
|
||||
bindings.insert(m.id, conn);
|
||||
}
|
||||
None => {
|
||||
bindings.remove(&m.id);
|
||||
}
|
||||
}
|
||||
conn
|
||||
};
|
||||
if !base.world.loaded(bx, bz) {
|
||||
keep.push(!m.hostile() || m.persistent);
|
||||
continue;
|
||||
}
|
||||
let nearest_player = nearest
|
||||
.nearest(m.position.x, m.position.y, m.position.z)
|
||||
.map(|p| p.position);
|
||||
let controlling_player_movement = horizon_client::mobs::equine::controlled(m)
|
||||
.then(|| {
|
||||
base.player
|
||||
.as_ref()
|
||||
@@ -1055,40 +1084,29 @@ impl Game<'_> {
|
||||
})
|
||||
})
|
||||
.flatten();
|
||||
// getCurrentDifficultyAt(blockPosition()): the mob's own chunk,
|
||||
// not the local player's (a server thread has no player).
|
||||
w.difficulty = self.local_difficulty([m.position.x, m.position.y, m.position.z]);
|
||||
w.nearest_player = nearest
|
||||
.nearest(m.position.x, m.position.y, m.position.z)
|
||||
.map(|p| p.position);
|
||||
let seen_guest = if guests.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let bound = bindings.get(&m.id).copied();
|
||||
let conn = super::lan_mobs::player_for(m, w.player.as_ref(), &guests, bound);
|
||||
match conn {
|
||||
Some(conn) => {
|
||||
bindings.insert(m.id, conn);
|
||||
w.player = guests.iter().find(|g| g.0 == conn).map(|g| g.1.clone());
|
||||
}
|
||||
None => {
|
||||
bindings.remove(&m.id);
|
||||
}
|
||||
}
|
||||
conn
|
||||
};
|
||||
if !w
|
||||
.world
|
||||
.loaded(m.position.x.floor() as i32, m.position.z.floor() as i32)
|
||||
{
|
||||
keep.push(!m.hostile() || m.persistent);
|
||||
continue;
|
||||
}
|
||||
// ServerLevel.tick: every loaded entity checks despawning, but
|
||||
// only those in entity-ticking chunks (within the simulation
|
||||
// distance) tick.
|
||||
if !ticking(m.position.x.floor() as i32, m.position.z.floor() as i32) {
|
||||
if !ticking(bx, bz) {
|
||||
let active_player = match seen_guest {
|
||||
Some(conn) => guests.iter().find(|g| g.0 == conn).map(|g| g.1.clone()),
|
||||
None => base.player.take(),
|
||||
};
|
||||
let mut w = MobWorld {
|
||||
others: &snapshot,
|
||||
others_grid: Some(&grid),
|
||||
others_index: Some(&index),
|
||||
others_villagers: Some(&villager_slots),
|
||||
nearest_player,
|
||||
player: active_player,
|
||||
teams: std::sync::Arc::clone(&base.teams),
|
||||
..base
|
||||
};
|
||||
w.controlling_player_movement = controlling_player_movement;
|
||||
let kept = horizon_client::mobs::ai::despawn_check(m, &w);
|
||||
if seen_guest.is_none() {
|
||||
base.player = w.player;
|
||||
}
|
||||
if !kept {
|
||||
died.remove(&m.id);
|
||||
snapshot[i].alive = false;
|
||||
@@ -1101,10 +1119,41 @@ impl Game<'_> {
|
||||
keep.push(true);
|
||||
continue;
|
||||
}
|
||||
// getCurrentDifficultyAt(blockPosition()): the mob's own chunk,
|
||||
// not the local player's (a server thread has no player).
|
||||
let chunk_key = (bx >> 4, bz >> 4);
|
||||
let difficulty = match cached_difficulty {
|
||||
Some((key, difficulty)) if key == chunk_key => difficulty,
|
||||
_ => {
|
||||
let difficulty =
|
||||
self.local_difficulty([m.position.x, m.position.y, m.position.z]);
|
||||
cached_difficulty = Some((chunk_key, difficulty));
|
||||
difficulty
|
||||
}
|
||||
};
|
||||
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
|
||||
horizon_client::mobs::blocks_changed(m, &path_changes);
|
||||
let from = events.len();
|
||||
let active_player = match seen_guest {
|
||||
Some(conn) => guests.iter().find(|g| g.0 == conn).map(|g| g.1.clone()),
|
||||
None => base.player.take(),
|
||||
};
|
||||
let mut w = MobWorld {
|
||||
others: &snapshot,
|
||||
others_grid: Some(&grid),
|
||||
others_index: Some(&index),
|
||||
others_villagers: Some(&villager_slots),
|
||||
difficulty,
|
||||
nearest_player,
|
||||
player: active_player,
|
||||
teams: std::sync::Arc::clone(&base.teams),
|
||||
..base
|
||||
};
|
||||
w.controlling_player_movement = controlling_player_movement;
|
||||
let kept = m.tick(&w, &mut scratch, &mut events);
|
||||
if seen_guest.is_none() {
|
||||
base.player = w.player;
|
||||
}
|
||||
// A killed mob's body is removed: its effects that act on
|
||||
// death run (`LivingEntity.remove` with KILLED).
|
||||
if !kept && !m.alive() && !m.removed && !m.extra.effects.is_empty() {
|
||||
@@ -1188,6 +1237,7 @@ impl Game<'_> {
|
||||
self.lan_entity_event(id as i32, 60, at);
|
||||
self.death_poof(at, width, height);
|
||||
}
|
||||
self.tick_boarding();
|
||||
self.sync_riders();
|
||||
self.tick_skeleton_traps();
|
||||
self.tick_apparition();
|
||||
@@ -2062,7 +2112,7 @@ impl Game<'_> {
|
||||
MobEvent::DamagePlayer { amount, from, kind } => {
|
||||
self.mob_hits_player(amount, from, kind);
|
||||
}
|
||||
MobEvent::ElderCurse { at } => self.elder_curse(at),
|
||||
MobEvent::ElderCurse { at, silent } => self.elder_curse(at, silent),
|
||||
MobEvent::SetVelocity { target, velocity } => {
|
||||
let v = Vec3::new(velocity[0], velocity[1], velocity[2]);
|
||||
match target {
|
||||
@@ -2925,8 +2975,12 @@ impl Game<'_> {
|
||||
}
|
||||
MobEvent::DigParticles { at } => self.warden_dig_particles(at),
|
||||
MobEvent::BlockBreakEffect { pos, block } => {
|
||||
// Turtle.aiStep broadcasts LevelEvent 2001, not the
|
||||
// particles themselves: the event carries the particles and
|
||||
// the sand's break sound, and reaches the clients the same
|
||||
// way a broken block's does.
|
||||
let state = self.world.block(block[0], block[1], block[2]);
|
||||
self.destroy_particles(pos, state);
|
||||
self.level_event_at(2001, pos, state.0 as i32);
|
||||
}
|
||||
MobEvent::Darkness { at, radius } => self.apply_darkness(at, radius),
|
||||
MobEvent::CreakingHurt {
|
||||
@@ -3471,15 +3525,16 @@ impl Game<'_> {
|
||||
/// `MobEffectUtil.addEffectToPlayersAround` for an elder guardian:
|
||||
/// mining fatigue III for five minutes unless a stronger or longer one
|
||||
/// is on, with the curse sound and the elder's apparition.
|
||||
fn elder_curse(&mut self, at: [f64; 3]) {
|
||||
fn elder_curse(&mut self, at: [f64; 3], silent: bool) {
|
||||
let mut cursed = Vec::new();
|
||||
self.for_each_player(|g| {
|
||||
if g.elder_curse_player(at) {
|
||||
if g.elder_curse_player(at, silent) {
|
||||
cursed.push(g.lan_acting);
|
||||
}
|
||||
});
|
||||
// A guest's client shows the apparition and plays the curse itself
|
||||
// (ClientboundGameEventPacket.GUARDIAN_ELDER_EFFECT).
|
||||
// (ClientboundGameEventPacket.GUARDIAN_ELDER_EFFECT), the sound only
|
||||
// for 1: `isSilent() ? 0.0F : 1.0F`.
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
for conn in cursed.into_iter().flatten() {
|
||||
host.hub.send_to(
|
||||
@@ -3487,7 +3542,7 @@ impl Game<'_> {
|
||||
&horizon_protocol::packets::play::Clientbound::GameEvent(
|
||||
horizon_protocol::packets::play::GameEvent {
|
||||
event: horizon_client::lan::hub::game_event::GUARDIAN_ELDER_EFFECT,
|
||||
param: 1.0,
|
||||
param: crate::mobs::guardian::elder_curse_param(silent),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -3495,7 +3550,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// The curse on the player in place; whether it took.
|
||||
fn elder_curse_player(&mut self, at: [f64; 3]) -> bool {
|
||||
fn elder_curse_player(&mut self, at: [f64; 3], silent: bool) -> bool {
|
||||
if self.creative || self.spectator() {
|
||||
return false;
|
||||
}
|
||||
@@ -3515,8 +3570,12 @@ impl Game<'_> {
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
self.give_player_effect("minecraft:mining_fatigue", 6000, 2);
|
||||
if self.lan_acting.is_none() {
|
||||
self.audio
|
||||
.play("minecraft:entity.elder_guardian.curse", Some(at), 1.0, 1.0);
|
||||
// ClientPacketListener.handleGameEvent: the apparition always, the
|
||||
// curse sound unless the elder is silent.
|
||||
if crate::mobs::guardian::elder_curse_param(silent) == 1.0 {
|
||||
self.audio
|
||||
.play("minecraft:entity.elder_guardian.curse", Some(at), 1.0, 1.0);
|
||||
}
|
||||
self.creatures.elder_apparition = super::mob_blocks::APPARITION_TICKS;
|
||||
}
|
||||
true
|
||||
@@ -3843,6 +3902,10 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
fn tick_projectiles(&mut self) -> Result<()> {
|
||||
if self.creatures.projectiles.is_empty() {
|
||||
self.flush_lan_explosions();
|
||||
return self.flush_wind_bursts();
|
||||
}
|
||||
let mut projectiles = std::mem::take(&mut self.creatures.projectiles);
|
||||
let mut hits = Vec::new();
|
||||
let mut deflections: Vec<[f64; 3]> = Vec::new();
|
||||
@@ -3865,7 +3928,7 @@ impl Game<'_> {
|
||||
.collect();
|
||||
// Drawn only when a breeze could deflect a projectile: an empty
|
||||
// level must not move the level random every tick.
|
||||
let jitter = if breezes.is_empty() || projectiles.is_empty() {
|
||||
let jitter = if breezes.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
self.world.random.next_float()
|
||||
@@ -3875,8 +3938,14 @@ impl Game<'_> {
|
||||
let scene = self.arrow_scene(&rain);
|
||||
// Mobs' projectiles also hit the guests (game/lan_mobs.rs).
|
||||
let guests = self.lan_projectile_targets();
|
||||
let mut targets_guests = targets.clone();
|
||||
targets_guests.extend(guests);
|
||||
let has_guests = !guests.is_empty();
|
||||
let targets_guests = if has_guests {
|
||||
let mut targets_guests = targets.clone();
|
||||
targets_guests.extend(guests);
|
||||
targets_guests
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let border = Some(self.border.bounds());
|
||||
let pw = ProjWorld {
|
||||
w: &w,
|
||||
@@ -3888,6 +3957,8 @@ impl Game<'_> {
|
||||
targets: targets_guests,
|
||||
border,
|
||||
};
|
||||
// Every guest, not only the ones a projectile can hit: a dead or
|
||||
// spectating guest's pearl or arrow still resolves to them.
|
||||
let guest_entities = self.lan_guest_entities();
|
||||
// ThrownEnderpearl.tick: a dead player's pearl is discarded in
|
||||
// place of its tick while ender_pearls_vanish_on_death is on.
|
||||
@@ -3935,7 +4006,8 @@ impl Game<'_> {
|
||||
if shooter.is_some() {
|
||||
p.owner = shooter;
|
||||
}
|
||||
let (keep, hit) = match (p.arrow.is_some(), mob_shot || pvp, winds.is_empty()) {
|
||||
let use_guests = (mob_shot || pvp) && has_guests;
|
||||
let (keep, hit) = match (p.arrow.is_some(), use_guests, winds.is_empty()) {
|
||||
(true, false, true) => horizon_client::arrows::tick(p, &scene, &pw.targets),
|
||||
(true, true, true) => {
|
||||
horizon_client::arrows::tick(p, &scene, &pw_guests.targets)
|
||||
@@ -4718,6 +4790,13 @@ impl Game<'_> {
|
||||
let mut arrow = p.clone();
|
||||
let amount = arrow_damage(&mut arrow, &mut self.world.random) + p.bonus.damage;
|
||||
let player_fire = self.player.as_ref().map(|pl| pl.fire_ticks);
|
||||
// AbstractArrow.onHitEntity saves the fire, ignites, then puts
|
||||
// it back when hurtOrSimulate fails and the target still takes
|
||||
// a projectile's side effects.
|
||||
let mob_fire = match who {
|
||||
Who::Mob(id) => self.mobs.iter().find(|m| m.id == id).map(|m| m.fire_ticks),
|
||||
Who::Player => None,
|
||||
};
|
||||
let mut dodged = false;
|
||||
// AbstractArrow.onHitEntity: a burning arrow sets its target alight.
|
||||
if p.arrow.as_ref().is_some_and(|a| a.on_fire()) {
|
||||
@@ -4872,10 +4951,22 @@ impl Game<'_> {
|
||||
}
|
||||
// EnderMan.hurtServer teleported away: the arrow flies on.
|
||||
(false, Who::Mob(_)) if dodged => {
|
||||
self.finish_arrow_hit(arrow, EntityHit::Dodged);
|
||||
self.finish_arrow_hit(arrow, arrow_miss(true));
|
||||
}
|
||||
// hurtOrSimulate returned false and
|
||||
// projectileReceivesSideEffectsOnHit(false) is the default
|
||||
// (Shulker.hurtServer on a closed shell, invulnerability).
|
||||
// The fire the arrow just set goes back, then
|
||||
// deflect(REVERSE, entity, owner, false, 0.2). The hit
|
||||
// sound plays only when the hurt lands.
|
||||
(false, Who::Mob(id)) => {
|
||||
if let (Some(m), Some(ticks)) =
|
||||
(self.mobs.iter_mut().find(|m| m.id == id), mob_fire)
|
||||
{
|
||||
m.fire_ticks = ticks;
|
||||
}
|
||||
self.finish_arrow_hit(arrow, arrow_miss(false));
|
||||
}
|
||||
// Mobs' other refusals don't follow hurtServer yet: the arrow is gone.
|
||||
(false, Who::Mob(_)) => {}
|
||||
}
|
||||
}
|
||||
// AbstractArrow.onHitBlock plays its sound itself (ArrowEvent).
|
||||
@@ -5042,6 +5133,8 @@ impl Game<'_> {
|
||||
let from = pl.position;
|
||||
self.spawn_mob("minecraft:endermite", [from.x, from.y, from.z], false);
|
||||
}
|
||||
// ServerPlayer.teleport: removeVehicle before the placement.
|
||||
self.stop_riding();
|
||||
if let Some(pl) = self.player.as_mut() {
|
||||
pl.position = to;
|
||||
pl.velocity = Vec3::ZERO;
|
||||
@@ -7782,6 +7875,53 @@ fn breeding_credit(
|
||||
from_parent.or(from_partner)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod shulker_arrow_tests {
|
||||
use super::*;
|
||||
use horizon_client::arrows::{ArrowEvent, deflect_reverse};
|
||||
use horizon_core::random::LegacyRandom;
|
||||
|
||||
/// `AbstractArrow.onHitEntity` after `hurtOrSimulate` fails and the target
|
||||
/// still takes a projectile's side effects: `deflect(REVERSE, …, 0.2)`,
|
||||
/// and no hit sound. A closed shulker's refusal is that miss
|
||||
/// (`shulker::tests`). An enderman's teleport is `Dodged`, not this.
|
||||
#[test]
|
||||
fn a_refused_arrow_reverses_without_a_hit_sound() {
|
||||
assert_eq!(arrow_miss(false), EntityHit::Refused);
|
||||
assert_eq!(arrow_miss(true), EntityHit::Dodged);
|
||||
|
||||
let mut arrow = Projectile::new(
|
||||
ProjectileKind::Arrow {
|
||||
base_damage: 2.0,
|
||||
critical: false,
|
||||
effect: None,
|
||||
},
|
||||
Vec3::new(0.0, 1.0, 0.0),
|
||||
Some(Who::Player),
|
||||
)
|
||||
.with_arrow_random(LegacyRandom::new(7));
|
||||
arrow.velocity = Vec3::new(3.0, -1.0, 2.0);
|
||||
arrow.yaw = 15.0;
|
||||
let mut oracle = LegacyRandom::new(7);
|
||||
let expect_yaw = 15.0 + 170.0 + oracle.next_float() * 20.0;
|
||||
let mut state = arrow.arrow.take().expect("an arrow");
|
||||
deflect_reverse(&mut arrow.velocity, &mut arrow.yaw, &mut state);
|
||||
// power 0.2, and REVERSE multiplies by -power * 0.5.
|
||||
assert!((arrow.velocity.x - -0.3).abs() < 1e-6);
|
||||
assert!((arrow.velocity.y - 0.1).abs() < 1e-6);
|
||||
assert!((arrow.velocity.z - -0.2).abs() < 1e-6);
|
||||
assert!((arrow.yaw - expect_yaw).abs() < 1e-4);
|
||||
assert!(!state.removed, "the reversed arrow stays in the world");
|
||||
assert!(
|
||||
state
|
||||
.events
|
||||
.iter()
|
||||
.all(|e| !matches!(e, ArrowEvent::Sound { .. })),
|
||||
"the deflect branch plays no hit sound"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod breeding_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -338,7 +338,7 @@ impl Game<'_> {
|
||||
}
|
||||
// `Entity.startRiding`: a passenger first gets off what it rides
|
||||
// (`stopRiding`), so a seated player moves to this cushion.
|
||||
if !self.native_may_board(id) {
|
||||
if !self.may_board() || !self.native_may_board(id) {
|
||||
return true;
|
||||
}
|
||||
self.stop_riding();
|
||||
|
||||
@@ -3203,7 +3203,7 @@ impl Dragon {
|
||||
"minecraft:ender_dragon",
|
||||
self.position,
|
||||
self.velocity,
|
||||
self.yaw,
|
||||
[self.yaw, 0.0],
|
||||
uuid,
|
||||
raw,
|
||||
);
|
||||
@@ -3240,7 +3240,7 @@ impl Crystal {
|
||||
"minecraft:end_crystal",
|
||||
self.position,
|
||||
Vec3::ZERO,
|
||||
0.0,
|
||||
[0.0, 0.0],
|
||||
uuid,
|
||||
raw,
|
||||
);
|
||||
|
||||
@@ -62,6 +62,12 @@ pub(super) trait Entity: EntityRules {
|
||||
/// `Entity.getDeltaMovement`.
|
||||
fn velocity(&self) -> Vec3;
|
||||
fn velocity_mut(&mut self) -> &mut Vec3;
|
||||
/// `Entity.getYRot` and `getXRot`: yaw, pitch.
|
||||
#[allow(dead_code)] // read by the entity-interface slices that follow
|
||||
fn rotation(&self) -> [f32; 2];
|
||||
/// The rotation part of `Entity.teleportSetPosition`: `setYRot`, `setXRot`
|
||||
/// (clamped to -90..90), then `setOldRot`.
|
||||
fn set_rotation(&mut self, rotation: [f32; 2]);
|
||||
fn bounds(&self) -> Aabb;
|
||||
/// What a selector finds this entity as (`@e`), or `None` for one it cannot
|
||||
/// name yet. Required, so a new kind states which.
|
||||
@@ -306,6 +312,7 @@ pub(super) fn tick_item(
|
||||
no_physics: bool,
|
||||
entity_boxes: &[Aabb],
|
||||
border: Option<&horizon_core::geometry::border::BorderGeometry>,
|
||||
blocks: Option<&horizon_world::movement_properties::BoundMovementProperties>,
|
||||
splash: &mut impl FnMut(horizon_client::entities::ItemSplash),
|
||||
) -> bool {
|
||||
let (water, _lava) = item.push_by_fluid(world, fluids);
|
||||
@@ -319,6 +326,7 @@ pub(super) fn tick_item(
|
||||
(water, _lava),
|
||||
entity_boxes,
|
||||
border,
|
||||
blocks,
|
||||
);
|
||||
item.push_by_bubble_column(world, registry, facts, collision);
|
||||
let alive = item.tick_settle(world, facts, no_physics, step);
|
||||
@@ -688,6 +696,20 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
impl Entity for Mob {
|
||||
fn rotation(&self) -> [f32; 2] {
|
||||
[self.yaw, self.head_pitch]
|
||||
}
|
||||
fn set_rotation(&mut self, [yaw, pitch]: [f32; 2]) {
|
||||
let previous = self.rotation();
|
||||
let yaw = if yaw.is_finite() { yaw } else { previous[0] };
|
||||
let pitch = if pitch.is_nan() { previous[1] } else { pitch };
|
||||
self.yaw = yaw;
|
||||
self.body_yaw = yaw;
|
||||
self.head_yaw = yaw;
|
||||
self.head_pitch = pitch.clamp(-90.0, 90.0);
|
||||
self.previous_yaw = self.yaw;
|
||||
self.previous_head_pitch = self.head_pitch;
|
||||
}
|
||||
fn native_type(&self) -> Cow<'static, str> {
|
||||
super::native_hooks::mob_kind(self).into()
|
||||
}
|
||||
@@ -786,6 +808,15 @@ impl Entity for Mob {
|
||||
}
|
||||
|
||||
impl Entity for ItemEntity {
|
||||
fn rotation(&self) -> [f32; 2] {
|
||||
self.rotation
|
||||
}
|
||||
fn set_rotation(&mut self, [yaw, pitch]: [f32; 2]) {
|
||||
let previous = self.rotation();
|
||||
let yaw = if yaw.is_finite() { yaw } else { previous[0] };
|
||||
let pitch = if pitch.is_nan() { previous[1] } else { pitch };
|
||||
self.rotation = [yaw, pitch.clamp(-90.0, 90.0)];
|
||||
}
|
||||
fn fluid_eye_y(&self) -> f64 {
|
||||
ItemEntity::fluid_eye_y(self)
|
||||
}
|
||||
@@ -847,6 +878,15 @@ impl Entity for ItemEntity {
|
||||
}
|
||||
|
||||
impl Entity for ExperienceOrb {
|
||||
fn rotation(&self) -> [f32; 2] {
|
||||
self.rotation
|
||||
}
|
||||
fn set_rotation(&mut self, [yaw, pitch]: [f32; 2]) {
|
||||
let previous = self.rotation();
|
||||
let yaw = if yaw.is_finite() { yaw } else { previous[0] };
|
||||
let pitch = if pitch.is_nan() { previous[1] } else { pitch };
|
||||
self.rotation = [yaw, pitch.clamp(-90.0, 90.0)];
|
||||
}
|
||||
fn ticks_after_projectiles(&self) -> bool {
|
||||
true
|
||||
}
|
||||
@@ -905,6 +945,16 @@ impl Entity for ExperienceOrb {
|
||||
}
|
||||
|
||||
impl Entity for Boat {
|
||||
fn rotation(&self) -> [f32; 2] {
|
||||
[self.yaw, self.pitch]
|
||||
}
|
||||
fn set_rotation(&mut self, [yaw, pitch]: [f32; 2]) {
|
||||
let previous = self.rotation();
|
||||
let yaw = if yaw.is_finite() { yaw } else { previous[0] };
|
||||
let pitch = if pitch.is_nan() { previous[1] } else { pitch };
|
||||
self.yaw = yaw;
|
||||
self.pitch = pitch.clamp(-90.0, 90.0);
|
||||
}
|
||||
fn native_type(&self) -> Cow<'static, str> {
|
||||
self.entity_type().into()
|
||||
}
|
||||
@@ -966,6 +1016,16 @@ impl Entity for Boat {
|
||||
}
|
||||
|
||||
impl Entity for Minecart {
|
||||
fn rotation(&self) -> [f32; 2] {
|
||||
[self.yaw, self.pitch]
|
||||
}
|
||||
fn set_rotation(&mut self, [yaw, pitch]: [f32; 2]) {
|
||||
let previous = self.rotation();
|
||||
let yaw = if yaw.is_finite() { yaw } else { previous[0] };
|
||||
let pitch = if pitch.is_nan() { previous[1] } else { pitch };
|
||||
self.yaw = yaw;
|
||||
self.pitch = pitch.clamp(-90.0, 90.0);
|
||||
}
|
||||
fn native_type(&self) -> Cow<'static, str> {
|
||||
self.kind.id().into()
|
||||
}
|
||||
@@ -1035,6 +1095,17 @@ fn armor_stand_size(stand: &ArmorStand) -> (f64, f64) {
|
||||
}
|
||||
|
||||
impl Entity for ArmorStand {
|
||||
fn rotation(&self) -> [f32; 2] {
|
||||
[self.yaw, self.pitch]
|
||||
}
|
||||
fn set_rotation(&mut self, [yaw, pitch]: [f32; 2]) {
|
||||
let previous = self.rotation();
|
||||
let yaw = if yaw.is_finite() { yaw } else { previous[0] };
|
||||
let pitch = if pitch.is_nan() { previous[1] } else { pitch };
|
||||
self.yaw = yaw;
|
||||
self.previous_yaw = yaw;
|
||||
self.pitch = pitch.clamp(-90.0, 90.0);
|
||||
}
|
||||
fn fluid_eye_y(&self) -> f64 {
|
||||
ArmorStand::fluid_eye_y(self)
|
||||
}
|
||||
@@ -1126,6 +1197,15 @@ impl Entity for ArmorStand {
|
||||
}
|
||||
|
||||
impl Entity for PrimedTnt {
|
||||
fn rotation(&self) -> [f32; 2] {
|
||||
self.rotation
|
||||
}
|
||||
fn set_rotation(&mut self, [yaw, pitch]: [f32; 2]) {
|
||||
let previous = self.rotation();
|
||||
let yaw = if yaw.is_finite() { yaw } else { previous[0] };
|
||||
let pitch = if pitch.is_nan() { previous[1] } else { pitch };
|
||||
self.rotation = [yaw, pitch.clamp(-90.0, 90.0)];
|
||||
}
|
||||
fn ticks_after_projectiles(&self) -> bool {
|
||||
true
|
||||
}
|
||||
@@ -1186,6 +1266,15 @@ impl Entity for PrimedTnt {
|
||||
}
|
||||
|
||||
impl Entity for FallingBlock {
|
||||
fn rotation(&self) -> [f32; 2] {
|
||||
self.rotation
|
||||
}
|
||||
fn set_rotation(&mut self, [yaw, pitch]: [f32; 2]) {
|
||||
let previous = self.rotation();
|
||||
let yaw = if yaw.is_finite() { yaw } else { previous[0] };
|
||||
let pitch = if pitch.is_nan() { previous[1] } else { pitch };
|
||||
self.rotation = [yaw, pitch.clamp(-90.0, 90.0)];
|
||||
}
|
||||
fn ticks_after_projectiles(&self) -> bool {
|
||||
true
|
||||
}
|
||||
@@ -1222,6 +1311,18 @@ impl Entity for FallingBlock {
|
||||
}
|
||||
|
||||
impl Entity for Projectile {
|
||||
fn rotation(&self) -> [f32; 2] {
|
||||
[self.yaw, self.pitch]
|
||||
}
|
||||
fn set_rotation(&mut self, [yaw, pitch]: [f32; 2]) {
|
||||
let previous = self.rotation();
|
||||
let yaw = if yaw.is_finite() { yaw } else { previous[0] };
|
||||
let pitch = if pitch.is_nan() { previous[1] } else { pitch };
|
||||
self.yaw = yaw;
|
||||
self.pitch = pitch.clamp(-90.0, 90.0);
|
||||
self.previous_yaw = self.yaw;
|
||||
self.previous_pitch = self.pitch;
|
||||
}
|
||||
fn native_type(&self) -> Cow<'static, str> {
|
||||
self.kind.id().into()
|
||||
}
|
||||
@@ -1310,6 +1411,15 @@ impl Entity for Projectile {
|
||||
}
|
||||
|
||||
impl Entity for Rocket {
|
||||
fn rotation(&self) -> [f32; 2] {
|
||||
self.rotation
|
||||
}
|
||||
fn set_rotation(&mut self, [yaw, pitch]: [f32; 2]) {
|
||||
let previous = self.rotation();
|
||||
let yaw = if yaw.is_finite() { yaw } else { previous[0] };
|
||||
let pitch = if pitch.is_nan() { previous[1] } else { pitch };
|
||||
self.rotation = [yaw, pitch.clamp(-90.0, 90.0)];
|
||||
}
|
||||
fn native_type(&self) -> Cow<'static, str> {
|
||||
"minecraft:firework_rocket".into()
|
||||
}
|
||||
@@ -1358,6 +1468,16 @@ impl Entity for Rocket {
|
||||
}
|
||||
|
||||
impl Entity for ThrownTrident {
|
||||
fn rotation(&self) -> [f32; 2] {
|
||||
[self.yaw, self.pitch]
|
||||
}
|
||||
fn set_rotation(&mut self, [yaw, pitch]: [f32; 2]) {
|
||||
let previous = self.rotation();
|
||||
let yaw = if yaw.is_finite() { yaw } else { previous[0] };
|
||||
let pitch = if pitch.is_nan() { previous[1] } else { pitch };
|
||||
self.yaw = yaw;
|
||||
self.pitch = pitch.clamp(-90.0, 90.0);
|
||||
}
|
||||
fn native_type(&self) -> Cow<'static, str> {
|
||||
"minecraft:trident".into()
|
||||
}
|
||||
@@ -1668,3 +1788,34 @@ mod join_order_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod set_rotation_tests {
|
||||
use super::Entity as _;
|
||||
use horizon_client::entities::ExperienceOrb;
|
||||
|
||||
/// `Entity.teleportSetPosition`: yaw as given, pitch clamped to -90..90, on
|
||||
/// every kind (the ones with no field of their own used to drop it).
|
||||
#[test]
|
||||
fn set_rotation_keeps_yaw_and_clamps_pitch_on_kinds_that_had_none() {
|
||||
let mut orb = ExperienceOrb {
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: 0,
|
||||
position: horizon_core::geometry::Vec3::ZERO,
|
||||
previous: horizon_core::geometry::Vec3::ZERO,
|
||||
velocity: horizon_core::geometry::Vec3::ZERO,
|
||||
value: 1,
|
||||
age: 0,
|
||||
on_ground: false,
|
||||
count: 1,
|
||||
ticks: 0,
|
||||
health: 5,
|
||||
rotation: [0.0, 0.0],
|
||||
};
|
||||
orb.set_rotation([90.0, 10.0]);
|
||||
assert_eq!(orb.rotation(), [90.0, 10.0]);
|
||||
orb.set_rotation([0.0, 200.0]);
|
||||
assert_eq!(orb.rotation(), [0.0, 90.0]);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@ enum RocketHit {
|
||||
}
|
||||
|
||||
pub(super) struct Rocket {
|
||||
/// `Entity.yRot` and `xRot` (`/tp`, `/data get ... Rotation`).
|
||||
pub rotation: [f32; 2],
|
||||
/// When it joined its list (`horizon_client::join_seq`); set by the list.
|
||||
pub(super) join: horizon_client::join_seq::JoinSeq,
|
||||
/// Its entity id: from the entity counter on the server, the server's on
|
||||
@@ -64,6 +66,7 @@ impl Rocket {
|
||||
let item = ItemStack::new("minecraft:firework_rocket", 1);
|
||||
let (_, explosions) = fireworks_of(&item);
|
||||
Self {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
id,
|
||||
position,
|
||||
@@ -90,6 +93,7 @@ impl Rocket {
|
||||
) -> horizon_world::nbt::Tag {
|
||||
horizon_client::entity_nbt::firework_rocket_to_nbt(
|
||||
&horizon_client::entity_nbt::RocketSave {
|
||||
rotation: self.rotation,
|
||||
position: self.position,
|
||||
motion: self.velocity,
|
||||
life: self.life,
|
||||
@@ -114,6 +118,7 @@ impl Rocket {
|
||||
.unwrap_or_else(|| ItemStack::new("minecraft:firework_rocket", 1));
|
||||
let (_, explosions) = fireworks_of(&item);
|
||||
Self {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
id: 0,
|
||||
position: r.position,
|
||||
@@ -231,6 +236,7 @@ impl Game<'_> {
|
||||
// Entity.<init>: the id comes from the one counter at construction.
|
||||
let id = self.take_entity_id();
|
||||
Rocket {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
id,
|
||||
position: at,
|
||||
@@ -308,16 +314,19 @@ impl Game<'_> {
|
||||
/// hand with the player's motion.
|
||||
pub(super) fn follow_own_player(&mut self, r: &mut Rocket) {
|
||||
let hand = self.rocket_hand_angle();
|
||||
let look = view_vector(self.yaw, self.pitch);
|
||||
// getLookAngle reads Mth's float sine table, as the server does; the
|
||||
// f64 sin/cos of super::view_vector drift the boost off the server's
|
||||
// path and trip the movement check while gliding under a rocket.
|
||||
let look = horizon_world::view_direction::view_vector(self.pitch, self.yaw);
|
||||
let Some(p) = self.player.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if p.fall_flying {
|
||||
let m = p.velocity;
|
||||
p.velocity = Vec3::new(
|
||||
m.x + look[0] * 0.1 + (look[0] * 1.5 - m.x) * 0.5,
|
||||
m.y + look[1] * 0.1 + (look[1] * 1.5 - m.y) * 0.5,
|
||||
m.z + look[2] * 0.1 + (look[2] * 1.5 - m.z) * 0.5,
|
||||
m.x + look.x * 0.1 + (look.x * 1.5 - m.x) * 0.5,
|
||||
m.y + look.y * 0.1 + (look.y * 1.5 - m.y) * 0.5,
|
||||
m.z + look.z * 0.1 + (look.z * 1.5 - m.z) * 0.5,
|
||||
);
|
||||
}
|
||||
r.position = p.position.add_vector(Vec3::new(hand[0], hand[1], hand[2]));
|
||||
|
||||
@@ -126,6 +126,24 @@ fn pull_item(item: &mut horizon_client::entities::ItemEntity, delta: Vec3) {
|
||||
// cannot request the immediate motion update for tracking clients.
|
||||
item.needs_sync = true;
|
||||
}
|
||||
/// `FishingHook.pullEntity`: add a tenth of the vector from the hook to its owner.
|
||||
fn pull_hooked(velocity: Vec3, hook: Vec3, owner: Vec3) -> Vec3 {
|
||||
Vec3::new(
|
||||
velocity.x + (owner.x - hook.x) * 0.1,
|
||||
velocity.y + (owner.y - hook.y) * 0.1,
|
||||
velocity.z + (owner.z - hook.z) * 0.1,
|
||||
)
|
||||
}
|
||||
/// Minecarts a hook can catch. `AbstractMinecart.isPickable` is true while the
|
||||
/// cart exists, and `Projectile.canHitEntity` skips the vehicle the owner rides.
|
||||
fn minecart_hook_targets<'a>(
|
||||
carts: impl Iterator<Item = &'a super::minecarts::Minecart> + 'a,
|
||||
own_vehicle: Option<u32>,
|
||||
) -> impl Iterator<Item = (u32, Aabb)> + 'a {
|
||||
carts
|
||||
.filter(move |c| Some(c.id) != own_vehicle)
|
||||
.map(|c| (c.id, c.bounds()))
|
||||
}
|
||||
impl Hook {
|
||||
pub(super) fn bounds(&self) -> Aabb {
|
||||
bounds(self.position)
|
||||
@@ -272,7 +290,7 @@ impl Game<'_> {
|
||||
owner.y - hook.position.y,
|
||||
owner.z - hook.position.z,
|
||||
);
|
||||
let pulled = |v: Vec3| Vec3::new(v.x + d.x * 0.1, v.y + d.y * 0.1, v.z + d.z * 0.1);
|
||||
let pulled = |v: Vec3| pull_hooked(v, hook.position, owner);
|
||||
let mut hooked_item = false;
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.velocity = pulled(m.velocity);
|
||||
@@ -285,6 +303,9 @@ impl Game<'_> {
|
||||
a.velocity = pulled(a.velocity);
|
||||
} else if let Some(b) = self.boats.iter_mut().find(|b| b.id == id) {
|
||||
b.velocity = pulled(b.velocity);
|
||||
} else if let Some(c) = self.carts.iter_mut().find(|c| c.id == id) {
|
||||
// `AbstractMinecart` is pickable, so `pullEntity` moves it too.
|
||||
c.velocity = pulled(c.velocity);
|
||||
} else if self.creatures.hook_targets.iter().any(|t| t.id == id) {
|
||||
// A player: the hub moves them when the tick ends.
|
||||
self.creatures
|
||||
@@ -710,6 +731,7 @@ impl Game<'_> {
|
||||
.filter(|b| Some(b.id) != own_vehicle)
|
||||
.map(|b| (b.id, b.bounds())),
|
||||
)
|
||||
.chain(minecart_hook_targets(self.carts.iter(), own_vehicle))
|
||||
.chain(self.dropped.iter().map(|i| (i.id, i.bounds())))
|
||||
.chain(
|
||||
self.tnts
|
||||
@@ -772,6 +794,12 @@ impl Game<'_> {
|
||||
.find(|b| b.id == id)
|
||||
.map(|b| (b.position, super::boats::HEIGHT))
|
||||
})
|
||||
.or_else(|| {
|
||||
self.carts
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
.map(|c| (c.position, super::minecarts::HEIGHT))
|
||||
})
|
||||
.or_else(|| {
|
||||
self.dropped
|
||||
.iter()
|
||||
@@ -1360,6 +1388,12 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.find(|m| m.id == id && m.alive())
|
||||
.map(|m| (m.position, f64::from(m.height())))
|
||||
.or_else(|| {
|
||||
self.carts
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
.map(|c| (c.position, super::minecarts::HEIGHT))
|
||||
})
|
||||
.or_else(|| {
|
||||
self.dropped
|
||||
.iter()
|
||||
@@ -1514,6 +1548,7 @@ mod tests {
|
||||
#[test]
|
||||
fn reeling_an_item_requests_an_immediate_motion_update() {
|
||||
let mut item = ItemEntity {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
health: 5,
|
||||
@@ -1594,4 +1629,31 @@ mod tests {
|
||||
Some(1)
|
||||
);
|
||||
}
|
||||
|
||||
/// A minecart is pickable (`AbstractMinecart.isPickable`), so the hook
|
||||
/// meets it. The cart the owner rides is the same vehicle and is skipped.
|
||||
#[test]
|
||||
fn a_fishing_hook_meets_a_minecart_but_not_the_one_it_rides() {
|
||||
use super::super::minecarts::{CartKind, Minecart};
|
||||
let cart = Minecart::new(7, CartKind::Rideable, Vec3::new(0.6, 0.0, 0.0));
|
||||
let ridden = Minecart::new(3, CartKind::Chest, Vec3::new(0.2, 0.0, 0.0));
|
||||
let boxes = super::minecart_hook_targets([&cart, &ridden].into_iter(), Some(3));
|
||||
assert_eq!(
|
||||
first_entity_hit(Vec3::ZERO, Vec3::new(1.0, 0.0, 0.0), 0.0, boxes),
|
||||
Some(7)
|
||||
);
|
||||
}
|
||||
|
||||
/// `FishingHook.pullEntity` scales the hook-to-owner vector by 0.1.
|
||||
#[test]
|
||||
fn reeling_pulls_a_hooked_minecart_a_tenth_of_the_way() {
|
||||
// Still cart at the origin, owner 10 east and 2 up: +1 x and +0.2 y,
|
||||
// on top of the cart's own 0.5.
|
||||
let pulled = super::pull_hooked(
|
||||
Vec3::new(0.5, 0.0, 0.0),
|
||||
Vec3::ZERO,
|
||||
Vec3::new(10.0, 2.0, 0.0),
|
||||
);
|
||||
assert_eq!(pulled, Vec3::new(1.5, 0.2, 0.0));
|
||||
}
|
||||
}
|
||||
@@ -90,7 +90,7 @@ impl Game<'_> {
|
||||
if harnessed && !sneaking {
|
||||
// doPlayerRide.
|
||||
let id = self.mobs[index].id;
|
||||
if !self.native_may_board(id) {
|
||||
if !self.may_board() || !self.native_may_board(id) {
|
||||
return true;
|
||||
}
|
||||
self.break_leash(id, true, false);
|
||||
|
||||
@@ -69,6 +69,7 @@ mod tests {
|
||||
fn item_at(x: f64, y: f64, z: f64) -> ItemEntity {
|
||||
let p = Vec3::new(x, y, z);
|
||||
ItemEntity {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
health: 5,
|
||||
fire_ticks: 0,
|
||||
|
||||
@@ -2222,6 +2222,17 @@ impl Game<'_> {
|
||||
/// before the entities': what joined the level up to here (a falling
|
||||
/// block, a dispensed arrow, the last tick's late joins) is paired and
|
||||
/// first updated in this tick's pass (`Entities::synced`).
|
||||
/// `ServerChunkCache.tick`'s broadcast, where ServerLevel.tick runs it:
|
||||
/// after the scheduled and random ticks, before the block events, so the
|
||||
/// players get the blocks as they are then. The block log's length after
|
||||
/// it: what the tick writes from there on goes out with the next tick's.
|
||||
pub(super) fn lan_broadcast_changes(&mut self) -> Option<usize> {
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
host.hub.broadcast_changes(&mut self.world);
|
||||
}
|
||||
self.world.block_log.as_ref().map(Vec::len)
|
||||
}
|
||||
|
||||
pub(super) fn lan_mark_synced(&mut self) {
|
||||
if let Some(next) = self.lan_ids_so_far()
|
||||
&& let Some(host) = self.lan.as_mut()
|
||||
@@ -2364,10 +2375,11 @@ impl Game<'_> {
|
||||
self.clock.frozen(),
|
||||
self.clock.step_state(),
|
||||
);
|
||||
// `runBlockEvents`' broadcast precedes the tick's block changes
|
||||
// (`ChunkHolder.broadcastChanges` in `tick_level` below): the
|
||||
// guest re-runs a piston's `triggerEvent` while its level still
|
||||
// has the blocks as they were before the move.
|
||||
// `runBlockEvents`' broadcast precedes what the events change (held
|
||||
// for the next tick, `tick_world`), and follows the blocks changed
|
||||
// before them (`lan_broadcast_changes`): the guest re-runs a
|
||||
// piston's `triggerEvent` on the blocks as they were before the
|
||||
// move, a base the last tick restored included.
|
||||
for (pos, b0, b1, state) in std::mem::take(&mut self.lan_block_events) {
|
||||
host.hub.block_event(pos, b0, b1, state);
|
||||
}
|
||||
@@ -2644,30 +2656,17 @@ impl Game<'_> {
|
||||
if own && self.lan_break_pos == Some([pos.x, pos.y, pos.z]) {
|
||||
own_break = false;
|
||||
}
|
||||
// The acting guest does not predict the egg's teleport
|
||||
// particles, bone meal's growth, anvil result sounds, or a
|
||||
// finished brushing or an eye inserted in a portal frame (these calls name no
|
||||
// excluded player), nor the digger's own crack and hit
|
||||
// sound (2019, 2020).
|
||||
let except = if matches!(
|
||||
// The acting guest does not predict these, and the vanilla
|
||||
// call names no excluded player (`levelEvent(id, pos, data)`
|
||||
// passes null): the composter fill (1500), the egg's
|
||||
// teleport, bone meal's growth, anvil result sounds, a
|
||||
// finished brushing or an eye inserted in a portal frame,
|
||||
// and the digger's own crack and hit sound (2019, 2020).
|
||||
let except = level_event_except(
|
||||
*id,
|
||||
2015 | 1501
|
||||
| 1503
|
||||
| 1505
|
||||
| 1029
|
||||
| 1030
|
||||
| 1045
|
||||
| 1052
|
||||
| 2019
|
||||
| 2020
|
||||
| 3000
|
||||
| 3008
|
||||
) || (*id == 2001 && self.lan_break_pos.is_some() && !own)
|
||||
{
|
||||
None
|
||||
} else {
|
||||
self.lan_acting
|
||||
};
|
||||
self.lan_acting,
|
||||
*id == 2001 && self.lan_break_pos.is_some() && !own,
|
||||
);
|
||||
self.lan_events
|
||||
.push((*id, [pos.x, pos.y, pos.z], *data, except));
|
||||
}
|
||||
@@ -3094,6 +3093,26 @@ fn event_kind(event: &HostEvent) -> EventKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// Who `ServerLevel.levelEvent` leaves out of the broadcast.
|
||||
///
|
||||
/// `LevelAccessor.levelEvent(int, BlockPos, int)` passes a null source, so the
|
||||
/// acting player is included. `ComposterBlock.useItemOn` (and the hopper input
|
||||
/// container) fill that way: event 1500, which the client turns into
|
||||
/// `handleFill` (the fill particles and `playLocalSound`). A call that names
|
||||
/// the player still excludes them. `collateral_break` is a later 2001 from
|
||||
/// `destroyBlock` with no player, after the breaker's own `playerWillDestroy`.
|
||||
fn level_event_except(id: i32, acting: Option<u32>, collateral_break: bool) -> Option<u32> {
|
||||
if matches!(
|
||||
id,
|
||||
2015 | 1500 | 1501 | 1503 | 1505 | 1029 | 1030 | 1045 | 1052 | 2019 | 2020 | 3000 | 3008
|
||||
) || collateral_break
|
||||
{
|
||||
None
|
||||
} else {
|
||||
acting
|
||||
}
|
||||
}
|
||||
|
||||
/// Notes how long the event that started at `started` took (`spans::note`), with what it
|
||||
/// was about when [`Game::event_detail`] said.
|
||||
fn note_event(host: &LanHost, started: Option<(Instant, EventKind, Option<UseDetail>)>) {
|
||||
@@ -3221,6 +3240,17 @@ mod tests {
|
||||
assert_eq!(living_flags(Some((OFFHAND_SLOT, 12))), 3);
|
||||
}
|
||||
|
||||
/// `ComposterBlock.useItemOn` calls `levelEvent(1500, pos, data)`, the
|
||||
/// null-source overload, so the player who filled it still receives
|
||||
/// `handleFill`. `Block.playerWillDestroy`'s 2001 names that player.
|
||||
#[test]
|
||||
fn the_composter_fill_reaches_the_player_who_filled_it() {
|
||||
let actor = Some(4);
|
||||
assert_eq!(super::level_event_except(1500, actor, false), None);
|
||||
assert_eq!(super::level_event_except(2001, actor, false), actor);
|
||||
assert_eq!(super::level_event_except(2001, actor, true), None);
|
||||
}
|
||||
|
||||
/// The names a mod matches on for a broken item's slot (a contract).
|
||||
#[test]
|
||||
fn a_broken_items_slot_is_named_for_mods() {
|
||||
|
||||
@@ -191,6 +191,7 @@ pub(super) struct GuestSwap {
|
||||
left: bool,
|
||||
right: bool,
|
||||
riding: Option<u32>,
|
||||
host_boarding: (i32, Option<u32>),
|
||||
host_station: Option<super::stations::Station>,
|
||||
sleeping: Option<[i32; 3]>,
|
||||
sleep_timer: u32,
|
||||
@@ -522,6 +523,11 @@ impl Game<'_> {
|
||||
self.right = play.using.is_some() || self.using.is_some();
|
||||
// The guest's vehicle (Entity.vehicle) for its turn.
|
||||
let riding = std::mem::replace(&mut self.riding, g.vehicle);
|
||||
// The guest's boarding cooldown for its turn; `guest_server_tick`
|
||||
// counts it down once per tick (Entity.baseTick), not per turn.
|
||||
let host_boarding = (self.boarding_cooldown, self.riding_at_tick);
|
||||
self.boarding_cooldown = g.boarding_cooldown;
|
||||
self.riding_at_tick = g.vehicle;
|
||||
// `Entity.isPassenger()` for that turn, from the same vehicle: the
|
||||
// guest's body keeps no swim (`Entity.updateSwimming` needs it false).
|
||||
// Taken from `g.vehicle` each turn, so boarding and getting out land.
|
||||
@@ -635,6 +641,7 @@ impl Game<'_> {
|
||||
left,
|
||||
right,
|
||||
riding,
|
||||
host_boarding,
|
||||
host_station,
|
||||
sleeping,
|
||||
sleep_timer,
|
||||
@@ -734,6 +741,7 @@ impl Game<'_> {
|
||||
left,
|
||||
right,
|
||||
riding,
|
||||
host_boarding,
|
||||
host_station,
|
||||
sleeping,
|
||||
sleep_timer,
|
||||
@@ -912,6 +920,11 @@ impl Game<'_> {
|
||||
self.guest_attachments.entry(g.uuid).or_default(),
|
||||
);
|
||||
let left_vehicle = g.vehicle.filter(|_| guest_vehicle.is_none());
|
||||
// Entity.removePassenger: 60 ticks before it may board again.
|
||||
let guest_boarding =
|
||||
super::boarding::after_leaving(self.boarding_cooldown, g.vehicle, guest_vehicle);
|
||||
(self.boarding_cooldown, self.riding_at_tick) = host_boarding;
|
||||
g.boarding_cooldown = guest_boarding;
|
||||
if guest_vehicle.is_none() {
|
||||
g.vehicle_good = None;
|
||||
}
|
||||
@@ -967,9 +980,19 @@ impl Game<'_> {
|
||||
std::mem::swap(&mut self.armor, &mut g.armor);
|
||||
std::mem::swap(&mut self.offhand, &mut g.offhand);
|
||||
std::mem::swap(&mut self.carried, &mut g.carried);
|
||||
// A teleport with a rotation (commands) turns the guest.
|
||||
let turned = [self.yaw, self.pitch] != [g.yaw, g.pitch];
|
||||
// A teleport with a rotation (commands) turns the guest. `/rotate`
|
||||
// (ServerPlayer.forceSetRotation) sends the rotation alone, rider or not.
|
||||
let forced = std::mem::take(&mut self.rotation_forced);
|
||||
let turned = !forced && [self.yaw, self.pitch] != [g.yaw, g.pitch];
|
||||
[g.yaw, g.pitch] = [self.yaw, self.pitch];
|
||||
if forced {
|
||||
g.send(&C::PlayerRotation(play::PlayerRotation {
|
||||
yaw: g.yaw,
|
||||
relative_yaw: false,
|
||||
pitch: g.pitch,
|
||||
relative_pitch: false,
|
||||
}));
|
||||
}
|
||||
self.yaw = yaw;
|
||||
self.pitch = pitch;
|
||||
self.eye = eye.1;
|
||||
@@ -997,14 +1020,8 @@ impl Game<'_> {
|
||||
let teleported = std::mem::take(&mut m.teleported);
|
||||
let moved: f64 = (0..3).map(|k| (at[k] - g.position[k]).powi(2)).sum();
|
||||
g.motion = Some(Box::new(m));
|
||||
// Entity.teleportTo takes a rider out of its vehicle.
|
||||
let left_vehicle = match g.vehicle.filter(|_| teleported) {
|
||||
Some(v) => {
|
||||
(g.vehicle, g.vehicle_good) = (None, None);
|
||||
Some(v)
|
||||
}
|
||||
None => left_vehicle,
|
||||
};
|
||||
// The pearl and the bed took the rider out of its vehicle at their
|
||||
// source (`stop_riding`), so `left_vehicle` already says so.
|
||||
// ServerPlayer.dismountTo: the client hears it is out of the
|
||||
// vehicle before the teleport beside it. A rider sits where its
|
||||
// vehicle puts it (lan_vehicles.rs), with no teleport.
|
||||
@@ -2110,10 +2127,13 @@ impl Game<'_> {
|
||||
g.forced_hit = Some(hit);
|
||||
g.guest_use = Some(GuestUse::OnBlock(hand));
|
||||
let swings = g.swings_started;
|
||||
let consumed = matches!(g.use_item(), Ok(Some(_)));
|
||||
// InteractionResult.consumesAction: a FAIL (`use_failed`,
|
||||
// as BlockItem.place's failed placement) does not count,
|
||||
// even though the click ended on it.
|
||||
let consumed = matches!(g.use_item(), Ok(Some(_))) && !g.use_failed;
|
||||
// handleUseItemOn: a consuming useItemOn triggers
|
||||
// ANY_BLOCK_USE with the hand's stack as it now is.
|
||||
if consumed && !g.use_failed {
|
||||
if consumed {
|
||||
let stack = g
|
||||
.with_hand(hand, |g| g.held().cloned())
|
||||
.unwrap_or_else(|| ItemStack::new("minecraft:air", 0));
|
||||
@@ -2854,19 +2874,12 @@ impl Game<'_> {
|
||||
let g = &host.hub.guests[gi];
|
||||
let eye = [
|
||||
g.position[0],
|
||||
g.position[1] + g.eye_height() - 0.3,
|
||||
g.position[1] + g.eye_height() - f64::from(0.3f32),
|
||||
g.position[2],
|
||||
];
|
||||
let (yaw, pitch) = (
|
||||
f64::from(g.yaw).to_radians(),
|
||||
f64::from(g.pitch).to_radians(),
|
||||
);
|
||||
for stack in stacks {
|
||||
let v = [
|
||||
-yaw.sin() * pitch.cos() * 0.3,
|
||||
-pitch.sin() * 0.3 + 0.1,
|
||||
yaw.cos() * pitch.cos() * 0.3,
|
||||
];
|
||||
let r = &mut self.world.random;
|
||||
let v = horizon_client::entities::drop_motion(r, g.yaw, g.pitch, false);
|
||||
self.spawn_item(stack, eye, v, 40);
|
||||
}
|
||||
}
|
||||
@@ -3878,8 +3891,8 @@ impl Game<'_> {
|
||||
.death_reward(self.ui.rules.bool("keep_inventory") || g.game_mode == SPECTATOR);
|
||||
let (name, uuid) = (g.name.clone(), g.uuid);
|
||||
// ServerPlayer.die: dropAllDeathLoot unless a spectator, from the eyes less 0.3.
|
||||
let drops =
|
||||
(g.game_mode != SPECTATOR).then(|| [at[0], at[1] + g.eye_height() - 0.3, at[2]]);
|
||||
let drops = (g.game_mode != SPECTATOR)
|
||||
.then(|| [at[0], at[1] + g.eye_height() - f64::from(0.3f32), at[2]]);
|
||||
let cause = host.play.get_mut(&conn).and_then(|p| p.death_cause.take());
|
||||
self.native_player_died(uuid, cause.as_deref());
|
||||
// ServerPlayer.die: the neutral mobs near a guest in this level
|
||||
@@ -4321,6 +4334,10 @@ impl Game<'_> {
|
||||
/// regeneration and the food tick (ServerPlayer.doTick). Their hurt
|
||||
/// sounds are heard at the guest.
|
||||
fn guest_server_tick(&mut self, host: &mut LanHost, conn: u32) {
|
||||
// Entity.baseTick: the boarding cooldown counts down once a tick.
|
||||
if let Some(g) = host.hub.guest_mut(conn) {
|
||||
g.boarding_cooldown = super::boarding::tick_down(g.boarding_cooldown);
|
||||
}
|
||||
self.guest_replay(host, conn);
|
||||
self.guest_vehicle_tick(host, conn);
|
||||
// The last move's fall stands in for the server's delta movement.
|
||||
|
||||
@@ -234,6 +234,8 @@ impl Game<'_> {
|
||||
if dead || (boat.is_none() && !cart && !mount && !cushion) {
|
||||
let g = &mut host.hub.guests[gi];
|
||||
(g.vehicle, g.vehicle_good) = (None, None);
|
||||
// Entity.removePassenger: the rider waits 60 ticks to board again.
|
||||
g.boarding_cooldown = super::boat_passengers::BOARDING_COOLDOWN;
|
||||
return;
|
||||
}
|
||||
let mut ejected = false;
|
||||
|
||||
@@ -133,7 +133,7 @@ impl Game<'_> {
|
||||
}
|
||||
// doPlayerRide; mounting drops a lead.
|
||||
let id = self.mobs[index].id;
|
||||
if !self.native_may_board(id) {
|
||||
if !self.may_board() || !self.native_may_board(id) {
|
||||
return true;
|
||||
}
|
||||
self.break_leash(id, true, false);
|
||||
|
||||
@@ -1518,6 +1518,7 @@ mod item_column_tests {
|
||||
fn item(id: u32, x: f64, y: f64, z: f64) -> ItemEntity {
|
||||
let at = Vec3::new(x, y, z);
|
||||
ItemEntity {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
health: 5,
|
||||
|
||||
@@ -2119,7 +2119,12 @@ impl Game<'_> {
|
||||
let id = self.carts[i].id;
|
||||
// isVehicle: a mob already sits in it.
|
||||
let has_mob = self.mobs.iter().any(|m| m.extra.mount == Some(id));
|
||||
if sneaking || self.riding.is_some() || self.ridden_by_guest(id) || has_mob {
|
||||
if sneaking
|
||||
|| self.riding.is_some()
|
||||
|| !self.may_board()
|
||||
|| self.ridden_by_guest(id)
|
||||
|| has_mob
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if !self.native_may_board(id) {
|
||||
@@ -2703,7 +2708,7 @@ impl Minecart {
|
||||
self.kind.id(),
|
||||
self.position,
|
||||
self.velocity,
|
||||
self.yaw,
|
||||
[self.yaw, self.pitch],
|
||||
uuid,
|
||||
raw,
|
||||
);
|
||||
|
||||
@@ -54,6 +54,50 @@ impl MountState {
|
||||
self.jump_scale = 0.0;
|
||||
}
|
||||
}
|
||||
/// `AbstractHorse.isFood` and `handleEating`: what a mount eats, and what that
|
||||
/// mouthful does — `(heal, age up seconds, temper, falls in love)`. The temper
|
||||
/// is the `u8` the mob stores it in.
|
||||
///
|
||||
/// `AbstractHorse.isFood` is the `#minecraft:horse_food` tag, `ZombieHorse`
|
||||
/// overrides it with `#minecraft:zombie_horse_food` (a red mushroom and
|
||||
/// nothing else), and the horse food tag carries no red mushrooms; a skeleton
|
||||
/// horse inherits the horse food tag but only a tamed one gets that far
|
||||
/// (`SkeletonHorse.mobInteract` passes for an untamed one).
|
||||
fn eating(species: Species, item: &str) -> Option<(f32, i32, u8, bool)> {
|
||||
let camel = species == Species::Camel;
|
||||
// CamelHusk.isFood: rabbit's feet (and it never falls in love).
|
||||
if species == Species::CamelHusk && item != "minecraft:rabbit_foot" {
|
||||
return None;
|
||||
}
|
||||
if species != Species::CamelHusk && camel != (item == "minecraft:cactus") {
|
||||
return None;
|
||||
}
|
||||
let food = match species {
|
||||
Species::ZombieHorse => item == "minecraft:red_mushroom",
|
||||
Species::Horse | Species::Donkey | Species::Mule | Species::SkeletonHorse => {
|
||||
item != "minecraft:red_mushroom"
|
||||
}
|
||||
// Camels and llamas answer their own tags above or in the table.
|
||||
_ => true,
|
||||
};
|
||||
if !food {
|
||||
return None;
|
||||
}
|
||||
Some(match item {
|
||||
"minecraft:rabbit_foot" if species == Species::CamelHusk => (2.0, 10, 0, false),
|
||||
// Camel.handleEating.
|
||||
"minecraft:cactus" => (2.0, 10, 0, true),
|
||||
"minecraft:wheat" => (2.0, 20, 3, false),
|
||||
"minecraft:sugar" => (1.0, 30, 3, false),
|
||||
"minecraft:hay_block" => (20.0, 180, 0, false),
|
||||
"minecraft:apple" => (3.0, 60, 3, false),
|
||||
"minecraft:red_mushroom" => (3.0, 0, 3, false),
|
||||
"minecraft:carrot" => (3.0, 60, 3, false),
|
||||
"minecraft:golden_carrot" => (4.0, 60, 5, true),
|
||||
"minecraft:golden_apple" | "minecraft:enchanted_golden_apple" => (10.0, 240, 10, true),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
/// `EntityTypeTags.DISMOUNTS_UNDERWATER` (`Entity.dismountsUnderwater`): the mounts that throw their
|
||||
/// riders off when the rider's eyes are in water.
|
||||
pub(super) fn dismounts_underwater(species: Species) -> bool {
|
||||
@@ -145,7 +189,7 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
let id = m.id;
|
||||
if !self.native_may_board(id) {
|
||||
if !self.may_board() || !self.native_may_board(id) {
|
||||
return true;
|
||||
}
|
||||
self.riding = Some(id);
|
||||
@@ -306,7 +350,7 @@ impl Game<'_> {
|
||||
}
|
||||
// doPlayerRide
|
||||
let id = self.mobs[index].id;
|
||||
if !self.native_may_board(id) {
|
||||
if !self.may_board() || !self.native_may_board(id) {
|
||||
return true;
|
||||
}
|
||||
let m = &mut self.mobs[index];
|
||||
@@ -321,33 +365,10 @@ impl Game<'_> {
|
||||
}
|
||||
/// `AbstractHorse.handleEating`.
|
||||
fn feed_mount(&mut self, index: usize, item: &str) -> bool {
|
||||
let species = self.mobs[index].species();
|
||||
let camel = species == Species::Camel;
|
||||
// CamelHusk.isFood: rabbit's feet (and it never falls in love).
|
||||
if species == Species::CamelHusk && item != "minecraft:rabbit_foot" {
|
||||
let Some((heal, age_up, temper, love)) = eating(self.mobs[index].species(), item) else {
|
||||
return false;
|
||||
}
|
||||
if species != Species::CamelHusk && camel != (item == "minecraft:cactus") {
|
||||
return false;
|
||||
}
|
||||
let (heal, age_up, temper, love) = match item {
|
||||
"minecraft:rabbit_foot" if species == Species::CamelHusk => (2.0, 10, 0, false),
|
||||
// Camel.handleEating.
|
||||
"minecraft:cactus" => (2.0, 10, 0, true),
|
||||
"minecraft:wheat" => (2.0, 20, 3, false),
|
||||
"minecraft:sugar" => (1.0, 30, 3, false),
|
||||
"minecraft:hay_block" => (20.0, 180, 0, false),
|
||||
"minecraft:apple" => (3.0, 60, 3, false),
|
||||
"minecraft:red_mushroom" => (3.0, 0, 3, false),
|
||||
"minecraft:carrot" => (3.0, 60, 3, false),
|
||||
"minecraft:golden_carrot" => (4.0, 60, 5, true),
|
||||
"minecraft:golden_apple" | "minecraft:enchanted_golden_apple" => (10.0, 240, 10, true),
|
||||
_ => return false,
|
||||
};
|
||||
let m = &mut self.mobs[index];
|
||||
if matches!(m.species(), Species::SkeletonHorse | Species::ZombieHorse) {
|
||||
return false;
|
||||
}
|
||||
let mut used = false;
|
||||
let mut loved = false;
|
||||
if love && m.extra.tame && m.extra.age == 0 && m.extra.in_love <= 0 {
|
||||
@@ -381,6 +402,8 @@ impl Game<'_> {
|
||||
Species::Donkey => "minecraft:entity.donkey.eat",
|
||||
Species::Mule => "minecraft:entity.mule.eat",
|
||||
Species::Horse if m.is_baby() => "minecraft:entity.baby_horse.eat",
|
||||
// ZombieHorse.getEatingSound.
|
||||
Species::ZombieHorse => "minecraft:entity.zombie_horse.eat",
|
||||
_ => "minecraft:entity.horse.eat",
|
||||
};
|
||||
let pitch = (self.world.random.next_float() - self.world.random.next_float()) * 0.2 + 1.0;
|
||||
@@ -1022,6 +1045,46 @@ mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// `ZombieHorse.isFood` is `#minecraft:zombie_horse_food`, which holds only
|
||||
/// the red mushroom, and `AbstractHorse.isFood` is `#minecraft:horse_food`,
|
||||
/// which holds none (#8593). A skeleton horse inherits the horse food tag.
|
||||
#[test]
|
||||
fn only_a_zombie_horse_eats_a_red_mushroom() {
|
||||
assert_eq!(
|
||||
eating(Species::ZombieHorse, "minecraft:red_mushroom"),
|
||||
Some((3.0, 0, 3, false)),
|
||||
"a zombie horse takes the red mushroom"
|
||||
);
|
||||
for item in ["minecraft:wheat", "minecraft:apple", "minecraft:hay_block"] {
|
||||
assert_eq!(eating(Species::ZombieHorse, item), None, "{item}");
|
||||
}
|
||||
for species in [
|
||||
Species::Horse,
|
||||
Species::Donkey,
|
||||
Species::Mule,
|
||||
Species::SkeletonHorse,
|
||||
] {
|
||||
assert_eq!(
|
||||
eating(species, "minecraft:red_mushroom"),
|
||||
None,
|
||||
"{species:?}: horse food has no red mushrooms"
|
||||
);
|
||||
assert_eq!(
|
||||
eating(species, "minecraft:wheat"),
|
||||
Some((2.0, 20, 3, false)),
|
||||
"{species:?}: wheat is horse food"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
eating(Species::Camel, "minecraft:cactus"),
|
||||
Some((2.0, 10, 0, true))
|
||||
);
|
||||
assert_eq!(
|
||||
eating(Species::CamelHusk, "minecraft:rabbit_foot"),
|
||||
Some((2.0, 10, 0, false))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mounts_that_throw_a_swimmer_off_are_the_dismounts_underwater_tag() {
|
||||
// data/minecraft/tags/entity_type/dismounts_underwater.json of 26.3.
|
||||
|
||||
@@ -93,6 +93,7 @@ impl Game<'_> {
|
||||
let at = Vec3::new(pos[0], pos[1], pos[2]);
|
||||
self.next_entity += 1;
|
||||
self.falling.push(FallingBlock {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
id: self.next_entity,
|
||||
state,
|
||||
|
||||
@@ -974,8 +974,8 @@ use horizon_client::mod_api::{
|
||||
};
|
||||
use horizon_client::mod_api::{AttachmentResult, Attachments, Holder, Suggestion};
|
||||
pub(crate) use horizon_client::mod_api::{
|
||||
NativeBlockRim, NativeColoredLight, NativeLootDrops, NativeMinimap, NativeMod,
|
||||
NativePanelEvent, NativeScreen, NativeScreenView, NativeStack, NativeZoom,
|
||||
ClientboundPlayPacket, NativeBlockRim, NativeColoredLight, NativeLootDrops, NativeMinimap,
|
||||
NativeMod, NativePanelEvent, NativeScreen, NativeScreenView, NativeStack, NativeZoom,
|
||||
};
|
||||
use horizon_client::{
|
||||
atlas_sources::qualified,
|
||||
@@ -6820,6 +6820,20 @@ impl Game<'_> {
|
||||
pub(super) fn native_net_from_server(&mut self, channel: &str, data: &[u8]) {
|
||||
mod_api::dispatch_net_from_server(self, mod_api::mods(), channel, data);
|
||||
}
|
||||
/// A play packet from the server reached the window's game, decoded,
|
||||
/// just before the game handles it (`Connection.genericsFtw` handing it
|
||||
/// to `ClientPacketListener`): every packet of the play phase, custom
|
||||
/// payloads included, in the order the server sent them. Packets the
|
||||
/// network thread handles itself are not heard: keep-alives, bundle
|
||||
/// delimiters, resource pack pushes and pops, cookies, server links,
|
||||
/// transfers, disconnects, the configuration switch, `minecraft:brand`
|
||||
/// and far terrain tiles; neither is the configuration phase.
|
||||
/// Read-only: the game handles the packet as it would without the mod.
|
||||
/// Client-side, on any server, single player's own included.
|
||||
/// API @API:client_packet@.
|
||||
pub(super) fn native_client_packet(&mut self, packet: &ClientboundPlayPacket) {
|
||||
mod_api::dispatch_client_packet(self, mod_api::mods(), packet);
|
||||
}
|
||||
/// `dimension`'s level opened on the server (NeoForge's `LevelEvent.Load`,
|
||||
/// posted in `MinecraftServer.createLevels`): before any of its chunks
|
||||
/// (`native_chunk_load`), so a mod sets up what it keeps for the level
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
//! reaches `native_net_from_client` once the hub is back, and what a mod sends
|
||||
//! a player leaves at the tick's end, from whichever hook sent it.
|
||||
use super::{Game, lan::LanHost};
|
||||
use horizon_client::mod_api::ServerboundPlayPacket;
|
||||
use horizon_protocol::{
|
||||
mod_net::{self, Message},
|
||||
packets::{
|
||||
configuration::CustomPayload,
|
||||
play::{Clientbound as C, Serverbound as S},
|
||||
},
|
||||
quiet,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -187,4 +189,97 @@ impl Game<'_> {
|
||||
self.send_chat(message);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sends `packet` to the server as it is, to any server, a vanilla one
|
||||
/// included: a plugin channel's payload (`ServerboundCustomPayloadPacket`)
|
||||
/// or any other play packet. Nothing counts it, and the game doesn't apply
|
||||
/// it on its side: a packet that needs a sequence number, a signature or
|
||||
/// the client's own state is the mod's to get right. An error when the
|
||||
/// player isn't in a server's play state, and for a custom payload past
|
||||
/// `ServerboundCustomPayloadPacket.MAX_PAYLOAD_SIZE`, which a server
|
||||
/// disconnects for. API @API:send_packet@.
|
||||
pub(super) fn native_send_packet(
|
||||
&mut self,
|
||||
packet: ServerboundPlayPacket,
|
||||
) -> Result<(), String> {
|
||||
let Some(remote) = self.remote.as_ref() else {
|
||||
return Err("not connected to a server".into());
|
||||
};
|
||||
// A server that reconfigures the player has them out of the play state.
|
||||
if !remote.joined || remote.reconfiguring.is_some() {
|
||||
return Err("the player has not joined yet".into());
|
||||
}
|
||||
if let S::CustomPayload(payload) = &packet
|
||||
&& payload.data.len() > quiet::MAX_PAYLOAD
|
||||
{
|
||||
return Err(format!("the payload is past {} bytes", quiet::MAX_PAYLOAD));
|
||||
}
|
||||
self.send(packet);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::{
|
||||
Audio, Game, Options, Progress, Settings, execution_role::ExecutionRole, load_resources,
|
||||
};
|
||||
use super::{CustomPayload, S, quiet};
|
||||
use horizon_protocol::{connection::Pipe, packets::configuration::Ping};
|
||||
|
||||
/// A mod's packet goes out only from a joined player, and a custom
|
||||
/// payload only within vanilla's size limit.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn mod_packets_need_a_joined_player_and_a_payload_within_the_limit() {
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
|
||||
);
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!("no menu handoff"),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = ExecutionRole::Client;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let payload = |len| {
|
||||
S::CustomPayload(CustomPayload {
|
||||
channel: "voicechat:request_secret".into(),
|
||||
data: vec![0; len],
|
||||
})
|
||||
};
|
||||
|
||||
assert!(game.native_send_packet(payload(4)).is_err());
|
||||
let (client, _server) = Pipe::pair();
|
||||
game.connect_local(client, "PacketTest").unwrap();
|
||||
assert!(game.native_send_packet(payload(4)).is_err());
|
||||
game.remote.as_mut().unwrap().joined = true;
|
||||
assert!(game.native_send_packet(payload(4)).is_ok());
|
||||
assert!(game.native_send_packet(payload(quiet::MAX_PAYLOAD)).is_ok());
|
||||
assert!(
|
||||
game.native_send_packet(payload(quiet::MAX_PAYLOAD + 1))
|
||||
.is_err()
|
||||
);
|
||||
assert!(game.native_send_packet(S::Pong(Ping { id: 7 })).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -128,7 +128,7 @@ impl Game<'_> {
|
||||
if tame && !food {
|
||||
// doPlayerRide.
|
||||
let id = self.mobs[index].id;
|
||||
if !self.native_may_board(id) {
|
||||
if !self.may_board() || !self.native_may_board(id) {
|
||||
return true;
|
||||
}
|
||||
self.break_leash(id, true, false);
|
||||
|
||||
@@ -963,6 +963,7 @@ impl Game<'_> {
|
||||
t.fuse,
|
||||
t.power,
|
||||
t.block_state_nbt(self.pack.assets.registry),
|
||||
t.rotation,
|
||||
uuid,
|
||||
self.persist_raw(id),
|
||||
);
|
||||
@@ -1036,7 +1037,7 @@ impl Game<'_> {
|
||||
&b.entity_type(),
|
||||
b.position,
|
||||
b.velocity,
|
||||
b.yaw,
|
||||
[b.yaw, b.pitch],
|
||||
uuid,
|
||||
self.persist_raw(id),
|
||||
b.chest.then_some(&b.items[..]),
|
||||
@@ -1269,6 +1270,7 @@ impl Game<'_> {
|
||||
f.drop_item,
|
||||
f.damage,
|
||||
f.fall_distance,
|
||||
f.rotation,
|
||||
uuid,
|
||||
);
|
||||
let mut tag = super::persist::with_raw(modelled, f.saved.as_ref().map(|s| &s.1));
|
||||
|
||||
@@ -500,6 +500,7 @@ impl Game<'_> {
|
||||
r.set_frame_generation(
|
||||
s.horizon.frame_generation != horizon_client::graphics::FrameGeneration::Off,
|
||||
);
|
||||
r.set_hdr_lighting(s.horizon.hdr_lighting);
|
||||
r.set_hdr(
|
||||
s.horizon.hdr,
|
||||
s.horizon.paper_white_nits() as f32,
|
||||
|
||||
@@ -138,7 +138,9 @@ impl Game<'_> {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
for m in &mut self.mobs {
|
||||
let mut mobs = std::mem::take(&mut self.mobs);
|
||||
let w = self.mob_world_base(&[]);
|
||||
for m in &mut mobs {
|
||||
if m.species() == Species::Panda
|
||||
&& !m.is_baby()
|
||||
&& m.on_ground
|
||||
@@ -148,12 +150,13 @@ impl Game<'_> {
|
||||
{
|
||||
// LivingEntity.jumpFromGround.
|
||||
m.needs_sync = true;
|
||||
let power = f64::from(m.jump_power());
|
||||
let power = f64::from(m.jump_power(&w));
|
||||
if power > 1.0e-5 {
|
||||
m.velocity.y = power.max(m.velocity.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.mobs = mobs;
|
||||
}
|
||||
/// What a window draws of its pandas: the crumbs of a bite
|
||||
/// (`addEatingParticles`) and the sneeze's particle.
|
||||
|
||||
@@ -563,6 +563,7 @@ impl Game<'_> {
|
||||
self.remember_uuid(self.next_entity, uuid, &tag);
|
||||
let uuid = self.uuid_for(self.next_entity);
|
||||
self.falling.push(FallingBlock {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
id: self.next_entity,
|
||||
state,
|
||||
@@ -634,6 +635,7 @@ impl Game<'_> {
|
||||
_ => 80,
|
||||
};
|
||||
self.tnts.push(super::tnt::PrimedTnt {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: entity_nbt::flag(&tag, "NoGravity"),
|
||||
id,
|
||||
@@ -979,7 +981,7 @@ impl Game<'_> {
|
||||
&b.entity_type(),
|
||||
b.position,
|
||||
b.velocity,
|
||||
b.yaw,
|
||||
[b.yaw, b.pitch],
|
||||
uuid,
|
||||
self.persist.raw.get(&root),
|
||||
b.chest.then_some(&b.items[..]),
|
||||
@@ -1685,6 +1687,7 @@ impl Game<'_> {
|
||||
f.drop_item,
|
||||
f.damage,
|
||||
f.fall_distance,
|
||||
f.rotation,
|
||||
uuid,
|
||||
);
|
||||
let mut tag = with_raw(tag, raw.as_ref());
|
||||
@@ -1734,6 +1737,7 @@ impl Game<'_> {
|
||||
t.fuse,
|
||||
t.power,
|
||||
t.block_state_nbt(self.pack.assets.registry),
|
||||
t.rotation,
|
||||
uuid,
|
||||
self.persist.raw.get(&id),
|
||||
);
|
||||
@@ -1755,7 +1759,7 @@ impl Game<'_> {
|
||||
&b.entity_type(),
|
||||
b.position,
|
||||
b.velocity,
|
||||
b.yaw,
|
||||
[b.yaw, b.pitch],
|
||||
uuid,
|
||||
self.persist.raw.get(&id),
|
||||
b.chest.then_some(&b.items[..]),
|
||||
|
||||
@@ -150,6 +150,7 @@ mod tests {
|
||||
fn orb_at(x: f64, y: f64, z: f64) -> Taken {
|
||||
let at = horizon_core::geometry::Vec3::new(x, y, z);
|
||||
Taken::Orb(Box::new(ExperienceOrb {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: 0,
|
||||
|
||||
@@ -157,8 +157,10 @@ fn excused(g: &Guest, world: &World, entities: &[EntityState], guests: &[Guest])
|
||||
};
|
||||
let unsent = world
|
||||
.block_log
|
||||
.as_ref()
|
||||
.is_some_and(|log| log.iter().any(|&(x, y, z)| near(at, [x, y, z], slack)));
|
||||
.iter()
|
||||
.flatten()
|
||||
.chain(&world.block_log_held)
|
||||
.any(|&(x, y, z)| near(at, [x, y, z], slack));
|
||||
let pistons = world
|
||||
.moving_pistons
|
||||
.iter()
|
||||
|
||||
@@ -640,7 +640,7 @@ impl Game<'_> {
|
||||
fn apply_raid_action(&mut self, id: i32, action: Action) {
|
||||
match action {
|
||||
Action::Spawn { pos, wave, spawns } => self.spawn_wave(id, pos, wave, &spawns),
|
||||
Action::Horn { pos, .. } => self.raid_horn(id, pos),
|
||||
Action::Horn { pos, seed } => self.raid_horn(id, pos, seed),
|
||||
Action::Remove { id: mob } => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
|
||||
let s = raider::state_mut(m);
|
||||
@@ -666,20 +666,61 @@ impl Game<'_> {
|
||||
}),
|
||||
}
|
||||
}
|
||||
/// `Raid.playSound`: the horn, 13 blocks away from the player toward the
|
||||
/// wave, for players within 64 blocks or in the raid.
|
||||
fn raid_horn(&mut self, id: i32, pos: [i32; 3]) {
|
||||
let Some(p) = self.level_players().first().map(|p| p.position) else {
|
||||
return;
|
||||
};
|
||||
/// `Raid.playSound`: the horn, 13 blocks from each player toward the wave,
|
||||
/// for everyone within 64 blocks or in the raid. One seed for every packet
|
||||
/// (`SoundSource.NEUTRAL`, volume 64, pitch 1).
|
||||
fn raid_horn(&mut self, id: i32, pos: [i32; 3], seed: i64) {
|
||||
let in_raid = self.raiding.raids.get(id).is_some_and(|r| r.bar.player);
|
||||
let (rx, rz) = (f64::from(pos[0]) + 0.5, f64::from(pos[2]) + 0.5);
|
||||
let distance = ((rx - p.x).powi(2) + (rz - p.z).powi(2)).sqrt();
|
||||
if distance <= 64.0 || in_raid {
|
||||
let k = 13.0 / distance;
|
||||
let at = [p.x + k * (rx - p.x), p.y, p.z + k * (rz - p.z)];
|
||||
// The raid tracks one participant (`bar.player`): the first player in
|
||||
// the level. Everyone else is heard by distance alone.
|
||||
let local_first = self.player.is_some();
|
||||
if let Some(p) = self.player.as_ref()
|
||||
&& let Some(at) = raid::horn_at(
|
||||
[p.position.x, p.position.y, p.position.z],
|
||||
rx,
|
||||
rz,
|
||||
in_raid && local_first,
|
||||
)
|
||||
{
|
||||
// This window only. A positioned `play` would be broadcast to
|
||||
// every guest on top of the packet each of them is sent.
|
||||
let capture = self.audio.capture.take();
|
||||
let neutral = horizon_client::settings::SOUND_SOURCES
|
||||
.iter()
|
||||
.position(|s| *s == "neutral")
|
||||
.unwrap_or(0);
|
||||
self.audio
|
||||
.play("minecraft:event.raid.horn", Some(at), 64.0, 1.0);
|
||||
.play_on(neutral, "minecraft:event.raid.horn", Some(at), 64.0, 1.0);
|
||||
self.audio.capture = capture;
|
||||
}
|
||||
let Some(host) = self.lan.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let dim = host.hub.config.dimension.clone();
|
||||
let spotted: Vec<(u32, [f64; 3], bool)> = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.map(|g| (g.conn, g.position, g.placed && g.dimension == dim))
|
||||
.collect();
|
||||
let neutral = horizon_client::settings::SOUND_SOURCES
|
||||
.iter()
|
||||
.position(|s| *s == "neutral")
|
||||
.unwrap_or(0) as i32;
|
||||
let mut first_guest = !local_first;
|
||||
for (conn, position, here) in spotted {
|
||||
if !here {
|
||||
continue;
|
||||
}
|
||||
let participating = std::mem::replace(&mut first_guest, false) && in_raid;
|
||||
let Some(at) = raid::horn_at(position, rx, rz, participating) else {
|
||||
continue;
|
||||
};
|
||||
let packet =
|
||||
host.hub
|
||||
.sound_packet("minecraft:event.raid.horn", at, 64.0, 1.0, neutral, seed);
|
||||
host.hub.send_to(conn, &packet);
|
||||
}
|
||||
}
|
||||
/// `Raid.spawnGroup`: a wave of raiders at `pos`, the leader wearing the
|
||||
|
||||
@@ -942,7 +942,6 @@ impl Game<'_> {
|
||||
self.dropped.clear();
|
||||
self.particles.clear();
|
||||
self.pickups.clear();
|
||||
self.orbs.clear();
|
||||
self.falling.clear();
|
||||
self.mining = None;
|
||||
self.container = None;
|
||||
@@ -1147,6 +1146,13 @@ impl Game<'_> {
|
||||
// Kept mod block entity geometry belongs to the world leaving.
|
||||
self.kept_block_entities.get_mut().clear();
|
||||
self.clear_narrator();
|
||||
// updateLevelInEngines(null): every sound stops and the music manager
|
||||
// lets go of its track. Leaving a server comes straight here, not
|
||||
// through finish_close_world, so the biome's ambient loop would
|
||||
// otherwise play on over the title screen until the next world.
|
||||
self.ambience.clear(&mut self.audio);
|
||||
self.music.stop_playing(None, &mut self.audio);
|
||||
self.audio.stop_all();
|
||||
// A world is leaving either way: its icon attempt goes with it.
|
||||
self.world_icon = None;
|
||||
// Minecraft.disconnect: the client level goes.
|
||||
@@ -1815,6 +1821,7 @@ impl Game<'_> {
|
||||
if let Received::Event(Event::Packet(packet)) = &item
|
||||
&& let C::ForgetLevelChunk(p) = &**packet
|
||||
{
|
||||
self.native_client_packet(packet);
|
||||
forgotten.insert((p.pos[0], p.pos[1]));
|
||||
continue;
|
||||
}
|
||||
@@ -1844,6 +1851,7 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
self.native_client_packet(&packet);
|
||||
if let C::LevelChunkWithLight(p) = *packet {
|
||||
self.remote_chunk(&p, Some((&format, column)));
|
||||
}
|
||||
@@ -2563,6 +2571,7 @@ impl Game<'_> {
|
||||
// Its value (ExperienceOrb.DATA_VALUE) follows in SetEntityData.
|
||||
self.orbs.retain(|o| o.id != id as u32);
|
||||
self.orbs.push(ExperienceOrb {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: id as u32,
|
||||
@@ -2650,6 +2659,26 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
if let Some(m) = mob {
|
||||
// Shulker.createInterpolationHandler is NO_OP, so a move packet
|
||||
// is setPos at once. The 6-tick slide starts from the block
|
||||
// the shulker occupied before this packet.
|
||||
if m.species() == horizon_client::mobs::Species::Shulker {
|
||||
r.stepped.remove(&id);
|
||||
if path.is_some() {
|
||||
horizon_client::mobs::shulker::client_set_pos(
|
||||
m,
|
||||
position[0],
|
||||
position[1],
|
||||
position[2],
|
||||
);
|
||||
}
|
||||
if let Some((yaw, pitch)) = rotation {
|
||||
m.yaw += wrap_degrees(yaw - m.yaw);
|
||||
m.head_pitch = (pitch % 360.0).clamp(-90.0, 90.0);
|
||||
}
|
||||
m.on_ground = on_ground;
|
||||
return;
|
||||
}
|
||||
// A living entity: LivingEntity.createInterpolationHandler's
|
||||
// SteppedInterpolationHandler, over its type's update interval.
|
||||
let entity = PosRot {
|
||||
@@ -3066,6 +3095,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
fn handle_packet(&mut self, packet: C) -> Result<()> {
|
||||
self.native_client_packet(&packet);
|
||||
match packet {
|
||||
// ClientCommonPacketListenerImpl.handlePing runs on the game
|
||||
// thread, after all preceding packets (especially teleports).
|
||||
@@ -4134,6 +4164,11 @@ impl Game<'_> {
|
||||
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
|
||||
horizon_client::mobs::sync::entity_event(m, p.event as u8);
|
||||
}
|
||||
// LivingEntity.handleEntityEvent 46: portal particles from the
|
||||
// old position to the new one (chorus fruit, an enderman).
|
||||
if p.event == 46 {
|
||||
self.entity_teleport_particles(p.id);
|
||||
}
|
||||
// LivingEntity.handleEntityEvent 60: the poof as the dead body goes.
|
||||
if p.event == 60
|
||||
&& let Some(m) = self.mob_index.get(&self.mobs, p.id as u32)
|
||||
@@ -5542,6 +5577,7 @@ impl Game<'_> {
|
||||
let at = Vec3::new(p[0], p[1], p[2]);
|
||||
let bob_offset = self.world.random.next_float() * std::f32::consts::PI * 2.0;
|
||||
self.dropped.push(ItemEntity {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
health: 5,
|
||||
@@ -6754,8 +6790,9 @@ impl Game<'_> {
|
||||
if m.species() == horizon_client::mobs::Species::Bat {
|
||||
horizon_client::mobs::bat::setup_animation_states(m);
|
||||
}
|
||||
// The animation states a server computes in its own `ai_step`.
|
||||
horizon_client::mobs::sync::client_tick(m, game_time);
|
||||
// The animation states a server computes in its own `ai_step`,
|
||||
// and the particles vanilla draws on the client.
|
||||
horizon_client::mobs::sync::client_tick(m, game_time, &mut mob_fx);
|
||||
match m.species() {
|
||||
// WitherBoss.aiStep: the side heads turn toward their targets.
|
||||
horizon_client::mobs::Species::Wither => {
|
||||
@@ -6864,6 +6901,7 @@ impl Game<'_> {
|
||||
// The server decides what an item rests on; this copy only moves between updates.
|
||||
&[],
|
||||
border,
|
||||
Some(&self.res.motion().blocks),
|
||||
&mut |splash| entered.push(splash),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -782,21 +782,21 @@ mod tests {
|
||||
return;
|
||||
};
|
||||
apply(&mut m, &[(20, s::BOOLEAN, Value::Bool(true))]);
|
||||
client_tick(&mut m, 0);
|
||||
client_tick(&mut m, 0, &mut Vec::new());
|
||||
assert!((m.anim.v[tame::INTEREST] - 0.4).abs() < 1e-6);
|
||||
assert_eq!(m.anim.vo[tame::INTEREST], 0.0);
|
||||
client_tick(&mut m, 0);
|
||||
client_tick(&mut m, 0, &mut Vec::new());
|
||||
assert!((m.anim.v[tame::INTEREST] - 0.64).abs() < 1e-6);
|
||||
assert!((m.anim.lerp(tame::INTEREST, 0.5) - 0.52).abs() < 1e-6);
|
||||
for _ in 0..30 {
|
||||
client_tick(&mut m, 0);
|
||||
client_tick(&mut m, 0, &mut Vec::new());
|
||||
}
|
||||
assert!(m.anim.v[tame::INTEREST] > 0.999);
|
||||
apply(&mut m, &[(20, s::BOOLEAN, Value::Bool(false))]);
|
||||
client_tick(&mut m, 0);
|
||||
client_tick(&mut m, 0, &mut Vec::new());
|
||||
assert!(m.anim.v[tame::INTEREST] < 0.61);
|
||||
for _ in 0..30 {
|
||||
client_tick(&mut m, 0);
|
||||
client_tick(&mut m, 0, &mut Vec::new());
|
||||
}
|
||||
assert!(m.anim.v[tame::INTEREST] < 0.001);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
first_person::Hand,
|
||||
mobs::{Species, equine, llama},
|
||||
mobs::{Mob, Species, armadillo, equine, llama, nautilus, render::DYE_NAMES},
|
||||
};
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -30,13 +30,24 @@ impl Game<'_> {
|
||||
{
|
||||
return true;
|
||||
}
|
||||
// Armadillo.mobInteract: a rolled-up armadillo fails before
|
||||
// AgeableMob/Animal.mobInteract, so food and age lock are not taken.
|
||||
// A brush still reaches brushOffScute below.
|
||||
if m.species() == Species::Armadillo && armadillo::client_rejects_food(m, item) {
|
||||
return false;
|
||||
}
|
||||
// AgeableMob.mobInteract: a golden dandelion on a baby that can be age
|
||||
// locked is SUCCESS on a client too (the server sets the lock).
|
||||
if self.age_lock_takes(m, item) {
|
||||
return true;
|
||||
}
|
||||
// Animal.mobInteract: its food is CONSUME on a client.
|
||||
if m.kind.flags.animal && m.kind.food.iter().any(|f| f.as_str() == item) {
|
||||
// Animal.mobInteract: its food is CONSUME on a client. A nautilus
|
||||
// overrides mobInteract, and an untamed one only takes its taming
|
||||
// items, so it answers in its own arm below.
|
||||
if m.kind.flags.animal
|
||||
&& !matches!(m.species(), Species::Nautilus | Species::ZombieNautilus)
|
||||
&& m.kind.food.iter().any(|f| f.as_str() == item)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft);
|
||||
@@ -57,7 +68,7 @@ impl Game<'_> {
|
||||
// TamableAnimal (wolf, cat, parrot) on a client: CONSUME once tame.
|
||||
Species::Wolf | Species::Cat | Species::Parrot => m.extra.tame,
|
||||
// Allay.mobInteract: gives an item or takes it back.
|
||||
Species::Allay => !item.is_empty() || m.extra.main_hand.is_some(),
|
||||
Species::Allay => allay_takes(hand, item, m.extra.main_hand.is_some(), m.extra.flag),
|
||||
Species::Cow | Species::Goat => item == "minecraft:bucket" && !baby,
|
||||
Species::Mooshroom => {
|
||||
!baby
|
||||
@@ -67,9 +78,27 @@ impl Game<'_> {
|
||||
) || m.extra.variant == 1
|
||||
&& horizon_client::flowers::stew_effect(item).is_some())
|
||||
}
|
||||
Species::Sheep => item == "minecraft:shears" || item.ends_with("_dye"),
|
||||
Species::Sheep => sheep_takes(item, m.extra.sheared, m.extra.color),
|
||||
// SnowGolem.mobInteract: shears take the pumpkin off.
|
||||
Species::SnowGolem => item == "minecraft:shears" && !m.extra.sheared,
|
||||
// AbstractNautilus.mobInteract: an untamed adult only answers its
|
||||
// taming items, a tame one is fed, mounted or opens its screen.
|
||||
Species::Nautilus | Species::ZombieNautilus => {
|
||||
let taming: Vec<String> = self
|
||||
.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:nautilus_taming_items")
|
||||
.map(|t| t.iter().cloned().collect())
|
||||
.unwrap_or_default();
|
||||
nautilus_takes(nautilus::is_food(m, item, &taming), m.extra.tame, baby)
|
||||
}
|
||||
// IronGolem.mobInteract.
|
||||
Species::IronGolem => iron_golem_takes(item, m.health, m.attrs.max_health),
|
||||
// CopperGolem.mobInteract.
|
||||
Species::CopperGolem => {
|
||||
copper_golem_takes(item, m.extra.main_hand.is_some(), self.golem_shearable(i))
|
||||
}
|
||||
// Bucketable.bucketMobPickup.
|
||||
Species::Cod
|
||||
| Species::Salmon
|
||||
@@ -81,7 +110,9 @@ impl Game<'_> {
|
||||
item == "minecraft:water_bucket"
|
||||
|| !m.extra.age_locked && m.kind.food.iter().any(|f| f.as_str() == item)
|
||||
}
|
||||
Species::Armadillo => item == "minecraft:brush",
|
||||
// Armadillo.brushOffScute fails on a baby, so its brush falls
|
||||
// through to FAIL (scared) or Mob.mobInteract's PASS.
|
||||
Species::Armadillo => item == "minecraft:brush" && !baby,
|
||||
// ZombieVillager.mobInteract: CONSUME for a golden apple.
|
||||
Species::ZombieVillager => item == "minecraft:golden_apple",
|
||||
// Creeper.mobInteract: CREEPER_IGNITERS.
|
||||
@@ -106,7 +137,7 @@ impl Game<'_> {
|
||||
/// villager particle. The server draws it on its own level only, so no
|
||||
/// packet brings it.
|
||||
pub(super) fn remote_feed_particles(&mut self, i: usize, hand: Hand) {
|
||||
let Some(m) = self.mobs.get(i).filter(|m| m.alive() && m.is_baby()) else {
|
||||
let Some(m) = self.mobs.get(i).filter(|m| m.alive()) else {
|
||||
return;
|
||||
};
|
||||
let stack = match hand {
|
||||
@@ -114,32 +145,264 @@ impl Game<'_> {
|
||||
Hand::Off => self.offhand.as_ref(),
|
||||
};
|
||||
let item = stack.map_or("", |s| &*s.item);
|
||||
let ages = match m.species() {
|
||||
// Llama.handleEating: wheat 10, hay 90.
|
||||
s if llama::is_llama(s) => matches!(item, "minecraft:wheat" | "minecraft:hay_block"),
|
||||
// Camel.handleEating: its food ages it by 10.
|
||||
Species::Camel => m.kind.food.iter().any(|f| f.as_str() == item),
|
||||
// AbstractHorse.handleEating: every food but the red mushroom.
|
||||
Species::Horse | Species::Donkey | Species::Mule => matches!(
|
||||
item,
|
||||
"minecraft:wheat"
|
||||
| "minecraft:sugar"
|
||||
| "minecraft:hay_block"
|
||||
| "minecraft:apple"
|
||||
| "minecraft:carrot"
|
||||
| "minecraft:golden_carrot"
|
||||
| "minecraft:golden_apple"
|
||||
| "minecraft:enchanted_golden_apple"
|
||||
),
|
||||
_ => false,
|
||||
};
|
||||
if ages {
|
||||
let id = m.id;
|
||||
self.particles_around_mob(
|
||||
id,
|
||||
"happy_villager",
|
||||
horizon_client::particles::Burst::HORSE_EATING,
|
||||
if !ages_on_food(m, item) {
|
||||
return;
|
||||
}
|
||||
let id = m.id;
|
||||
self.particles_around_mob(
|
||||
id,
|
||||
"happy_villager",
|
||||
horizon_client::particles::Burst::HORSE_EATING,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Sheep.mobInteract` on a client answers CONSUME for shears whether or not the
|
||||
/// sheep is sheared (only the server checks `readyForShearing`).
|
||||
/// `DyeItem.interactLivingEntity` takes a living, unsheared sheep of another
|
||||
/// colour, and passes otherwise.
|
||||
fn sheep_takes(item: &str, sheared: bool, color: u8) -> bool {
|
||||
let dye = item
|
||||
.strip_prefix("minecraft:")
|
||||
.and_then(|name| name.strip_suffix("_dye"))
|
||||
.and_then(|name| DYE_NAMES.iter().position(|&d| d == name));
|
||||
item == "minecraft:shears" || !sheared && dye.is_some_and(|c| c != usize::from(color))
|
||||
}
|
||||
|
||||
/// `AbstractNautilus.mobInteract` on a client: its food is answered while the
|
||||
/// nautilus takes it (taming items for an untamed adult, its food once tamed),
|
||||
/// and a tame adult answers every click because that is the mount.
|
||||
fn nautilus_takes(food: bool, tame: bool, baby: bool) -> bool {
|
||||
food || (tame && !baby)
|
||||
}
|
||||
|
||||
/// `IronGolem.mobInteract`: an iron ingot heals 25 and only answers SUCCESS when
|
||||
/// that changed the health, so a full golem leaves the click to the item.
|
||||
fn iron_golem_takes(item: &str, health: f32, max_health: f32) -> bool {
|
||||
item == "minecraft:iron_ingot" && health < max_health
|
||||
}
|
||||
|
||||
/// `CopperGolem.mobInteract` on a client: an empty hand makes the golem throw
|
||||
/// what it holds, and shears answer only when `readyForShearing`. Honeycomb and
|
||||
/// axes return `PASS` here because the waxing and scraping run on the server.
|
||||
fn copper_golem_takes(item: &str, holding: bool, shearable: bool) -> bool {
|
||||
(item.is_empty() && holding) || (item == "minecraft:shears" && shearable)
|
||||
}
|
||||
|
||||
/// `Allay.mobInteract` on the client's copy: a dancing allay with a
|
||||
/// `#minecraft:duplicates_allays` item duplicates, an empty-handed one takes
|
||||
/// what the used hand offers, and one that holds something gives it back only
|
||||
/// to a **main hand** that is empty (vanilla compares the hand and returns
|
||||
/// `PASS` otherwise, which lets the item be used).
|
||||
fn allay_takes(hand: Hand, item: &str, holding: bool, dancing: bool) -> bool {
|
||||
if dancing && item == "minecraft:amethyst_shard" {
|
||||
return true;
|
||||
}
|
||||
if !holding {
|
||||
return !item.is_empty();
|
||||
}
|
||||
hand == Hand::Main && item.is_empty()
|
||||
}
|
||||
|
||||
/// `AbstractHorse.handleEating` (and the llama and camel variants): this
|
||||
/// mouthful is the one that ages the baby up, which is the same moment the
|
||||
/// level draws one happy villager particle.
|
||||
///
|
||||
/// `handleEating` gates both on `isBaby() && !isAgeLocked()` — a locked baby
|
||||
/// does not grow, and the particle goes with the growth.
|
||||
pub(super) fn ages_on_food(m: &Mob, item: &str) -> bool {
|
||||
if !m.is_baby() || m.extra.age_locked || item.is_empty() {
|
||||
return false;
|
||||
}
|
||||
match m.species() {
|
||||
// Llama.handleEating: wheat 10, hay 90.
|
||||
s if llama::is_llama(s) => matches!(item, "minecraft:wheat" | "minecraft:hay_block"),
|
||||
// Camel.handleEating: its food ages it by 10.
|
||||
Species::Camel => m.kind.food.iter().any(|f| f.as_str() == item),
|
||||
// AbstractHorse.handleEating: every food but the red mushroom.
|
||||
Species::Horse | Species::Donkey | Species::Mule => matches!(
|
||||
item,
|
||||
"minecraft:wheat"
|
||||
| "minecraft:sugar"
|
||||
| "minecraft:hay_block"
|
||||
| "minecraft:apple"
|
||||
| "minecraft:carrot"
|
||||
| "minecraft:golden_carrot"
|
||||
| "minecraft:golden_apple"
|
||||
| "minecraft:enchanted_golden_apple"
|
||||
),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn mob(kind: &str) -> Option<Mob> {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("entity-types.json").exists() {
|
||||
return None;
|
||||
}
|
||||
let data = horizon_client::mobs::MobTypes::load(&root).ok()?;
|
||||
let kind = Arc::new(data.types.get(kind)?.clone());
|
||||
Some(Mob::new(
|
||||
1,
|
||||
kind,
|
||||
horizon_core::geometry::Vec3::new(0.0, 4.0, 0.0),
|
||||
0.0,
|
||||
))
|
||||
}
|
||||
|
||||
/// `Sheep.mobInteract` and `DyeItem.interactLivingEntity` on a client (#9121).
|
||||
#[test]
|
||||
fn a_sheep_takes_shears_always_and_dye_only_to_change_its_colour() {
|
||||
for sheared in [false, true] {
|
||||
assert!(
|
||||
sheep_takes("minecraft:shears", sheared, 0),
|
||||
"shears, sheared {sheared}"
|
||||
);
|
||||
}
|
||||
assert!(sheep_takes("minecraft:red_dye", false, 0));
|
||||
assert!(!sheep_takes("minecraft:white_dye", false, 0), "same colour");
|
||||
assert!(sheep_takes("minecraft:white_dye", false, 14));
|
||||
assert!(!sheep_takes("minecraft:red_dye", false, 14), "same colour");
|
||||
assert!(!sheep_takes("minecraft:red_dye", true, 0), "sheared");
|
||||
assert!(!sheep_takes("minecraft:stick", false, 0));
|
||||
}
|
||||
|
||||
/// `AbstractNautilus.mobInteract` (#8595): an untamed adult takes its taming
|
||||
/// items only, and a tame adult is mounted by any click.
|
||||
#[test]
|
||||
fn a_nautilus_answers_its_food_and_a_tame_one_is_ridden() {
|
||||
assert!(
|
||||
!nautilus_takes(false, false, false),
|
||||
"untamed, taming item missing"
|
||||
);
|
||||
assert!(nautilus_takes(true, false, false), "a taming item");
|
||||
assert!(
|
||||
nautilus_takes(false, true, false),
|
||||
"a tame adult is mounted"
|
||||
);
|
||||
assert!(!nautilus_takes(false, true, true), "a baby is not mounted");
|
||||
|
||||
// `AbstractNautilus.isFood`: taming items while untamed, its food after.
|
||||
let Some(mut m) = mob("minecraft:nautilus") else {
|
||||
return;
|
||||
};
|
||||
let taming = vec!["minecraft:pufferfish".to_string()];
|
||||
m.extra.age = 0;
|
||||
m.extra.tame = false;
|
||||
assert!(nautilus::is_food(&m, "minecraft:pufferfish", &taming));
|
||||
assert!(
|
||||
!nautilus::is_food(&m, "minecraft:cod", &taming),
|
||||
"cod tames nothing"
|
||||
);
|
||||
m.extra.tame = true;
|
||||
assert!(
|
||||
nautilus::is_food(&m, "minecraft:cod", &taming),
|
||||
"a tame one eats cod"
|
||||
);
|
||||
}
|
||||
|
||||
/// `IronGolem.mobInteract` and `CopperGolem.mobInteract` (#8630): the click
|
||||
/// has to end on the golem, or the client goes on to use the item and turns
|
||||
/// to the off hand.
|
||||
#[test]
|
||||
fn the_golems_answer_the_click() {
|
||||
assert!(iron_golem_takes("minecraft:iron_ingot", 50.0, 100.0));
|
||||
assert!(
|
||||
!iron_golem_takes("minecraft:iron_ingot", 100.0, 100.0),
|
||||
"full"
|
||||
);
|
||||
assert!(
|
||||
!iron_golem_takes("minecraft:stick", 50.0, 100.0),
|
||||
"not ingot"
|
||||
);
|
||||
|
||||
assert!(copper_golem_takes("", true, false), "takes its item back");
|
||||
assert!(!copper_golem_takes("", false, false), "nothing to take");
|
||||
assert!(
|
||||
copper_golem_takes("minecraft:shears", false, true),
|
||||
"shears"
|
||||
);
|
||||
assert!(
|
||||
!copper_golem_takes("minecraft:shears", false, false),
|
||||
"no flower"
|
||||
);
|
||||
assert!(
|
||||
!copper_golem_takes("minecraft:honeycomb", false, true),
|
||||
"waxing is server-only"
|
||||
);
|
||||
assert!(
|
||||
!copper_golem_takes("minecraft:iron_axe", false, true),
|
||||
"scraping too"
|
||||
);
|
||||
}
|
||||
|
||||
/// `Allay.mobInteract` (#8629): a holding allay gives its item back only to
|
||||
/// an empty **main** hand, so an item in hand is still used and an empty
|
||||
/// off hand does not take the item.
|
||||
#[test]
|
||||
fn a_holding_allay_only_answers_an_empty_main_hand() {
|
||||
assert!(allay_takes(Hand::Main, "", true, false), "main hand, empty");
|
||||
assert!(!allay_takes(Hand::Off, "", true, false), "off hand, empty");
|
||||
assert!(
|
||||
!allay_takes(Hand::Main, "minecraft:bread", true, false),
|
||||
"item in hand"
|
||||
);
|
||||
assert!(!allay_takes(Hand::Off, "minecraft:bread", true, false));
|
||||
assert!(
|
||||
allay_takes(Hand::Main, "minecraft:stick", false, false),
|
||||
"give it one"
|
||||
);
|
||||
assert!(
|
||||
allay_takes(Hand::Off, "minecraft:stick", false, false),
|
||||
"either hand gives"
|
||||
);
|
||||
assert!(
|
||||
!allay_takes(Hand::Main, "", false, false),
|
||||
"empty hand gives nothing"
|
||||
);
|
||||
assert!(
|
||||
allay_takes(Hand::Main, "minecraft:amethyst_shard", true, true),
|
||||
"a dancing allay duplicates"
|
||||
);
|
||||
}
|
||||
|
||||
/// `AbstractHorse.handleEating` draws the particle only while the baby can
|
||||
/// still grow: `isBaby() && !isAgeLocked()` (#8590).
|
||||
#[test]
|
||||
fn a_locked_baby_draws_no_happy_villager() {
|
||||
let Some(mut horse) = mob("minecraft:horse") else {
|
||||
return;
|
||||
};
|
||||
horse.extra.age = -24000;
|
||||
assert!(ages_on_food(&horse, "minecraft:wheat"));
|
||||
horse.extra.age_locked = true;
|
||||
assert!(!ages_on_food(&horse, "minecraft:wheat"), "locked");
|
||||
horse.extra.age_locked = false;
|
||||
horse.extra.age = 0;
|
||||
assert!(!ages_on_food(&horse, "minecraft:wheat"), "adult");
|
||||
horse.extra.age = -24000;
|
||||
assert!(!ages_on_food(&horse, "minecraft:red_mushroom"), "no food");
|
||||
|
||||
if let Some(mut llama) = mob("minecraft:llama") {
|
||||
llama.extra.age = -24000;
|
||||
assert!(ages_on_food(&llama, "minecraft:hay_block"));
|
||||
llama.extra.age_locked = true;
|
||||
assert!(!ages_on_food(&llama, "minecraft:hay_block"), "locked llama");
|
||||
}
|
||||
if let Some(mut camel) = mob("minecraft:camel")
|
||||
&& camel.kind.food.iter().any(|f| f == "minecraft:cactus")
|
||||
{
|
||||
camel.extra.age = -24000;
|
||||
assert!(ages_on_food(&camel, "minecraft:cactus"));
|
||||
camel.extra.age_locked = true;
|
||||
assert!(!ages_on_food(&camel, "minecraft:cactus"), "locked camel");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -443,6 +443,11 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// Every object a server sent (a new connection, level or dimension).
|
||||
/// Experience orbs too: `ClientPacketListener.handleRespawn` replaces the
|
||||
/// `ClientLevel` on a dimension change, and `Hub::change_level` does not
|
||||
/// remove the old level's entities one by one. An orb kept here follows
|
||||
/// the player (`ExperienceOrb.tick` on the client) and is never
|
||||
/// `playerTouch`ed.
|
||||
pub(super) fn clear_remote_objects(&mut self) {
|
||||
self.text_displays.clear();
|
||||
self.interactions.clear();
|
||||
@@ -454,6 +459,7 @@ impl Game<'_> {
|
||||
self.cushions.clear();
|
||||
self.item_frames.clear();
|
||||
self.paintings.clear();
|
||||
self.orbs.clear();
|
||||
}
|
||||
|
||||
pub(super) fn remove_remote_object(&mut self, id: u32) {
|
||||
|
||||
@@ -344,6 +344,7 @@ impl Game<'_> {
|
||||
match kind {
|
||||
"minecraft:evoker_fangs" => self.insert_fangs(id, position, yaw, 0, 0),
|
||||
"minecraft:tnt" => self.tnts.push(super::tnt::PrimedTnt {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id,
|
||||
@@ -359,6 +360,7 @@ impl Game<'_> {
|
||||
}),
|
||||
// FallingBlockEntity.recreateFromPacket: the block state is the data.
|
||||
"minecraft:falling_block" => self.falling.push(FallingBlock {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
id,
|
||||
state: BlockStateId(data.max(0) as u32),
|
||||
@@ -663,17 +665,18 @@ impl Game<'_> {
|
||||
// fuse down (the swell and flash follow it) between the server's
|
||||
// updates (every 10 ticks), and a client discards it when the fuse
|
||||
// runs out; only the server explodes it.
|
||||
let (world, collision, assets, facts, fluids, border) = (
|
||||
let (world, collision, assets, facts, fluids, border, blocks) = (
|
||||
&self.world,
|
||||
self.collision,
|
||||
&self.pack.assets,
|
||||
self.res.facts(),
|
||||
&self.res.motion().fluids,
|
||||
Some(self.border.geometry()),
|
||||
Some(&self.res.motion().blocks),
|
||||
);
|
||||
self.tnts.retain_mut(|t| {
|
||||
// A fluid lookup that fails leaves it where its move put it.
|
||||
let _ = t.tick(world, collision, assets, facts, fluids, border);
|
||||
let _ = t.tick(world, collision, assets, facts, fluids, border, blocks);
|
||||
t.fuse > 0
|
||||
});
|
||||
// PrimedTnt.tick: while the fuse is still lit, a client adds one SMOKE
|
||||
|
||||
@@ -293,9 +293,15 @@ impl Game<'_> {
|
||||
table.len() / 3,
|
||||
started_ids.elapsed().as_millis()
|
||||
);
|
||||
if let Some((programs, notes)) =
|
||||
renderer.set_shader_pack(dimension, Some(post), &table)
|
||||
{
|
||||
let report = renderer
|
||||
.set_shader_pack(dimension, Some(post), &table)
|
||||
.map_err(|refused| {
|
||||
for note in refused.notes {
|
||||
log::warn!("shader pack {name}: {note}");
|
||||
}
|
||||
refused.reason
|
||||
})?;
|
||||
if let Some((programs, notes)) = report {
|
||||
for note in notes {
|
||||
log::warn!("shader pack {name}: {note}");
|
||||
}
|
||||
@@ -310,12 +316,15 @@ impl Game<'_> {
|
||||
Err(e) => {
|
||||
self.pack_ids = None;
|
||||
log::warn!("shader pack {name}: {e}");
|
||||
renderer.set_shader_pack(dimension, None, &[]);
|
||||
// Without a pack this can't fail.
|
||||
let _ = renderer.set_shader_pack(dimension, None, &[]);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
if let Err(panic) = loaded {
|
||||
self.shader_pack_failed(name, dimension, &panic);
|
||||
// A pack that can't run is turned off like one that panics.
|
||||
if let Err(error) = loaded.flatten() {
|
||||
self.shader_pack_failed(name, dimension, &error);
|
||||
}
|
||||
}
|
||||
let lerp = |(old, new): (f32, f32)| old + (new - old) * partial;
|
||||
@@ -445,14 +454,16 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns off the pack `name` after loading it panicked: nothing runs in its place, it is no
|
||||
/// longer the picked pack (so the next start does not load it again), and the chat says so.
|
||||
/// Turns off the pack `name` after loading it panicked or failed: nothing runs in its place,
|
||||
/// it is no longer the picked pack (so the next start does not load it again), and the chat
|
||||
/// says so.
|
||||
fn shader_pack_failed(&mut self, name: &str, dimension: i32, error: &str) {
|
||||
log::error!("shader pack {name}: turned off: {error}");
|
||||
self.pack_ids = None;
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
// Marks this dimension as loaded, without a pack, so it is not tried again.
|
||||
renderer.set_shader_pack(dimension, None, &[]);
|
||||
// Marks this dimension as loaded, without a pack, so it is not tried again (without
|
||||
// a pack this can't fail).
|
||||
let _ = renderer.set_shader_pack(dimension, None, &[]);
|
||||
}
|
||||
if self.settings.horizon.shader_pack == name {
|
||||
self.settings.horizon.shader_pack.clear();
|
||||
|
||||
@@ -15,6 +15,8 @@ use horizon_world::level::flags as level_flags;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct PrimedTnt {
|
||||
/// `Entity.yRot` and `xRot` (`/tp`, `/data get ... Rotation`).
|
||||
pub rotation: [f32; 2],
|
||||
/// When it joined its list (`horizon_client::join_seq`); set by the list.
|
||||
pub(super) join: horizon_client::join_seq::JoinSeq,
|
||||
/// The entity id a server tracks it by (0 until it is tracked).
|
||||
@@ -66,6 +68,7 @@ impl PrimedTnt {
|
||||
facts: &horizon_client::blocks::BlockFacts,
|
||||
fluids: &horizon_client::motion::FluidResources,
|
||||
border: Option<&horizon_core::geometry::border::BorderGeometry>,
|
||||
blocks: Option<&horizon_world::movement_properties::BoundMovementProperties>,
|
||||
) -> Result<Vec<BlockPos>> {
|
||||
self.previous = self.position;
|
||||
self.velocity.y -= self.get_gravity();
|
||||
@@ -109,6 +112,13 @@ impl PrimedTnt {
|
||||
&*facts.get(below).name == "minecraft:slime_block",
|
||||
);
|
||||
}
|
||||
// Entity.move's last step: soul sand and honey slow the TNT's
|
||||
// horizontal speed. `blocks` is None only for the isolated unit tests.
|
||||
if let Some(blocks) = blocks {
|
||||
let factor =
|
||||
horizon_client::motion::block_speed_factor_at(world, blocks, self.position, None);
|
||||
self.velocity = horizon_world::entity_move::scale_by_block_speed(self.velocity, factor);
|
||||
}
|
||||
// Entity.applyEffectsFromBlocks: a bubble column lifts or drags it, and the
|
||||
// columns it is at the top of send their surface particles.
|
||||
let mut tops = Vec::new();
|
||||
@@ -225,6 +235,7 @@ impl Game<'_> {
|
||||
// An id from the entity counter at once, so a command can name it.
|
||||
self.next_entity += 1;
|
||||
self.tnts.push(PrimedTnt {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: self.next_entity,
|
||||
@@ -254,6 +265,7 @@ impl Game<'_> {
|
||||
self.res.facts(),
|
||||
&self.res.motion().fluids,
|
||||
Some(self.border.geometry()),
|
||||
Some(&self.res.motion().blocks),
|
||||
)?;
|
||||
let snow = (t.previous, t.position, t.bounds(), t.velocity);
|
||||
self.entity_powder_snow_particles(snow.0, snow.1, snow.2, snow.3, false);
|
||||
@@ -537,6 +549,7 @@ mod tests {
|
||||
]))
|
||||
.unwrap();
|
||||
let mut tnt = PrimedTnt {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
id: 1,
|
||||
position: Vec3::ZERO,
|
||||
@@ -564,6 +577,7 @@ mod tests {
|
||||
tnt.fuse,
|
||||
tnt.power,
|
||||
tnt.block_state_nbt(®istry),
|
||||
[0.0, 0.0],
|
||||
[221, 0, 0, 1],
|
||||
Some(&raw),
|
||||
);
|
||||
@@ -597,6 +611,7 @@ mod tests {
|
||||
use horizon_core::geometry::Direction;
|
||||
use horizon_world::piston_push::bounce_velocity;
|
||||
let mut tnt = PrimedTnt {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: 0,
|
||||
@@ -639,6 +654,7 @@ mod tests {
|
||||
for bordered in [false, true] {
|
||||
let at = Vec3::new(24.0, 4.0, 20.5);
|
||||
let mut tnt = PrimedTnt {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: 0,
|
||||
@@ -653,7 +669,7 @@ mod tests {
|
||||
block_state: None,
|
||||
};
|
||||
let border = bordered.then_some(&border);
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, border)
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, border, None)
|
||||
.unwrap();
|
||||
let east = tnt.bounds().max_position().x;
|
||||
if bordered {
|
||||
@@ -667,6 +683,77 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.move`'s last step, `getBlockSpeedFactor`: primed TNT sliding
|
||||
/// over soul sand or honey keeps 40% of the horizontal speed it keeps over
|
||||
/// stone. Skips quietly without the vanilla data (CI has none).
|
||||
#[test]
|
||||
fn primed_tnt_on_soul_sand_or_honey_is_slowed_by_the_move() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = horizon_client::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = horizon_client::blocks::BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let fluids = horizon_client::motion::FluidResources::new(&assets, &facts).unwrap();
|
||||
let collision = horizon_client::collisions::load(&root, assets.registry).unwrap();
|
||||
let motion = horizon_client::motion::MotionResources::load(&root, &assets).unwrap();
|
||||
let mut after = Vec::new();
|
||||
// The tops of the blocks: stone 1, soul sand 7/8, honey 15/16.
|
||||
for (block, top) in [
|
||||
("minecraft:stone", 4.0),
|
||||
("minecraft:soul_sand", 3.875),
|
||||
("minecraft:honey_block", 3.9375),
|
||||
] {
|
||||
let mut world = horizon_client::world::World::test_scene(&assets, &render, 3).unwrap();
|
||||
let id = assets.registry.default_state(block).unwrap();
|
||||
for x in 18..=23 {
|
||||
for z in 19..=21 {
|
||||
world.set(x, 3, z, id).unwrap();
|
||||
}
|
||||
}
|
||||
let at = Vec3::new(19.5, top, 20.5);
|
||||
let mut tnt = PrimedTnt {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: 0,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity: Vec3::new(0.2, 0.0, 0.0),
|
||||
fuse: 80,
|
||||
on_ground: true,
|
||||
owner: None,
|
||||
guest: None,
|
||||
power: 4.0,
|
||||
block_state: None,
|
||||
};
|
||||
tnt.tick(
|
||||
&world,
|
||||
&collision,
|
||||
&assets,
|
||||
&facts,
|
||||
&fluids,
|
||||
None,
|
||||
Some(&motion.blocks),
|
||||
)
|
||||
.unwrap();
|
||||
after.push(tnt.velocity.x);
|
||||
}
|
||||
let (stone, soul, honey) = (after[0], after[1], after[2]);
|
||||
assert!(stone > 0.0);
|
||||
assert!(
|
||||
(soul - stone * f64::from(0.4_f32)).abs() < 1.0e-9,
|
||||
"soul sand {soul} vs stone {stone}"
|
||||
);
|
||||
assert!(
|
||||
(honey - stone * f64::from(0.4_f32)).abs() < 1.0e-9,
|
||||
"honey {honey} vs stone {stone}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primed_tnt_follows_water_currents_after_moving() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
@@ -695,6 +782,7 @@ mod tests {
|
||||
world.set(21, 4, 20, flowing).unwrap();
|
||||
let at = Vec3::new(20.5, 4.0, 20.5);
|
||||
let mut tnt = PrimedTnt {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: 0,
|
||||
@@ -708,7 +796,7 @@ mod tests {
|
||||
power: 4.0,
|
||||
block_state: None,
|
||||
};
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, None, None)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
tnt.position.x, at.x,
|
||||
@@ -720,14 +808,14 @@ mod tests {
|
||||
tnt.velocity
|
||||
);
|
||||
assert_eq!(tnt.fuse, 79);
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, None, None)
|
||||
.unwrap();
|
||||
assert!((tnt.position.x - at.x - 0.014).abs() < 1e-12);
|
||||
assert!(tnt.on_ground);
|
||||
tnt.position = at;
|
||||
tnt.velocity = Vec3::ZERO;
|
||||
tnt.fuse = 1;
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, None, None)
|
||||
.unwrap();
|
||||
assert_eq!(tnt.fuse, 0);
|
||||
assert_eq!(
|
||||
@@ -738,7 +826,7 @@ mod tests {
|
||||
tnt.position = at;
|
||||
tnt.velocity = Vec3::ZERO;
|
||||
tnt.fuse = 80;
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, None, None)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
tnt.velocity.x, 0.0,
|
||||
@@ -754,7 +842,7 @@ mod tests {
|
||||
.unwrap();
|
||||
tnt.on_ground = false;
|
||||
tnt.velocity = Vec3::ZERO;
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, None, None)
|
||||
.unwrap();
|
||||
assert!(
|
||||
tnt.position.y < at.y && tnt.velocity.y < 0.0,
|
||||
@@ -799,6 +887,7 @@ mod tests {
|
||||
// (Entity.handleOnInsideBubbleColumn / handleOnAboveBubbleColumn).
|
||||
let at = Vec3::new(20.5, 6.0, 20.5);
|
||||
let mut tnt = PrimedTnt {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: 0,
|
||||
@@ -813,7 +902,7 @@ mod tests {
|
||||
block_state: None,
|
||||
};
|
||||
for _ in 0..10 {
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, None, None)
|
||||
.unwrap();
|
||||
}
|
||||
assert!(
|
||||
@@ -834,7 +923,7 @@ mod tests {
|
||||
tnt.velocity = Vec3::ZERO;
|
||||
tnt.on_ground = false;
|
||||
for _ in 0..4 {
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, None, None)
|
||||
.unwrap();
|
||||
}
|
||||
assert!(
|
||||
@@ -879,6 +968,7 @@ mod tests {
|
||||
world.set(20, 2, 20, soul_sand).unwrap();
|
||||
let at = Vec3::new(20.5, 6.0, 20.5);
|
||||
let primed = || PrimedTnt {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: 0,
|
||||
@@ -896,7 +986,7 @@ mod tests {
|
||||
let mut tnt = primed();
|
||||
for _ in 0..30 {
|
||||
tops.extend(
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, None, None)
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
@@ -910,7 +1000,7 @@ mod tests {
|
||||
let mut tnt = primed();
|
||||
for _ in 0..10 {
|
||||
assert!(
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, None, None)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ use horizon_client::{
|
||||
enchant::EffectContext,
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
mobs::{Damage, DamageKind, Who, anim::Poses},
|
||||
mobs::{Damage, DamageKind, Species, Who, anim::Poses},
|
||||
projectiles::ProjectileWorld,
|
||||
};
|
||||
use horizon_core::{
|
||||
@@ -45,8 +45,10 @@ pub(super) struct ThrownTrident {
|
||||
pub(super) fire_ticks: i32,
|
||||
/// Water membership from the preceding `baseTick` (not updated while stuck).
|
||||
was_touching_water: bool,
|
||||
/// `Entity.random`, used for the extinguishing sound.
|
||||
/// `Entity.random`, used for the extinguishing sound and a breeze's turn.
|
||||
random: horizon_core::random::LegacyRandom,
|
||||
/// `Projectile.lastDeflectedBy`: the breeze that already turned this trident.
|
||||
deflected_by: Option<u32>,
|
||||
/// `Entity.tickCount`: `ProjectileUtil.computeMargin` grows with it.
|
||||
age: u32,
|
||||
/// The entity id a server sent as its owner (the one Loyalty returns it to).
|
||||
@@ -133,6 +135,7 @@ impl ThrownTrident {
|
||||
age: 0,
|
||||
owner: 0,
|
||||
guest: None,
|
||||
deflected_by: None,
|
||||
}
|
||||
}
|
||||
/// `ThrownTrident.tick`'s Loyalty return: pulled up and towards the
|
||||
@@ -203,6 +206,15 @@ impl ThrownTrident {
|
||||
}
|
||||
}
|
||||
const HALF: f64 = 0.25;
|
||||
/// `Breeze.PROJECTILE_DEFLECTION`: `ProjectileDeflection.REVERSE` at power
|
||||
/// `(1, 1, 1)`. Each axis is multiplied by `-0.5`, and the yaw turns by
|
||||
/// `170 + nextFloat * 20`.
|
||||
fn breeze_deflect(velocity: Vec3, yaw: f32, jitter: f32) -> (Vec3, f32) {
|
||||
(
|
||||
Vec3::new(velocity.x * -0.5, velocity.y * -0.5, velocity.z * -0.5),
|
||||
yaw + 170.0 + jitter * 20.0,
|
||||
)
|
||||
}
|
||||
/// The entry distance of a segment into a box, if it crosses it.
|
||||
fn segment_box(from: Vec3, d: Vec3, b: Aabb) -> Option<f64> {
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
@@ -465,6 +477,7 @@ impl Game<'_> {
|
||||
age: 0,
|
||||
owner: 0,
|
||||
guest: self.lan_acting,
|
||||
deflected_by: None,
|
||||
});
|
||||
self.player_projectile_shot();
|
||||
self.audio
|
||||
@@ -686,110 +699,136 @@ impl Game<'_> {
|
||||
// No damage, sound or deflection: it flies on.
|
||||
} else if let Some((id, d)) = hit_mob {
|
||||
// The trident stops where it met the box.
|
||||
t.position = Vec3::new(
|
||||
let at = Vec3::new(
|
||||
t.position.x + t.velocity.x * d,
|
||||
t.position.y + t.velocity.y * d,
|
||||
t.position.z + t.velocity.z * d,
|
||||
);
|
||||
// ThrownTrident.getDamage: `EnchantmentHelper.modifyDamage` on 8
|
||||
// (impaling hits `#sensitive_to_impaling`, the aquatic tag).
|
||||
let victim = self
|
||||
t.position = at;
|
||||
// `Breeze.deflection`: a trident is not a wind charge, so
|
||||
// `PROJECTILE_DEFLECTION` turns it around. `onHit` does not
|
||||
// run, so the breeze takes no damage and Channeling never fires.
|
||||
let breeze = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.map(|m| m.kind.id.clone())
|
||||
.or_else(|| {
|
||||
self.boats
|
||||
.iter()
|
||||
.find(|b| b.id == id)
|
||||
.map(|b| b.entity_type())
|
||||
})
|
||||
.or_else(|| {
|
||||
self.carts
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
.map(|c| c.kind.id().to_owned())
|
||||
});
|
||||
let ctx = EffectContext {
|
||||
this: victim.as_deref(),
|
||||
attacker: Some("minecraft:player"),
|
||||
direct_attacker: Some("minecraft:trident"),
|
||||
is_direct: true,
|
||||
damage_type: Some("minecraft:trident"),
|
||||
items: Some(world_items!(self)),
|
||||
..Default::default()
|
||||
};
|
||||
let damage = world_stations!(self).enchantments.modify_value(
|
||||
Some(&t.stack),
|
||||
"minecraft:damage",
|
||||
&ctx,
|
||||
&mut self.world.random,
|
||||
8.0,
|
||||
);
|
||||
t.dealt_damage = true;
|
||||
let mut hurt_at = None;
|
||||
let aim = self.attacker_aim(Some(Who::Player));
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
let from = Some([t.position.x, t.position.y, t.position.z]);
|
||||
let hurt = m.damage_projectile(
|
||||
damage,
|
||||
Damage {
|
||||
kind: DamageKind::Arrow,
|
||||
attacker: Some(Who::Player),
|
||||
from,
|
||||
},
|
||||
aim,
|
||||
[t.velocity.x, t.velocity.z],
|
||||
);
|
||||
if hurt {
|
||||
hurt_at = Some([m.position.x, m.position.y, m.position.z]);
|
||||
.find(|m| m.id == id && m.alive() && m.species() == Species::Breeze);
|
||||
if let Some(breeze) = breeze {
|
||||
if t.deflected_by != Some(id) {
|
||||
let sound_at =
|
||||
[breeze.position.x, breeze.position.y, breeze.position.z];
|
||||
self.audio.play(
|
||||
"minecraft:entity.breeze.deflect",
|
||||
Some(sound_at),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
let jitter = t.random.next_float();
|
||||
let (velocity, yaw) = breeze_deflect(t.velocity, t.yaw, jitter);
|
||||
t.velocity = velocity;
|
||||
t.yaw = yaw;
|
||||
t.deflected_by = Some(id);
|
||||
}
|
||||
}
|
||||
// ThrownTrident.onHitEntity on a boat or minecart:
|
||||
// VehicleEntity.hurtServer takes the damage.
|
||||
let creative = t.guest.is_none() && self.creative;
|
||||
if let Some(i) = self.boats.iter().position(|b| b.id == id) {
|
||||
self.hurt_boat(i, damage, creative);
|
||||
} else if let Some(i) = self.carts.iter().position(|c| c.id == id) {
|
||||
self.hurt_cart(i, damage, creative, false);
|
||||
}
|
||||
// `minecraft:post_attack` of Channeling, on the victim.
|
||||
if let Some(at) = hurt_at
|
||||
&& t.stack.enchantment_level("minecraft:channeling") > 0
|
||||
{
|
||||
self.channel_lightning(at, t.guest);
|
||||
}
|
||||
if hurt_at.is_some()
|
||||
&& let Some(m) = self.mobs.iter().find(|m| m.id == id)
|
||||
{
|
||||
// LivingEntity.hurtServer: PLAYER_HURT_ENTITY.
|
||||
let victim = self.mob_entity(m);
|
||||
let direct = horizon_client::advancements::EntityView {
|
||||
kind: "minecraft:trident".into(),
|
||||
position: [t.position.x, t.position.y, t.position.z],
|
||||
} else {
|
||||
// ThrownTrident.getDamage: `EnchantmentHelper.modifyDamage` on 8
|
||||
// (impaling hits `#sensitive_to_impaling`, the aquatic tag).
|
||||
let victim = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.map(|m| m.kind.id.clone())
|
||||
.or_else(|| {
|
||||
self.boats
|
||||
.iter()
|
||||
.find(|b| b.id == id)
|
||||
.map(|b| b.entity_type())
|
||||
})
|
||||
.or_else(|| {
|
||||
self.carts
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
.map(|c| c.kind.id().to_owned())
|
||||
});
|
||||
let ctx = EffectContext {
|
||||
this: victim.as_deref(),
|
||||
attacker: Some("minecraft:player"),
|
||||
direct_attacker: Some("minecraft:trident"),
|
||||
is_direct: true,
|
||||
damage_type: Some("minecraft:trident"),
|
||||
items: Some(world_items!(self)),
|
||||
..Default::default()
|
||||
};
|
||||
self.thrower_hit(t.guest, &victim, direct, damage);
|
||||
let damage = world_stations!(self).enchantments.modify_value(
|
||||
Some(&t.stack),
|
||||
"minecraft:damage",
|
||||
&ctx,
|
||||
&mut self.world.random,
|
||||
8.0,
|
||||
);
|
||||
t.dealt_damage = true;
|
||||
let mut hurt_at = None;
|
||||
let aim = self.attacker_aim(Some(Who::Player));
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
let from = Some([t.position.x, t.position.y, t.position.z]);
|
||||
let hurt = m.damage_projectile(
|
||||
damage,
|
||||
Damage {
|
||||
kind: DamageKind::Arrow,
|
||||
attacker: Some(Who::Player),
|
||||
from,
|
||||
},
|
||||
aim,
|
||||
[t.velocity.x, t.velocity.z],
|
||||
);
|
||||
if hurt {
|
||||
hurt_at = Some([m.position.x, m.position.y, m.position.z]);
|
||||
}
|
||||
}
|
||||
// ThrownTrident.onHitEntity on a boat or minecart:
|
||||
// VehicleEntity.hurtServer takes the damage.
|
||||
let creative = t.guest.is_none() && self.creative;
|
||||
if let Some(i) = self.boats.iter().position(|b| b.id == id) {
|
||||
self.hurt_boat(i, damage, creative);
|
||||
} else if let Some(i) = self.carts.iter().position(|c| c.id == id) {
|
||||
self.hurt_cart(i, damage, creative, false);
|
||||
}
|
||||
// `minecraft:post_attack` of Channeling, on the victim.
|
||||
if let Some(at) = hurt_at
|
||||
&& t.stack.enchantment_level("minecraft:channeling") > 0
|
||||
{
|
||||
self.channel_lightning(at, t.guest);
|
||||
}
|
||||
if hurt_at.is_some()
|
||||
&& let Some(m) = self.mobs.iter().find(|m| m.id == id)
|
||||
{
|
||||
// LivingEntity.hurtServer: PLAYER_HURT_ENTITY.
|
||||
let victim = self.mob_entity(m);
|
||||
let direct = horizon_client::advancements::EntityView {
|
||||
kind: "minecraft:trident".into(),
|
||||
position: [t.position.x, t.position.y, t.position.z],
|
||||
..Default::default()
|
||||
};
|
||||
self.thrower_hit(t.guest, &victim, direct, damage);
|
||||
}
|
||||
// Projectile.onHit: the landing is heard, at the hit.
|
||||
self.projectile_event(
|
||||
"minecraft:projectile_land",
|
||||
Some(Who::Player),
|
||||
[t.position.x, t.position.y, t.position.z],
|
||||
);
|
||||
self.audio.play(
|
||||
"minecraft:item.trident.hit",
|
||||
Some([t.position.x, t.position.y, t.position.z]),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
// ProjectileDeflection.REVERSE (half, reversed) scaled by
|
||||
// (0.02, 0.2, 0.02).
|
||||
t.velocity = Vec3::new(
|
||||
t.velocity.x * (-0.02 * 0.5),
|
||||
t.velocity.y * (-0.2 * 0.5),
|
||||
t.velocity.z * (-0.02 * 0.5),
|
||||
);
|
||||
}
|
||||
// Projectile.onHit: the landing is heard, at the hit.
|
||||
self.projectile_event(
|
||||
"minecraft:projectile_land",
|
||||
Some(Who::Player),
|
||||
[t.position.x, t.position.y, t.position.z],
|
||||
);
|
||||
self.audio.play(
|
||||
"minecraft:item.trident.hit",
|
||||
Some([t.position.x, t.position.y, t.position.z]),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
// ProjectileDeflection.REVERSE (half, reversed) scaled by
|
||||
// (0.02, 0.2, 0.02).
|
||||
t.velocity = Vec3::new(
|
||||
t.velocity.x * (-0.02 * 0.5),
|
||||
t.velocity.y * (-0.2 * 0.5),
|
||||
t.velocity.z * (-0.02 * 0.5),
|
||||
);
|
||||
} else {
|
||||
// AbstractArrow.tick: a zero-width ray clip of the movement
|
||||
// (`ClipContext.Block.COLLIDER`, no fluids), then
|
||||
@@ -1028,6 +1067,18 @@ horizon_client::impl_joined!(ThrownTrident);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::breeze_deflect;
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
/// `ProjectileDeflection.REVERSE` at power 1: half speed, the other way,
|
||||
/// and a turn of 170 degrees plus a fifth of the random draw.
|
||||
#[test]
|
||||
fn a_breeze_turns_a_trident_around_at_half_speed() {
|
||||
let (velocity, yaw) = breeze_deflect(Vec3::new(4.0, -2.0, 1.0), 10.0, 0.25);
|
||||
assert_eq!(velocity, Vec3::new(-2.0, 1.0, -0.5));
|
||||
assert_eq!(yaw, 185.0);
|
||||
}
|
||||
|
||||
/// A stack can reach its final durability point after use began. The release
|
||||
/// guard is independent of admission, and short charges still do nothing.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
//! The writers `/tp` and `/rotate` share with a vehicle's riders: a vehicle
|
||||
//! that is teleported takes its passengers along in the same operation
|
||||
//! (`Entity.teleportSameDimension` -> `teleportPassengers` with
|
||||
//! `calculatePassengerTransition`), and `/rotate` turns an entity without
|
||||
//! teleporting it (`RotateCommand` -> `Entity.forceSetRotation`).
|
||||
use super::Game;
|
||||
use super::commands::Target;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
|
||||
/// `Entity.calculatePassengerTransition` with no relative axes: the passenger
|
||||
/// keeps its offset from the vehicle.
|
||||
pub(super) fn passenger_destination(old_vehicle: Vec3, new_vehicle: Vec3, passenger: Vec3) -> Vec3 {
|
||||
Vec3::new(
|
||||
new_vehicle.x + (passenger.x - old_vehicle.x),
|
||||
new_vehicle.y + (passenger.y - old_vehicle.y),
|
||||
new_vehicle.z + (passenger.z - old_vehicle.z),
|
||||
)
|
||||
}
|
||||
|
||||
/// `PositionMoveRotation.calculateAbsolute` then `Entity.setXRot`: the pitch
|
||||
/// is clamped to -90..90 first (so 360 and +infinity are +90), and NaN is
|
||||
/// discarded (`None`).
|
||||
pub(super) fn absolute_pitch(pitch: f32) -> Option<f32> {
|
||||
let clamped = pitch.clamp(-90.0, 90.0);
|
||||
(!clamped.is_nan()).then(|| (clamped % 360.0).clamp(-90.0, 90.0))
|
||||
}
|
||||
|
||||
/// The entity id a vehicle's riders point at, for the targets that carry some.
|
||||
fn vehicle_id(t: Target) -> Option<u32> {
|
||||
match t {
|
||||
Target::Boat(id) | Target::Cart(id) | Target::Mob(id) => Some(id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Entity.teleportPassengers`: after `t` moved from `from` to `to`, its
|
||||
/// riders move by the same offset and stay seated, recursively. The
|
||||
/// player in place and the hub's guests (a guest also gets its own
|
||||
/// position packet, as `ServerPlayer.teleport` sends one for a passenger).
|
||||
pub(super) fn teleport_passengers(&mut self, t: Target, from: [f64; 3], to: [f64; 3]) {
|
||||
let Some(id) = vehicle_id(t) else {
|
||||
return;
|
||||
};
|
||||
let (old, new) = (
|
||||
Vec3::new(from[0], from[1], from[2]),
|
||||
Vec3::new(to[0], to[1], to[2]),
|
||||
);
|
||||
self.move_riders(id, old, new);
|
||||
}
|
||||
fn move_riders(&mut self, vehicle: u32, old: Vec3, new: Vec3) {
|
||||
let riders: Vec<u32> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.extra.mount == Some(vehicle))
|
||||
.map(|m| m.id)
|
||||
.collect();
|
||||
for id in riders {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.position = passenger_destination(old, new, m.position);
|
||||
m.previous = m.position;
|
||||
}
|
||||
self.move_riders(id, old, new);
|
||||
}
|
||||
if self.riding == Some(vehicle)
|
||||
&& let Some(p) = self.player.as_mut()
|
||||
{
|
||||
p.position = passenger_destination(old, new, p.position);
|
||||
self.previous_position = p.position;
|
||||
}
|
||||
let [yaw, pitch] = self.vehicle_rotation(vehicle);
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
for i in 0..host.hub.guests.len() {
|
||||
if host.hub.guests[i].vehicle != Some(vehicle) {
|
||||
continue;
|
||||
}
|
||||
// Entity.sendTeleportTransitionToRidingPlayers: the rider's
|
||||
// client moves the vehicle it drives itself, so it hears the
|
||||
// vehicle's teleport (else its next MoveVehicle undoes it).
|
||||
host.hub.guests[i].send(&C::TeleportEntity(play::TeleportEntity {
|
||||
id: vehicle as i32,
|
||||
position: [new.x, new.y, new.z],
|
||||
delta: [0.0; 3],
|
||||
yaw,
|
||||
pitch,
|
||||
relatives: 0,
|
||||
on_ground: false,
|
||||
}));
|
||||
let g = host.hub.guests[i].position;
|
||||
let at = passenger_destination(old, new, Vec3::new(g[0], g[1], g[2]));
|
||||
host.hub.teleport_keeping_motion(i, [at.x, at.y, at.z]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A vehicle's yaw and pitch, for the riders' teleport packet.
|
||||
fn vehicle_rotation(&self, id: u32) -> [f32; 2] {
|
||||
if let Some(b) = self.boats.iter().find(|b| b.id == id) {
|
||||
[b.yaw, 0.0]
|
||||
} else if let Some(c) = self.carts.iter().find(|c| c.id == id) {
|
||||
[c.yaw, c.pitch]
|
||||
} else if let Some(m) = self.mobs.iter().find(|m| m.id == id) {
|
||||
[m.yaw, m.head_pitch]
|
||||
} else {
|
||||
[0.0, 0.0]
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.forceSetRotation`: yaw, head yaw, pitch and their old values
|
||||
/// only. Position, motion, the vehicle and the riders stay.
|
||||
pub(super) fn set_rotation(&mut self, t: Target, rotation: [f32; 2]) {
|
||||
let _ = self.in_target_level(t, |g| g.set_rotation_in_level(t, rotation));
|
||||
}
|
||||
fn set_rotation_in_level(&mut self, t: Target, [yaw, pitch]: [f32; 2]) {
|
||||
let pitch = absolute_pitch(pitch);
|
||||
match t {
|
||||
Target::Guest(_) => {
|
||||
self.for_player(t, |g| {
|
||||
g.set_rotation_in_level(Target::Player, [yaw, pitch.unwrap_or(f32::NAN)])
|
||||
});
|
||||
}
|
||||
Target::Player => {
|
||||
if yaw.is_finite() {
|
||||
self.yaw = yaw;
|
||||
}
|
||||
if let Some(pitch) = pitch {
|
||||
self.pitch = pitch;
|
||||
}
|
||||
// A server's player hears the turn alone when its turn ends.
|
||||
self.rotation_forced = self.lan_acting.is_some();
|
||||
}
|
||||
Target::Mob(id) => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
if yaw.is_finite() {
|
||||
m.yaw = yaw;
|
||||
m.body_yaw = yaw;
|
||||
m.head_yaw = yaw;
|
||||
}
|
||||
if let Some(pitch) = pitch {
|
||||
m.head_pitch = pitch;
|
||||
}
|
||||
m.previous_yaw = m.yaw;
|
||||
m.previous_head_pitch = m.head_pitch;
|
||||
}
|
||||
}
|
||||
Target::Other(id) => {
|
||||
if let Some(o) = self.others.iter_mut().find(|o| o.id == id) {
|
||||
if yaw.is_finite() {
|
||||
o.yaw = yaw;
|
||||
}
|
||||
if let Some(pitch) = pitch {
|
||||
o.pitch = pitch;
|
||||
}
|
||||
}
|
||||
}
|
||||
Target::Stand(id) if yaw.is_finite() => {
|
||||
if let Some(a) = self.armor_stands.iter_mut().find(|a| a.id == id) {
|
||||
a.yaw = yaw;
|
||||
}
|
||||
}
|
||||
Target::Boat(id) if yaw.is_finite() => {
|
||||
if let Some(b) = self.boats.iter_mut().find(|b| b.id == id) {
|
||||
b.yaw = yaw;
|
||||
}
|
||||
}
|
||||
Target::Cart(id) if yaw.is_finite() => {
|
||||
if let Some(c) = self.carts.iter_mut().find(|c| c.id == id) {
|
||||
c.yaw = yaw;
|
||||
}
|
||||
}
|
||||
Target::Fangs(id) if yaw.is_finite() => {
|
||||
if let Some(f) = self.fangs.list.iter_mut().find(|f| f.id == id) {
|
||||
f.yaw = yaw;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn passenger_keeps_its_offset() {
|
||||
let at = passenger_destination(
|
||||
Vec3::new(0.0, 64.0, 0.0),
|
||||
Vec3::new(100.0, 70.0, -20.0),
|
||||
Vec3::new(0.0, 64.5, 0.25),
|
||||
);
|
||||
assert_eq!((at.x, at.y, at.z), (100.0, 70.5, -19.75));
|
||||
}
|
||||
#[test]
|
||||
fn pitch_clamps_before_the_setter() {
|
||||
assert_eq!(absolute_pitch(360.0), Some(90.0));
|
||||
assert_eq!(absolute_pitch(-360.0), Some(-90.0));
|
||||
assert_eq!(absolute_pitch(f32::INFINITY), Some(90.0));
|
||||
assert_eq!(absolute_pitch(f32::NEG_INFINITY), Some(-90.0));
|
||||
assert_eq!(absolute_pitch(45.0), Some(45.0));
|
||||
assert_eq!(absolute_pitch(f32::NAN), None);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 298 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user