Scope mod camera targets to their running owner

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
2026-11-04 16:16:52 +03:00
parent d8fa77b000
commit 57f8b2853c
12 changed files with 195 additions and 47 deletions
+2 -2
View File
@@ -11706,7 +11706,7 @@ impl Game<'_> {
let main_view = self.mod_main_view(alpha);
// AirDef fork: a cut starts the temporal histories over; a film hears from the camera's
// orientation (the sounds are already placed round the player).
let view_id = main_view.as_ref().map(|v| v.id);
let view_id = main_view.as_ref().map(|v| (v.owner, v.id));
if view_id != self.film.last_view {
self.film.last_view = view_id;
if let Some(r) = self.renderer.as_mut() {
@@ -11993,7 +11993,7 @@ impl Game<'_> {
let mod_views: Vec<_> = self
.mod_views(alpha)
.into_iter()
.filter(|v| main_view.as_ref().is_none_or(|m| m.id != v.id))
.filter(|v| main_view.as_ref().is_none_or(|m| (m.owner, m.id) != (v.owner, v.id)))
.collect();
let hd_lights = self.mod_lights(camera, alpha);
let hd_spots = self.mod_spots(camera, alpha);
+2 -2
View File
@@ -93,7 +93,7 @@ pub(super) struct Film {
outcome: Arc<Mutex<Option<Outcome>>>,
thread: Option<std::thread::JoinHandle<()>>,
/// The shot the last frame showed (a change is a cut: warm-up frames follow).
view: Option<u32>,
view: Option<(u64, u32)>,
/// Audio frames mixed so far.
mixed: u64,
/// Where the film's own clock (`film_clock`) began: real time at the first frame.
@@ -109,7 +109,7 @@ pub(super) struct FilmState {
pub(super) film: Option<Film>,
pub(super) report: FilmReport,
/// The main view of the last rendered frame (a cut resets the temporal histories).
pub(super) last_view: Option<u32>,
pub(super) last_view: Option<(u64, u32)>,
}
/// An `ffmpeg` to run: `HORIZON_FFMPEG`, the `PATH`, or where Homebrew and packages put it (a
+3 -1
View File
@@ -266,6 +266,8 @@ impl Game<'_> {
/// The canvas: a dark ground under the mod's shapes.
pub(super) fn draw_canvas(&self, batch: &mut HudBatch, owner: usize) {
batch.fill(&self.gui.atlas, 0.0, 0.0, batch.width, batch.height, 0xFF0A100C);
self.draw_shapes(batch, self.mods.mods.canvas_shapes(owner), (0.0, 0.0));
if let Some(run) = self.mods.mods.run_serial(owner) {
self.draw_shapes(batch, run, self.mods.mods.canvas_shapes(owner), (0.0, 0.0));
}
}
}
+11 -15
View File
@@ -39,7 +39,7 @@ pub(super) struct ModModels {
/// The version of the mod post pass the renderer has (0: none).
post: u64,
/// The mods' cameras by id: a tick ago and now.
views: Vec<(CameraView, CameraView)>,
views: Vec<(u64, CameraView, CameraView)>,
/// The mods' lights by (mod, id): a tick ago and now.
lights: HashMap<(usize, u32), (PointLight, PointLight)>,
/// The mods' spot lights by (mod, id): a tick ago and now.
@@ -187,18 +187,13 @@ impl Game<'_> {
spots.insert(key, (before, *l));
}
self.mods.models.spots = spots;
let views: Vec<CameraView> = self.mods.mods.views().map(|(_, v)| *v).collect();
let views: Vec<(u64, CameraView)> = self.mods.mods.views().map(|(owner, v)| (owner, *v)).collect();
let old = std::mem::take(&mut self.mods.models.views);
self.mods.models.views = views
.into_iter()
.map(|v| {
let before = old
.iter()
.find(|(_, o)| o.id == v.id)
.map_or(v, |(_, o)| *o);
(before, v)
})
.collect();
self.mods.models.views = views.into_iter().map(|(owner, v)| {
let before = old.iter().find(|(o, _, previous)| *o == owner && previous.id == v.id)
.map_or(v, |(_, _, previous)| *previous);
(owner, before, v)
}).collect();
}
/// The HD lighting a mod asked for, if any.
@@ -370,10 +365,11 @@ impl Game<'_> {
.models
.views
.iter()
.map(|(a, b)| {
.map(|(owner, a, b)| {
// The short way round.
let turn = (b.yaw - a.yaw + 540.0).rem_euclid(360.0) - 180.0;
horizon_client::gfx::ModView {
owner: *owner,
id: b.id,
camera: DVec3::new(a.x, a.y, a.z).lerp(DVec3::new(b.x, b.y, b.z), t),
yaw: a.yaw + turn * t as f32,
@@ -387,8 +383,8 @@ impl Game<'_> {
/// The camera a mod asked the screen's world from (`render.set-main-view`), for this frame.
pub(super) fn mod_main_view(&self, alpha: f64) -> Option<horizon_client::gfx::ModView> {
let id = self.mods.mods.main_view()?;
self.mod_views(alpha).into_iter().find(|v| v.id == id)
let (owner, id) = self.mods.mods.owned_main_view()?;
self.mod_views(alpha).into_iter().find(|v| v.owner == owner && v.id == id)
}
/// Draws every mod model between its last two poses, lit by the world
+4 -3
View File
@@ -86,12 +86,12 @@ impl Game<'_> {
impl Game<'_> {
/// The running mods' HUD shapes, in order, over the HUD.
pub(super) fn draw_mod_shapes(&self, batch: &mut HudBatch) {
for shape in self.mods.mods.hud_shapes() {
self.draw_shapes(batch, std::slice::from_ref(shape), (0.0, 0.0));
for (owner, shape) in self.mods.mods.owned_hud_shapes() {
self.draw_shapes(batch, owner, std::slice::from_ref(shape), (0.0, 0.0));
}
}
/// Shapes with their GUI coordinates moved by `(ox, oy)`.
pub(super) fn draw_shapes(&self, batch: &mut HudBatch, shapes: &[Shape], (ox, oy): (f32, f32)) {
pub(super) fn draw_shapes(&self, batch: &mut HudBatch, owner: u64, shapes: &[Shape], (ox, oy): (f32, f32)) {
let atlas = &self.gui.atlas;
for shape in shapes {
match shape {
@@ -123,6 +123,7 @@ impl Game<'_> {
Shape::View(v) => {
let r = v.rect;
batch.views.push(horizon_client::hud::HudView {
owner,
at: batch.vertices.len() as u32,
id: v.view,
rect: [ox + r.x, oy + r.y, r.w, r.h],
+5 -5
View File
@@ -378,7 +378,7 @@ impl Game<'_> {
}
pub(super) fn mods_acoustics_prepared(&mut self) {}
pub(super) fn mods_film_report(&mut self, _report: &super::film::FilmReport) {}
pub(super) fn mods_main_view_id(&self) -> Option<u32> {
pub(super) fn mods_main_view_id(&self) -> Option<(u64, u32)> {
None
}
pub(super) fn mod_command(&mut self, _command: &str) -> bool {
@@ -925,8 +925,8 @@ impl Game<'_> {
message: r.message.clone(),
});
}
pub(super) fn mods_main_view_id(&self) -> Option<u32> {
self.mods.mods.main_view()
pub(super) fn mods_main_view_id(&self) -> Option<(u64, u32)> {
self.mods.mods.owned_main_view()
}
pub(super) fn mods_frame(&mut self, elapsed: Duration) {
self.reload_dev_mods();
@@ -1182,7 +1182,7 @@ impl Game<'_> {
extras_hud::draw_lines(batch, &self.gui.atlas, corners, corner, &lines);
}
// Capability `render`'s corner panels, placed the same way.
for panel in self.mods.mods.corner_panels() {
for (owner, panel) in self.mods.mods.owned_corner_panels() {
let corner = match panel.corner {
horizon_mods::RenderCorner::TopLeft => Corner::TopLeft,
horizon_mods::RenderCorner::TopRight => Corner::TopRight,
@@ -1193,7 +1193,7 @@ impl Game<'_> {
continue;
}
let at = corners.place(corner, panel.width, panel.height);
self.draw_shapes(batch, &panel.shapes, at);
self.draw_shapes(batch, owner, &panel.shapes, at);
}
}
+3 -2
View File
@@ -3424,7 +3424,7 @@ impl Renderer {
self.uploads
.buffer(encoder, &views.main, 0, bytemuck::bytes_of(globals));
for d in due {
let Some(t) = views.targets.get(&d.id) else {
let Some(t) = views.targets.get(&(d.owner, d.id)) else {
continue;
};
let own = Globals {
@@ -3452,7 +3452,7 @@ impl Renderer {
a: 1.0,
};
for d in due {
let Some(t) = views.targets.get_mut(&d.id) else {
let Some(t) = views.targets.get_mut(&(d.owner, d.id)) else {
continue;
};
t.drawn = true;
@@ -4583,6 +4583,7 @@ impl Renderer {
}
let n = (seen.len() + tiles.len()) as u32;
views_due.push(mod_views::Due {
owner: v.owner,
id: v.id,
view_proj,
view,
+73 -12
View File
@@ -9,6 +9,7 @@ use std::collections::HashMap;
/// One camera of a mod, as the game interpolated it for this frame.
#[derive(Clone, Copy, Debug)]
pub struct ModView {
pub owner: u64,
pub id: u32,
pub camera: DVec3,
/// Degrees, as the player's.
@@ -20,6 +21,10 @@ pub struct ModView {
pub use crate::hud::HudView;
fn view_key(owner: u64, id: u32) -> (u64, u32) {
(owner, id)
}
/// The most cameras drawn a frame; the others wait their turn.
pub(super) const PER_FRAME: usize = 3;
/// Sections round a camera it draws, in chunks.
@@ -56,6 +61,7 @@ pub(super) fn post_budget(size: [u32; 2]) -> Option<(usize, usize)> {
/// A camera drawn this frame.
pub(super) struct Due {
pub owner: u64,
pub id: u32,
/// Camera-relative to the player's camera, as the entities and the
/// world's other geometry are.
@@ -76,7 +82,7 @@ pub(super) struct Due {
}
pub(super) struct Views {
pub targets: HashMap<u32, Target>,
pub targets: HashMap<(u64, u32), Target>,
/// The player's camera's globals, copied back after the pictures.
pub main: wgpu::Buffer,
cursor: usize,
@@ -86,6 +92,17 @@ pub(super) struct Views {
pipeline: wgpu::RenderPipeline,
}
/// Indices scheduled for this frame; IDs may coincide across runs.
fn scheduled_indices(len: usize, cursor: &mut usize) -> Vec<usize> {
if len == 0 {
return Vec::new();
}
let n = len.min(PER_FRAME);
let start = *cursor % len;
*cursor = cursor.wrapping_add(n);
(0..n).map(|k| (start + k) % len).collect()
}
const BLIT: &str = r#"
@group(0) @binding(0) var picture: texture_2d<f32>;
@group(0) @binding(1) var picture_sampler: sampler;
@@ -217,20 +234,21 @@ impl Views {
globals_size: u64,
) -> Vec<&'a ModView> {
self.targets
.retain(|id, _| views.iter().any(|v| v.id == *id));
.retain(|key, _| views.iter().any(|v| view_key(v.owner, v.id) == *key));
for v in views {
if self.targets.get(&v.id).is_none_or(|t| t.size != v.size) {
if self
.targets
.get(&view_key(v.owner, v.id))
.is_none_or(|t| t.size != v.size)
{
let t = self.target(device, v.size, globals_size);
self.targets.insert(v.id, t);
self.targets.insert(view_key(v.owner, v.id), t);
}
}
if views.is_empty() {
return Vec::new();
}
let n = views.len().min(PER_FRAME);
let start = self.cursor % views.len();
self.cursor = self.cursor.wrapping_add(n);
(0..n).map(|k| &views[(start + k) % views.len()]).collect()
scheduled_indices(views.len(), &mut self.cursor)
.into_iter()
.map(|i| &views[i])
.collect()
}
fn target(&self, device: &wgpu::Device, size: [u32; 2], globals_size: u64) -> Target {
@@ -315,7 +333,11 @@ impl Views {
view: &HudView,
scale: f32,
) {
let Some(t) = self.targets.get(&view.id).filter(|t| t.drawn) else {
let Some(t) = self
.targets
.get(&view_key(view.owner, view.id))
.filter(|t| t.drawn)
else {
return;
};
let [x, y, w, h] = view.rect.map(|v| v * scale);
@@ -360,3 +382,42 @@ pub(super) fn matrices(v: &ModView, eye: DVec3, far: f32) -> (Mat4, Mat4, f32) {
aspect,
)
}
#[cfg(test)]
mod owner_tests {
use super::*;
#[test]
fn identical_local_ids_resolve_distinct_targets_and_survive_other_owner_removal() {
let mut cursor = 0;
assert_eq!(scheduled_indices(2, &mut cursor), [0, 1]);
assert!(scheduled_indices(0, &mut cursor).is_empty());
let mut targets = HashMap::new();
targets.insert(view_key(101, 7), "first texture");
targets.insert(view_key(202, 7), "second texture");
let first = HudView {
owner: 101,
id: 7,
at: 0,
rect: [0.0; 4],
mode: 0,
};
let second = HudView {
owner: 202,
..first
};
assert_eq!(
targets.get(&view_key(first.owner, first.id)),
Some(&"first texture")
);
assert_eq!(
targets.get(&view_key(second.owner, second.id)),
Some(&"second texture")
);
targets.retain(|key, _| *key == view_key(second.owner, second.id));
assert_eq!(
targets.get(&view_key(second.owner, second.id)),
Some(&"second texture")
);
assert!(targets.get(&view_key(first.owner, first.id)).is_none());
}
}
+1
View File
@@ -633,6 +633,7 @@ pub static GUI_SCALE: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU3
/// vertex `at`, over what was drawn before.
#[derive(Clone, Copy, Debug)]
pub struct HudView {
pub owner: u64,
pub at: u32,
pub id: u32,
/// GUI pixels: x, y, width, height.
+29 -5
View File
@@ -1288,6 +1288,17 @@ impl Mods {
.iter()
.filter_map(|e| e.run.as_ref()?.store.data().corner.as_ref())
}
/// Corner panels tagged with their running instance, for scoped camera IDs.
pub fn owned_corner_panels(&self) -> impl Iterator<Item = (u64, &CornerPanel)> {
self.entries.iter().filter_map(|e| {
let state = e.run.as_ref()?.store.data();
Some((state.run_serial, state.corner.as_ref()?))
})
}
/// The current running instance for a canvas owner index.
pub fn run_serial(&self, index: usize) -> Option<u64> {
Some(self.entries.get(index)?.run.as_ref()?.store.data().run_serial)
}
/// Some running mod calls `game.raycast`: the snapshot needs a
/// [`Snapshot::view_hit`].
pub fn wants_raycast(&self) -> bool {
@@ -1561,11 +1572,10 @@ impl Mods {
})
}
/// Every running mod's cameras (AirDef fork), with the mod's index.
pub fn views(&self) -> impl Iterator<Item = (usize, &CameraView)> {
self.entries.iter().enumerate().flat_map(|(i, e)| {
e.run
.iter()
.flat_map(move |r| r.store.data().views.iter().map(move |v| (i, v)))
pub fn views(&self) -> impl Iterator<Item = (u64, &CameraView)> {
self.entries.iter().filter_map(|e| e.run.as_ref()).flat_map(|r| {
let state = r.store.data();
state.views.iter().map(move |v| (state.run_serial, v))
})
}
/// Every running mod's lights (AirDef fork), with the mod's index.
@@ -1780,6 +1790,13 @@ impl Mods {
.iter()
.find_map(|e| e.run.as_ref()?.store.data().main_view)
}
/// The selected main view and the run whose ID namespace it belongs to.
pub fn owned_main_view(&self) -> Option<(u64, u32)> {
self.entries.iter().find_map(|e| {
let state = e.run.as_ref()?.store.data();
Some((state.run_serial, state.main_view?))
})
}
/// The argument trees running client mods gave their registered commands
/// (`command.set-tree`), for the chat to colour and complete; a tree
/// for a name the mod did not register is left out.
@@ -1920,6 +1937,13 @@ impl Mods {
.filter_map(|e| e.run.as_ref())
.flat_map(|r| &r.store.data().shapes)
}
/// HUD shapes tagged with the run that submitted them.
pub fn owned_hud_shapes(&self) -> impl Iterator<Item = (u64, &Shape)> {
self.entries.iter().filter_map(|e| e.run.as_ref()).flat_map(|r| {
let state = r.store.data();
state.shapes.iter().map(move |shape| (state.run_serial, shape))
})
}
/// The screen mod `i` last pushed with `ui.open-screen`, while it is up.
pub fn screen(&self, i: usize) -> Option<&UiScreen> {
self.entries
+38
View File
@@ -110,6 +110,44 @@ fn player() -> Snapshot {
}
}
#[test]
fn same_camera_id_from_two_mod_runs_keeps_its_owner() {
let original = Package::read(&mods_dir().join("horizon_mods_test_ui_guest.hmod"))
.unwrap()
.wasm
.unwrap();
let dir = mods_dir().join("camera_owner");
std::fs::create_dir_all(&dir).unwrap();
let one = dir.join("one.hmod");
let two = dir.join("two.hmod");
std::fs::write(&one, Package::write(UI_MANIFEST, &original).unwrap()).unwrap();
let other = UI_MANIFEST
.replace("test_ui_guest", "test_ui_guest_other")
.replace("Test UI Guest", "Other UI Guest");
std::fs::write(&two, Package::write(&other, &original).unwrap()).unwrap();
let mut mods = Mods::load(&[one, two], |_| true, |_| false);
mods.set_snapshot(Snapshot {
world: Some("test".into()),
world_seed: Some(-987_654_321),
..Snapshot::default()
});
mods.tick(1);
let views: Vec<_> = mods.views().map(|(run, view)| (run, view.id)).collect();
let hud: Vec<_> = mods
.owned_hud_shapes()
.filter_map(|(run, shape)| match shape {
Shape::View(view) => Some((run, view.view)),
_ => None,
})
.collect();
assert_eq!(views.len(), 2, "{views:?}");
assert_ne!(views[0].0, views[1].0);
assert_eq!(views[0].1, 7);
assert_eq!(views[1].1, 7);
assert_eq!(hud, views);
assert_eq!(mods.owned_main_view(), Some(views[0]));
}
/// The guest submits changes; only the audio apply acknowledgement advances its status.
#[test]
fn sound_sources_status_tracks_apply_output_and_generation() {
@@ -666,6 +666,30 @@ impl Mod for Guest {
Ok(None)
}
fn tick(&mut self, _tick: u64) {
if game::world_seed() == Some(-987_654_321) {
render::set_views(&[render::CameraView {
id: 7,
x: 1.0,
y: 2.0,
z: 3.0,
yaw: 0.0,
pitch: 0.0,
fov: 70.0,
width: 64,
height: 64,
}]);
render::set_main_view(Some(7));
render::set_hud(&[Shape::View(render::ViewShape {
view: 7,
rect: Rect {
x: 0.0,
y: 0.0,
w: 64.0,
h: 64.0,
},
mode: 0,
})]);
}
if let Some((book, all, id)) = self.pending.take() {
match recipes::place(book, all) {
Err(e) if e.starts_with("no screen") => self.pending = Some((book, all, id)),