Advance WebGPU volume and bounded workflows
This commit is contained in:
@@ -5,6 +5,8 @@ import {
|
||||
Group,
|
||||
Line,
|
||||
LineBasicMaterial,
|
||||
Points,
|
||||
PointsMaterial,
|
||||
type Object3D,
|
||||
} from "../vendor/three/three.module.js";
|
||||
import type { GreasePencilDataIR, GreasePencilDrawingIR, GreasePencilFrameIR, GreasePencilLayerIR } from "../../../protocol/grease-pencil";
|
||||
@@ -12,9 +14,26 @@ import type { SceneNodeIR } from "../../../protocol/scene-ir";
|
||||
|
||||
interface DrawingPreview {
|
||||
drawing: GreasePencilDrawingIR;
|
||||
frame: number;
|
||||
onion: "NONE" | "PREVIOUS" | "NEXT";
|
||||
}
|
||||
|
||||
export interface GreasePencilPointRef {
|
||||
dataId: string;
|
||||
layerId: string;
|
||||
frame: number;
|
||||
strokeIndex: number;
|
||||
pointIndex: number;
|
||||
}
|
||||
|
||||
export interface GreasePencilPointPreview extends GreasePencilPointRef {
|
||||
position: [number, number, number];
|
||||
}
|
||||
|
||||
function blenderPosition(position: readonly number[]): [number, number, number] {
|
||||
return [position[0], position[2], -position[1]];
|
||||
}
|
||||
|
||||
function activeFrame(frames: readonly GreasePencilFrameIR[], frame: number): GreasePencilFrameIR | undefined {
|
||||
let selected: GreasePencilFrameIR | undefined;
|
||||
for (const candidate of frames) {
|
||||
@@ -26,20 +45,26 @@ function activeFrame(frames: readonly GreasePencilFrameIR[], frame: number): Gre
|
||||
function layerDrawings(layer: GreasePencilLayerIR, frame: number): DrawingPreview[] {
|
||||
const current = activeFrame(layer.frames, frame);
|
||||
if (!current) return [];
|
||||
const result: DrawingPreview[] = [{ drawing: current.drawing, onion: "NONE" }];
|
||||
const result: DrawingPreview[] = [{ drawing: current.drawing, frame: current.frame, onion: "NONE" }];
|
||||
if (!layer.onionSkinning) return result;
|
||||
const sorted = [...layer.frames].sort((left, right) => left.frame - right.frame);
|
||||
const currentIndex = sorted.findIndex((candidate) => candidate.frame === current.frame);
|
||||
if (currentIndex > 0) result.unshift({ drawing: sorted[currentIndex - 1].drawing, onion: "PREVIOUS" });
|
||||
if (currentIndex >= 0 && currentIndex + 1 < sorted.length) result.push({ drawing: sorted[currentIndex + 1].drawing, onion: "NEXT" });
|
||||
if (currentIndex > 0) result.unshift({ drawing: sorted[currentIndex - 1].drawing, frame: sorted[currentIndex - 1].frame, onion: "PREVIOUS" });
|
||||
if (currentIndex >= 0 && currentIndex + 1 < sorted.length) result.push({ drawing: sorted[currentIndex + 1].drawing, frame: sorted[currentIndex + 1].frame, onion: "NEXT" });
|
||||
return result;
|
||||
}
|
||||
|
||||
function addDrawing(group: Group, layer: GreasePencilLayerIR, preview: DrawingPreview): number {
|
||||
function pointKey(point: GreasePencilPointRef): string {
|
||||
return `${point.dataId}\u0000${point.layerId}\u0000${point.frame}\u0000${point.strokeIndex}\u0000${point.pointIndex}`;
|
||||
}
|
||||
|
||||
function addDrawing(group: Group, dataId: string, layer: GreasePencilLayerIR, preview: DrawingPreview): number {
|
||||
let count = 0;
|
||||
for (const stroke of preview.drawing.strokes) {
|
||||
if (!stroke.points || stroke.points.length < 2) continue;
|
||||
const pointCount = stroke.points.length + (stroke.cyclic ? 1 : 0);
|
||||
for (let strokeIndex = 0; strokeIndex < preview.drawing.strokes.length; strokeIndex++) {
|
||||
const stroke = preview.drawing.strokes[strokeIndex];
|
||||
if (!stroke.points || stroke.points.length === 0) continue;
|
||||
const linePointCount = stroke.points.length + (stroke.cyclic && stroke.points.length > 1 ? 1 : 0);
|
||||
const pointCount = Math.max(stroke.points.length, linePointCount);
|
||||
const positions = new Float32Array(pointCount * 3);
|
||||
let red = 0;
|
||||
let green = 0;
|
||||
@@ -59,19 +84,42 @@ function addDrawing(group: Group, layer: GreasePencilLayerIR, preview: DrawingPr
|
||||
opacity += point.opacity * color[3];
|
||||
}
|
||||
const divisor = stroke.points.length;
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
|
||||
const onionColor = preview.onion === "PREVIOUS" ? new Color(0x6aa8ff) : preview.onion === "NEXT" ? new Color(0xff8a63) : null;
|
||||
const material = new LineBasicMaterial({
|
||||
color: onionColor ?? new Color(red / divisor, green / divisor, blue / divisor),
|
||||
opacity: Math.max(0, Math.min(1, layer.opacity * opacity / divisor * (preview.onion === "NONE" ? 1 : 0.28))),
|
||||
transparent: true,
|
||||
depthWrite: preview.onion === "NONE",
|
||||
});
|
||||
const line = new Line(geometry, material);
|
||||
line.userData.greasePencilOnion = preview.onion;
|
||||
line.userData.greasePencilMaterialIndex = stroke.materialIndex ?? 0;
|
||||
group.add(line);
|
||||
let strokeObject: Line | null = null;
|
||||
if (stroke.points.length > 1) {
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new Float32BufferAttribute(positions.subarray(0, linePointCount * 3), 3));
|
||||
const material = new LineBasicMaterial({
|
||||
color: onionColor ?? new Color(red / divisor, green / divisor, blue / divisor),
|
||||
opacity: Math.max(0, Math.min(1, layer.opacity * opacity / divisor * (preview.onion === "NONE" ? 1 : 0.28))),
|
||||
transparent: true,
|
||||
depthWrite: preview.onion === "NONE",
|
||||
});
|
||||
const line = new Line(geometry, material);
|
||||
line.userData.greasePencilOnion = preview.onion;
|
||||
line.userData.greasePencilMaterialIndex = stroke.materialIndex ?? 0;
|
||||
line.userData.greasePencilPreviewDataId = dataId;
|
||||
line.userData.greasePencilPreviewLayerId = layer.id;
|
||||
line.userData.greasePencilPreviewFrame = preview.frame;
|
||||
line.userData.greasePencilPreviewStrokeIndex = strokeIndex;
|
||||
line.userData.greasePencilPointIndexMap = Array.from({ length: linePointCount }, (_, index) => index % stroke.points!.length);
|
||||
line.userData.greasePencilPointBasePositions = new Float32Array(positions.subarray(0, linePointCount * 3));
|
||||
group.add(line);
|
||||
strokeObject = line;
|
||||
}
|
||||
if (preview.onion === "NONE") {
|
||||
const pointGeometry = new BufferGeometry();
|
||||
pointGeometry.setAttribute("position", new Float32BufferAttribute(positions.subarray(0, stroke.points.length * 3), 3));
|
||||
const points = new Points(pointGeometry, new PointsMaterial({ color: new Color(0x76baff), size: 0.1, sizeAttenuation: true, vertexColors: true }));
|
||||
points.userData.greasePencilPointDataId = dataId;
|
||||
points.userData.greasePencilPointLayerId = layer.id;
|
||||
points.userData.greasePencilPointFrame = preview.frame;
|
||||
points.userData.greasePencilPointStrokeIndex = strokeIndex;
|
||||
points.userData.greasePencilPointIndexMap = Array.from({ length: stroke.points.length }, (_, index) => index);
|
||||
points.userData.greasePencilPointBasePositions = new Float32Array(positions.subarray(0, stroke.points.length * 3));
|
||||
points.visible = false;
|
||||
(strokeObject ?? group).add(points);
|
||||
}
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
@@ -85,7 +133,7 @@ export function createGreasePencilObject(data: GreasePencilDataIR, frame: number
|
||||
for (const layer of data.layers) {
|
||||
if (!layer.visible || layer.opacity <= 0) continue;
|
||||
for (const preview of layerDrawings(layer, frame)) {
|
||||
const added = addDrawing(group, layer, preview);
|
||||
const added = addDrawing(group, data.id, layer, preview);
|
||||
if (preview.onion === "NONE") currentDrawingCount += added;
|
||||
else onionDrawingCount += added;
|
||||
}
|
||||
@@ -95,6 +143,66 @@ export function createGreasePencilObject(data: GreasePencilDataIR, frame: number
|
||||
return group.children.length > 0 ? group : null;
|
||||
}
|
||||
|
||||
export function greasePencilPointRef(object: Object3D, pointIndex: number): GreasePencilPointRef | null {
|
||||
const dataId = object.userData.greasePencilPointDataId;
|
||||
const layerId = object.userData.greasePencilPointLayerId;
|
||||
const frame = object.userData.greasePencilPointFrame;
|
||||
const strokeIndex = object.userData.greasePencilPointStrokeIndex;
|
||||
if (typeof dataId !== "string" || typeof layerId !== "string" || !Number.isSafeInteger(frame) || !Number.isSafeInteger(strokeIndex) || !Number.isSafeInteger(pointIndex) || pointIndex < 0) return null;
|
||||
return { dataId, layerId, frame, strokeIndex, pointIndex };
|
||||
}
|
||||
|
||||
export function applyGreasePencilPointSelection(root: Object3D, selection: readonly GreasePencilPointRef[]): void {
|
||||
const selected = new Set(selection.map(pointKey));
|
||||
root.traverse((object) => {
|
||||
if (!(object instanceof Points) || !(object.material instanceof PointsMaterial)) return;
|
||||
const count = object.geometry.getAttribute("position")?.count ?? 0;
|
||||
const first = greasePencilPointRef(object, 0);
|
||||
if (!first) return;
|
||||
const colors = new Float32Array(count * 3);
|
||||
for (let pointIndex = 0; pointIndex < count; pointIndex++) {
|
||||
const active = selected.has(pointKey({ ...first, pointIndex }));
|
||||
colors.set(active ? [1, 0.38, 0.08] : [0.46, 0.73, 1], pointIndex * 3);
|
||||
}
|
||||
object.geometry.setAttribute("color", new Float32BufferAttribute(colors, 3));
|
||||
object.material.color.set(0xffffff);
|
||||
object.material.vertexColors = true;
|
||||
object.material.needsUpdate = true;
|
||||
});
|
||||
}
|
||||
|
||||
export function applyGreasePencilPointPreview(
|
||||
root: Object3D,
|
||||
dataId: string,
|
||||
layerId: string,
|
||||
frame: number,
|
||||
points: readonly GreasePencilPointPreview[] | null,
|
||||
): void {
|
||||
const positions = new Map((points ?? []).map((point) => [`${point.strokeIndex}:${point.pointIndex}`, point.position]));
|
||||
root.traverse((object) => {
|
||||
const objectDataId = object.userData.greasePencilPreviewDataId ?? object.userData.greasePencilPointDataId;
|
||||
const objectLayerId = object.userData.greasePencilPreviewLayerId ?? object.userData.greasePencilPointLayerId;
|
||||
const objectFrame = object.userData.greasePencilPreviewFrame ?? object.userData.greasePencilPointFrame;
|
||||
const strokeIndex = object.userData.greasePencilPreviewStrokeIndex ?? object.userData.greasePencilPointStrokeIndex;
|
||||
if (objectDataId !== dataId
|
||||
|| objectLayerId !== layerId
|
||||
|| objectFrame !== frame
|
||||
|| !(object instanceof Line || object instanceof Points)) return;
|
||||
const position = object.geometry.getAttribute("position");
|
||||
const base = object.userData.greasePencilPointBasePositions;
|
||||
const indexMap = object.userData.greasePencilPointIndexMap as number[] | undefined;
|
||||
if (!(base instanceof Float32Array) || !indexMap || !Number.isSafeInteger(strokeIndex) || position.count * 3 !== base.length) return;
|
||||
const values = new Float32Array(base);
|
||||
for (let index = 0; index < indexMap.length; index++) {
|
||||
const previewPosition = positions.get(`${strokeIndex}:${indexMap[index]}`);
|
||||
if (previewPosition) values.set(blenderPosition(previewPosition), index * 3);
|
||||
}
|
||||
position.array.set(values);
|
||||
position.needsUpdate = true;
|
||||
object.geometry.computeBoundingSphere();
|
||||
});
|
||||
}
|
||||
|
||||
export function applyGreasePencilTransform(object: Object3D, node: SceneNodeIR): void {
|
||||
const [x, y, z] = node.transform.translation;
|
||||
const [rx, ry, rz] = node.transform.rotationEuler;
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "../vendor/three/three.module.js";
|
||||
import type { NonMeshDataIR, SceneNodeIR } from "../../../protocol/scene-ir";
|
||||
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
|
||||
import type { CurveGizmoHandleIR } from "../../../protocol/nonmesh-interaction";
|
||||
|
||||
export type NonMeshElementKind = "CONTROL_POINT" | "HANDLE_LEFT" | "HANDLE_RIGHT";
|
||||
export type NonMeshElementSelection = ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>;
|
||||
@@ -90,6 +91,9 @@ function createCurvePreview(data: NonMeshDataIR, points: ArrayLike<number> = dat
|
||||
const lineGeometry = new BufferGeometry();
|
||||
lineGeometry.setAttribute("position", new Float32BufferAttribute(handleLines, 3));
|
||||
const lines = new LineSegments(lineGeometry, new LineBasicMaterial({ color: new Color(0x9a7bff), transparent: true, opacity: 0.65 }));
|
||||
lines.userData.nonMeshDataId = data.id;
|
||||
lines.userData.nonMeshHandleLinePointIndexMap = [...handlePointIndices];
|
||||
lines.userData.nonMeshHandleBasePositions = new Float32Array(handleLines);
|
||||
group.add(lines);
|
||||
const pointGeometry = new BufferGeometry();
|
||||
pointGeometry.setAttribute("position", new Float32BufferAttribute(handlePositions, 3));
|
||||
@@ -97,6 +101,7 @@ function createCurvePreview(data: NonMeshDataIR, points: ArrayLike<number> = dat
|
||||
handles.userData.nonMeshDataId = data.id;
|
||||
handles.userData.nonMeshPointIndexMap = handleIndexMap;
|
||||
handles.userData.nonMeshPointKindMap = handleKindMap;
|
||||
handles.userData.nonMeshHandleBasePositions = new Float32Array(handlePositions);
|
||||
group.add(handles);
|
||||
}
|
||||
return group.children.length > 0 ? group : null;
|
||||
@@ -195,3 +200,37 @@ export function applyNonMeshElementSelection(root: Object3D, selection: NonMeshE
|
||||
object.material.needsUpdate = true;
|
||||
});
|
||||
}
|
||||
|
||||
export function applyCurveHandlePreview(root: Object3D, dataId: string, handles: readonly CurveGizmoHandleIR[] | null): void {
|
||||
const positionsByIdentity = new Map((handles ?? []).map((handle) => [`${handle.pointIndex}:${handle.side}`, handle.position]));
|
||||
root.traverse((object) => {
|
||||
if (object.userData.nonMeshDataId !== dataId || !(object instanceof Points || object instanceof LineSegments)) return;
|
||||
const position = object.geometry.getAttribute("position");
|
||||
const base = object.userData.nonMeshHandleBasePositions;
|
||||
if (!(base instanceof Float32Array) || position.count * 3 !== base.length) return;
|
||||
const values = new Float32Array(base);
|
||||
if (object instanceof Points) {
|
||||
const indexMap = object.userData.nonMeshPointIndexMap as number[] | undefined;
|
||||
const kindMap = object.userData.nonMeshPointKindMap as NonMeshElementKind[] | undefined;
|
||||
if (!indexMap || !kindMap) return;
|
||||
for (let index = 0; index < indexMap.length; index++) {
|
||||
const side = kindMap[index] === "HANDLE_LEFT" ? "LEFT" : kindMap[index] === "HANDLE_RIGHT" ? "RIGHT" : null;
|
||||
const preview = side ? positionsByIdentity.get(`${indexMap[index]}:${side}`) : undefined;
|
||||
if (preview) values.set(blenderPosition(...preview), index * 3);
|
||||
}
|
||||
}
|
||||
else {
|
||||
const indexMap = object.userData.nonMeshHandleLinePointIndexMap as number[] | undefined;
|
||||
if (!indexMap) return;
|
||||
for (let index = 0; index < indexMap.length; index++) {
|
||||
const left = positionsByIdentity.get(`${indexMap[index]}:LEFT`);
|
||||
const right = positionsByIdentity.get(`${indexMap[index]}:RIGHT`);
|
||||
if (left) values.set(blenderPosition(...left), index * 12 + 3);
|
||||
if (right) values.set(blenderPosition(...right), index * 12 + 9);
|
||||
}
|
||||
}
|
||||
position.array.set(values);
|
||||
position.needsUpdate = true;
|
||||
object.geometry.computeBoundingSphere();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,14 +3,21 @@ import type { MeshElementMode, MeshGeometryBuffer } from "../../../protocol/web-
|
||||
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
|
||||
import type { GPUTextureAsset } from "../../../protocol/render-assets";
|
||||
import type { NonMeshElementKind } from "./nonmesh";
|
||||
import type { GreasePencilPointPreview, GreasePencilPointRef } from "./grease-pencil";
|
||||
import type { CurveGizmoFrameIR, CurveGizmoHandleIR, CurveGizmoScreenFrameIR } from "../../../protocol/nonmesh-interaction";
|
||||
import type { NanoVDBViewportAssetIR } from "../volume/nanovdb-viewport";
|
||||
|
||||
export type OffscreenViewportRequest =
|
||||
| { type: "init"; canvas: OffscreenCanvas; width: number; height: number; pixelRatio: number }
|
||||
| { type: "snapshot"; snapshot: SceneSnapshotIR; geometryBuffers: MeshGeometryBuffer[]; nonMeshGeometryBuffers: NonMeshGeometryChunk[] }
|
||||
| { type: "textureAssets"; assets: GPUTextureAsset[] }
|
||||
| { type: "volumeAssets"; assets: NanoVDBViewportAssetIR[] }
|
||||
| { type: "resize"; width: number; height: number; pixelRatio: number }
|
||||
| { type: "selection"; objectIds: string[]; elements: Array<{ dataId: string; kind: NonMeshElementKind; index: number }> }
|
||||
| { type: "selection"; objectIds: string[]; elements: Array<{ dataId: string; kind: NonMeshElementKind; index: number }>; greasePencilPoints: GreasePencilPointRef[] }
|
||||
| { type: "interaction"; editMode: boolean; selectionMode: MeshElementMode }
|
||||
| { type: "curveHandlePreview"; dataId: string; handles: CurveGizmoHandleIR[] | null }
|
||||
| { type: "greasePencilPointPreview"; dataId: string; layerId: string; frame: number; points: GreasePencilPointPreview[] | null }
|
||||
| { type: "curveGizmoFrame"; dataId: string | null; frame: CurveGizmoFrameIR | null }
|
||||
| { type: "orbit"; deltaX: number; deltaY: number; zoom: number }
|
||||
| { type: "pick"; x: number; y: number; additive: boolean }
|
||||
| { type: "dispose" };
|
||||
@@ -20,6 +27,9 @@ export type OffscreenViewportResponse =
|
||||
| { type: "frame"; visiblePixels: number }
|
||||
| { type: "snapshotStatus"; nonMeshCount: number; nonMeshBlockedCount: number; greasePencilCount: number; greasePencilBlockedCount: number; greasePencilOnionStrokeCount: number }
|
||||
| { type: "textureStatus"; loaded: number; rejected: number; bytes: number; errors: string[]; errorCodes: string[] }
|
||||
| { type: "volumeStatus"; status: "none" | "loading" | "ready" | "blocked"; count: number; errorCode?: string }
|
||||
| { type: "selected"; objectId: string; additive: boolean }
|
||||
| { type: "elementSelected"; meshId: string; mode: MeshElementMode; index: number; additive: boolean; nonMeshKind?: NonMeshElementKind }
|
||||
| { type: "greasePencilPointSelected"; point: GreasePencilPointRef; additive: boolean }
|
||||
| { type: "curveGizmoScreenFrame"; frame: CurveGizmoScreenFrameIR | null }
|
||||
| { type: "error"; message: string };
|
||||
|
||||
@@ -8,12 +8,19 @@ 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";
|
||||
|
||||
export interface ViewportBackend {
|
||||
setSnapshot(snapshot: SceneSnapshotIR, geometryBuffers?: MeshGeometryBuffer[], nonMeshGeometryBuffers?: NonMeshGeometryChunk[]): void;
|
||||
setTextureAssets(assets: readonly GPUTextureAsset[]): void;
|
||||
setSelection(objectIds: ReadonlySet<string>, elementSelection?: ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>): void;
|
||||
setVolumeAssets(assets: readonly NanoVDBViewportAssetIR[]): void;
|
||||
setSelection(objectIds: ReadonlySet<string>, elementSelection?: ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>, greasePencilPoints?: readonly GreasePencilPointRef[]): void;
|
||||
setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): 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;
|
||||
}
|
||||
@@ -34,6 +41,7 @@ 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) => void,
|
||||
): OffscreenViewportRenderer {
|
||||
const existing = sharedBackends.get(canvas);
|
||||
if (existing) {
|
||||
@@ -42,7 +50,7 @@ export function acquireOffscreenViewportRenderer(
|
||||
existing.references += 1;
|
||||
return existing.renderer;
|
||||
}
|
||||
const renderer = new OffscreenViewportRenderer(canvas, onSelect, onElementSelect);
|
||||
const renderer = new OffscreenViewportRenderer(canvas, onSelect, onElementSelect, onGreasePencilPointSelect);
|
||||
sharedBackends.set(canvas, { renderer, references: 1 });
|
||||
return renderer;
|
||||
}
|
||||
@@ -65,6 +73,7 @@ export class OffscreenViewportRenderer implements ViewportBackend {
|
||||
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) => void;
|
||||
private pointer: { id: number; x: number; y: number; moved: boolean } | null = null;
|
||||
private lastSnapshot: SceneSnapshotIR | null = null;
|
||||
private lastGeometryBuffers: MeshGeometryBuffer[] | null = null;
|
||||
@@ -74,11 +83,13 @@ export class OffscreenViewportRenderer implements ViewportBackend {
|
||||
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,
|
||||
) {
|
||||
if (!supportsOffscreenViewport(canvas)) throw new Error("OffscreenCanvas viewport is unavailable");
|
||||
this.canvas = canvas;
|
||||
this.onSelect = onSelect;
|
||||
this.onElementSelect = onElementSelect;
|
||||
this.onGreasePencilPointSelect = onGreasePencilPointSelect;
|
||||
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();
|
||||
@@ -147,15 +158,34 @@ export class OffscreenViewportRenderer implements ViewportBackend {
|
||||
this.worker.postMessage({ type: "textureAssets", assets: cloned } satisfies OffscreenViewportRequest, transfer);
|
||||
}
|
||||
|
||||
setSelection(objectIds: ReadonlySet<string>, elementSelection?: ReadonlyMap<string, ReadonlyMap<NonMeshElementKind, ReadonlySet<number>>>): void {
|
||||
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[] = []): void {
|
||||
const elements = [...(elementSelection ?? new Map())].flatMap(([dataId, kinds]) => [...kinds].flatMap(([kind, indices]) => [...indices].map((index) => ({ dataId, kind, index }))));
|
||||
this.worker.postMessage({ type: "selection", objectIds: [...objectIds], elements } satisfies OffscreenViewportRequest);
|
||||
this.worker.postMessage({ type: "selection", objectIds: [...objectIds], elements, greasePencilPoints: [...greasePencilPoints] } satisfies OffscreenViewportRequest);
|
||||
}
|
||||
|
||||
setInteractionMode(editMode: boolean, selectionMode: MeshElementMode): void {
|
||||
this.worker.postMessage({ type: "interaction", editMode, selectionMode } 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.
|
||||
@@ -204,6 +234,7 @@ export class OffscreenViewportRenderer implements ViewportBackend {
|
||||
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);
|
||||
else if (message.type === "frame") this.canvas.dataset.rendererPixels = String(message.visiblePixels);
|
||||
else if (message.type === "snapshotStatus") {
|
||||
this.canvas.dataset.nonMeshCount = String(message.nonMeshCount);
|
||||
@@ -218,6 +249,14 @@ export class OffscreenViewportRenderer implements ViewportBackend {
|
||||
this.canvas.dataset.textureBytes = String(message.bytes);
|
||||
this.canvas.dataset.textureErrorCode = message.errorCodes[0] ?? "";
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -72,8 +72,30 @@ export function blenderLightIntensity(definition: LightIR): number {
|
||||
return Math.max(0, definition.energy) * 2 ** clamp(definition.exposure, -20, 20, 0) / 10;
|
||||
}
|
||||
|
||||
function blackbodySrgb(temperature: number): [number, number, number] {
|
||||
const value = clamp(temperature, 800, 20_000, 6500) / 100;
|
||||
const red = value <= 66 ? 255 : 329.698727446 * (value - 60) ** -0.1332047592;
|
||||
const green = value <= 66 ? 99.4708025861 * Math.log(value) - 161.1195681661 : 288.1221695283 * (value - 60) ** -0.0755148492;
|
||||
const blue = value >= 66 ? 255 : value <= 19 ? 0 : 138.5177312231 * Math.log(value - 10) - 305.0447927307;
|
||||
return [red, green, blue].map((component) => clamp(component / 255, 0, 1, 0)) as [number, number, number];
|
||||
}
|
||||
|
||||
function srgbToLinear(value: number): number {
|
||||
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||
}
|
||||
|
||||
/** Returns a bounded linear-RGB light color with Blender's 6500 K default treated as neutral. */
|
||||
export function blenderLightColor(definition: LightIR): [number, number, number] {
|
||||
if (!definition.useTemperature) return [...definition.color];
|
||||
const neutral = blackbodySrgb(6500);
|
||||
const blackbody = blackbodySrgb(definition.temperature ?? 6500).map((component, index) => component / neutral[index]);
|
||||
const peak = Math.max(1, ...blackbody);
|
||||
const linear = blackbody.map((component) => srgbToLinear(component / peak));
|
||||
return definition.color.map((component, index) => clamp(component, 0, 1, 0) * linear[index]) as [number, number, number];
|
||||
}
|
||||
|
||||
export function createPBRLight(definition: LightIR): Light {
|
||||
const color = new Color().setRGB(...definition.color);
|
||||
const color = new Color().setRGB(...blenderLightColor(definition));
|
||||
const intensity = blenderLightIntensity(definition);
|
||||
const light = definition.lightType === 1 ? new DirectionalLight(color, intensity) :
|
||||
definition.lightType === 2 ? new SpotLight(color, intensity, 0, definition.spotAngle, definition.spotBlend, 2) :
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
43
web/app/src/three-adapter/volume.ts
Normal file
43
web/app/src/three-adapter/volume.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
BufferGeometry,
|
||||
DataTexture,
|
||||
DoubleSide,
|
||||
Float32BufferAttribute,
|
||||
Mesh,
|
||||
MeshBasicMaterial,
|
||||
RGBAFormat,
|
||||
Uint32BufferAttribute,
|
||||
UnsignedByteType,
|
||||
} from "../vendor/three/three.module.js";
|
||||
import type { SceneNodeIR } from "../../../protocol/scene-ir";
|
||||
import type { NanoVDBViewportRenderResultIR } from "../volume/nanovdb-viewport";
|
||||
import { applyNonMeshTransform } from "./nonmesh";
|
||||
|
||||
export function createNanoVDBViewportObject(result: NanoVDBViewportRenderResultIR, node: SceneNodeIR): Mesh {
|
||||
const { min, max } = result.grid.worldBounds;
|
||||
const z = (min[2] + max[2]) / 2;
|
||||
const positions = [
|
||||
min[0], z, -min[1],
|
||||
max[0], z, -min[1],
|
||||
max[0], z, -max[1],
|
||||
min[0], z, -max[1],
|
||||
];
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
|
||||
geometry.setAttribute("uv", new Float32BufferAttribute([0, 0, 1, 0, 1, 1, 0, 1], 2));
|
||||
geometry.setIndex(new Uint32BufferAttribute([0, 1, 2, 0, 2, 3], 1));
|
||||
const texture = new DataTexture(result.pixels, result.width, result.height, RGBAFormat, UnsignedByteType);
|
||||
texture.needsUpdate = true;
|
||||
const material = new MeshBasicMaterial({ map: texture, transparent: true, depthWrite: false, side: DoubleSide, toneMapped: false });
|
||||
const mesh = new Mesh(geometry, material);
|
||||
mesh.name = `${node.name} (NanoVDB)`;
|
||||
mesh.renderOrder = 4;
|
||||
mesh.userData.sceneNodeId = node.id;
|
||||
mesh.userData.blenderId = node.id;
|
||||
mesh.userData.nonMeshDataId = result.dataId;
|
||||
mesh.userData.nanoVDBVolume = true;
|
||||
mesh.userData.nanoVDBGrid = result.grid.name;
|
||||
mesh.userData.nanoVDBImageSize = [result.width, result.height];
|
||||
applyNonMeshTransform(mesh, node);
|
||||
return mesh;
|
||||
}
|
||||
Reference in New Issue
Block a user