perf(ui): more bbox optimizations

- Keep track of whether the bbox needs to be recalculated (e.g. had lines/points added)
- Keep track of whether the bbox has eraser strokes - if yes, we need to do the full pixel-perfect bbox calculation, otherwise we can use the faster getClientRect
- Use comparison rather than Math.min/max in bbox calculation (slightly faster)
- Return `null` if no pixel data at all in bbox
This commit is contained in:
psychedelicious 2024-04-20 11:06:25 +10:00
parent a71ed10b71
commit 8a69fbd336
4 changed files with 71 additions and 18 deletions

View File

@ -49,7 +49,7 @@ const useStageRenderer = (container: HTMLDivElement | null, wrapper: HTMLDivElem
); );
const onBboxChanged = useCallback( const onBboxChanged = useCallback(
(layerId: string, bbox: IRect) => { (layerId: string, bbox: IRect | null) => {
dispatch(rpLayerBboxChanged({ layerId, bbox })); dispatch(rpLayerBboxChanged({ layerId, bbox }));
}, },
[dispatch] [dispatch]
@ -138,8 +138,8 @@ const useStageRenderer = (container: HTMLDivElement | null, wrapper: HTMLDivElem
if (!stage) { if (!stage) {
return; return;
} }
renderBbox(stage, tool, state.selectedLayerId, onBboxChanged); renderBbox(stage, state.layers, state.selectedLayerId, tool, onBboxChanged);
}, [dispatch, stage, tool, state.selectedLayerId, onBboxChanged]); }, [dispatch, stage, state.layers, state.selectedLayerId, tool, onBboxChanged]);
}; };
const $container = atom<HTMLDivElement | null>(null); const $container = atom<HTMLDivElement | null>(null);

View File

@ -53,6 +53,8 @@ export type RegionalPromptLayer = LayerBase & {
x: number; x: number;
y: number; y: number;
bbox: IRect | null; bbox: IRect | null;
bboxNeedsUpdate: boolean;
hasEraserStrokes: boolean;
kind: 'regionalPromptLayer'; kind: 'regionalPromptLayer';
objects: LayerObject[]; objects: LayerObject[];
positivePrompt: string; positivePrompt: string;
@ -104,6 +106,8 @@ export const regionalPromptsSlice = createSlice({
x: 0, x: 0,
y: 0, y: 0,
autoNegative: 'off', autoNegative: 'off',
bboxNeedsUpdate: false,
hasEraserStrokes: false,
}; };
state.layers.push(layer); state.layers.push(layer);
state.selectedLayerId = layer.id; state.selectedLayerId = layer.id;
@ -154,6 +158,8 @@ export const regionalPromptsSlice = createSlice({
layer.objects = []; layer.objects = [];
layer.bbox = null; layer.bbox = null;
layer.isVisible = true; layer.isVisible = true;
layer.hasEraserStrokes = false;
layer.bboxNeedsUpdate = false;
} }
}, },
rpLayerTranslated: (state, action: PayloadAction<{ layerId: string; x: number; y: number }>) => { rpLayerTranslated: (state, action: PayloadAction<{ layerId: string; x: number; y: number }>) => {
@ -169,6 +175,7 @@ export const regionalPromptsSlice = createSlice({
const layer = state.layers.find((l) => l.id === layerId); const layer = state.layers.find((l) => l.id === layerId);
if (isRPLayer(layer)) { if (isRPLayer(layer)) {
layer.bbox = bbox; layer.bbox = bbox;
layer.bboxNeedsUpdate = false;
} }
}, },
allLayersDeleted: (state) => { allLayersDeleted: (state) => {
@ -218,6 +225,10 @@ export const regionalPromptsSlice = createSlice({
points: [points[0] - layer.x, points[1] - layer.y, points[2] - layer.x, points[3] - layer.y], points: [points[0] - layer.x, points[1] - layer.y, points[2] - layer.x, points[3] - layer.y],
strokeWidth: state.brushSize, strokeWidth: state.brushSize,
}); });
layer.bboxNeedsUpdate = true;
if (!layer.hasEraserStrokes) {
layer.hasEraserStrokes = tool === 'eraser';
}
} }
}, },
prepare: (payload: { layerId: string; points: [number, number, number, number]; tool: DrawingTool }) => ({ prepare: (payload: { layerId: string; points: [number, number, number, number]; tool: DrawingTool }) => ({
@ -236,6 +247,7 @@ export const regionalPromptsSlice = createSlice({
// Points must be offset by the layer's x and y coordinates // Points must be offset by the layer's x and y coordinates
// TODO: Handle this in the event listener // TODO: Handle this in the event listener
lastLine.points.push(point[0] - layer.x, point[1] - layer.y); lastLine.points.push(point[0] - layer.x, point[1] - layer.y);
layer.bboxNeedsUpdate = true;
} }
}, },
rpLayerAutoNegativeChanged: ( rpLayerAutoNegativeChanged: (
@ -364,7 +376,7 @@ const undoableGroupByMatcher = isAnyOf(
rpLayerPositivePromptChanged, rpLayerPositivePromptChanged,
rpLayerNegativePromptChanged, rpLayerNegativePromptChanged,
rpLayerTranslated, rpLayerTranslated,
rpLayerColorChanged, rpLayerColorChanged
); );
const LINE_1 = 'LINE_1'; const LINE_1 = 'LINE_1';

View File

@ -6,31 +6,49 @@ import type { Node as KonvaNodeType, NodeConfig as KonvaNodeConfigType } from 'k
import type { IRect } from 'konva/lib/types'; import type { IRect } from 'konva/lib/types';
import { assert } from 'tsafe'; import { assert } from 'tsafe';
type Extents = {
minX: number;
minY: number;
maxX: number;
maxY: number;
};
/** /**
* Get the bounding box of an image. * Get the bounding box of an image.
* @param imageData The ImageData object to get the bounding box of. * @param imageData The ImageData object to get the bounding box of.
* @returns The minimum and maximum x and y values of the image's bounding box. * @returns The minimum and maximum x and y values of the image's bounding box.
*/ */
const getImageDataBbox = (imageData: ImageData) => { const getImageDataBbox = (imageData: ImageData): Extents | null => {
const { data, width, height } = imageData; const { data, width, height } = imageData;
let minX = width; let minX = width;
let minY = height; let minY = height;
let maxX = 0; let maxX = -1;
let maxY = 0; let maxY = -1;
let alpha = 0;
let isEmpty = true;
for (let y = 0; y < height; y++) { for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) { for (let x = 0; x < width; x++) {
const alpha = data[(y * width + x) * 4 + 3] ?? 0; alpha = data[(y * width + x) * 4 + 3] ?? 0;
if (alpha > 0) { if (alpha > 0) {
minX = Math.min(minX, x); isEmpty = false;
maxX = Math.max(maxX, x); if (x < minX) {
minY = Math.min(minY, y); minX = x;
maxY = Math.max(maxY, y); }
if (x > maxX) {
maxX = x;
}
if (y < minY) {
minY = y;
}
if (y > maxY) {
maxY = y;
}
} }
} }
} }
return { minX, minY, maxX, maxY }; return isEmpty ? null : { minX, minY, maxX, maxY };
}; };
/** /**
@ -43,7 +61,7 @@ export const getKonvaLayerBbox = (
layer: KonvaLayerType, layer: KonvaLayerType,
filterChildren?: (item: KonvaNodeType<KonvaNodeConfigType>) => boolean, filterChildren?: (item: KonvaNodeType<KonvaNodeConfigType>) => boolean,
preview: boolean = false preview: boolean = false
): IRect => { ): IRect | null => {
// To calculate the layer's bounding box, we must first export it to a pixel array, then do some math. // To calculate the layer's bounding box, we must first export it to a pixel array, then do some math.
// //
// Though it is relatively fast, we can't use Konva's `getClientRect`. It programmatically determines the rect // Though it is relatively fast, we can't use Konva's `getClientRect`. It programmatically determines the rect
@ -89,6 +107,10 @@ export const getKonvaLayerBbox = (
// Calculate the layer's bounding box. // Calculate the layer's bounding box.
const layerBbox = getImageDataBbox(layerImageData); const layerBbox = getImageDataBbox(layerImageData);
if (!layerBbox) {
return null;
}
// Correct the bounding box to be relative to the layer's position. // Correct the bounding box to be relative to the layer's position.
const correctedLayerBbox = { const correctedLayerBbox = {
x: layerBbox.minX - stage.x() + layerRect.x - layer.x(), x: layerBbox.minX - stage.x() + layerRect.x - layer.x(),

View File

@ -302,6 +302,8 @@ export const renderLayers = (
const selectPromptLayerObjectGroup = (item: Node<NodeConfig>) => const selectPromptLayerObjectGroup = (item: Node<NodeConfig>) =>
item.name() !== REGIONAL_PROMPT_LAYER_OBJECT_GROUP_NAME; item.name() !== REGIONAL_PROMPT_LAYER_OBJECT_GROUP_NAME;
const GET_CLIENT_RECT_CONFIG = { skipTransform: true };
/** /**
* *
* @param stage The konva stage to render on. * @param stage The konva stage to render on.
@ -312,9 +314,10 @@ const selectPromptLayerObjectGroup = (item: Node<NodeConfig>) =>
*/ */
export const renderBbox = ( export const renderBbox = (
stage: Konva.Stage, stage: Konva.Stage,
tool: RPTool, reduxLayers: Layer[],
selectedLayerIdId: string | null, selectedLayerIdId: string | null,
onBboxChanged: (layerId: string, bbox: IRect) => void tool: RPTool,
onBboxChanged: (layerId: string, bbox: IRect | null) => void
) => { ) => {
// Hide all bounding boxes // Hide all bounding boxes
for (const bboxRect of stage.find<Konva.Rect>(`.${REGIONAL_PROMPT_LAYER_BBOX_NAME}`)) { for (const bboxRect of stage.find<Konva.Rect>(`.${REGIONAL_PROMPT_LAYER_BBOX_NAME}`)) {
@ -327,11 +330,27 @@ export const renderBbox = (
return; return;
} }
const reduxLayer = reduxLayers.find((layer) => layer.id === selectedLayerIdId);
assert(reduxLayer, `Selected layer ${selectedLayerIdId} not found in redux layers`);
const konvaLayer = stage.findOne<Konva.Layer>(`#${selectedLayerIdId}`); const konvaLayer = stage.findOne<Konva.Layer>(`#${selectedLayerIdId}`);
assert(konvaLayer, `Selected layer ${selectedLayerIdId} not found in stage`); assert(konvaLayer, `Selected layer ${selectedLayerIdId} not found in stage`);
const bbox = getKonvaLayerBbox(konvaLayer, selectPromptLayerObjectGroup); let bbox = reduxLayer.bbox;
onBboxChanged(selectedLayerIdId, bbox);
// We only need to recalculate the bbox if the layer has changed and it has objects
if (reduxLayer.bboxNeedsUpdate && reduxLayer.objects.length) {
// We only need to use the pixel-perfect bounding box if the layer has eraser strokes
bbox = reduxLayer.hasEraserStrokes
? getKonvaLayerBbox(konvaLayer, selectPromptLayerObjectGroup)
: konvaLayer.getClientRect(GET_CLIENT_RECT_CONFIG);
onBboxChanged(selectedLayerIdId, bbox);
}
if (!bbox) {
return;
}
let rect = konvaLayer.findOne<Konva.Rect>(`.${REGIONAL_PROMPT_LAYER_BBOX_NAME}`); let rect = konvaLayer.findOne<Konva.Rect>(`.${REGIONAL_PROMPT_LAYER_BBOX_NAME}`);
if (!rect) { if (!rect) {