feat(web): add scene multiview authoring
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled

This commit is contained in:
mes123456
2026-08-29 20:17:50 +08:00
parent 217ab7cfe9
commit acf4d3d3d0
46 changed files with 1939 additions and 36 deletions

Binary file not shown.

View File

@@ -947,6 +947,7 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, grea
{activeScene?.compositorSettings ? <div className="property-section" data-testid="scene-compositor-settings-workbench"><h3>Compositor Execution</h3><output>Device {activeScene.compositorSettings.device} · Precision {activeScene.compositorSettings.precision} · Denoise {activeScene.compositorSettings.denoiseDevice} · Quality {activeScene.compositorSettings.previewQuality}/{activeScene.compositorSettings.finalQuality}</output><div className="property-actions"><button type="button" onClick={() => onCommand(animationRenderCommands.setSceneCompositorSettings(activeScene.id, { device: activeScene.compositorSettings!.device === 0 ? 1 : 0 }))}>切换设备</button><button type="button" onClick={() => onCommand(animationRenderCommands.setSceneCompositorSettings(activeScene.id, { precision: activeScene.compositorSettings!.precision === 0 ? 1 : 0 }))}>切换精度</button><button type="button" onClick={() => onCommand(animationRenderCommands.setSceneCompositorSettings(activeScene.id, { denoiseDevice: ((activeScene.compositorSettings!.denoiseDevice + 1) % 3) as 0 | 1 | 2 }))}>切换降噪设备</button></div></div> : null}
{activeScene?.performanceSettings ? <div className="property-section" data-testid="scene-performance-settings-workbench"><h3>Render Performance</h3><output>{activeScene.performanceSettings.fixedThreads ? `${activeScene.performanceSettings.threads} fixed threads` : "Automatic threads"} · {activeScene.performanceSettings.persistentData ? "Persistent data" : "Transient data"} · {activeScene.performanceSettings.highQualityNormals ? "HQ normals" : "Standard normals"}</output><div className="property-actions"><button type="button" onClick={() => onCommand(animationRenderCommands.setScenePerformanceSettings(activeScene.id, { fixedThreads: !activeScene.performanceSettings!.fixedThreads }))}>切换线程模式</button><button type="button" onClick={() => onCommand(animationRenderCommands.setScenePerformanceSettings(activeScene.id, { threads: Math.min(1024, activeScene.performanceSettings!.threads + 1) }))}>增加线程</button><button type="button" onClick={() => onCommand(animationRenderCommands.setScenePerformanceSettings(activeScene.id, { persistentData: !activeScene.performanceSettings!.persistentData }))}>切换持久数据</button></div></div> : null}
{activeScene?.samplingSettings ? <div className="property-section" data-testid="scene-sampling-settings-workbench"><h3>Image Sampling</h3><output>Pixel aspect {activeScene.samplingSettings.pixelAspectX.toFixed(3)} × {activeScene.samplingSettings.pixelAspectY.toFixed(3)} · Filter {activeScene.samplingSettings.filterSize.toFixed(3)} · Dither {activeScene.samplingSettings.dither.toFixed(3)}</output><div className="property-actions"><button type="button" onClick={() => onCommand(animationRenderCommands.setSceneSamplingSettings(activeScene.id, { filterSize: Math.min(100, activeScene.samplingSettings!.filterSize + 0.1) }))}>增加滤镜</button><button type="button" onClick={() => onCommand(animationRenderCommands.setSceneSamplingSettings(activeScene.id, { dither: Math.min(100, activeScene.samplingSettings!.dither + 0.1) }))}>增加抖动</button><button type="button" onClick={() => onCommand(animationRenderCommands.setSceneSamplingSettings(activeScene.id, { pixelAspectX: activeScene.samplingSettings!.pixelAspectX === 1 ? 2 : 1 }))}>切换像素宽高比</button></div></div> : null}
{activeScene?.multiviewSettings ? <div className="property-section" data-testid="scene-multiview-settings-workbench"><h3>Multiview</h3><output>{activeScene.multiviewSettings.enabled ? "Enabled" : "Disabled"} · Views {activeScene.multiviewSettings.viewsFormat} · Output {activeScene.multiviewSettings.imageViewsFormat} · Stereo {activeScene.multiviewSettings.stereoDisplayMode}</output><div className="property-actions"><button type="button" onClick={() => onCommand(animationRenderCommands.setSceneMultiviewSettings(activeScene.id, { enabled: !activeScene.multiviewSettings!.enabled }))}>{activeScene.multiviewSettings.enabled ? "关闭多视图" : "启用多视图"}</button><button type="button" onClick={() => onCommand(animationRenderCommands.setSceneMultiviewSettings(activeScene.id, { viewsFormat: activeScene.multiviewSettings!.viewsFormat === 0 ? 1 : 0 }))}>切换视图模式</button><button type="button" onClick={() => onCommand(animationRenderCommands.setSceneMultiviewSettings(activeScene.id, { stereoDisplayMode: ((activeScene.multiviewSettings!.stereoDisplayMode + 1) % 5) as 0 | 1 | 2 | 3 | 4 }))}>切换立体显示</button></div></div> : null}
{activeScene?.renderSettings ? <div className="property-section" data-testid="scene-render-settings-workbench"><h3>Render Output</h3><output>{activeScene.renderSettings.resolutionX} × {activeScene.renderSettings.resolutionY} @ {activeScene.renderSettings.resolutionPercentage}%</output><output>Frames {activeScene.renderSettings.frameStart} - {activeScene.renderSettings.frameEnd} · {activeScene.renderSettings.outputPath}</output><div className="property-actions"><button type="button" onClick={() => onCommand(animationRenderCommands.setSceneRenderSettings(activeScene.id, { resolutionX: Math.min(65536, activeScene.renderSettings!.resolutionX + 64), resolutionY: Math.min(65536, activeScene.renderSettings!.resolutionY + 64) }))}>增加分辨率</button><button type="button" onClick={() => onCommand(animationRenderCommands.setSceneRenderSettings(activeScene.id, { resolutionPercentage: Math.min(1000, activeScene.renderSettings!.resolutionPercentage + 10) }))}>增加百分比</button><button type="button" onClick={() => onCommand(animationRenderCommands.setSceneRenderSettings(activeScene.id, { frameEnd: Math.min(1000000, activeScene.renderSettings!.frameEnd + 1) }))}>延长帧范围</button></div></div> : null}
{activeScene?.audioSettings ? <div className="property-section" data-testid="scene-audio-settings-workbench"><h3>Scene Audio</h3><output>Volume {activeScene.audioSettings.volume.toFixed(2)} · Speed {activeScene.audioSettings.speedOfSound.toFixed(1)} · Doppler {activeScene.audioSettings.dopplerFactor.toFixed(2)}</output><output>Distance model {activeScene.audioSettings.distanceModel} · {activeScene.audioSettings.muted ? "Muted" : "Audible"} · {activeScene.audioSettings.sync ? "Synced" : "Free"}</output><div className="property-actions"><button type="button" onClick={() => onCommand(animationRenderCommands.setSceneAudioSettings(activeScene.id, { volume: Math.min(10, activeScene.audioSettings!.volume + 0.1) }))}>增加音量</button><button type="button" onClick={() => onCommand(animationRenderCommands.setSceneAudioSettings(activeScene.id, { muted: !activeScene.audioSettings!.muted }))}>{activeScene.audioSettings.muted ? "取消静音" : "静音"}</button><button type="button" onClick={() => onCommand(animationRenderCommands.setSceneAudioSettings(activeScene.id, { sync: !activeScene.audioSettings!.sync }))}>切换同步</button></div></div> : null}
{activeScene?.unitSettings ? <div className="property-section" data-testid="scene-unit-settings-workbench"><h3>Scene Units</h3><output>System {activeScene.unitSettings.system} · Scale {activeScene.unitSettings.scaleLength.toFixed(4)} · Rotation {activeScene.unitSettings.rotationSystem}</output><output>Length {activeScene.unitSettings.lengthUnit} · Mass {activeScene.unitSettings.massUnit} · Time {activeScene.unitSettings.timeUnit}</output><div className="property-actions"><button type="button" onClick={() => onCommand(animationRenderCommands.setSceneUnitSettings(activeScene.id, { system: activeScene.unitSettings!.system === 1 ? 2 : 1 }))}>切换单位系统</button><button type="button" onClick={() => onCommand(animationRenderCommands.setSceneUnitSettings(activeScene.id, { scaleLength: Math.min(1000000, Math.max(0.000001, activeScene.unitSettings!.scaleLength || 1) * 1.1) }))}>调整单位缩放</button><button type="button" onClick={() => onCommand(animationRenderCommands.setSceneUnitSettings(activeScene.id, { rotationSystem: activeScene.unitSettings!.rotationSystem === 2 ? 0 : 2 }))}>切换旋转单位</button></div></div> : null}

Binary file not shown.

View File

@@ -776,6 +776,17 @@ function assertFutureCapability(payload: Extract<WebEngineRequest["command"], {
if (properties.dither !== undefined && !boundedNumber(properties.dither, 0, 100)) throw report("RENDER_PROPERTY_INVALID", "Scene sampling dither is outside the bounded range");
return;
}
case "setSceneMultiviewSettings": {
const scene = currentSnapshot?.scenes.find((candidate) => candidate.id === payload.dataId);
const properties = payload.properties;
if (!scene || !validPropertyKeys(properties, ["enabled", "viewsFormat", "imageViewsFormat", "stereoDisplayMode", "interlaceSwap", "sideBySideCrossed", "squeezedFrame"])) throw report("RENDER_PROPERTY_INVALID", "Scene multiview target or properties are invalid");
if (properties.enabled !== undefined && typeof properties.enabled !== "boolean") throw report("RENDER_PROPERTY_INVALID", "Scene multiview enabled must be boolean");
if (properties.viewsFormat !== undefined && ![0, 1].includes(properties.viewsFormat)) throw report("RENDER_PROPERTY_INVALID", "Scene viewsFormat is unsupported");
if (properties.imageViewsFormat !== undefined && ![0, 1, 2].includes(properties.imageViewsFormat)) throw report("RENDER_PROPERTY_INVALID", "Scene imageViewsFormat is unsupported");
if (properties.stereoDisplayMode !== undefined && ![0, 1, 2, 3, 4].includes(properties.stereoDisplayMode)) throw report("RENDER_PROPERTY_INVALID", "Scene stereoDisplayMode is unsupported");
for (const field of ["interlaceSwap", "sideBySideCrossed", "squeezedFrame"] as const) if (properties[field] !== undefined && typeof properties[field] !== "boolean") throw report("RENDER_PROPERTY_INVALID", `Scene ${field} must be boolean`);
return;
}
case "setGeometryNodeGraph": {
const snapshot = currentSnapshot;
const availableResourceIds = new Set([

View File

@@ -83,4 +83,7 @@ export const animationRenderCommands = Object.freeze({
setSceneSamplingSettings(dataId: string, properties: AnimationCommand<"setSceneSamplingSettings">["properties"]): AnimationCommand<"setSceneSamplingSettings"> {
return { type: "setSceneSamplingSettings", dataId, properties: { ...properties } };
},
setSceneMultiviewSettings(dataId: string, properties: AnimationCommand<"setSceneMultiviewSettings">["properties"]): AnimationCommand<"setSceneMultiviewSettings"> {
return { type: "setSceneMultiviewSettings", dataId, properties: { ...properties } };
},
} satisfies Record<string, (...args: never[]) => WebEngineEditCommand>);

View File

@@ -580,6 +580,7 @@ export interface SceneIR {
compositorSettings?: { device: number; precision: number; denoiseDevice: number; previewQuality: number; finalQuality: number };
performanceSettings?: { fixedThreads: boolean; threads: number; persistentData: boolean; highQualityNormals: boolean };
samplingSettings?: { pixelAspectX: number; pixelAspectY: number; filterSize: number; dither: number };
multiviewSettings?: { enabled: boolean; viewsFormat: number; imageViewsFormat: number; stereoDisplayMode: number; interlaceSwap: boolean; sideBySideCrossed: boolean; squeezedFrame: boolean };
compositorGraph?: CompositorGraphIR;
compositorStatus?: "AVAILABLE" | "BLOCKED";
sequencerTimeline?: SequencerTimelineIR;
@@ -1643,6 +1644,11 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
if (!isRecord(scene.samplingSettings)) throw new Error(`SceneIR.scenes[${index}].samplingSettings must be an object`);
for (const field of ["pixelAspectX", "pixelAspectY", "filterSize", "dither"] as const) requireNumber(scene.samplingSettings[field], `scenes[${index}].samplingSettings.${field}`);
}
if (scene.multiviewSettings !== undefined) {
if (!isRecord(scene.multiviewSettings)) throw new Error(`SceneIR.scenes[${index}].multiviewSettings must be an object`);
for (const field of ["viewsFormat", "imageViewsFormat", "stereoDisplayMode"] as const) requireNumber(scene.multiviewSettings[field], `scenes[${index}].multiviewSettings.${field}`);
for (const field of ["enabled", "interlaceSwap", "sideBySideCrossed", "squeezedFrame"] as const) requireBoolean(scene.multiviewSettings[field], `scenes[${index}].multiviewSettings.${field}`);
}
if (scene.compositorStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(scene.compositorStatus as string)) {
throw new Error(`scenes[${index}].compositorStatus is invalid`);
}

View File

@@ -97,6 +97,7 @@ export const WEB_ENGINE_COMMAND_CAPABILITIES = {
setSceneCompositorSettings: { family: "COMPOSITOR", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setScenePerformanceSettings: { family: "PERFORMANCE", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setSceneSamplingSettings: { family: "RENDER", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setSceneMultiviewSettings: { family: "RENDER", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setMetaballElements: { family: "GEOMETRY", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
sculptStroke: { family: "SCULPT", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setSculptMeshAttributes: { family: "SCULPT", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },

View File

@@ -161,6 +161,7 @@ export type WebEngineEditCommand =
| { type: "setSceneCompositorSettings"; dataId: string; properties: { device?: 0 | 1; precision?: 0 | 1; denoiseDevice?: 0 | 1 | 2; previewQuality?: 0 | 1 | 2; finalQuality?: 0 | 1 | 2 } }
| { type: "setScenePerformanceSettings"; dataId: string; properties: { fixedThreads?: boolean; threads?: number; persistentData?: boolean; highQualityNormals?: boolean } }
| { type: "setSceneSamplingSettings"; dataId: string; properties: { pixelAspectX?: number; pixelAspectY?: number; filterSize?: number; dither?: number } }
| { type: "setSceneMultiviewSettings"; dataId: string; properties: { enabled?: boolean; viewsFormat?: 0 | 1; imageViewsFormat?: 0 | 1 | 2; stereoDisplayMode?: 0 | 1 | 2 | 3 | 4; interlaceSwap?: boolean; sideBySideCrossed?: boolean; squeezedFrame?: boolean } }
| { type: "setMetaballElements"; dataId: string; elements: Array<{ type: number; position: [number, number, number]; radius: number; scale: [number, number, number] }> }
| { type: "sculptStroke"; stroke: SculptStrokeIR }
| { type: "setSculptMeshAttributes"; attributes: SculptMeshAttributesIR }

View File

@@ -4,7 +4,7 @@ export const WORKBENCH_COMMAND_GROUPS = Object.freeze({
OBJECT: ["createPrimitive", "duplicateObject", "deleteObject", "setObjectTransform", "setParent", "createCollection", "moveObjectToCollection", "renameId", "applyObjectTransform", "setObjectOrigin"],
MESH: ["meshEdit", "translateMeshVertices", "createUVMap", "setActiveUVMap", "unwrapUV", "setVertexColors", "setVertexWeights"],
ANIMATION: ["setFrame", "insertObjectKeyframe", "deleteObjectKeyframe", "setFCurveInterpolation", "setConstraint", "setNLAStack"],
RENDER: ["setCameraProperties", "setLightProperties", "setWorldProperties", "setSceneProperties", "setSceneRenderSettings", "setSceneAudioSettings", "setSceneUnitSettings", "setSceneTimelineSettings", "setScenePipelineSettings", "setSceneBindings", "setSceneViewLayerSettings", "setSceneCollectionSettings", "setSceneBorderSettings", "setSceneSimplifySettings", "setSceneColorManagement", "setSceneImageFormat", "setSceneMotionBlur", "setSceneHairSettings", "setSceneCompositorSettings", "setScenePerformanceSettings", "setSceneSamplingSettings"],
RENDER: ["setCameraProperties", "setLightProperties", "setWorldProperties", "setSceneProperties", "setSceneRenderSettings", "setSceneAudioSettings", "setSceneUnitSettings", "setSceneTimelineSettings", "setScenePipelineSettings", "setSceneBindings", "setSceneViewLayerSettings", "setSceneCollectionSettings", "setSceneBorderSettings", "setSceneSimplifySettings", "setSceneColorManagement", "setSceneImageFormat", "setSceneMotionBlur", "setSceneHairSettings", "setSceneCompositorSettings", "setScenePerformanceSettings", "setSceneSamplingSettings", "setSceneMultiviewSettings"],
IO: ["importImage", "importVFont", "setMaterialImageNode"],
} as const satisfies Record<string, readonly WebEngineCommandType[]>);