Optimizing native hit testing of DOM elements (Chrome)
Asked Answered
J

5

50

I have a heavily optimized JavaScript app, a highly interactive graph editor. I now started profiling it (using Chrome dev-tools) with massive amounts of data (thousands of shapes in the graph), and I'm encountering a previously unusual performance bottleneck, Hit Test.

| Self Time       | Total Time      | Activity            |
|-----------------|-----------------|---------------------|
| 3579 ms (67.5%) | 3579 ms (67.5%) | Rendering           |
| 3455 ms (65.2%) | 3455 ms (65.2%) |   Hit Test          | <- this one
|   78 ms  (1.5%) |   78 ms  (1.5%) |   Update Layer Tree |
|   40 ms  (0.8%) |   40 ms  (0.8%) |   Recalculate Style |
| 1343 ms (25.3%) | 1343 ms (25.3%) | Scripting           |
|  378 ms  (7.1%) |  378 ms  (7.1%) | Painting            |

This takes up 65% of everything (!), remaining a monster bottleneck in my codebase. I know this is the process of tracing the object under the pointer, and I have my useless ideas about how this could be optimized (use fewer elements, use fewer mouse events, etc.).

Context: The above performance profile shows a "screen panning" feature in my app, where the contents of the screen can be moved around by dragging the empty area. This results in lots of objects being moved around, optimized by moving their container instead of each object individually. I made a demo.


Before jumping into this, I wanted to search for the general principles of optimizing hit testing (those good ol' "No sh*t, Sherlock" blog articles), as well as if any tricks exist to improve performance on this end (such as using translate3d to enable GPU processing).

I tried queries like js optimize hit test, but the results are full of graphics programming articles and manual implementation examples -- it's as if the JS community hadn't even heard of this thing before! Even the chrome devtools guide lacks this area.

So here I am, proudly done with my research, asking: how do I get about optimizing native hit testing in JavaScript?


I prepared a demo that demonstrates the performance bottleneck, although it's not exactly the same as my actual app, and numbers will obviously vary by device as well. To see the bottleneck:

  1. Go to the Timeline tab on Chrome (or the equivalent of your browser)
  2. Start recording, then pan around in the demo like a mad-man
  3. Stop recording and check the results

A recap of all significant optimizations I have already done in this area:

  • moving a single container on the screen instead of moving thousands of elements individually
  • using transform: translate3d to move container
  • v-syncing mouse movement to screen refresh rate
  • removing all possible unnecessary "wrapper" and "fixer" elements
  • using pointer-events: none on shapes -- no effect

Additional notes:

  • the bottleneck exists both with and without GPU acceleration
  • testing was only done in Chrome, latest
  • the DOM is rendered using ReactJS, but the same issue is observable without it, as seen in the linked demo
Jammie answered 24/1, 2017 at 14:19 Comment(5)
Interesting, is this crbug.com/454909 ("Compositor does not honour pointer-events:none") or something else under bugs.chromium.org/p/chromium/issues/… ?Muir
@JohnWeisz did you consider rendering your nodes lazily, as in "only render what's visible on the screen" ? I think that's the only way to have reliable performance with large amounts of nodes. It suddenly forces you to write a lot more code of courseTechnical
@RenanLeCaro Actually yes, but unfortunately, the repeated addition and removal of DOM-elements have an even bigger performance impact.Jammie
That can probably be solved using canvas, but dealing with complex shapes is going to get tricky fastTechnical
For anyone reading this in (or after) 2020, pointer-events: none now works. At least in scrolling on Chrome 80. Scrolling was a performace bottleneck on my case.Bradney
I
16

Interesting, that pointer-events: none has no effect. But if you think about it, it makes sense, since elements with that flag set still obscure other elements' pointer events, so the hittest has to take place anyways.

What you can do is put a overlay over critical content and respond to mouse-events on that overlay, let your code decide what to do with it.

This works because once the hittest algorithm has found a hit, and I'm assuming it does that downwards the z-index, it stops.


With overlay

// ================================================
// Increase or decrease this value for testing:
var NUMBER_OF_OBJECTS = 40000;
// Wether to use the overlay or the container directly
var USE_OVERLAY = true;
// ================================================

var overlay = document.getElementById("overlay");
var container = document.getElementById("container");
var contents = document.getElementById("contents");

for (var i = 0; i < NUMBER_OF_OBJECTS; i++) {
    var node = document.createElement("div");
    node.innerHtml = i;
    node.className = "node";
    node.style.top = Math.abs(Math.random() * 2000) + "px";
    node.style.left = Math.abs(Math.random() * 2000) + "px";
    contents.appendChild(node);
}

var posX = 100;
var posY = 100;
var previousX = null;
var previousY = null;

var mousedownHandler = function (e) {
    window.onmousemove = globalMousemoveHandler;
    window.onmouseup = globalMouseupHandler;
    previousX = e.clientX;
    previousY = e.clientY;
}

var globalMousemoveHandler = function (e) {
    posX += e.clientX - previousX;
    posY += e.clientY - previousY;
    previousX = e.clientX;
    previousY = e.clientY;
    contents.style.transform = "translate3d(" + posX + "px, " + posY + "px, 0)";
}

var globalMouseupHandler = function (e) {
    window.onmousemove = null;
    window.onmouseup = null;
    previousX = null;
    previousY = null;
}

if(USE_OVERLAY){
	overlay.onmousedown = mousedownHandler;
}else{
	overlay.style.display = 'none';
	container.onmousedown = mousedownHandler;
}


contents.style.transform = "translate3d(" + posX + "px, " + posY + "px, 0)";
#overlay{
  position: absolute;
  top: 0;
  left: 0;
  height: 400px;
  width: 800px;
  opacity: 0;
  z-index: 100;
  cursor: -webkit-grab;
  cursor: -moz-grab;
  cursor: grab;
  -moz-user-select: none;
  -ms-user-select: none;
  -webkit-user-select: none;
  user-select: none;
}

#container {
  height: 400px;
  width: 800px;
  background-color: #ccc;
  overflow: hidden;
}

#container:active {
  cursor: move;
  cursor: -webkit-grabbing;
  cursor: -moz-grabbing;
  cursor: grabbing;
}

.node {
  position: absolute;
  height: 20px;
  width: 20px;
  background-color: red;
  border-radius: 10px;
  pointer-events: none;
}
<div id="overlay"></div>
<div id="container">
    <div id="contents"></div>
</div>

Without overlay

// ================================================
// Increase or decrease this value for testing:
var NUMBER_OF_OBJECTS = 40000;
// Wether to use the overlay or the container directly
var USE_OVERLAY = false;
// ================================================

var overlay = document.getElementById("overlay");
var container = document.getElementById("container");
var contents = document.getElementById("contents");

for (var i = 0; i < NUMBER_OF_OBJECTS; i++) {
    var node = document.createElement("div");
    node.innerHtml = i;
    node.className = "node";
    node.style.top = Math.abs(Math.random() * 2000) + "px";
    node.style.left = Math.abs(Math.random() * 2000) + "px";
    contents.appendChild(node);
}

var posX = 100;
var posY = 100;
var previousX = null;
var previousY = null;

var mousedownHandler = function (e) {
    window.onmousemove = globalMousemoveHandler;
    window.onmouseup = globalMouseupHandler;
    previousX = e.clientX;
    previousY = e.clientY;
}

var globalMousemoveHandler = function (e) {
    posX += e.clientX - previousX;
    posY += e.clientY - previousY;
    previousX = e.clientX;
    previousY = e.clientY;
    contents.style.transform = "translate3d(" + posX + "px, " + posY + "px, 0)";
}

var globalMouseupHandler = function (e) {
    window.onmousemove = null;
    window.onmouseup = null;
    previousX = null;
    previousY = null;
}

if(USE_OVERLAY){
	overlay.onmousedown = mousedownHandler;
}else{
	overlay.style.display = 'none';
	container.onmousedown = mousedownHandler;
}


contents.style.transform = "translate3d(" + posX + "px, " + posY + "px, 0)";
#overlay{
  position: absolute;
  top: 0;
  left: 0;
  height: 400px;
  width: 800px;
  opacity: 0;
  z-index: 100;
  cursor: -webkit-grab;
  cursor: -moz-grab;
  cursor: grab;
  -moz-user-select: none;
  -ms-user-select: none;
  -webkit-user-select: none;
  user-select: none;
}

#container {
  height: 400px;
  width: 800px;
  background-color: #ccc;
  overflow: hidden;
}

#container:active {
  cursor: move;
  cursor: -webkit-grabbing;
  cursor: -moz-grabbing;
  cursor: grabbing;
}

.node {
  position: absolute;
  height: 20px;
  width: 20px;
  background-color: red;
  border-radius: 10px;
  pointer-events: none;
}
<div id="overlay"></div>
<div id="container">
    <div id="contents"></div>
</div>
Icicle answered 26/7, 2018 at 8:44 Comment(3)
Wonderful - with 40,000 elements, with the overlay, my hit tests take about 50 microseconds instead of 25 milliseconds. Enjoy your bounty!Affiance
Yes, this is quite an improvement, nice catch.Jammie
yes, It boost up my performace 300%. thank you for thatFieldfare
D
6

One of the problems is that you're moving EVERY single element inside your container, it doesn't matter if you have GPU-acceleration or not, the bottle neck is recalculating their new position, that is processor field.

My suggestion here is to segment the containers, therefore you can move various panes individually, reducing the load, this is called a broad-phase calculation, that is, only move what needs to be moved. If you got something out of the screen, why should you move it?

Start by making instead of one, 16 containers, you'll have to do some math here to find out which of these panes are being shown. Then, when a mouse event happens, move only those panes and leave the ones not shown where they are. This should reduce greatly the time used to move them.

+------+------+------+------+
|    SS|SS    |      |      |
|    SS|SS    |      |      |
+------+------+------+------+
|      |      |      |      |
|      |      |      |      |
+------+------+------+------+
|      |      |      |      |
|      |      |      |      |
+------+------+------+------+
|      |      |      |      |
|      |      |      |      |
+------+------+------+------+

On this example, we have 16 panes, of which, 2 are being shown (marked by S for Screen). When a user pans, check the bounding box of the "screen", find out which panes pertain to the "screen", move only those panes. This is theoretically infinitely scalable.

Unfortunately I lack the time to write the code showing the thought, but I hope this helps you.

Cheers!

Dextrogyrate answered 3/2, 2017 at 12:51 Comment(1)
You may be partially true. Since the divs inside of container have absolute position, they don't need to recalculating their new position. The hitTest is done for mouse events on that container. maybe stopPropagation or something like that would help with it.Kmeson
R
4

There's now a CSS property in Chrome, content-visibility: auto, that helps to prevent hit-testing when DOM elements are out of view. See web.dev.

The content-visibility property accepts several values, but auto is the one that provides immediate performance improvements. An element that has content-visibility: auto gains layout, style and paint containment. If the element is off-screen (and not otherwise relevant to the user—relevant elements would be the ones that have focus or selection in their subtree), it also gains size containment (and it stops painting and hit-testing its contents).

I couldn't replicate the issues of this demo, likely due to pointer-events: none now working as intended, as @rodrigo-cabral mentioned, however I was having significant issues while dragging using HTML5 drag and drop due to having a large number of elements with dragOver or dragEnter event handlers, most of which were on off screen elements (virtualising these elements came with significant drawbacks, so we haven't done so yet).

Adding the content-visibility: auto property to the elements that had the drag event handlers significantly improved hit-test times (from 12ms down to <2ms).

This does come with some caveats, such as causing elements to render as though they have overflow: hidden, or requiring contain-intrinsic-size to be set on the elements to ensure they take up that space when they're offscreen, but it's the only property I've found that helps reduce hit-test times.

NOTE: Attempting to use contain: layout style paint size alone did not have any impact on reducing hit-test times.

Radarscope answered 9/12, 2020 at 1:43 Comment(0)
W
1

We had a problem with a hit test in a Chrome webview on Android. Our application freezes completely... It turned out that because of a pointer-events : all, the hit test didn't succeed for a very long time. Sometimes as long as 5 times 30 seconds (seen in the timeline of the debugging tool). We removed the pointer-events: everything came back. It turns out that in our case it was a click on a complex SVG. And that slowed down the hit test analysis even more. Unfortunately I don't have any more explanation, but I hope it will save someone the hours we spent on it!

EDIT: this is a bug that had already been encountered years earlier and which seems to have returned. The code we had to remove dates back to February 2023, whereas we only noticed the problem returning in March 2024. I don't know if an update was pushed somewhere in Chrome, Android, or elsewhere without us knowing about it. The bug is so violent that there's no way we could have missed it for 1 year (application with over 300k installations).

Wingspread answered 27/4 at 19:11 Comment(1)
This does not provide an answer to the question. Once you have sufficient reputation you will be able to comment on any post; instead, provide answers that don't require clarification from the asker. - From ReviewWorked
P
0
 In the example 'Without overlay',Modify class '.node' as below,It can also prompt performance, but the principle is unclear:
.node {
        /* position: absolute; */
        height: 20px;
        width: 20px;
        background-color: red;
        border-radius: 10px;
        pointer-events: none;
        /* display: block; */   
        display: inline-block; 
      }
Plated answered 28/2 at 3:30 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.