diff --git a/README.md b/README.md index 179d11e..768f839 100644 --- a/README.md +++ b/README.md @@ -48,50 +48,57 @@ cd packages/lesson_001 pnpm run dev ``` -## Lesson 1 +## Lesson 1 - Initialize canvas - A hardware abstraction layer based on WebGL1/2 and WebGPU. - Canvas API design. - Implementing a simple plugin system. - Implementing a rendering plugin based on the hardware abstraction layer. -## Lesson 2 +## Lesson 2 - Draw a circle - Adding shapes to the canvas. - Drawing a circle using SDF. - Anti Aliasing. - Dirty flag design pattern. -## Lesson 3 +## Lesson 3 - Scene graph and transform - Transformations. Make shapes support pan, zoom, rotate, and skew transformations. - Scene graph. -## Lesson 4 +## Lesson 4 - Camera - What is a Camera? - Projection transformation. - Camera transformation. - Camera animation. Using Landmark transition between different camera states. -## Lesson 5 +## Lesson 5 - Grid - Drawing straight lines using Line Geometry or screen-space techniques. - Drawing dots grid. -## Lesson 6 +## Lesson 6 - Event System - Implement an event system compatible with DOM Event API. - How to pick a circle. - Implement a drag-and-drop plugin based on our event system. - Support for pinch zoom gestures. -## Lesson 7 +## Lesson 7 - Web UI - Developing Web UI with Lit and Shoelace - Implementing a canvas component - Implementing a zoom toolbar component +# Lesson 8 - Optimize performance + +- What is a draw call +- Reducing draw calls with culling +- Reducing draw calls by combining batches +- Using spatial indexing to improve pickup efficiency + [infinitecanvas]: https://infinitecanvas.tools/ [Figma]: https://madebyevan.com/figma/building-a-professional-design-tool-on-the-web/ [Modyfi]: https://digest.browsertech.com/archive/browsertech-digest-how-modyfi-is-building-with/ diff --git a/README.zh_CN.md b/README.zh_CN.md index 7d85369..b95c743 100644 --- a/README.zh_CN.md +++ b/README.zh_CN.md @@ -51,49 +51,56 @@ cd packages/lesson_001 pnpm run dev ``` -## 课程 1 +## 课程 1 - 初始化画布 - 基于 WebGL1/2 和 WebGPU 的硬件抽象层 - 画布 API 设计 - 实现一个简单的插件系统 - 基于硬件抽象层实现一个渲染插件 -## 课程 2 +## 课程 2 - 绘制圆 - 向画布中添加图形 - 使用 SDF 绘制一个圆形 - 反走样 -## 课程 3 +## 课程 3 - 变换和场景图 - 变换。让图形支持平移、缩放、旋转、斜切变换。 - 场景图。 -## 课程 4 +## 课程 4 - 相机 - 相机是什么? - 投影变换。 - 相机变换。通过一个插件实现平移、旋转和缩放功能。 - 相机动画。平滑过渡到任意相机状态。 -## 课程 5 +## 课程 5 - 绘制网格 - 绘制直线网格。使用 Line Geometry 或者屏幕空间技术。 - 绘制点网格。 -## 课程 6 +## 课程 6 - 事件系统 - 参考 DOM API 实现事件系统 - 如何拾取一个圆形 - 实现一个拖拽插件 - 支持双指缩放手势 -## 课程 7 +## 课程 7 - Web UI - 使用 Lit 和 Shoelace 开发 Web UI - 实现画布组件,监听页面宽高变换 - 实现缩放组件 +## 课程 8 - 性能优化 + +- 什么是 Draw call +- 使用剔除减少 draw call +- 使用合批减少 draw call +- 使用空间索引提升拾取效率 + [infinitecanvas]: https://infinitecanvas.tools/ [Figma]: https://madebyevan.com/figma/building-a-professional-design-tool-on-the-web/ [Modyfi]: https://digest.browsertech.com/archive/browsertech-digest-how-modyfi-is-building-with/ diff --git a/packages/lesson_008/src/Canvas.ts b/packages/lesson_008/src/Canvas.ts index 9fb74e3..6c5c875 100644 --- a/packages/lesson_008/src/Canvas.ts +++ b/packages/lesson_008/src/Canvas.ts @@ -224,7 +224,7 @@ export class Canvas { } }); // find group with max z-index - hitTestList.sort((a, b) => b.globalRenderOrder - a.globalRenderOrder); + hitTestList.sort((a, b) => a.globalRenderOrder - b.globalRenderOrder); return hitTestList; } diff --git a/packages/lesson_008/src/drawcalls/SDF.ts b/packages/lesson_008/src/drawcalls/SDF.ts index ef8ced9..0a25bb1 100644 --- a/packages/lesson_008/src/drawcalls/SDF.ts +++ b/packages/lesson_008/src/drawcalls/SDF.ts @@ -20,7 +20,7 @@ import { vert, frag } from '../shaders/sdf'; import { paddingMat3 } from '../utils'; export class SDF extends Drawcall { - protected maxInstances = 5000; + // protected maxInstances = 5000; #program: Program; #fragUnitBuffer: Buffer; diff --git a/packages/site/docs/.vitepress/config/en.js b/packages/site/docs/.vitepress/config/en.js index e41acfd..48cda28 100644 --- a/packages/site/docs/.vitepress/config/en.js +++ b/packages/site/docs/.vitepress/config/en.js @@ -92,6 +92,10 @@ export const en = defineConfig({ text: 'Reduce draw calls with instanced array', link: 'instanced', }, + { + text: 'Optimize picking performance with RBush', + link: 'picking', + }, ], }, ], diff --git a/packages/site/docs/.vitepress/config/zh.js b/packages/site/docs/.vitepress/config/zh.js index 08219bb..27af860 100644 --- a/packages/site/docs/.vitepress/config/zh.js +++ b/packages/site/docs/.vitepress/config/zh.js @@ -80,6 +80,10 @@ export const zh = defineConfig({ text: '通过实例化数组减少 draw call', link: 'instanced', }, + { + text: '通过 RBush 加速拾取', + link: 'picking', + }, ], }, ], diff --git a/packages/site/docs/components/Picking.vue b/packages/site/docs/components/Picking.vue new file mode 100644 index 0000000..6615493 --- /dev/null +++ b/packages/site/docs/components/Picking.vue @@ -0,0 +1,70 @@ + + + diff --git a/packages/site/docs/example/picking.md b/packages/site/docs/example/picking.md new file mode 100644 index 0000000..c4a43d6 --- /dev/null +++ b/packages/site/docs/example/picking.md @@ -0,0 +1,11 @@ +--- +--- + +We can enhance picking performance with RBush, see: +Performace optimazation + + + + diff --git a/packages/site/docs/guide/lesson-008.md b/packages/site/docs/guide/lesson-008.md index b2b7c42..4557fa7 100644 --- a/packages/site/docs/guide/lesson-008.md +++ b/packages/site/docs/guide/lesson-008.md @@ -4,272 +4,301 @@ outline: deep # Lesson 8 - Optimize performance -在这节课中你将学习到以下内容: +In this lesson you will learn the following: -- 什么是 Draw call -- 使用 GPU Instancing 提升绘制性能 +- What is a draw call +- Reducing draw calls with culling +- Reducing draw calls by combining batches +- Using spatial indexing to improve pickup efficiency -性能优化是一个复杂而长期的任务,我倾向于在项目早期就开始关注。之前我们学习了如何使用 SDF 绘制圆,现在让我们来做一下性能测试,绘制 1000 个圆 FPS 约为 35: +Performance optimization is a complex and long-term task that I tend to focus on early in a project. Earlier we learned how to draw circles using SDF, now let's do a performance test and draw 1000 circles with an FPS of about 35: ```js eval code=false $icCanvas = call(() => { - return document.createElement('ic-canvas-lesson7'); + return document.createElement('ic-canvas-lesson7'); }); ``` ```js eval code=false inspector=false call(() => { - const { Canvas, Circle } = Lesson7; - - const stats = new Stats(); - stats.showPanel(0); - const $stats = stats.dom; - $stats.style.position = 'absolute'; - $stats.style.left = '0px'; - $stats.style.top = '0px'; - - $icCanvas.parentElement.style.position = 'relative'; - $icCanvas.parentElement.appendChild($stats); - - $icCanvas.addEventListener('ic-ready', (e) => { - const canvas = e.detail; - - for (let i = 0; i < 10; i++) { - const circle = new Circle({ - cx: Math.random() * 400, - cy: Math.random() * 200, - r: Math.random() * 20, - fill: 'red', - }); - canvas.appendChild(circle); - } - }); + const { Canvas, Circle } = Lesson7; + + const stats = new Stats(); + stats.showPanel(0); + const $stats = stats.dom; + $stats.style.position = 'absolute'; + $stats.style.left = '0px'; + $stats.style.top = '0px'; + + $icCanvas.parentElement.style.position = 'relative'; + $icCanvas.parentElement.appendChild($stats); + + $icCanvas.addEventListener('ic-ready', (e) => { + const canvas = e.detail; + + for (let i = 0; i < 100; i++) { + const circle = new Circle({ + cx: Math.random() * 400, + cy: Math.random() * 200, + r: Math.random() * 20, + fill: 'red', + }); + canvas.appendChild(circle); + } + }); - $icCanvas.addEventListener('ic-frame', (e) => { - stats.update(); - }); + $icCanvas.addEventListener('ic-frame', (e) => { + stats.update(); + }); }); ``` -我们使用 [stats.js] 度量 FPS,创建的面板放在画布左上角,显然目前并不流畅: +We're using [stats.js] to measure FPS, creating panels that are placed in the upper left corner of the canvas, which obviously isn't smooth at the moment: ```ts const stats = new Stats(); -stats.showPanel(0); // 仅展示 FPS 面板 +stats.showPanel(0); // Only show FPS panel const animate = () => { - // 触发更新 - if (stats) { - stats.update(); - } - canvas.render(); - requestAnimationFrame(animate); + // Trigger updating + if (stats) { + stats.update(); + } + canvas.render(); + requestAnimationFrame(animate); }; ``` -使用第一节课介绍过的 [Spector.js] 可以看到有大量的绘制命令,我只筛选保留了 `drawElements` 命令,事实上每一个圆都对应着好几条 WebGL 命令(包含创建 Buffer 等等)。 +Using [Spector.js], which was introduced in the first lesson, you can see that there are a large number of drawing commands. I've filtered down to the `drawElements` command, and in fact each circle corresponds to a number of WebGL commands (including the creation of buffers, etc.). ![draw calls in spector.js](/draw-calls.png) -## 什么是 Draw call +## What is a draw call {#draw-call} -这些绘制命令称作 Draw call。下面这张图来自 [Draw calls in a nutshell],解释了为何 Draw call 数量增多时会影响性能。这是由于 Draw call 都是从 CPU 发起调用的,当数量增多时 CPU 准备时间也更长,GPU 虽然渲染快但仍然需要等待,存在大量空闲时间,因此瓶颈在 CPU 上。 +These draw commands are called Draw calls, and the following graphic from [Draw calls in a nutshell] explains why an increase in the number of Draw calls affects performance. This is because all draw calls are initiated from the CPU, and as the number of draw calls increases, the CPU takes longer to prepare for them, and the GPU, while rendering faster, still has to wait and has a lot of idle time, so the bottleneck is on the CPU. ![CPU GPU draw calls](https://miro.medium.com/v2/resize:fit:1400/format:webp/1*EEqn28cbO11QXkyqcoaO7g.jpeg) 那么如何减少 Draw call 呢?通常有两种思路: -- Culling 剔除掉视口外的图形 -- Draw call batching。将多个 Draw call 进行合并 +- Viewport culling. +- Draw call batching. -## Culling +## Viewport culling {#culling} -视口之外的图形是不需要渲染的,下图来自 Unreal [How Culling Works],从上帝视角展示了相机视锥之外被剔除的红色对象,可以看出这将大大减少不必要的 draw call。 +Graphics outside the viewport do not need to be rendered. The image below from Unreal [How Culling Works] shows the culled red objects outside the camera's view cone from a God's perspective, and you can see that this greatly reduces the number of unnecessary draw calls. ![viewfrustum culled](https://d1iv7db44yhgxn.cloudfront.net/documentation/images/6f2a0e24-c0e0-4fc0-b637-29c792739474/sceneview_viewfrustumculled.png) -在 3D 场景中有很多基于相机视锥剔除算法的优化手段,[Efficient View Frustum Culling] 中就介绍了多种方法,例如可以利用场景图信息,如果一个图形已经完全处于视锥体内部,其子节点也就不需要检测了。 +In 3D scenes there are many optimizations based on the camera view cone culling algorithm, as described in [Efficient View Frustum Culling], e.g., scene graph information can be used so that if a graph is already completely inside the view cone, its child nodes do not need to be detected. -渲染引擎都会提供相应的功能,例如: +Rendering engines are provided with corresponding features, for example: -- Cesium [Fast Hierarchical Culling] -- Babylon.js [Changing Mesh Culling Strategy] -- [pixi-cull] +- Cesium [Fast Hierarchical Culling] +- Babylon.js [Changing Mesh Culling Strategy] +- [pixi-cull] -相比 3D 场景,我们的 2D 画布实现起来会简单很多。那么如何判断一个图形是否在视口内呢?这就需要引入包围盒的概念。 +Compared to a 3D scene, our 2D canvas is much simpler to implement. So how do you determine if a shape is in the viewport? This is where the concept of a bounding box comes in. Of course, you don't have to use a bounding box, you can use a bounding sphere instead in a 3D scene. -### 包围盒 +### Axis-Aligned Bounding Box {#aabb} -轴对齐包围盒(Axis-Aligned Bounding Box,简称 AABB)是一种在三维图形学中常用的简单包围盒,它与世界坐标系的轴平行。换句话说,它的边与坐标轴的方向一致。轴对齐包围盒通常是、长方体,其用途是将一个物体或一组物体在空间中占据的区域用一个简化的盒子来表示。 +An Axis-Aligned Bounding Box, or AABB for short, is a simple bounding box commonly used in 3D graphics that is parallel to the axes of the world coordinate system. In other words, its sides are oriented in the same direction as the coordinate axes. An axis-aligned bounding box is usually rectangular, and its purpose is to represent the area in space occupied by an object or group of objects as a simplified box. In our 2D scene it is a rectangle, and we only need to store its top-left `minX/Y` and bottom-right `maxX/Y` coordinates: ```ts export class AABB { - minX: number; - minY: number; - maxX: number; - maxY: number; - matrix: Matrix; - - isEmpty() { - return this.minX > this.maxX || this.minY > this.maxY; - } + minX: number; + minY: number; + maxX: number; + maxY: number; + matrix: Matrix; + + isEmpty() { + return this.minX > this.maxX || this.minY > this.maxY; + } } ``` -接下来为图形增加获取包围盒的方法。 +Next add a way to get the enclosing box for the graph. Take a circle as an example and take the center, radius and line width into account. Also here [Dirty flag] is used so that recalculation is done only when the relevant attributes are changed: -### 增加剔除插件 +```ts +export class Circle extends Shape { + getRenderBounds() { + if (this.renderBoundsDirtyFlag) { + const halfLineWidth = this.#strokeWidth / 2; + this.renderBoundsDirtyFlag = false; + this.renderBounds = new AABB( + this.#cx - this.#r - halfLineWidth, + this.#cy - this.#r - halfLineWidth, + this.#cx + this.#r + halfLineWidth, + this.#cy + this.#r + halfLineWidth, + ); + } + return this.renderBounds; + } +} +``` -增加一个剔除插件,保存视口对应的包围盒,后续和每个图形的包围盒进行求交。考虑到相机变换,我们获取视口四个顶点在世界坐标系下的坐标,用一个包围盒框住它们。每次相机发生变化时更新这个包围盒。 +### Add a culling plugin {#culling-plugin} + +Add a culling plugin that saves the viewport's corresponding enclosing box, and subsequently intersects with each graph's enclosing box. Considering the camera transformation, we get the coordinates of the four vertices of the viewport in the world coordinate system and frame them with a bounding box. The bounding box is updated every time the camera changes. ```ts export class Culling implements Plugin { - #viewport: AABB = new AABB(); - - private updateViewport() { - const { - camera, - api: { viewport2Canvas }, - } = this.#context; - const { width, height } = camera; - - // tl, tr, br, bl - const tl = viewport2Canvas({ - x: 0, - y: 0, - }); - - this.#viewport.minX = Math.min(tl.x, tr.x, br.x, bl.x); - this.#viewport.minY = Math.min(tl.y, tr.y, br.y, bl.y); - this.#viewport.maxX = Math.max(tl.x, tr.x, br.x, bl.x); - this.#viewport.maxY = Math.max(tl.y, tr.y, br.y, bl.y); - } + #viewport: AABB = new AABB(); + + private updateViewport() { + const { + camera, + api: { viewport2Canvas }, + } = this.#context; + const { width, height } = camera; + + // tl, tr, br, bl + const tl = viewport2Canvas({ + x: 0, + y: 0, + }); + + this.#viewport.minX = Math.min(tl.x, tr.x, br.x, bl.x); + this.#viewport.minY = Math.min(tl.y, tr.y, br.y, bl.y); + this.#viewport.maxX = Math.max(tl.x, tr.x, br.x, bl.x); + this.#viewport.maxY = Math.max(tl.y, tr.y, br.y, bl.y); + } } ``` -在每一帧开始时遍历场景图,判断图形包围盒是否和视口相交,如果不相交设置 `culled` 为 `true`,这样在渲染时就可以跳过: +Iterate through the scene graph at the start of each frame to determine if the graphics enclosing box intersects the viewport, and if it doesn't set `culled` to `true` so that it can be skipped during rendering: ```ts hooks.beginFrame.tap(() => { - const { minX, minY, maxX, maxY } = this.#viewport; - - traverse(root, (shape) => { - if (shape.renderable && shape.cullable) { - const bounds = shape.getBounds(); - shape.culled = - bounds.minX >= maxX || - bounds.minY >= maxY || - bounds.maxX <= minX || - bounds.maxY <= minY; - } - - return shape.culled; - }); + const { minX, minY, maxX, maxY } = this.#viewport; + + traverse(root, (shape) => { + if (shape.renderable && shape.cullable) { + const bounds = shape.getBounds(); + shape.culled = + bounds.minX >= maxX || + bounds.minY >= maxY || + bounds.maxX <= minX || + bounds.maxY <= minY; + } + + return shape.culled; + }); }); ``` -来看下效果,缩放时被剔除图形数量也会随之变化: +To see the effect, the number of rejected graphics changes as you zoom in and out, and the more graphics that are rejected, the higher the FPS: ```js eval code=false $total = call(() => { - return document.createElement('div'); + return document.createElement('div'); }); ``` ```js eval code=false $culled = call(() => { - return document.createElement('div'); + return document.createElement('div'); }); ``` ```js eval code=false $icCanvas2 = call(() => { - return document.createElement('ic-canvas-lesson8'); + return document.createElement('ic-canvas-lesson8'); }); ``` ```js eval code=false inspector=false call(() => { - const { Canvas, Circle } = Lesson8; - - const stats = new Stats(); - stats.showPanel(0); - const $stats = stats.dom; - $stats.style.position = 'absolute'; - $stats.style.left = '0px'; - $stats.style.top = '0px'; - - $icCanvas2.parentElement.style.position = 'relative'; - $icCanvas2.parentElement.appendChild($stats); - - const circles = []; - $icCanvas2.addEventListener('ic-ready', (e) => { - const canvas = e.detail; - - for (let i = 0; i < 100; i++) { - const circle = new Circle({ - cx: Math.random() * 1000, - cy: Math.random() * 1000, - // cx: Math.random() * 0, - // cy: Math.random() * 0, - r: Math.random() * 20, - fill: `rgb(${Math.floor(Math.random() * 255)},${Math.floor( - Math.random() * 255, - )},${Math.floor(Math.random() * 255)})`, - }); - canvas.appendChild(circle); - circles.push(circle); - } - }); + const { Canvas, Circle } = Lesson8; + + const stats = new Stats(); + stats.showPanel(0); + const $stats = stats.dom; + $stats.style.position = 'absolute'; + $stats.style.left = '0px'; + $stats.style.top = '0px'; + + $icCanvas2.parentElement.style.position = 'relative'; + $icCanvas2.parentElement.appendChild($stats); + + const circles = []; + $icCanvas2.addEventListener('ic-ready', (e) => { + const canvas = e.detail; + + for (let i = 0; i < 500; i++) { + const circle = new Circle({ + cx: Math.random() * 1000, + cy: Math.random() * 1000, + r: Math.random() * 20, + fill: `rgb(${Math.floor(Math.random() * 255)},${Math.floor( + Math.random() * 255, + )},${Math.floor(Math.random() * 255)})`, + batchable: false, + // cullable: false, + }); + canvas.appendChild(circle); + circles.push(circle); + } + }); - $icCanvas2.addEventListener('ic-frame', (e) => { - stats.update(); - const total = circles.length; - const culled = circles.filter((circle) => circle.culled).length; + $icCanvas2.addEventListener('ic-frame', (e) => { + stats.update(); + const total = circles.length; + const culled = circles.filter((circle) => circle.culled).length; - $total.innerHTML = `total: ${total}`; - $culled.innerHTML = `culled: ${culled}`; - }); + $total.innerHTML = `total: ${total}`; + $culled.innerHTML = `culled: ${culled}`; + }); }); ``` -当视口包含所有图形时,任何图形都没法剔除,此时我们得使用其他手段减少 draw call。 +When the viewport contains all the shapes, there is no way to eliminate any of the shapes, so we have to use other means to reduce the draw calls. -## Draw call batching +## Batch rendering {#batch-rendering} -可以合并的 Draw call 是需要满足一定条件的,例如拥有相似的 Geometry,相同的 Shader 等。[Draw call batching - Unity] 提供了两种方式: +Draw calls that can be merged require certain conditions to be met, such as having similar Geometry, same Shader, etc. [Draw call batching - Unity] provides two ways to do this: -- [Static batching] 适用于静止不动的物体,将它们转换到世界坐标系下,使用共享的顶点数组。完成后就不能对单个物体应用变换了。 -- [Dynamic batching] 适用于运动的物体。在 CPU 侧将顶点转换到世界坐标系下,但转换本身也有开销。 +- [Static batching] For stationary objects, transform them to the world coordinate system, using a shared vertex array. Once done, transformations cannot be applied to individual objects. +- [Dynamic batching] For moving objects. Transforms vertices to the world coordinate system on the CPU side, but the transformation itself has overhead. -Pixi.js 也内置了一个合批渲染系统:[Inside PixiJS: Batch Rendering System] +Pixi.js also has a built-in batch rendering system, which has been used until the V8 version currently in development: [Inside PixiJS: Batch Rendering System]. -首先我们将渲染逻辑从图形中分离出来,这也是合理的,图形不应该关心自身如何被渲染: +First we separate the rendering logic from the graphics, which makes sense; the graphics shouldn't care about how they are rendered: ```ts class Circle { - render(device: Device, renderPass: RenderPass, uniformBuffer: Buffer) {} // [!code --] + render(device: Device, renderPass: RenderPass, uniformBuffer: Buffer) {} // [!code --] } ``` -之前在遍历场景图时,我们会立刻触发图形的渲染逻辑,但现在将图形加入待渲染队列,等待合并后一起输出: +Previously, when traversing the scene graph, we would immediately trigger the rendering logic for the graphs, but now the graphs are added to the pending render queue and wait to be merged and output together: ```ts hooks.render.tap((shape) => { - shape.render(); !code --] - if (shape.renderable) { - this.#batchManager.add(shape); // [!code ++] - } + shape.render(); // [!code --] + if (shape.renderable) { + this.#batchManager.add(shape); // [!code ++] + } }); ``` -### Instanced +Then we abstract the Drawcall class and let the previously implemented SDF inherit it. It contains the following life cycle: + +```ts +export abstract class Drawcall { + abstract createGeometry(): void; + abstract createMaterial(uniformBuffer: Buffer): void; + abstract render(renderPass: RenderPass): void; + abstract destroy(): void; +} -[WebGL2 Optimization - Instanced Drawing] +export class SDF extends Drawcall {} +``` -在 Three.js 中称作 [InstancedMesh] +### Instanced -Babylon.js 中也提供了 [Instances] +For large numbers of similar shapes, [WebGL2 Optimization - Instanced Drawing] can significantly reduce the number of draw calls. This is called [InstancedMesh] in Three.js. Babylon.js also provides [Instances], where per-instance-specific attributes such as transformation matrices, colors, etc. can be passed in as vertex arrays: ![instances node](https://doc.babylonjs.com/img/how_to/instances-node.png) @@ -284,7 +313,7 @@ Babylon.js 中也提供了 [Instances] #endif ``` -由于我们只考虑 2D 场景,变换矩阵只需要存储 6 个分量: +Since we only consider 2D scenes, the transformation matrix only needs to store 6 components: ```glsl #ifdef USE_INSTANCES @@ -295,24 +324,251 @@ Babylon.js 中也提供了 [Instances] #endif ``` -另外也可以将各个实例的变换矩阵存储在数据纹理中,通过索引引用: +It is worth mentioning that the transformation matrices of individual instances can be stored in the data texture in addition to being stored directly in the vertex data, referenced by indexes in the vertex array: [Drawing Many different models in a single draw call] -### Batching +We add a flag `instanced` for Drawcall: + +```ts +export abstract class Drawcall { + constructor(protected device: Device, protected instanced: boolean) {} +} +``` + +Adds a `define` precompile directive to the shader header based on this flag: + +```ts{5} +export class SDF extends Drawcall { + createMaterial(uniformBuffer: Buffer): void { + let defines = ''; + if (this.instanced) { + defines += '#define USE_INSTANCES\n'; + } + } +} +``` + +This way the model transformation matrix can be computed from the attribute or uniform: + +```glsl +void main() { + mat3 model; + #ifdef USE_INSTANCES + model = mat3(a_Abcd.x, a_Abcd.y, 0, a_Abcd.z, a_Abcd.w, 0, a_Txty.x, a_Txty.y, 1); + #else + model = u_ModelMatrix; + #endif +} +``` + +At this point you can use [Spector.js] to see that even if there are 1000 circles in the viewport, it can be done with a single draw call: + +![instanced draw calls in spector.js](/instanced-spector.png) + +We can also limit the maximum number of instances in a Drawcall by checking it before each creation and recreating it if it is exceeded: + +```ts +export abstract class Drawcall { + protected maxInstances = Infinity; + validate() { + return this.count() <= this.maxInstances - 1; + } +} +``` + +### Rendering order {#rendering-order} + +Since we're drawing multiple shapes with a single draw call, we need to pay attention to the drawing order. The position of each element in the instances array does not necessarily equate to the final draw order, so we need to assign a value to each figure before we actually draw it, and then normalize that value to `[0, 1]` and pass it into the shader as the depth value: + +```ts{4} +export class Renderer implements Plugin { + apply(context: PluginContext) { + hooks.render.tap((shape) => { + shape.globalRenderOrder = this.#zIndexCounter++; + }); + + hooks.beginFrame.tap(() => { + this.#zIndexCounter = 0; + }); + } +} +``` + +Then we turn on [Depth testing], and the test method is changed to: Depth values greater than the current Depth buffer storage value (in the range `[0, 1]`) will be written (WebGL defaults to `gl.LESS`). In our scenario the graphics with the larger ZIndex will overwrite the smaller ones. + +```ts +export class SDF extends Drawcall { + createMaterial(uniformBuffer: Buffer): void { + this.#pipeline = this.device.createRenderPipeline({ + megaStateDescriptor: { + depthWrite: true, // [!code ++] + depthCompare: CompareFunction.GREATER, // [!code ++] + }, + }); + } +} +``` + +Finally an additional Depth RenderTarget is created when the RenderPass is created: + +```ts +this.#renderPass = this.#device.createRenderPass({ + colorAttachment: [this.#renderTarget], + colorResolveTo: [onscreenTexture], + colorClearColor: [TransparentWhite], + depthStencilAttachment: this.#depthRenderTarget, // [!code ++] + depthClearValue: 1, // [!code ++] +}); +``` + +See how it works, drawing 5000 circles with culling and batch optimization turned on at the same time: + +```js eval code=false +$icCanvas3 = call(() => { + return document.createElement('ic-canvas-lesson8'); +}); +``` + +```js eval code=false inspector=false +call(() => { + const { Canvas, Circle } = Lesson8; + + const stats = new Stats(); + stats.showPanel(0); + const $stats = stats.dom; + $stats.style.position = 'absolute'; + $stats.style.left = '0px'; + $stats.style.top = '0px'; + + $icCanvas3.parentElement.style.position = 'relative'; + $icCanvas3.parentElement.appendChild($stats); + + const circles = []; + $icCanvas3.addEventListener('ic-ready', (e) => { + const canvas = e.detail; + + for (let i = 0; i < 5000; i++) { + const circle = new Circle({ + cx: Math.random() * 1000, + cy: Math.random() * 1000, + r: Math.random() * 20, + fill: `rgb(${Math.floor(Math.random() * 255)},${Math.floor( + Math.random() * 255, + )},${Math.floor(Math.random() * 255)})`, + batchable: true, + cullable: true, + }); + canvas.appendChild(circle); + circles.push(circle); + } + }); + + $icCanvas3.addEventListener('ic-frame', (e) => { + stats.update(); + }); +}); +``` + +## Optimizing picking performance {#optimizing-picking-perf} + +Next, we'll measure the pickup performance by adding a pointerenter/leave event listener to each Circle: + +```ts +circle.addEventListener('pointerenter', () => { + circle.fill = 'red'; +}); +circle.addEventListener('pointerleave', () => { + circle.fill = fill; +}); +``` -Three.js [BatchedMesh: Proposal] +20000 Circle pickups took the following time: -## 优化拾取性能 +![pick perf](/pick-perf.png) -首先想到为图形增加包围盒,相比数学方法可以进行更快速的近似判断。后续在基于视口的剔除时还会使用到。 +AABB for graphs can be used for viewport-based culling, which allows for quicker approximation judgments compared to mathematical methods. + +### Using spatial indexing {#using-spatial-indexing} + +A Spatial Index is a data structure used for efficient handling of spatial data and query operations, especially in Geographic Information Systems (GIS), computer graphics, 3D game development and database technology. The main purpose of spatial indexing is to reduce the amount of computation and time required to search for specific spatial objects in large amounts of data. There are various data structures for spatial indexing, such as Quadtree, Octree, R-tree, K-d tree, etc. Each structure has its specific application scenarios and advantages. + +In the PIXI.js ecosystem there are libraries like [pixi-spatial-hash] that create new spatial indexes in each frame. However, there seems to be a lack of maintenance at the moment. + +We use [rbush], which supports batch insertion, which is usually 2-3 times faster than inserting frame-by-frame, and is also used in mapbox. + +```ts +import RBush from 'rbush'; +const rBushRoot = new RBush(); + +export interface RBushNodeAABB { + shape: Shape; + minX: number; + minY: number; + maxX: number; + maxY: number; +} +``` + +### RBush search {#rbush-search} + +RBush provides a region query function [search], which is passed a query box to return a list of hit graphs: + +```ts +export class Canvas { + elementsFromBBox( + minX: number, + minY: number, + maxX: number, + maxY: number, + ): Shape[] { + const { rBushRoot } = this.#pluginContext; + const rBushNodes = rBushRoot.search({ minX, minY, maxX, maxY }); + + const hitTestList: Shape[] = []; + rBushNodes.forEach(({ shape }) => { + // Omit the process of handling the visibility and pointerEvents properties of shape. + }); + // Sort by global render order. + hitTestList.sort((a, b) => a.globalRenderOrder - b.globalRenderOrder); + + return hitTestList; + } +} +``` + +In [picking plugin] we use the above region lookup method, but of course a point is passed in instead of an AABB: + +```ts{9} +export class Picker implements Plugin { + apply(context: PluginContext) { + hooks.pickSync.tap((result: PickingResult) => { + const { + position: { x, y }, + } = result; + + const picked: Shape[] = [root]; + elementsFromBBox(x, y, x, y).forEach((shape) => { + if (this.hitTest(shape, x, y)) { + picked.unshift(shape); + } + }); + result.picked = picked; + return result; + }); + } +} +``` -### 使用空间索引加速 +Let's re-measure that, 20,000 Circle picking time becomes 0.088ms, an improvement of about 20 times! -空间索引(Spatial Index)是一种数据结构,用于高效地处理空间数据和查询操作,特别是在地理信息系统(GIS)、计算机图形学、三维游戏开发和数据库技术中。空间索引的主要目的是减少在大量数据中搜索特定空间对象所需的计算量和时间。空间索引有多种数据结构,如四叉树(Quadtree)、八叉树(Octree)、R 树(R-tree)、K-d 树(K-dimensional tree)等,每种结构都有其特定的应用场景和优势。 +![pick perf with rbush](/pick-rbush-perf.png) -在 PIXI.js 的生态中有 [pixi-spatial-hash] 这样的库,在每一帧中创建新的空间索引。但目前似乎缺少维护。 +## Extended reading {#extended-reading} -我们使用 [rbush],它支持批量插入,通常比逐个插入要快 2-3 倍,在 mapbox 中也有应用。 +- [Inside PixiJS: Batch Rendering System] +- [Depth testing] +- [The Depth Texture | WebGPU] +- Three.js [BatchedMesh: Proposal] [stats.js]: https://github.com/mrdoob/stats.js [Spector.js]: https://spector.babylonjs.com/ @@ -333,3 +589,8 @@ Three.js [BatchedMesh: Proposal] [WebGL2 Optimization - Instanced Drawing]: https://webgl2fundamentals.org/webgl/lessons/webgl-instanced-drawing.html [Drawing Many different models in a single draw call]: https://webglfundamentals.org/webgl/lessons/webgl-qna-drawing-many-different-models-in-a-single-draw-call.html [Instances]: https://doc.babylonjs.com/features/featuresDeepDive/mesh/copies/instances +[Dirty flag]: /guide/lesson-002#dirty-flag +[Depth testing]: https://learnopengl.com/Advanced-OpenGL/Depth-testing +[The Depth Texture | WebGPU]: https://carmencincotti.com/2022-06-13/webgpu-the-depth-texture/ +[picking plugin]: /guide/lesson-006#picking-plugin +[search]: https://github.com/mourner/rbush?tab=readme-ov-file#search diff --git a/packages/site/docs/zh/example/picking.md b/packages/site/docs/zh/example/picking.md new file mode 100644 index 0000000..7b4976e --- /dev/null +++ b/packages/site/docs/zh/example/picking.md @@ -0,0 +1,10 @@ +--- +--- + +我们可以使用 RBush 加速拾取,详见:性能优化 + + + + diff --git a/packages/site/docs/zh/guide/lesson-008.md b/packages/site/docs/zh/guide/lesson-008.md index 921d831..5f4065b 100644 --- a/packages/site/docs/zh/guide/lesson-008.md +++ b/packages/site/docs/zh/guide/lesson-008.md @@ -6,50 +6,50 @@ outline: deep 在这节课中你将学习到以下内容: -- 什么是 Draw call -- 使用剔除减少 draw call -- 使用合批减少 draw call -- 使用空间索引提升拾取效率 +- 什么是 Draw call +- 使用剔除减少 draw call +- 使用合批减少 draw call +- 使用空间索引提升拾取效率 性能优化是一个复杂而长期的任务,我倾向于在项目早期就开始关注。之前我们学习了如何使用 SDF 绘制圆,现在让我们来做一下性能测试,绘制 1000 个圆 FPS 约为 35: ```js eval code=false $icCanvas = call(() => { - return document.createElement('ic-canvas-lesson7'); + return document.createElement('ic-canvas-lesson7'); }); ``` ```js eval code=false inspector=false call(() => { - const { Canvas, Circle } = Lesson7; - - const stats = new Stats(); - stats.showPanel(0); - const $stats = stats.dom; - $stats.style.position = 'absolute'; - $stats.style.left = '0px'; - $stats.style.top = '0px'; - - $icCanvas.parentElement.style.position = 'relative'; - $icCanvas.parentElement.appendChild($stats); - - $icCanvas.addEventListener('ic-ready', (e) => { - const canvas = e.detail; - - for (let i = 0; i < 10; i++) { - const circle = new Circle({ - cx: Math.random() * 400, - cy: Math.random() * 200, - r: Math.random() * 20, - fill: 'red', - }); - canvas.appendChild(circle); - } - }); + const { Canvas, Circle } = Lesson7; + + const stats = new Stats(); + stats.showPanel(0); + const $stats = stats.dom; + $stats.style.position = 'absolute'; + $stats.style.left = '0px'; + $stats.style.top = '0px'; + + $icCanvas.parentElement.style.position = 'relative'; + $icCanvas.parentElement.appendChild($stats); + + $icCanvas.addEventListener('ic-ready', (e) => { + const canvas = e.detail; + + for (let i = 0; i < 100; i++) { + const circle = new Circle({ + cx: Math.random() * 400, + cy: Math.random() * 200, + r: Math.random() * 20, + fill: 'red', + }); + canvas.appendChild(circle); + } + }); - $icCanvas.addEventListener('ic-frame', (e) => { - stats.update(); - }); + $icCanvas.addEventListener('ic-frame', (e) => { + stats.update(); + }); }); ``` @@ -60,12 +60,12 @@ const stats = new Stats(); stats.showPanel(0); // 仅展示 FPS 面板 const animate = () => { - // 触发更新 - if (stats) { - stats.update(); - } - canvas.render(); - requestAnimationFrame(animate); + // 触发更新 + if (stats) { + stats.update(); + } + canvas.render(); + requestAnimationFrame(animate); }; ``` @@ -81,8 +81,8 @@ const animate = () => { 那么如何减少 Draw call 呢?通常有两种思路: -- Culling 剔除掉视口外的图形 -- Draw call batching。将多个 Draw call 进行合并 +- Culling 剔除掉视口外的图形 +- Draw call batching。将多个 Draw call 进行合并 ## 剔除 {#culling} @@ -94,9 +94,9 @@ const animate = () => { 渲染引擎都会提供相应的功能,例如: -- Cesium [Fast Hierarchical Culling] -- Babylon.js [Changing Mesh Culling Strategy] -- [pixi-cull] +- Cesium [Fast Hierarchical Culling] +- Babylon.js [Changing Mesh Culling Strategy] +- [pixi-cull] 相比 3D 场景,我们的 2D 画布实现起来会简单很多。那么如何判断一个图形是否在视口内呢?这就需要引入包围盒的概念。当然不一定非要使用包围盒,3D 场景中也可以用包围球代替。 @@ -106,15 +106,15 @@ const animate = () => { ```ts export class AABB { - minX: number; - minY: number; - maxX: number; - maxY: number; - matrix: Matrix; - - isEmpty() { - return this.minX > this.maxX || this.minY > this.maxY; - } + minX: number; + minY: number; + maxX: number; + maxY: number; + matrix: Matrix; + + isEmpty() { + return this.minX > this.maxX || this.minY > this.maxY; + } } ``` @@ -122,19 +122,19 @@ export class AABB { ```ts export class Circle extends Shape { - getRenderBounds() { - if (this.renderBoundsDirtyFlag) { - const halfLineWidth = this.#strokeWidth / 2; - this.renderBoundsDirtyFlag = false; - this.renderBounds = new AABB( - this.#cx - this.#r - halfLineWidth, - this.#cy - this.#r - halfLineWidth, - this.#cx + this.#r + halfLineWidth, - this.#cy + this.#r + halfLineWidth, - ); + getRenderBounds() { + if (this.renderBoundsDirtyFlag) { + const halfLineWidth = this.#strokeWidth / 2; + this.renderBoundsDirtyFlag = false; + this.renderBounds = new AABB( + this.#cx - this.#r - halfLineWidth, + this.#cy - this.#r - halfLineWidth, + this.#cx + this.#r + halfLineWidth, + this.#cy + this.#r + halfLineWidth, + ); + } + return this.renderBounds; } - return this.renderBounds; - } } ``` @@ -144,26 +144,26 @@ export class Circle extends Shape { ```ts export class Culling implements Plugin { - #viewport: AABB = new AABB(); - - private updateViewport() { - const { - camera, - api: { viewport2Canvas }, - } = this.#context; - const { width, height } = camera; - - // tl, tr, br, bl - const tl = viewport2Canvas({ - x: 0, - y: 0, - }); - - this.#viewport.minX = Math.min(tl.x, tr.x, br.x, bl.x); - this.#viewport.minY = Math.min(tl.y, tr.y, br.y, bl.y); - this.#viewport.maxX = Math.max(tl.x, tr.x, br.x, bl.x); - this.#viewport.maxY = Math.max(tl.y, tr.y, br.y, bl.y); - } + #viewport: AABB = new AABB(); + + private updateViewport() { + const { + camera, + api: { viewport2Canvas }, + } = this.#context; + const { width, height } = camera; + + // tl, tr, br, bl + const tl = viewport2Canvas({ + x: 0, + y: 0, + }); + + this.#viewport.minX = Math.min(tl.x, tr.x, br.x, bl.x); + this.#viewport.minY = Math.min(tl.y, tr.y, br.y, bl.y); + this.#viewport.maxX = Math.max(tl.x, tr.x, br.x, bl.x); + this.#viewport.maxY = Math.max(tl.y, tr.y, br.y, bl.y); + } } ``` @@ -171,20 +171,20 @@ export class Culling implements Plugin { ```ts hooks.beginFrame.tap(() => { - const { minX, minY, maxX, maxY } = this.#viewport; - - traverse(root, (shape) => { - if (shape.renderable && shape.cullable) { - const bounds = shape.getBounds(); - shape.culled = - bounds.minX >= maxX || - bounds.minY >= maxY || - bounds.maxX <= minX || - bounds.maxY <= minY; - } + const { minX, minY, maxX, maxY } = this.#viewport; + + traverse(root, (shape) => { + if (shape.renderable && shape.cullable) { + const bounds = shape.getBounds(); + shape.culled = + bounds.minX >= maxX || + bounds.minY >= maxY || + bounds.maxX <= minX || + bounds.maxY <= minY; + } - return shape.culled; - }); + return shape.culled; + }); }); ``` @@ -192,64 +192,64 @@ hooks.beginFrame.tap(() => { ```js eval code=false $total = call(() => { - return document.createElement('div'); + return document.createElement('div'); }); ``` ```js eval code=false $culled = call(() => { - return document.createElement('div'); + return document.createElement('div'); }); ``` ```js eval code=false $icCanvas2 = call(() => { - return document.createElement('ic-canvas-lesson8'); + return document.createElement('ic-canvas-lesson8'); }); ``` ```js eval code=false inspector=false call(() => { - const { Canvas, Circle } = Lesson8; - - const stats = new Stats(); - stats.showPanel(0); - const $stats = stats.dom; - $stats.style.position = 'absolute'; - $stats.style.left = '0px'; - $stats.style.top = '0px'; - - $icCanvas2.parentElement.style.position = 'relative'; - $icCanvas2.parentElement.appendChild($stats); - - const circles = []; - $icCanvas2.addEventListener('ic-ready', (e) => { - const canvas = e.detail; - - for (let i = 0; i < 500; i++) { - const circle = new Circle({ - cx: Math.random() * 1000, - cy: Math.random() * 1000, - r: Math.random() * 20, - fill: `rgb(${Math.floor(Math.random() * 255)},${Math.floor( - Math.random() * 255, - )},${Math.floor(Math.random() * 255)})`, - batchable: false, - // cullable: false, - }); - canvas.appendChild(circle); - circles.push(circle); - } - }); + const { Canvas, Circle } = Lesson8; + + const stats = new Stats(); + stats.showPanel(0); + const $stats = stats.dom; + $stats.style.position = 'absolute'; + $stats.style.left = '0px'; + $stats.style.top = '0px'; + + $icCanvas2.parentElement.style.position = 'relative'; + $icCanvas2.parentElement.appendChild($stats); + + const circles = []; + $icCanvas2.addEventListener('ic-ready', (e) => { + const canvas = e.detail; + + for (let i = 0; i < 500; i++) { + const circle = new Circle({ + cx: Math.random() * 1000, + cy: Math.random() * 1000, + r: Math.random() * 20, + fill: `rgb(${Math.floor(Math.random() * 255)},${Math.floor( + Math.random() * 255, + )},${Math.floor(Math.random() * 255)})`, + batchable: false, + // cullable: false, + }); + canvas.appendChild(circle); + circles.push(circle); + } + }); - $icCanvas2.addEventListener('ic-frame', (e) => { - stats.update(); - const total = circles.length; - const culled = circles.filter((circle) => circle.culled).length; + $icCanvas2.addEventListener('ic-frame', (e) => { + stats.update(); + const total = circles.length; + const culled = circles.filter((circle) => circle.culled).length; - $total.innerHTML = `total: ${total}`; - $culled.innerHTML = `culled: ${culled}`; - }); + $total.innerHTML = `total: ${total}`; + $culled.innerHTML = `culled: ${culled}`; + }); }); ``` @@ -259,8 +259,8 @@ call(() => { 可以合并的 Draw call 是需要满足一定条件的,例如拥有相似的 Geometry,相同的 Shader 等。[Draw call batching - Unity] 提供了两种方式: -- [Static batching] 适用于静止不动的物体,将它们转换到世界坐标系下,使用共享的顶点数组。完成后就不能对单个物体应用变换了。 -- [Dynamic batching] 适用于运动的物体。在 CPU 侧将顶点转换到世界坐标系下,但转换本身也有开销。 +- [Static batching] 适用于静止不动的物体,将它们转换到世界坐标系下,使用共享的顶点数组。完成后就不能对单个物体应用变换了。 +- [Dynamic batching] 适用于运动的物体。在 CPU 侧将顶点转换到世界坐标系下,但转换本身也有开销。 Pixi.js 也内置了一个合批渲染系统,一直沿用到目前开发中的 V8 版本:[Inside PixiJS: Batch Rendering System] @@ -268,7 +268,7 @@ Pixi.js 也内置了一个合批渲染系统,一直沿用到目前开发中的 ```ts class Circle { - render(device: Device, renderPass: RenderPass, uniformBuffer: Buffer) {} // [!code --] + render(device: Device, renderPass: RenderPass, uniformBuffer: Buffer) {} // [!code --] } ``` @@ -276,10 +276,10 @@ class Circle { ```ts hooks.render.tap((shape) => { - shape.render(); // [!code --] - if (shape.renderable) { - this.#batchManager.add(shape); // [!code ++] - } + shape.render(); // [!code --] + if (shape.renderable) { + this.#batchManager.add(shape); // [!code ++] + } }); ``` @@ -287,10 +287,10 @@ hooks.render.tap((shape) => { ```ts export abstract class Drawcall { - abstract createGeometry(): void; - abstract createMaterial(uniformBuffer: Buffer): void; - abstract render(renderPass: RenderPass): void; - abstract destroy(): void; + abstract createGeometry(): void; + abstract createMaterial(uniformBuffer: Buffer): void; + abstract render(renderPass: RenderPass): void; + abstract destroy(): void; } export class SDF extends Drawcall {} @@ -331,7 +331,7 @@ export class SDF extends Drawcall {} ```ts export abstract class Drawcall { - constructor(protected device: Device, protected instanced: boolean) {} + constructor(protected device: Device, protected instanced: boolean) {} } ``` @@ -369,10 +369,10 @@ void main() { ```ts export abstract class Drawcall { - protected maxInstances = Infinity; - validate() { - return this.count() <= this.maxInstances - 1; - } + protected maxInstances = Infinity; + validate() { + return this.count() <= this.maxInstances - 1; + } } ``` @@ -398,14 +398,14 @@ export class Renderer implements Plugin { ```ts export class SDF extends Drawcall { - createMaterial(uniformBuffer: Buffer): void { - this.#pipeline = this.device.createRenderPipeline({ - megaStateDescriptor: { - depthWrite: true, // [!code ++] - depthCompare: CompareFunction.GREATER, // [!code ++] - }, - }); - } + createMaterial(uniformBuffer: Buffer): void { + this.#pipeline = this.device.createRenderPipeline({ + megaStateDescriptor: { + depthWrite: true, // [!code ++] + depthCompare: CompareFunction.GREATER, // [!code ++] + }, + }); + } } ``` @@ -413,11 +413,11 @@ export class SDF extends Drawcall { ```ts this.#renderPass = this.#device.createRenderPass({ - colorAttachment: [this.#renderTarget], - colorResolveTo: [onscreenTexture], - colorClearColor: [TransparentWhite], - depthStencilAttachment: this.#depthRenderTarget, // [!code ++] - depthClearValue: 1, // [!code ++] + colorAttachment: [this.#renderTarget], + colorResolveTo: [onscreenTexture], + colorClearColor: [TransparentWhite], + depthStencilAttachment: this.#depthRenderTarget, // [!code ++] + depthClearValue: 1, // [!code ++] }); ``` @@ -425,47 +425,47 @@ this.#renderPass = this.#device.createRenderPass({ ```js eval code=false $icCanvas3 = call(() => { - return document.createElement('ic-canvas-lesson8'); + return document.createElement('ic-canvas-lesson8'); }); ``` ```js eval code=false inspector=false call(() => { - const { Canvas, Circle } = Lesson8; - - const stats = new Stats(); - stats.showPanel(0); - const $stats = stats.dom; - $stats.style.position = 'absolute'; - $stats.style.left = '0px'; - $stats.style.top = '0px'; - - $icCanvas3.parentElement.style.position = 'relative'; - $icCanvas3.parentElement.appendChild($stats); - - const circles = []; - $icCanvas3.addEventListener('ic-ready', (e) => { - const canvas = e.detail; - - for (let i = 0; i < 5000; i++) { - const circle = new Circle({ - cx: Math.random() * 1000, - cy: Math.random() * 1000, - r: Math.random() * 20, - fill: `rgb(${Math.floor(Math.random() * 255)},${Math.floor( - Math.random() * 255, - )},${Math.floor(Math.random() * 255)})`, - batchable: true, - cullable: true, - }); - canvas.appendChild(circle); - circles.push(circle); - } - }); + const { Canvas, Circle } = Lesson8; + + const stats = new Stats(); + stats.showPanel(0); + const $stats = stats.dom; + $stats.style.position = 'absolute'; + $stats.style.left = '0px'; + $stats.style.top = '0px'; + + $icCanvas3.parentElement.style.position = 'relative'; + $icCanvas3.parentElement.appendChild($stats); + + const circles = []; + $icCanvas3.addEventListener('ic-ready', (e) => { + const canvas = e.detail; + + for (let i = 0; i < 5000; i++) { + const circle = new Circle({ + cx: Math.random() * 1000, + cy: Math.random() * 1000, + r: Math.random() * 20, + fill: `rgb(${Math.floor(Math.random() * 255)},${Math.floor( + Math.random() * 255, + )},${Math.floor(Math.random() * 255)})`, + batchable: true, + cullable: true, + }); + canvas.appendChild(circle); + circles.push(circle); + } + }); - $icCanvas3.addEventListener('ic-frame', (e) => { - stats.update(); - }); + $icCanvas3.addEventListener('ic-frame', (e) => { + stats.update(); + }); }); ``` @@ -475,10 +475,10 @@ call(() => { ```ts circle.addEventListener('pointerenter', () => { - circle.fill = 'red'; + circle.fill = 'red'; }); circle.addEventListener('pointerleave', () => { - circle.fill = fill; + circle.fill = fill; }); ``` @@ -501,29 +501,38 @@ import RBush from 'rbush'; const rBushRoot = new RBush(); export interface RBushNodeAABB { - shape: Shape; - minX: number; - minY: number; - maxX: number; - maxY: number; + shape: Shape; + minX: number; + minY: number; + maxX: number; + maxY: number; } ``` ### 区域查询 {#rbush-search} -RBush 提供了区域查询功能 [search],传入一个包围盒返回 +RBush 提供了区域查询功能 [search],传入一个查询包围盒返回命中的图形列表: ```ts export class Canvas { - elementsFromBBox( - minX: number, - minY: number, - maxX: number, - maxY: number, - ): Shape[] { - const { rBushRoot } = this.#pluginContext; - const rBushNodes = rBushRoot.search({ minX, minY, maxX, maxY }); - } + elementsFromBBox( + minX: number, + minY: number, + maxX: number, + maxY: number, + ): Shape[] { + const { rBushRoot } = this.#pluginContext; + const rBushNodes = rBushRoot.search({ minX, minY, maxX, maxY }); + + const hitTestList: Shape[] = []; + rBushNodes.forEach(({ shape }) => { + // 省略考虑 shape 的 visibility 和 pointerEvents 属性 + }); + // 按渲染次序排序 + hitTestList.sort((a, b) => a.globalRenderOrder - b.globalRenderOrder); + + return hitTestList; + } } ``` @@ -550,16 +559,16 @@ export class Picker implements Plugin { } ``` -让我们重新度量一下,20000 个 Circle 拾取事件变成了 0.088ms,提升了大约 20 倍! +让我们重新度量一下,20000 个 Circle 拾取时间变成了 0.088ms,提升了大约 20 倍! ![pick perf with rbush](/pick-rbush-perf.png) ## 扩展阅读 {#extended-reading} -- [Inside PixiJS: Batch Rendering System] -- [Depth testing] -- [The Depth Texture | WebGPU] -- Three.js [BatchedMesh: Proposal] +- [Inside PixiJS: Batch Rendering System] +- [Depth testing] +- [The Depth Texture | WebGPU] +- Three.js [BatchedMesh: Proposal] [stats.js]: https://github.com/mrdoob/stats.js [Spector.js]: https://spector.babylonjs.com/