Browse Source

merge osmd-public 1.5.0 (30-60% performance boost in rendering)

sschmidTU 3 years ago
parent
commit
940fd0b64d

+ 8 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "osmd-extended",
-  "version": "1.4.5",
+  "version": "1.5.0",
   "description": "Private / sponsor exclusive OSMD mirror/audio player.",
   "main": "build/opensheetmusicdisplay.min.js",
   "types": "build/dist/src/index.d.ts",
@@ -29,11 +29,15 @@
     "generatePNG:paged:debug": "node ./test/Util/generateImages_browserless.mjs ../../build ./test/data ./export png 210 297 all --debug 5000",
     "generatePNG:paged:single": "node ./test/Util/generateImages_browserless.mjs ../../build ./test/data ./export png 0 0 ^Beethoven",
     "generate:current": "node ./test/Util/generateImages_browserless.mjs ../../build ./test/data ./visual_regression/current png 0 0 allSmall --osmdtesting",
+    "generate:current:batch": "node ./test/Util/generateImages_browserless.mjs ../../build ./test/data ./visual_regression/current png 0 0 allSmall --osmdtesting 0 --batch",
+    "generate:current:webgl": "node ./test/Util/generateImages_browserless.mjs ../../build ./test/data ./visual_regression/current png 0 0 allSmall --osmdtesting 0 --webgl",
     "generate:current:debug": "node ./test/Util/generateImages_browserless.mjs ../../build ./test/data ./visual_regression/current png 0 0 allSmall --debugosmdtesting",
     "generate:current:singletest": "node test/Util/generateImages_browserless.mjs ../../build ./test/data ./visual_regression/current png 0 0 ^Beethoven --osmdtestingsingle",
     "generate:blessed": "node ./test/Util/generateImages_browserless.mjs ../../build ./test/data ./visual_regression/blessed png 0 0 allSmall --osmdtesting",
     "test:visual": "bash ./test/Util/visual_regression.sh ./visual_regression",
     "test:visual:build": "npm-run-all prebuild build:webpack generate:current test:visual",
+    "test:visual:build:batch": "npm-run-all prebuild build:webpack generate:current:batch test:visual",
+    "test:visual:build:webgl": "npm-run-all prebuild build:webpack generate:current:webgl test:visual",
     "test:visual:singletest": "sh ./test/Util/visual_regression.sh ./visual_regression Beethoven",
     "fix-memory-limit": "cross-env NODE_OPTIONS=--max_old_space_size=4096"
   },
@@ -134,6 +138,9 @@
     "uuid": "^8.3.0",
     "@types/uuid": "^3.4.3"
   },
+  "optionalDependencies": {
+    "gl": "^5.0.0"
+  },
   "config": {
     "commitizen": {
       "path": "./node_modules/cz-conventional-changelog"

+ 54 - 2
src/MusicalScore/Graphical/EngravingRules.ts

@@ -3,7 +3,12 @@ import { PagePlacementEnum } from "./GraphicalMusicPage";
 import log from "loglevel";
 import { TextAlignmentEnum } from "../../Common/Enums/TextAlignment";
 import { PlacementEnum } from "../VoiceData/Expressions/AbstractExpression";
-import { AutoBeamOptions, AlignRestOption, FillEmptyMeasuresWithWholeRests } from "../../OpenSheetMusicDisplay/OSMDOptions";
+import {
+    AutoBeamOptions,
+    AlignRestOption,
+    FillEmptyMeasuresWithWholeRests,
+    SkyBottomLineBatchCalculatorBackendType
+} from "../../OpenSheetMusicDisplay/OSMDOptions";
 import { ColoringModes as ColoringMode } from "./DrawingParameters";
 import { Dictionary } from "typescript-collections";
 import { FontStyles } from "../../Common/Enums";
@@ -340,7 +345,28 @@ export class EngravingRules {
 
     public static FixStafflineBoundingBox: boolean; // TODO temporary workaround
 
-    // Playback (these are not about "engraving", but EngravingRules are the one object that is available in most places in OSMD)
+    /** The skyline and bottom-line batch calculation algorithm to use.
+     *  Note that this can be overridden if AlwaysSetPreferredSkyBottomLineBackendAutomatically is true (which is the default).
+     */
+    public PreferredSkyBottomLineBatchCalculatorBackend: SkyBottomLineBatchCalculatorBackendType;
+    /** Whether to consider using WebGL in Firefox in EngravingRules.setPreferredSkyBottomLineBackendAutomatically() */
+    public DisableWebGLInFirefox: boolean;
+    /** Whether to consider using WebGL in Safari/iOS in EngravingRules.setPreferredSkyBottomLineBackendAutomatically() */
+    public DisableWebGLInSafariAndIOS: boolean;
+
+    /** The minimum number of measures in the sheet where the skyline and bottom-line batch calculation is enabled.
+     *  Batch is faster for medium to large size scores, but slower for very short scores.
+     */
+    public SkyBottomLineBatchMinMeasures: number;
+    /** The minimum number of measures in the sheet where WebGL will be used. WebGL is slower for short scores, but much faster for large ones.
+     * Note that WebGL is currently never used in Safari and Firefox, because it's always slower there.
+     */
+    public SkyBottomLineWebGLMinMeasures: number;
+    /** Whether to always set preferred backend (WebGL or Plain) automatically, depending on browser and number of measures. */
+    public AlwaysSetPreferredSkyBottomLineBackendAutomatically: boolean;
+
+    // Playback section (these are not about "engraving", but EngravingRules are the one object that is available in most places in OSMD)
+
     public PlayAlreadyStartedNotesFromCursorPosition: boolean = false;
     /** The interval between current timer position and note timestamp beyond which notes are not played.
      * If you experience notes being skipped during playback, try increasing this interval slightly (e.g. 0.02 -> 0.03).
@@ -682,6 +708,13 @@ export class EngravingRules {
         this.NoteToGraphicalNoteMap = new Dictionary<number, GraphicalNote>();
         this.NoteToGraphicalNoteMapObjectCount = 0;
 
+        this.SkyBottomLineBatchMinMeasures = 5;
+        this.SkyBottomLineWebGLMinMeasures = 80;
+        this.AlwaysSetPreferredSkyBottomLineBackendAutomatically = true;
+        this.DisableWebGLInFirefox = true;
+        this.DisableWebGLInSafariAndIOS = true;
+        this.setPreferredSkyBottomLineBackendAutomatically();
+
         // this.populateDictionaries(); // these values aren't used currently
         try {
             this.MaxInstructionsConstValue = this.ClefLeftMargin + this.ClefRightMargin + this.KeyRightMargin + this.RhythmRightMargin + 11;
@@ -700,6 +733,25 @@ export class EngravingRules {
         }
     }
 
+    public setPreferredSkyBottomLineBackendAutomatically(numberOfGraphicalMeasures: number = -1): void {
+        const vendor: string = globalThis.navigator?.vendor ?? "";
+        const userAgent: string = globalThis.navigator?.userAgent ?? "";
+        let alwaysUsePlain: boolean = false;
+        if (this.DisableWebGLInSafariAndIOS && (/apple/i).test(vendor)) { // doesn't apply to Chrome on MacOS
+            alwaysUsePlain = true;
+        } else if (this.DisableWebGLInFirefox && userAgent.includes("Firefox")) {
+            alwaysUsePlain = true;
+        }
+        // In Safari (/iOS) and Firefox, the plain version is always faster (currently, Safari 15).
+        //   WebGL is faster for large scores in Chrome and Edge (both Chromium based). See #1158
+        this.PreferredSkyBottomLineBatchCalculatorBackend = SkyBottomLineBatchCalculatorBackendType.Plain;
+        if (!alwaysUsePlain) {
+            if (numberOfGraphicalMeasures >= this.SkyBottomLineWebGLMinMeasures) {
+                this.PreferredSkyBottomLineBatchCalculatorBackend = SkyBottomLineBatchCalculatorBackendType.WebGL;
+            }
+        }
+    }
+
     /** Makes it so that all musical elements (including key/time signature)
      *  are colored with the given color by default,
      *  unless an element has a different color set (e.g. VoiceEntry.StemColor).

+ 2 - 6
src/MusicalScore/Graphical/MusicSheetCalculator.ts

@@ -2558,12 +2558,8 @@ export abstract class MusicSheetCalculator {
         }
     }
 
-    private calculateSkyBottomLines(): void {
-        for (const musicSystem of this.musicSystems) {
-            for (const staffLine of musicSystem.StaffLines) {
-                staffLine.SkyBottomLineCalculator.calculateLines();
-            }
-        }
+    protected calculateSkyBottomLines(): void {
+        // override
     }
 
     /**

+ 98 - 0
src/MusicalScore/Graphical/PlainSkyBottomLineBatchCalculatorBackend.ts

@@ -0,0 +1,98 @@
+import { EngravingRules } from "./EngravingRules";
+import { VexFlowMeasure } from "./VexFlow/VexFlowMeasure";
+import { SkyBottomLineCalculationResult } from "./SkyBottomLineCalculationResult";
+import {
+    ISkyBottomLineBatchCalculatorBackendPartialTableConfiguration,
+    ISkyBottomLineBatchCalculatorBackendTableConfiguration,
+    SkyBottomLineBatchCalculatorBackend
+} from "./SkyBottomLineBatchCalculatorBackend";
+
+/**
+ * This class calculates the skylines and the bottom lines by iterating over pixels retrieved via
+ * CanvasRenderingContext2D.getImageData().
+ */
+export class PlainSkyBottomLineBatchCalculatorBackend extends SkyBottomLineBatchCalculatorBackend {
+    constructor(rules: EngravingRules, measures: VexFlowMeasure[]) {
+        super(rules, measures);
+    }
+
+    protected getPreferredRenderingConfiguration(maxWidth: number, elementHeight: number): ISkyBottomLineBatchCalculatorBackendPartialTableConfiguration {
+        return {
+            elementWidth: Math.ceil(maxWidth),
+            numColumns: 6,
+            numRows: 6,
+        };
+    }
+
+    protected onInitialize(tableConfiguration: ISkyBottomLineBatchCalculatorBackendTableConfiguration): void {
+        // does nothing
+    }
+
+    protected calculateFromCanvas(
+        canvas: HTMLCanvasElement,
+        vexFlowContext: Vex.Flow.CanvasContext,
+        measures: VexFlowMeasure[],
+        samplingUnit: number,
+        tableConfiguration: ISkyBottomLineBatchCalculatorBackendTableConfiguration
+    ): SkyBottomLineCalculationResult[] {
+        // vexFlowContext is CanvasRenderingContext2D in runtime
+        const canvasWidth: number = canvas.width;
+        const context: CanvasRenderingContext2D = vexFlowContext as unknown as CanvasRenderingContext2D;
+        const imageData: ImageData = context.getImageData(0, 0, canvas.width, canvas.height);
+        const rgbaLength: number = 4;
+        const { elementWidth, elementHeight, numColumns } = tableConfiguration;
+
+        const result: SkyBottomLineCalculationResult[] = [];
+        for (let i: number = 0; i < measures.length; ++i) {
+            const measure: VexFlowMeasure = measures[i];
+            const measureWidth: number = Math.floor(measure.getVFStave().getWidth());
+            const measureArrayLength: number =  Math.max(Math.ceil(measure.PositionAndShape.Size.width * samplingUnit), 1);
+            const u: number = i % numColumns;
+            const v: number = Math.floor(i / numColumns);
+
+            const xStart: number = u * elementWidth;
+            const xEnd: number = xStart + measureWidth;
+            const yStart: number = v * elementHeight;
+            const yEnd: number = yStart + elementHeight;
+
+            const skyLine: number[] = new Array(Math.max(measureArrayLength, measureWidth)).fill(0);
+            const bottomLine: number[] = new Array(Math.max(measureArrayLength, measureWidth)).fill(0);
+
+            for (let x: number = xStart; x < xEnd; ++x) {
+                // SkyLine
+                skyLine[x - xStart] = 0;
+                for (let y: number = yStart; y < yEnd; ++y) {
+                    const yOffset: number = y * canvasWidth * rgbaLength;
+                    const bufIndex: number = yOffset + x * rgbaLength;
+                    const alpha: number = imageData.data[bufIndex + 3];
+                    if (alpha > 0) {
+                        skyLine[x - xStart] = y - yStart;
+                        break;
+                    }
+                }
+                // BottomLine
+                bottomLine[x - xStart] = elementHeight;
+                for (let y: number = yEnd - 1; y >= yStart; y--) {
+                    const yOffset: number = y * canvasWidth * rgbaLength;
+                    const bufIndex: number = yOffset + x * rgbaLength;
+                    const alpha: number = imageData.data[bufIndex + 3];
+                    if (alpha > 0) {
+                        bottomLine[x - xStart] = y - yStart;
+                        break;
+                    }
+                }
+            }
+
+            const lowestSkyLine: number = Math.max(...skyLine);
+            const highestBottomLine: number = Math.min(...bottomLine);
+
+            for (let x: number = 0; x < measureWidth; ++x) {
+                skyLine[x] = skyLine[x] === 0 ? lowestSkyLine : skyLine[x];
+                bottomLine[x] = bottomLine[x] === elementHeight ? highestBottomLine : bottomLine[x];
+            }
+
+            result.push(new SkyBottomLineCalculationResult(skyLine, bottomLine));
+        }
+        return result;
+    }
+}

+ 54 - 0
src/MusicalScore/Graphical/Shaders/FragmentShader.glsl

@@ -0,0 +1,54 @@
+precision mediump float;
+uniform sampler2D u_image;
+varying vec4 v_position;
+
+#define NUM_ROWS 5
+#define ELEMENT_HEIGHT 300
+
+void main() {
+    const float halfPixel = 1.0 / float(ELEMENT_HEIGHT * 2);
+
+    vec2 absolutePosition = (v_position.xy + vec2(1.0)) / vec2(2.0);
+    float absX = absolutePosition.x;
+    float absY = absolutePosition.y;
+
+    int skyLine = 0;
+    for (int i = 0; i < ELEMENT_HEIGHT; ++i) {
+        float ratioY = float(i) / float(ELEMENT_HEIGHT);
+        float relY = (ratioY - 0.5 + halfPixel) / float(NUM_ROWS);
+        float x = absX;
+        float y = absY + relY;
+
+        float currentAlpha = texture2D(u_image, vec2(x, y)).a;
+        if (currentAlpha > 0.0) {
+            skyLine = i;
+            break;
+        }
+    }
+
+    int bottomLine = ELEMENT_HEIGHT;
+    for (int i = ELEMENT_HEIGHT - 1; i >= 0; --i) {
+        float ratioY = float(i) / float(ELEMENT_HEIGHT);
+        float relY = (ratioY - 0.5 + halfPixel) / float(NUM_ROWS);
+        float x = absX;
+        float y = absY + relY;
+
+        float currentAlpha = texture2D(u_image, vec2(x, y)).a;
+        if (currentAlpha > 0.0) {
+            bottomLine = i;
+            break;
+        }
+    }
+
+    int r = skyLine;
+    if (r > 256) {
+        r -= 256;
+    }
+    int g = bottomLine;
+    if (g > 256) {
+        g -= 256;
+    }
+    int b = (skyLine / 256 * 16) + (bottomLine / 256);
+
+    gl_FragColor = vec4(float(r) / 255.0, float(g) / 255.0, float(b) / 255.0, 1.0);
+}

+ 7 - 0
src/MusicalScore/Graphical/Shaders/VertexShader.glsl

@@ -0,0 +1,7 @@
+attribute vec4 a_position;
+varying vec4 v_position;
+
+void main() {
+    gl_Position = a_position;
+    v_position = a_position;
+}

+ 85 - 0
src/MusicalScore/Graphical/SkyBottomLineBatchCalculator.ts

@@ -0,0 +1,85 @@
+import { SkyBottomLineBatchCalculatorBackendType } from "../../OpenSheetMusicDisplay";
+import { EngravingRules } from "./EngravingRules";
+import { PlainSkyBottomLineBatchCalculatorBackend } from "./PlainSkyBottomLineBatchCalculatorBackend";
+import { SkyBottomLineCalculator } from "./SkyBottomLineCalculator";
+import { SkyBottomLineBatchCalculatorBackend } from "./SkyBottomLineBatchCalculatorBackend";
+import { SkyBottomLineCalculationResult } from "./SkyBottomLineCalculationResult";
+import { StaffLine } from "./StaffLine";
+import { VexFlowMeasure } from "./VexFlow/VexFlowMeasure";
+import { WebGLSkyBottomLineBatchCalculatorBackend } from "./WebGLSkyBottomLineBatchCalculatorBackend";
+import log from "loglevel";
+
+interface IBatchEntry {
+    skyBottomLineCalculator: SkyBottomLineCalculator;
+    measures: VexFlowMeasure[];
+}
+
+interface IBatch {
+    backend: SkyBottomLineBatchCalculatorBackend;
+    entries: IBatchEntry[];
+}
+
+/**
+ * This class calculates the skylines and the bottom lines for multiple stafflines.
+ */
+export class SkyBottomLineBatchCalculator {
+    private batches: Map<EngravingRules, IBatch>;
+
+    constructor(staffLines: StaffLine[], preferredBackend: SkyBottomLineBatchCalculatorBackendType) {
+        const batchEntryArrayList: Map<EngravingRules, IBatchEntry[]> = new Map<EngravingRules, IBatchEntry[]>();
+        for (const staffLine of staffLines) {
+            const rules: EngravingRules = staffLine.ParentMusicSystem.rules;
+            const batchEntryArray: IBatchEntry[] = ((): IBatchEntry[] => {
+                if (batchEntryArrayList.has(rules)) {
+                    return batchEntryArrayList.get(rules)!;
+                } else {
+                    const array: IBatchEntry[] = [];
+                    batchEntryArrayList.set(rules, array);
+                    return array;
+                }
+            })();
+            batchEntryArray.push({
+                skyBottomLineCalculator: staffLine.SkyBottomLineCalculator,
+                measures: staffLine.Measures as VexFlowMeasure[]
+            });
+        }
+
+        this.batches = new Map<EngravingRules, IBatch>();
+        for (const [rules, batchEntryArray] of batchEntryArrayList.entries()) {
+            const measures: VexFlowMeasure[] = batchEntryArray.map(entry => entry.measures).flat();
+            const backend: SkyBottomLineBatchCalculatorBackend = ((): SkyBottomLineBatchCalculatorBackend => {
+                if (preferredBackend === SkyBottomLineBatchCalculatorBackendType.Plain) {
+                    return new PlainSkyBottomLineBatchCalculatorBackend(rules, measures).initialize();
+                } else {
+                    try {
+                        return new WebGLSkyBottomLineBatchCalculatorBackend(rules, measures).initialize();
+                    } catch {
+                        log.info("Couldn't create WebGLBackend for Skyline. Using fallback.");
+                        return new PlainSkyBottomLineBatchCalculatorBackend(rules, measures).initialize();
+                    }
+                }
+            })();
+            backend.initialize();
+
+            this.batches.set(rules, {
+                backend,
+                entries: batchEntryArray
+            });
+        }
+    }
+
+    /**
+     * This method calculates the skylines and the bottom lines for the stafflines passed to the constructor.
+     */
+    public calculateLines(): void {
+        for (const [, { backend, entries }] of this.batches) {
+            const results: SkyBottomLineCalculationResult[] = backend.calculateLines();
+            let start: number = 0;
+            for (const { skyBottomLineCalculator, measures } of entries) {
+                const end: number = start + measures.length;
+                skyBottomLineCalculator.updateLines(results.slice(start, end));
+                start = end;
+            }
+        }
+    }
+}

+ 234 - 0
src/MusicalScore/Graphical/SkyBottomLineBatchCalculatorBackend.ts

@@ -0,0 +1,234 @@
+import { EngravingRules } from "./EngravingRules";
+import { SkyBottomLineCalculationResult } from "./SkyBottomLineCalculationResult";
+import { CanvasVexFlowBackend } from "./VexFlow/CanvasVexFlowBackend";
+import { VexFlowMeasure } from "./VexFlow/VexFlowMeasure";
+import log from "loglevel";
+
+/**
+ * SkyBottomLineBatchCalculatorBackend renders measures in a borderless table.
+ * This interface contains the configuration for the table returned by classes
+ * implementing SkyBottomLineBatchCalculatorBackend. The height of a cell is
+ * set to a fixed value by SkyBottomLineBatchCalculatorBackend.
+ */
+ export interface ISkyBottomLineBatchCalculatorBackendPartialTableConfiguration {
+    /** The width of each cell */
+    elementWidth: number;
+    /** The number of cell in a row */
+    numColumns: number;
+    /** The number of cell in a column */
+    numRows: number;
+}
+
+/**
+ * This interface contains the complete configuration for the table rendered by
+ * SkyBottomLineBatchCalculatorBackend,
+ */
+export interface ISkyBottomLineBatchCalculatorBackendTableConfiguration
+    extends ISkyBottomLineBatchCalculatorBackendPartialTableConfiguration {
+    /** The height of each cell determined by SkyBottomLineBatchCalculatorBackend */
+    elementHeight: number;
+}
+
+/**
+ * This class calculates the sky lines and the bottom lines for multiple stafflines.
+ */
+export abstract class SkyBottomLineBatchCalculatorBackend {
+    /** The canvas where the measures are to be drawn in */
+    private readonly canvas: CanvasVexFlowBackend;
+    /** The measures to draw */
+    private readonly measures: VexFlowMeasure[];
+    /** The width of the widest measure */
+    private readonly maxWidth: number;
+    /** The samplingUnit from the EngravingRules */
+    private readonly samplingUnit: number;
+    /**
+     * The default height used by CanvasVexFlowBackend. Update this value when the
+     * default height value of CanvasVexFlowBackend.initializeHeadless is updated.
+     * This value is used as a height of each cell in the table rendered by this class.
+     */
+    private readonly elementHeight: number = 300;
+    /**
+     * The table configuration returned by getPreferredRenderingConfiguration. This value
+     * is set after initialize() returns.
+     */
+    private tableConfiguration: ISkyBottomLineBatchCalculatorBackendTableConfiguration;
+
+    constructor(rules: EngravingRules, measures: VexFlowMeasure[]) {
+        this.canvas = new CanvasVexFlowBackend(rules);
+        this.measures = measures;
+        this.maxWidth = Math.max(...this.measures.map(measure => {
+            let width: number = measure.getVFStave().getWidth();
+            if (!(width > 0) && !measure.IsExtraGraphicalMeasure) {
+                log.warn("SkyBottomLineBatchCalculatorBackend: width not > 0 in measure " + measure.MeasureNumber);
+                width = 50;
+            }
+            return width;
+        }));
+        this.samplingUnit = rules.SamplingUnit;
+    }
+
+    /**
+     * This method returns the configuration for the table where the measures are to be rendered.
+     * @param maxWidth the width of the widest measure
+     * @param elementHeight the height of each cell
+     */
+    protected abstract getPreferredRenderingConfiguration(
+        maxWidth: number,
+        elementHeight: number
+    ): ISkyBottomLineBatchCalculatorBackendPartialTableConfiguration;
+
+    /**
+     * This method allocates resources required by the implementation class.
+     * @param tableConfiguration the table configuration returned by getPreferredRenderingConfiguration
+     */
+    protected abstract onInitialize(tableConfiguration: ISkyBottomLineBatchCalculatorBackendTableConfiguration): void;
+
+    /**
+     * This method allocates required resources for the calculation.
+     */
+    public initialize(): SkyBottomLineBatchCalculatorBackend {
+        this.tableConfiguration = {
+            ...this.getPreferredRenderingConfiguration(this.maxWidth, this.elementHeight),
+            elementHeight: this.elementHeight
+        };
+        if (this.tableConfiguration.numRows < 1 || this.tableConfiguration.numColumns < 1) {
+            log.warn("SkyBottomLineBatchCalculatorBackend: numRows or numColumns in tableConfiguration is 0");
+            throw new Error("numRows or numColumns in tableConfiguration is 0");
+        }
+
+        if (this.tableConfiguration.elementWidth < this.maxWidth) {
+            log.warn("SkyBottomLineBatchCalculatorBackend: elementWidth in tableConfiguration is less than the width of widest measure");
+        }
+
+        const width: number = this.tableConfiguration.elementWidth * this.tableConfiguration.numColumns;
+        const height: number = this.elementHeight * this.tableConfiguration.numRows;
+        this.canvas.initializeHeadless(width, height);
+        this.onInitialize(this.tableConfiguration);
+
+        return this;
+    }
+
+    /**
+     * This method calculates the skylines and the bottom lines for the measures rendered in the given canvas.
+     * @param canvas the canvas where the measures are rendered
+     * @param context the drawing context of canvas
+     * @param measures the rendered measures
+     * @param tableConfiguration the table configuration returned by getPreferredRenderingConfiguration
+     */
+    protected abstract calculateFromCanvas(
+        canvas: HTMLCanvasElement,
+        context: Vex.Flow.CanvasContext,
+        measures: VexFlowMeasure[],
+        samplingUnit: number,
+        tableConfiguration: ISkyBottomLineBatchCalculatorBackendTableConfiguration
+    ): SkyBottomLineCalculationResult[];
+
+    /**
+     * This method calculates the skylines and the bottom lines for the measures passed to the constructor.
+     */
+    public calculateLines(): SkyBottomLineCalculationResult[] {
+        const debugTmpCanvas: boolean = false;
+
+        const { numColumns, numRows, elementWidth } = this.tableConfiguration;
+        const elementHeight: number = this.elementHeight;
+        const numElementsPerTable: number = numColumns * numRows;
+
+        const vexFlowContext: Vex.Flow.CanvasContext = this.canvas.getContext();
+        const context: CanvasRenderingContext2D = vexFlowContext as unknown as CanvasRenderingContext2D;
+        const canvasElement: HTMLCanvasElement = this.canvas.getCanvas() as HTMLCanvasElement;
+
+        if (debugTmpCanvas) {
+            document.querySelectorAll(".osmd-sky-bottom-line-tmp-canvas").forEach(element => element.parentElement.removeChild(element));
+        }
+
+        const results: SkyBottomLineCalculationResult[] = [];
+        for (let i: number = 0; i < this.measures.length; i += numElementsPerTable) {
+            vexFlowContext.clear();
+
+            const measures: VexFlowMeasure[] = this.measures.slice(i, i + numElementsPerTable);
+
+            for (let j: number = 0; j < measures.length; ++j) {
+                const measure: VexFlowMeasure = measures[j];
+                const vsStaff: Vex.Flow.Stave = measure.getVFStave();
+
+                // (u, v) is the position of measure in the table
+                const u: number = j % numColumns;
+                const v: number = Math.floor(j / numColumns);
+
+                let currentWidth: number = vsStaff.getWidth();
+                if (!(currentWidth > 0) && !measure.IsExtraGraphicalMeasure) {
+                    currentWidth = 50;
+                }
+                currentWidth = Math.floor(currentWidth);
+
+                // must calculate first AbsolutePositions
+                measure.PositionAndShape.calculateAbsolutePositionsRecursive(0, 0);
+
+                const x: number = 0;
+                vsStaff.setX(x);
+
+                // The magic number 100 is an offset from the top image border so that
+                // elements above the staffline can be drawn correctly.
+                const y: number = (<any>vsStaff).y as number + 100;
+                vsStaff.setY(y);
+
+                const oldMeasureWidth: number = vsStaff.getWidth();
+                // We need to tell the VexFlow stave about the canvas width. This looks
+                // redundant because it should know the canvas but somehow it doesn't.
+                // Maybe I am overlooking something but for now this does the trick
+                vsStaff.setWidth(currentWidth);
+                measure.format();
+                vsStaff.setWidth(oldMeasureWidth);
+
+                try {
+                    context.translate(u * elementWidth, v * elementHeight);
+                    measure.draw(vexFlowContext);
+                    context.translate(-u * elementWidth, -v * elementHeight);
+                    // Vexflow errors can happen here, then our complete rendering loop would halt without catching errors.
+                } catch (ex) {
+                    log.warn("SkyBottomLineBatchCalculatorBackend.calculateLines.draw", ex);
+                }
+            }
+
+            const result: SkyBottomLineCalculationResult[] = this.calculateFromCanvas(
+                canvasElement,
+                vexFlowContext,
+                measures,
+                this.samplingUnit,
+                this.tableConfiguration
+            );
+            results.push(...result);
+
+            if (debugTmpCanvas) {
+                const canvasContext: CanvasRenderingContext2D = vexFlowContext as unknown as CanvasRenderingContext2D;
+                const oldFillStyle: string | CanvasGradient | CanvasPattern = canvasContext.fillStyle;
+                for (let j: number = 0; j < result.length; ++j) {
+                    const { skyLine, bottomLine } = result[j];
+
+                    const u: number = j % numColumns;
+                    const v: number = Math.floor(j / numColumns);
+
+                    const xStart: number = u * elementWidth;
+                    const yStart: number = v * elementHeight;
+
+                    canvasContext.fillStyle = "#FF0000";
+                    skyLine.forEach((y, x) => vexFlowContext.fillRect(x - 1 + xStart, y - 1 + yStart, 2, 2));
+                    canvasContext.fillStyle = "#0000FF";
+                    bottomLine.forEach((y, x) => vexFlowContext.fillRect(x - 1 + xStart, y - 1 + yStart, 2, 2));
+                }
+                canvasContext.fillStyle = oldFillStyle;
+                const url: string = canvasElement.toDataURL("image/png");
+                const img: HTMLImageElement = document.createElement("img");
+                img.classList.add("osmd-sky-bottom-line-tmp-canvas");
+                img.src = url;
+                document.body.appendChild(img);
+
+                const hr: HTMLHRElement = document.createElement("hr");
+                hr.classList.add("osmd-sky-bottom-line-tmp-canvas");
+                document.body.appendChild(hr);
+            }
+        }
+
+        return results;
+    }
+}

+ 12 - 0
src/MusicalScore/Graphical/SkyBottomLineCalculationResult.ts

@@ -0,0 +1,12 @@
+/**
+ * Contains a skyline and a bottomline for a measure.
+ */
+export class SkyBottomLineCalculationResult {
+    public skyLine: number[];
+    public bottomLine: number[];
+
+    constructor(skyLine: number[], bottomLine: number[]) {
+        this.skyLine = skyLine;
+        this.bottomLine = bottomLine;
+    }
+}

+ 130 - 106
src/MusicalScore/Graphical/SkyBottomLineCalculator.ts

@@ -2,10 +2,11 @@ import { EngravingRules } from "./EngravingRules";
 import { StaffLine } from "./StaffLine";
 import { PointF2D } from "../../Common/DataObjects/PointF2D";
 import { VexFlowMeasure } from "./VexFlow/VexFlowMeasure";
-import { BoundingBox } from "./BoundingBox";
-import { CanvasVexFlowBackend } from "./VexFlow";
 import { unitInPixels } from "./VexFlow/VexFlowMusicSheetDrawer";
 import log from "loglevel";
+import { BoundingBox } from "./BoundingBox";
+import { SkyBottomLineCalculationResult } from "./SkyBottomLineCalculationResult";
+import { CanvasVexFlowBackend } from "./VexFlow/CanvasVexFlowBackend";
 /**
  * This class calculates and holds the skyline and bottom line information.
  * It also has functions to update areas of the two lines if new elements are
@@ -31,16 +32,83 @@ export class SkyBottomLineCalculator {
     }
 
     /**
-     * This method calculates the Sky- and BottomLines for a StaffLine using the canvas pixel method
+     * This method updates the skylines and bottomlines for mStaffLineParent.
+     * @param calculationResults the skylines and bottomlines of mStaffLineParent's measures calculated by SkyBottomLineBatchCalculator
      */
-    public calculateLines(): void {
-        // calculate arrayLength
+    public updateLines(calculationResults: SkyBottomLineCalculationResult[]): void {
+        const measures: VexFlowMeasure[] = this.StaffLineParent.Measures as VexFlowMeasure[];
+
+        if (calculationResults.length !== measures.length) {
+            log.warn("SkyBottomLineCalculator: lengths of calculation result array and measure array do not match");
+
+            if (calculationResults.length < measures.length) {
+                while (calculationResults.length < measures.length) {
+                    calculationResults.push(new SkyBottomLineCalculationResult([], []));
+                }
+            } else {
+                calculationResults = calculationResults.slice(0, measures.length);
+            }
+        }
+
         const arrayLength: number = Math.max(Math.ceil(this.StaffLineParent.PositionAndShape.Size.width * this.SamplingUnit), 1);
         this.mSkyLine = [];
         this.mBottomLine = [];
 
+        for (const { skyLine, bottomLine } of calculationResults) {
+            this.mSkyLine.push(...skyLine);
+            this.mBottomLine.push(...bottomLine);
+        }
+
+        // Subsampling:
+        // The pixel width is bigger than the measure size in units. So we split the array into
+        // chunks with the size of MeasurePixelWidth/measureUnitWidth and reduce the value to its
+        // average
+        const arrayChunkSize: number = this.mSkyLine.length / arrayLength;
+
+        const subSampledSkyLine: number[] = [];
+        const subSampledBottomLine: number[] = [];
+        for (let chunkIndex: number = 0; chunkIndex < this.mSkyLine.length; chunkIndex += arrayChunkSize) {
+            if (subSampledSkyLine.length === arrayLength) {
+                break; // TODO find out why skyline.length becomes arrayLength + 1. see log.debug below
+            }
+
+            const endIndex: number = Math.min(this.mSkyLine.length, chunkIndex + arrayChunkSize);
+            let chunk: number[] = this.mSkyLine.slice(chunkIndex, endIndex + 1); // slice does not include end index
+            // TODO chunkIndex + arrayChunkSize is sometimes bigger than this.mSkyLine.length -> out of bounds
+            // TODO chunkIndex + arrayChunkSize is often a non-rounded float as well. is that ok to use with slice?
+            /*const diff: number = this.mSkyLine.length - (chunkIndex + arrayChunkSize);
+            if (diff < 0) { // out of bounds
+                console.log("length - slice end index: " + diff);
+            }*/
+
+            subSampledSkyLine.push(Math.min(...chunk));
+            chunk = this.mBottomLine.slice(chunkIndex, endIndex + 1); // slice does not include end index
+            subSampledBottomLine.push(Math.max(...chunk));
+        }
+
+        this.mSkyLine = subSampledSkyLine;
+        this.mBottomLine = subSampledBottomLine;
+        if (this.mSkyLine.length !== arrayLength) { // bottomline will always be same length as well
+            log.debug(`SkyLine calculation was not correct (${this.mSkyLine.length} instead of ${arrayLength})`);
+        }
+
+        // Remap the values from 0 to +/- height in units
+        const lowestSkyLine: number = Math.max(...this.mSkyLine);
+        this.mSkyLine = this.mSkyLine.map(v => (v - lowestSkyLine) / unitInPixels + this.StaffLineParent.TopLineOffset);
+
+        const highestBottomLine: number = Math.min(...this.mBottomLine);
+        this.mBottomLine = this.mBottomLine.map(v => (v - highestBottomLine) / unitInPixels + this.StaffLineParent.BottomLineOffset);
+    }
+
+    /**
+     * This method calculates the Sky- and BottomLines for a StaffLine.
+     */
+    public calculateLines(): void {
+        const samplingUnit: number = this.mRules.SamplingUnit;
+        const results: SkyBottomLineCalculationResult[] = [];
+
         // Create a temporary canvas outside the DOM to draw the measure in.
-        const tmpCanvas: any = new CanvasVexFlowBackend(this.StaffLineParent.ParentMusicSystem.rules);
+        const tmpCanvas: any = new CanvasVexFlowBackend(this.mRules);
         // search through all Measures
         for (const measure of this.StaffLineParent.Measures as VexFlowMeasure[]) {
             // must calculate first AbsolutePositions
@@ -82,7 +150,7 @@ export class SkyBottomLineCalculator {
             // Since we are only interested in black or white we can take 32bit words at once
             const imageData: any = ctx.getImageData(0, 0, width, height);
             const rgbaLength: number = 4;
-            const measureArrayLength: number = Math.max(Math.ceil(measure.PositionAndShape.Size.width * this.mRules.SamplingUnit), 1);
+            const measureArrayLength: number = Math.max(Math.ceil(measure.PositionAndShape.Size.width * samplingUnit), 1);
             const tmpSkyLine: number[] = new Array(measureArrayLength);
             const tmpBottomLine: number[] = new Array(measureArrayLength);
             for (let x: number = 0; x < width; x++) {
@@ -119,8 +187,7 @@ export class SkyBottomLineCalculator {
                 }
             }
 
-            this.mSkyLine.push(...tmpSkyLine);
-            this.mBottomLine.push(...tmpBottomLine);
+            results.push(new SkyBottomLineCalculationResult(tmpSkyLine, tmpBottomLine));
 
             // Set to true to only show the "mini canvases" and the corresponding skylines
             const debugTmpCanvas: boolean = false;
@@ -132,90 +199,8 @@ export class SkyBottomLineCalculator {
             }
             tmpCanvas.clear();
         }
-        // Subsampling:
-        // The pixel width is bigger than the measure size in units. So we split the array into
-        // chunks with the size of MeasurePixelWidth/measureUnitWidth and reduce the value to its
-        // average
-        const arrayChunkSize: number = this.mSkyLine.length / arrayLength;
-
-        const subSampledSkyLine: number[] = [];
-        const subSampledBottomLine: number[] = [];
-        for (let chunkIndex: number = 0; chunkIndex < this.mSkyLine.length; chunkIndex += arrayChunkSize) {
-            if (subSampledSkyLine.length === arrayLength) {
-                break; // TODO find out why skyline.length becomes arrayLength + 1. see log.debug below
-            }
-
-            const endIndex: number = Math.min(this.mSkyLine.length, chunkIndex + arrayChunkSize);
-            let chunk: number[] = this.mSkyLine.slice(chunkIndex, endIndex + 1); // slice does not include end index
-            // TODO chunkIndex + arrayChunkSize is sometimes bigger than this.mSkyLine.length -> out of bounds
-            // TODO chunkIndex + arrayChunkSize is often a non-rounded float as well. is that ok to use with slice?
-            /*const diff: number = this.mSkyLine.length - (chunkIndex + arrayChunkSize);
-            if (diff < 0) { // out of bounds
-                console.log("length - slice end index: " + diff);
-            }*/
-
-            subSampledSkyLine.push(Math.min(...chunk));
-            chunk = this.mBottomLine.slice(chunkIndex, endIndex + 1); // slice does not include end index
-            subSampledBottomLine.push(Math.max(...chunk));
-        }
-
-        this.mSkyLine = subSampledSkyLine;
-        this.mBottomLine = subSampledBottomLine;
-        if (this.mSkyLine.length !== arrayLength) { // bottomline will always be same length as well
-            log.debug(`SkyLine calculation was not correct (${this.mSkyLine.length} instead of ${arrayLength})`);
-        }
-        // Remap the values from 0 to +/- height in units
-        this.mSkyLine = this.mSkyLine.map(v => (v - Math.max(...this.mSkyLine)) / unitInPixels + this.StaffLineParent.TopLineOffset);
-        this.mBottomLine = this.mBottomLine.map(v => (v - Math.min(...this.mBottomLine)) / unitInPixels + this.StaffLineParent.BottomLineOffset);
-    }
-
-    /**
-     * go backwards through the skyline array and find a number so that
-     * we can properly calculate the average
-     * @param start
-     * @param backend
-     * @param color
-     */
-    private findPreviousValidNumber(start: number, tSkyLine: number[]): number {
-        for (let idx: number = start; idx >= 0; idx--) {
-            if (!isNaN(tSkyLine[idx])) {
-                return tSkyLine[idx];
-            }
-        }
-        return 0;
-    }
-
-    /**
-     * go forward through the skyline array and find a number so that
-     * we can properly calculate the average
-     * @param start
-     * @param backend
-     * @param color
-     */
-    private findNextValidNumber(start: number, tSkyLine: Array<number>): number {
-        if (start >= tSkyLine.length) {
-            return tSkyLine[start - 1];
-        }
-        for (let idx: number = start; idx < tSkyLine.length; idx++) {
-            if (!isNaN(tSkyLine[idx])) {
-                return tSkyLine[idx];
-            }
-        }
-        return 0;
-    }
 
-    /**
-     * Debugging drawing function that can draw single pixels
-     * @param coord Point to draw to
-     * @param backend the backend to be used
-     * @param color the color to be used, default is red
-     */
-    private drawPixel(coord: PointF2D, backend: CanvasVexFlowBackend, color: string = "#FF0000FF"): void {
-        const ctx: any = backend.getContext();
-        const oldStyle: string = ctx.fillStyle;
-        ctx.fillStyle = color;
-        ctx.fillRect(coord.x, coord.y, 2, 2);
-        ctx.fillStyle = oldStyle;
+        this.updateLines(results);
     }
     /**
      * This method updates the SkyLine for a given Wedge.
@@ -291,8 +276,8 @@ export class SkyBottomLineCalculator {
     /**
      * This method updates the SkyLine for a given range with a given value
      * //param  to update the SkyLine for
-     * @param start Start index of the range
-     * @param end End index of the range
+     * @param startIndex Start index of the range
+     * @param endIndex End index of the range
      * @param value ??
      */
     public updateSkyLineInRange(startIndex: number, endIndex: number, value: number): void {
@@ -301,9 +286,8 @@ export class SkyBottomLineCalculator {
 
     /**
      * This method updates the BottomLine for a given range with a given value
-     * @param  to update the BottomLine for
-     * @param start Start index of the range
-     * @param end End index of the range (excluding)
+     * @param startIndex Start index of the range
+     * @param endIndex End index of the range (excluding)
      * @param value ??
      */
     public updateBottomLineInRange(startIndex: number, endIndex: number, value: number): void {
@@ -312,7 +296,6 @@ export class SkyBottomLineCalculator {
 
     /**
      * Resets a SkyLine in a range to its original value
-     * @param  to reset the SkyLine in
      * @param startIndex Start index of the range
      * @param endIndex End index of the range (excluding)
      */
@@ -322,7 +305,6 @@ export class SkyBottomLineCalculator {
 
     /**
      * Resets a bottom line in a range to its original value
-     * @param  to reset the bottomline in
      * @param startIndex Start index of the range
      * @param endIndex End index of the range
      */
@@ -386,7 +368,6 @@ export class SkyBottomLineCalculator {
 
     /**
      * This method finds the minimum value of the SkyLine.
-     * @param staffLine StaffLine to apply to
      */
     public getSkyLineMin(): number {
         return Math.min(...this.SkyLine.filter(s => !isNaN(s)));
@@ -399,7 +380,6 @@ export class SkyBottomLineCalculator {
 
     /**
      * This method finds the SkyLine's minimum value within a given range.
-     * @param staffLine Staffline to apply to
      * @param startIndex Starting index
      * @param endIndex End index (including)
      */
@@ -409,7 +389,6 @@ export class SkyBottomLineCalculator {
 
     /**
      * This method finds the maximum value of the BottomLine.
-     * @param staffLine Staffline to apply to
      */
     public getBottomLineMax(): number {
         return Math.max(...this.BottomLine.filter(s => !isNaN(s)));
@@ -422,7 +401,6 @@ export class SkyBottomLineCalculator {
 
     /**
      * This method finds the BottomLine's maximum value within a given range.
-     * @param staffLine Staffline to find the max value in
      * @param startIndex Start index of the range
      * @param endIndex End index of the range (excluding)
      */
@@ -443,12 +421,9 @@ export class SkyBottomLineCalculator {
         return this.getMaxInRange(this.mBottomLine, startPoint, endPoint);
     }
 
-    //#region Private methods
-
     /**
      * Updates sky- and bottom line with a boundingBox and its children
      * @param boundingBox Bounding box to be added
-     * @param topBorder top
      */
     public updateWithBoundingBoxRecursively(boundingBox: BoundingBox): void {
         if (boundingBox.ChildElements && boundingBox.ChildElements.length > 0) {
@@ -473,6 +448,55 @@ export class SkyBottomLineCalculator {
         }
     }
 
+    //#region Private methods
+
+    /**
+     * go backwards through the skyline array and find a number so that
+     * we can properly calculate the average
+     * @param start the starting index of the search
+     * @param tSkyLine the skyline to search through
+     */
+     private findPreviousValidNumber(start: number, tSkyLine: number[]): number {
+        for (let idx: number = start; idx >= 0; idx--) {
+            if (!isNaN(tSkyLine[idx])) {
+                return tSkyLine[idx];
+            }
+        }
+        return 0;
+    }
+
+    /**
+     * go forward through the skyline array and find a number so that
+     * we can properly calculate the average
+     * @param start the starting index of the search
+     * @param tSkyLine the skyline to search through
+     */
+    private findNextValidNumber(start: number, tSkyLine: Array<number>): number {
+        if (start >= tSkyLine.length) {
+            return tSkyLine[start - 1];
+        }
+        for (let idx: number = start; idx < tSkyLine.length; idx++) {
+            if (!isNaN(tSkyLine[idx])) {
+                return tSkyLine[idx];
+            }
+        }
+        return 0;
+    }
+
+    /**
+     * Debugging drawing function that can draw single pixels
+     * @param coord Point to draw to
+     * @param backend the backend to be used
+     * @param color the color to be used, default is red
+     */
+    private drawPixel(coord: PointF2D, backend: CanvasVexFlowBackend, color: string = "#FF0000FF"): void {
+        const ctx: any = backend.getContext();
+        const oldStyle: string = ctx.fillStyle;
+        ctx.fillStyle = color;
+        ctx.fillRect(coord.x, coord.y, 2, 2);
+        ctx.fillStyle = oldStyle;
+    }
+
     /**
      * Update an array with the value given inside a range. NOTE: will only be updated if value > oldValue
      * @param array Array to fill in the new value

+ 26 - 0
src/MusicalScore/Graphical/VexFlow/VexFlowMusicSheetCalculator.ts

@@ -2,6 +2,7 @@ import { MusicSheetCalculator } from "../MusicSheetCalculator";
 import { VexFlowGraphicalSymbolFactory } from "./VexFlowGraphicalSymbolFactory";
 import { GraphicalMeasure } from "../GraphicalMeasure";
 import { StaffLine } from "../StaffLine";
+import { SkyBottomLineBatchCalculator } from "../SkyBottomLineBatchCalculator";
 import { VoiceEntry } from "../../VoiceData/VoiceEntry";
 import { GraphicalNote } from "../GraphicalNote";
 import { GraphicalStaffEntry } from "../GraphicalStaffEntry";
@@ -1562,6 +1563,31 @@ export class VexFlowMusicSheetCalculator extends MusicSheetCalculator {
     }
   }
 
+  protected calculateSkyBottomLines(): void {
+    const staffLines: StaffLine[] = this.musicSystems.map(musicSystem => musicSystem.StaffLines).flat();
+    //const numMeasures: number = staffLines.map(staffLine => staffLine.Measures.length).reduce((a, b) => a + b, 0);
+    let numMeasures: number = 0; // number of graphical measures that are rendered
+    for (const staffline of staffLines) {
+      for (const measure of staffline.Measures) {
+        if (measure) { // can be undefined and not rendered in multi-measure rest
+          numMeasures++;
+        }
+      }
+    }
+    if (this.rules.AlwaysSetPreferredSkyBottomLineBackendAutomatically) {
+      this.rules.setPreferredSkyBottomLineBackendAutomatically(numMeasures);
+    }
+    if (numMeasures >= this.rules.SkyBottomLineBatchMinMeasures) {
+      const calculator: SkyBottomLineBatchCalculator = new SkyBottomLineBatchCalculator(
+        staffLines, this.rules.PreferredSkyBottomLineBatchCalculatorBackend);
+      calculator.calculateLines();
+    } else {
+      for (const staffLine of staffLines) {
+        staffLine.SkyBottomLineCalculator.calculateLines();
+      }
+    }
+  }
+
   /**
    * Re-adjust the x positioning of expressions. Update the skyline afterwards
    */

+ 223 - 0
src/MusicalScore/Graphical/WebGLSkyBottomLineBatchCalculatorBackend.ts

@@ -0,0 +1,223 @@
+import { EngravingRules } from "./EngravingRules";
+import { VexFlowMeasure } from "./VexFlow/VexFlowMeasure";
+import { SkyBottomLineCalculationResult } from "./SkyBottomLineCalculationResult";
+import {
+    ISkyBottomLineBatchCalculatorBackendPartialTableConfiguration,
+    ISkyBottomLineBatchCalculatorBackendTableConfiguration,
+    SkyBottomLineBatchCalculatorBackend
+} from "./SkyBottomLineBatchCalculatorBackend";
+import vertexShaderSource from "./Shaders/VertexShader.glsl";
+import fragmentShaderSource from "./Shaders/FragmentShader.glsl";
+import log from "loglevel";
+
+// WebGL helper functions
+
+function createShader(gl: WebGLRenderingContext, type: number, source: string): WebGLShader {
+    const shader: WebGLProgram = gl.createShader(type);
+    if (!shader) {
+        log.warn("WebGLSkyBottomLineCalculatorBackend: Could not create a WebGL shader");
+        throw new Error("Could not create a WebGL shader");
+    }
+
+    gl.shaderSource(shader, source);
+    gl.compileShader(shader);
+    if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
+        log.warn("Shader compilation failed\n" + gl.getShaderInfoLog(shader));
+        gl.deleteShader(shader);
+        throw new Error("WebGL shader compilation failed");
+    }
+
+    return shader;
+}
+
+function createProgram(gl: WebGLRenderingContext, vertexShader: WebGLShader, fragmentShader: WebGLShader): WebGLProgram {
+    const program: WebGLProgram = gl.createProgram();
+    if (!program) {
+        log.warn("WebGLSkyBottomLineCalculatorBackend: Could not create a WebGL program");
+        throw new Error("Could not create a WebGL program");
+    }
+
+    gl.attachShader(program, vertexShader);
+    gl.attachShader(program, fragmentShader);
+    gl.linkProgram(program);
+    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
+        log.warn("WebGLSkyBottomLineCalculatorBackend: WebGL program link failed\n" + gl.getProgramInfoLog(program));
+        gl.deleteProgram(program);
+        throw new Error("WebGL program link failed");
+    }
+    return program;
+}
+
+function createVertexBuffer(gl: WebGLRenderingContext, program: WebGLShader, attributeName: string, vertices: [number, number][]): WebGLBuffer {
+    const vertexBuffer: WebGLBuffer = gl.createBuffer();
+    if (!vertexBuffer) {
+        log.warn("WebGLSkyBottomLineCalculatorBackend: WebGL buffer creation failed");
+        throw new Error("WebGL buffer creation failed");
+    }
+
+    gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
+    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices.flat()), gl.STATIC_DRAW);
+    gl.useProgram(program);
+
+    const positionAttributeLocation: number = gl.getAttribLocation(program, attributeName);
+    gl.enableVertexAttribArray(positionAttributeLocation);
+    gl.vertexAttribPointer(
+        positionAttributeLocation,
+        2,
+        gl.FLOAT,
+        false, // no nomralization
+        0,     // stride = 0
+        0,     // offset = 0
+    );
+
+    return vertexBuffer;
+}
+
+function createTexture(gl: WebGLRenderingContext, program: WebGLShader, textureIdx: number, uniformName: string): WebGLTexture {
+    const texture: WebGLTexture = gl.createTexture();
+    if (!texture) {
+        log.warn("WebGLSkyBottomLineCalculatorBackend: WebGL texture creation failed");
+        throw new Error("WebGL texture creation failed");
+    }
+
+    gl.activeTexture(gl.TEXTURE0 + textureIdx);
+    gl.bindTexture(gl.TEXTURE_2D, texture);
+    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
+    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+
+    const uniformLocation: WebGLUniformLocation = gl.getUniformLocation(program, uniformName);
+    if (!uniformLocation) {
+        log.warn("WebGLSkyBottomLineCalculatorBackend: WebGL invalid uniform name");
+        throw new Error("WebGL invalid uniform name");
+    }
+    gl.uniform1i(uniformLocation, textureIdx);
+
+    return texture;
+}
+
+function updateMacroConstantsInShaderSource(source: string, constants: { [macroName: string]: number }): string {
+    let result: string = source;
+    for (const [macroName, macroValue] of Object.entries(constants)) {
+        const regex: RegExp = new RegExp(`#define ${macroName} .*`);
+        result = result.replace(regex, `#define ${macroName} ${macroValue}`);
+    }
+    return result;
+}
+
+function getMaximumTextureSize(): number {
+    const canvas: HTMLCanvasElement = document.createElement("canvas");
+    const gl: WebGLRenderingContext = canvas.getContext("webgl");
+    return gl.getParameter(gl.MAX_TEXTURE_SIZE) as number;
+}
+
+/**
+ * This class calculates the skylines and the bottom lines by using WebGL acceleration.
+ */
+export class WebGLSkyBottomLineBatchCalculatorBackend extends SkyBottomLineBatchCalculatorBackend {
+    private gl: WebGLRenderingContext;
+    private texture: WebGLTexture;
+
+    constructor(rules: EngravingRules, measures: VexFlowMeasure[]) {
+        super(rules, measures);
+    }
+
+    protected getPreferredRenderingConfiguration(maxWidth: number, elementHeight: number): ISkyBottomLineBatchCalculatorBackendPartialTableConfiguration {
+        const maxTextureSize: number = Math.min(4096, getMaximumTextureSize());
+        const elementWidth: number = Math.ceil(maxWidth);
+        const numColumns: number = Math.min(5, Math.floor(maxTextureSize / elementWidth));
+        const numRows: number = Math.min(5, Math.floor(maxTextureSize / elementHeight));
+
+        return { elementWidth, numColumns, numRows };
+    }
+
+    protected onInitialize(tableConfiguration: ISkyBottomLineBatchCalculatorBackendTableConfiguration): void {
+        const { elementWidth, elementHeight, numColumns, numRows } = tableConfiguration;
+        const canvas: HTMLCanvasElement = document.createElement("canvas");
+        canvas.width = elementWidth * numColumns;
+        canvas.height = numRows;
+
+        const gl: WebGLRenderingContext = canvas.getContext("webgl");
+        if (!gl) {
+            log.warn("WebGLSkyBottomLineCalculatorBackend: No WebGL support");
+            throw new Error("No WebGL support");
+        }
+        this.gl = gl;
+
+        const vertexShader: WebGLShader = createShader(gl, gl.VERTEX_SHADER, vertexShaderSource);
+        const fragmentShader: WebGLShader = createShader(
+            gl, gl.FRAGMENT_SHADER,
+            updateMacroConstantsInShaderSource(fragmentShaderSource, {
+                NUM_ROWS: numRows,
+                ELEMENT_HEIGHT: elementHeight,
+            })
+        );
+        const program: WebGLProgram = createProgram(gl, vertexShader, fragmentShader);
+        createVertexBuffer(gl, program, "a_position", [
+            [-1, -1],
+            [1, -1],
+            [1, 1],
+            [-1, -1],
+            [1, 1],
+            [-1, 1],
+        ]);
+        this.texture = createTexture(gl, program, 0, "u_image");
+    }
+
+    protected calculateFromCanvas(
+        canvas: HTMLCanvasElement,
+        _: Vex.Flow.CanvasContext,
+        measures: VexFlowMeasure[],
+        samplingUnit: number,
+        tableConfiguration: ISkyBottomLineBatchCalculatorBackendTableConfiguration
+    ): SkyBottomLineCalculationResult[] {
+        const gl: WebGLRenderingContext = this.gl;
+        const rgbaLength: number = 4;
+        const { elementWidth, elementHeight, numColumns } = tableConfiguration;
+
+        gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
+        gl.bindTexture(gl.TEXTURE_2D, this.texture);
+        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas);
+        gl.drawArrays(gl.TRIANGLES, 0, 6);
+
+        const pixels: Uint8Array = new Uint8Array(gl.canvas.width * gl.canvas.height * rgbaLength);
+        gl.readPixels(0, 0, gl.canvas.width, gl.canvas.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
+
+        const result: SkyBottomLineCalculationResult[] = [];
+        for (let i: number = 0; i < measures.length; ++i) {
+            const measure: VexFlowMeasure = measures[i];
+            const measureWidth: number = Math.floor(measure.getVFStave().getWidth());
+            const measureArrayLength: number =  Math.max(Math.ceil(measure.PositionAndShape.Size.width * samplingUnit), 1);
+            const u: number = i % numColumns;
+            const v: number = Math.floor(i / numColumns);
+
+            const xOffset: number = u * elementWidth * rgbaLength;
+            const yOffset: number = v * elementWidth * numColumns * rgbaLength;
+
+            const skyLine: number[] = new Array(Math.max(measureArrayLength, measureWidth)).fill(0);
+            const bottomLine: number[] = new Array(Math.max(measureArrayLength, measureWidth)).fill(0);
+
+            for (let x: number = 0; x < measureWidth; ++x) {
+                const r: number = pixels[x * rgbaLength + xOffset + yOffset];
+                const g: number = pixels[x * rgbaLength + xOffset + yOffset + 1];
+                const b: number = pixels[x * rgbaLength + xOffset + yOffset + 2];
+                const skyLinePixel: number = r + (Math.floor(b / 16) * 256);
+                const bottomLinePixel: number = g + (b % 16 * 256);
+                skyLine[x] = skyLinePixel;
+                bottomLine[x] = bottomLinePixel;
+            }
+
+            const lowestSkyLine: number = Math.max(...skyLine);
+            const highestBottomLine: number = Math.min(...bottomLine);
+
+            for (let x: number = 0; x < measureWidth; ++x) {
+                skyLine[x] = skyLine[x] === 0 ? lowestSkyLine : skyLine[x];
+                bottomLine[x] = bottomLine[x] === elementHeight ? highestBottomLine : bottomLine[x];
+            }
+
+            result.push(new SkyBottomLineCalculationResult(skyLine, bottomLine));
+        }
+        return result;
+    }
+}

+ 13 - 0
src/OpenSheetMusicDisplay/OSMDOptions.ts

@@ -262,6 +262,14 @@ export interface IOSMDOptions {
      * Defines multiple simultaneous cursors. If left undefined the standard cursor will be used.
      */
     cursorsOptions?: CursorOptions[];
+    /**
+     * Defines which skyline and bottom-line batch calculation algorithm to use.
+     */
+    preferredSkyBottomLineBatchCalculatorBackend?: SkyBottomLineBatchCalculatorBackendType;
+    /**
+     * Defines the minimum number of measures in the entire sheet music where the skyline and bottom-line batch calculation is enabled.
+     */
+    skyBottomLineBatchMinMeasures?: number;
 }
 
 export enum AlignRestOption {
@@ -281,6 +289,11 @@ export enum BackendType {
     Canvas = 1
 }
 
+export enum SkyBottomLineBatchCalculatorBackendType {
+    Plain = 0,
+    WebGL = 1,
+}
+
 /** Handles [[IOSMDOptions]], e.g. returning default options with OSMDOptionsStandard() */
 export class OSMDOptions {
     /** Returns the default options for OSMD.

+ 7 - 1
src/OpenSheetMusicDisplay/OpenSheetMusicDisplay.ts

@@ -35,7 +35,7 @@ import { DynamicsCalculator } from "../MusicalScore/ScoreIO/MusicSymbolModules/D
  * After the constructor, use load() and render() to load and render a MusicXML file.
  */
 export class OpenSheetMusicDisplay {
-    private version: string = "1.4.5-audio-extended"; // getter: this.Version
+    private version: string = "1.5.0-audio-extended"; // getter: this.Version
     // at release, bump version and change to -release, afterwards to -dev again
 
     /**
@@ -696,6 +696,12 @@ export class OpenSheetMusicDisplay {
         } else {
             this.cursorsOptions = [{type: 0, color: this.EngravingRules.DefaultColorCursor, alpha: 0.5, follow: true}];
         }
+        if (options.preferredSkyBottomLineBatchCalculatorBackend !== undefined) {
+            this.rules.PreferredSkyBottomLineBatchCalculatorBackend = options.preferredSkyBottomLineBatchCalculatorBackend;
+        }
+        if (options.skyBottomLineBatchMinMeasures !== undefined) {
+            this.rules.SkyBottomLineBatchMinMeasures = options.skyBottomLineBatchMinMeasures;
+        }
     }
 
     public setColoringMode(options: IOSMDOptions): void {

+ 4 - 0
src/global.d.ts

@@ -0,0 +1,4 @@
+declare module "*.glsl" {
+    const content: string;
+    export default content;
+}

+ 56 - 2
test/Util/generateImages_browserless.mjs

@@ -1,6 +1,7 @@
 import Blob from "cross-blob";
 import FS from "fs";
 import jsdom from "jsdom";
+//import headless_gl from "gl"; // this is now imported dynamically in a try catch, in case gl install fails, see #1160
 import OSMD from "../../build/opensheetmusicdisplay.min.js"; // window needs to be available before we can require OSMD
 /*
   Render each OSMD sample, grab the generated images, and
@@ -32,7 +33,7 @@ function sleep (ms) {
 // global variables
 //   (without these being global, we'd have to pass many of these values to the generateSampleImage function)
 // eslint-disable-next-line prefer-const
-let [osmdBuildDir, sampleDir, imageDir, imageFormat, pageWidth, pageHeight, filterRegex, mode, debugSleepTimeString] = process.argv.slice(2, 11);
+let [osmdBuildDir, sampleDir, imageDir, imageFormat, pageWidth, pageHeight, filterRegex, mode, debugSleepTimeString, skyBottomLinePreference] = process.argv.slice(2, 12);
 if (!osmdBuildDir || !sampleDir || !imageDir || (imageFormat !== "png" && imageFormat !== "svg")) {
     console.log("usage: " +
         // eslint-disable-next-line max-len
@@ -106,6 +107,36 @@ async function init () {
         global.Canvas = window.Canvas;
     }
 
+    // For WebGLSkyBottomLineCalculatorBackend: Try to import gl dynamically
+    //   this is so that the script doesn't fail if gl could not be installed,
+    //   which can happen in some linux setups where gcc-11 is installed, see #1160
+    try {
+        const { default: headless_gl } = await import("gl");
+        const oldCreateElement = document.createElement.bind(document);
+        document.createElement = function (tagName, options) {
+            if (tagName.toLowerCase() === "canvas") {
+                const canvas = oldCreateElement(tagName, options);
+                const oldGetContext = canvas.getContext.bind(canvas);
+                canvas.getContext = function (contextType, contextAttributes) {
+                    if (contextType.toLowerCase() === "webgl" || contextType.toLowerCase() === "experimental-webgl") {
+                        const gl = headless_gl(canvas.width, canvas.height, contextAttributes);
+                        gl.canvas = canvas;
+                        return gl;
+                    } else {
+                        return oldGetContext(contextType, contextAttributes);
+                    }
+                };
+                return canvas;
+            } else {
+                return oldCreateElement(tagName, options);
+            }
+        };
+    } catch {
+        if (skyBottomLinePreference === "--webgl") {
+            console.log("WebGL image generation was requested but gl is not installed; using non-WebGL generation.");
+        }
+    }
+
     // fix Blob not found (to support external modules like is-blob)
     global.Blob = Blob;
 
@@ -246,6 +277,27 @@ async function init () {
 // let maxRss = 0, maxRssFilename = '' // to log memory usage (debug)
 async function generateSampleImage (sampleFilename, directory, osmdInstance, osmdTestingMode,
     options = {}, DEBUG = false) {
+
+    function makeSkyBottomLineOptions() {
+        const preference = skyBottomLinePreference ?? "";
+        if (preference === "--batch") {
+            return {
+                preferredSkyBottomLineBatchCalculatorBackend: 0, // plain
+                skyBottomLineBatchCriteria: 0, // use batch algorithm only
+            };
+        } else if (preference === "--webgl") {
+            return {
+                preferredSkyBottomLineBatchCalculatorBackend: 1, // webgl
+                skyBottomLineBatchCriteria: 0, // use batch algorithm only
+            };
+        } else {
+            return {
+                preferredSkyBottomLineBatchCalculatorBackend: 0, // plain
+                skyBottomLineBatchCriteria: Infinity, // use non-batch algorithm only
+            };
+        }
+    }
+
     const samplePath = directory + "/" + sampleFilename;
     let loadParameter = FS.readFileSync(samplePath);
 
@@ -285,8 +337,10 @@ async function generateSampleImage (sampleFilename, directory, osmdInstance, osm
             newSystemFromXML: isFunctionTestSystemAndPageBreaks,
             newPageFromXML: isFunctionTestSystemAndPageBreaks,
             pageBackgroundColor: "#FFFFFF", // reset by drawingparameters default
-            pageFormat: pageFormat // reset by drawingparameters default
+            pageFormat: pageFormat, // reset by drawingparameters default,
+            ...makeSkyBottomLineOptions()
         });
+        osmdInstance.EngravingRules.AlwaysSetPreferredSkyBottomLineBackendAutomatically = false; // this would override the command line options (--plain etc)
         includeSkyBottomLine = options.skyBottomLine ? options.skyBottomLine : false; // apparently es6 doesn't have ?? operator
         osmdInstance.drawSkyLine = includeSkyBottomLine; // if includeSkyBottomLine, draw skyline and bottomline, else not
         osmdInstance.drawBottomLine = includeSkyBottomLine;

+ 5 - 0
webpack.common.js

@@ -41,6 +41,11 @@ module.exports = {
                 loader: 'json5-loader',
                 type: 'javascript/auto',
                 exclude: /(node_modules|bower_components|demo|build|bin)/
+            },
+            {
+                test: /\.glsl$/,
+                type: "asset/source",
+                exclude: /(node_modules|bower_components)/
             }
         ]
     },