BabylonJS/Spector.js

View on GitHub
src/spector.ts

Summary

Maintainability
D
2 days
Test Coverage
import { WebGLRenderingContexts, IContextInformation } from "./backend/types/contextInformation";

import { ICapture } from "./shared/capture/capture";

import { ProgramRecompilerHelper } from "./backend/utils/programRecompilerHelper";
import { Logger } from "./shared/utils/logger";
import { Observable } from "./shared/utils/observable";
import { ContextSpy } from "./backend/spies/contextSpy";
import { TimeSpy } from "./backend/spies/timeSpy";
import { CanvasSpy } from "./backend/spies/canvasSpy";
import { Program } from "./backend/webGlObjects/webGlObjects";
import { CaptureMenu } from "./embeddedFrontend/captureMenu/captureMenu";
import { ResultView } from "./embeddedFrontend/resultView/resultView";
import { XRSpy } from "./backend/spies/xrSpy";

const CAPTURE_LIMIT = 10000; // Limit command count to 10000 record (to be kept in sync with the documentation)

export interface IAvailableContext {
    readonly canvas: HTMLCanvasElement | OffscreenCanvas;
    readonly contextSpy: ContextSpy;
}

export const EmbeddedFrontend = {
    CaptureMenu,
    ResultView,
};

interface IAnnotatedOffscreenCanvas extends OffscreenCanvas {
    __spector_context_type?: string;
}

type SpectorInitOptions = {
    enableXRCapture?: boolean;
};

export class Spector {
    public static getFirstAvailable3dContext(canvas: HTMLCanvasElement | OffscreenCanvas): WebGLRenderingContexts {
        // Custom detection to run in the extension.
        return this.tryGetContextFromHelperField(canvas) ||
            this.tryGetContextFromCanvas(canvas, "webgl") ||
            this.tryGetContextFromCanvas(canvas, "experimental-webgl") ||
            this.tryGetContextFromCanvas(canvas, "webgl2") ||
            this.tryGetContextFromCanvas(canvas, "experimental-webgl2");
    }

    private static tryGetContextFromHelperField(canvas: HTMLCanvasElement | OffscreenCanvas): WebGLRenderingContexts {
        const type: string | void = canvas instanceof HTMLCanvasElement ?
            canvas.getAttribute("__spector_context_type") :
            (canvas as IAnnotatedOffscreenCanvas).__spector_context_type;

        if (type) {
            return this.tryGetContextFromCanvas(canvas, type);
        }

        return undefined;
    }

    private static tryGetContextFromCanvas(canvas: HTMLCanvasElement | OffscreenCanvas, type: string): WebGLRenderingContexts {
        let context: WebGLRenderingContexts;
        try {
            // Cast canvas to any because lib.dom.d.ts types are not suitably
            // general to allow for custom canvas context types that are
            // potentially specified by __spector_context_type:
            context = (canvas as any).getContext(type) as WebGLRenderingContexts;
        }
        catch (e) {
            // Nothing to do here, canvas has not been found.;
        }
        return context;
    }

    public readonly onCaptureStarted: Observable<any>;
    public readonly onCapture: Observable<ICapture>;
    public readonly onError: Observable<string>;

    private readonly timeSpy: TimeSpy;
    private readonly xrSpy: XRSpy | undefined;
    private readonly contexts: IAvailableContext[];

    private canvasSpy: CanvasSpy;
    private captureNextFrames: number;
    private captureNextCommands: number;
    private quickCapture: boolean;
    private fullCapture: boolean;
    private capturingContext: ContextSpy;
    private captureMenu: CaptureMenu;
    private resultView: ResultView;
    private retry: number;
    private noFrameTimeout = -1;
    private marker: string;

    private options: SpectorInitOptions;

    constructor(options: SpectorInitOptions = {}) {
        this.options = {
            enableXRCapture: false,
            ...options,
        };

        this.captureNextFrames = 0;
        this.captureNextCommands = 0;
        this.quickCapture = false;
        this.fullCapture = false;
        this.retry = 0;
        this.contexts = [];

        this.timeSpy = new TimeSpy();
        this.onCaptureStarted = new Observable<ICapture>();
        this.onCapture = new Observable<ICapture>();
        this.onError = new Observable<string>();

        this.timeSpy.onFrameStart.add(this.onFrameStart, this);
        this.timeSpy.onFrameEnd.add(this.onFrameEnd, this);
        this.timeSpy.onError.add(this.onErrorInternal, this);

        // if we want to capture WebXR sessions, we have to polyfill a bunch of stuff to ensure Spector.JS has access to the session
        // and the GL context. So we do that here.
        if (this.options.enableXRCapture) {
            this.xrSpy = new XRSpy(this.timeSpy);
        }
    }

    public displayUI(disableTracking: boolean = false) {
        if (!this.captureMenu) {
            this.getCaptureUI();

            this.captureMenu.onPauseRequested.add(this.pause, this);
            this.captureMenu.onPlayRequested.add(this.play, this);
            this.captureMenu.onPlayNextFrameRequested.add(this.playNextFrame, this);
            this.captureMenu.onCaptureRequested.add((info) => {
                if (info) {
                    this.captureCanvas(info.ref);
                }
            }, this);

            setInterval(() => { this.captureMenu.setFPS(this.getFps()); }, 1000);

            if (!disableTracking) {
                this.captureMenu.trackPageCanvases();
            }

            this.captureMenu.display();
        }

        if (!this.resultView) {
            this.getResultUI();

            this.onCapture.add((capture) => {
                this.resultView.display();
                this.resultView.addCapture(capture);
            });
        }
    }

    public getResultUI(): ResultView {
        if (!this.resultView) {
            this.resultView = new ResultView();
            this.resultView.onSourceCodeChanged.add((sourceCodeEvent) => {
                this.rebuildProgramFromProgramId(sourceCodeEvent.programId,
                    sourceCodeEvent.sourceVertex,
                    sourceCodeEvent.sourceFragment,
                    (program) => {
                        this.referenceNewProgram(sourceCodeEvent.programId, program);
                        this.resultView.showSourceCodeError(null);
                    },
                    (error) => {
                        this.resultView.showSourceCodeError(error);
                    });
            });
        }
        return this.resultView;
    }

    public getCaptureUI(): CaptureMenu {
        if (!this.captureMenu) {
            this.captureMenu = new CaptureMenu();
        }
        return this.captureMenu;
    }

    public rebuildProgramFromProgramId(programId: number,
        vertexSourceCode: string,
        fragmentSourceCode: string,
        onCompiled: (program: WebGLProgram) => void,
        onError: (message: string) => void) {

        const program = Program.getFromGlobalStore(programId);

        this.rebuildProgram(program,
            vertexSourceCode,
            fragmentSourceCode,
            onCompiled,
            onError,
        );
    }

    public rebuildProgram(program: WebGLProgram,
        vertexSourceCode: string,
        fragmentSourceCode: string,
        onCompiled: (program: WebGLProgram) => void,
        onError: (message: string) => void) {
        ProgramRecompilerHelper.rebuildProgram(program,
            vertexSourceCode,
            fragmentSourceCode,
            onCompiled,
            onError,
        );
    }

    public referenceNewProgram(programId: number, program: WebGLProgram): void {
        Program.updateInGlobalStore(programId, program);
    }

    public pause(): void {
        this.timeSpy.changeSpeedRatio(0);
    }

    public play(): void {
        this.timeSpy.changeSpeedRatio(1);
    }

    public playNextFrame(): void {
        this.timeSpy.playNextFrame();
    }

    public drawOnlyEveryXFrame(x: number): void {
        this.timeSpy.changeSpeedRatio(x);
    }

    public getFps(): number {
        return this.timeSpy.getFps();
    }

    public spyCanvases(): void {
        if (this.canvasSpy) {
            this.onErrorInternal("Already spying canvas.");
            return;
        }

        this.canvasSpy = new CanvasSpy();
        this.canvasSpy.onContextRequested.add(this.spyContext, this);
    }

    public spyCanvas(canvas: HTMLCanvasElement | OffscreenCanvas): void {
        if (this.canvasSpy) {
            this.onErrorInternal("Already spying canvas.");
            return;
        }

        this.canvasSpy = new CanvasSpy(canvas);
        this.canvasSpy.onContextRequested.add(this.spyContext, this);
    }

    public getAvailableContexts(): IAvailableContext[] {
        return this.getAvailableContexts();
    }

    public captureCanvas(canvas: HTMLCanvasElement | OffscreenCanvas,
        commandCount = 0,
        quickCapture: boolean = false,
        fullCapture: boolean = false): void {
        const contextSpy = this.getAvailableContextSpyByCanvas(canvas);
        if (!contextSpy) {
            const context = Spector.getFirstAvailable3dContext(canvas);
            if (context) {
                this.captureContext(context, commandCount, quickCapture, fullCapture);
            }
            else {
                Logger.error("No webgl context available on the chosen canvas.");
            }
        }
        else {
            this.captureContextSpy(contextSpy, commandCount, quickCapture, fullCapture);
        }
    }

    public captureContext(context: WebGLRenderingContexts,
        commandCount = 0,
        quickCapture: boolean = false,
        fullCapture: boolean = false): void {
        let contextSpy = this.getAvailableContextSpyByCanvas(context.canvas as HTMLCanvasElement | OffscreenCanvas);

        if (!contextSpy) {
            if ((context as WebGL2RenderingContext).getIndexedParameter) {
                contextSpy = new ContextSpy({
                    context,
                    version: 2,
                    recordAlways: false,
                });
            }
            else {
                contextSpy = new ContextSpy({
                    context,
                    version: 1,
                    recordAlways: false,
                });
            }

            contextSpy.onMaxCommand.add(this.stopCapture, this);

            this.contexts.push({
                canvas: contextSpy.context.canvas as HTMLCanvasElement | OffscreenCanvas,
                contextSpy,
            });
        }

        if (contextSpy) {
            this.captureContextSpy(contextSpy, commandCount, quickCapture, fullCapture);
        }
    }

    public captureXRContext(commandCount = 0,
        quickCapture: boolean = false,
        fullCapture: boolean = false): void {
        this.captureContext(this.getXRContext(), commandCount, quickCapture, fullCapture);
    }

    public captureContextSpy(contextSpy: ContextSpy,
        commandCount = 0,
        quickCapture: boolean = false,
        fullCapture: boolean = false): void {
        this.quickCapture = quickCapture;
        this.fullCapture = fullCapture;

        if (this.capturingContext) {
            this.onErrorInternal("Already capturing a context.");
        }
        else {
            this.retry = 0;
            this.capturingContext = contextSpy;
            this.capturingContext.setMarker(this.marker);

            // Limit the shown command count
            commandCount = Math.min(commandCount, CAPTURE_LIMIT);
            if (commandCount > 0) {
                this.captureCommands(commandCount);
            }
            else {
                // Capture only one frame.
                this.captureFrames(1);
            }

            this.noFrameTimeout = setTimeout(() => {
                if (commandCount > 0) {
                    this.stopCapture();
                }
                else if (this.capturingContext && this.retry > 1) {
                    this.onErrorInternal("No frames with gl commands detected. Try moving the camera.");
                }
                else {
                    this.onErrorInternal("No frames detected. Try moving the camera or implementing requestAnimationFrame.");
                }
            }, 10 * 1000);
        }
    }

    public captureNextFrame(obj: HTMLCanvasElement | OffscreenCanvas | WebGLRenderingContexts,
        quickCapture: boolean = false,
        fullCapture: boolean = false): void {
        if (obj instanceof HTMLCanvasElement || (self.OffscreenCanvas && obj instanceof OffscreenCanvas)) {
            this.captureCanvas(obj, 0, quickCapture, fullCapture);
        } else {
            this.captureContext(obj as WebGLRenderingContexts, 0, quickCapture, fullCapture);
        }
    }

    public startCapture(obj: HTMLCanvasElement | OffscreenCanvas | WebGLRenderingContexts,
        commandCount: number,
        quickCapture: boolean = false,
        fullCapture: boolean = false): void {
        if (obj instanceof HTMLCanvasElement || (self.OffscreenCanvas && obj instanceof OffscreenCanvas)) {
            this.captureCanvas(obj, commandCount, quickCapture, fullCapture);
        } else {
            this.captureContext(obj as WebGLRenderingContexts, commandCount, quickCapture, fullCapture);
        }
    }

    public stopCapture(): ICapture {
        if (this.capturingContext) {
            const capture = this.capturingContext.stopCapture();
            if (capture.commands.length > 0) {
                if (this.noFrameTimeout > -1) {
                    clearTimeout(this.noFrameTimeout);
                }
                this.triggerCapture(capture);

                this.capturingContext = undefined;
                this.captureNextFrames = 0;
                this.captureNextCommands = 0;
                return capture;
            }
            else if (this.captureNextCommands === 0) {
                this.retry++;
                this.captureFrames(1);
            }
        }
        return undefined;
    }

    public setMarker(marker: string): void {
        this.marker = marker;
        if (this.capturingContext) {
            this.capturingContext.setMarker(marker);
        }
    }

    public clearMarker(): void {
        this.marker = null;
        if (this.capturingContext) {
            this.capturingContext.clearMarker();
        }
    }

    public addRequestAnimationFrameFunctionName(functionName: string): void {
        this.timeSpy.addRequestAnimationFrameFunctionName(functionName);
    }

    public setSpiedScope(spiedScope: { [name: string]: any }): void {
        this.timeSpy.setSpiedScope(spiedScope);
    }

    public log(value: string): void {
        if (this.capturingContext) {
            this.capturingContext.log(value);
        }
    }

    private captureFrames(frameCount: number): void {
        this.captureNextFrames = frameCount;
        this.captureNextCommands = 0;

        this.playNextFrame();
    }

    private captureCommands(commandCount: number): void {
        this.captureNextFrames = 0;
        this.captureNextCommands = commandCount;

        this.play();

        if (this.capturingContext) {
            this.onCaptureStarted.trigger(undefined);
            this.capturingContext.startCapture(commandCount, this.quickCapture, this.fullCapture);
        }
        else {
            this.onErrorInternal("No context to capture from.");
            this.captureNextCommands = 0;
        }
    }

    private spyContext(contextInformation: IContextInformation) {
        let contextSpy = this.getAvailableContextSpyByCanvas(contextInformation.context.canvas as HTMLCanvasElement | OffscreenCanvas);
        if (!contextSpy) {
            contextSpy = new ContextSpy({
                context: contextInformation.context,
                version: contextInformation.contextVersion,
                recordAlways: true,
            });

            contextSpy.onMaxCommand.add(this.stopCapture, this);

            this.contexts.push({
                canvas: contextSpy.context.canvas as HTMLCanvasElement | OffscreenCanvas,
                contextSpy,
            });
        }

        contextSpy.spy();
    }

    private getAvailableContextSpyByCanvas(canvas: HTMLCanvasElement | OffscreenCanvas): ContextSpy {
        for (const availableContext of this.contexts) {
            if (availableContext.canvas === canvas) {
                return availableContext.contextSpy;
            }
        }
        return undefined;
    }

    private getXRContext(): WebGLRenderingContexts {
        if (!this.options.enableXRCapture) {
            Logger.error("Cannot retrieve WebXR context if capturing WebXR is disabled.");
        }
        if (!this.xrSpy.currentXRSession) {
            Logger.error("No currently active WebXR session.");
        }
        return this.xrSpy.currentXRSession.glContext;
    }

    private onFrameStart(): void {
        if (this.captureNextCommands > 0) {
            // Nothing to do here but preventing to drop the capturing context.
        }
        else if (this.captureNextFrames > 0) {
            if (this.capturingContext) {
                this.onCaptureStarted.trigger(undefined);
                this.capturingContext.startCapture(0, this.quickCapture, this.fullCapture);
            }
            this.captureNextFrames--;
        }
        else {
            this.capturingContext = undefined;
        }
    }

    private onFrameEnd(): void {
        if (this.captureNextCommands > 0) {
            // Nothing to do here but preventing to drop the capturing context.
        }
        else if (this.captureNextFrames === 0) {
            this.stopCapture();
        }
    }

    private triggerCapture(capture: ICapture) {
        if (this.captureMenu) {
            this.captureMenu.captureComplete(null);
        }
        this.onCapture.trigger(capture);
    }

    private onErrorInternal(error: string) {
        Logger.error(error);
        if (this.noFrameTimeout > -1) {
            clearTimeout(this.noFrameTimeout);
        }

        if (this.capturingContext) {
            this.capturingContext = undefined;
            this.captureNextFrames = 0;
            this.captureNextCommands = 0;
            this.retry = 0;

            if (this.captureMenu) {
                this.captureMenu.captureComplete(error);
            }
            this.onError.trigger(error);
        }
        else {
            throw error;
        }
    }
}