docd27/rollup-plugin-glsl-optimize

View on GitHub
src/lib/glslProcess.js

Summary

Maintainability
B
5 hrs
Test Coverage
import {EOL} from 'os';
import * as path from 'path';
import * as fsSync from 'fs';
import {insertExtensionPreamble, fixupDirectives, insertPreamble} from './preamble.js';
import {argQuote, configureTools, getCachePath, launchTool, printToolDiagnostic, waitForToolBuffered} from './tools.js';
import {checkMakeFolder, rmDir} from './download.js';
import {compressShader} from './minify.js';
import * as crypto from 'crypto';
import MagicString from 'magic-string';

/**
 * @typedef {'vert'|'tesc'|'tese'|'geom'|'frag'|'comp'} GLSLStageName
 *
 * @typedef {Object} GLSLToolSharedOptions
 * @property {boolean} sourceMap
 *   Emit source maps
 * @property {boolean} compress
 *  Strip whitespace
 * @property {boolean} optimize
 *  true: Preprocess and Compile GLSL to SPIR-V, optimize, then cross-compile to GLSL
 *  false: Preprocess (and Validate) GLSL only
 * @property {boolean} emitLineDirectives
 *  Emit #line directives. Useful for debugging #include. Note these may cause problems with certain drivers.
 * @property {boolean} suppressLineExtensionDirective
 *  When emitLineDirectives enabled, suppress the GL_GOOGLE_cpp_style_line_directive extension directive
 * @property {boolean} optimizerPreserveUnusedBindings
 *  Ensure that the optimizer preserves all declared bindings, even when those bindings are unused.
 * @property {boolean} optimizerDebugSkipOptimizer
 *  Debugging: skip the SPIR-V optimizer (compiles then cross-compiles directly)
 * @property {string} preamble
 *  Prepend to the shader (after the #version directive)
 * @property {string[]} includePaths
 *  Additional search paths for #include directive (source file directory is always searched)
 * @property {string[]} extraValidatorParams
 * @property {string[]} extraOptimizerParams
 * @property {string[]} extraCrossParams
 * @typedef {GLSLToolSharedOptions & import('./tools').GLSLToolPathConfig} GLSLToolOptions
 */

/**
 * @internal
 * @param {import('./tools.js').GLSLToolVals} kind
 * @param {string} title
 * @param {string} name
 * @param {string} workingDir
 * @param {string} input
 * @param {string[]} params
 */
async function glslRunTool(kind, title, name, workingDir, input, params) {
  const result = await waitForToolBuffered(launchTool(kind, workingDir, params), input);
  if (result.error) {
    printToolDiagnostic(result.outLines);
    printToolDiagnostic(result.errLines);
    const errMsg = `${title}: ${name} failed, ${result.exitMessage}`;
    console.error(errMsg);
    throw new Error(errMsg);
  }
  return result.outLines ? result.outLines.join(EOL) : '';
}

/**
 * @internal
 * @param {string} name
 * @param {string} workingDir
 * @param {string} stageName
 * @param {string} input
 * @param {string[]} params
 * @param {string[]} extraParams
 */
async function glslRunValidator(name, workingDir, stageName, input, params, extraParams) {
  return glslRunTool('Validator', 'Khronos glslangValidator', name, workingDir, input, [
    '--stdin',
    '-C', // cascading errors (don't stop after first)
    '-t', // Multithreaded
    '-S', stageName, // Shader type
    ...params,
    ...extraParams,
  ]);
}

/**
 * @internal
 * @param {string} name
 * @param {string} workingDir
 * @param {string} inputFile
 * @param {string} outputFile
 * @param {string} input
 * @param {boolean} preserveUnusedBindings
 * @param {string[]} params
 * @param {string[]} extraParams
 */
async function glslRunOptimizer(name, workingDir, inputFile, outputFile, input,
    preserveUnusedBindings = true, params, extraParams) {
  return glslRunTool('Optimizer', 'Khronos spirv-opt', name, workingDir, input, [
    '-O', // optimize for performance
    '--target-env=opengl4.0', // One of opengl4.0|opengl4.1|opengl4.2|opengl4.3|opengl4.5
    ...(preserveUnusedBindings ? ['--preserve-bindings'] : []),
    ...params,
    ...extraParams,
    ...argQuote(inputFile),
    '-o', ...argQuote(outputFile),
  ]);
}

/**
 * @internal
 * @param {string} name
 * @param {string} workingDir
 * @param {string} stageName
 * @param {string} inputFile
 * @param {string} input
 * @param {boolean} emitLineInfo
 * @param {string[]} params
 * @param {string[]} extraParams
 */
async function glslRunCross(name, workingDir, stageName, inputFile, input, emitLineInfo, params, extraParams) {
  return glslRunTool('Cross', 'Khronos spirv-cross', name, workingDir, input, [
    ...argQuote(inputFile),
    ...(emitLineInfo ? ['--emit-line-directives'] : []),
    `--stage`, stageName,
    ...params,
    ...extraParams,
  ]);
}

/**
 * Generate unique build path
 * @param {string} id
 * @return {string}
 */
function getBuildDir(id) {
  const sanitizeID = path.basename(id).replace(/([^a-z0-9]+)/gi, '-').toLowerCase();
  const uniqID = ((Date.now()>>>0) + crypto.randomBytes(4).readUInt32LE())>>>0; // +ve 4 byte unique ID
  const uniqIDHex = uniqID.toString(16).padStart(8, '0'); // 8 char random hex
  return path.join(getCachePath(), 'glslBuild', `${sanitizeID}-${uniqIDHex}`);
}

/**
 * @internal
 * @param {string} id File path
 * @param {string} source Source code
 * @param {GLSLStageName} stageName
 * @param {Partial<GLSLToolOptions>} [glslOptions]
 * @param {(message: string) => void} [warnLog]
 * @return {Promise<import('rollup').SourceDescription>}
 */
export async function glslProcessSource(id, source, stageName, glslOptions = {}, warnLog = console.error) {

  /** @type {GLSLToolOptions} */
  const options = {
    sourceMap: true,
    compress: true,
    optimize: true,
    emitLineDirectives: false,
    suppressLineExtensionDirective: false,
    optimizerPreserveUnusedBindings: true,
    optimizerDebugSkipOptimizer: false,
    preamble: undefined,
    includePaths: [],
    extraValidatorParams: [],
    extraOptimizerParams: [],
    extraCrossParams: [],
    ...glslOptions,
  };

  configureTools({}, options.optimize ? ['Validator', 'Optimizer', 'Cross'] : ['Validator']);

  let tempBuildDir;
  if (options.optimize) {
    tempBuildDir = getBuildDir(id);
    rmDir(tempBuildDir);
    checkMakeFolder(tempBuildDir);
  }

  const baseDir = path.dirname(id);
  const baseName = path.basename(id);
  let targetID = `./${baseName}`;
  let targetDir = baseDir;

  let outputFile = targetID;

  if (!fsSync.existsSync(targetDir)) {
    warnLog(`Error resolving path: '${id}' : Khronos glslangValidator may fail to find includes`);
    targetDir = process.cwd();
    targetID = id;
    outputFile = `temp`;
  }

  let outputFileAbs;
  let optimizedFileAbs;
  let versionReplacer;
  let targetGlslVersion = 300; // WebGL2
  if (options.optimize) {
    outputFileAbs = path.join(tempBuildDir, `${outputFile}.spv`);
    optimizedFileAbs = path.join(tempBuildDir, `${outputFile}-opt.spv`);
    versionReplacer = (version) => {
      // Try and parse the #version directive if present
      const versionParts = version && version.match(/^\s*(\d+)(?:\s+(es))?\s*$/i);
      if (versionParts && versionParts.length === 3) {
        targetGlslVersion = +versionParts[1];
      }
      if (targetGlslVersion < 300) {
        throw new Error(`Only GLSL ES shaders version 300 (WebGL2) or higher can be optimized`);
      }
      // SPIR-V compilation requires >= 310 es
      // and we run the optimizer under OpenGL 4.0 (GLSL 400) semantics
      // though the emitted code is compatible with 300 es
      return `${Math.max(targetGlslVersion, 310)} es`;
    };
  }
  const {code, didInsertion} = insertExtensionPreamble(source, targetID, versionReplacer, options.preamble);

  // if (options.optimizeBuild) {
  //   console.log(`Target GLSL version: ${targetGlslVersion}`);
  // }

  const extraValidatorParams = [
    ...options.includePaths.map((path) => `-I${path}`),
    ...options.extraValidatorParams,
  ];

  let processedGLSL;

  if (options.optimize) {
    await glslRunValidator('Build spirv', targetDir, stageName,
        code, [
          '-G', // opengl
          '-g', // debug info (required for cross --emit-line-directives)
          '--auto-map-locations', // avoid "SPIR-V requires location for user input/output"
          '--auto-map-bindings',
          // '-Od', // disable optimizations (in validator)
          // '--no-storage-format',
          '-o', ...argQuote(outputFileAbs),
          // '-H', // Human-readable spirv
        ], extraValidatorParams);

    if (!fsSync.existsSync(outputFileAbs)) {
      throw new Error(`Build spirv failed: no output file`);
    }
    if (!options.optimizerDebugSkipOptimizer) {
      await glslRunOptimizer('Optimize spirv', targetDir,
          outputFileAbs, optimizedFileAbs, undefined, options.optimizerPreserveUnusedBindings, [
            // '--print-all', // Print spirv for debugging
          ], options.extraOptimizerParams);
      if (!fsSync.existsSync(optimizedFileAbs)) {
        throw new Error(`Optimize spirv failed: no output file (${optimizedFileAbs})`);
      }
    }

    processedGLSL = await glslRunCross('Build spirv to GLSL', targetDir, stageName,
      options.optimizerDebugSkipOptimizer ? outputFileAbs : optimizedFileAbs, undefined, options.emitLineDirectives, [
        '--es', // WebGL is always ES
        '--version', `${targetGlslVersion}`,
        // '--disable-storage-image-qualifier-deduction',
        // '--glsl-es-default-float-precision highp',
        // '--glsl-es-default-int-precision highp',
      ], options.extraCrossParams);

    // Cleanup:
    rmDir(tempBuildDir);



  } else {
    processedGLSL = await glslRunValidator('Preprocessing', targetDir, stageName, code, [
      '-E', // print pre-processed GLSL
    ], extraValidatorParams);
    await glslRunValidator('Validation', targetDir, stageName,
        processedGLSL, [], extraValidatorParams);
  }

  processedGLSL = fixupDirectives(processedGLSL,
      options.emitLineDirectives && !options.suppressLineExtensionDirective,
      didInsertion && (!options.optimize || options.emitLineDirectives),
      options.optimize, !options.emitLineDirectives, undefined);


  const outputCode = options.compress ? compressShader(processedGLSL) : processedGLSL;

  /** @type {import('rollup').LoadResult} */
  const result = {
    code: outputCode,
    map: {mappings: ''},
  };

  if (options.sourceMap) {
    const sourceMapSource = insertPreamble(processedGLSL,
        '/*\n' +
        `* Preprocessed${options.optimize?' + Optimized':''} from '${targetID}'\n` +
        (options.compress ? '* [Embedded string is compressed]\n':'') +
        '*/',
    ).code;
    const magicString = new MagicString(sourceMapSource);
    result.map = magicString.generateMap({
      source: id,
      includeContent: true,
      hires: true,
    });
  }

  return result;

}