Files
MrMelbert 5146cfd403 Moves camera update handling to background subsystem, (maybe) fixing lag (#92208)
## About The Pull Request

When a camera update is triggered, it is instead added to a queue on a
background subsystem

An AI entering a camera chunk which is queued to update will force the
update immediately (bypassing the queue)

While the root problem of this is, ultimately, not addressed...

<img width="554" height="58"
alt="467828777-eff3f0e5-49d6-4997-b4d7-05eff6432155"
src="https://github.com/user-attachments/assets/c2d6a5f5-d958-463e-959f-116bd0dab475"
/>

...the change will ultimately prevent update spam from consuming all of
the server's resources - instead allocating updates to the backburner in
times of high server stress (or on multi-z maps)

## Changelog

🆑 Melbert
refactor: Refactored the way camera updates are handled to hopefully
reduce some lag. Report any oddities
/🆑
2025-11-08 01:43:48 +01:00

297 lines
12 KiB
Plaintext

/// Manages the security cameras and camera chunks
SUBSYSTEM_DEF(cameras)
name = "Cameras"
flags = SS_BACKGROUND
priority = FIRE_PRIORITY_CAMERAS
runlevels = RUNLEVEL_GAME | RUNLEVEL_POSTGAME
wait = 2 MINUTES
dependencies = list(
// Required to get plane offset for static images
/datum/controller/subsystem/mapping,
)
/// The cameras on the map, no matter if they work or not.
/// Updated in obj/machinery/camera.dm in Initialize() and Destroy().
var/list/obj/machinery/camera/cameras = list()
/// The chunks of the map, mapping the areas that the cameras can see.
var/list/chunks = list()
/// Chunks that must be updated
var/list/chunks_to_update = list()
/// List of images cloned by all chunk static images put onto turfs cameras cant see
/// Indexed by the plane offset to use
var/list/image/obscured_images = list()
/// Primarily for debugging, outright prevents all camera chunk updates
var/disable_camera_updates = FALSE
/// Tracks current subsystem run
var/list/current_run = list()
/datum/controller/subsystem/cameras/Initialize()
update_offsets(SSmapping.max_plane_offset)
RegisterSignal(SSmapping, COMSIG_PLANE_OFFSET_INCREASE, PROC_REF(on_offset_growth))
return SS_INIT_SUCCESS
/datum/controller/subsystem/cameras/fire(resumed = FALSE)
if(!resumed)
src.current_run = chunks_to_update.Copy()
chunks_to_update = list()
var/list/current_run = src.current_run
while(current_run.len)
var/datum/camerachunk/chunk = current_run[current_run.len]
chunk.force_update(only_if_necessary = TRUE) // Forces an update if necessary
current_run.len--
if(MC_TICK_CHECK)
break
/datum/controller/subsystem/cameras/stat_entry(msg)
msg = "Cams: [length(cameras)] | Chunks: [length(chunks)] | Updating: [length(chunks_to_update)]"
return ..()
/// Updates the images for new plane offsets
/datum/controller/subsystem/cameras/proc/update_offsets(new_offset)
for(var/i in length(obscured_images) to new_offset)
var/image/obscured = new('icons/effects/cameravis.dmi')
SET_PLANE_W_SCALAR(obscured, CAMERA_STATIC_PLANE, i)
obscured.appearance_flags = RESET_TRANSFORM | RESET_ALPHA | RESET_COLOR | KEEP_APART
obscured.override = TRUE
obscured_images += obscured
/datum/controller/subsystem/cameras/proc/on_offset_growth(datum/source, old_offset, new_offset)
SIGNAL_HANDLER
update_offsets(new_offset)
/// Checks if a chunk has been generated in x, y, z.
/datum/controller/subsystem/cameras/proc/get_camera_chunk(x, y, z)
x = GET_CHUNK_COORD(x)
y = GET_CHUNK_COORD(y)
if(GET_LOWEST_STACK_OFFSET(z) != 0)
var/turf/lowest = get_lowest_turf(locate(x, y, z))
return chunks["[x],[y],[lowest.z]"]
return chunks["[x],[y],[z]"]
// Returns the chunk in the x, y, z.
// If there is no chunk, it creates a new chunk and returns that.
/datum/controller/subsystem/cameras/proc/generate_chunk(x, y, z)
x = GET_CHUNK_COORD(x)
y = GET_CHUNK_COORD(y)
var/turf/lowest = get_lowest_turf(locate(x, y, z))
var/key = "[x],[y],[lowest.z]"
. = chunks[key]
if(!.)
. = new /datum/camerachunk(x, y, lowest.z)
chunks[key] = .
/// Updates what the camera eye can see.
/// It is recommended you use this when a camera eye moves or its location is set.
/datum/controller/subsystem/cameras/proc/update_eye_chunk(mob/eye/camera/eye)
var/list/visibleChunks = list()
//Get the eye's turf in case its located in an object like a mecha
var/turf/eye_turf = get_turf(eye)
if(eye.loc)
var/static_range = eye.static_visibility_range
var/x1 = max(1, eye_turf.x - static_range)
var/y1 = max(1, eye_turf.y - static_range)
var/x2 = min(world.maxx, eye_turf.x + static_range)
var/y2 = min(world.maxy, eye_turf.y + static_range)
for(var/x = x1; x <= x2; x += CHUNK_SIZE)
for(var/y = y1; y <= y2; y += CHUNK_SIZE)
visibleChunks |= generate_chunk(x, y, eye_turf.z)
var/list/remove = eye.visibleCameraChunks - visibleChunks
var/list/add = visibleChunks - eye.visibleCameraChunks
for(var/datum/camerachunk/chunk as anything in remove)
chunk.remove(eye)
for(var/datum/camerachunk/chunk as anything in add)
chunk.add(eye)
/// Used in [/proc/major_chunk_change] - indicates the camera should be removed from the chunk list.
#define REMOVE_CAMERA 0
/// Used in [/proc/major_chunk_change] - indicates the camera should be added to the chunk list.
#define ADD_CAMERA 1
/// Used in [/proc/major_chunk_change] - indicates the chunk should be updated without adding/removing a camera.
#define IGNORE_CAMERA 2
/// Updates the chunks that the turf is located in. Use this when obstacles are destroyed or when doors open.
/datum/controller/subsystem/cameras/proc/update_visibility(atom/relevant_atom)
if(!SSticker)
return
major_chunk_change(relevant_atom, IGNORE_CAMERA)
/// Removes a camera from a chunk.
/datum/controller/subsystem/cameras/proc/remove_camera_from_chunk(obj/machinery/camera/old_cam)
major_chunk_change(old_cam, REMOVE_CAMERA)
/// Add a camera to a chunk.
/datum/controller/subsystem/cameras/proc/add_camera_to_chunk(obj/machinery/camera/new_cam)
if(new_cam.can_use())
major_chunk_change(new_cam, ADD_CAMERA)
/**
* Used for Cyborg/mecha cameras. Since portable cameras can be in ANY chunk.
* update_delay_buffer is passed all the way to queue_update() from their camera updates on movement
* to change the time between static updates.
*/
/datum/controller/subsystem/cameras/proc/update_portable_camera(obj/machinery/camera/updating_camera, update_delay_buffer)
if(updating_camera.can_use())
major_chunk_change(updating_camera, ADD_CAMERA, update_delay_buffer)
/**
* Never access this proc directly!!!!
* This will update the chunk and all the surrounding chunks.
* It will also add the atom to the cameras list if you set the choice to 1.
* Setting the choice to 0 will remove the camera from the chunks.
* If you want to update the chunks around an object, without adding/removing a camera, use choice 2.
* update_delay_buffer is passed all the way to queue_update() from portable camera updates on movement
* to change the time between static updates.
*/
/datum/controller/subsystem/cameras/proc/major_chunk_change(atom/center_or_camera, choice = IGNORE_CAMERA, update_delay_buffer = 0)
PROTECTED_PROC(TRUE)
if(QDELETED(center_or_camera) && choice == ADD_CAMERA)
CRASH("Tried to add a qdeleting camera to the net")
var/turf/chunk_turf = get_turf(center_or_camera)
if(isnull(chunk_turf))
return
var/x1 = max(1, chunk_turf.x - (CHUNK_SIZE / 2))
var/y1 = max(1, chunk_turf.y - (CHUNK_SIZE / 2))
var/x2 = min(world.maxx, chunk_turf.x + (CHUNK_SIZE / 2))
var/y2 = min(world.maxy, chunk_turf.y + (CHUNK_SIZE / 2))
for(var/x = x1; x <= x2; x += CHUNK_SIZE)
for(var/y = y1; y <= y2; y += CHUNK_SIZE)
var/datum/camerachunk/chunk = get_camera_chunk(x, y, chunk_turf.z)
if(isnull(chunk))
continue
if(choice == REMOVE_CAMERA)
// Remove the camera.
chunk.cameras[chunk_turf.z] -= center_or_camera
if(choice == ADD_CAMERA)
// You can't have the same camera in the list twice.
chunk.cameras[chunk_turf.z] |= center_or_camera
chunk.queue_update(center_or_camera, update_delay_buffer)
/// A faster, turf only version of [/datum/controller/subsystem/cameras/proc/major_chunk_change]
/// For use in sensitive code, be careful with it
/datum/controller/subsystem/cameras/proc/bare_major_chunk_change(turf/changed)
var/x1 = max(1, changed.x - (CHUNK_SIZE / 2))
var/y1 = max(1, changed.y - (CHUNK_SIZE / 2))
var/x2 = min(world.maxx, changed.x + (CHUNK_SIZE / 2))
var/y2 = min(world.maxy, changed.y + (CHUNK_SIZE / 2))
for(var/x = x1; x <= x2; x += CHUNK_SIZE)
for(var/y = y1; y <= y2; y += CHUNK_SIZE)
var/datum/camerachunk/chunk = get_camera_chunk(x, y, changed.z)
chunk?.queue_update(changed, 0)
/// Will check if an atom is on a viewable turf.
/// Returns TRUE if the atom is visible by any camera, FALSE otherwise.
/datum/controller/subsystem/cameras/proc/is_visible_by_cameras(atom/target)
return turf_visible_by_cameras(get_turf(target))
/// Checks if the passed turf is visible by any camera.
/// Returns TRUE if the turf is visible by any camera, FALSE otherwise.
/datum/controller/subsystem/cameras/proc/turf_visible_by_cameras(turf/position)
PRIVATE_PROC(TRUE)
if(isnull(position))
return FALSE
var/datum/camerachunk/chunk = generate_chunk(position.x, position.y, position.z)
if(isnull(chunk))
return FALSE
chunk.force_update(only_if_necessary = TRUE) // Update NOW if necessary
if(chunk.visibleTurfs[position])
return TRUE
return FALSE
/// Gets the camera chunk the passed turf is in.
/// Returns the chunk if it exists and is visible, null otherwise.
/datum/controller/subsystem/cameras/proc/get_turf_camera_chunk(turf/position)
RETURN_TYPE(/datum/camerachunk)
var/datum/camerachunk/chunk = generate_chunk(position.x, position.y, position.z)
if(!chunk)
return null
chunk.force_update(only_if_necessary = TRUE) // Update NOW if necessary
if(chunk.visibleTurfs[position])
return chunk
return null
/// Returns list of available cameras, ready to use for UIs displaying list of them
/// The format is: list("name" = "camera.c_tag", ref = REF(camera))
/datum/controller/subsystem/cameras/proc/get_available_cameras_data(list/networks_available, list/z_levels_available)
var/list/available_cameras_data = list()
for(var/obj/machinery/camera/camera as anything in get_filtered_and_sorted_cameras(networks_available, z_levels_available))
available_cameras_data += list(list(
name = camera.c_tag,
ref = REF(camera),
))
return available_cameras_data
/**
* get_available_camera_by_tag_list
*
* Builds a list of all available cameras that can be seen to networks_available and in z_levels_available.
* Entries are stored in `c_tag[camera.can_use() ? null : " (Deactivated)"]` => `camera` format
* Args:
* networks_available - List of networks that we use to see which cameras are visible to it.
* z_levels_available - List of z levels to filter camera by. If empty, all z levels are considered valid.
* sort_by_ctag - If the resulting list should be sorted by `c_tag`.
*/
/datum/controller/subsystem/cameras/proc/get_available_camera_by_tag_list(list/networks_available, list/z_levels_available)
var/list/available_cameras_by_tag = list()
for(var/obj/machinery/camera/camera as anything in get_filtered_and_sorted_cameras(networks_available, z_levels_available))
available_cameras_by_tag["[camera.c_tag][camera.can_use() ? null : " (Deactivated)"]"] = camera
return available_cameras_by_tag
/// Returns list of all cameras that passed `is_camera_available` filter and sorted by `cmp_camera_ctag_asc`
/datum/controller/subsystem/cameras/proc/get_filtered_and_sorted_cameras(list/networks_available, list/z_levels_available)
PRIVATE_PROC(TRUE)
var/list/filtered_cameras = list()
for(var/obj/machinery/camera/camera as anything in cameras)
if(!is_camera_available(camera, networks_available, z_levels_available))
continue
filtered_cameras += camera
return sortTim(filtered_cameras, GLOBAL_PROC_REF(cmp_camera_ctag_asc))
/// Checks if the `camera_to_check` meets the requirements of availability.
/datum/controller/subsystem/cameras/proc/is_camera_available(obj/machinery/camera/camera_to_check, list/networks_available, list/z_levels_available)
PRIVATE_PROC(TRUE)
if(!camera_to_check.c_tag)
return FALSE
if(length(z_levels_available) && !(camera_to_check.z in z_levels_available))
return FALSE
return length(camera_to_check.network & networks_available) > 0
#undef ADD_CAMERA
#undef REMOVE_CAMERA
#undef IGNORE_CAMERA
/obj/effect/overlay/camera_static
name = "static"
icon = null
icon_state = null
anchored = TRUE // should only appear in vis_contents, but to be safe
appearance_flags = RESET_TRANSFORM | TILE_BOUND | LONG_GLIDE
// this combination makes the static block clicks to everything below it,
// without appearing in the right-click menu for non-AI clients
mouse_opacity = MOUSE_OPACITY_ICON
invisibility = INVISIBILITY_ABSTRACT
plane = CAMERA_STATIC_PLANE
ADMIN_VERB(pause_camera_updates, R_ADMIN, "Toggle Camera Updates", "Stop security cameras from updating, meaning what they see now is what they will see forever.", ADMIN_CATEGORY_DEBUG)
SScameras.disable_camera_updates = !SScameras.disable_camera_updates
log_admin("[key_name_admin(user)] [SScameras.disable_camera_updates ? "disabled" : "enabled"] camera updates.")
message_admins("Admin [key_name_admin(user)] has [SScameras.disable_camera_updates ? "disabled" : "enabled"] camera updates.")
BLACKBOX_LOG_ADMIN_VERB("Toggle Camera Updates")