Files
xenia-rs/crates/xenia-ui/src/texture_cache_host.rs
MechaCat02 e2b8860e10 Add xenia-ui crate; switch analysis store to DuckDB
Workspace gains a new xenia-ui member that owns the winit/wgpu
window, the Xenos display pipeline (xenos_pipeline + render +
texture_cache_host), HUD font/blit shaders, and the input-bridge
plumbing the app uses to surface guest framebuffers and overlays.

Workspace dependencies grow accordingly: rusqlite is replaced with
duckdb (analysis pipeline now writes DuckDB stores), and tracing /
metrics / pprof / winit / wgpu / gilrs / pollster / crossbeam /
bytemuck are added at workspace level so xenia-ui and xenia-app
share versions. Cargo.lock regenerated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:26:48 +02:00

228 lines
8.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Host-side wgpu texture cache — pairs with `xenia_gpu::texture_cache` (the
//! CPU-side decoded-bytes layer) by mapping the same [`TextureKey`] onto an
//! uploaded `wgpu::Texture` + `TextureView`.
//!
//! The xenos pipeline only binds one texture at a time (group 1 single slot);
//! this cache lets the draw-dispatch loop uphold that layout without
//! re-uploading the same texture across frames. Entries carry the
//! `version_when_uploaded` stamp that originated from
//! [`xenia_memory::GuestMemory::page_version`]; on a stale hit the caller
//! re-decodes (via the CPU cache) and [`TextureCacheHost::upload`] replaces
//! the wgpu texture in place.
use std::collections::HashMap;
use xenia_gpu::texture_cache::{CachedTexture, TextureFormat, TextureKey};
pub struct HostTextureEntry {
/// Retained so the `wgpu::Texture` outlives its `TextureView` for
/// the lifetime of the cache entry. wgpu views internally reference
/// their backing texture through an `Arc`, so dropping this field
/// would technically still leave the view valid — but keeping it
/// makes the ownership intent explicit and avoids relying on wgpu
/// internals. Read only at construction; the `#[allow]` silences
/// `dead_code` since Rust can't tell the field is load-bearing for
/// drop ordering.
#[allow(dead_code)]
pub texture: wgpu::Texture,
pub view: wgpu::TextureView,
pub version_when_uploaded: u64,
}
pub struct TextureCacheHost {
entries: HashMap<TextureKey, HostTextureEntry>,
/// HUD-surfaced counters — mirror the CPU-side cache so a session
/// can tell whether uploads are dominated by fresh work or stale
/// invalidations.
pub uploads_total: u64,
pub reuploads_total: u64,
}
impl Default for TextureCacheHost {
fn default() -> Self {
Self::new()
}
}
impl TextureCacheHost {
pub fn new() -> Self {
Self {
entries: HashMap::new(),
uploads_total: 0,
reuploads_total: 0,
}
}
pub fn len(&self) -> usize {
self.entries.len()
}
/// Kept for API symmetry with `len` (clippy recommends both together).
/// Unused in code today — callers check `len() == 0` via the HUD.
#[allow(dead_code)]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// Upload (or refresh) a cached texture. Returns a `&TextureView` the
/// pipeline can bind. Idempotent when `cached.version_when_uploaded`
/// matches an existing entry — in that case we just hand back the
/// existing view without touching GPU bandwidth.
pub fn upload(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
cached: &CachedTexture,
) -> &HostTextureEntry {
let key = cached.key;
let needs_refresh = match self.entries.get(&key) {
None => true,
Some(existing) => existing.version_when_uploaded < cached.version_when_uploaded,
};
if !needs_refresh {
return self.entries.get(&key).unwrap();
}
// Evict + re-upload. For P5 we always recreate the texture (no
// in-place writeback); re-uploading a 1280×720 texture is <3 MB
// and happens only when the guest actually touches the bytes.
let Some(descriptor) = descriptor_for(&key) else {
// Unsupported — keep a record-keeping entry pointing at a
// dummy and let the pipeline fall back to its own magenta
// placeholder. We still create a minimal 1×1 magenta view
// so `get` returns something bindable.
let (tex, view) = create_magenta_stub(device, queue);
let entry = HostTextureEntry {
texture: tex,
view,
version_when_uploaded: cached.version_when_uploaded,
};
self.entries.insert(key, entry);
self.uploads_total += 1;
return self.entries.get(&key).unwrap();
};
let texture = device.create_texture(&descriptor);
// Write pixel data. BCn formats require block-aligned extents;
// uncompressed formats use byte-aligned rows. First-Pixels M5:
// BC1 = 8 bytes/block, BC2/BC3 = 16 bytes/block.
let bytes_per_row = match key.format {
TextureFormat::Dxt1 => Some((key.width as u32).div_ceil(4) * 8),
TextureFormat::Dxt2_3 | TextureFormat::Dxt4_5 => {
Some((key.width as u32).div_ceil(4) * 16)
}
// K8888 and K565 (CPU-expanded to Rgba8Unorm) both produce
// 4 bytes per texel in the wgpu-uploadable buffer.
_ => Some(key.width as u32 * 4),
};
queue.write_texture(
wgpu::ImageCopyTexture {
texture: &texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
&cached.bytes,
wgpu::ImageDataLayout {
offset: 0,
bytes_per_row,
rows_per_image: None,
},
wgpu::Extent3d {
width: key.width as u32,
height: key.height as u32,
depth_or_array_layers: 1,
},
);
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
let entry = HostTextureEntry {
texture,
view,
version_when_uploaded: cached.version_when_uploaded,
};
let had_prior = self.entries.contains_key(&key);
self.entries.insert(key, entry);
if had_prior {
self.reuploads_total += 1;
} else {
self.uploads_total += 1;
}
self.entries.get(&key).unwrap()
}
pub fn view_for(&self, key: &TextureKey) -> Option<&wgpu::TextureView> {
self.entries.get(key).map(|e| &e.view)
}
}
/// wgpu `TextureDescriptor` matching a Xenos [`TextureKey`]. Returns `None`
/// for unsupported formats — caller uses a magenta stub.
///
/// First-Pixels M5 adds `K565` (CPU-expanded to `Rgba8Unorm` in the
/// decoder), `Dxt2_3` → `Bc2RgbaUnorm`, and `Dxt4_5` → `Bc3RgbaUnorm`.
/// BC2/BC3 have a 16-byte block footprint (BC1 is 8), so `upload`'s
/// `bytes_per_row` computation branches on the block size too.
fn descriptor_for(key: &TextureKey) -> Option<wgpu::TextureDescriptor<'static>> {
let format = match key.format {
TextureFormat::K8888 => wgpu::TextureFormat::Rgba8Unorm,
TextureFormat::K565 => wgpu::TextureFormat::Rgba8Unorm, // CPU-expanded
TextureFormat::Dxt1 => wgpu::TextureFormat::Bc1RgbaUnorm,
TextureFormat::Dxt2_3 => wgpu::TextureFormat::Bc2RgbaUnorm,
TextureFormat::Dxt4_5 => wgpu::TextureFormat::Bc3RgbaUnorm,
_ => return None,
};
Some(wgpu::TextureDescriptor {
label: Some("xenos cached texture"),
size: wgpu::Extent3d {
width: key.width as u32,
height: key.height as u32,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
})
}
fn create_magenta_stub(
device: &wgpu::Device,
queue: &wgpu::Queue,
) -> (wgpu::Texture, wgpu::TextureView) {
let tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("xenos cached stub"),
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
queue.write_texture(
wgpu::ImageCopyTexture {
texture: &tex,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
&[0xFFu8, 0x00, 0xFF, 0xFF],
wgpu::ImageDataLayout {
offset: 0,
bytes_per_row: Some(4),
rows_per_image: Some(1),
},
wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
);
let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
(tex, view)
}