Cache animated sprite mip chains within an 8 MiB budget

This commit is contained in:
rdbtCVS committed 2026-10-15 13:59:45 +01:00
1 parent 89b58991bb
commit b1c365fca1
4 files changed
+626 -20

No files matched your search

+162 -20
View File
@@ -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);
}
}
+148
View File
@@ -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-*`.
+90
View File
@@ -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()
+226
View File
@@ -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(())
}