script: Unify script-based "update the rendering" and throttle it to 60 FPS

Signed-off-by: Martin Robinson <mrobinson@igalia.com>
This commit is contained in:
Martin Robinson 2025-07-14 10:26:29 +02:00
parent c09e117bfe
commit 3640c027f2
4 changed files with 131 additions and 123 deletions

View file

@ -3658,6 +3658,29 @@ impl Document {
self.webgpu_contexts.clone()
}
/// Whether or not this [`Document`] needs a rendering update, due to changed
/// contents or pending events.
pub(crate) fn needs_rendering_update(&self) -> bool {
if !self.is_fully_active() {
return false;
}
if !self.window().layout_blocked() && !self.restyle_reason().is_empty() {
return true;
}
if self.has_pending_input_events() {
return true;
}
if self.has_resize_observers() {
return true;
}
if self.window().has_unhandled_resize_event() {
return true;
}
false
}
/// An implementation of step 22 from
/// <https://html.spec.whatwg.org/multipage/#update-the-rendering>:
///
@ -3718,6 +3741,11 @@ impl Document {
.push(Dom::from_ref(resize_observer));
}
/// Whether or not this [`Document`] has any active [`ResizeObserver`].
pub(crate) fn has_resize_observers(&self) -> bool {
!self.resize_observers.borrow().is_empty()
}
/// <https://drafts.csswg.org/resize-observer/#gather-active-observations-h>
/// <https://drafts.csswg.org/resize-observer/#has-active-resize-observations>
pub(crate) fn gather_active_resize_observations_at_depth(
@ -4429,6 +4457,12 @@ impl Document {
mem::take(&mut *self.pending_input_events.borrow_mut())
}
/// Whether or not this [`Document`] has any pending input events to be processed during
/// "update the rendering."
pub(crate) fn has_pending_input_events(&self) -> bool {
!self.pending_input_events.borrow().is_empty()
}
pub(crate) fn set_csp_list(&self, csp_list: Option<CspList>) {
self.policy_container.borrow_mut().set_csp_list(csp_list);
}

View file

@ -2776,6 +2776,11 @@ impl Window {
self.unhandled_resize_event.borrow_mut().take()
}
/// Whether or not this [`Window`] has any resize events that have not been processed.
pub(crate) fn has_unhandled_resize_event(&self) -> bool {
self.unhandled_resize_event.borrow().is_some()
}
pub(crate) fn set_viewport_size(&self, new_viewport_size: UntypedSize2D<f32>) {
let new_viewport_size = Size2D::new(
Au::from_f32_px(new_viewport_size.width),

View file

@ -200,7 +200,8 @@ type NodeIdSet = HashSet<String>;
#[cfg_attr(crown, allow(crown::unrooted_must_root))]
pub struct ScriptThread {
/// <https://html.spec.whatwg.org/multipage/#last-render-opportunity-time>
last_render_opportunity_time: DomRefCell<Option<Instant>>,
last_render_opportunity_time: Cell<Option<Instant>>,
/// The documents for pipelines managed by this thread
documents: DomRefCell<DocumentCollection>,
/// The window proxies known by this thread
@ -1208,20 +1209,7 @@ impl ScriptThread {
}));
}
/// <https://html.spec.whatwg.org/multipage/#update-the-rendering>
///
/// Attempt to update the rendering and then do a microtask checkpoint if rendering was actually
/// updated.
pub(crate) fn update_the_rendering(&self, can_gc: CanGc) {
*self.last_render_opportunity_time.borrow_mut() = Some(Instant::now());
let is_animation_tick = self.has_pending_animation_tick.load(Ordering::Relaxed);
if is_animation_tick {
self.has_pending_animation_tick
.store(false, Ordering::Relaxed);
// If this is an animation tick, cancel any upcoming ScriptThread-based animation timer.
// This tick serves the purpose and we to limit animation ticks if some are coming from
// the renderer.
fn cancel_scheduled_update_the_rendering(&self) {
if let Some(timer_id) = self
.scheduled_script_thread_animation_timer
.borrow_mut()
@ -1231,14 +1219,39 @@ impl ScriptThread {
}
}
if !self.can_continue_running_inner() {
fn schedule_update_the_rendering_timer_if_necessary(&self, delay: Duration) {
if self
.scheduled_script_thread_animation_timer
.borrow()
.is_some()
{
return;
}
let any_animations_running = self.documents.borrow().iter().any(|(_, document)| {
document.is_fully_active() && document.animations().running_animation_count() != 0
debug!("Scheduling ScriptThread animation frame.");
let trigger_script_thread_animation = self.has_pending_animation_tick.clone();
let timer_id = self.schedule_timer(TimerEventRequest {
callback: Box::new(move || {
trigger_script_thread_animation.store(true, Ordering::Relaxed);
}),
duration: delay,
});
*self.scheduled_script_thread_animation_timer.borrow_mut() = Some(timer_id);
}
/// <https://html.spec.whatwg.org/multipage/#update-the-rendering>
///
/// Attempt to update the rendering and then do a microtask checkpoint if rendering was actually
/// updated.
pub(crate) fn update_the_rendering(&self, can_gc: CanGc) {
self.last_render_opportunity_time.set(Some(Instant::now()));
self.cancel_scheduled_update_the_rendering();
if !self.can_continue_running_inner() {
return;
}
// TODO: The specification says to filter out non-renderable documents,
// as well as those for which a rendering update would be unnecessary,
// but this isn't happening here.
@ -1247,13 +1260,6 @@ impl ScriptThread {
// has pending initial observation targets
// https://w3c.github.io/IntersectionObserver/#pending-initial-observation
// If we aren't explicitly running rAFs, this update wasn't requested by the compositor,
// and we are running animations, then wait until the compositor tells us it is time to
// update the rendering via a TickAllAnimations message.
if !is_animation_tick && any_animations_running {
return;
}
// > 2. Let docs be all fully active Document objects whose relevant agent's event loop
// > is eventLoop, sorted arbitrarily except that the following conditions must be
// > met:
@ -1323,9 +1329,7 @@ impl ScriptThread {
// > 14. For each doc of docs, run the animation frame callbacks for doc, passing
// > in the relative high resolution time given frameTimestamp and doc's
// > relevant global object as the timestamp.
if is_animation_tick {
document.run_the_animation_frame_callbacks(can_gc);
}
// Run the resize observer steps.
let _realm = enter_realm(&*document);
@ -1365,106 +1369,75 @@ impl ScriptThread {
// should be run in a task and a microtask checkpoint is always done when running tasks.
self.perform_a_microtask_checkpoint(can_gc);
// If there are pending reflows, they were probably caused by the execution of
// the microtask checkpoint above and we should spin the event loop one more
// time to resolve them.
self.schedule_rendering_opportunity_if_necessary();
// If this was a animation update request, then potentially schedule a new
// animation update in the case that the compositor might not do it due to
// not receiving any display lists.
if is_animation_tick {
self.schedule_script_thread_animation_tick_if_necessary(saw_any_reflows);
}
self.maybe_schedule_rendering_opportunity_after_rendering_upate(saw_any_reflows);
}
fn maybe_schedule_rendering_opportunity_after_rendering_upate(&self, saw_any_reflows: bool) {
// If there are any pending reflows and we are not having rendering opportunities
// driven by the compositor, then schedule the next rendering opportunity.
//
// TODO: This is a workaround until rendering opportunities can be triggered from a
// timer in the script thread.
fn schedule_rendering_opportunity_if_necessary(&self) {
// If any Document has active animations of rAFs, then we should be receiving
// regular rendering opportunities from the compositor (or fake animation frame
// ticks). In this case, don't schedule an opportunity, just wait for the next
// one.
if self.documents.borrow().iter().any(|(_, document)| {
document.is_fully_active() &&
(document.animations().running_animation_count() != 0 ||
document.has_active_request_animation_frame_callbacks())
}) {
return;
if self
.documents
.borrow()
.iter()
.any(|(_, document)| document.needs_rendering_update())
{
self.cancel_scheduled_update_the_rendering();
self.schedule_update_the_rendering_timer_if_necessary(Duration::ZERO);
}
let Some((_, document)) = self.documents.borrow().iter().find(|(_, document)| {
document.is_fully_active() &&
!document.window().layout_blocked() &&
!document.restyle_reason().is_empty()
}) else {
return;
};
// Queues a task to update the rendering.
// <https://html.spec.whatwg.org/multipage/#event-loop-processing-model:queue-a-global-task>
//
// Note: The specification says to queue a task using the navigable's active
// window, but then updates the rendering for all documents.
//
// This task is empty because any new IPC messages in the ScriptThread trigger a
// rendering update when animations are not running.
let _realm = enter_realm(&*document);
document
.owner_global()
.task_manager()
.rendering_task_source()
.queue_unconditionally(task!(update_the_rendering: move || { }));
}
/// The renderer triggers animation ticks based on the arrival and painting of new
/// display lists. In the case that a `WebView` is animating or has a
/// requestAnimationFrame callback, it may be that an animation tick reflow does
/// not change anything and thus does not send a new display list to the renderer.
/// If that's the case, we need to schedule a ScriptThread-based animation update
/// (to avoid waking the renderer up).
fn schedule_script_thread_animation_tick_if_necessary(&self, saw_any_reflows: bool) {
if saw_any_reflows {
return;
}
// Always schedule a ScriptThread-based animation tick, unless none of the
// documents are active and have animations running and/or rAF callbacks.
if !self.documents.borrow().iter().any(|(_, document)| {
if !saw_any_reflows &&
self.documents.borrow().iter().any(|(_, document)| {
document.is_fully_active() &&
!document.window().throttled() &&
(document.animations().running_animation_count() != 0 ||
document.has_active_request_animation_frame_callbacks())
}) {
})
{
const SCRIPT_THREAD_ANIMATION_TICK_DELAY: Duration = Duration::from_millis(30);
self.schedule_update_the_rendering_timer_if_necessary(
SCRIPT_THREAD_ANIMATION_TICK_DELAY,
);
}
}
fn maybe_schedule_rendering_opportunity_after_ipc_message(&self, can_gc: CanGc) {
if self.has_pending_animation_tick.load(Ordering::Relaxed) {
self.update_the_rendering(can_gc);
return;
}
/// The amount of time between ScriptThread animation ticks when nothing is
/// changing. In order to be more efficient, only tick at around 30 frames a
/// second, which also gives time for any renderer ticks to come in and cancel
/// this tick. A renderer tick might happen for a variety of reasons, such as a
/// Pipeline in another ScriptThread producing a display list.
const SCRIPT_THREAD_ANIMATION_TICK_DELAY: u64 = 30;
// If no document needs a rendering update, exit early to avoid doing more work.
if !self
.documents
.borrow()
.iter()
.any(|(_, document)| document.needs_rendering_update())
{
return;
}
debug!("Scheduling ScriptThread animation frame.");
let trigger_script_thread_animation = self.has_pending_animation_tick.clone();
let timer_id = self.schedule_timer(TimerEventRequest {
callback: Box::new(move || {
trigger_script_thread_animation.store(true, Ordering::Relaxed);
}),
duration: Duration::from_millis(SCRIPT_THREAD_ANIMATION_TICK_DELAY),
});
// Wait 20 milliseconds between frames triggered by the script thread itself. This
// should, in theory, allow compositor-based ticks to arrive sooner.
const SCRIPT_THREAD_ANIMATION_TICK_DELAY: Duration = Duration::from_millis(20);
let time_since_last_rendering_opportunity = self
.last_render_opportunity_time
.get()
.map(|last_render_opportunity_time| Instant::now() - last_render_opportunity_time)
.unwrap_or(Duration::MAX);
let mut scheduled_script_thread_animation_timer =
self.scheduled_script_thread_animation_timer.borrow_mut();
assert!(
scheduled_script_thread_animation_timer.is_none(),
"Should never schedule a new timer when one is already scheduled."
// If it's been more than the time of a single frame the last rendering opportunity,
// just run it now.
if time_since_last_rendering_opportunity > SCRIPT_THREAD_ANIMATION_TICK_DELAY {
self.update_the_rendering(can_gc);
return;
}
self.schedule_update_the_rendering_timer_if_necessary(
SCRIPT_THREAD_ANIMATION_TICK_DELAY - time_since_last_rendering_opportunity,
);
*scheduled_script_thread_animation_timer = Some(timer_id);
}
/// Handle incoming messages from other tasks and the task queue.
@ -1676,10 +1649,7 @@ impl ScriptThread {
docs.clear();
}
// Update the rendering whenever we receive an IPC message. This may not actually do anything if
// we are running animations and the compositor hasn't requested a new frame yet via a TickAllAnimatons
// message.
self.update_the_rendering(can_gc);
self.maybe_schedule_rendering_opportunity_after_ipc_message(can_gc);
true
}

View file

@ -145,7 +145,6 @@ impl TaskManager {
task_source_functions!(self, performance_timeline_task_source, PerformanceTimeline);
task_source_functions!(self, port_message_queue, PortMessage);
task_source_functions!(self, remote_event_task_source, RemoteEvent);
task_source_functions!(self, rendering_task_source, Rendering);
task_source_functions!(self, timer_task_source, Timer);
task_source_functions!(self, user_interaction_task_source, UserInteraction);
task_source_functions!(self, websocket_task_source, WebSocket);