perf(tui): skip rounding unchanged layout subtrees
This commit is contained in:
@@ -436,6 +436,21 @@ export class Node {
|
||||
_cGen = -1
|
||||
_cN = 0
|
||||
_cWr = 0
|
||||
_rLayoutGen = -1
|
||||
_rSubtreeLayoutGen = -1
|
||||
_rValid = false
|
||||
_rLeft = NaN
|
||||
_rTop = NaN
|
||||
_rWidth = NaN
|
||||
_rHeight = NaN
|
||||
_rRoundedLeft = NaN
|
||||
_rRoundedTop = NaN
|
||||
_rRoundedWidth = NaN
|
||||
_rRoundedHeight = NaN
|
||||
_rParentAbsLeft = NaN
|
||||
_rParentAbsTop = NaN
|
||||
_rScale = NaN
|
||||
_rIsText = false
|
||||
constructor(config?: Config) {
|
||||
this.style = defaultStyle()
|
||||
this.layout = {
|
||||
@@ -509,6 +524,7 @@ export class Node {
|
||||
this._cN = 0
|
||||
this._cWr = 0
|
||||
this._fbBasis = NaN
|
||||
this._rValid = false
|
||||
}
|
||||
markDirty(): void {
|
||||
this.isDirty_ = true
|
||||
@@ -842,6 +858,8 @@ export class Node {
|
||||
_yogaNodesVisited = 0
|
||||
_yogaMeasureCalls = 0
|
||||
_yogaCacheHits = 0
|
||||
_yogaRoundedNodes = 0
|
||||
_yogaRoundSkips = 0
|
||||
_generation++
|
||||
const w = ownerWidth === undefined ? NaN : ownerWidth
|
||||
const h = ownerHeight === undefined ? NaN : ownerHeight
|
||||
@@ -924,18 +942,24 @@ let _yogaNodesVisited = 0
|
||||
let _yogaMeasureCalls = 0
|
||||
let _yogaCacheHits = 0
|
||||
let _yogaLiveNodes = 0
|
||||
let _yogaRoundedNodes = 0
|
||||
let _yogaRoundSkips = 0
|
||||
|
||||
export function getYogaCounters(): {
|
||||
visited: number
|
||||
measured: number
|
||||
cacheHits: number
|
||||
live: number
|
||||
rounded: number
|
||||
roundSkips: number
|
||||
} {
|
||||
return {
|
||||
visited: _yogaNodesVisited,
|
||||
measured: _yogaMeasureCalls,
|
||||
cacheHits: _yogaCacheHits,
|
||||
live: _yogaLiveNodes
|
||||
live: _yogaLiveNodes,
|
||||
rounded: _yogaRoundedNodes,
|
||||
roundSkips: _yogaRoundSkips
|
||||
}
|
||||
}
|
||||
|
||||
@@ -952,6 +976,18 @@ function layoutNode(
|
||||
forceHeight = false
|
||||
): void {
|
||||
_yogaNodesVisited++
|
||||
|
||||
if (performLayout) {
|
||||
node._rLayoutGen = _generation
|
||||
|
||||
let ancestor: Node | null = node
|
||||
|
||||
while (ancestor && ancestor._rSubtreeLayoutGen !== _generation) {
|
||||
ancestor._rSubtreeLayoutGen = _generation
|
||||
ancestor = ancestor.parent
|
||||
}
|
||||
}
|
||||
|
||||
const style = node.style
|
||||
const layout = node.layout
|
||||
const sameGen = node._cGen === _generation && !performLayout
|
||||
@@ -2191,28 +2227,83 @@ function collectLayoutChildren(node: Node, flow: Node[], abs: Node[]): void {
|
||||
}
|
||||
|
||||
function roundLayout(node: Node, scale: number, absLeft: number, absTop: number): void {
|
||||
if (scale === 0) {
|
||||
const l = node.layout
|
||||
const isText = node.measureFunc !== null
|
||||
const wasLaidOut = node._rLayoutGen === _generation
|
||||
|
||||
if (
|
||||
node._rValid &&
|
||||
node._rSubtreeLayoutGen !== _generation &&
|
||||
sameFloat(node._rParentAbsLeft, absLeft) &&
|
||||
sameFloat(node._rParentAbsTop, absTop) &&
|
||||
sameFloat(node._rScale, scale) &&
|
||||
node._rIsText === isText &&
|
||||
sameFloat(node._rRoundedLeft, l.left) &&
|
||||
sameFloat(node._rRoundedTop, l.top) &&
|
||||
sameFloat(node._rRoundedWidth, l.width) &&
|
||||
sameFloat(node._rRoundedHeight, l.height)
|
||||
) {
|
||||
_yogaRoundSkips++
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const l = node.layout
|
||||
if (
|
||||
node._rValid &&
|
||||
!wasLaidOut &&
|
||||
sameFloat(l.left, node._rRoundedLeft) &&
|
||||
sameFloat(l.top, node._rRoundedTop) &&
|
||||
sameFloat(l.width, node._rRoundedWidth) &&
|
||||
sameFloat(l.height, node._rRoundedHeight)
|
||||
) {
|
||||
l.left = node._rLeft
|
||||
l.top = node._rTop
|
||||
l.width = node._rWidth
|
||||
l.height = node._rHeight
|
||||
}
|
||||
|
||||
_yogaRoundedNodes++
|
||||
|
||||
const nodeLeft = l.left
|
||||
const nodeTop = l.top
|
||||
const nodeWidth = l.width
|
||||
const nodeHeight = l.height
|
||||
const absNodeLeft = absLeft + nodeLeft
|
||||
const absNodeTop = absTop + nodeTop
|
||||
const isText = node.measureFunc !== null
|
||||
l.left = roundValue(nodeLeft, scale, false, isText)
|
||||
l.top = roundValue(nodeTop, scale, false, isText)
|
||||
const absRight = absNodeLeft + nodeWidth
|
||||
const absBottom = absNodeTop + nodeHeight
|
||||
const hasFracW = !isWholeNumber(nodeWidth * scale)
|
||||
const hasFracH = !isWholeNumber(nodeHeight * scale)
|
||||
l.width =
|
||||
roundValue(absRight, scale, isText && hasFracW, isText && !hasFracW) - roundValue(absNodeLeft, scale, false, isText)
|
||||
l.height =
|
||||
roundValue(absBottom, scale, isText && hasFracH, isText && !hasFracH) - roundValue(absNodeTop, scale, false, isText)
|
||||
node._rValid = true
|
||||
node._rLeft = nodeLeft
|
||||
node._rTop = nodeTop
|
||||
node._rWidth = nodeWidth
|
||||
node._rHeight = nodeHeight
|
||||
node._rParentAbsLeft = absLeft
|
||||
node._rParentAbsTop = absTop
|
||||
node._rScale = scale
|
||||
node._rIsText = isText
|
||||
|
||||
if (scale === 0) {
|
||||
l.left = nodeLeft
|
||||
l.top = nodeTop
|
||||
l.width = nodeWidth
|
||||
l.height = nodeHeight
|
||||
} else {
|
||||
l.left = roundValue(nodeLeft, scale, false, isText)
|
||||
l.top = roundValue(nodeTop, scale, false, isText)
|
||||
const absRight = absNodeLeft + nodeWidth
|
||||
const absBottom = absNodeTop + nodeHeight
|
||||
const hasFracW = !isWholeNumber(nodeWidth * scale)
|
||||
const hasFracH = !isWholeNumber(nodeHeight * scale)
|
||||
l.width =
|
||||
roundValue(absRight, scale, isText && hasFracW, isText && !hasFracW) -
|
||||
roundValue(absNodeLeft, scale, false, isText)
|
||||
l.height =
|
||||
roundValue(absBottom, scale, isText && hasFracH, isText && !hasFracH) -
|
||||
roundValue(absNodeTop, scale, false, isText)
|
||||
}
|
||||
|
||||
node._rRoundedLeft = l.left
|
||||
node._rRoundedTop = l.top
|
||||
node._rRoundedWidth = l.width
|
||||
node._rRoundedHeight = l.height
|
||||
|
||||
for (const c of node.children) {
|
||||
roundLayout(c, scale, absNodeLeft, absNodeTop)
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import Yoga, { FlexDirection, getYogaCounters, type Node } from './index.js'
|
||||
|
||||
const snapshot = (node: Node): number[] => {
|
||||
const result = [node.getComputedLeft(), node.getComputedTop(), node.getComputedWidth(), node.getComputedHeight()]
|
||||
|
||||
for (let index = 0; index < node.getChildCount(); index++) {
|
||||
result.push(...snapshot(node.getChild(index)))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const buildTree = (rootWidth: number, widths: number[], scale: number) => {
|
||||
const config = Yoga.Config.create()
|
||||
config.setPointScaleFactor(scale)
|
||||
const root = Yoga.Node.create(config)
|
||||
root.setFlexDirection(FlexDirection.Column)
|
||||
root.setWidth(rootWidth)
|
||||
root.setHeight(20)
|
||||
const leaves: Node[] = []
|
||||
|
||||
for (let groupIndex = 0; groupIndex < 4; groupIndex++) {
|
||||
const group = Yoga.Node.create(config)
|
||||
group.setFlexDirection(FlexDirection.Row)
|
||||
group.setHeight(3.125)
|
||||
root.insertChild(group, groupIndex)
|
||||
|
||||
for (let leafIndex = 0; leafIndex < 8; leafIndex++) {
|
||||
const leaf = Yoga.Node.create(config)
|
||||
leaf.setWidth(widths[groupIndex * 8 + leafIndex]!)
|
||||
leaf.setHeight(1.125 + (leafIndex % 3) * 0.25)
|
||||
group.insertChild(leaf, leafIndex)
|
||||
leaves.push(leaf)
|
||||
}
|
||||
}
|
||||
|
||||
return { config, leaves, root }
|
||||
}
|
||||
|
||||
describe('incremental layout rounding', () => {
|
||||
it('skips an unchanged transcript subtree when only the clock changes', () => {
|
||||
const config = Yoga.Config.create()
|
||||
config.setPointScaleFactor(2)
|
||||
|
||||
const root = Yoga.Node.create(config)
|
||||
root.setWidth(80)
|
||||
root.setHeight(40)
|
||||
|
||||
const transcript = Yoga.Node.create(config)
|
||||
transcript.setHeight(39)
|
||||
root.insertChild(transcript, 0)
|
||||
|
||||
for (let index = 0; index < 500; index++) {
|
||||
const row = Yoga.Node.create(config)
|
||||
row.setWidth(20.25)
|
||||
row.setHeight(0.25)
|
||||
transcript.insertChild(row, index)
|
||||
}
|
||||
|
||||
const clock = Yoga.Node.create(config)
|
||||
clock.setWidth(5.25)
|
||||
clock.setHeight(1)
|
||||
root.insertChild(clock, 1)
|
||||
|
||||
root.calculateLayout(80, 40)
|
||||
const transcriptWidth = transcript.getComputedWidth()
|
||||
|
||||
clock.setWidth(6.25)
|
||||
root.calculateLayout(80, 40)
|
||||
|
||||
const counters = getYogaCounters()
|
||||
expect(clock.getComputedWidth()).toBe(6.5)
|
||||
expect(transcript.getComputedWidth()).toBe(transcriptWidth)
|
||||
expect(counters.rounded).toBeLessThanOrEqual(4)
|
||||
expect(counters.roundSkips).toBeGreaterThanOrEqual(1)
|
||||
|
||||
root.freeRecursive()
|
||||
Yoga.Config.destroy(config)
|
||||
})
|
||||
|
||||
it('re-rounds cached raw geometry when the point scale changes', () => {
|
||||
const config = Yoga.Config.create()
|
||||
config.setPointScaleFactor(2)
|
||||
|
||||
const root = Yoga.Node.create(config)
|
||||
root.setWidth(20)
|
||||
root.setHeight(10)
|
||||
|
||||
const child = Yoga.Node.create(config)
|
||||
child.setWidth(10.25)
|
||||
child.setHeight(1)
|
||||
root.insertChild(child, 0)
|
||||
|
||||
root.calculateLayout(20, 10)
|
||||
expect(child.getComputedWidth()).toBe(10.5)
|
||||
|
||||
config.setPointScaleFactor(4)
|
||||
child.setWidth(10.125)
|
||||
root.calculateLayout(20, 10)
|
||||
|
||||
expect(child.getComputedWidth()).toBe(10.25)
|
||||
|
||||
config.setPointScaleFactor(0)
|
||||
root.calculateLayout(20, 10)
|
||||
|
||||
expect(child.getComputedWidth()).toBe(10.125)
|
||||
|
||||
root.freeRecursive()
|
||||
Yoga.Config.destroy(config)
|
||||
})
|
||||
|
||||
it('matches a fresh full layout across leaf, root, and scale changes', () => {
|
||||
const widths = Array.from({ length: 32 }, (_, index) => 1.125 + (index % 5) * 0.375)
|
||||
let rootWidth = 40.25
|
||||
let scale = 2
|
||||
const incremental = buildTree(rootWidth, widths, scale)
|
||||
|
||||
for (let step = 0; step < 24; step++) {
|
||||
if (step % 6 === 0) {
|
||||
scale = scale === 2 ? 4 : 2
|
||||
incremental.config.setPointScaleFactor(scale)
|
||||
} else if (step % 5 === 0) {
|
||||
rootWidth += 0.375
|
||||
incremental.root.setWidth(rootWidth)
|
||||
} else {
|
||||
const leafIndex = (step * 7) % widths.length
|
||||
widths[leafIndex]! += 0.125
|
||||
incremental.leaves[leafIndex]!.setWidth(widths[leafIndex]!)
|
||||
}
|
||||
|
||||
incremental.root.calculateLayout(rootWidth, 20)
|
||||
const fresh = buildTree(rootWidth, widths, scale)
|
||||
fresh.root.calculateLayout(rootWidth, 20)
|
||||
|
||||
expect(snapshot(incremental.root), `step ${step}`).toEqual(snapshot(fresh.root))
|
||||
|
||||
fresh.root.freeRecursive()
|
||||
Yoga.Config.destroy(fresh.config)
|
||||
}
|
||||
|
||||
incremental.root.freeRecursive()
|
||||
Yoga.Config.destroy(incremental.config)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user