feat: add render graph driven renderer architecture
Amp-Thread-ID: https://ampcode.com/threads/T-019f9d91-77c1-7206-a60f-ed6554ce92ab Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
+260
@@ -0,0 +1,260 @@
|
||||
import { FXNODE_VIEW_LIMITS } from "../browser/view-limits.js";
|
||||
|
||||
export interface AtlasSize {
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
}
|
||||
export interface AtlasRect extends AtlasSize {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
}
|
||||
export interface AtlasItem extends AtlasSize {
|
||||
readonly id: string;
|
||||
}
|
||||
export interface AtlasLayout extends AtlasSize {
|
||||
readonly items: ReadonlyMap<string, AtlasSize>;
|
||||
readonly regions: ReadonlyMap<string, AtlasRect>;
|
||||
readonly free: readonly AtlasRect[];
|
||||
}
|
||||
export type AtlasPlanKind = "allocated" | "resized-in-place" | "relocated" | "repacked" | "grown";
|
||||
export type AtlasPlan =
|
||||
| {
|
||||
readonly ok: true;
|
||||
readonly kind: AtlasPlanKind;
|
||||
readonly layout: AtlasLayout;
|
||||
readonly movedIds: readonly string[];
|
||||
}
|
||||
| { readonly ok: false; readonly code: "atlas.dimension" | "atlas.capacity" };
|
||||
|
||||
const BLOCK = 256;
|
||||
const area = ({ width, height }: AtlasSize) => width * height;
|
||||
const freeOrder = (left: AtlasRect, right: AtlasRect) =>
|
||||
left.y - right.y || left.x - right.x || left.height - right.height || left.width - right.width;
|
||||
const validItem = ({ width, height }: AtlasSize) =>
|
||||
Number.isSafeInteger(width) &&
|
||||
Number.isSafeInteger(height) &&
|
||||
width > 0 &&
|
||||
height > 0 &&
|
||||
width <= FXNODE_VIEW_LIMITS.maxDeviceDimension &&
|
||||
height <= FXNODE_VIEW_LIMITS.maxDeviceDimension &&
|
||||
width * height <= FXNODE_VIEW_LIMITS.maxDevicePixelsPerView;
|
||||
const freezeRect = (rect: AtlasRect): AtlasRect => Object.freeze(rect);
|
||||
const makeLayout = (
|
||||
width: number,
|
||||
height: number,
|
||||
items: ReadonlyMap<string, AtlasSize>,
|
||||
regions: ReadonlyMap<string, AtlasRect>,
|
||||
free: readonly AtlasRect[],
|
||||
): AtlasLayout =>
|
||||
Object.freeze({
|
||||
width,
|
||||
height,
|
||||
items: new Map(items),
|
||||
regions: new Map(regions),
|
||||
free: Object.freeze(free.map(freezeRect).sort(freeOrder)),
|
||||
});
|
||||
|
||||
function coalesce(rectangles: readonly AtlasRect[]): AtlasRect[] {
|
||||
const result = rectangles.map((rect) => ({ ...rect }));
|
||||
for (;;) {
|
||||
let merged = false;
|
||||
outer: for (let i = 0; i < result.length; i++)
|
||||
for (let j = i + 1; j < result.length; j++) {
|
||||
const a = result[i]!,
|
||||
b = result[j]!;
|
||||
let next: AtlasRect | undefined;
|
||||
if (a.y === b.y && a.height === b.height && (a.x + a.width === b.x || b.x + b.width === a.x))
|
||||
next = { x: Math.min(a.x, b.x), y: a.y, width: a.width + b.width, height: a.height };
|
||||
else if (a.x === b.x && a.width === b.width && (a.y + a.height === b.y || b.y + b.height === a.y))
|
||||
next = { x: a.x, y: Math.min(a.y, b.y), width: a.width, height: a.height + b.height };
|
||||
if (next) {
|
||||
result.splice(j, 1);
|
||||
result.splice(i, 1, next);
|
||||
merged = true;
|
||||
break outer;
|
||||
}
|
||||
}
|
||||
if (!merged) return result.sort(freeOrder);
|
||||
}
|
||||
}
|
||||
|
||||
function insert(layout: AtlasLayout, item: AtlasItem): AtlasLayout | undefined {
|
||||
const candidates = layout.free
|
||||
.map((rect, index) => ({ rect, index }))
|
||||
.filter(({ rect }) => item.width <= rect.width && item.height <= rect.height)
|
||||
.sort(({ rect: left }, { rect: right }) => {
|
||||
const ldw = left.width - item.width,
|
||||
ldh = left.height - item.height,
|
||||
rdw = right.width - item.width,
|
||||
rdh = right.height - item.height;
|
||||
return (
|
||||
area(left) - area(item) - (area(right) - area(item)) ||
|
||||
Math.min(ldw, ldh) - Math.min(rdw, rdh) ||
|
||||
Math.max(ldw, ldh) - Math.max(rdw, rdh) ||
|
||||
freeOrder(left, right)
|
||||
);
|
||||
});
|
||||
const selected = candidates[0];
|
||||
if (!selected) return;
|
||||
const { rect, index } = selected,
|
||||
region = { x: rect.x, y: rect.y, width: item.width, height: item.height },
|
||||
dw = rect.width - item.width,
|
||||
dh = rect.height - item.height,
|
||||
remainder: AtlasRect[] = [];
|
||||
if (dw > dh) {
|
||||
if (dw) remainder.push({ x: rect.x + item.width, y: rect.y, width: dw, height: rect.height });
|
||||
if (dh) remainder.push({ x: rect.x, y: rect.y + item.height, width: item.width, height: dh });
|
||||
} else {
|
||||
if (dh) remainder.push({ x: rect.x, y: rect.y + item.height, width: rect.width, height: dh });
|
||||
if (dw) remainder.push({ x: rect.x + item.width, y: rect.y, width: dw, height: item.height });
|
||||
}
|
||||
const items = new Map(layout.items),
|
||||
regions = new Map(layout.regions),
|
||||
free = layout.free.slice();
|
||||
items.set(item.id, Object.freeze({ width: item.width, height: item.height }));
|
||||
regions.set(item.id, freezeRect(region));
|
||||
free.splice(index, 1, ...remainder);
|
||||
return makeLayout(layout.width, layout.height, items, regions, free);
|
||||
}
|
||||
|
||||
function emptyLayout(width: number, height: number): AtlasLayout {
|
||||
return makeLayout(width, height, new Map(), new Map(), [{ x: 0, y: 0, width, height }]);
|
||||
}
|
||||
function sortedItems(items: ReadonlyMap<string, AtlasSize>): AtlasItem[] {
|
||||
return [...items]
|
||||
.map(([id, size]) => ({ id, ...size }))
|
||||
.sort((left, right) => {
|
||||
const side = Math.max(right.width, right.height) - Math.max(left.width, left.height);
|
||||
return (
|
||||
side ||
|
||||
area(right) - area(left) ||
|
||||
right.height - left.height ||
|
||||
right.width - left.width ||
|
||||
(left.id < right.id ? -1 : left.id > right.id ? 1 : 0)
|
||||
);
|
||||
});
|
||||
}
|
||||
function pack(items: readonly AtlasItem[], width: number, height: number): AtlasLayout | undefined {
|
||||
let layout = emptyLayout(width, height);
|
||||
for (const item of items) {
|
||||
const next = insert(layout, item);
|
||||
if (!next) return;
|
||||
layout = next;
|
||||
}
|
||||
return layout;
|
||||
}
|
||||
function candidates(items: readonly AtlasItem[], minimumArea: number): AtlasSize[] {
|
||||
const largestWidth = Math.max(...items.map((item) => item.width)),
|
||||
largestHeight = Math.max(...items.map((item) => item.height)),
|
||||
result: AtlasSize[] = [];
|
||||
for (let width = BLOCK; width <= FXNODE_VIEW_LIMITS.maxAtlasDimension; width += BLOCK)
|
||||
for (let height = BLOCK; height <= FXNODE_VIEW_LIMITS.maxAtlasDimension; height += BLOCK) {
|
||||
const pixels = width * height;
|
||||
if (
|
||||
width >= largestWidth &&
|
||||
height >= largestHeight &&
|
||||
pixels >= minimumArea &&
|
||||
pixels <= FXNODE_VIEW_LIMITS.maxAtlasPixels
|
||||
)
|
||||
result.push({ width, height });
|
||||
}
|
||||
return result.sort(
|
||||
(left, right) =>
|
||||
area(left) - area(right) ||
|
||||
Math.abs(left.width - left.height) - Math.abs(right.width - right.height) ||
|
||||
left.width - right.width ||
|
||||
left.height - right.height,
|
||||
);
|
||||
}
|
||||
function repack(
|
||||
itemsMap: ReadonlyMap<string, AtlasSize>,
|
||||
current?: AtlasLayout,
|
||||
compact = false,
|
||||
): AtlasLayout | undefined {
|
||||
const items = sortedItems(itemsMap),
|
||||
activeArea = items.reduce((sum, item) => sum + area(item), 0);
|
||||
if (current && !compact) {
|
||||
const same = pack(items, current.width, current.height);
|
||||
if (same) return same;
|
||||
}
|
||||
const floor =
|
||||
current && !compact
|
||||
? Math.min(FXNODE_VIEW_LIMITS.maxAtlasPixels, Math.max(activeArea, area(current) * 2))
|
||||
: activeArea;
|
||||
for (const size of candidates(items, floor)) {
|
||||
const layout = pack(items, size.width, size.height);
|
||||
if (layout) return layout;
|
||||
}
|
||||
}
|
||||
function movedIds(before: AtlasLayout | undefined, after: AtlasLayout): string[] {
|
||||
return [...after.regions]
|
||||
.filter(([id, rect]) => {
|
||||
const previous = before?.regions.get(id);
|
||||
return !previous || previous.x !== rect.x || previous.y !== rect.y;
|
||||
})
|
||||
.map(([id]) => id)
|
||||
.sort();
|
||||
}
|
||||
|
||||
export function planAtlasUpsert(current: AtlasLayout | undefined, item: AtlasItem): AtlasPlan {
|
||||
if (!validItem(item)) return { ok: false, code: "atlas.dimension" };
|
||||
const items = new Map(current?.items);
|
||||
items.set(item.id, { width: item.width, height: item.height });
|
||||
if ([...items.values()].reduce((sum, value) => sum + area(value), 0) > FXNODE_VIEW_LIMITS.maxActiveDevicePixels)
|
||||
return { ok: false, code: "atlas.capacity" };
|
||||
const previousSize = current?.items.get(item.id),
|
||||
previousRegion = current?.regions.get(item.id);
|
||||
if (
|
||||
current &&
|
||||
previousSize &&
|
||||
previousRegion &&
|
||||
item.width <= previousRegion.width &&
|
||||
item.height <= previousRegion.height
|
||||
) {
|
||||
const nextItems = new Map(current.items);
|
||||
nextItems.set(item.id, Object.freeze({ width: item.width, height: item.height }));
|
||||
return {
|
||||
ok: true,
|
||||
kind: "resized-in-place",
|
||||
layout: makeLayout(current.width, current.height, nextItems, current.regions, current.free),
|
||||
movedIds: [],
|
||||
};
|
||||
}
|
||||
let base = current;
|
||||
if (current && previousRegion) base = removeAtlasItem(current, item.id);
|
||||
const incremental = base && insert(base, item);
|
||||
if (incremental)
|
||||
return {
|
||||
ok: true,
|
||||
kind: previousSize ? "relocated" : "allocated",
|
||||
layout: incremental,
|
||||
movedIds: movedIds(current, incremental),
|
||||
};
|
||||
const layout = repack(items, current);
|
||||
if (!layout) return { ok: false, code: "atlas.capacity" };
|
||||
return {
|
||||
ok: true,
|
||||
kind: current && layout.width === current.width && layout.height === current.height ? "repacked" : "grown",
|
||||
layout,
|
||||
movedIds: movedIds(current, layout),
|
||||
};
|
||||
}
|
||||
|
||||
export function removeAtlasItem(current: AtlasLayout, id: string): AtlasLayout | undefined {
|
||||
const region = current.regions.get(id);
|
||||
if (!region) return current;
|
||||
const items = new Map(current.items),
|
||||
regions = new Map(current.regions);
|
||||
items.delete(id);
|
||||
regions.delete(id);
|
||||
if (!items.size) return;
|
||||
return makeLayout(current.width, current.height, items, regions, coalesce([...current.free, region]));
|
||||
}
|
||||
|
||||
export function planAtlasCompaction(current: AtlasLayout): AtlasPlan | undefined {
|
||||
const active = [...current.items.values()].reduce((sum, item) => sum + area(item), 0);
|
||||
if (active / area(current) > 0.25) return;
|
||||
const layout = repack(current.items, current, true);
|
||||
if (!layout || area(layout) > area(current) / 2) return;
|
||||
return { ok: true, kind: "repacked", layout, movedIds: movedIds(current, layout) };
|
||||
}
|
||||
Reference in New Issue
Block a user