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 <noreply@anthropic.com>
This commit is contained in:
Gh4zi
2026-10-08 19:45:58 +03:00
parent 91217cd0ac
commit b65454ab88
7 changed files with 113 additions and 32 deletions
+13
View File
@@ -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 {
@@ -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 =
@@ -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;
+6 -6
View File
@@ -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,
};
@@ -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;
@@ -89,6 +89,10 @@ pub struct DpvsFrameStats {
pub cell_clips: Vec<CellClipPlanes>,
/// 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;
}
@@ -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<Self> {
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],