Advance WebGPU volume and bounded workflows
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
InstancedMesh,
|
||||
Matrix4,
|
||||
Mesh,
|
||||
MeshBasicMaterial,
|
||||
HemisphereLight,
|
||||
MeshPhysicalMaterial,
|
||||
Raycaster,
|
||||
@@ -24,7 +25,7 @@ import {
|
||||
} from "../vendor/three/three.module.js";
|
||||
import { OrbitControls } from "../vendor/three/addons/controls/OrbitControls.js";
|
||||
import type { MaterialIR, SceneSnapshotIR } from "../../../protocol/scene-ir";
|
||||
import { applySceneDelta, type SceneDelta } from "../../../protocol/scene-delta";
|
||||
import { applySceneDelta, sceneDeltaRequiresRendererRebuild, type SceneDelta } from "../../../protocol/scene-delta";
|
||||
import type { MeshElementMode, MeshGeometryBuffer, WebEngineLODLevelResult } from "../../../protocol/web-engine";
|
||||
import { ThreeLODAdapter, type ThreeLODLevel, type LODSelectionResult } from "./lod";
|
||||
import {
|
||||
@@ -40,9 +41,21 @@ import {
|
||||
import { GPUTextureStore } from "./texture-assets";
|
||||
import type { GPUTextureAsset } from "../../../protocol/render-assets";
|
||||
import { gateEnvironmentImage, gateUDIMImage } from "../../../protocol/render-assets";
|
||||
import { applyNonMeshElementSelection, applyNonMeshTransform, createNonMeshObject, type NonMeshElementKind } from "./nonmesh";
|
||||
import { applyCurveHandlePreview, applyNonMeshElementSelection, applyNonMeshTransform, createNonMeshObject, type NonMeshElementKind } from "./nonmesh";
|
||||
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
|
||||
import { applyGreasePencilTransform, createGreasePencilObject } from "./grease-pencil";
|
||||
import type { CurveGizmoFrameIR, CurveGizmoHandleIR, CurveGizmoScreenFrameIR } from "../../../protocol/nonmesh-interaction";
|
||||
import type { NanoVDBViewportAssetIR, NanoVDBViewportRenderResultIR } from "../volume/nanovdb-viewport";
|
||||
import { NanoVDBViewportRenderSession, renderNanoVDBViewportAsset } from "../volume/nanovdb-viewport";
|
||||
import { createNanoVDBViewportObject } from "./volume";
|
||||
import {
|
||||
applyGreasePencilPointSelection,
|
||||
applyGreasePencilPointPreview,
|
||||
applyGreasePencilTransform,
|
||||
createGreasePencilObject,
|
||||
greasePencilPointRef,
|
||||
type GreasePencilPointRef,
|
||||
type GreasePencilPointPreview,
|
||||
} from "./grease-pencil";
|
||||
|
||||
export function collectMeshInstanceGroups(snapshot: SceneSnapshotIR, minimumSize = 2): Map<string, string[]> {
|
||||
const groups = new Map<string, string[]>();
|
||||
@@ -68,6 +81,7 @@ export class ViewportRenderer {
|
||||
private readonly resizeObserver: ResizeObserver;
|
||||
private animationFrame = 0;
|
||||
private disposed = false;
|
||||
private contextLost = false;
|
||||
private currentSnapshot: SceneSnapshotIR | null = null;
|
||||
private readonly objectByBlenderId = new Map<string, Object3D>();
|
||||
private readonly instanceIndexByBlenderId = new Map<string, number>();
|
||||
@@ -77,17 +91,33 @@ export class ViewportRenderer {
|
||||
private readonly pointer = new Vector2();
|
||||
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) => void;
|
||||
private editMode = false;
|
||||
private selectionMode: MeshElementMode = "FACE";
|
||||
private curveGizmoFrame: { dataId: string; frame: CurveGizmoFrameIR } | null = null;
|
||||
private curveGizmoScreenFrame = "";
|
||||
private volumeAssets: NanoVDBViewportAssetIR[] = [];
|
||||
private readonly volumeRenderCache = new Map<string, NanoVDBViewportRenderResultIR>();
|
||||
private volumeRenderGeneration = 0;
|
||||
private readonly volumeRenderSession: NanoVDBViewportRenderSession;
|
||||
|
||||
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) => void,
|
||||
) {
|
||||
this.canvas = canvas;
|
||||
this.onSelect = onSelect;
|
||||
this.onElementSelect = onElementSelect;
|
||||
this.onGreasePencilPointSelect = onGreasePencilPointSelect;
|
||||
this.volumeRenderSession = new NanoVDBViewportRenderSession(() => {
|
||||
if (this.disposed) return;
|
||||
this.volumeRenderCache.clear();
|
||||
this.canvas.dataset.volumeStatus = "loading";
|
||||
void this.refreshVolumes();
|
||||
});
|
||||
this.raycaster.params.Points.threshold = 0.14;
|
||||
this.renderer = new WebGLRenderer({ canvas, antialias: true, alpha: false, preserveDrawingBuffer: true });
|
||||
configurePBRRenderer(this.renderer);
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||
@@ -96,6 +126,7 @@ export class ViewportRenderer {
|
||||
this.canvas.dataset.pbrProfile = PBR_PROFILE;
|
||||
this.canvas.dataset.toneMapping = PBR_TONE_MAPPING;
|
||||
this.canvas.dataset.shadowMap = PBR_SHADOW_PROFILE;
|
||||
this.canvas.dataset.deviceStatus = "ready";
|
||||
this.scene = new Scene();
|
||||
this.camera = new PerspectiveCamera(45, 1, 0.01, 1000);
|
||||
this.camera.position.set(4.5, -4.5, 3.5);
|
||||
@@ -118,6 +149,8 @@ export class ViewportRenderer {
|
||||
this.resizeObserver = new ResizeObserver(() => this.resize());
|
||||
this.resizeObserver.observe(canvas);
|
||||
this.canvas.addEventListener("click", this.handleClick);
|
||||
this.canvas.addEventListener("webglcontextlost", this.handleContextLost);
|
||||
this.canvas.addEventListener("webglcontextrestored", this.handleContextRestored);
|
||||
this.resize();
|
||||
this.renderLoop();
|
||||
}
|
||||
@@ -243,6 +276,7 @@ export class ViewportRenderer {
|
||||
this.objectByBlenderId.set(node.id, mesh);
|
||||
}
|
||||
this.coalesceMeshInstances(snapshot);
|
||||
void this.refreshVolumes();
|
||||
if (this.importedRoot.children.length > 0) {
|
||||
this.controls.target.set(0, 0, 0);
|
||||
}
|
||||
@@ -256,6 +290,7 @@ export class ViewportRenderer {
|
||||
if (!node.visible || !node.dataId || node.type === "MESH" || node.type === "LIGHT" || node.type === "CAMERA") continue;
|
||||
const data = dataById.get(node.dataId);
|
||||
if (!data) continue;
|
||||
if (data.type === "VOLUME") continue;
|
||||
const object = createNonMeshObject(data, nonMeshGeometryBuffers);
|
||||
if (!object) {
|
||||
blockedCount++;
|
||||
@@ -322,11 +357,68 @@ export class ViewportRenderer {
|
||||
});
|
||||
}
|
||||
|
||||
setVolumeAssets(assets: readonly NanoVDBViewportAssetIR[]): void {
|
||||
this.volumeAssets = [...assets];
|
||||
void this.refreshVolumes();
|
||||
}
|
||||
|
||||
private async refreshVolumes(): Promise<void> {
|
||||
const generation = ++this.volumeRenderGeneration;
|
||||
for (const child of [...this.importedRoot.children]) {
|
||||
if (!child.userData.nanoVDBVolume) continue;
|
||||
this.importedRoot.remove(child);
|
||||
child.traverse((object) => {
|
||||
const mesh = object as Mesh;
|
||||
mesh.geometry?.dispose?.();
|
||||
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
|
||||
for (const material of materials) {
|
||||
if (material instanceof MeshBasicMaterial) material.map?.dispose();
|
||||
material?.dispose?.();
|
||||
}
|
||||
});
|
||||
}
|
||||
const snapshot = this.currentSnapshot;
|
||||
const volumeNodes = snapshot?.nodes.filter((node) => node.visible && node.type === "VOLUME" && node.dataId) ?? [];
|
||||
if (!snapshot || volumeNodes.length === 0) {
|
||||
this.canvas.dataset.volumeStatus = "none";
|
||||
this.canvas.dataset.volumeCount = "0";
|
||||
return;
|
||||
}
|
||||
this.canvas.dataset.volumeStatus = "loading";
|
||||
this.canvas.dataset.volumeCount = "0";
|
||||
try {
|
||||
let rendered = 0;
|
||||
for (const node of volumeNodes) {
|
||||
const asset = this.volumeAssets.find((candidate) => candidate.dataId === node.dataId);
|
||||
if (!asset) continue;
|
||||
const cacheKey = `${asset.dataId}:${asset.manifest.bundleSha256}:${JSON.stringify(asset.material ?? asset.manifest.material)}`;
|
||||
let result = this.volumeRenderCache.get(cacheKey);
|
||||
if (!result) {
|
||||
result = await renderNanoVDBViewportAsset(asset, 128, 128, this.volumeRenderSession);
|
||||
this.volumeRenderCache.set(cacheKey, result);
|
||||
}
|
||||
if (generation !== this.volumeRenderGeneration || this.currentSnapshot !== snapshot) return;
|
||||
const object = createNanoVDBViewportObject(result, node);
|
||||
this.importedRoot.add(object);
|
||||
this.objectByBlenderId.set(node.id, object);
|
||||
rendered++;
|
||||
}
|
||||
if (generation !== this.volumeRenderGeneration) return;
|
||||
this.canvas.dataset.volumeCount = String(rendered);
|
||||
this.canvas.dataset.volumeStatus = rendered === volumeNodes.length ? "ready" : "blocked";
|
||||
this.canvas.dataset.volumeErrorCode = rendered === volumeNodes.length ? "" : "NON_MESH_RESOURCE_MISSING";
|
||||
}
|
||||
catch (error) {
|
||||
if (generation !== this.volumeRenderGeneration) return;
|
||||
this.canvas.dataset.volumeStatus = "blocked";
|
||||
this.canvas.dataset.volumeErrorCode = error instanceof Error ? error.message.split(":", 1)[0] : "VOLUME_SHADER_UNAVAILABLE";
|
||||
}
|
||||
}
|
||||
|
||||
applyDelta(delta: SceneDelta, geometryBuffers: MeshGeometryBuffer[] = [], nonMeshGeometryBuffers: NonMeshGeometryChunk[] = []): void {
|
||||
if (!this.currentSnapshot) throw new Error("Cannot apply a SceneDelta before a snapshot");
|
||||
const next = applySceneDelta(this.currentSnapshot, delta);
|
||||
const hasLifecycleChanges = Boolean(delta.nodes?.added?.length || delta.nodes?.removed?.length ||
|
||||
delta.meshes || delta.materials || delta.cameras || delta.lights || delta.animations);
|
||||
const hasLifecycleChanges = sceneDeltaRequiresRendererRebuild(delta);
|
||||
if (hasLifecycleChanges) {
|
||||
this.setSnapshot(next, geometryBuffers, nonMeshGeometryBuffers);
|
||||
return;
|
||||
@@ -353,7 +445,11 @@ export class ViewportRenderer {
|
||||
this.currentSnapshot = next;
|
||||
}
|
||||
|
||||
setSelection(objectIds: ReadonlySet<string>, elementSelection?: ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>): void {
|
||||
setSelection(
|
||||
objectIds: ReadonlySet<string>,
|
||||
elementSelection?: ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>,
|
||||
greasePencilPoints: readonly GreasePencilPointRef[] = [],
|
||||
): void {
|
||||
const visitedInstances = new Set<InstancedMesh>();
|
||||
for (const [objectId, object] of this.objectByBlenderId) {
|
||||
if (object instanceof InstancedMesh) {
|
||||
@@ -374,11 +470,30 @@ export class ViewportRenderer {
|
||||
}
|
||||
}
|
||||
applyNonMeshElementSelection(this.importedRoot, elementSelection ?? new Map());
|
||||
applyGreasePencilPointSelection(this.importedRoot, greasePencilPoints);
|
||||
}
|
||||
|
||||
setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): void {
|
||||
this.editMode = editMode;
|
||||
this.selectionMode = selectionMode;
|
||||
this.importedRoot.traverse((object) => {
|
||||
if (typeof object.userData.greasePencilPointDataId === "string") object.visible = editMode;
|
||||
});
|
||||
}
|
||||
|
||||
setCurveHandlePreview(dataId: string, handles: readonly CurveGizmoHandleIR[] | null): void {
|
||||
applyCurveHandlePreview(this.importedRoot, dataId, handles);
|
||||
this.canvas.dataset.curveGizmoPreview = handles ? String(handles.length) : "0";
|
||||
}
|
||||
|
||||
setGreasePencilPointPreview(dataId: string, layerId: string, frame: number, points: readonly GreasePencilPointPreview[] | null): void {
|
||||
applyGreasePencilPointPreview(this.importedRoot, dataId, layerId, frame, points);
|
||||
this.canvas.dataset.greasePencilPreview = points ? String(points.length) : "0";
|
||||
}
|
||||
|
||||
setCurveGizmoFrame(dataId: string | null, frame: CurveGizmoFrameIR | null): void {
|
||||
this.curveGizmoFrame = dataId && frame ? { dataId, frame } : null;
|
||||
this.publishCurveGizmoFrame();
|
||||
}
|
||||
|
||||
registerLOD(meshId: string, levels: readonly ThreeLODLevel[], radius: number): void {
|
||||
@@ -529,6 +644,7 @@ export class ViewportRenderer {
|
||||
if (mesh.geometry && typeof mesh.geometry.dispose === "function") mesh.geometry.dispose();
|
||||
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
|
||||
for (const material of materials) {
|
||||
if (mesh.userData.nanoVDBVolume && material instanceof MeshBasicMaterial) material.map?.dispose();
|
||||
if (material && typeof material.dispose === "function") material.dispose();
|
||||
}
|
||||
});
|
||||
@@ -602,8 +718,17 @@ export class ViewportRenderer {
|
||||
-((event.clientY - bounds.top) / bounds.height) * 2 + 1,
|
||||
);
|
||||
this.raycaster.setFromCamera(this.pointer, this.camera);
|
||||
const hit = this.raycaster.intersectObjects(this.importedRoot.children, true)
|
||||
.find((intersection) => typeof intersection.object.userData.blenderId === "string" || Array.isArray(intersection.object.userData.instanceNodeIds));
|
||||
const hits = this.raycaster.intersectObjects(this.importedRoot.children, true);
|
||||
const greasePencilHit = this.editMode
|
||||
? hits.find((intersection) => intersection.index !== undefined && greasePencilPointRef(intersection.object, intersection.index) !== null)
|
||||
: undefined;
|
||||
if (greasePencilHit?.index !== undefined) {
|
||||
const point = greasePencilPointRef(greasePencilHit.object, greasePencilHit.index);
|
||||
if (point) this.onGreasePencilPointSelect?.(point, event.shiftKey || event.ctrlKey || event.metaKey);
|
||||
return;
|
||||
}
|
||||
const preferredNonMeshHit = this.editMode ? hits.find((intersection) => intersection.index !== undefined && Array.isArray(intersection.object.userData.nonMeshPointKindMap)) : undefined;
|
||||
const hit = preferredNonMeshHit ?? hits.find((intersection) => typeof intersection.object.userData.blenderId === "string" || Array.isArray(intersection.object.userData.instanceNodeIds));
|
||||
if (!hit) return;
|
||||
const additive = event.shiftKey || event.ctrlKey || event.metaKey;
|
||||
const nonMeshDataId = hit.object.userData.nonMeshDataId;
|
||||
@@ -611,7 +736,9 @@ export class ViewportRenderer {
|
||||
const indexMap = hit.object.userData.nonMeshPointIndexMap as number[] | undefined;
|
||||
const pointIndex = indexMap?.[hit.index] ?? Math.max(0, Math.floor(hit.object.userData.nonMeshPointOffset ?? 0) + hit.index);
|
||||
const kindMap = hit.object.userData.nonMeshPointKindMap as NonMeshElementKind[] | undefined;
|
||||
this.onElementSelect?.(nonMeshDataId, "VERT", pointIndex, additive, kindMap?.[hit.index] ?? "CONTROL_POINT");
|
||||
const kind = kindMap?.[hit.index] ?? "CONTROL_POINT";
|
||||
this.canvas.dataset.nonMeshLastPick = `${nonMeshDataId}:${kind}:${pointIndex}`;
|
||||
this.onElementSelect?.(nonMeshDataId, "VERT", pointIndex, additive, kind);
|
||||
return;
|
||||
}
|
||||
const meshId = hit.object.userData.meshId;
|
||||
@@ -662,20 +789,74 @@ export class ViewportRenderer {
|
||||
|
||||
private renderLoop = (): void => {
|
||||
if (this.disposed) return;
|
||||
this.controls.update();
|
||||
this.lodAdapter.update(this.camera, Math.max(1, this.canvas.clientHeight));
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
if (!this.contextLost) {
|
||||
this.controls.update();
|
||||
this.lodAdapter.update(this.camera, Math.max(1, this.canvas.clientHeight));
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
this.publishCurveGizmoFrame();
|
||||
if (this.canvas.dataset.deviceStatus === "restoring") {
|
||||
this.canvas.dataset.deviceStatus = "ready";
|
||||
this.canvas.dispatchEvent(new CustomEvent("viewport-device-restored"));
|
||||
}
|
||||
}
|
||||
this.animationFrame = window.requestAnimationFrame(this.renderLoop);
|
||||
};
|
||||
|
||||
private handleContextLost = (event: Event): void => {
|
||||
event.preventDefault();
|
||||
this.contextLost = true;
|
||||
this.canvas.dataset.deviceStatus = "lost";
|
||||
};
|
||||
|
||||
private handleContextRestored = (): void => {
|
||||
this.contextLost = false;
|
||||
this.canvas.dataset.deviceStatus = "restoring";
|
||||
configurePBRRenderer(this.renderer);
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||
this.renderer.setClearColor(new Color("#25272b"));
|
||||
this.resize();
|
||||
this.volumeRenderCache.clear();
|
||||
void this.refreshVolumes();
|
||||
};
|
||||
|
||||
private publishCurveGizmoFrame(): void {
|
||||
const active = this.curveGizmoFrame;
|
||||
let frame: CurveGizmoScreenFrameIR | null = null;
|
||||
const node = active ? this.currentSnapshot?.nodes.find((candidate) => candidate.dataId === active.dataId && candidate.id === this.currentSnapshot?.activeObjectId) : undefined;
|
||||
const object = node ? this.objectByBlenderId.get(node.id) : undefined;
|
||||
if (active && object) {
|
||||
object.updateWorldMatrix(true, false);
|
||||
this.camera.updateMatrixWorld(true);
|
||||
const project = (value: readonly number[]): Vector3 => new Vector3(value[0], value[2], -value[1]).applyMatrix4(object.matrixWorld).project(this.camera);
|
||||
const origin = project(active.frame.origin);
|
||||
const axes = active.frame.axes.map((axis) => {
|
||||
const endpoint = project([active.frame.origin[0] + axis[0], active.frame.origin[1] + axis[1], active.frame.origin[2] + axis[2]]);
|
||||
const x = endpoint.x - origin.x;
|
||||
const y = origin.y - endpoint.y;
|
||||
const magnitude = Math.hypot(x, y);
|
||||
return magnitude > 1e-8 ? [x / magnitude, y / magnitude] as [number, number] : [0, 0] as [number, number];
|
||||
}) as CurveGizmoScreenFrameIR["axes"];
|
||||
frame = { origin: [(origin.x + 1) / 2, (1 - origin.y) / 2], axes };
|
||||
}
|
||||
const serialized = JSON.stringify(frame);
|
||||
if (serialized === this.curveGizmoScreenFrame) return;
|
||||
this.curveGizmoScreenFrame = serialized;
|
||||
this.canvas.dispatchEvent(new CustomEvent<CurveGizmoScreenFrameIR | null>("curve-gizmo-frame", { detail: frame }));
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
window.cancelAnimationFrame(this.animationFrame);
|
||||
this.resizeObserver.disconnect();
|
||||
this.canvas.removeEventListener("click", this.handleClick);
|
||||
this.canvas.removeEventListener("webglcontextlost", this.handleContextLost);
|
||||
this.canvas.removeEventListener("webglcontextrestored", this.handleContextRestored);
|
||||
this.controls.dispose();
|
||||
this.lodAdapter.clear();
|
||||
this.clearImportedScene();
|
||||
this.volumeRenderGeneration++;
|
||||
this.volumeRenderCache.clear();
|
||||
this.volumeRenderSession.dispose();
|
||||
this.textureStore.dispose();
|
||||
this.renderer.dispose();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user