Skip to content

Commit 7e69ae5

Browse files
authored
Inspector V2: Capture Tools updates (#17713)
<img width="465" height="642" alt="image" src="https://github.com/user-attachments/assets/3dc0d199-49ef-4ed0-a059-12e6c659ea19" /> ### Changes - Add GIF recorder - Add scene replay - Some aesthetics... let me know what you think. - Separate capture screenshot, capture equirectangular, and record video into their own sections. - Always show width/height of screenshot - Marry capture screenshot with capture RTT (for a UI, they seem functionally equivalent, right?) ### Notes *State in components vs. service*: I've kept the functionality in the components for now, instead of in a service, both because it was simpler and because these components don't really need to persist state outside of the Inspector's lifetime. (Unlike the glTF loader options and glTF validator in the Import Tools...) *GIF module*: There aren't many options out there for recording GIFs on the web. `gif.js` seems to be standard, and it's what we had before. I installed it as a devDependency in the Inspector v2, assuming that it will be built into the bundled package. Hope this is OK, but let me know.
1 parent 10440bc commit 7e69ae5

File tree

14 files changed

+416
-132
lines changed

14 files changed

+416
-132
lines changed

package-lock.json

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/dev/core/src/Misc/sceneRecorder.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/* eslint-disable @typescript-eslint/naming-convention */
2-
import type { Scene } from "../scene";
2+
import type { IDisposable, Scene } from "../scene";
33
import type { Nullable } from "../types";
44
import { SceneSerializer } from "./sceneSerializer";
55
import { Mesh } from "../Meshes/mesh";
@@ -19,7 +19,7 @@ import { SerializationHelper } from "./decorators.serialization";
1919
/**
2020
* Class used to record delta files between 2 scene states
2121
*/
22-
export class SceneRecorder {
22+
export class SceneRecorder implements IDisposable {
2323
private _trackedScene: Nullable<Scene> = null;
2424
private _savedJSON: any;
2525

@@ -194,6 +194,14 @@ export class SceneRecorder {
194194
}
195195
}
196196

197+
/**
198+
* Dispose the recorder.
199+
*/
200+
public dispose() {
201+
this._trackedScene = null;
202+
this._savedJSON = null;
203+
}
204+
197205
private static GetShadowGeneratorById(scene: Scene, id: string) {
198206
const allGenerators = scene.lights.map((l) => l.getShadowGenerators());
199207

packages/dev/inspector-v2/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"@tools/gui-editor": "1.0.0",
3030
"@types/react": "^18.0.0",
3131
"@types/react-dom": "^18.0.0",
32+
"gif.js.optimized": "^1.0.1",
3233
"html-webpack-plugin": "^5.5.0",
3334
"react": "^18.2.0",
3435
"react-dom": "^18.2.0",
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { ButtonLine } from "shared-ui-components/fluent/hoc/buttonLine";
2+
import { useCallback } from "react";
3+
import type { FunctionComponent } from "react";
4+
import type { Scene } from "core/scene";
5+
import { captureEquirectangularFromScene } from "core/Misc/equirectangularCapture";
6+
import { CameraRegular } from "@fluentui/react-icons";
7+
import { FrameGraphUtils } from "core/FrameGraph/frameGraphUtils";
8+
9+
export const EquirectangularCaptureTool: FunctionComponent<{ scene: Scene }> = ({ scene }) => {
10+
const captureEquirectangularAsync = useCallback(async () => {
11+
const currentActiveCamera = scene.activeCamera;
12+
if (!currentActiveCamera && scene.frameGraph) {
13+
scene.activeCamera = FrameGraphUtils.FindMainCamera(scene.frameGraph);
14+
}
15+
if (scene.activeCamera) {
16+
await captureEquirectangularFromScene(scene, { size: 1024, filename: "equirectangular_capture.png" });
17+
}
18+
// eslint-disable-next-line require-atomic-updates
19+
scene.activeCamera = currentActiveCamera;
20+
}, [scene]);
21+
22+
return <ButtonLine label="Capture Equirectangular" icon={CameraRegular} onClick={captureEquirectangularAsync} />;
23+
};
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// See https://github.com/jnordberg/gif.js?tab=readme-ov-file#options
2+
declare module "gif.js.optimized" {
3+
class GIF {
4+
constructor(options?: {
5+
// pixel sample interval, lower is better
6+
quality?: number;
7+
// number of web workers to spawn
8+
workers?: number;
9+
// url to load worker script from
10+
workerScript?: string;
11+
});
12+
addFrame(
13+
image: HTMLImageElement | HTMLCanvasElement | CanvasRenderingContext2D,
14+
options?: {
15+
// frame delay (ms)
16+
delay?: number;
17+
// copy the pixel data
18+
copy?: boolean;
19+
}
20+
): void;
21+
on(event: "finished", callback: (blob: Blob) => void): void;
22+
render(): void;
23+
}
24+
25+
export default GIF;
26+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { ButtonLine } from "shared-ui-components/fluent/hoc/buttonLine";
2+
import { useState, useCallback, useEffect } from "react";
3+
import type { Scene } from "core/scene";
4+
import { SyncedSliderPropertyLine } from "shared-ui-components/fluent/hoc/propertyLines/syncedSliderPropertyLine";
5+
import { Collapse } from "shared-ui-components/fluent/primitives/collapse";
6+
import { RecordRegular, RecordStopRegular } from "@fluentui/react-icons";
7+
import { Tools } from "core/Misc/tools";
8+
import { Label } from "@fluentui/react-components";
9+
import { MakeLazyComponent } from "shared-ui-components/fluent/primitives/lazyComponent";
10+
import type gif from "gif.js.optimized";
11+
import type { Observer } from "core/Misc/observable";
12+
13+
type RecordingSession =
14+
| {
15+
state: "Recording";
16+
gif: gif;
17+
captureObserver: Observer<Scene>;
18+
previousHardwareScaling: number;
19+
}
20+
| {
21+
state: "Rendering";
22+
gif: gif;
23+
}
24+
| {
25+
state: "Idle";
26+
};
27+
28+
export const GIFCaptureTool = MakeLazyComponent(
29+
async () => {
30+
const gif = (await import("gif.js.optimized")).default;
31+
32+
// TODO: Figure out how to grab this from NPM package instead of CDN
33+
const workerContent = await Tools.LoadFileAsync("https://cdn.jsdelivr.net/gh//terikon/gif.js.optimized@0.1.6/dist/gif.worker.js");
34+
const workerBlob = new Blob([workerContent], { type: "application/javascript" });
35+
const workerUrl = URL.createObjectURL(workerBlob);
36+
37+
return ({ scene }: { scene: Scene }) => {
38+
const [recordingSession, setRecordingSession] = useState<RecordingSession>({ state: "Idle" });
39+
const [targetWidth, setTargetWidth] = useState(512);
40+
const [frequency, setFrequency] = useState(200);
41+
42+
useEffect(() => {
43+
return () => {
44+
if (recordingSession.state === "Recording") {
45+
// Reset session resources if component is unmounted
46+
scene.onAfterRenderObservable.remove(recordingSession.captureObserver);
47+
scene.getEngine().setHardwareScalingLevel(recordingSession.previousHardwareScaling);
48+
}
49+
};
50+
}, [recordingSession, scene]);
51+
52+
// Use functional setState to guard against multiple rapid clicks
53+
const startRecording = useCallback(() => {
54+
setRecordingSession((currentSession) => {
55+
// If already recording/rendering, don't start a new session
56+
if (currentSession.state !== "Idle") {
57+
return currentSession;
58+
}
59+
60+
const engine = scene.getEngine();
61+
const canvas = engine.getRenderingCanvas();
62+
if (!canvas) {
63+
return currentSession;
64+
}
65+
66+
const gifInstance = new gif({
67+
workers: 2,
68+
quality: 10,
69+
workerScript: workerUrl,
70+
});
71+
72+
// Adjust hardware scaling to match desired width
73+
const previousHardwareScaling = engine.getHardwareScalingLevel();
74+
engine.setHardwareScalingLevel(engine.getRenderWidth() / (targetWidth * globalThis.devicePixelRatio) || 1);
75+
76+
// Capture frames after each render
77+
let lastCaptureTime = 0;
78+
const captureObserver = scene.onAfterRenderObservable.add(() => {
79+
const now = Date.now();
80+
if (now - lastCaptureTime >= frequency && gifInstance) {
81+
lastCaptureTime = now;
82+
gifInstance.addFrame(canvas, { delay: 1, copy: true });
83+
}
84+
});
85+
86+
return {
87+
state: "Recording",
88+
gif: gifInstance,
89+
captureObserver: captureObserver,
90+
previousHardwareScaling: previousHardwareScaling,
91+
};
92+
});
93+
}, [scene, targetWidth, frequency]);
94+
95+
const stopRecording = useCallback(() => {
96+
setRecordingSession((currentSession) => {
97+
if (currentSession.state !== "Recording") {
98+
return currentSession;
99+
}
100+
101+
// Remove the frame capture observer
102+
scene.onAfterRenderObservable.remove(currentSession.captureObserver);
103+
104+
// Restore previous hardware scaling
105+
scene.getEngine().setHardwareScalingLevel(currentSession.previousHardwareScaling);
106+
107+
currentSession.gif.on("finished", (blob: Blob) => {
108+
// Download the rendered GIF
109+
Tools.Download(blob, "recording.gif");
110+
111+
// Reset state
112+
setRecordingSession({ state: "Idle" });
113+
});
114+
115+
// Start rendering the GIF
116+
currentSession.gif.render();
117+
118+
return { state: "Rendering", gif: currentSession.gif };
119+
});
120+
}, [scene]);
121+
122+
return (
123+
<>
124+
{recordingSession.state === "Idle" && <ButtonLine label="Record GIF" icon={RecordRegular} onClick={startRecording} />}
125+
{recordingSession.state === "Recording" && <ButtonLine label="Stop" icon={RecordStopRegular} onClick={stopRecording} />}
126+
{recordingSession.state === "Rendering" && <Label>Creating the GIF file...</Label>}
127+
<Collapse visible={recordingSession.state === "Idle"}>
128+
<SyncedSliderPropertyLine
129+
label="Resolution"
130+
description="The pixel width of the output. The height will be adjusted accordingly to maintain the aspect ratio."
131+
value={targetWidth}
132+
onChange={(value) => setTargetWidth(Math.floor(value))}
133+
min={128}
134+
max={2048}
135+
step={128}
136+
/>
137+
<SyncedSliderPropertyLine
138+
label="Frequency (ms)"
139+
description="The time interval in milliseconds between each capture of the scene."
140+
value={frequency}
141+
onChange={(value) => setFrequency(Math.floor(value))}
142+
min={50}
143+
max={1000}
144+
step={50}
145+
/>
146+
</Collapse>
147+
</>
148+
);
149+
};
150+
},
151+
{ spinnerSize: "extra-tiny", spinnerLabel: "Loading..." }
152+
);
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { ButtonLine } from "shared-ui-components/fluent/hoc/buttonLine";
2+
import { useState, useCallback } from "react";
3+
import type { FunctionComponent } from "react";
4+
import type { Scene } from "core/scene";
5+
import { RecordRegular, SaveRegular, ArrowDownloadRegular } from "@fluentui/react-icons";
6+
import { SceneRecorder } from "core/Misc/sceneRecorder";
7+
import { Tools } from "core/Misc/tools";
8+
import { FileUploadLine } from "shared-ui-components/fluent/hoc/fileUploadLine";
9+
import { Label } from "@fluentui/react-components";
10+
import { Logger } from "core/Misc/logger";
11+
import { useResource } from "../../../hooks/resourceHooks";
12+
13+
export const SceneReplayTool: FunctionComponent<{ scene: Scene }> = ({ scene }) => {
14+
const [isRecording, setIsRecording] = useState(false);
15+
const sceneRecorder = useResource(() => new SceneRecorder());
16+
17+
const startRecording = useCallback(() => {
18+
sceneRecorder.track(scene);
19+
setIsRecording(true);
20+
}, [scene]);
21+
22+
const exportReplay = useCallback(() => {
23+
const content = JSON.stringify(sceneRecorder.getDelta());
24+
const blob = new Blob([content], { type: "application/json" });
25+
Tools.Download(blob, "replay_delta.json");
26+
setIsRecording(false);
27+
}, []);
28+
29+
const applyDelta = useCallback(
30+
(files: FileList) => {
31+
const file = files[0];
32+
if (!file) {
33+
return;
34+
}
35+
36+
Tools.ReadFile(
37+
file,
38+
(data) => {
39+
try {
40+
const json = JSON.parse(data);
41+
SceneRecorder.ApplyDelta(json, scene);
42+
} catch (error) {
43+
Logger.Error("Failed to apply replay delta:" + error);
44+
}
45+
},
46+
undefined,
47+
false
48+
);
49+
},
50+
[scene]
51+
);
52+
53+
return (
54+
<>
55+
{!isRecording && <ButtonLine label="Start Recording" icon={RecordRegular} onClick={startRecording} />}
56+
{isRecording && (
57+
<>
58+
<Label>Recording in progress...</Label>
59+
<ButtonLine label="Generate Delta File" icon={SaveRegular} onClick={exportReplay} />
60+
</>
61+
)}
62+
<FileUploadLine label="Apply Delta File" icon={ArrowDownloadRegular} onClick={applyDelta} accept=".json" />
63+
</>
64+
);
65+
};
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { ButtonLine } from "shared-ui-components/fluent/hoc/buttonLine";
2+
import { useState, useCallback } from "react";
3+
import type { FunctionComponent } from "react";
4+
import type { Scene } from "core/scene";
5+
import { SyncedSliderPropertyLine } from "shared-ui-components/fluent/hoc/propertyLines/syncedSliderPropertyLine";
6+
import { CameraRegular } from "@fluentui/react-icons";
7+
import { FrameGraphUtils } from "core/FrameGraph/frameGraphUtils";
8+
import { CreateScreenshotUsingRenderTargetAsync } from "core/Misc/screenshotTools";
9+
import { SwitchPropertyLine } from "shared-ui-components/fluent/hoc/propertyLines/switchPropertyLine";
10+
import { Collapse } from "shared-ui-components/fluent/primitives/collapse";
11+
12+
export const ScreenshotTool: FunctionComponent<{ scene: Scene }> = ({ scene }) => {
13+
const [precision, setPrecision] = useState<number>(1);
14+
const [useCustomSize, setUseCustomSize] = useState<boolean>(false);
15+
const [width, setWidth] = useState<number>(512);
16+
const [height, setHeight] = useState<number>(512);
17+
18+
const captureScreenshot = useCallback(async () => {
19+
const engine = scene.getEngine();
20+
const camera = scene.frameGraph ? FrameGraphUtils.FindMainCamera(scene.frameGraph) : scene.activeCamera;
21+
const screenshotSize = useCustomSize ? { width, height, precision } : { precision };
22+
23+
if (camera) {
24+
await CreateScreenshotUsingRenderTargetAsync(engine, camera, screenshotSize, "image/png", undefined, undefined, "screenshot.png");
25+
}
26+
}, [useCustomSize, precision, width, height, scene]);
27+
28+
return (
29+
<>
30+
<ButtonLine label="Capture Screenshot" icon={CameraRegular} onClick={captureScreenshot} />
31+
<SyncedSliderPropertyLine
32+
label="Precision"
33+
description="A multiplier allowing capture at a higher or lower resolution."
34+
value={precision}
35+
onChange={setPrecision}
36+
min={0.1}
37+
max={10}
38+
step={0.1}
39+
/>
40+
<SwitchPropertyLine label="Use Custom Size" value={useCustomSize} onChange={setUseCustomSize} />
41+
<Collapse visible={useCustomSize}>
42+
<SyncedSliderPropertyLine label="Width" description="The width of the screenshot in pixels. " value={width} onChange={setWidth} min={1} step={1} />
43+
<SyncedSliderPropertyLine label="Height" description="The height of the screenshot in pixels." value={height} onChange={setHeight} min={1} step={1} />
44+
</Collapse>
45+
</>
46+
);
47+
};

0 commit comments

Comments
 (0)