From 8ade05c748c9725bfe59ed9b1401dd09993a1179 Mon Sep 17 00:00:00 2001 From: Philpax Date: Wed, 12 Aug 2026 15:13:41 +0200 Subject: [PATCH] feat(stereo): add experimental water stereo fixes for #47 The WaveWorks ocean simulation is gated to one step per frame. The simulation step inside the main-body water draw runs on the first water draw of the frame and is suppressed thereafter, so both eyes render the same archived displacement state. The planar reflection camera is re-mirrored per eye. The water shader samples reflection maps at the pixel's own screen position, so a map rendered from one mirrored camera only matches one view. The engine mirror is snapshotted on the first dispatch and the eye's rigid pose delta is conjugated by the water-plane mirror before being applied to the camera. Reflection pre-passes are re-rendered on the second eye when the toggle is on. The screen-space water reflection flag can be disabled for A/B testing. Disabling forces the water onto the full-reflection binding; if the per-eye mismatch disappears, the screen-space sampling is the seam. Screenshots now capture per-eye water draw inputs. WaterDrawSnapshot records frame, dispatch ordinal, render pass, under-water flag, simulation time, screen-space reflection state, per-eye reflection state, reflection camera pose, reflection pass count and texture identities. The sidecar JSON emitted with F12 captures includes a water field. The debug UI exposes share_water_simulation, per_eye_water_reflection and disable_screen_space_water_reflection. The scene module visibility is widened to pub(crate) so the screenshot path can read the water snapshots. This is incomplete experimental work for #47. --- payload/src/hooks/game.rs | 5 + payload/src/hooks/graphics_engine/mod.rs | 2 +- .../src/hooks/graphics_engine/render_pass.rs | 60 +- .../src/hooks/graphics_engine/scene/mod.rs | 4 + .../src/hooks/graphics_engine/scene/water.rs | 772 ++++++++++++++++++ payload/src/screenshot.rs | 15 +- payload/src/stereo/config.rs | 22 + .../src/stereo/single_pass/per_eye_reissue.rs | 87 ++ payload/src/ui/render/corrections.rs | 26 + 9 files changed, 988 insertions(+), 5 deletions(-) create mode 100644 payload/src/hooks/graphics_engine/scene/water.rs diff --git a/payload/src/hooks/game.rs b/payload/src/hooks/game.rs index da81146..6bfcfb1 100644 --- a/payload/src/hooks/game.rs +++ b/payload/src/hooks/game.rs @@ -93,6 +93,11 @@ fn game_update_render(game: *mut Game, update_contexts: *mut UpdateContexts) { // Apply the sun-shadow diagnostic override before the original runs, so this frame's // sim-side CShadowManager::UpdateRender sees it and drives the engine's own SetEnabled path. apply_sun_shadow_override(Config::lock_query(|c| c.stereo.disable_sun_shadows)); + // Same discipline for the screen-space water-reflection override: applied here so the + // engine's water update and this frame's water draws both see it. + graphics_engine::scene::water::apply_ssr_override(Config::lock_query(|c| { + c.stereo.disable_screen_space_water_reflection + })); // Apply a requested shader reload here, on the game thread before this frame's draws, so the // PCF-patch hook re-creates the already-loaded shaders (injection is normally after the game diff --git a/payload/src/hooks/graphics_engine/mod.rs b/payload/src/hooks/graphics_engine/mod.rs index a8a38d0..b5d86d1 100644 --- a/payload/src/hooks/graphics_engine/mod.rs +++ b/payload/src/hooks/graphics_engine/mod.rs @@ -25,7 +25,7 @@ pub mod shader; mod single_pass; mod post; -mod scene; +pub(crate) mod scene; mod screen; pub(crate) use post::post_effects; diff --git a/payload/src/hooks/graphics_engine/render_pass.rs b/payload/src/hooks/graphics_engine/render_pass.rs index 4aada9c..0ac4187 100644 --- a/payload/src/hooks/graphics_engine/render_pass.rs +++ b/payload/src/hooks/graphics_engine/render_pass.rs @@ -25,6 +25,7 @@ use crate::{ stereo_diff::op_total, trace::{TraceEvent, TraceState, tracing_active}, }, + hooks::graphics_engine::scene::water, profiler::gpu::seam, stereo::{STEREO_STATE, draw_index, is_second_eye}, vr::foveation::{FORCE_STENCIL_TEST, FoveationParams}, @@ -561,6 +562,12 @@ fn pre_draw(this: *mut RenderEngine, ctx: *mut HContext_t) -> u64 { // rather than from the game thread, which runs concurrently with it once the frame tail is // deferred. crate::stereo::single_pass::begin_dispatch(); + // The water-simulation once-per-frame latch resets at the same seam, for the same + // concurrency reason (see its doc comment). + water::begin_dispatch(); + // Re-mirror the water reflection camera for this dispatch's eye before the reflection + // pre-passes (which the un-sharing below lets run per eye) read it. + water::apply_per_eye_reflection_camera(); let original = PRE_DRAW.get().unwrap(); let share_cfg = Config::lock_query(|c| c.stereo.share_prepasses && c.stereo.restore_frame_counters); @@ -569,14 +576,44 @@ fn pre_draw(this: *mut RenderEngine, ctx: *mut HContext_t) -> u64 { // engine-owned, null-checked pass pointers; the `m_Enabled` flag write mirrors the shadow // scheduler's own store in `commit_render_pass_settings`. let disabled = unsafe { disable_shared_prepasses(this) }; + unsafe { record_reflection_pass_count(this) }; let r = original.call(this, ctx); unsafe { reenable_passes(&disabled) }; r } else { + // SAFETY: as above, read-only. + unsafe { record_reflection_pass_count(this) }; original.call(this, ctx) } } +/// Record how many reflection-chain passes (the categories +/// [`RenderPassId::PRE_RP_REFLECTION_PRE`] through [`RenderPassId::PRE_RP_REFLECTION_POST`]) are +/// enabled for this dispatch's pre-pass loop, for the water diagnostics snapshot (issue #47): zero +/// on an eye that should have re-rendered them means the per-eye reflection re-render never ran. +/// +/// # Safety +/// +/// `this` must be the live render engine. +unsafe fn record_reflection_pass_count(this: *mut RenderEngine) { + let Some(engine) = (unsafe { this.as_ref() }) else { + return; + }; + let mut enabled = 0; + for cat in + RenderPassId::PRE_RP_REFLECTION_PRE as usize..=RenderPassId::PRE_RP_REFLECTION_POST as usize + { + for &pass in unsafe { engine.m_RenderPasses[cat].as_slice() } { + if let Some(pass) = (unsafe { pass.as_ref() }) + && pass.m_StateFlags.contains(RenderPassState::m_Enabled) + { + enabled += 1; + } + } + } + water::record_reflection_passes_enabled(enabled); +} + /// The pre-pass categories ([`RenderPassId`] indices) that render identically for both eyes and whose /// outputs persist for the whole frame, so eye 1 can reuse eye 0's output instead of re-running /// them: the reflection chain (which also fixes the per-eye shadow flicker #31), the sun-shadow @@ -593,6 +630,22 @@ const SHARED_PREPASS_CATEGORIES: &[(usize, usize)] = &[ ), ]; +/// The shared categories with the water planar-reflection chain carved out, used while +/// [`per_eye_water_reflection`](crate::stereo::config::StereoConfig::per_eye_water_reflection) is +/// on: those passes re-render on the second eye's dispatch from that eye's re-mirrored reflection +/// camera (see `scene::water::apply_per_eye_reflection_camera`), while the environment cube, cloud +/// shadows, and the shadow/water-sim block stay shared. +const SHARED_PREPASS_CATEGORIES_PER_EYE_REFLECTION: &[(usize, usize)] = &[ + ( + RenderPassId::PRE_RP_ENVREFLECTION as usize, + RenderPassId::PRE_RP_CLOUDSHADOWS as usize, + ), + ( + RenderPassId::PRE_RP_STATIC_SHADOW_0 as usize, + RenderPassId::PRE_RP_WATER_DISPLACEMENT_PRE as usize, + ), +]; + /// Clear [`RenderPassState::m_Enabled`] on every enabled pass in the shared pre-pass categories so /// `PreDraw`'s loop skips them, returning the passes cleared so [`reenable_passes`] can restore them. /// @@ -603,8 +656,13 @@ unsafe fn disable_shared_prepasses(this: *mut RenderEngine) -> Vec<*mut RenderPa let Some(engine) = (unsafe { this.as_mut() }) else { return Vec::new(); }; + let categories = if Config::lock_query(|c| c.stereo.per_eye_water_reflection) { + SHARED_PREPASS_CATEGORIES_PER_EYE_REFLECTION + } else { + SHARED_PREPASS_CATEGORIES + }; let mut disabled = Vec::new(); - for &(lo, hi) in SHARED_PREPASS_CATEGORIES { + for &(lo, hi) in categories { for cat in lo..=hi { for &pass in unsafe { engine.m_RenderPasses[cat].as_slice() } { if let Some(pass) = (unsafe { pass.as_mut() }) diff --git a/payload/src/hooks/graphics_engine/scene/mod.rs b/payload/src/hooks/graphics_engine/scene/mod.rs index 5be2d72..721eaa9 100644 --- a/payload/src/hooks/graphics_engine/scene/mod.rs +++ b/payload/src/hooks/graphics_engine/scene/mod.rs @@ -13,6 +13,9 @@ pub mod terrain; // The stereo relaxation of the volumetric-patch terrain's view-dependent hull culls (black terrain // patch gaps). pub(crate) mod terrain_cull; +// The legacy water blocks' per-eye screen-UV bias under the collapse. +pub(crate) mod water; + /// Bundle the world-geometry detours into one hook library. pub(super) fn hook_library() -> HookLibrary { HookLibrary::new() @@ -20,4 +23,5 @@ pub(super) fn hook_library() -> HookLibrary { .with_hook_library(culling::hook_library()) .with_hook_library(terrain::hook_library()) .with_hook_library(terrain_cull::hook_library()) + .with_hook_library(water::hook_library()) } diff --git a/payload/src/hooks/graphics_engine/scene/water.rs b/payload/src/hooks/graphics_engine/scene/water.rs new file mode 100644 index 0000000..0f84223 --- /dev/null +++ b/payload/src/hooks/graphics_engine/scene/water.rs @@ -0,0 +1,772 @@ +//! The legacy (non-WaveWorks) water render blocks' screen-space reflection/refraction lookup, biased +//! into each eye's half of the double-wide target under the single-pass collapse. +//! +//! These blocks sample `ReflectionMap`, `RefractionMap`, and `DepthMap` through a *projective* +//! coordinate rather than `SV_Position`: their block type stages a world→screen-UV matrix on vertex +//! `cb1` once per pass, the vertex shader transforms the water vertex by it and passes the result on +//! as `TEXCOORD1`, and the pixel shader divides by `w`. The NDC→UV half-scale is already folded into +//! the CPU-side matrix, so the UV it yields is normalized over the **viewport** -- one eye's half -- +//! while every buffer it indexes is the whole double-wide target. Each eye therefore reads the entire +//! two-eye image stretched across its water surface, and because the error is a fixed 2x scale it is a +//! 2x motion gain too: the reflections slide over the water as the camera moves. +//! +//! The fix is four rows of arithmetic on the matrix the type already staged -- +//! `u' = (u + eye) · 0.5` -- applied around a per-eye re-issue of the block's `Draw` +//! ([`screen_uv_cb_per_eye`](crate::stereo::single_pass::screen_uv_cb_per_eye)), which is also what +//! makes the eye known. No shader is touched: the water vertex shaders take their clip position from +//! the global `cb0`, which the collapse already handles, and the projective coordinate is entirely a +//! CPU-side constant. +//! +//! The WaveWorks family (`NvWater*`, [`NvWaterHighEndRenderBlock`]) does not have that defect -- its +//! shaders build the screen UV as `SV_Position × (1/2W, 1/H)`, which is already self-consistent under +//! double-wide -- but it has the other one, and the second half of this module fixes it: the whole +//! family takes its clip position from a model-view-projection the block bakes into its own constant +//! buffer, so the collapse's per-eye machinery never reaches it and both eyes see the collapsed centre +//! view of the water surface. See [`nv_water_per_eye`]. +//! +//! Independent of the collapse, [`wave_works_simulation_step`] also holds the WaveWorks simulation +//! to one step per real frame across the two-pass stereo dispatches (issue #47): the step lives +//! inside the main-body water draw, so a per-eye dispatch would otherwise advance the ocean between +//! the eyes and decorrelate the sun glint. +//! +//! Not covered: the water-box *surface* geometry (`WaterBoxRenderBlock::DrawSurface`, and the +//! `NWater::DrawWaterBoxSurface` loop [`NvWaterHighEndRenderBlock::Draw`] runs over every registered +//! box). Neither of the two mechanisms above reaches it, and neither is its defect. Its vertex shader +//! (`waterboxsurface`) builds clip as +//! +//! ```text +//! world_rel = box_transform(cb1[0..3]) · position // scale by half-extents + (centre - camera) +//! clip = cb0[0..3] · (world_rel + cb0[4]) // full view-projection · absolute world +//! ``` +//! +//! -- the *full*, translation-bearing view-projection at global rows `0..3`, which the collapse's +//! per-eye register remap does not cover (it claims only `cb0[4]` and `cb0[29..32]`). The remap does +//! claim the shader, on that lone `cb0[4]` camera-position reference, and retargets it to `cb13` +//! while leaving the projection centred -- so the eye offset is added to the *world position* and +//! then viewed from the centre, which displaces the surface by the eye offset in the wrong direction +//! instead of giving it parallax. The per-eye re-issue below draws with one instance, so the parity +//! resolves to eye 0 in both halves and both eyes get eye 0's displacement. +//! +//! This is the whole legacy family's idiom, not one permutation's: `waterbox`, `waterboxbelow`, +//! `watershader_lod0`, and `watershader_lod1` read the same rows. The transform they want is the +//! reprojection rewrite, which replaces the clip position wholesale and so does not care that the +//! source was `cb0[0..3]` -- but reprojecting them moves where they rasterize, which invalidates the +//! projective screen UV the first half of this module corrects (that fix is deliberately *not* +//! reprojected, precisely because the geometry still lands at the centre view). The two are one +//! change, and the surface grid additionally has no per-eye re-issue of its own to hang it off. +//! See `docs/mod/stereo/single-pass-stereo.md`. + +use std::{ + ffi::c_void, + sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering}, +}; + +use detours_macro::detour; +use jc3gi::{ + graphics_engine::{ + graphics_engine::RenderContext, + render_block::{ + NvWaterHighEndRenderBlock, NvWaterHighEndRenderBlockType, RBIInfo, WaterBoxRenderBlock, + WaterHighEndRenderBlock, + }, + render_engine::RenderPassId, + }, + types::math::Matrix4, + water_patch_manager::WaterPatchManager, +}; +use parking_lot::Mutex; +use re_utilities::hook_library::HookLibrary; +use serde::Serialize; + +use crate::config::Config; + +pub(super) fn hook_library() -> HookLibrary { + HookLibrary::new() + .with_static_binder(&WATER_HIGH_END_DRAW_BINDER) + .with_static_binder(&WATER_BOX_DRAW_BINDER) + .with_static_binder(&NV_WATER_HIGH_END_DRAW_BINDER) + .with_static_binder(&WAVE_WORKS_SIMULATION_STEP_BINDER) +} + +/// Reset the once-per-frame simulation latch at the frame's first dispatch. Called from the +/// `PreDraw` dispatch prologue on the draw thread -- the same seam +/// [`crate::stereo::single_pass::begin_dispatch`] uses, and for the same reason: with the frame +/// tail deferred, the game thread runs concurrently with the previous frame's still-walking +/// dispatch, so a game-thread frame-start reset could re-arm the step under that dispatch's live +/// water draw. +pub(crate) fn begin_dispatch() { + if crate::stereo::dispatch_ordinal() == 0 { + SIMULATION_STEPPED_THIS_FRAME.store(false, Ordering::Relaxed); + WATER_FRAME.fetch_add(1, Ordering::Relaxed); + } +} + +/// Everything the WaveWorks water draw read for one dispatch, snapshotted at the main-body `Draw` +/// for the F12 sidecar (issue #47): the per-eye water mismatch survives every input we can reason +/// about statically, so each capture records what the two eyes' draws were *actually* given -- +/// texture identities as pointers (equal pointers between the eyes mean shared content), the +/// shader-permutation selectors, and the simulation clock. +#[derive(Clone, Serialize)] +pub struct WaterDrawSnapshot { + /// The value of [`WATER_FRAME`] at the draw, so the sidecar shows whether the two eyes' + /// snapshots come from the same frame. + pub frame: u64, + pub dispatch_ordinal: usize, + /// The pass the draw ran under (`RenderPassId`); `RP_WATER` (109) is the expected scene pass. + pub render_pass: i32, + /// Selects the below-water shader permutation and tweak set. + pub under_water: bool, + /// Whether the ocean simulation is altitude-paused this draw. + pub altitude_simulation_pause: bool, + /// The block's simulation clock in seconds. + pub render_time: f64, + /// `WaterPatchManager::m_EnableScreenSpaceWaterReflection`; picks the distant-reflection + /// binding over the full one. `None` when the manager singleton is unavailable. + pub screen_space_reflection: Option, + /// Whether the per-eye reflection re-mirror was configured for this frame (issue #47). + pub per_eye_reflection: bool, + /// The reflection camera's world position at this eye's water draw, to verify the per-eye + /// re-mirror actually landed (the two eyes should differ by the mirrored eye delta). `None` + /// when the manager or camera is unavailable. + pub reflection_camera_position: Option<[f32; 3]>, + /// The reflection camera's world forward row at this eye's water draw, for the rotational part + /// of the same verification. + pub reflection_camera_forward: Option<[f32; 3]>, + /// How many passes in the reflection chain (categories 9..=16) were enabled when this + /// dispatch's pre-pass loop ran; zero on an eye that skipped them means the re-render never + /// happened. `None` until the pre-draw hook records it. + pub reflection_passes_enabled: Option, + pub depth_texture: String, + pub dynamic_reflection_color_texture: String, + pub dynamic_reflection_alpha_texture: String, + pub back_buffer_texture: String, + /// The block type's shared textures and tessellation selector; `None` when the type singleton + /// is unavailable. + pub type_textures: Option, +} + +/// The [`NvWaterHighEndRenderBlockType`] singleton's shared bindings, as pointer identities. +#[derive(Clone, Serialize)] +pub struct WaterTypeTextures { + pub tessellation_options: i32, + pub water_mod: String, + pub foam: String, + pub water_bump: String, + pub distant_reflection: String, + pub full_reflection: String, +} + +/// The latest [`WaterDrawSnapshot`] per eye (`draw_index`-indexed), for the screenshot sidecar. +pub fn last_water_draws() -> [Option; 2] { + LAST_WATER_DRAWS.lock().clone() +} + +/// Re-mirror the water reflection camera for the dispatch's eye (issue #47). The water shader +/// samples its reflection maps at the pixel's own screen position, so the maps are only valid for +/// the exact camera they were rendered from; the engine mirrors one camera per frame (the centre +/// pose, in `CWaterPatchManager::UpdateThread`), which matches neither eye. This composes the +/// eye's rigid pose delta -- conjugated by the water-plane mirror, so it moves the mirrored camera +/// the way the mirrored eye moves -- onto the engine's own mirror, and the reflection pre-passes +/// re-render from it each dispatch (their sharing is carved out in +/// `render_pass::disable_shared_prepasses` while this is on). +/// +/// Called from the `PreDraw` dispatch prologue on the draw thread, after the dispatch's eye camera +/// is established and before the reflection passes run. The engine's own per-frame mirror is +/// snapshotted at the frame's first dispatch so the second eye composes onto it rather than onto +/// the first eye's already-offset camera. A no-op under the single-pass collapse: its one dispatch +/// keeps the engine's centre mirror, whose error is at least symmetric between the eye-halves. +pub(crate) fn apply_per_eye_reflection_camera() { + if !Config::lock_query(|c| c.stereo.per_eye_water_reflection) + || !crate::stereo::active() + || crate::stereo::single_pass::collapse_active() + { + return; + } + let eye = crate::stereo::draw_index(); + let Some(params) = crate::vr::render_params(eye) else { + return; + }; + let Some(center) = crate::stereo::STEREO_STATE.lock().center_transform else { + return; + }; + // SAFETY: the water-patch-manager singleton and its reflection camera are engine-owned and + // null-checked; the camera's matrices are written on the draw thread at the dispatch prologue, + // before the reflection passes that read them run on this same thread. + unsafe { + let Some(manager) = WaterPatchManager::get() else { + return; + }; + let Some(camera) = manager.m_ReflectionCamera.as_mut() else { + return; + }; + let center_world = glam::Mat4::from(center); + let mut eye_world = center_world; + eye_world.w_axis += params.world_offset.extend(0.0); + let eye_world = eye_world * glam::Mat4::from_quat(params.orientation_delta); + let mirrored = { + let mut lock = REFLECTION_MIRROR_STATE.lock(); + let current = glam::Mat4::from(camera.m_TransformF); + let state = lock.get_or_insert_with(|| MirrorState { + engine_base: current, + last_written: None, + }); + // Adopt the camera as the frame's engine mirror only when the engine actually rewrote + // it since our last write: `UpdateThread` skips its mirror rebuild in some camera + // configurations, and adopting our own previous write as the base would compound the + // eye delta frame over frame. + if crate::stereo::dispatch_ordinal() == 0 && state.last_written != Some(current) { + state.engine_base = current; + } + let mirrored = + mirrored_eye_reflection_transform(state.engine_base, center_world, eye_world); + state.last_written = Some(mirrored); + mirrored + }; + camera.m_TransformF = Matrix4::from(mirrored); + camera.m_View = Matrix4::from(mirrored.inverse()); + // The engine rebuilt the reflection camera's projections on the game thread; only the pose + // changes here, so rebuild the view-projections from the existing projections (`Matrix4`'s + // `*` is the engine's row-major `Multiply4x4` convention). + camera.m_ViewProjection = camera.m_View * camera.m_Projection; + camera.m_ViewProjectionF = camera.m_View * camera.m_ProjectionF; + } +} + +/// Apply or lift the screen-space water-reflection override +/// ([`crate::stereo::config::StereoConfig::disable_screen_space_water_reflection`]): while +/// overridden, `WaterPatchManager::m_EnableScreenSpaceWaterReflection` is held false, and the +/// engine's own value is saved on entry and restored on exit. Called once per frame from +/// `game_update_render` on the game thread (before the engine's update reads the flag), mirroring +/// `apply_sun_shadow_override`'s sentinel discipline. +pub(crate) fn apply_ssr_override(disable: bool) { + // SAFETY: the water-patch-manager singleton is live once the engine is initialised, and it is + // null-checked; the flag is a plain settings toggle read by the game-thread water update and + // the water draws. + unsafe { + let Some(manager) = WaterPatchManager::get() else { + return; + }; + let saved = SAVED_SSWR_ENABLED.load(Ordering::Relaxed); + if disable && saved == u8::MAX { + SAVED_SSWR_ENABLED.store( + u8::from(manager.m_EnableScreenSpaceWaterReflection), + Ordering::Relaxed, + ); + manager.m_EnableScreenSpaceWaterReflection = false; + } else if !disable && saved != u8::MAX { + manager.m_EnableScreenSpaceWaterReflection = saved != 0; + SAVED_SSWR_ENABLED.store(u8::MAX, Ordering::Relaxed); + } + } +} + +/// The high-end water family (`waterhighend`, `waterbelow`, `watershader_lod0/1/2`) reads its +/// screen-UV matrix from vertex `cb1` registers 1..4, staged from the render context's +/// world→clip view-projection. +#[detour(address = jc3gi::graphics_engine::render_block::WaterHighEndRenderBlock::Draw_ADDRESS)] +fn water_high_end_draw( + this: *const WaterHighEndRenderBlock, + rc: *mut RenderContext, + info: *const RBIInfo, +) { + let detour = WATER_HIGH_END_DRAW.get().unwrap(); + // SAFETY: `rc` is the live render context the engine passed into `Draw`, read only for its + // matrices; the closure re-invokes the original `Draw` trampoline. + let handled = unsafe { + per_eye( + rc, + HIGH_END_REGISTER, + |rc| rc.m_ViewProjectionF.data, + || { + detour.call(this, rc, info); + }, + ) + }; + if !handled { + detour.call(this, rc, info); + } +} + +/// The water-box family (`waterbox`, `waterboxbelow`, `waterboxclear`) reads the same kind of matrix +/// from vertex `cb1` registers 4..7, staged from the translation-free view-projection instead -- +/// its geometry is camera-relative. +#[detour(address = jc3gi::graphics_engine::render_block::WaterBoxRenderBlock::Draw_ADDRESS)] +fn water_box_draw(this: *const WaterBoxRenderBlock, rc: *mut RenderContext, info: *const RBIInfo) { + let detour = WATER_BOX_DRAW.get().unwrap(); + // SAFETY: as above. + let handled = unsafe { + per_eye( + rc, + BOX_REGISTER, + |rc| rc.m_OffsetViewProjection.data, + || detour.call(this, rc, info), + ) + }; + if !handled { + detour.call(this, rc, info); + } +} + +/// The WaveWorks family, re-issued once per eye with that eye's camera substituted into the render +/// context the block bakes its matrices from. See [`nv_water_per_eye`]. +#[detour(address = jc3gi::graphics_engine::render_block::NvWaterHighEndRenderBlock::Draw_ADDRESS)] +fn nv_water_high_end_draw( + this: *const NvWaterHighEndRenderBlock, + rc: *mut RenderContext, + info: *const RBIInfo, +) { + // SAFETY: `this` and `rc` are the live block and render context the engine passed into `Draw`. + unsafe { snapshot_water_draw(this, rc) }; + let detour = NV_WATER_HIGH_END_DRAW.get().unwrap(); + // SAFETY: `this` and `rc` are the live block and render context the engine passed into `Draw`; the + // closure re-invokes the original `Draw` trampoline. + let handled = unsafe { + nv_water_per_eye(this, rc, || { + detour.call(this, rc, info); + }) + }; + if !handled { + detour.call(this, rc, info); + } +} + +/// The WaveWorks simulation step, run at most once per frame: suppressed on the second eye of an +/// [`nv_water_per_eye`] re-issue, and on every stereo dispatch after the frame's first water draw +/// ([`SIMULATION_STEPPED_THIS_FRAME`]). +/// +/// The engine calls this once per frame from the main-body water draw, and the call is the only part +/// of that draw that is not idempotent: it advances the simulation clock, blocks on the readback +/// staging cursor, and archives another displacement snapshot into the ring the CPU-side wave-height +/// and buoyancy queries read. Repeating it within a frame halves that ring's time span, pays the +/// stall again -- and, across the two-pass stereo dispatches, hands the second eye a later ocean than +/// the first, decorrelating the normal-sensitive sun glint between the eyes (issue #47). +#[detour(address = jc3gi::graphics_engine::render_block::WaveWorksSimulationStep_ADDRESS)] +extern "system" fn wave_works_simulation_step( + render_time: f64, + gfx_context: *mut c_void, + kick_id: *mut u64, + simulation: *mut c_void, + savestate: *mut c_void, +) { + if SUPPRESS_SIMULATION_STEP.load(Ordering::Relaxed) { + return; + } + // The swap must come last: it may only latch the step as taken when stereo is active and the + // sharing flag is on, so a flat frame (or an A/B with the flag off) keeps stock behaviour. + if crate::stereo::active() + && Config::lock_query(|c| c.stereo.share_water_simulation) + && SIMULATION_STEPPED_THIS_FRAME.swap(true, Ordering::Relaxed) + { + return; + } + WAVE_WORKS_SIMULATION_STEP.get().unwrap().call( + render_time, + gfx_context, + kick_id, + simulation, + savestate, + ); +} + +/// The vertex constant buffer the water block types stage their screen-UV matrix into. +const CONSTANT_BUFFER: i32 = 1; + +/// The first register of the high-end family's screen-UV matrix (`TypeConstants.ReflectionViewProj`). +const HIGH_END_REGISTER: u32 = 1; + +/// The first register of the water-box family's screen-UV matrix (`cbWaterConsts.WaterConsts[4..7]`). +const BOX_REGISTER: u32 = 4; + +/// Re-issue `draw` once per eye with the eye-half-biased screen-UV matrix, or return `false` when the +/// flag is off, the render context is unreadable, or the collapse intercept declines. +/// +/// `view_projection` picks the render-context matrix the block type bakes the matrix from; this +/// recomputes the type's staged rows rather than intercepting them, because the type stages them once +/// per pass, well before the `Draw` being re-issued. +/// +/// # Safety +/// +/// `rc` must be the live [`RenderContext`] the detoured `Draw` received, and `draw` must invoke the +/// block's original `Draw` trampoline. +unsafe fn per_eye( + rc: *mut RenderContext, + reg_offset: u32, + view_projection: impl Fn(&RenderContext) -> [f32; 16], + draw: impl FnMut(), +) -> bool { + if !Config::lock_query(|c| c.stereo.single_pass.water_uv_per_eye) { + return false; + } + // SAFETY: `rc` is live per the caller contract. + let Some(view_projection) = (unsafe { rc.as_ref() }).map(view_projection) else { + return false; + }; + // SAFETY: as above; `draw` is the block's original `Draw`. + unsafe { + crate::stereo::single_pass::screen_uv_cb_per_eye( + rc, + CONSTANT_BUFFER, + reg_offset, + screen_uv_matrix(view_projection), + draw, + ) + } +} + +/// The rows the block type stages: `view_projection · TEX_BIAS`, in the engine's row-major storage. +/// +/// Loading a row-major matrix into glam's column-major reading yields its transpose, and the +/// row-vector product `A · B` transposes to `Bᵀ · Aᵀ` -- so the factors swap and the result reads back +/// row-major unchanged. +fn screen_uv_matrix(view_projection: [f32; 16]) -> [f32; 16] { + (glam::Mat4::from_cols_array(&TEX_BIAS) * glam::Mat4::from_cols_array(&view_projection)) + .to_cols_array() +} + +/// The NDC→texture bias the water block types post-multiply their view-projection by: the standard +/// `xy · 0.5 + w · 0.5` with the depth row left alone, folded in on the CPU so the shader can divide +/// the interpolated result by `w` and sample directly. Row-major, row-vector convention (the +/// translation is the last row). +#[rustfmt::skip] +const TEX_BIAS: [f32; 16] = [ + 0.5, 0.0, 0.0, 0.0, + 0.0, 0.5, 0.0, 0.0, + 0.0, 0.0, 1.0, 0.0, + 0.5, 0.5, 0.0, 1.0, +]; + +/// Re-issue the WaveWorks block's `Draw` once per eye with that eye's camera, or return `false` when +/// the flag is off, the pass is one of the block's auxiliary passes, the per-eye camera is +/// unavailable, or the collapse intercept declines. +/// +/// Every `NvWater*` permutation writes clip position from a `g_ModelViewProjectionMatrix` at the +/// block type's *own* vertex/domain `cb1` registers 0..3, and its hull and domain shaders carry the +/// same epilogue -- none of them touch the global `cb0` the collapse's shader rewrite works on, so no +/// amount of `cb13`/viewport routing reaches them. The matrix is built in +/// [`NvWaterHighEndRenderBlock::Setup`] from exactly two render-context fields, `m_View` and +/// `m_ProjectionF`, and those same two matrices are also handed to WaveWorks itself as the view and +/// projection its quadtree culls and picks patch LODs against. +/// +/// So rather than reproject a constant in flight, this substitutes the *inputs*: it writes the eye's +/// view and projection into the render context, calls the block's own `Setup` to rebuild and re-upload +/// everything downstream of them, and re-issues `Draw` -- once per eye, each into that eye's half of +/// the double-wide target. The render context is restored afterwards. +/// +/// What that does *not* restore is the block's own cached matrices and the shared constant buffer, +/// which are left holding the right eye's. Nothing reads them before the next `Setup`: the draw-list +/// walk calls `Setup` before the first `Draw` of a block-type run and again whenever the sort id +/// changes, and every `Draw` in between comes back through here and restages per eye anyway. This is +/// verified by the pyxis definitions for `NvWaterHighEndRenderBlock::Setup` ("Neither buffer is +/// written anywhere else, and `Draw` restages nothing") and `NvWaterHighEndRenderBlock::Draw` (which +/// hands the same two block-held matrices straight to WaveWorks without restaging). +/// +/// # Safety +/// +/// `this` and `rc` must be the live pointers the detoured `Draw` received, and `draw` must invoke the +/// block's original `Draw` trampoline. +unsafe fn nv_water_per_eye( + this: *const NvWaterHighEndRenderBlock, + rc: *mut RenderContext, + mut draw: impl FnMut(), +) -> bool { + if !Config::lock_query(|c| c.stereo.single_pass.nvwater_per_eye) { + return false; + } + // SAFETY: `rc` is the live render context per the caller contract. + let (pass, center_view) = unsafe { ((*rc).m_ActiveRenderPass, (*rc).m_View) }; + if NV_WATER_AUXILIARY_PASSES.contains(&pass) { + return false; + } + let Some(eyes) = eye_cameras(center_view) else { + return false; + }; + // SAFETY: as above. + let saved_projection = unsafe { (*rc).m_ProjectionF }; + + // The guard restores the render context's view/projection and clears the simulation-suppression + // flag on drop, so engine state is consistent even if the per-eye closure unwinds. + let _restore = RenderContextRestore { + rc, + center_view, + saved_projection, + }; + + crate::stereo::single_pass::draw_per_eye_half_ignoring_bound_vs(|eye| { + // SAFETY: `rc` is live, and `this` is the live block whose `Setup` reads it. The two trailing + // arguments are the draw-list sort ids, which this block's `Setup` override does not read. + unsafe { + (*rc).m_View = eyes[eye].view; + (*rc).m_ProjectionF = eyes[eye].projection; + SUPPRESS_SIMULATION_STEP.store(eye != 0, Ordering::Relaxed); + (*this).Setup(rc, 0, 0); + } + draw(); + }) +} + +/// The passes whose `CNvWaterHighEndRenderBlock::Draw` body is not the water surface: the compute +/// foam sub-pass, the wake prepass, and the painted-foam prepass. They render into the block's own +/// simulation and foam targets from their own viewports rather than into the scene, so the eye split +/// does not apply to them and `Setup` stages no view matrix for them either. +const NV_WATER_AUXILIARY_PASSES: [i32; 3] = [ + RenderPassId::PRE_RP_WATER_CS_PRE as i32, + RenderPassId::PRE_RP_WATER_WAKES_PRE as i32, + RenderPassId::PRE_RP_WATER_FOAM_PRE as i32, +]; + +/// Raised for the duration of the second eye's re-issue; see [`wave_works_simulation_step`]. +static SUPPRESS_SIMULATION_STEP: AtomicBool = AtomicBool::new(false); + +/// A frame ordinal advanced by [`begin_dispatch`] at each frame's first dispatch, stamped into the +/// [`WaterDrawSnapshot`]s so the sidecar can tell same-frame snapshots from stale ones. +static WATER_FRAME: AtomicU64 = AtomicU64::new(0); + +/// The engine's own `m_EnableScreenSpaceWaterReflection` value while [`apply_ssr_override`] holds +/// it overridden, or `u8::MAX` when no override is active. +static SAVED_SSWR_ENABLED: AtomicU8 = AtomicU8::new(u8::MAX); + +/// The engine's own per-frame reflection-camera mirror, and the transform this module last wrote +/// over it, so [`apply_per_eye_reflection_camera`] can tell an engine rebuild from its own +/// leftover write. Draw-thread only. +struct MirrorState { + engine_base: glam::Mat4, + last_written: Option, +} + +/// See [`MirrorState`]. +static REFLECTION_MIRROR_STATE: Mutex> = Mutex::new(None); + +/// How many reflection-chain passes (categories 9..=16) were enabled for the current dispatch's +/// pre-pass loop, recorded by the `PreDraw` hook via [`record_reflection_passes_enabled`] and read +/// into the [`WaterDrawSnapshot`]. +static REFLECTION_PASSES_ENABLED: AtomicUsize = AtomicUsize::new(usize::MAX); + +/// Record the dispatch's enabled reflection-pass count for the diagnostics snapshot. +pub(crate) fn record_reflection_passes_enabled(count: usize) { + REFLECTION_PASSES_ENABLED.store(count, Ordering::Relaxed); +} + +/// The per-eye reflection-camera transform: the eye's rigid motion relative to the centre +/// (`eye = delta ∘ center`, in world space), conjugated by the water-plane mirror, applied to the +/// engine's own mirror `base`. The conjugation moves the mirrored camera exactly as the mirrored +/// eye moves relative to the mirrored centre, so no assumption about the engine's mirrored-camera +/// orientation convention is needed. The plane height is inferred from the engine's own mirror +/// (`base.y = 2 * plane - center.y`) rather than read from engine state, so the +/// screen-space-reflection path's own plane convention is honoured automatically. +fn mirrored_eye_reflection_transform( + base: glam::Mat4, + center_world: glam::Mat4, + eye_world: glam::Mat4, +) -> glam::Mat4 { + let delta = eye_world * center_world.inverse(); + let plane_y = (base.w_axis.y + center_world.w_axis.y) * 0.5; + let mirror = glam::Mat4::from_translation(glam::Vec3::Y * plane_y) + * glam::Mat4::from_scale(glam::Vec3::new(1.0, -1.0, 1.0)) + * glam::Mat4::from_translation(glam::Vec3::Y * -plane_y); + mirror * delta * mirror * base +} + +/// The latest main-body water draw snapshot per eye. On a share frame the far dispatch's snapshot +/// (also `draw_index` 0) is overwritten by eye 0's near dispatch, which is the one the sidecar +/// wants; the `dispatch_ordinal` field records which one survived. +static LAST_WATER_DRAWS: Mutex<[Option; 2]> = Mutex::new([None, None]); + +/// Record the inputs of a main-body water draw into [`LAST_WATER_DRAWS`] under the current +/// `draw_index`. A no-op for the block's auxiliary passes, which stage none of these bindings. +/// +/// # Safety +/// +/// `this` and `rc` must be the live pointers the detoured `Draw` received. +unsafe fn snapshot_water_draw(this: *const NvWaterHighEndRenderBlock, rc: *const RenderContext) { + // SAFETY: live per the caller contract; the singletons are null-checked by their accessors. + unsafe { + let (Some(block), Some(rc)) = (this.as_ref(), rc.as_ref()) else { + return; + }; + if NV_WATER_AUXILIARY_PASSES.contains(&rc.m_ActiveRenderPass) { + return; + } + let reflection_camera = WaterPatchManager::get() + .and_then(|m| m.m_ReflectionCamera.as_ref()) + .map(|cam| cam.m_TransformF.data); + let snapshot = WaterDrawSnapshot { + frame: WATER_FRAME.load(Ordering::Relaxed), + dispatch_ordinal: crate::stereo::dispatch_ordinal(), + render_pass: rc.m_ActiveRenderPass, + under_water: block.m_UnderWater, + altitude_simulation_pause: block.m_AltitudeSimulationPause, + render_time: block.m_RenderTime, + screen_space_reflection: WaterPatchManager::get() + .map(|m| m.m_EnableScreenSpaceWaterReflection), + per_eye_reflection: Config::lock_query(|c| c.stereo.per_eye_water_reflection), + reflection_camera_position: reflection_camera.map(|t| [t[12], t[13], t[14]]), + reflection_camera_forward: reflection_camera.map(|t| [t[8], t[9], t[10]]), + reflection_passes_enabled: match REFLECTION_PASSES_ENABLED.load(Ordering::Relaxed) { + usize::MAX => None, + n => Some(n), + }, + depth_texture: ptr_hex(rc.m_DepthBufferTexture), + dynamic_reflection_color_texture: ptr_hex(rc.m_DynamicReflectionColorTexture), + dynamic_reflection_alpha_texture: ptr_hex(rc.m_DynamicReflectionAlphaTexture), + back_buffer_texture: ptr_hex(rc.m_BackBufferTexture), + type_textures: NvWaterHighEndRenderBlockType::get().map(|t| WaterTypeTextures { + tessellation_options: t.m_TessellationOptions, + water_mod: ptr_hex(t.m_WaterModTexture), + foam: ptr_hex(t.m_FoamTexture), + water_bump: ptr_hex(t.m_WaterBumpTexture), + distant_reflection: ptr_hex(t.m_DistantReflectionTexture), + full_reflection: ptr_hex(t.m_FullReflectionTexture), + }), + }; + if let Some(slot) = LAST_WATER_DRAWS.lock().get_mut(crate::stereo::draw_index()) { + *slot = Some(snapshot); + } + } +} + +/// A raw pointer as a hex-string identity for the sidecar (null for a null pointer). +fn ptr_hex(ptr: *mut T) -> String { + format!("{:#x}", ptr as usize) +} + +/// Whether the WaveWorks simulation has already stepped this frame: cleared at the frame's first +/// dispatch by [`begin_dispatch`], latched by [`wave_works_simulation_step`] when it runs the step. +/// Keyed to the frame's first *water draw* rather than to a fixed dispatch ordinal because which +/// dispatch that is depends on the frame shape -- a share frame's far dispatch may or may not carry +/// the ocean -- and suppressing on ordinal alone could silence the step for the whole frame, +/// freezing the simulation. Written only on the draw thread. +static SIMULATION_STEPPED_THIS_FRAME: AtomicBool = AtomicBool::new(false); + +/// Restores the render context's view/projection and clears the simulation-suppression flag on drop, +/// so engine state is consistent even if the per-eye closure in [`nv_water_per_eye`] unwinds. +struct RenderContextRestore { + rc: *mut RenderContext, + center_view: Matrix4, + saved_projection: Matrix4, +} + +impl Drop for RenderContextRestore { + fn drop(&mut self) { + SUPPRESS_SIMULATION_STEP.store(false, Ordering::Relaxed); + // SAFETY: `rc` is the live render context that was valid when the guard was constructed, and + // the render thread still owns it during drop on the same unwind path. + unsafe { + (*self.rc).m_View = self.center_view; + (*self.rc).m_ProjectionF = self.saved_projection; + } + } +} + +/// One eye's substitute for the render context's camera matrices. +struct EyeCamera { + view: Matrix4, + projection: Matrix4, +} + +/// The two eyes' view and projection matrices, derived from the collapse's centre view the same way +/// the `SetupRenderCamera` hook derives the double-draw path's per-eye camera: offset the centre +/// camera's world transform by the eye's world offset, apply its head-local orientation delta on the +/// right (about the now-offset eye position), and invert. `None` when no VR frame is in flight. +fn eye_cameras(center_view: Matrix4) -> Option<[EyeCamera; 2]> { + let center_world = glam::Mat4::from(center_view).inverse(); + Some([eye_camera(center_world, 0)?, eye_camera(center_world, 1)?]) +} + +fn eye_camera(center_world: glam::Mat4, eye: usize) -> Option { + let params = crate::vr::render_params(eye)?; + let mut world = center_world; + world.w_axis += params.world_offset.extend(0.0); + let world = world * glam::Mat4::from_quat(params.orientation_delta); + Some(EyeCamera { + view: Matrix4::from(world.inverse()), + // The reverse-Z form, which is what the render camera's `m_ProjectionF` holds by the time a + // render context is filled from it, under either projection convention. + projection: params.projection_reverse_z, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// When the base is exactly the plane mirror of the centre (no engine orientation fixup), the + /// conjugated result must be exactly the plane mirror of the eye: `M·D·M·(M·C) = M·E`. + #[test] + fn eye_reflection_transform_mirrors_the_eye() { + let plane_y = 2.0; + let mirror = glam::Mat4::from_translation(glam::Vec3::Y * plane_y) + * glam::Mat4::from_scale(glam::Vec3::new(1.0, -1.0, 1.0)) + * glam::Mat4::from_translation(glam::Vec3::Y * -plane_y); + let center = glam::Mat4::from_rotation_translation( + glam::Quat::from_rotation_y(0.4), + glam::Vec3::new(3.0, 10.0, -7.0), + ); + let mut eye = center; + eye.w_axis += glam::Vec4::new(0.034, 0.004, 0.01, 0.0); + let eye = eye * glam::Mat4::from_quat(glam::Quat::from_rotation_x(0.05)); + + let base = mirror * center; + // The plane inference recovers y = 2 from `base` and `center` alone. + assert!(((base.w_axis.y + center.w_axis.y) * 0.5 - plane_y).abs() < 1e-6); + + let result = mirrored_eye_reflection_transform(base, center, eye); + let expected = mirror * eye; + assert!( + result.abs_diff_eq(expected, 1e-5), + "{result:?} vs {expected:?}" + ); + } + + /// The result's position is the eye position reflected across the water plane, regardless of + /// the base's own orientation convention (here an arbitrary proper rotation). + #[test] + fn eye_reflection_transform_reflects_the_position() { + let center = glam::Mat4::from_translation(glam::Vec3::new(0.0, 10.0, 0.0)); + let mut eye = center; + eye.w_axis += glam::Vec4::new(0.034, 0.004, 0.01, 0.0); + // The engine's mirror of the centre about y = 2, with a proper-rotation orientation as + // `CreateOrientation` produces. + let base = glam::Mat4::from_rotation_translation( + glam::Quat::from_rotation_x(std::f32::consts::PI), + glam::Vec3::new(0.0, -6.0, 0.0), + ); + let result = mirrored_eye_reflection_transform(base, center, eye); + let expected = glam::Vec3::new(0.034, 2.0 * 2.0 - 10.004, 0.01); + assert!( + result.w_axis.truncate().abs_diff_eq(expected, 1e-5), + "{:?} vs {expected:?}", + result.w_axis + ); + } + + /// The composed transform maps a clip position into the eye's half of the double-wide target: the + /// screen-UV matrix's own NDC→UV bias, then the eye-half bias the per-eye re-issue applies. + #[test] + fn eye_half_bias_maps_ndc_into_the_eyes_half() { + // A clip position with a non-unit `w`, to catch a bias applied to the post-divide `u` instead + // of to the projective components. + let clip = glam::Vec4::new(0.5, -0.25, 0.75, 2.0); + let rows = screen_uv_matrix(glam::Mat4::IDENTITY.to_cols_array()); + let row = |k: usize| glam::Vec4::from_slice(&rows[k * 4..k * 4 + 4]); + + for eye in 0..2 { + // The row-vector product with the per-eye bias `screen_uv_cb_per_eye` composes. + let biased = |k: usize| { + let r = row(k); + glam::Vec4::new(r.x * 0.5 + r.w * 0.5 * eye as f32, r.y, r.z, r.w) + }; + let projective: glam::Vec4 = (0..4).map(|k| clip[k] * biased(k)).sum(); + // The vertex shader packs `(x, y, w)` into `TEXCOORD1` and the pixel shader divides the + // first two by the third. + let uv = glam::Vec2::new(projective.x, projective.y) / projective.w; + + let ndc = glam::Vec2::new(clip.x, clip.y) / clip.w; + let expected_u = ((ndc.x * 0.5 + 0.5) + eye as f32) * 0.5; + assert!((uv.x - expected_u).abs() < 1e-6, "eye {eye}: {uv:?}"); + assert!( + (uv.y - (ndc.y * 0.5 + 0.5)).abs() < 1e-6, + "eye {eye}: {uv:?}" + ); + } + } +} diff --git a/payload/src/screenshot.rs b/payload/src/screenshot.rs index 81c8eb1..316292d 100644 --- a/payload/src/screenshot.rs +++ b/payload/src/screenshot.rs @@ -45,7 +45,9 @@ use windows::{ core::Interface as _, }; -use crate::stereo::single_pass::FrameDiagnostics; +use crate::{ + hooks::graphics_engine::scene::water::WaterDrawSnapshot, stereo::single_pass::FrameDiagnostics, +}; /// What a capture reads its pixels from. Both variants produce one PNG containing both eyes; they /// differ in whether the GPU already laid the eyes out side by side. @@ -144,6 +146,9 @@ struct PendingWrite { /// The single-pass state of the captured frame, snapshotted here because by the time the writer /// runs the engine is several frames on. diagnostics: Option, + /// The per-eye WaveWorks water-draw inputs of the captured frame (issue #47), snapshotted here + /// for the same reason. + water: [Option; 2], } /// The render-thread half: staging copy (or two half-copies for the stitched form), map, memcpy @@ -271,6 +276,7 @@ unsafe fn map_out( format: format!("{:?}", desc.Format), file_name: format!("jc3vrs-{n:04}.png"), diagnostics: crate::stereo::single_pass::last_frame_diagnostics(), + water: crate::hooks::graphics_engine::scene::water::last_water_draws(), }) } } @@ -332,8 +338,9 @@ fn write_screenshot(mut pending: PendingWrite) -> anyhow::Result { .with_context(|| format!("encoding {}", path.display()))?; // Sidecar JSON: everything relevant about this frame's single-pass state (the per-eye cb13 - // matrices, the centre transform, the viewport, the config), so the exact matrices that - // produced the image can be inspected offline instead of read from a log line. + // matrices, the centre transform, the viewport, the config), plus the per-eye water-draw + // inputs (issue #47), so the exact state that produced the image can be inspected offline + // instead of read from a log line. let sidecar = serde_json::json!({ "image": path.file_name().and_then(|f| f.to_str()), "width": pending.width, @@ -341,6 +348,8 @@ fn write_screenshot(mut pending: PendingWrite) -> anyhow::Result { "format": pending.format, "single_pass": serde_json::to_value(&pending.diagnostics) .unwrap_or(serde_json::Value::Null), + "water": serde_json::to_value(&pending.water) + .unwrap_or(serde_json::Value::Null), }); let json_path = path.with_extension("json"); let json = match serde_json::to_string_pretty(&sidecar) { diff --git a/payload/src/stereo/config.rs b/payload/src/stereo/config.rs index 179cc01..0c05429 100644 --- a/payload/src/stereo/config.rs +++ b/payload/src/stereo/config.rs @@ -378,6 +378,12 @@ pub struct StereoConfig { /// shadow flicker (issue #31). Requires [`restore_frame_counters`](Self::restore_frame_counters) so /// both eyes share the shadow-atlas parity slot; a no-op without it. VR/stereo only. pub share_prepasses: bool, + /// Advance the WaveWorks ocean simulation once per frame instead of once per dispatch (issue + /// #47): the simulation step inside the main-body water draw runs on the frame's first water + /// draw and is suppressed for the rest, so every dispatch renders the same archived + /// displacement state. Without it the second eye kicks the simulation again and renders a + /// later ocean, decorrelating the sun-glint sparkle between the eyes. VR/stereo only. + pub share_water_simulation: bool, /// Skip SetupRenderFrameData on eye 1 (experimental; normally inert). pub gate_setup_render_frame_data: bool, /// Skip HandBackBuffers on eye 1. @@ -524,6 +530,19 @@ pub struct StereoConfig { /// sharpest shadow-pipeline discriminator: an artifact that survives with no shadows at all /// cannot be shadow data. pub disable_sun_shadows: bool, + /// Diagnostic: clear `CWaterPatchManager::m_EnableScreenSpaceWaterReflection`, forcing the + /// water onto the full-reflection binding instead of the distant/screen-space path (issue + /// #47). The screen-space path samples a reflection map aligned to one view; if the per-eye + /// water mismatch dies with this off, that sampling is the seam. The engine value is saved and + /// restored when the toggle clears. + pub disable_screen_space_water_reflection: bool, + /// Render the water's planar reflection per eye (issue #47). The water shader samples its + /// reflection maps at the pixel's own screen position, so a map rendered from one mirrored + /// camera is only valid for one view; per eye, this re-mirrors the reflection camera with that + /// eye's pose delta and lets the reflection pre-passes re-render on the second eye's dispatch, + /// so each eye's water samples a map aligned to its own view. Two-pass stereo only; under the + /// single-pass collapse the single dispatch keeps the engine's own centre mirror. + pub per_eye_water_reflection: bool, /// Diagnostic: freeze the sun-shadow atlas by re-clearing the pass-enable flags after /// `CommitRenderPassSettings` sets them, so no shadow pass renders and the atlas keeps its last /// contents. Shadows stay visible but stop updating: an artifact that survives the freeze is in @@ -781,6 +800,7 @@ impl StereoConfig { present_eye_0: false, restore_frame_counters: true, share_prepasses: true, + share_water_simulation: true, gate_setup_render_frame_data: false, gate_hand_back_buffers: false, gate_eye1_dt: true, @@ -812,6 +832,8 @@ impl StereoConfig { patch_shadow_pcf_hash: true, patch_lod_dissolve: false, disable_sun_shadows: false, + disable_screen_space_water_reflection: false, + per_eye_water_reflection: true, freeze_shadow_maps: false, dedupe_post_block: true, invalidate_terrain_cb: true, diff --git a/payload/src/stereo/single_pass/per_eye_reissue.rs b/payload/src/stereo/single_pass/per_eye_reissue.rs index 52075a3..9c210d6 100644 --- a/payload/src/stereo/single_pass/per_eye_reissue.rs +++ b/payload/src/stereo/single_pass/per_eye_reissue.rs @@ -361,10 +361,75 @@ pub unsafe fn reproject_baked_cb_per_eye_staged( true } +/// Re-issue a render block's `Draw` once per eye with a *projective screen-UV* constant biased into +/// that eye's half of the double-wide target, restoring the staged rows afterwards. Returns `false` +/// when the intercept must not run (the same gate every other per-eye re-issue takes), in which case +/// the caller draws normally once. +/// +/// `base` is the four `float4` rows the block's *type* staged at (`cb_index`, `reg_offset`): a +/// world→screen-UV transform with the NDC→UV `x·0.5 + w·0.5` already folded in, which the vertex +/// shader applies with a multiply-add chain over the four registers (so they are the matrix's rows in +/// the row-vector convention) and hands on as a projective `TEXCOORD1` for the pixel shader to divide +/// by `w`. The resulting UV is normalized over the *viewport*, i.e. over one eye's half, while the +/// buffers it indexes are the whole double-wide target -- so each eye reads the entire two-eye image +/// across its surface, and the mismatch slides as the camera moves. Composing one more bias per eye, +/// `u' = (u + eye) · 0.5`, maps it back into that eye's half; row-wise, and because `u` and `w` are +/// both per-row sums, that is `row.x ← row.x · 0.5 + row.w · 0.5 · eye`. +/// +/// Unlike [`reproject_baked_cb_per_eye`] this restages rather than intercepts, because the constant is +/// staged by the block *type*'s per-pass setup rather than inside the `Draw` being re-issued (the same +/// reason [`terrain_detail_per_eye`] stages its own rows). It is also deliberately *not* reprojected +/// by `M_eye`: the geometry these blocks rasterize still comes from the collapsed centre view, so the +/// UV must describe where that geometry actually landed, not where the eye's own projection would have +/// put it. +/// +/// # Safety +/// +/// `rc` must be the live [`RenderContext`] the detoured `Draw` received, and `draw` must invoke the +/// block's original `Draw` trampoline. +pub unsafe fn screen_uv_cb_per_eye( + rc: *const RenderContext, + cb_index: i32, + reg_offset: u32, + base: [f32; 16], + mut draw: impl FnMut(), +) -> bool { + let Some((_, full, d3d)) = + baked_cb_intercept_ready("legacy-water screen UV", BoundVsGate::Checked) + else { + return false; + }; + // SAFETY: `rc` is live per the caller contract. + let ctx = unsafe { render_context_graphics_context(rc) }; + per_eye_halves(full, d3d, &mut |eye| { + let mut rows = base; + for k in 0..4 { + rows[k * 4] = base[k * 4].mul_add(0.5, base[k * 4 + 3] * 0.5 * eye as f32); + } + // SAFETY: `ctx` is the render context's live graphics context; `rows` is four float4 rows. + unsafe { SetVertexProgramConstants(ctx, cb_index, reg_offset, rows.as_ptr(), 4) }; + draw(); + }); + // Put the type's own rows back. It stages them once per pass, ahead of every block it covers, so + // leaving the second eye's bias behind would hand it to any later draw this intercept declines. + // SAFETY: as above; `base` is the four rows the type staged. + unsafe { SetVertexProgramConstants(ctx, cb_index, reg_offset, base.as_ptr(), 4) }; + true +} + /// Run `render` once per eye with that eye's half-viewport pinned on both slots, restoring the /// collapse's full viewport afterwards. Returns `false` when the intercept must not run -- the same /// gate every other per-eye re-issue takes ([`baked_cb_intercept_ready`]) -- in which case the caller /// must do its work itself, exactly once. +/// +/// The bare form of [`reproject_baked_cb_per_eye`] and [`screen_uv_cb_per_eye`], for a block whose +/// per-eye state is not a vertex constant this module knows how to transform: `render` receives the +/// eye and does whatever staging that block needs before invoking its own `Draw`. Each call is +/// bracketed by [`PER_EYE_REISSUE`], so the draw and viewport detours leave the block's own +/// submissions alone instead of splitting them a second time. +/// `site` names the calling block for the decline diagnostic (see +/// [`warn_intercept_declined_on_patched_vs`]); it is the caller's identity rather than this helper's, +/// because a bound-shader decline is a fact about that block's own shaders. pub fn draw_per_eye_half(site: &'static str, mut render: impl FnMut(usize)) -> bool { let Some((_, full, d3d)) = baked_cb_intercept_ready(site, BoundVsGate::Checked) else { return false; @@ -373,6 +438,28 @@ pub fn draw_per_eye_half(site: &'static str, mut render: impl FnMut(usize)) -> b true } +/// [`draw_per_eye_half`] for a block that binds its own vertex programs *inside* the `Draw` being +/// re-issued, so the shader bound when the gate runs is the previous draw's and says nothing about +/// this one. +/// +/// [`baked_cb_intercept_ready`]'s bound-shader gate exists to leave already-patched geometry to the +/// patched path, and it reads the shader that `VSSetShader` last saw. For a block whose type binds +/// its programs in a per-pass setup that is a fair proxy; for one that binds them per draw it is a +/// coin flip on whatever drew before it, which would make the re-issue fire intermittently. Callers +/// take this variant only when the block's own vertex programs are provably outside the rewrite. +/// +/// "Builds clip from a constant buffer of its own" is *not* on its own enough to establish that: the +/// rewrite claims any shader referencing the per-eye `cb0` entries, and `cb0[4]` is a camera position +/// that such a family may still read for shading. Where a family reads it, the shader has to be +/// declined at creation instead ([`baked_cb_block_owns_vs`]). +pub fn draw_per_eye_half_ignoring_bound_vs(mut render: impl FnMut(usize)) -> bool { + let Some((_, full, d3d)) = eye_split_state() else { + return false; + }; + per_eye_halves(full, d3d, &mut render); + true +} + /// Run `render` once per eye with that eye's half of `full` pinned on both viewport slots, then put /// `full` back for the draws that follow. pub(super) fn per_eye_halves( diff --git a/payload/src/ui/render/corrections.rs b/payload/src/ui/render/corrections.rs index 8463aa1..0cf92fa 100644 --- a/payload/src/ui/render/corrections.rs +++ b/payload/src/ui/render/corrections.rs @@ -74,6 +74,32 @@ pub(super) fn section(ui: &mut egui::Ui, cfg: &mut config::Config) { re-rendering them. Requires 'Restore frame counters'. If distant reflections or \ shadows look wrong in one eye, turn this off.", ); + ui.checkbox( + &mut cfg.stereo.share_water_simulation, + "Share the WaveWorks ocean step across eyes (one simulation kick per frame)", + ) + .on_hover_text( + "Without this the second eye advances the ocean simulation again and renders a later \ + sea state, so the sun-glint sparkle decorrelates between the eyes (issue #47).", + ); + ui.checkbox( + &mut cfg.stereo.per_eye_water_reflection, + "Per-eye water reflection (re-mirror + re-render the planar reflection per eye)", + ) + .on_hover_text( + "The water samples its reflection map at each pixel's own screen position, so a map \ + rendered from one mirrored camera only matches one eye (issue #47). Costs one extra \ + reflection render per frame.", + ); + ui.checkbox( + &mut cfg.stereo.disable_screen_space_water_reflection, + "Disable screen-space water reflection (A/B for the per-eye water mismatch)", + ) + .on_hover_text( + "Forces the water onto the full-reflection binding. If the eyes then agree, the \ + screen-space reflection sampling is the seam (issue #47). Restores the engine value \ + when unchecked.", + ); ui.checkbox( &mut cfg.stereo.force_smaa_1x, "Force SMAA 1x (T2X's shared history ghosts across eyes)",