diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs index 2b47e2a89c..8f5c8fe123 100644 --- a/crates/horizon-client/src/gfx.rs +++ b/crates/horizon-client/src/gfx.rs @@ -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, - /// 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, + // Cache bookkeeping must not consume randomness or affect other maps' seeding. + cache: crate::fast_hash::FxHashMap<(usize, usize), Vec>, + /// 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::() + ); + } + } + + #[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); + } } diff --git a/docs/animated-sprite-mips-benchmark.md b/docs/animated-sprite-mips-benchmark.md new file mode 100644 index 0000000000..60aed97db5 --- /dev/null +++ b/docs/animated-sprite-mips-benchmark.md @@ -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-*`. diff --git a/tools/bench_sprite_mips.py b/tools/bench_sprite_mips.py new file mode 100644 index 0000000000..93e6e88996 --- /dev/null +++ b/tools/bench_sprite_mips.py @@ -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() diff --git a/tools/bench_sprite_mips.rs b/tools/bench_sprite_mips.rs new file mode 100644 index 0000000000..34f72444a2 --- /dev/null +++ b/tools/bench_sprite_mips.rs @@ -0,0 +1,226 @@ +// Compiled by bench_sprite_mips.py with the production gfx functions and loaders. +type Result = std::result::Result>; +use std::{hint::black_box, time::Instant}; + +#[cfg(not(feature = "cached"))] +struct AnimatedSprites { + animations: Vec<(SpriteAnimation, usize)>, + size: u32, + settings: Vec, +} +#[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> = 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::() + ); + 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(()) +}