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>
228 lines
8.4 KiB
Rust
228 lines
8.4 KiB
Rust
//! 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)
|
||
}
|