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

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