-
Notifications
You must be signed in to change notification settings - Fork 1.4k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Improve the performance of certain GraphNode methods and avoid recalculating the AABB multiple times during rendering. #7245
base: main
Are you sure you want to change the base?
Conversation
…e aabb a few times while rendering
Please test the memory footprint and allocation/garbage collection difference in dynamic scenes with/without this PR. Glancing into the code, this PR will introduce a lot of allocations (arrays, pop, unshift, etc) into hot code (the code that might be running multiple times on every frame). This might lead to a minor/major GC stalls. |
@Maksims It is a good point, that's why _tmpGraphNodeStack was added. It doesn's looks like there is a big difference in terms of memory
|
src/scene/graph-node.js
Outdated
@@ -98,6 +73,12 @@ function findNode(node, test) { | |||
* a powerful set of features that are leveraged by the `Entity` class. | |||
*/ | |||
class GraphNode extends EventHandler { | |||
/** | |||
* @type {GraphNode[]} | |||
* @ignore |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should this be @private
instead?
This is very cool. I was wondering...since this is such performance critical code, would it be more efficient to store the stack pointer (index)? Then, a pop is a decrement and a push is an array 'write at index'. And to start using the stack, just set the pointer number to |
src/scene/graph-node.js
Outdated
@@ -594,11 +593,22 @@ class GraphNode extends EventHandler { | |||
const results = []; | |||
const test = createTest(attr, value); | |||
|
|||
this.forEach((node) => { | |||
const stack = GraphNode._tmpGraphNodeStack; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since forEach
is now optimized to execute iteratively, why duplicate the iterative loop again here?
This approach eats up a lot of memory, maybe we should move away from storing references to objects in arrays on indexes? about: https://www.mattzeunert.com/2018/01/25/v8-javascript-memory-quiz.html jsbench: https://jsbench.me/cvm5h498pk/1
|
@AlexAPPi I'll look at your suggestion tomorrow, ok? Regarding my solution, as noted in the comment, I'm using shift() in the const queue = [node];
while (queue.length > 0) {
// shift() is used for breadth-first approach; use pop() for depth-first
const current = queue.shift();
// . . .
const children = current.children;
for (let i = 0; i < children.length; i++) {
queue.push(children[i]);
}
} While breadth-first traversal tends to be slower and consume more memory than depth-first, it's necessary here to pass unit tests that expect a specific order. I'd prefer to use Note: It appears that the third solution from https://jsbench.me/cvm5h498pk/1 has a bug (I'm not sure about purpose of the I cannot run the fourth solution: But as I can see the depth-first solution is faster than the second solution: Thank you for this article! @willeastcott can I change breadth-first to depth-first? And a unit test that requires breadth-first? I'll try to use an offset instead of |
… tests for them, use the classes in the forEach method of GraphNode
Thanks @querielo - hopefully @mvaligursky can do a review tomorrow. 😄 |
Changing the order of execution in public APIs definitely will break people's project, so we always avoid any breaking changes of public APIs. |
I went, and did various tests for specific mathods of a GraphNode: And the performance execution of findByName and forEach is reduced in some cases, and is statistically insignificant in overall.On my machine (windows, gaming laptop, chome), here are the stats:
Please run the test provided below. Observations:
The complexity introduced by this PR in certain methods is not justified by increased memory usage and lack of performance benefits.It would be best to tackle AABB's issue in a different way, so it does not bring complexity to core methods of GraphNode, or at least it is significantly beneficial to it. Also, there is a behaviour difference on forEach and potentially related methods, where modifications to immediate children during forEach execution with this PR will be different. As it collects an array of children before executing callback on them. So if you delete subsequent entities during forEach, with this PR it will call forEach for already removed entities.Regarding findByName - it constructs a method to test for the property, this can be optimized, by not creating test function and avoiding extra executions. A simple change for that, makes findByName ~12% faster. Here is the test: import { deviceType, rootPath } from 'examples/utils';
import * as pc from 'playcanvas';
const canvas = /** @type {HTMLCanvasElement} */ (document.getElementById('application-canvas'));
window.focus();
const gfxOptions = {
deviceTypes: [deviceType],
glslangUrl: `${rootPath}/static/lib/glslang/glslang.js`,
twgslUrl: `${rootPath}/static/lib/twgsl/twgsl.js`
};
const device = await pc.createGraphicsDevice(canvas, gfxOptions);
device.maxPixelRatio = Math.min(window.devicePixelRatio, 2);
const createOptions = new pc.AppOptions();
createOptions.graphicsDevice = device;
createOptions.componentSystems = [pc.RenderComponentSystem, pc.CameraComponentSystem, pc.LightComponentSystem];
createOptions.resourceHandlers = [pc.TextureHandler, pc.ContainerHandler];
const app = new pc.AppBase(canvas);
app.init(createOptions);
app.start();
// Set the canvas to fill the window and automatically change resolution to be the same as the canvas size
app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW);
app.setCanvasResolution(pc.RESOLUTION_AUTO);
// Ensure canvas is resized when window changes size
const resize = () => app.resizeCanvas();
window.addEventListener('resize', resize);
app.on('destroy', () => {
window.removeEventListener('resize', resize);
});
// create box entity
const box = new pc.Entity('cube');
box.addComponent('render', {
type: 'box'
});
app.root.addChild(box);
// create camera entity
const camera = new pc.Entity('camera');
camera.addComponent('camera', {
clearColor: new pc.Color(0.5, 0.6, 0.9)
});
app.root.addChild(camera);
camera.setPosition(0, 0, 3);
// create directional light entity
const light = new pc.Entity('light');
light.addComponent('light');
app.root.addChild(light);
light.setEulerAngles(45, 0, 0);
const entitySingle = new pc.Entity('single');
app.root.addChild(entitySingle);
const entitySimple16 = new pc.Entity('simple-16');
for(let i = 0; i < 16; i++) {
const child = new pc.Entity(`child-${i}`);
entitySimple16.addChild(child);
}
app.root.addChild(entitySimple16);
const entityComplex = new pc.Entity('complex');
for(let a = 0; a < 32; a++) {
const childA = new pc.Entity(`child-${a}`);
entityComplex.addChild(childA);
for(let b = 0; b < 16; b++) {
const childB = new pc.Entity(`child-${a}-${b}`);
childA.addChild(childB);
for(let c = 0; c < 8; c++) {
const childC = new pc.Entity(`child-${a}-${b}-${c}`);
childB.addChild(childC);
for(let d = 0; d < 4; d++) {
const childD = new pc.Entity(`child-${a}-${b}-${c}-${d}`);
childC.addChild(childD);
}
}
}
}
app.root.addChild(entityComplex);
// rotate the box according to the delta time since the last frame
app.on('update', (/** @type {number} */ dt) => box.rotate(10 * dt, 20 * dt, 30 * dt));
const samplesSingle = 1024 * 1024;
const samplesSimple = 512 * 512;
const samplesComplex = 64 * 64;
let timingSingle = 0;
let timingSingleMiss = 0;
let timingSingleForEach = 0;
let timingSimpleEarly = 0;
let timingSimpleMid = 0;
let timingSimpleLate = 0;
let timingSimpleMiss = 0;
let timingSimpleForEach = 0;
let timingComplexEarly = 0;
let timingComplexDeepEarly = 0;
let timingComplexMid = 0;
let timingComplexDeepMid = 0;
let timingComplexLate = 0;
let timingComplexDeepLate = 0;
let timingComplexMiss = 0;
let timingComplexForEach = 0;
let timingTotal = 0;
// run test only at 10th frame
let frame = 10;
app.on('update', () => {
if (--frame === 0) {
// run test
let time = 0;
let timeStart = performance.now();
// single
time = performance.now();
for(let i = 0; i < samplesSingle; i++) {
entitySingle.findByName('single');
}
timingSingle += performance.now() - time;
// miss
time = performance.now();
for(let i = 0; i < samplesSingle; i++) {
entitySingle.findByName('does-not-exist');
}
timingSingleMiss += performance.now() - time;
// for each
time = performance.now();
for(let i = 0; i < samplesSingle; i++) {
entitySingle.forEach(() => { });
}
timingSingleForEach += performance.now() - time;
// simple
// early
time = performance.now();
for(let i = 0; i < samplesSimple; i++) {
entitySimple16.findByName('child-0');
}
timingSimpleEarly += performance.now() - time;
// mid
time = performance.now();
for(let i = 0; i < samplesSimple; i++) {
entitySimple16.findByName('child-8');
}
timingSimpleMid += performance.now() - time;
// late
time = performance.now();
for(let i = 0; i < samplesSimple; i++) {
entitySimple16.findByName('child-15');
}
timingSimpleLate += performance.now() - time;
// miss
time = performance.now();
for(let i = 0; i < samplesSimple; i++) {
entitySimple16.findByName('does-not-exist');
}
timingSimpleMiss += performance.now() - time;
// for each
time = performance.now();
for(let i = 0; i < samplesSimple; i++) {
entitySimple16.forEach(() => { });
}
timingSimpleForEach += performance.now() - time;
// complex
// early
time = performance.now();
for(let i = 0; i < samplesComplex; i++) {
entityComplex.findByName('child-0');
}
timingComplexEarly += performance.now() - time;
// deep early
time = performance.now();
for(let i = 0; i < samplesComplex; i++) {
entityComplex.findByName('child-0-0-0-0');
}
timingComplexDeepEarly += performance.now() - time;
// mid
time = performance.now();
for(let i = 0; i < samplesComplex; i++) {
entityComplex.findByName('child-16');
}
timingComplexMid += performance.now() - time;
// deep mid
time = performance.now();
for(let i = 0; i < samplesComplex; i++) {
entityComplex.findByName('child-16-8-4-2');
}
timingComplexDeepMid += performance.now() - time;
// late
time = performance.now();
for(let i = 0; i < samplesComplex; i++) {
entityComplex.findByName('child-31');
}
timingComplexLate += performance.now() - time;
// deep late
time = performance.now();
for(let i = 0; i < samplesComplex; i++) {
entityComplex.findByName('child-31-15-7-3');
}
timingComplexDeepLate += performance.now() - time;
// deep late
time = performance.now();
for(let i = 0; i < samplesComplex; i++) {
entityComplex.findByName('does-not-exist');
}
timingComplexMiss += performance.now() - time;
// for each
time = performance.now();
for(let i = 0; i < samplesComplex; i++) {
entityComplex.forEach(() => { });
}
timingComplexForEach += performance.now() - time;
timingTotal = performance.now() - timeStart;
console.log('samples single', samplesSingle);
console.log('timingSingle total', Math.round(timingSingle + timingSingleMiss + timingSingleForEach));
console.log('timingSingle', Math.round(timingSingle));
console.log('timingSingleMiss', Math.round(timingSingleMiss));
console.log('timingSingleForEach', Math.round(timingSingleForEach));
console.log('samples simple', samplesSimple);
console.log('timingSimple total', Math.round(timingSimpleEarly + timingSimpleMid + timingSimpleLate + timingSimpleMiss + timingSimpleForEach));
console.log('timingSimpleEarly', Math.round(timingSimpleEarly));
console.log('timingSimpleMid', Math.round(timingSimpleMid));
console.log('timingSimpleLate', Math.round(timingSimpleLate));
console.log('timingSimpleMiss', Math.round(timingSimpleMiss));
console.log('timingSimpleForEach', Math.round(timingSimpleForEach));
console.log('samples complex', samplesComplex);
console.log('timingComplex total', Math.round(timingComplexEarly + timingComplexDeepEarly + timingComplexMid + timingComplexDeepMid + timingComplexLate + timingComplexDeepLate + timingComplexMiss + timingComplexForEach));
console.log('timingComplexEarly', Math.round(timingComplexEarly));
console.log('timingComplexDeepEarly', Math.round(timingComplexDeepEarly));
console.log('timingComplexMid', Math.round(timingComplexMid));
console.log('timingComplexDeepMid', Math.round(timingComplexDeepMid));
console.log('timingComplexLate', Math.round(timingComplexLate));
console.log('timingComplexDeepLate', Math.round(timingComplexDeepLate));
console.log('timingComplexMiss', Math.round(timingComplexMiss));
console.log('timingComplexForEach', Math.round(timingComplexForEach));
console.log('total', Math.round(timingTotal));
}
});
export { app }; |
@Maksims Yes, I moved the PR to draft because there are several scenarios where we see performance drops. In some synthetic tests, there’s a slowdown, while in others there’s an improvement. I’m still investigating and looking for ways to fix it. I checked the performance in real PlayCanvas projects --- most of them show gains, but some do experience a slowdown and I understand the reasons behind it. I’m looking into possible solutions now. I can separate the AABB and GraphNode changes into a separate PR. |
@querielo It's definitely a great move to isolate incremental improvements into different PRs, yeah. |
Is it possible to keep core GraphNode functions as they are, as they are extremely simple and straight forward currently. Also, it is not clear from PR start what is a current AABB's bottleneck, and what is optimized specifically. Please provide tests and numbers for isolated parts (AABB's only), and some numbers related to them, for example if there are unnecessary AABB's calculations, is it possible to show the number of them, and how many are avoided with this optimisation. And similar numbers, to understand the problem better. If AABB's recalculations require a different way to iterate through hierarchy, then instead of changing existing iteration methods, introduce a specific one for optimized functionality. Also it should be probably in a specific Component, not GraphNode - as it is a very generic an slim class that knows nothing about rendering. |
That depends on whether a meaningful speed up is still achievable. 😄 |
My gut feeling when I had a quick look at this during holidays was that the aabb calculations could be a nice win, but I wasn't sold on big wins based on recursive vs non-recursive walk over the hierarchy. I agree these two are separate optimisations and need separate PRs. Based on one screenshot, I think you're testing this on a large number of characters. By default, characters have a high cost update of aabb, as we make sure it's precise, and the aabbs of bones are transformed by their matrices. This was originally added to model component here, but render component supports it as well, which was added after this. |
@querielo Any progress with the PRs you were planning? |
We got around these issues by using lods and a deferred execution system to reduce the framerate of lower quality lods which mostly solved the performance issue. Is it possible to have a fully gpu skinning? Whatabout skinning and instancing? |
In general yes: https://stoyan3d.wordpress.com/2021/07/23/vertex-animation-texture-vat/
Doable but not done. Are you trying to save draw calls? |
@willeastcott Unfortunately, I had not enough time. Still WIP I experiment with this benchmark. My best result is here:
The test cases measure the performance of different hierarchical structures and operations on them.
p* represents the node at *% of the total hierarchy traversal order, calculated after converting the tree into a linear array. Result: my WIP code solves nearly all of @Maksims' concerns. However, the current implementation (it is not in this PR) is somewhat unrefined and requires further improvement before publishing. Also, it's essential to conduct additional testing to enhance code quality and reliability and expand the suite of unit tests. It is interesting that could'n I believe I should close the pull request until it's ready. :-( But I added this PR about AABB. |
I experimented with placing multiple animated skinned meshes in Playcanvas and Unity 6.1, which is going to support WebGPU to address huge performance issues with skinned meshes.
I understand that it's best to bake animations for skinned meshes instead of using the Anim component when dealing with high number of animated skinned meshes. But still...
The PR proposes replacing the recursive implementation of core GraphNode methods with an iterative approach and avoid recalculating the AABB multiple times during rendering to improve performance of skinned meshes.
Here are my results: