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

@@ -3,6 +3,7 @@ export const PAINT_BUDGET = {
maxWeightEntries: 1_000_000,
maxTextureTileBytes: 256 * 1024 * 1024,
maxStrokeBytes: 64 * 1024 * 1024,
maxSpatialCells: 1_000_000,
} as const;
export type PaintMode = "VERTEX_COLOR" | "WEIGHT" | "TEXTURE";
@@ -51,6 +52,112 @@ export interface PaintBrushVertexIR {
export interface PaintBrushWeightIR { index: number; weight: number }
export interface PaintBrushQueryOptionsIR {
ignoreOccluded?: boolean;
frontFaceOnly?: boolean;
viewDirection?: [number, number, number];
visibleVertexIndices?: readonly number[];
requireVisibility?: boolean;
selectedVertexIndices?: readonly number[];
requireSelection?: boolean;
maskWeights?: readonly PaintBrushWeightIR[];
}
function validateBrushGateIdentities(vertices: readonly PaintBrushVertexIR[], options: PaintBrushQueryOptionsIR): void {
const known = new Set(vertices.map((vertex) => vertex.index));
for (const [path, values] of [["visibleVertexIndices", options.visibleVertexIndices], ["selectedVertexIndices", options.selectedVertexIndices]] as const) {
values?.forEach((value, index) => {
const vertexIndex = integer(value, `${path}[${index}]`);
if (!known.has(vertexIndex)) fail(`${path}[${index}]`, "references an unknown vertex identity");
});
}
options.maskWeights?.forEach((value, index) => {
const entry = record(value, `maskWeights[${index}]`);
const vertexIndex = integer(entry.index, `maskWeights[${index}].index`);
if (!known.has(vertexIndex)) fail(`maskWeights[${index}].index`, "references an unknown vertex identity");
});
}
export interface PaintBrushSpatialIndex {
readonly schemaVersion: 1;
readonly cellSize: number;
readonly vertices: readonly PaintBrushVertexIR[];
readonly cells: ReadonlyMap<string, readonly number[]>;
}
export interface PaintBrushSpatialQueryIR {
weights: PaintBrushWeightIR[];
candidateCount: number;
visitedCellCount: number;
}
export interface PaintColorPatchIR { indices: number[]; colors: number[] }
function parseBrushWeights(value: unknown, path = "brushWeights"): PaintBrushWeightIR[] {
if (!Array.isArray(value) || value.length > PAINT_BUDGET.maxWeightEntries) fail(path, "exceeds the brush patch budget", true);
if (value.length === 0) fail(path, "must contain at least one brush hit");
const seen = new Set<number>();
return value.map((item, index) => {
const entry = record(item, `${path}[${index}]`);
const vertexIndex = integer(entry.index, `${path}[${index}].index`);
const weight = finite(entry.weight, `${path}[${index}].weight`);
if (weight < 0 || weight > 1) fail(`${path}[${index}].weight`, "must be in [0,1]");
if (seen.has(vertexIndex)) fail(`${path}[${index}].index`, "contains a duplicate vertex");
seen.add(vertexIndex);
return { index: vertexIndex, weight };
}).sort((left, right) => left.index - right.index);
}
export function composePaintWeightPatch(
objectId: string,
vertexGroup: string,
revision: number,
currentRevision: number,
currentWeightsValue: unknown,
brushWeightsValue: unknown,
targetValue: unknown,
): WeightPatchIR {
const parsedRevision = integer(revision, "revision");
if (parsedRevision !== integer(currentRevision, "currentRevision")) throw new Error("REVISION_CONFLICT: Paint weight stroke is stale");
if (!Array.isArray(currentWeightsValue)) fail("currentWeights", "must be an array");
const currentWeights = currentWeightsValue.map((value, index) => {
const weight = finite(value, `currentWeights[${index}]`);
if (weight < 0 || weight > 1) fail(`currentWeights[${index}]`, "must be in [0,1]");
return weight;
});
const target = finite(targetValue, "targetWeight");
if (target < 0 || target > 1) fail("targetWeight", "must be in [0,1]");
const weights = parseBrushWeights(brushWeightsValue);
if (weights.some((entry) => entry.index >= currentWeights.length)) fail("brushWeights", "references an unknown current weight");
return parseWeightPatch({ schemaVersion: 1, objectId, revision: parsedRevision, vertexGroup, indices: weights.map((entry) => entry.index), values: weights.map((entry) => currentWeights[entry.index] + (target - currentWeights[entry.index]) * entry.weight), normalize: false });
}
export function composePaintColorPatch(
revision: number,
currentRevision: number,
currentColorsValue: unknown,
brushWeightsValue: unknown,
targetColorValue: unknown,
): PaintColorPatchIR {
if (integer(revision, "revision") !== integer(currentRevision, "currentRevision")) throw new Error("REVISION_CONFLICT: Paint color stroke is stale");
if (!Array.isArray(currentColorsValue) || currentColorsValue.length % 4 !== 0) fail("currentColors", "must contain RGBA values");
const currentColors = currentColorsValue.map((value, index) => {
const component = finite(value, `currentColors[${index}]`);
if (component < 0 || component > 1) fail(`currentColors[${index}]`, "must be in [0,1]");
return component;
});
const target = tuple(targetColorValue, 4, "targetColor");
if (target.some((component) => component < 0 || component > 1)) fail("targetColor", "must be in [0,1]");
const weights = parseBrushWeights(brushWeightsValue);
if (weights.some((entry) => entry.index >= currentColors.length / 4)) fail("brushWeights", "references an unknown current color");
return {
indices: weights.map((entry) => entry.index),
colors: weights.flatMap((entry) => Array.from({ length: 4 }, (_, component) => currentColors[entry.index * 4 + component] + (target[component] - currentColors[entry.index * 4 + component]) * entry.weight)),
};
}
const paintBrushSpatialIndexes = new WeakMap<object, { cellSize: number; vertices: PaintBrushVertexIR[]; cells: Map<string, number[]> }>();
export interface UdimTilePatchIR {
schemaVersion: 1;
textureAssetId: string;
@@ -178,14 +285,33 @@ export function parseWeightPatch(value: unknown): WeightPatchIR {
return result;
}
export function computePaintBrushWeights(
verticesValue: unknown,
function parsePaintBrushVertices(verticesValue: unknown): PaintBrushVertexIR[] {
if (!Array.isArray(verticesValue) || verticesValue.length > PAINT_BUDGET.maxWeightEntries) fail("vertices", "exceeds the brush vertex budget", true);
const seen = new Set<number>();
return verticesValue.map((item, vertexIndex) => {
const vertex = record(item, `vertices[${vertexIndex}]`);
const index = integer(vertex.index, `vertices[${vertexIndex}].index`);
if (seen.has(index)) fail(`vertices[${vertexIndex}].index`, "contains a duplicate vertex");
seen.add(index);
const position = tuple(vertex.position, 3, `vertices[${vertexIndex}].position`) as [number, number, number];
if (position.some((component) => Math.abs(component) > 1_000_000_000)) fail(`vertices[${vertexIndex}].position`, "is outside the spatial index range");
const result: PaintBrushVertexIR = { index, position };
if (vertex.occluded !== undefined) {
if (typeof vertex.occluded !== "boolean") fail(`vertices[${vertexIndex}].occluded`, "must be boolean");
result.occluded = vertex.occluded;
}
if (vertex.normal !== undefined) result.normal = tuple(vertex.normal, 3, `vertices[${vertexIndex}].normal`) as [number, number, number];
return result;
});
}
function brushWeights(
vertices: readonly PaintBrushVertexIR[],
centerValue: unknown,
radiusValue: unknown,
strengthValue: unknown,
options: { ignoreOccluded?: boolean; frontFaceOnly?: boolean; viewDirection?: [number, number, number] } = {},
options: PaintBrushQueryOptionsIR = {},
): PaintBrushWeightIR[] {
if (!Array.isArray(verticesValue) || verticesValue.length > PAINT_BUDGET.maxWeightEntries) fail("vertices", "exceeds the brush vertex budget", true);
const center = tuple(centerValue, 3, "center") as [number, number, number];
const radius = finite(radiusValue, "radius");
const strength = finite(strengthValue, "strength");
@@ -193,30 +319,112 @@ export function computePaintBrushWeights(
if (strength < 0 || strength > 1) fail("strength", "must be in [0,1]");
const viewDirection = options.viewDirection ?? [0, 0, -1];
tuple(viewDirection, 3, "viewDirection");
if (options.requireVisibility && options.visibleVertexIndices === undefined) fail("visibleVertexIndices", "is required for depth-gated brush queries");
if (options.visibleVertexIndices !== undefined && (!Array.isArray(options.visibleVertexIndices) || options.visibleVertexIndices.length > PAINT_BUDGET.maxWeightEntries)) fail("visibleVertexIndices", "exceeds the visibility budget", true);
const visible = options.visibleVertexIndices === undefined ? undefined : new Set(options.visibleVertexIndices.map((value, index) => integer(value, `visibleVertexIndices[${index}]`)));
if (visible && visible.size !== options.visibleVertexIndices?.length) fail("visibleVertexIndices", "contains duplicates");
if (options.requireSelection && options.selectedVertexIndices === undefined) fail("selectedVertexIndices", "is required for selection-gated brush queries");
if (options.selectedVertexIndices !== undefined && (!Array.isArray(options.selectedVertexIndices) || options.selectedVertexIndices.length > PAINT_BUDGET.maxWeightEntries)) fail("selectedVertexIndices", "exceeds the selection budget", true);
const selected = options.selectedVertexIndices === undefined ? undefined : new Set(options.selectedVertexIndices.map((value, index) => integer(value, `selectedVertexIndices[${index}]`)));
if (selected && selected.size !== options.selectedVertexIndices?.length) fail("selectedVertexIndices", "contains duplicates");
if (options.maskWeights !== undefined && (!Array.isArray(options.maskWeights) || options.maskWeights.length > PAINT_BUDGET.maxWeightEntries)) fail("maskWeights", "exceeds the mask budget", true);
const mask = options.maskWeights === undefined ? undefined : new Map<number, number>();
options.maskWeights?.forEach((value, index) => {
const entry = record(value, `maskWeights[${index}]`);
const vertexIndex = integer(entry.index, `maskWeights[${index}].index`);
const weight = finite(entry.weight, `maskWeights[${index}].weight`);
if (weight < 0 || weight > 1) fail(`maskWeights[${index}].weight`, "must be in [0,1]");
if (mask!.has(vertexIndex)) fail(`maskWeights[${index}].index`, "contains a duplicate vertex");
mask!.set(vertexIndex, weight);
});
const result: PaintBrushWeightIR[] = [];
const seen = new Set<number>();
for (const [vertexIndex, item] of verticesValue.entries()) {
const vertex = record(item, `vertices[${vertexIndex}]`);
const index = integer(vertex.index, `vertices[${vertexIndex}].index`);
if (seen.has(index)) fail(`vertices[${vertexIndex}].index`, "contains a duplicate vertex");
seen.add(index);
const position = tuple(vertex.position, 3, `vertices[${vertexIndex}].position`) as [number, number, number];
if (vertex.occluded !== undefined && typeof vertex.occluded !== "boolean") fail(`vertices[${vertexIndex}].occluded`, "must be boolean");
for (const vertex of vertices) {
const { index, position } = vertex;
if (visible && !visible.has(index)) continue;
if (selected && !selected.has(index)) continue;
const maskWeight = mask?.get(index) ?? (mask ? 0 : 1);
if (maskWeight === 0) continue;
if (options.ignoreOccluded !== false && vertex.occluded === true) continue;
if (vertex.normal !== undefined) {
const normal = tuple(vertex.normal, 3, `vertices[${vertexIndex}].normal`) as [number, number, number];
const normal = tuple(vertex.normal, 3, `vertices[${index}].normal`) as [number, number, number];
if (options.frontFaceOnly && normal[0] * viewDirection[0] + normal[1] * viewDirection[1] + normal[2] * viewDirection[2] >= 0) continue;
}
const distance = Math.hypot(position[0] - center[0], position[1] - center[1], position[2] - center[2]);
if (distance > radius) continue;
const normalized = distance / radius;
const smoothstep = 1 - normalized * normalized * (3 - 2 * normalized);
const weight = Math.max(0, Math.min(1, strength * smoothstep));
const weight = Math.max(0, Math.min(1, strength * smoothstep * maskWeight));
if (weight > 0) result.push({ index, weight });
}
return result.sort((left, right) => left.index - right.index);
}
export function computePaintBrushWeights(
verticesValue: unknown,
centerValue: unknown,
radiusValue: unknown,
strengthValue: unknown,
options: PaintBrushQueryOptionsIR = {},
): PaintBrushWeightIR[] {
const vertices = parsePaintBrushVertices(verticesValue);
validateBrushGateIdentities(vertices, options);
return brushWeights(vertices, centerValue, radiusValue, strengthValue, options);
}
function spatialCell(position: readonly number[], cellSize: number): [number, number, number] {
return [Math.floor(position[0] / cellSize), Math.floor(position[1] / cellSize), Math.floor(position[2] / cellSize)];
}
function spatialKey(x: number, y: number, z: number): string { return `${x}:${y}:${z}`; }
export function buildPaintBrushSpatialIndex(verticesValue: unknown, cellSizeValue: unknown): PaintBrushSpatialIndex {
const vertices = parsePaintBrushVertices(verticesValue);
const cellSize = finite(cellSizeValue, "cellSize");
if (cellSize < 1e-6 || cellSize > 100_000) fail("cellSize", "is outside the bounded range");
const cells = new Map<string, number[]>();
vertices.forEach((vertex, offset) => {
const cell = spatialCell(vertex.position, cellSize);
if (cell.some((component) => !Number.isSafeInteger(component))) fail("vertices", "produces an unsafe spatial cell");
const key = spatialKey(...cell);
const offsets = cells.get(key) ?? [];
offsets.push(offset);
cells.set(key, offsets);
});
if (cells.size > PAINT_BUDGET.maxSpatialCells) fail("vertices", "exceeds the spatial cell budget", true);
const publicVertices = vertices.map((vertex) => ({ ...vertex, position: [...vertex.position] as [number, number, number], ...(vertex.normal ? { normal: [...vertex.normal] as [number, number, number] } : {}) }));
const publicCells = new Map([...cells].map(([key, offsets]) => [key, [...offsets]]));
const index: PaintBrushSpatialIndex = Object.freeze({ schemaVersion: 1, cellSize, vertices: publicVertices, cells: publicCells });
paintBrushSpatialIndexes.set(index, { cellSize, vertices, cells });
return index;
}
export function queryPaintBrushSpatialIndex(
index: PaintBrushSpatialIndex,
centerValue: unknown,
radiusValue: unknown,
strengthValue: unknown,
options: PaintBrushQueryOptionsIR = {},
): PaintBrushSpatialQueryIR {
const source = paintBrushSpatialIndexes.get(index);
if (!source) fail("spatialIndex", "is invalid");
validateBrushGateIdentities(source.vertices, options);
const center = tuple(centerValue, 3, "center") as [number, number, number];
const radius = finite(radiusValue, "radius");
if (radius <= 0 || radius > 100_000) fail("radius", "is outside the bounded range");
const minimum = spatialCell(center.map((component) => component - radius), source.cellSize);
const maximum = spatialCell(center.map((component) => component + radius), source.cellSize);
const spans = maximum.map((component, axis) => component - minimum[axis] + 1);
if (spans.some((span) => !Number.isSafeInteger(span) || span <= 0) || spans[0] > PAINT_BUDGET.maxSpatialCells / spans[1] / spans[2]) fail("spatialQuery", "exceeds the visited cell budget", true);
const offsets = new Set<number>();
let visitedCellCount = 0;
for (let x = minimum[0]; x <= maximum[0]; x++) for (let y = minimum[1]; y <= maximum[1]; y++) for (let z = minimum[2]; z <= maximum[2]; z++) {
visitedCellCount++;
for (const offset of source.cells.get(spatialKey(x, y, z)) ?? []) offsets.add(offset);
}
const candidates = [...offsets].sort((left, right) => left - right).map((offset) => source.vertices[offset]);
return { weights: brushWeights(candidates, center, radius, strengthValue, options), candidateCount: candidates.length, visitedCellCount };
}
export function parseUdimTilePatch(value: unknown): UdimTilePatchIR {
const patch = record(value, "udimPatch");
if (patch.schemaVersion !== 1 || patch.format !== "RGBA8" || (patch.colorSpace !== "SRGB" && patch.colorSpace !== "LINEAR")) fail("udimPatch", "has an unsupported schema or pixel format");