src/lib/glslProcess.js
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;
}