233 lines
12 KiB
TypeScript
233 lines
12 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";
|
|
|
|
export interface ViewportBackend {
|
|
setSnapshot(snapshot: SceneSnapshotIR, geometryBuffers?: MeshGeometryBuffer[], nonMeshGeometryBuffers?: NonMeshGeometryChunk[]): void;
|
|
setTextureAssets(assets: readonly GPUTextureAsset[]): void;
|
|
setSelection(objectIds: ReadonlySet<string>): void;
|
|
setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): 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,
|
|
): 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);
|
|
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 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;
|
|
|
|
constructor(
|
|
canvas: HTMLCanvasElement,
|
|
onSelect?: (objectId: string, additive: boolean) => void,
|
|
onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void,
|
|
) {
|
|
if (!supportsOffscreenViewport(canvas)) throw new Error("OffscreenCanvas viewport is unavailable");
|
|
this.canvas = canvas;
|
|
this.onSelect = onSelect;
|
|
this.onElementSelect = onElementSelect;
|
|
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";
|
|
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);
|
|
}
|
|
|
|
setSelection(objectIds: ReadonlySet<string>): void {
|
|
this.worker.postMessage({ type: "selection", objectIds: [...objectIds] } satisfies OffscreenViewportRequest);
|
|
}
|
|
|
|
setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): void {
|
|
this.worker.postMessage({ type: "interaction", editMode, selectionMode } 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 };
|
|
this.canvas.setPointerCapture(event.pointerId);
|
|
};
|
|
|
|
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 } 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 === "frame") this.canvas.dataset.rendererPixels = String(message.visiblePixels);
|
|
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);
|
|
}
|
|
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] ?? "";
|
|
}
|
|
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();
|
|
}
|
|
}
|