mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 02:56:17 -10:00
Cache animated sprite mip chains within an 8 MiB budget
This commit is contained in:
1 parent
89b58991bb
commit
b1c365fca1
4 files changed
+626
-20
No files matched your search
@@ -274,11 +274,7 @@ pub struct Renderer {
|
||||
sprite_sampler: wgpu::Sampler,
|
||||
lightmap_view: wgpu::TextureView,
|
||||
lightmap_sampler: wgpu::Sampler,
|
||||
animations: Vec<(SpriteAnimation, usize)>,
|
||||
/// Each sprite layer's MipmapGenerator settings, for animation frames.
|
||||
sprite_mips: Vec<crate::mipmap::Settings>,
|
||||
/// Edge length of the block sprite array's layers.
|
||||
sprite_size: u32,
|
||||
animations: AnimatedSprites,
|
||||
sprite_minimum_size: u32,
|
||||
indices: wgpu::Buffer,
|
||||
index_quads: u32,
|
||||
@@ -1423,9 +1419,7 @@ impl Renderer {
|
||||
sprite_sampler,
|
||||
lightmap_view,
|
||||
lightmap_sampler,
|
||||
animations: animations.iter().map(|a| (a.clone(), usize::MAX)).collect(),
|
||||
sprite_mips: sprite_mips.to_vec(),
|
||||
sprite_size,
|
||||
animations: AnimatedSprites::new(animations, sprite_size, sprite_mips),
|
||||
sprite_minimum_size,
|
||||
indices,
|
||||
index_quads,
|
||||
@@ -1806,9 +1800,7 @@ impl Renderer {
|
||||
animate_sprites(
|
||||
&mut self.animations,
|
||||
tick,
|
||||
self.sprite_size,
|
||||
self.mip_levels,
|
||||
&self.sprite_mips,
|
||||
|layer, level, size, pixels| {
|
||||
self.queue.write_texture(
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
@@ -2717,21 +2709,80 @@ pub(crate) fn sprite_mipmap_limit(requested: u32, size: u32, minimum: u32, avail
|
||||
let upscale = (size / minimum.max(1)).max(1).ilog2();
|
||||
requested.saturating_add(upscale).min(available - 1)
|
||||
}
|
||||
fn animate_sprites(
|
||||
animations: &mut [(crate::sprites::SpriteAnimation, usize)],
|
||||
tick: u64,
|
||||
const ANIMATION_MIP_BUDGET: usize = 8 << 20;
|
||||
const ANIMATION_MIP_ENTRIES: usize = 4096;
|
||||
|
||||
/// One immutable resource snapshot and its lazily generated frame mips. A new
|
||||
/// renderer on resource reload drops this entire snapshot, including its cache:
|
||||
/// frame indices can never alias another resource's pixels, size or settings.
|
||||
struct AnimatedSprites {
|
||||
animations: Vec<(SpriteAnimation, usize)>,
|
||||
size: u32,
|
||||
settings: Vec<crate::mipmap::Settings>,
|
||||
// Cache bookkeeping must not consume randomness or affect other maps' seeding.
|
||||
cache: crate::fast_hash::FxHashMap<(usize, usize), Vec<u8>>,
|
||||
/// Retained pixel-buffer capacities, not just their initialized lengths.
|
||||
cache_bytes: usize,
|
||||
budget: usize,
|
||||
}
|
||||
impl AnimatedSprites {
|
||||
fn new(
|
||||
animations: &[SpriteAnimation],
|
||||
size: u32,
|
||||
settings: &[crate::mipmap::Settings],
|
||||
) -> Self {
|
||||
Self {
|
||||
animations: animations
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|a| (a, usize::MAX))
|
||||
.collect(),
|
||||
size,
|
||||
settings: settings.to_vec(),
|
||||
cache: Default::default(),
|
||||
cache_bytes: 0,
|
||||
budget: ANIMATION_MIP_BUDGET,
|
||||
}
|
||||
}
|
||||
}
|
||||
fn animate_sprites(
|
||||
state: &mut AnimatedSprites,
|
||||
tick: u64,
|
||||
max_levels: u32,
|
||||
settings: &[crate::mipmap::Settings],
|
||||
mut write: impl FnMut(u32, u32, u32, &[u8]),
|
||||
) {
|
||||
for (animation, previous) in animations {
|
||||
let size = state.size;
|
||||
let levels = size.trailing_zeros() + 1;
|
||||
for (index, (animation, previous)) in state.animations.iter_mut().enumerate() {
|
||||
let frame = animation.frame(tick);
|
||||
if frame == *previous {
|
||||
continue;
|
||||
}
|
||||
*previous = frame;
|
||||
let (mips, levels) = mip_chain(animation.pixels(frame), size, settings, animation.layer, 1);
|
||||
let room = state.cache.len() < ANIMATION_MIP_ENTRIES;
|
||||
let uncached;
|
||||
let mips: &[u8] = match state.cache.entry((index, frame)) {
|
||||
std::collections::hash_map::Entry::Occupied(entry) => entry.into_mut(),
|
||||
std::collections::hash_map::Entry::Vacant(entry) => {
|
||||
let (pixels, _) = mip_chain(
|
||||
animation.pixels(frame),
|
||||
size,
|
||||
&state.settings,
|
||||
animation.layer,
|
||||
1,
|
||||
);
|
||||
// Bound both pixel storage and map metadata. Keep admitted
|
||||
// frames rather than churning them on large/unique workloads;
|
||||
// frames that do not fit still use the same generation path.
|
||||
if room && pixels.capacity() <= state.budget - state.cache_bytes {
|
||||
state.cache_bytes += pixels.capacity();
|
||||
entry.insert(pixels)
|
||||
} else {
|
||||
uncached = pixels;
|
||||
&uncached
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut offset = 0;
|
||||
for level in 0..levels.min(max_levels) {
|
||||
let edge = size >> level;
|
||||
@@ -2758,7 +2809,8 @@ mod sprite_upload_tests {
|
||||
let pixels = animation.pixels(animation.frame(1)).to_vec();
|
||||
let settings = [];
|
||||
let (expected, levels) = mip_chain(&pixels, size, &settings, 7, 1);
|
||||
let mut animations = vec![(animation, 0)];
|
||||
let mut animations = AnimatedSprites::new(&[animation], size, &settings);
|
||||
animations.animations[0].1 = 0;
|
||||
let mut writes = Vec::new();
|
||||
let mut record = |layer, level, edge, data: &[u8]| {
|
||||
assert_eq!(layer, 7);
|
||||
@@ -2766,12 +2818,102 @@ mod sprite_upload_tests {
|
||||
assert_eq!(data.len(), (edge * edge * 4) as usize);
|
||||
writes.push(data.to_vec());
|
||||
};
|
||||
animate_sprites(&mut animations, 0, size, levels, &settings, &mut record);
|
||||
animate_sprites(&mut animations, 1, size, levels, &settings, &mut record);
|
||||
animate_sprites(&mut animations, 1, size, levels, &settings, &mut record);
|
||||
animate_sprites(&mut animations, 0, levels, &mut record);
|
||||
animate_sprites(&mut animations, 1, levels, &mut record);
|
||||
animate_sprites(&mut animations, 1, levels, &mut record);
|
||||
assert_eq!(writes.len(), levels as usize);
|
||||
assert_eq!(writes[0], pixels);
|
||||
assert_eq!(writes.concat(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_and_uncached_uploads_match_through_loops_and_mip_changes() {
|
||||
for size in [16, 32] {
|
||||
let animations = [crate::sprites::test_animation(size, 7)];
|
||||
let settings = vec![
|
||||
crate::mipmap::Settings {
|
||||
strategy: crate::mipmap::Strategy::StrictCutout,
|
||||
alpha_cutoff_bias: -0.2,
|
||||
..Default::default()
|
||||
};
|
||||
8
|
||||
];
|
||||
let mut cached = AnimatedSprites::new(&animations, size, &settings);
|
||||
let mut uncached = AnimatedSprites::new(&animations, size, &settings);
|
||||
uncached.budget = 0;
|
||||
for tick in [0, 0, 1, 2, 3, 33, 32, 32, 1, 0] {
|
||||
let levels = (tick % 7) as u32;
|
||||
let mut expected = Vec::new();
|
||||
let mut actual = Vec::new();
|
||||
animate_sprites(&mut uncached, tick, levels, |layer, level, edge, bytes| {
|
||||
expected.push((layer, level, edge, bytes.to_vec()));
|
||||
});
|
||||
animate_sprites(&mut cached, tick, levels, |layer, level, edge, bytes| {
|
||||
actual.push((layer, level, edge, bytes.to_vec()));
|
||||
});
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
assert_eq!(cached.cache.len(), 2);
|
||||
assert_eq!(uncached.cache_bytes, 0);
|
||||
assert_eq!(
|
||||
cached.cache_bytes,
|
||||
cached.cache.values().map(Vec::capacity).sum::<usize>()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budget_exhaustion_keeps_admitted_frames_and_uploads_misses() {
|
||||
let size = 16;
|
||||
let animation = crate::sprites::test_animation(size, 0);
|
||||
let bytes = mip_chain(animation.pixels(0), size, &[], 0, 1).0.capacity();
|
||||
let mut state = AnimatedSprites::new(&[animation], size, &[]);
|
||||
state.budget = bytes;
|
||||
for tick in 0..8 {
|
||||
let mut writes = 0;
|
||||
animate_sprites(&mut state, tick, 5, |_, _, _, _| writes += 1);
|
||||
assert_eq!(writes, 5);
|
||||
assert_eq!(state.cache_bytes, bytes);
|
||||
assert_eq!(state.cache.len(), 1);
|
||||
assert!(state.cache.contains_key(&(0, 0)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_limit_also_bounds_metadata_for_tiny_frames() {
|
||||
let animation = crate::sprites::test_animation(1, 0);
|
||||
let animations = vec![animation; ANIMATION_MIP_ENTRIES + 1];
|
||||
let mut state = AnimatedSprites::new(&animations, 1, &[]);
|
||||
for tick in 0..2 {
|
||||
let mut writes = 0;
|
||||
animate_sprites(&mut state, tick, 1, |_, _, _, _| writes += 1);
|
||||
assert_eq!(writes, animations.len());
|
||||
assert_eq!(state.cache.len(), ANIMATION_MIP_ENTRIES);
|
||||
assert!(state.cache_bytes <= ANIMATION_MIP_BUDGET);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_snapshot_replacement_discards_pixels_size_and_settings() {
|
||||
let old = crate::sprites::test_animation(16, 0);
|
||||
let mut state = AnimatedSprites::new(&[old], 16, &[]);
|
||||
animate_sprites(&mut state, 0, 5, |_, _, _, _| {});
|
||||
assert!(!state.cache.is_empty());
|
||||
let animation = crate::sprites::test_animation(32, 0);
|
||||
let settings = [crate::mipmap::Settings {
|
||||
strategy: crate::mipmap::Strategy::StrictCutout,
|
||||
alpha_cutoff_bias: -0.4,
|
||||
..Default::default()
|
||||
}];
|
||||
let expected = mip_chain(animation.pixels(0), 32, &settings, 0, 1).0;
|
||||
state = AnimatedSprites::new(&[animation], 32, &settings);
|
||||
assert_eq!(state.cache_bytes, 0);
|
||||
assert!(state.cache.is_empty());
|
||||
let mut actual = Vec::new();
|
||||
animate_sprites(&mut state, 0, 6, |_, _, _, bytes| {
|
||||
actual.extend_from_slice(bytes)
|
||||
});
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
# Animated sprite mip cache
|
||||
|
||||
`animate_sprites` retains full mip chains for previously displayed source frames.
|
||||
The renderer owns an immutable snapshot of animations, size and mip settings;
|
||||
resource reload replaces that snapshot and drops its cache. Mipmap options only
|
||||
change the sampled view, so the full chains stay valid. Frame selection, skipped
|
||||
unchanged frames, upload order and mip bytes are preserved.
|
||||
|
||||
Admission is lazy, with an 8 MiB limit on retained `Vec` capacities and a separate
|
||||
4,096-entry limit on map metadata. The existing fixed integer hasher avoids
|
||||
consuming random seeds for cache bookkeeping. An admitted frame stays until resource reload;
|
||||
a frame that does not fit uses the original generation path. There is no eviction
|
||||
churn. These limits apply to the additional cache, excluding the existing source
|
||||
frames and the original generator's temporary allocations. Map/allocator overhead
|
||||
is additional and bounded by the entry limit.
|
||||
|
||||
## Reproduce
|
||||
|
||||
Use prepared 26.3 resources at `.local/horizon-client/26.3`. All block/item
|
||||
textures are loaded, including static layers that set the shared array size
|
||||
(the 64px straw-bed texture). The Python driver
|
||||
compiles the production `gfx.rs` functions and actual sprite/mipmap loaders into
|
||||
a private Cargo workspace under `.local/sprite-mips-bench`; it does not build the
|
||||
rest of the client or expose a benchmark API in the renderer. `--ref` selects the
|
||||
baseline's actual gfx code; the sprite/mipmap loaders are unchanged by this
|
||||
optimization. Both use release optimization, LTO off, 16 codegen units.
|
||||
|
||||
Build first, then release the lock before the paired measurement session. Keep
|
||||
all measurement pairs in one lock acquisition so other heavy work cannot overlap:
|
||||
|
||||
```sh
|
||||
flock /tmp/horizon-optimizations-20261015-heavy.lock bash -c '
|
||||
set -e
|
||||
export CARGO_BUILD_JOBS=2
|
||||
python3 tools/bench_sprite_mips.py --ref 89b58991bbc02eb79b6965fc4ca56357fe2297d9 --label baseline --build-only
|
||||
python3 tools/bench_sprite_mips.py --label candidate --build-only
|
||||
'
|
||||
flock /tmp/horizon-optimizations-20261015-heavy.lock bash -c '
|
||||
set -e
|
||||
export CARGO_BUILD_JOBS=2
|
||||
for round in 1 2 3; do
|
||||
variants="baseline candidate"
|
||||
if [ "$round" = 2 ]; then variants="candidate baseline"; fi
|
||||
for variant in $variants; do
|
||||
python3 -c "import resource, subprocess, sys; subprocess.run(sys.argv[1:], check=True); print(\"max_rss_kib=\" + str(resource.getrusage(resource.RUSAGE_CHILDREN).ru_maxrss), file=sys.stderr)" \
|
||||
.local/sprite-mips-bench/target/release/sprite-mips-$variant \
|
||||
.local/horizon-client/26.3 .local/sprite-mips-bench/$variant/names.txt
|
||||
done
|
||||
done
|
||||
'
|
||||
```
|
||||
|
||||
Each process reports resource loading, its first frame (including one-time mip
|
||||
tables), nine warmed-code samples of fresh-state setup/first upload/teardown,
|
||||
and setup plus the first 4,096 ticks. It then samples 1,024 warmed animation ticks
|
||||
and 3,072 rendered frames at three renders per tick, nine times each. Another
|
||||
1,024 ticks report CPU latency p50/p95/max. The warmed state is dropped before
|
||||
the unique workloads, keeping one live renderer cache at a time. A black-box
|
||||
upload callback consumes each slice without GPU calls. The unchanged initial
|
||||
atlas generation, GPU staging, draw calls and presentation are outside this CPU
|
||||
measurement; these numbers do not establish an FPS improvement.
|
||||
|
||||
The first-seen workloads request each distinct source frame found over 4,096
|
||||
ticks once, and separately simulate 8,192 distinct array layers sharing real
|
||||
immutable inputs. The latter deliberately exceeds the cache budget and practical
|
||||
atlas sizes to exercise admission and fallback. Fresh-state samples include
|
||||
teardown. Source-frame samples report peak retained bytes for one animation;
|
||||
other samples report the state's retained pixel-buffer capacity.
|
||||
|
||||
Every process also compares complete upload streams with the original function
|
||||
at 517 tick positions, including duplicate ticks, jumps, rewinds and changing mip
|
||||
limits, using all real animations (including interpolation metadata). This
|
||||
preserves the existing frame-based path; it does not add or validate GPU blending
|
||||
of interpolated frames.
|
||||
|
||||
## Validation
|
||||
|
||||
- `pr_ready.py --base 89b58991bbc02eb79b6965fc4ca56357fe2297d9 --quick`: public tree,
|
||||
formatting, workspace clippy, 196 Python tests and 635 client tests passed.
|
||||
- Native animation parity: 907 fixtures (63 resource animations), including
|
||||
258,996 tick states and 203 intentionally rejected inputs, match the pinned client.
|
||||
- Native mipmap parity: 1,319 cases / 7,914 output lines match exactly.
|
||||
- Native breaking-model parity: 2,740 cases / 155,928 vertices match within the
|
||||
harness tolerance; maximum error `1.40395e-07`.
|
||||
|
||||
The full game parity suite was not run. This checkout has no recorded harness
|
||||
index; `pr_ready --quick` lists that deferred selection, and the relevant native
|
||||
harnesses above were run explicitly. GPU interpolation pixels and end-to-end FPS
|
||||
remain outside this evaluation.
|
||||
|
||||
## Measured outcome
|
||||
|
||||
Kept. Ryzen 5 5600X (12 logical CPUs), rustc 1.99.0
|
||||
(`b940084d7`, 2026-09-28), Linux; 2,131 prepared block/item textures,
|
||||
54 animations and 594 distinct source frames. Three paired process runs,
|
||||
reversing baseline/candidate order in round two, each with one warmup and nine
|
||||
samples per workload (27 samples per variant). Both binaries used the same
|
||||
private Cargo dependency resolution and optimized profile. Baseline gfx is
|
||||
`89b58991bbc02eb79b6965fc4ca56357fe2297d9`.
|
||||
|
||||
CPU times below are median [minimum, maximum], in milliseconds. Each repeated
|
||||
sample averages 1,024 ticks or 3,072 render calls; unique/cold samples include
|
||||
state setup and teardown.
|
||||
|
||||
| Workload | Baseline | Cache | Median change |
|
||||
| --- | --- | --- | --- |
|
||||
| Fresh state + first tick + drop | 7.532 [7.193, 7.567] | 7.545 [7.128, 7.791] | +0.18% |
|
||||
| Repeated tick | 3.627 [3.491, 3.720] | 1.692 [1.646, 1.739] | -53.34% |
|
||||
| Repeated render, three renders/tick | 1.203 [1.181, 1.244] | 0.562 [0.546, 0.585] | -53.33% |
|
||||
| Each actual source frame once | 98.168 [96.647, 102.813] | 97.093 [96.674, 104.441] | -1.09% |
|
||||
| 8,192 distinct layers, first frame | 1123.475 [1088.996, 1156.957] | 1121.300 [1087.745, 1154.184] | -0.19% |
|
||||
|
||||
Repeated-tick medians for the three processes were respectively
|
||||
`3.647 / 3.646 / 3.540` ms baseline and `1.702 / 1.674 / 1.692` ms cached.
|
||||
The corresponding render-call medians were `1.233 / 1.195 / 1.192` ms and
|
||||
`0.562 / 0.556 / 0.562` ms. The improvement repeats in every pair; cold and
|
||||
unique-frame differences are within the observed variation.
|
||||
|
||||
Additional per-process samples, baseline → cache:
|
||||
|
||||
- Resource decoding: `69.829 / 69.350 / 67.308` → `68.632 / 66.235 / 67.252` ms.
|
||||
- Process-first setup/upload/drop, including mip table initialization:
|
||||
`7.255 / 7.979 / 7.610` → `8.796 / 7.706 / 7.688` ms. The first cached
|
||||
process had a cold outlier; no cold-start speedup is claimed.
|
||||
- Setup plus first 4,096 ticks, including cache fill:
|
||||
`14.835 / 15.001 / 14.746` → `7.053 / 6.997 / 6.955` seconds.
|
||||
- Individual warmed-tick p50: `4.311 / 4.313 / 4.318` →
|
||||
`1.639 / 1.543 / 1.550` ms; p95: `6.613 / 6.617 / 6.885` →
|
||||
`4.139 / 3.960 / 3.972` ms; maximum: `7.621 / 7.702 / 7.569` →
|
||||
`5.285 / 6.448 / 5.213` ms.
|
||||
- Whole-process peak RSS: `84412 / 83388 / 85416` →
|
||||
`84480 / 83820 / 84524` KiB, using a fresh Python parent's
|
||||
`resource.getrusage(RUSAGE_CHILDREN).ru_maxrss` per invocation. RSS includes
|
||||
resource loading and the synthetic workload, and allocator reuse/other peaks
|
||||
hide the retained cache cost; it does **not** establish zero added memory.
|
||||
|
||||
At 64px, each generated chain has 32,768 bytes of `Vec` capacity. The cache
|
||||
retains 256 entries / exactly 8,388,608 bytes, with map capacity 448, and bypasses
|
||||
the remaining frames. Retaining all 594 frames would require 18.56 MiB of buffer
|
||||
capacity. The first tick retains 1,769,472 bytes; the one-animation-at-a-time
|
||||
unique-source workload peaks at 1 MiB. The separate 4,096-entry cap bounds map
|
||||
metadata for tiny textures. No GPU memory or upload bytes are saved.
|
||||
|
||||
All six measured processes passed the 517-position upload-stream comparison.
|
||||
The 196 Python tests were rerun after adding the build-only driver mode and
|
||||
passed. The first RSS wrapper attempt failed before collecting samples because
|
||||
`/usr/bin/time` was unavailable; the complete paired session above uses Python
|
||||
resource counters. Private raw output remains under `.local/sprite-mips-*`.
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Benchmark the actual gfx CPU upload path, isolated from GPU/window startup.
|
||||
|
||||
Run under the shared heavy-work flock. Uses a private .local Cargo workspace;
|
||||
--ref selects an unmodified baseline, otherwise reads the working tree.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def function(source, name):
|
||||
start = source.index(f'fn {name}(')
|
||||
brace = source.index('{', start)
|
||||
depth = 1
|
||||
end = brace + 1
|
||||
while depth:
|
||||
depth += (source[end] == '{') - (source[end] == '}')
|
||||
end += 1
|
||||
return source[start:end]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--ref')
|
||||
mode = parser.add_mutually_exclusive_group()
|
||||
mode.add_argument('--test', action='store_true', help='run extracted production upload tests')
|
||||
mode.add_argument('--build-only', action='store_true', help='build without measuring')
|
||||
parser.add_argument('--label', default='candidate')
|
||||
parser.add_argument('--resources', type=Path, default=ROOT / '.local/horizon-client/26.3')
|
||||
args = parser.parse_args()
|
||||
path = 'crates/horizon-client/src/gfx.rs'
|
||||
source = (subprocess.check_output(['git', 'show', f'{args.ref}:{path}'], cwd=ROOT, text=True)
|
||||
if args.ref else (ROOT / path).read_text())
|
||||
build = ROOT / '.local/sprite-mips-bench' / args.label
|
||||
build.mkdir(parents=True, exist_ok=True)
|
||||
cached = 'struct AnimatedSprites' in source
|
||||
code = function(source, 'mip_chain') + '\n'
|
||||
if cached:
|
||||
code += source[source.index('const ANIMATION_MIP_BUDGET'):source.index('#[cfg(test)]\nmod sprite_upload_tests')]
|
||||
else:
|
||||
code += function(source, 'animate_sprites')
|
||||
baseline = subprocess.check_output(['git', 'show', '89b58991bbc02eb79b6965fc4ca56357fe2297d9:crates/horizon-client/src/gfx.rs'], cwd=ROOT, text=True)
|
||||
code += '\n' + function(baseline, 'animate_sprites').replace('fn animate_sprites(', 'fn reference_animate_sprites(', 1)
|
||||
if args.test:
|
||||
code += '\n' + function(source, 'sprite_mipmap_limit')
|
||||
code += '\n' + source[source.index('#[cfg(test)]\nmod sprite_upload_tests'):]
|
||||
modules = '\n'.join(f'#[path = {json.dumps(str(ROOT / "crates/horizon-client/src" / (name + ".rs")))}] mod {name};'
|
||||
for name in ('mipmap', 'sprites', 'fast_hash'))
|
||||
(build / 'main.rs').write_text(modules + '\nuse sprites::SpriteAnimation;\n'
|
||||
+ code + '\n' + (ROOT / 'tools/bench_sprite_mips.rs').read_text())
|
||||
(build / 'Cargo.toml').write_text(f'''[package]
|
||||
name = "sprite-mips-{args.label}"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
[workspace]
|
||||
[[bin]]
|
||||
name = "sprite-mips-{args.label}"
|
||||
path = "main.rs"
|
||||
[dependencies]
|
||||
horizon-assets = {{ path = "{ROOT / 'crates/horizon-assets'}" }}
|
||||
png = "0.18"
|
||||
serde_json = {{ version = "1.0.151", features = ["raw_value"] }}
|
||||
log = "0.4"
|
||||
[features]
|
||||
cached = []
|
||||
[profile.release]
|
||||
lto = false
|
||||
codegen-units = 16
|
||||
''')
|
||||
# Include static sprites too: the largest layer sets the animation upload
|
||||
# resolution (the prepared straw-bed texture makes this a 64px array).
|
||||
names = [f'minecraft:{folder}/{image.stem}'
|
||||
for folder in ('block', 'item')
|
||||
for image in sorted((args.resources / f'assets/minecraft/textures/{folder}').glob('*.png'))]
|
||||
(build / 'names.txt').write_text('\n'.join(names))
|
||||
command = ['cargo', 'test' if args.test else 'build' if args.build_only else 'run', '--release', '--manifest-path', str(build / 'Cargo.toml')]
|
||||
if cached:
|
||||
command += ['--features', 'cached']
|
||||
if not args.test and not args.build_only:
|
||||
command += ['--', str(args.resources), str(build / 'names.txt')]
|
||||
subprocess.run(command, cwd=ROOT, check=True, env=dict(os.environ, CARGO_TARGET_DIR=str(ROOT / '.local/sprite-mips-bench/target')))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,226 @@
|
||||
// Compiled by bench_sprite_mips.py with the production gfx functions and loaders.
|
||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
use std::{hint::black_box, time::Instant};
|
||||
|
||||
#[cfg(not(feature = "cached"))]
|
||||
struct AnimatedSprites {
|
||||
animations: Vec<(SpriteAnimation, usize)>,
|
||||
size: u32,
|
||||
settings: Vec<mipmap::Settings>,
|
||||
}
|
||||
#[cfg(not(feature = "cached"))]
|
||||
impl AnimatedSprites {
|
||||
fn new(animations: &[SpriteAnimation], size: u32, settings: &[mipmap::Settings]) -> Self {
|
||||
Self {
|
||||
animations: animations
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|a| (a, usize::MAX))
|
||||
.collect(),
|
||||
size,
|
||||
settings: settings.to_vec(),
|
||||
}
|
||||
}
|
||||
}
|
||||
fn update(state: &mut AnimatedSprites, tick: u64) {
|
||||
let mut sink = |layer, level, size, pixels: &[u8]| {
|
||||
black_box((layer, level, size, pixels));
|
||||
};
|
||||
#[cfg(feature = "cached")]
|
||||
{
|
||||
let levels = state.size.trailing_zeros() + 1;
|
||||
animate_sprites(state, tick, levels, &mut sink);
|
||||
}
|
||||
#[cfg(not(feature = "cached"))]
|
||||
animate_sprites(
|
||||
&mut state.animations,
|
||||
tick,
|
||||
state.size,
|
||||
state.size.trailing_zeros() + 1,
|
||||
&state.settings,
|
||||
&mut sink,
|
||||
);
|
||||
}
|
||||
fn retained(state: &AnimatedSprites) -> usize {
|
||||
#[cfg(feature = "cached")]
|
||||
{
|
||||
state.cache_bytes
|
||||
}
|
||||
#[cfg(not(feature = "cached"))]
|
||||
{
|
||||
let _ = state;
|
||||
0
|
||||
}
|
||||
}
|
||||
fn samples(name: &str, mut run: impl FnMut() -> (f64, usize)) {
|
||||
run(); // Warm code and allocator; each cold/unique run still has a new cache.
|
||||
let rows: Vec<_> = (0..9).map(|_| run()).collect();
|
||||
println!("{name} us,bytes: {rows:?}");
|
||||
}
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<_> = std::env::args().collect();
|
||||
let start = Instant::now();
|
||||
let mut sprites = sprites::Sprites::default();
|
||||
for name in std::fs::read_to_string(&args[2])?.lines() {
|
||||
sprites.insert(std::path::Path::new(&args[1]), name)?;
|
||||
}
|
||||
sprites.finish();
|
||||
println!(
|
||||
"resources_ms={:.3} animations={} size={}",
|
||||
start.elapsed().as_secs_f64() * 1e3,
|
||||
sprites.animations.len(),
|
||||
sprites.size
|
||||
);
|
||||
let start = Instant::now();
|
||||
let mut first = AnimatedSprites::new(&sprites.animations, sprites.size, &sprites.mips);
|
||||
update(&mut first, 0);
|
||||
let bytes = retained(&first);
|
||||
drop(first);
|
||||
println!(
|
||||
"process_first_frame_us={:.3} bytes={bytes}",
|
||||
start.elapsed().as_secs_f64() * 1e6
|
||||
);
|
||||
samples("setup+cold+drop", || {
|
||||
let start = Instant::now();
|
||||
let mut state = AnimatedSprites::new(&sprites.animations, sprites.size, &sprites.mips);
|
||||
update(&mut state, 0);
|
||||
let bytes = retained(&state);
|
||||
drop(state);
|
||||
(start.elapsed().as_secs_f64() * 1e6, bytes)
|
||||
});
|
||||
let start = Instant::now();
|
||||
let mut state = AnimatedSprites::new(&sprites.animations, sprites.size, &sprites.mips);
|
||||
for tick in 0..4096 {
|
||||
update(&mut state, tick);
|
||||
}
|
||||
println!(
|
||||
"setup+4096ticks_ms={:.3} bytes={}",
|
||||
start.elapsed().as_secs_f64() * 1e3,
|
||||
retained(&state)
|
||||
);
|
||||
#[cfg(feature = "cached")]
|
||||
println!(
|
||||
"cache_entries={} map_capacity={}",
|
||||
state.cache.len(),
|
||||
state.cache.capacity()
|
||||
);
|
||||
samples("repeated/tick", || {
|
||||
let start = Instant::now();
|
||||
for tick in 4096..5120 {
|
||||
update(&mut state, tick);
|
||||
}
|
||||
(
|
||||
start.elapsed().as_secs_f64() * 1e6 / 1024.0,
|
||||
retained(&state),
|
||||
)
|
||||
});
|
||||
samples("repeated/render60hz", || {
|
||||
let start = Instant::now();
|
||||
for frame in 0..3072 {
|
||||
update(&mut state, 4096 + frame / 3);
|
||||
}
|
||||
(
|
||||
start.elapsed().as_secs_f64() * 1e6 / 3072.0,
|
||||
retained(&state),
|
||||
)
|
||||
});
|
||||
let mut latencies: Vec<_> = (5120..6144)
|
||||
.map(|tick| {
|
||||
let start = Instant::now();
|
||||
update(&mut state, tick);
|
||||
start.elapsed().as_secs_f64() * 1e6
|
||||
})
|
||||
.collect();
|
||||
latencies.sort_unstable_by(f64::total_cmp);
|
||||
println!(
|
||||
"repeat_tick_latency_us p50={:.3} p95={:.3} max={:.3}",
|
||||
latencies[512], latencies[972], latencies[1023]
|
||||
);
|
||||
drop(state); // One live renderer cache at a time, including for RSS samples.
|
||||
// Each actual source frame is requested once, without warming its cache.
|
||||
let unique_ticks: Vec<Vec<u64>> = sprites
|
||||
.animations
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
(0..4096)
|
||||
.filter(|&tick| seen.insert(a.frame(tick)))
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
println!(
|
||||
"unique_source_frames={}",
|
||||
unique_ticks.iter().map(Vec::len).sum::<usize>()
|
||||
);
|
||||
samples("unique_source_frames/setup+drop (peak bytes)", || {
|
||||
let start = Instant::now();
|
||||
let mut peak = 0;
|
||||
for (animation, ticks) in sprites.animations.iter().zip(&unique_ticks) {
|
||||
let mut state =
|
||||
AnimatedSprites::new(std::slice::from_ref(animation), sprites.size, &sprites.mips);
|
||||
for &tick in ticks {
|
||||
update(&mut state, tick);
|
||||
}
|
||||
peak = peak.max(retained(&state));
|
||||
}
|
||||
(start.elapsed().as_secs_f64() * 1e6, peak)
|
||||
});
|
||||
// Distinct array layers, sharing immutable real frame inputs. Every request
|
||||
// is a first-seen key; tests admission overhead and budget saturation.
|
||||
let unique: Vec<_> = (0..8192)
|
||||
.map(|i| {
|
||||
let mut animation = sprites.animations[i % sprites.animations.len()].clone();
|
||||
animation.layer = i as u32;
|
||||
animation
|
||||
})
|
||||
.collect();
|
||||
let settings: Vec<_> = (0..unique.len())
|
||||
.map(|i| sprites.mips[sprites.animations[i % sprites.animations.len()].layer as usize])
|
||||
.collect();
|
||||
samples("unique8192/setup+first+drop", || {
|
||||
let start = Instant::now();
|
||||
let mut state = AnimatedSprites::new(&unique, sprites.size, &settings);
|
||||
update(&mut state, 0);
|
||||
let bytes = retained(&state);
|
||||
drop(state);
|
||||
(start.elapsed().as_secs_f64() * 1e6, bytes)
|
||||
});
|
||||
// Compare the complete write stream, including frames with interpolation
|
||||
// metadata, duplicate ticks, jumps, rewind and changing upload mip limits.
|
||||
let mut state = AnimatedSprites::new(&sprites.animations, sprites.size, &sprites.mips);
|
||||
let mut reference: Vec<_> = sprites
|
||||
.animations
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|a| (a, usize::MAX))
|
||||
.collect();
|
||||
for tick in (0..512).chain([511, 0, 9999, 9999, 17]) {
|
||||
let levels = (tick % (sprites.size.trailing_zeros() as u64 + 2)) as u32;
|
||||
let mut expected = Vec::new();
|
||||
reference_animate_sprites(
|
||||
&mut reference,
|
||||
tick,
|
||||
sprites.size,
|
||||
levels,
|
||||
&sprites.mips,
|
||||
|layer, level, edge, pixels| expected.push((layer, level, edge, pixels.to_vec())),
|
||||
);
|
||||
let mut actual = Vec::new();
|
||||
#[cfg(feature = "cached")]
|
||||
animate_sprites(&mut state, tick, levels, |layer, level, edge, pixels| {
|
||||
actual.push((layer, level, edge, pixels.to_vec()))
|
||||
});
|
||||
#[cfg(not(feature = "cached"))]
|
||||
animate_sprites(
|
||||
&mut state.animations,
|
||||
tick,
|
||||
state.size,
|
||||
levels,
|
||||
&state.settings,
|
||||
|layer, level, edge, pixels| actual.push((layer, level, edge, pixels.to_vec())),
|
||||
);
|
||||
assert_eq!(actual, expected, "write stream at tick {tick}");
|
||||
}
|
||||
println!("write_stream_parity=passed (517 ticks, real animations, varying mip limits)");
|
||||
Ok(())
|
||||
}
|
||||
Reference in new issue
Block a user