From b65454ab883abc89ec7f92d9ef4337036c39b2c5 Mon Sep 17 00:00:00 2001 From: Gh4zi Date: Thu, 8 Oct 2026 19:45:58 +0300 Subject: [PATCH] Fix models disappearing as the player moves; fix red tint on breakable glass - Map models (Terminal's rooftop tank, boxes and barrels; Favela's laundry) disappeared and came back as the player moved, out in the open as well. The DPVS walk visits a cell once per visibility portal it is seen through, but only the first visit's clip planes were kept, and the cell entity commands culled against those alone, so anything visible only through a later portal was dropped. The walk now reports every visit and the cell entity commands run once per visit, as IW4 queues them. More visits than the cell queue holds falls back to the camera frustum, never to one visit's planes. - Separately, breakable glass skipped model lighting for a pane whose centre was more than 64 units off screen, then drew it anyway with lighting handle 0 and reflection probe 0: Terminal's wide glass panes showed a red tint with a hard edge. The test now uses the pane's own radius. Co-Authored-By: Claude Opus 5.5 --- crates/dpvs_iw4/src/walk.rs | 13 +++ .../src/adapters/anim/dyn_ent_brush.rs | 15 +++- .../src/assemble/drawsurf/tess/glass.rs | 6 +- crates/render_frontend/src/lib.rs | 12 +-- .../src/prepare/scene/cell_frustum_cmds.rs | 80 ++++++++++++++----- .../render_frontend/src/prepare/scene/cull.rs | 8 ++ .../src/prepare/worker_cmds.rs | 11 +++ 7 files changed, 113 insertions(+), 32 deletions(-) diff --git a/crates/dpvs_iw4/src/walk.rs b/crates/dpvs_iw4/src/walk.rs index 662dc79..5592f54 100644 --- a/crates/dpvs_iw4/src/walk.rs +++ b/crates/dpvs_iw4/src/walk.rs @@ -302,6 +302,9 @@ pub(crate) fn finalize_queued_hull_no_frustum(item: &mut Queued, view_dir: [f32; true } +/// Called for every portal visit of a cell, with the planes it was seen through. +pub type CellVisit<'a> = dyn FnMut(usize, &CellClipPlanes) + 'a; + pub fn visit_cells( graph: &CellPortalGraph<'_>, camera_cell: usize, @@ -313,6 +316,7 @@ pub fn visit_cells( scratch: &mut WalkScratch, mut cell_clips: Option<&mut [CellClipPlanes]>, bevels: Option<&PortalBevels>, + mut on_visit: Option<&mut CellVisit<'_>>, ) -> WalkStats { let mut stats = WalkStats::default(); let near = clip_planes.first().copied(); @@ -384,6 +388,15 @@ pub fn visit_cells( } } } + // A cell seen through several portals is visited once per portal, each visit with its + // own clip planes; `cell_clips` keeps only the first. Entities in the cell must be culled + // against every visit (IW4 queues one cell command per visit), or what shows only + // through a later portal vanishes. + if let Some(visit) = on_visit.as_mut() { + let mut planes = CellClipPlanes::EMPTY; + record_cell_clip(&item, clip_planes, &mut planes); + visit(cell, &planes); + } let cell_planes = queued_clip_planes(&item, clip_planes); let Some(edges) = graph.portals.get(cell).copied() else { if let Some(slot) = recycle { diff --git a/crates/render_frontend/src/adapters/anim/dyn_ent_brush.rs b/crates/render_frontend/src/adapters/anim/dyn_ent_brush.rs index 0f84a4c..88e512a 100644 --- a/crates/render_frontend/src/adapters/anim/dyn_ent_brush.rs +++ b/crates/render_frontend/src/adapters/anim/dyn_ent_brush.rs @@ -496,13 +496,22 @@ fn exec_cell_scene_ent_cmds( scene.scene_ent_walked = true; let bit_count = scene_ent_cell_walk_bits(GFX_CFG_ENT_COUNT); + // The planes this cell was seen through on this command's portal visit (see + // `CellFrustumWorkerCmd::visit`); the camera frustum when there are none. let cell_planes: Vec<[f32; 4]> = stats .as_ref() - .and_then(|s| { - s.cell_clips + .and_then(|s| match cmd.visit { + render_frontend::CELL_VISIT_FRUSTUM => None, + render_frontend::CELL_VISIT_FIRST => s + .cell_clips .get(cell) .filter(|c| c.plane_count > 0) - .map(|c| c.as_slice().to_vec()) + .map(|c| c.as_slice().to_vec()), + visit => s + .cell_clip_visits + .get(visit as usize - 1) + .filter(|(visited, c)| *visited as usize == cell && c.plane_count > 0) + .map(|(_, c)| c.as_slice().to_vec()), }) .unwrap_or_else(|| planes.clone()); let sphere_planes = diff --git a/crates/render_frontend/src/assemble/drawsurf/tess/glass.rs b/crates/render_frontend/src/assemble/drawsurf/tess/glass.rs index 716e4d1..9b5bdb8 100644 --- a/crates/render_frontend/src/assemble/drawsurf/tess/glass.rs +++ b/crates/render_frontend/src/assemble/drawsurf/tess/glass.rs @@ -763,7 +763,11 @@ pub(crate) fn enqueue_glass_model_lighting( let owner = ModelLightingOwner::Glass(draw.piece); draw.lighting_prev = u32::from(cache.handle_for(owner)); draw.pending_lighting = None; - if sphere_behind_frustum(draw.origin, GLASS_FRUSTUM_LIGHT_RADIUS, &planes) { + // The pane's own extent: a window much wider than 64 units can show while its centre + // is off screen, and a pane drawn without lighting takes probe 0 and the default + // lighting (the red-tinted windows on Terminal). + let radius = draw.radius.max(GLASS_FRUSTUM_LIGHT_RADIUS); + if sphere_behind_frustum(draw.origin, radius, &planes) { draw.lighting_handle = 0; draw.reflection_probe_index = 0; continue; diff --git a/crates/render_frontend/src/lib.rs b/crates/render_frontend/src/lib.rs index d61b8c7..74d4422 100644 --- a/crates/render_frontend/src/lib.rs +++ b/crates/render_frontend/src/lib.rs @@ -22,10 +22,10 @@ pub use prepare::gfx_scene::{ pub use prepare::scene::world::*; pub use prepare::sun::{add_bsp_sun_shadow_partition, pack_sun_shadow_caster_lists}; pub use prepare::worker_cmds::{ - AddWorkerCmd, BoundEntWorkerCmd, CellFrustumWorkerCmd, DpvsEntWorkerCmd, - SkinCachedStaticModelCmd, SpotShadowEntWorkerCmd, WORKER_CMD_BOUND_ENT, WORKER_CMD_BUSY, - WORKER_CMD_CELL_DYN_BRUSH, WORKER_CMD_CELL_DYN_MODEL, WORKER_CMD_CELL_SCENE_ENT, - WORKER_CMD_COUNT, WORKER_CMD_DPVS_ENT, WORKER_CMD_SMODELCACHE, WORKER_CMD_SPOT_SHADOW_ENT, - WorkerCmdBusy, WorkerCmdBusyInput, WorkerCmdError, WorkerCmdQueues, enqueue_cell_frustum_cmds, - worker_cmd_dispatch_skips_device_lost, + AddWorkerCmd, BoundEntWorkerCmd, CELL_VISIT_FIRST, CELL_VISIT_FRUSTUM, CellFrustumWorkerCmd, + DpvsEntWorkerCmd, SkinCachedStaticModelCmd, SpotShadowEntWorkerCmd, WORKER_CMD_BOUND_ENT, + WORKER_CMD_BUSY, WORKER_CMD_CELL_DYN_BRUSH, WORKER_CMD_CELL_DYN_MODEL, + WORKER_CMD_CELL_SCENE_ENT, WORKER_CMD_COUNT, WORKER_CMD_DPVS_ENT, WORKER_CMD_SMODELCACHE, + WORKER_CMD_SPOT_SHADOW_ENT, WorkerCmdBusy, WorkerCmdBusyInput, WorkerCmdError, WorkerCmdQueues, + enqueue_cell_frustum_cmds, worker_cmd_dispatch_skips_device_lost, }; diff --git a/crates/render_frontend/src/prepare/scene/cell_frustum_cmds.rs b/crates/render_frontend/src/prepare/scene/cell_frustum_cmds.rs index f2a095b..8fbb665 100644 --- a/crates/render_frontend/src/prepare/scene/cell_frustum_cmds.rs +++ b/crates/render_frontend/src/prepare/scene/cell_frustum_cmds.rs @@ -1,7 +1,8 @@ use bevy::prelude::*; use render_frontend::{ - AddWorkerCmd, CellFrustumWorkerCmd, WORKER_CMD_CELL_DYN_BRUSH, WORKER_CMD_CELL_DYN_MODEL, - WORKER_CMD_CELL_SCENE_ENT, enqueue_cell_frustum_cmds, + AddWorkerCmd, CELL_VISIT_FIRST, CELL_VISIT_FRUSTUM, CellFrustumWorkerCmd, + WORKER_CMD_CELL_DYN_BRUSH, WORKER_CMD_CELL_DYN_MODEL, WORKER_CMD_CELL_SCENE_ENT, + enqueue_cell_frustum_cmds, }; use crate::prepare::scene::cull::DpvsFrameStats; @@ -43,30 +44,65 @@ fn enqueue_cell_frustum_cmds_after_vis( let Some(stats) = stats.as_deref() else { return; }; - let frustum_n = stats.frustum_planes.len(); + let frustum_n = u8::try_from(stats.frustum_planes.len()).unwrap_or(u8::MAX); let cells = admitted_cell_indices(&stats.cell_vis, stats.cell_vis_count); - for cell in cells { - let (plane_count, plane_begin) = stats - .cell_clips - .get(cell as usize) - .filter(|c| c.plane_count > 0) - .map(|c| (c.plane_count, c.frustum_plane_count)) - .unwrap_or_else(|| { - let n = u8::try_from(frustum_n).unwrap_or(u8::MAX); - (n, n) - }); - let cmd = CellFrustumWorkerCmd { - cell, - plane_count, - plane_begin, - view: 0, + let mut enqueue = + |cmd: CellFrustumWorkerCmd| match enqueue_cell_frustum_cmds(&mut worker_cmds.queues, cmd) { + Ok(adds) if adds.contains(&AddWorkerCmd::OverflowInline) => false, + Err(_) => false, + Ok(_) => true, }; - match enqueue_cell_frustum_cmds(&mut worker_cmds.queues, cmd) { - Ok(adds) if adds.iter().any(|a| *a == AddWorkerCmd::OverflowInline) => { + // One command per portal visit, each culling the cell's entities against the planes it + // was seen through, as IW4 does: an entity seen through any of a cell's portals is drawn. + let visits = &stats.cell_clip_visits; + if !visits.is_empty() && visits.len() <= CELL_VISIT_BUDGET { + for (index, (cell, clip)) in visits.iter().enumerate() { + if !cells.contains(cell) || clip.plane_count == 0 { + continue; + } + let cmd = CellFrustumWorkerCmd { + visit: index as u32 + 1, + cell: *cell, + plane_count: clip.plane_count, + plane_begin: clip.frustum_plane_count, + view: 0, + }; + if !enqueue(cmd) { break; } - Err(_) => break, - Ok(_) => {} + } + return; + } + // No walk this frame (single cell, all cells, no eye cell), or too many visits to queue: + // one command per cell. With visits it falls back to the camera frustum, never to one + // visit's planes, so nothing a later portal shows is culled. + for cell in cells { + let first = stats + .cell_clips + .get(cell as usize) + .filter(|c| c.plane_count > 0 && visits.is_empty()); + let cmd = match first { + Some(c) => CellFrustumWorkerCmd { + visit: CELL_VISIT_FIRST, + cell, + plane_count: c.plane_count, + plane_begin: c.frustum_plane_count, + view: 0, + }, + None => CellFrustumWorkerCmd { + visit: CELL_VISIT_FRUSTUM, + cell, + plane_count: frustum_n, + plane_begin: frustum_n, + view: 0, + }, + }; + if !enqueue(cmd) { + break; } } } + +/// Portal visits queued as one cell command each; the cell queues hold 512 commands +/// (`0x1800` bytes of 12), so a frame with more visits falls back to one command per cell. +const CELL_VISIT_BUDGET: usize = 480; diff --git a/crates/render_frontend/src/prepare/scene/cull.rs b/crates/render_frontend/src/prepare/scene/cull.rs index 86eeae6..e3d9c3e 100644 --- a/crates/render_frontend/src/prepare/scene/cull.rs +++ b/crates/render_frontend/src/prepare/scene/cull.rs @@ -89,6 +89,10 @@ pub struct DpvsFrameStats { pub cell_clips: Vec, + /// Every portal visit of the walk, in order: the cell and the planes it was seen through. + /// A cell seen through several portals appears once per portal. + pub cell_clip_visits: Vec<(u32, CellClipPlanes)>, + pub cell_vis_all: bool, } @@ -146,6 +150,7 @@ pub fn apply_dpvs_cull( stats.cell_vis.clear(); stats.cell_vis_count = 0; stats.cell_clips.clear(); + stats.cell_clip_visits.clear(); stats.cell_vis_all = false; stats.view_prepared = 0; stats.lock_pvs = 0; @@ -222,6 +227,9 @@ pub fn apply_dpvs_cull( &mut scratch, Some(&mut cell_clips), bevels.as_ref(), + Some(&mut |visited: usize, planes: &CellClipPlanes| { + stats.cell_clip_visits.push((visited as u32, *planes)); + }), ); stats.cell_clips = cell_clips; } diff --git a/crates/render_frontend/src/prepare/worker_cmds.rs b/crates/render_frontend/src/prepare/worker_cmds.rs index 001407d..02a27a3 100644 --- a/crates/render_frontend/src/prepare/worker_cmds.rs +++ b/crates/render_frontend/src/prepare/worker_cmds.rs @@ -169,16 +169,26 @@ pub struct SkinCachedStaticModelCmd { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct CellFrustumWorkerCmd { + /// Which planes cull the cell's entities: [`CELL_VISIT_FIRST`] the cell's first portal visit + /// (`DpvsFrameStats::cell_clips`), [`CELL_VISIT_FRUSTUM`] the camera frustum, else the + /// 1-based index of one portal visit in `DpvsFrameStats::cell_clip_visits`. + pub visit: u32, pub cell: u32, pub plane_count: u8, pub plane_begin: u8, pub view: u16, } +/// [`CellFrustumWorkerCmd::visit`]: the cell's first-visit planes. +pub const CELL_VISIT_FIRST: u32 = 0; +/// [`CellFrustumWorkerCmd::visit`]: the camera frustum alone. +pub const CELL_VISIT_FRUSTUM: u32 = u32::MAX; + impl CellFrustumWorkerCmd { #[must_use] pub fn to_bytes(self) -> [u8; 12] { let mut out = [0u8; 12]; + out[0..4].copy_from_slice(&self.visit.to_le_bytes()); out[4..8].copy_from_slice(&self.cell.to_le_bytes()); out[8] = self.plane_count; out[9] = self.plane_begin; @@ -190,6 +200,7 @@ impl CellFrustumWorkerCmd { pub fn from_bytes(data: &[u8]) -> Option { let bytes: [u8; 12] = data.try_into().ok()?; Some(Self { + visit: u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]), cell: u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]), plane_count: bytes[8], plane_begin: bytes[9],