[iterate-4A] intro-video ROOT #3: render the YUV movie in correct color
The intro video (ADV.wmv) now plays end-to-end in correct color. Three stacked host-render-path bugs, each masked by the prior: #3a Multi-texture render path. The host bound a single texture slot, so the YUV pixel shader's three plane fetches (Y 1280x720 + U/V 640x360, all k_8) collapsed onto one texture. Expanded the Xenos pipeline to 8 tex+1 sampler slots (xenos_pipeline.rs, xenos_interp.wgsl, translator.rs headers); each tfetch selects its texture by fetch-constant slot; the DrawCapture textures tuple now carries the slot; render.rs uploads+binds every plane per-draw. Also added the scalar-constant ALU ops MULSC/ADDSC/ SUBSC (42-47) the YUV->RGB shader uses. #3b tfetch destination swizzle. decode_fetch read the tfetch dest as a 4-bit write mask (w1 & 0xF), but Xenos tfetch dword1[0:11] is a 12-bit destination swizzle (3 bits/component: 0-3=xyzw, 4/5=const 0/1, 6/7=keep). The result: all three plane fetches did a full-vec4 overwrite of the dest register, so only the last plane survived. Decode the real 12-bit swizzle (dest_swizzle) and emit per-lane writes so Y/U/V coexist in r1.x/.y/.z. #3c Pixel-shader constant bank. Xenos splits the 512-entry float-constant file: the vertex shader addresses c0..255 -> physical 0..255, but the pixel shader's c0..255 map to physical 256..511. The game uploads the YUV->RGB coefficients to physical 510/511. Our translator indexed the low half for PS constants, reading all-zero -> R=B=Y^2, G=0 (magenta). emit_alu now adds a const_base of 256 for pixel-stage constant reads. Plus a bounded (FIFO, 64-entry) host texture cache: the movie streams ~3 new-VA planes per frame, and the previously-unbounded cache exhausted GPU memory into a device-lost crash mid-playback. Verified visually: the SQUARE ENIX logo and ADV.wmv footage render in correct color (was magenta); the translated movie shader now reads alu[510]/alu[511]; frame green channel is nonzero and R != B. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -774,29 +774,37 @@ impl RenderState {
|
||||
host_texture_cache,
|
||||
..
|
||||
} = self;
|
||||
match cap.textures.first() {
|
||||
Some((key, version, bytes)) => {
|
||||
// iterate-3AD: use the decoder's real content `version`
|
||||
// (from `span_max_version`) so the host cache re-uploads
|
||||
// when the guest fills MORE of an evolving atlas. The
|
||||
// publisher and the 2nd splash logo share one K8888
|
||||
// surface (base 0x4dbee000); the 2nd logo's texels land
|
||||
// AFTER the first upload. With the old hardcoded
|
||||
// `version_when_uploaded = 1`, the same `TextureKey`
|
||||
// never re-uploaded, so the 2nd logo sampled its (then
|
||||
// still-zero) atlas region as black. The real version
|
||||
// increases as the guest writes, triggering re-upload.
|
||||
if cap.textures.is_empty() {
|
||||
xenos_pipeline.set_texture_view(device, None);
|
||||
} else {
|
||||
// Root-#3: upload EVERY plane this draw samples, then bind
|
||||
// each to its own fetch-constant slot so a multi-texture
|
||||
// shader (the intro video's YUV Y/U/V planes) reads the
|
||||
// right texture per `tfetch`. iterate-3AD: use the decoder's
|
||||
// real content `version` (from `span_max_version`) so the
|
||||
// host cache re-uploads when the guest fills MORE of an
|
||||
// evolving atlas (e.g. the 2nd splash logo sharing a K8888
|
||||
// surface with the publisher logo).
|
||||
for (_slot, key, version, bytes) in &cap.textures {
|
||||
let cached = xenia_gpu::texture_cache::CachedTexture {
|
||||
key: *key,
|
||||
version_when_uploaded: *version,
|
||||
bytes: bytes.clone(),
|
||||
};
|
||||
host_texture_cache.upload(device, queue, &cached);
|
||||
if let Some(view) = host_texture_cache.view_for(key) {
|
||||
xenos_pipeline.set_texture_view(device, Some(view));
|
||||
}
|
||||
// Collect a view per slot (immutable borrow — uploads are
|
||||
// done) and bind them all at once.
|
||||
let mut slot_views: [Option<&wgpu::TextureView>;
|
||||
crate::xenos_pipeline::TEX_SLOTS] =
|
||||
[None; crate::xenos_pipeline::TEX_SLOTS];
|
||||
for (slot, key, _version, _bytes) in &cap.textures {
|
||||
let s = *slot as usize;
|
||||
if s < crate::xenos_pipeline::TEX_SLOTS {
|
||||
slot_views[s] = host_texture_cache.view_for(key);
|
||||
}
|
||||
}
|
||||
None => xenos_pipeline.set_texture_view(device, None),
|
||||
xenos_pipeline.set_texture_slots(device, &slot_views);
|
||||
}
|
||||
}
|
||||
let raw_vs = shader_blobs.get(&cap.vs_key).cloned().unwrap_or_default();
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! re-decodes (via the CPU cache) and [`TextureCacheHost::upload`] replaces
|
||||
//! the wgpu texture in place.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
|
||||
use xenia_gpu::texture_cache::{CachedTexture, TextureFormat, TextureKey};
|
||||
|
||||
@@ -31,6 +31,13 @@ pub struct HostTextureEntry {
|
||||
|
||||
pub struct TextureCacheHost {
|
||||
entries: HashMap<TextureKey, HostTextureEntry>,
|
||||
/// FIFO insertion order for bounded eviction. The intro video uploads
|
||||
/// ~3 new-VA textures per frame (the Y/U/V planes land at rotating guest
|
||||
/// addresses), so without a cap the wgpu texture set grows unbounded until
|
||||
/// the GPU device runs out of memory (a device-lost hard crash mid-movie).
|
||||
/// Once we exceed `MAX_ENTRIES` the oldest texture is evicted; an evicted
|
||||
/// texture simply re-uploads on demand if the guest samples it again.
|
||||
order: VecDeque<TextureKey>,
|
||||
/// HUD-surfaced counters — mirror the CPU-side cache so a session
|
||||
/// can tell whether uploads are dominated by fresh work or stale
|
||||
/// invalidations.
|
||||
@@ -45,9 +52,16 @@ impl Default for TextureCacheHost {
|
||||
}
|
||||
|
||||
impl TextureCacheHost {
|
||||
/// Upper bound on simultaneously-resident host textures. The movie's live
|
||||
/// working set is small (~3–9 planes across the in-flight frames) and boot
|
||||
/// uses a couple dozen; 64 leaves headroom while bounding GPU memory to a
|
||||
/// few hundred MB worst case.
|
||||
const MAX_ENTRIES: usize = 64;
|
||||
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: HashMap::new(),
|
||||
order: VecDeque::new(),
|
||||
uploads_total: 0,
|
||||
reuploads_total: 0,
|
||||
}
|
||||
@@ -57,6 +71,28 @@ impl TextureCacheHost {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
/// Record a freshly-inserted key in FIFO order and evict the oldest
|
||||
/// entries until we're back under `MAX_ENTRIES`. Never evicts the key we
|
||||
/// just inserted (it's at the back of the queue).
|
||||
fn record_and_evict(&mut self, key: TextureKey, is_new: bool) {
|
||||
if is_new {
|
||||
self.order.push_back(key);
|
||||
}
|
||||
while self.order.len() > Self::MAX_ENTRIES {
|
||||
match self.order.pop_front() {
|
||||
Some(old) if old != key => {
|
||||
self.entries.remove(&old);
|
||||
}
|
||||
Some(old) => {
|
||||
// The only remaining entry is the current key — keep it.
|
||||
self.order.push_back(old);
|
||||
break;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kept for API symmetry with `len` (clippy recommends both together).
|
||||
/// Unused in code today — callers check `len() == 0` via the HUD.
|
||||
#[allow(dead_code)]
|
||||
@@ -90,6 +126,7 @@ impl TextureCacheHost {
|
||||
// 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 is_new = !self.entries.contains_key(&key);
|
||||
let (tex, view) = create_magenta_stub(device, queue);
|
||||
let entry = HostTextureEntry {
|
||||
texture: tex,
|
||||
@@ -98,6 +135,7 @@ impl TextureCacheHost {
|
||||
};
|
||||
self.entries.insert(key, entry);
|
||||
self.uploads_total += 1;
|
||||
self.record_and_evict(key, is_new);
|
||||
return self.entries.get(&key).unwrap();
|
||||
};
|
||||
let texture = device.create_texture(&descriptor);
|
||||
@@ -145,6 +183,7 @@ impl TextureCacheHost {
|
||||
} else {
|
||||
self.uploads_total += 1;
|
||||
}
|
||||
self.record_and_evict(key, !had_prior);
|
||||
self.entries.get(&key).unwrap()
|
||||
}
|
||||
|
||||
|
||||
@@ -238,6 +238,13 @@ pub struct XenosPipeline {
|
||||
pub target_format: wgpu::TextureFormat,
|
||||
}
|
||||
|
||||
/// Number of simultaneously-bindable Xenos texture slots in `group(1)`
|
||||
/// (binding 0 = shared sampler, bindings 1..=TEX_SLOTS = tex0..). Must match
|
||||
/// the binding count in `xenos_interp.wgsl`. 8 covers the intro video's 3
|
||||
/// YUV planes with headroom while staying well under the 16-sampled-texture
|
||||
/// per-stage limit.
|
||||
pub const TEX_SLOTS: usize = 8;
|
||||
|
||||
impl XenosPipeline {
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
@@ -315,26 +322,29 @@ impl XenosPipeline {
|
||||
},
|
||||
],
|
||||
});
|
||||
let mut tex_bgl_entries: Vec<wgpu::BindGroupLayoutEntry> =
|
||||
Vec::with_capacity(TEX_SLOTS + 1);
|
||||
tex_bgl_entries.push(wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
});
|
||||
for k in 0..TEX_SLOTS {
|
||||
tex_bgl_entries.push(wgpu::BindGroupLayoutEntry {
|
||||
binding: (k + 1) as u32,
|
||||
visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
});
|
||||
}
|
||||
let tex_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("xenos tex bind group layout"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
entries: &tex_bgl_entries,
|
||||
});
|
||||
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
@@ -437,19 +447,21 @@ impl XenosPipeline {
|
||||
mipmap_filter: wgpu::FilterMode::Nearest,
|
||||
..Default::default()
|
||||
});
|
||||
let mut tex_bg_entries: Vec<wgpu::BindGroupEntry> = Vec::with_capacity(TEX_SLOTS + 1);
|
||||
tex_bg_entries.push(wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::Sampler(&dummy_sampler),
|
||||
});
|
||||
for k in 0..TEX_SLOTS {
|
||||
tex_bg_entries.push(wgpu::BindGroupEntry {
|
||||
binding: (k + 1) as u32,
|
||||
resource: wgpu::BindingResource::TextureView(&dummy_view),
|
||||
});
|
||||
}
|
||||
let tex_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("xenos tex bind group"),
|
||||
layout: &tex_bgl,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&dummy_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::Sampler(&dummy_sampler),
|
||||
},
|
||||
],
|
||||
entries: &tex_bg_entries,
|
||||
});
|
||||
|
||||
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
@@ -731,20 +743,35 @@ impl XenosPipeline {
|
||||
/// [`TextureCacheHost`]. Pass `None` to revert to the built-in dummy
|
||||
/// magenta stub.
|
||||
pub fn set_texture_view(&mut self, device: &wgpu::Device, view: Option<&wgpu::TextureView>) {
|
||||
let bound = view.unwrap_or(&self.dummy_view);
|
||||
self.set_texture_slots(device, &[view]);
|
||||
}
|
||||
|
||||
/// Rebind `group(1)` with per-slot texture views. `views[k]` binds to
|
||||
/// texture slot `k` (the `tfetch` fetch-constant index the shader selects);
|
||||
/// `None`, or any slot past `views.len()`/`TEX_SLOTS`, falls back to the
|
||||
/// transparent dummy. The shared sampler stays at binding 0. Each call
|
||||
/// rebuilds the bind group, so per-draw slot sets composite correctly.
|
||||
pub fn set_texture_slots(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
views: &[Option<&wgpu::TextureView>],
|
||||
) {
|
||||
let mut entries: Vec<wgpu::BindGroupEntry> = Vec::with_capacity(TEX_SLOTS + 1);
|
||||
entries.push(wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
});
|
||||
for k in 0..TEX_SLOTS {
|
||||
let v = views.get(k).copied().flatten().unwrap_or(&self.dummy_view);
|
||||
entries.push(wgpu::BindGroupEntry {
|
||||
binding: (k + 1) as u32,
|
||||
resource: wgpu::BindingResource::TextureView(v),
|
||||
});
|
||||
}
|
||||
self.tex_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("xenos tex bind group (rebind)"),
|
||||
label: Some("xenos tex bind group (slots)"),
|
||||
layout: &self.tex_bgl,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(bound),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
},
|
||||
],
|
||||
entries: &entries,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user