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): 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(); 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) => 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): 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(); } }