Files
workinf_Blender_Wasm/web/app/src/three-adapter/offscreen-viewport.ts
mes123456 0fe8d2bb56
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled
Advance M8-M11 parity workflows
2026-08-17 04:37:07 -04:00

375 lines
22 KiB
TypeScript

import type { SceneSnapshotIR } from "../../../protocol/scene-ir";
import type { GPUTextureAsset } from "../../../protocol/render-assets";
import { gateEnvironmentImage, gateUDIMImage } from "../../../protocol/render-assets";
import type { MeshElementMode, MeshGeometryBuffer, WebEngineLODLevelResult } from "../../../protocol/web-engine";
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
import { cloneMeshGeometryBuffers } from "../../../protocol/mesh-geometry-delta";
import { nonMeshChunkTransferables } from "../../../protocol/nonmesh-binary";
import type { OffscreenViewportRequest, OffscreenViewportResponse } from "./offscreen-viewport-protocol";
import { PBR_PROFILE, PBR_SHADOW_PROFILE, PBR_TONE_MAPPING } from "./pbr";
import type { NonMeshElementKind } from "./nonmesh";
import type { GreasePencilPointPreview, GreasePencilPointRef } from "./grease-pencil";
import type { CurveGizmoFrameIR, CurveGizmoHandleIR } from "../../../protocol/nonmesh-interaction";
import { cloneNanoVDBViewportAssets, nanoVDBViewportAssetTransferables, type NanoVDBViewportAssetIR } from "../volume/nanovdb-viewport";
import type { GreasePencilDrawingScopeIR, GreasePencilMarqueeBoxIR, GreasePencilMarqueeResultIR } from "../../../protocol/grease-pencil-marquee";
import {
validatePaintDepthVisibilityRequest,
validatePaintDepthVisibilityResult,
type PaintDepthVisibilityRequestIR,
type PaintDepthVisibilityResultIR,
} from "../../../protocol/paint-depth-visibility";
export interface ViewportBackend {
setSnapshot(snapshot: SceneSnapshotIR, geometryBuffers?: MeshGeometryBuffer[], nonMeshGeometryBuffers?: NonMeshGeometryChunk[]): void;
setTextureAssets(assets: readonly GPUTextureAsset[]): void;
setVolumeAssets(assets: readonly NanoVDBViewportAssetIR[]): void;
setSelection(objectIds: ReadonlySet<string>, elementSelection?: ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>, greasePencilPoints?: readonly GreasePencilPointRef[], greasePencilSelectionRevision?: number): void;
setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): void;
samplePaintVisibility(request: PaintDepthVisibilityRequestIR): Promise<PaintDepthVisibilityResultIR>;
selectGreasePencilMarquee(drawing: GreasePencilDrawingScopeIR, box: GreasePencilMarqueeBoxIR, baseRevision: number, baseSelectionRevision: number, additive: boolean): void;
setCurveHandlePreview(dataId: string, handles: readonly CurveGizmoHandleIR[] | null): void;
setGreasePencilPointPreview(dataId: string, layerId: string, frame: number, points: readonly GreasePencilPointPreview[] | null): void;
setCurveGizmoFrame(dataId: string | null, frame: CurveGizmoFrameIR | null): void;
installLODLevels(meshId: string, levels: readonly WebEngineLODLevelResult[]): void;
dispose(): void;
}
export function supportsOffscreenViewport(canvas: HTMLCanvasElement): boolean {
return typeof canvas.transferControlToOffscreen === "function" && typeof Worker !== "undefined";
}
interface SharedOffscreenBackend {
renderer: OffscreenViewportRenderer;
references: number;
disposeTimer?: number;
}
const sharedBackends = new WeakMap<HTMLCanvasElement, SharedOffscreenBackend>();
export function acquireOffscreenViewportRenderer(
canvas: HTMLCanvasElement,
onSelect?: (objectId: string, additive: boolean) => void,
onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void,
onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean, baseSelectionRevision: number) => void,
onGreasePencilMarqueeSelect?: (result: GreasePencilMarqueeResultIR, additive: boolean) => void,
): OffscreenViewportRenderer {
const existing = sharedBackends.get(canvas);
if (existing) {
if (existing.disposeTimer !== undefined) window.clearTimeout(existing.disposeTimer);
existing.disposeTimer = undefined;
existing.references += 1;
return existing.renderer;
}
const renderer = new OffscreenViewportRenderer(canvas, onSelect, onElementSelect, onGreasePencilPointSelect, onGreasePencilMarqueeSelect);
sharedBackends.set(canvas, { renderer, references: 1 });
return renderer;
}
export function releaseOffscreenViewportRenderer(canvas: HTMLCanvasElement, renderer: OffscreenViewportRenderer): void {
const existing = sharedBackends.get(canvas);
if (!existing || existing.renderer !== renderer) return;
existing.references = Math.max(0, existing.references - 1);
if (existing.references > 0) return;
existing.disposeTimer = window.setTimeout(() => {
if (existing.references > 0) return;
existing.renderer.dispose();
sharedBackends.delete(canvas);
}, 0);
}
export class OffscreenViewportRenderer implements ViewportBackend {
private readonly canvas: HTMLCanvasElement;
private readonly worker: Worker;
private readonly resizeObserver: ResizeObserver;
private readonly onSelect?: (objectId: string, additive: boolean) => void;
private readonly onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void;
private readonly onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean, baseSelectionRevision: number) => void;
private readonly onGreasePencilMarqueeSelect?: (result: GreasePencilMarqueeResultIR, additive: boolean) => void;
private pointer: { id: number; x: number; y: number; moved: boolean } | null = null;
private lastSnapshot: SceneSnapshotIR | null = null;
private lastGeometryBuffers: MeshGeometryBuffer[] | null = null;
private lastNonMeshGeometryBuffers: NonMeshGeometryChunk[] | null = null;
private greasePencilSelectionRevision = 0;
private paintDepthRequestSequence = 0;
private readonly pendingPaintDepth = new Map<string, {
request: PaintDepthVisibilityRequestIR;
resolve: (result: PaintDepthVisibilityResultIR) => void;
reject: (error: Error) => void;
}>();
constructor(
canvas: HTMLCanvasElement,
onSelect?: (objectId: string, additive: boolean) => void,
onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void,
onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean, baseSelectionRevision: number) => void,
onGreasePencilMarqueeSelect?: (result: GreasePencilMarqueeResultIR, additive: boolean) => void,
) {
if (!supportsOffscreenViewport(canvas)) throw new Error("OffscreenCanvas viewport is unavailable");
this.canvas = canvas;
this.onSelect = onSelect;
this.onElementSelect = onElementSelect;
this.onGreasePencilPointSelect = onGreasePencilPointSelect;
this.onGreasePencilMarqueeSelect = onGreasePencilMarqueeSelect;
this.worker = new Worker(new URL("../workers/viewport-render.worker.ts", import.meta.url), { type: "module" });
this.worker.onmessage = (event: MessageEvent<OffscreenViewportResponse>) => this.handleMessage(event.data);
const offscreen = canvas.transferControlToOffscreen();
const request: OffscreenViewportRequest = {
type: "init",
canvas: offscreen,
width: Math.max(1, canvas.clientWidth),
height: Math.max(1, canvas.clientHeight),
pixelRatio: Math.min(window.devicePixelRatio || 1, 2),
};
this.worker.postMessage(request, [offscreen]);
this.resizeObserver = new ResizeObserver(() => this.resize());
this.resizeObserver.observe(canvas);
canvas.addEventListener("pointerdown", this.pointerDown);
canvas.addEventListener("pointermove", this.pointerMove);
canvas.addEventListener("pointerup", this.pointerUp);
canvas.addEventListener("pointercancel", this.pointerUp);
canvas.addEventListener("wheel", this.wheel, { passive: false });
canvas.dataset.rendererBackend = "offscreen-worker";
canvas.dataset.pbrProfile = PBR_PROFILE;
canvas.dataset.toneMapping = PBR_TONE_MAPPING;
canvas.dataset.shadowMap = PBR_SHADOW_PROFILE;
}
setSnapshot(snapshot: SceneSnapshotIR, geometryBuffers: MeshGeometryBuffer[] = [], nonMeshGeometryBuffers: NonMeshGeometryChunk[] = []): void {
if (snapshot === this.lastSnapshot && geometryBuffers === this.lastGeometryBuffers && nonMeshGeometryBuffers === this.lastNonMeshGeometryBuffers) return;
this.lastSnapshot = snapshot;
const udim = snapshot.images.find((image) => image.tiles && image.tiles.length > 0);
if (udim) {
const gate = gateUDIMImage(udim);
this.canvas.dataset.udimGate = gate.status.toLowerCase();
this.canvas.dataset.udimGateCode = gate.issues[0]?.code ?? "";
}
const worldId = snapshot.scenes[0]?.worldId;
const world = snapshot.worlds.find((candidate) => candidate.id === worldId) ?? snapshot.worlds[0];
if (world?.environmentImageId) {
const gate = gateEnvironmentImage(snapshot.images.find((image) => image.id === world.environmentImageId));
this.canvas.dataset.iblGate = gate.status.toLowerCase();
this.canvas.dataset.iblGateCode = gate.issues[0]?.code ?? "";
}
this.lastGeometryBuffers = geometryBuffers;
this.lastNonMeshGeometryBuffers = nonMeshGeometryBuffers;
const cloned = cloneMeshGeometryBuffers(geometryBuffers);
const nonMeshCloned = nonMeshGeometryBuffers.map((chunk) => ({
...chunk,
positions: chunk.positions.slice(0),
radii: chunk.radii?.slice(0),
curveOffsets: chunk.curveOffsets?.slice(0),
attributes: chunk.attributes.map((attribute) => ({ ...attribute, data: attribute.data.slice(0) })),
}));
const transfer: Transferable[] = [];
for (const payload of cloned) for (const value of Object.values(payload)) if (value instanceof ArrayBuffer) transfer.push(value);
transfer.push(...nonMeshChunkTransferables(nonMeshCloned));
this.worker.postMessage({ type: "snapshot", snapshot, geometryBuffers: cloned, nonMeshGeometryBuffers: nonMeshCloned } satisfies OffscreenViewportRequest, transfer);
}
setTextureAssets(assets: readonly GPUTextureAsset[]): void {
if (assets.length === 0) {
this.canvas.dataset.textureStatus = "none";
this.canvas.dataset.textureLoaded = "0";
this.canvas.dataset.textureBytes = "0";
this.canvas.dataset.textureBudgetStatus = "ready";
this.canvas.dataset.textureBudgetCode = "";
this.canvas.dataset.textureBudgetAssets = "0";
this.canvas.dataset.textureBudgetPayloadBytes = "0";
this.canvas.dataset.textureBudgetGpuBytes = "0";
return;
}
const cloned = assets.map((asset) => ({ ...asset, data: asset.data.slice(0) }));
const transfer: Transferable[] = cloned.map((asset) => asset.data);
this.worker.postMessage({ type: "textureAssets", assets: cloned } satisfies OffscreenViewportRequest, transfer);
}
setVolumeAssets(assets: readonly NanoVDBViewportAssetIR[]): void {
const cloned = cloneNanoVDBViewportAssets(assets);
this.worker.postMessage({ type: "volumeAssets", assets: cloned } satisfies OffscreenViewportRequest, nanoVDBViewportAssetTransferables(cloned));
}
setSelection(objectIds: ReadonlySet<string>, elementSelection?: ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>, greasePencilPoints: readonly GreasePencilPointRef[] = [], greasePencilSelectionRevision = 0): void {
const elements = [...(elementSelection ?? new Map())].flatMap(([dataId, kinds]) => [...kinds].flatMap(([kind, indices]) => [...indices].map((index) => ({ dataId, kind, index }))));
this.greasePencilSelectionRevision = greasePencilSelectionRevision;
this.worker.postMessage({ type: "selection", objectIds: [...objectIds], elements, greasePencilPoints: [...greasePencilPoints], greasePencilSelectionRevision } satisfies OffscreenViewportRequest);
}
setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): void {
this.worker.postMessage({ type: "interaction", editMode, selectionMode } satisfies OffscreenViewportRequest);
}
samplePaintVisibility(requestValue: PaintDepthVisibilityRequestIR): Promise<PaintDepthVisibilityResultIR> {
const request = validatePaintDepthVisibilityRequest(requestValue, this.lastSnapshot?.revision ?? -1);
const requestId = `paint-depth-${++this.paintDepthRequestSequence}`;
return new Promise((resolve, reject) => {
this.pendingPaintDepth.set(requestId, { request, resolve, reject });
this.worker.postMessage({ type: "paintDepthVisibility", requestId, request } satisfies OffscreenViewportRequest);
});
}
selectGreasePencilMarquee(
drawing: GreasePencilDrawingScopeIR,
box: GreasePencilMarqueeBoxIR,
baseRevision: number,
baseSelectionRevision: number,
additive: boolean,
): void {
this.worker.postMessage({ type: "greasePencilMarquee", drawing, box, baseRevision, baseSelectionRevision, additive } satisfies OffscreenViewportRequest);
}
setCurveHandlePreview(dataId: string, handles: readonly CurveGizmoHandleIR[] | null): void {
this.canvas.dataset.curveGizmoPreview = handles ? String(handles.length) : "0";
this.worker.postMessage({ type: "curveHandlePreview", dataId, handles: handles ? handles.map((handle) => ({ ...handle, position: [...handle.position] as [number, number, number] })) : null } satisfies OffscreenViewportRequest);
}
setGreasePencilPointPreview(dataId: string, layerId: string, frame: number, points: readonly GreasePencilPointPreview[] | null): void {
this.canvas.dataset.greasePencilPreview = points ? String(points.length) : "0";
this.worker.postMessage({ type: "greasePencilPointPreview", dataId, layerId, frame, points: points ? points.map((point) => ({ ...point, position: [...point.position] as [number, number, number] })) : null } satisfies OffscreenViewportRequest);
}
setCurveGizmoFrame(dataId: string | null, frame: CurveGizmoFrameIR | null): void {
this.worker.postMessage({ type: "curveGizmoFrame", dataId, frame } satisfies OffscreenViewportRequest);
}
installLODLevels(): void {
// The main-thread renderer remains the adaptive LOD owner; the worker path
// renders the source instanced mesh and retains native frustum culling.
}
private resize(): void {
this.worker.postMessage({
type: "resize",
width: Math.max(1, this.canvas.clientWidth),
height: Math.max(1, this.canvas.clientHeight),
pixelRatio: Math.min(window.devicePixelRatio || 1, 2),
} satisfies OffscreenViewportRequest);
}
private pointerDown = (event: PointerEvent): void => {
this.pointer = { id: event.pointerId, x: event.clientX, y: event.clientY, moved: false };
try {
this.canvas.setPointerCapture(event.pointerId);
}
catch {
// Synthetic test events and browsers without pointer capture still support picking.
}
};
private pointerMove = (event: PointerEvent): void => {
if (!this.pointer || this.pointer.id !== event.pointerId || (event.buttons & 1) === 0) return;
const deltaX = event.clientX - this.pointer.x;
const deltaY = event.clientY - this.pointer.y;
if (Math.abs(deltaX) + Math.abs(deltaY) > 1) this.pointer.moved = true;
this.pointer.x = event.clientX;
this.pointer.y = event.clientY;
this.worker.postMessage({ type: "orbit", deltaX, deltaY, zoom: 0 } satisfies OffscreenViewportRequest);
};
private pointerUp = (event: PointerEvent): void => {
if (!this.pointer || this.pointer.id !== event.pointerId) return;
if (!this.pointer.moved) {
const bounds = this.canvas.getBoundingClientRect();
const x = ((event.clientX - bounds.left) / Math.max(1, bounds.width)) * 2 - 1;
const y = -((event.clientY - bounds.top) / Math.max(1, bounds.height)) * 2 + 1;
this.worker.postMessage({ type: "pick", x, y, additive: event.shiftKey || event.ctrlKey || event.metaKey, baseSelectionRevision: this.greasePencilSelectionRevision } satisfies OffscreenViewportRequest);
}
this.pointer = null;
};
private wheel = (event: WheelEvent): void => {
event.preventDefault();
this.worker.postMessage({ type: "orbit", deltaX: 0, deltaY: 0, zoom: event.deltaY } satisfies OffscreenViewportRequest);
};
private handleMessage(message: OffscreenViewportResponse): void {
if (message.type === "selected") this.onSelect?.(message.objectId, message.additive);
else if (message.type === "elementSelected") this.onElementSelect?.(message.meshId, message.mode, message.index, message.additive, message.nonMeshKind);
else if (message.type === "greasePencilPointSelected") this.onGreasePencilPointSelect?.(message.point, message.additive, message.baseSelectionRevision);
else if (message.type === "greasePencilSelectionStatus") {
this.canvas.dataset.greasePencilSelectionRevision = String(message.selectionRevision);
this.canvas.dataset.greasePencilSelectionPointIds = message.pointIds.join(",");
}
else if (message.type === "greasePencilMarqueeSelected") {
this.canvas.dataset.greasePencilMarqueeSelectionRevision = String(message.result.baseSelectionRevision);
this.canvas.dataset.greasePencilMarqueeDrawingId = message.result.drawing.drawingId;
this.canvas.dataset.greasePencilMarqueePointIds = message.result.selectedPoints.map((point) => point.pointId).join(",");
this.canvas.dataset.greasePencilMarqueeStrokeIds = message.result.selectedStrokeIds.join(",");
this.canvas.dataset.greasePencilMarqueeCount = String(message.result.selectedPoints.length);
this.onGreasePencilMarqueeSelect?.(message.result, message.additive);
}
else if (message.type === "paintDepthVisibilityResult") {
const pending = this.pendingPaintDepth.get(message.requestId);
if (!pending) return;
this.pendingPaintDepth.delete(message.requestId);
try { pending.resolve(validatePaintDepthVisibilityResult(message.result, pending.request)); }
catch (error) { pending.reject(error instanceof Error ? error : new Error(String(error))); }
}
else if (message.type === "paintDepthVisibilityError") {
const pending = this.pendingPaintDepth.get(message.requestId);
if (!pending) return;
this.pendingPaintDepth.delete(message.requestId);
pending.reject(new Error(message.message));
}
else if (message.type === "frame") {
this.canvas.dataset.rendererPixels = String(message.visiblePixels);
if (message.camera) {
this.canvas.dataset.cameraPosition = message.camera.position.map((value) => Number(value.toFixed(6))).join(",");
this.canvas.dataset.cameraTarget = message.camera.target.map((value) => Number(value.toFixed(6))).join(",");
this.canvas.dataset.cameraYaw = message.camera.yaw.toFixed(6);
this.canvas.dataset.cameraPitch = message.camera.pitch.toFixed(6);
this.canvas.dataset.cameraDistance = message.camera.distance.toFixed(6);
}
}
else if (message.type === "snapshotStatus") {
this.canvas.dataset.nonMeshCount = String(message.nonMeshCount);
this.canvas.dataset.nonMeshBlockedCount = String(message.nonMeshBlockedCount);
this.canvas.dataset.greasePencilCount = String(message.greasePencilCount);
this.canvas.dataset.greasePencilBlockedCount = String(message.greasePencilBlockedCount);
this.canvas.dataset.greasePencilOnionStrokeCount = String(message.greasePencilOnionStrokeCount);
this.canvas.dataset.renderBudgetBackend = message.renderBudget.backend;
this.canvas.dataset.renderBudgetStatus = message.renderBudget.status.toLowerCase();
this.canvas.dataset.renderBudgetCode = message.renderBudget.issues[0]?.code ?? "";
this.canvas.dataset.renderBudgetLights = String(message.renderBudget.requestedLights);
this.canvas.dataset.renderBudgetRenderedLights = String(message.renderBudget.renderedLightNodeIds.length);
this.canvas.dataset.renderBudgetDroppedLights = String(message.renderBudget.droppedLightNodeIds.length);
this.canvas.dataset.renderBudgetShadows = String(message.renderBudget.requestedShadowMaps);
this.canvas.dataset.renderBudgetRenderedShadows = String(message.renderBudget.shadowLightNodeIds.length);
this.canvas.dataset.renderBudgetBlockedShadows = String(message.renderBudget.shadowBlockedLightNodeIds.length);
this.canvas.dataset.renderBudgetShadowMapDimension = String(message.renderBudget.budget.shadowMapDimension);
}
else if (message.type === "textureStatus") {
this.canvas.dataset.textureStatus = message.rejected > 0 ? "blocked" : "ready";
this.canvas.dataset.textureLoaded = String(message.loaded);
this.canvas.dataset.textureBytes = String(message.bytes);
this.canvas.dataset.textureErrorCode = message.errorCodes[0] ?? "";
this.canvas.dataset.textureBudgetStatus = message.budget.status.toLowerCase();
this.canvas.dataset.textureBudgetCode = message.budget.issues[0]?.code ?? "";
this.canvas.dataset.textureBudgetAssets = String(message.budget.requestedAssets);
this.canvas.dataset.textureBudgetPayloadBytes = String(message.budget.payloadBytes);
this.canvas.dataset.textureBudgetGpuBytes = String(message.budget.decodedGPUBytes);
}
else if (message.type === "volumeStatus") {
this.canvas.dataset.volumeStatus = message.status;
this.canvas.dataset.volumeCount = String(message.count);
this.canvas.dataset.volumeErrorCode = message.errorCode ?? "";
}
else if (message.type === "curveGizmoScreenFrame") {
this.canvas.dispatchEvent(new CustomEvent("curve-gizmo-frame", { detail: message.frame }));
}
else if (message.type === "error") this.canvas.dataset.rendererError = message.message;
}
dispose(): void {
this.resizeObserver.disconnect();
this.canvas.removeEventListener("pointerdown", this.pointerDown);
this.canvas.removeEventListener("pointermove", this.pointerMove);
this.canvas.removeEventListener("pointerup", this.pointerUp);
this.canvas.removeEventListener("pointercancel", this.pointerUp);
this.canvas.removeEventListener("wheel", this.wheel);
this.worker.postMessage({ type: "dispose" } satisfies OffscreenViewportRequest);
this.worker.terminate();
for (const pending of this.pendingPaintDepth.values()) pending.reject(new Error("PAINT_DEPTH_UNAVAILABLE: Offscreen viewport disposed"));
this.pendingPaintDepth.clear();
}
}