Advance WebGPU volume and bounded workflows

This commit is contained in:
mes123456
2026-08-14 18:08:29 -04:00
parent 3da1dfc804
commit 68d50f810f
119 changed files with 9028 additions and 430 deletions

View File

@@ -11,6 +11,7 @@ export const COMPOSITOR_BUDGET = {
maxImageBytes: 256 * 1024 * 1024,
maxBlurRadius: 32,
maxOperations: 100_000_000,
maxFrameCacheBytes: 256 * 1024 * 1024,
} as const;
export const COMPOSITOR_NODE_TYPES = [
@@ -77,6 +78,69 @@ export interface CompositorExecutionResult {
evaluatedNodeIds: string[];
}
export interface CompositorCachedExecutionResult extends CompositorExecutionResult {
cacheKey: string;
cacheHit: boolean;
}
interface CompositorFrameCacheEntry {
result: CompositorExecutionResult;
byteLength: number;
}
function cloneImage(image: CompositorImageBuffer): CompositorImageBuffer {
return { ...image, data: image.data.slice() };
}
function cloneExecution(result: CompositorExecutionResult): CompositorExecutionResult {
return { composite: cloneImage(result.composite), viewers: new Map([...result.viewers].map(([id, image]) => [id, cloneImage(image)])), evaluatedNodeIds: [...result.evaluatedNodeIds] };
}
function executionBytes(result: CompositorExecutionResult): number {
const unique = new Set<ArrayBuffer>();
unique.add(result.composite.data.buffer as ArrayBuffer);
for (const image of result.viewers.values()) unique.add(image.data.buffer as ArrayBuffer);
return [...unique].reduce((total, buffer) => total + buffer.byteLength, 0);
}
export class CompositorFrameCache {
readonly maxBytes: number;
private readonly entries = new Map<string, CompositorFrameCacheEntry>();
private currentBytes = 0;
constructor(maxBytes = COMPOSITOR_BUDGET.maxFrameCacheBytes) {
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > COMPOSITOR_BUDGET.maxFrameCacheBytes) throw new CompositorValidationError("COMPOSITOR_BUDGET_EXCEEDED", "Compositor frame cache byte budget is invalid");
this.maxBytes = maxBytes;
}
get byteLength(): number { return this.currentBytes; }
get size(): number { return this.entries.size; }
get(key: string): CompositorExecutionResult | undefined {
const entry = this.entries.get(key);
if (!entry) return undefined;
this.entries.delete(key);
this.entries.set(key, entry);
return cloneExecution(entry.result);
}
set(key: string, result: CompositorExecutionResult): void {
const clone = cloneExecution(result);
const byteLength = executionBytes(clone);
if (byteLength > this.maxBytes) throw new CompositorValidationError("COMPOSITOR_BUDGET_EXCEEDED", "Compositor frame exceeds the cache byte budget");
const previous = this.entries.get(key);
if (previous) { this.currentBytes -= previous.byteLength; this.entries.delete(key); }
while (this.currentBytes + byteLength > this.maxBytes) {
const oldest = this.entries.entries().next().value as [string, CompositorFrameCacheEntry] | undefined;
if (!oldest) break;
this.entries.delete(oldest[0]);
this.currentBytes -= oldest[1].byteLength;
}
this.entries.set(key, { result: clone, byteLength });
this.currentBytes += byteLength;
}
}
export class CompositorValidationError extends Error {
readonly code: ErrorCode;
@@ -267,6 +331,11 @@ export function executeCompositorGraph(
const outputs = new Map<string, CompositorImageBuffer>();
const viewers = new Map<string, CompositorImageBuffer>();
const evaluatedNodeIds: string[] = [];
let operationCounter = 0;
const checkCancelled = (operations = 1): void => {
operationCounter += operations;
if ((operationCounter === operations || operationCounter % 16_384 < operations) && options.cancelled?.()) throw new CompositorValidationError("COMPOSITOR_CANCELLED", "Compositor execution was cancelled");
};
const requireInput = (nodeId: string, socket: string): CompositorImageBuffer => {
const source = incoming.get(`${nodeId}:${socket}`)?.fromNodeId;
const image = source ? outputs.get(source) : undefined;
@@ -279,7 +348,7 @@ export function executeCompositorGraph(
const evaluate = (id: string): CompositorImageBuffer => {
const existing = outputs.get(id);
if (existing) return existing;
if (options.cancelled?.()) throw new CompositorValidationError("COMPOSITOR_CANCELLED", "Compositor execution was cancelled");
checkCancelled();
const node = byId.get(id);
if (!node) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `Missing node ${id}`);
for (const link of graph.links.filter((candidate) => candidate.toNodeId === id)) evaluate(link.fromNodeId);
@@ -297,7 +366,7 @@ export function executeCompositorGraph(
const height = options.height ?? 1;
output = allocate(width, height);
const color = node.properties.color as number[];
for (let offset = 0; offset < output.data.length; offset += 4) output.data.set(color, offset);
for (let offset = 0; offset < output.data.length; offset += 4) { checkCancelled(); output.data.set(color, offset); }
}
else if (node.type === "TRANSFORM") {
const input = requireInput(id, "Image");
@@ -305,6 +374,7 @@ export function executeCompositorGraph(
const tx = Number(node.properties.translateX ?? 0), ty = Number(node.properties.translateY ?? 0);
const sx = Number(node.properties.scaleX ?? 1), sy = Number(node.properties.scaleY ?? 1);
for (let y = 0; y < input.height; y++) for (let x = 0; x < input.width; x++) {
checkCancelled();
const sourceX = Math.round((x - tx) / sx), sourceY = Math.round((y - ty) / sy);
if (sourceX < 0 || sourceX >= input.width || sourceY < 0 || sourceY >= input.height) continue;
output.data.set(input.data.subarray((sourceY * input.width + sourceX) * 4, (sourceY * input.width + sourceX) * 4 + 4), (y * input.width + x) * 4);
@@ -315,6 +385,7 @@ export function executeCompositorGraph(
output = allocate(input.width, input.height);
const multiplier = node.type === "EXPOSURE" ? 2 ** Number(node.properties.exposure ?? 0) : 1;
for (let offset = 0; offset < input.data.length; offset += 4) {
checkCancelled();
for (let channel = 0; channel < 3; channel++) output.data[offset + channel] = node.type === "INVERT" ? 1 - input.data[offset + channel] : input.data[offset + channel] * multiplier;
output.data[offset + 3] = input.data[offset + 3];
}
@@ -325,6 +396,7 @@ export function executeCompositorGraph(
sameSize(left, right, id);
output = allocate(left.width, left.height);
for (let offset = 0; offset < left.data.length; offset += 4) {
checkCancelled();
if (node.type === "MIX") {
const factor = Number(node.properties.factor ?? 0.5);
for (let channel = 0; channel < 4; channel++) output.data[offset + channel] = left.data[offset + channel] * (1 - factor) + right.data[offset + channel] * factor;
@@ -344,6 +416,7 @@ export function executeCompositorGraph(
if (operations > COMPOSITOR_BUDGET.maxOperations) throw new CompositorValidationError("COMPOSITOR_BUDGET_EXCEEDED", `${id} exceeds the blur operation budget`);
output = allocate(input.width, input.height);
for (let y = 0; y < input.height; y++) for (let x = 0; x < input.width; x++) {
checkCancelled((radius * 2 + 1) ** 2);
const target = (y * input.width + x) * 4;
let samples = 0;
for (let dy = -radius; dy <= radius; dy++) for (let dx = -radius; dx <= radius; dx++) {
@@ -366,3 +439,52 @@ export function executeCompositorGraph(
};
return { composite: evaluate(graph.outputNodeId), viewers, evaluatedNodeIds };
}
async function sha256Bytes(data: ArrayBuffer): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, "0")).join("");
}
function stableJSON(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableJSON).join(",")}]`;
if (record(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJSON(value[key])}`).join(",")}}`;
return JSON.stringify(value);
}
export async function compositorFrameCacheKey(
value: unknown,
sourceImages: ReadonlyMap<string, CompositorImageBuffer>,
frame: number,
width?: number,
height?: number,
): Promise<string> {
const graph = parseCompositorGraph(value);
if (!Number.isSafeInteger(frame) || frame < -1_000_000 || frame > 1_000_000) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", "Compositor cache frame is invalid");
const sources: Array<{ id: string; width: number; height: number; sha256: string }> = [];
for (const resource of [...graph.resources].sort((left, right) => left.sourceId.localeCompare(right.sourceId))) {
const image = sourceImages.get(resource.sourceId);
if (!image) throw new CompositorValidationError("COMPOSITOR_RESOURCE_MISSING", `Missing compositor resource ${resource.sourceId}`);
validateImage(image, resource.sourceId);
const sha256 = await sha256Bytes(image.data.buffer.slice(image.data.byteOffset, image.data.byteOffset + image.data.byteLength) as ArrayBuffer);
if (resource.sha256 && resource.sha256 !== sha256) throw new CompositorValidationError("COMPOSITOR_RESOURCE_MISSING", `Compositor resource ${resource.sourceId} failed SHA-256 verification`);
sources.push({ id: resource.sourceId, width: image.width, height: image.height, sha256 });
}
const descriptor = new TextEncoder().encode(stableJSON({ graph, sources, frame, width: width ?? null, height: height ?? null }));
return sha256Bytes(descriptor.buffer as ArrayBuffer);
}
export async function executeCompositorGraphCached(
value: unknown,
sourceImages: ReadonlyMap<string, CompositorImageBuffer>,
cache: CompositorFrameCache,
options: { frame: number; width?: number; height?: number; cancelled?: () => boolean },
): Promise<CompositorCachedExecutionResult> {
if (options.cancelled?.()) throw new CompositorValidationError("COMPOSITOR_CANCELLED", "Compositor execution was cancelled");
const cacheKey = await compositorFrameCacheKey(value, sourceImages, options.frame, options.width, options.height);
if (options.cancelled?.()) throw new CompositorValidationError("COMPOSITOR_CANCELLED", "Compositor execution was cancelled");
const cached = cache.get(cacheKey);
if (cached) return { ...cached, cacheKey, cacheHit: true };
const result = executeCompositorGraph(value, sourceImages, options);
cache.set(cacheKey, result);
return { ...result, cacheKey, cacheHit: false };
}