@animech-public/playcanvas
Version:
PlayCanvas WebGL game engine
376 lines (328 loc) • 13.5 kB
JavaScript
import { Debug } from './debug.js';
// id for debug tracing
const TRACEID = 'Preprocessor';
// accepted keywords
const KEYWORD = /[ \t]*#(ifn?def|if|endif|else|elif|define|undef|extension|include)/g;
// #define EXPRESSION
// eslint-disable-next-line regexp/no-super-linear-backtracking, regexp/optimal-quantifier-concatenation
const DEFINE = /define[ \t]+([^\n]+)\r?(?:\n|$)/g;
// #extension IDENTIFIER : enabled
const EXTENSION = /extension[ \t]+([\w-]+)[ \t]*:[ \t]*(enable|require)/g;
// #undef EXPRESSION
// eslint-disable-next-line regexp/no-super-linear-backtracking, regexp/optimal-quantifier-concatenation
const UNDEF = /undef[ \t]+([^\n]+)\r?(?:\n|$)/g;
// #ifdef/#ifndef SOMEDEFINE, #if EXPRESSION
// eslint-disable-next-line regexp/no-unused-capturing-group, regexp/no-super-linear-backtracking
const IF = /(ifdef|ifndef|if)[ \t]*([^\r\n]+)\r?\n/g;
// #endif/#else or #elif EXPRESSION
const ENDIF = /(endif|else|elif)([ \t][^\r\n]+)?\r?(?:\n|$)/g;
// identifier
const IDENTIFIER = /([\w-]+)/;
// [!]defined(EXPRESSION)
const DEFINED = /(!|\s)?defined\(([\w-]+)\)/;
// currently unsupported characters in the expression: | & < > = + -
const INVALID = /[><=|&+-]/g;
// #include "identifier"
const INCLUDE = /include[ \t]+"([\w-]+)"\r?(?:\n|$)/g;
/**
* Pure static class implementing subset of C-style preprocessor.
* inspired by: https://github.com/dcodeIO/Preprocessor.js
*
* @ignore
*/
class Preprocessor {
/**
* Run c-like preprocessor on the source code, and resolves the code based on the defines and ifdefs
*
* @param {string} source - The source code to work on.
* @param {Map<string, string>} [includes] - A map containing key-value pairs of include names
* and their content. These are used for resolving #include directives in the source.
* @param {boolean} [stripUnusedColorAttachments] - If true, strips unused color attachments.
* @returns {string|null} Returns preprocessed source code, or null in case of error.
*/
static run(source, includes = new Map(), stripUnusedColorAttachments = false) {
// strips comments, handles // and many cases of /*
source = source.replace(/\/\*[\s\S]*?\*\/|([^\\:]|^)\/\/.*$/gm, '$1');
// right trim each line
source = source.split(/\r?\n/).map(line => line.trimEnd()).join('\n');
// generate defines to remove unused color attachments
const defines = new Map();
if (stripUnusedColorAttachments) {
// find out how many times pcFragColorX is used (see gles3.js)
const counts = new Map();
const regex = /(pcFragColor[1-8])\b/g;
const matches = source.match(regex);
matches == null || matches.forEach(match => {
var _counts$get;
const index = parseInt(match.charAt(match.length - 1), 10);
counts.set(index, ((_counts$get = counts.get(index)) != null ? _counts$get : 0) + 1);
});
// if pcFragColorX is used only once, remove it
counts.forEach((count, index) => {
if (count === 1) {
defines.set(`REMOVE_COLOR_ATTACHMENT_${index}`, '');
}
});
}
// preprocess defines / ifdefs ..
source = this._preprocess(source, defines, includes);
// extract defines that evaluate to an integer number
const intDefines = new Map();
defines.forEach((value, key) => {
if (Number.isInteger(parseFloat(value)) && !value.includes('.')) {
intDefines.set(key, value);
}
});
// remove empty lines
source = this.RemoveEmptyLines(source);
// process array sizes
source = this.processArraySize(source, intDefines);
return source;
}
static processArraySize(source, intDefines) {
if (source !== null) {
// replace lines containing "[intDefine]" with their values, so that we know the array size for WebGPU uniform buffer
// example: weight[SAMPLES] => float weight[11] in case there was a "define SAMPLES 11" in the source code
intDefines.forEach((value, key) => {
source = source.replace(new RegExp(`\\[${key}\\]`, 'g'), `[${value}]`);
});
}
return source;
}
static RemoveEmptyLines(source) {
if (source !== null) {
source = source.split(/\r?\n/)
// convert lines with only white space into empty string
.map(line => line.trim() === '' ? '' : line).join('\n');
// remove more than 1 consecutive empty lines
source = source.replace(/(\n\n){3,}/g, '\n\n');
}
return source;
}
/**
* Process source code, and resolves the code based on the defines and ifdefs.
*
* @param {string} source - The source code to work on.
* @param {Map<string, string>} defines - Supplied defines which are used in addition to those
* defined in the source code. Maps a define name to its value. Note that the map is modified
* by the function.
* @param {Map<string, string>} [includes] - An object containing key-value pairs of include names and their
* content.
* @returns {string} Returns preprocessed source code.
*/
static _preprocess(source, defines = new Map(), includes) {
const originalSource = source;
// stack, storing info about ifdef blocks
const stack = [];
// true if the function encounter a problem
let error = false;
let match;
while ((match = KEYWORD.exec(source)) !== null) {
const keyword = match[1];
switch (keyword) {
case 'define':
{
// read the rest of the define line
DEFINE.lastIndex = match.index;
const define = DEFINE.exec(source);
Debug.assert(define, `Invalid [${keyword}]: ${source.substring(match.index, match.index + 100)}...`);
error || (error = define === null);
const expression = define[1];
// split it to identifier name and a value
IDENTIFIER.lastIndex = define.index;
const identifierValue = IDENTIFIER.exec(expression);
const identifier = identifierValue[1];
let value = expression.substring(identifier.length).trim();
if (value === '') value = 'true';
// are we inside if-blocks that are accepted
const keep = Preprocessor._keep(stack);
if (keep) {
defines.set(identifier, value);
}
Debug.trace(TRACEID, `${keyword}: [${identifier}] ${value} ${keep ? '' : 'IGNORED'}`);
// continue on the next line
KEYWORD.lastIndex = define.index + define[0].length;
break;
}
case 'undef':
{
// read the rest of the define line
UNDEF.lastIndex = match.index;
const undef = UNDEF.exec(source);
const identifier = undef[1].trim();
// are we inside if-blocks that are accepted
const keep = Preprocessor._keep(stack);
// remove it from defines
if (keep) {
defines.delete(identifier);
}
Debug.trace(TRACEID, `${keyword}: [${identifier}] ${keep ? '' : 'IGNORED'}`);
// continue on the next line
KEYWORD.lastIndex = undef.index + undef[0].length;
break;
}
case 'extension':
{
EXTENSION.lastIndex = match.index;
const extension = EXTENSION.exec(source);
Debug.assert(extension, `Invalid [${keyword}]: ${source.substring(match.index, match.index + 100)}...`);
error || (error = extension === null);
if (extension) {
const identifier = extension[1];
// are we inside if-blocks that are accepted
const keep = Preprocessor._keep(stack);
if (keep) {
defines.set(identifier, 'true');
}
Debug.trace(TRACEID, `${keyword}: [${identifier}] ${keep ? '' : 'IGNORED'}`);
}
// continue on the next line
KEYWORD.lastIndex = extension.index + extension[0].length;
break;
}
case 'ifdef':
case 'ifndef':
case 'if':
{
// read the if line
IF.lastIndex = match.index;
const iff = IF.exec(source);
const expression = iff[2];
// evaluate expression
const evaluated = Preprocessor.evaluate(expression, defines);
error || (error = evaluated.error);
let result = evaluated.result;
if (keyword === 'ifndef') {
result = !result;
}
// add info to the stack (to be handled later)
stack.push({
anyKeep: result,
// true if any branch was already accepted
keep: result,
// true if this branch is being taken
start: match.index,
// start index if IF line
end: IF.lastIndex // end index of IF line
});
Debug.trace(TRACEID, `${keyword}: [${expression}] => ${result}`);
// continue on the next line
KEYWORD.lastIndex = iff.index + iff[0].length;
break;
}
case 'endif':
case 'else':
case 'elif':
{
// match the endif
ENDIF.lastIndex = match.index;
const endif = ENDIF.exec(source);
const blockInfo = stack.pop();
// code between if and endif
const blockCode = blockInfo.keep ? source.substring(blockInfo.end, match.index) : '';
Debug.trace(TRACEID, `${keyword}: [previous block] => ${blockCode !== ''}`);
// cut out the IF and ENDIF lines, leave block if required
source = source.substring(0, blockInfo.start) + blockCode + source.substring(ENDIF.lastIndex);
KEYWORD.lastIndex = blockInfo.start + blockCode.length;
// handle else if
const endifCommand = endif[1];
if (endifCommand === 'else' || endifCommand === 'elif') {
// if any branch was already accepted, all else branches need to fail regardless of the result
let result = false;
if (!blockInfo.anyKeep) {
if (endifCommand === 'else') {
result = !blockInfo.keep;
} else {
const evaluated = Preprocessor.evaluate(endif[2], defines);
result = evaluated.result;
error || (error = evaluated.error);
}
}
// add back to stack
stack.push({
anyKeep: blockInfo.anyKeep || result,
keep: result,
start: KEYWORD.lastIndex,
end: KEYWORD.lastIndex
});
Debug.trace(TRACEID, `${keyword}: [${endif[2]}] => ${result}`);
}
break;
}
case 'include':
{
// match the include
INCLUDE.lastIndex = match.index;
const include = INCLUDE.exec(source);
error || (error = include === null);
Debug.assert(include, `Invalid [${keyword}]: ${source.substring(match.index, match.index + 100)}...`);
const identifier = include[1].trim();
// are we inside if-blocks that are accepted
const keep = Preprocessor._keep(stack);
if (keep) {
// cut out the include line and replace it with the included string
const includeSource = includes == null ? void 0 : includes.get(identifier);
if (includeSource) {
source = source.substring(0, include.index - 1) + includeSource + source.substring(INCLUDE.lastIndex);
// process the just included test
KEYWORD.lastIndex = include.index;
} else {
console.error(`Include not found: ${identifier}`);
error = true;
}
}
Debug.trace(TRACEID, `${keyword}: [${identifier}] ${keep ? '' : 'IGNORED'}`);
break;
}
}
}
if (error) {
console.warn('Failed to preprocess shader: ', {
source: originalSource
});
return originalSource;
}
return source;
}
// function returns true if the evaluation is inside keep branches
static _keep(stack) {
for (let i = 0; i < stack.length; i++) {
if (!stack[i].keep) {
return false;
}
}
return true;
}
/**
* Very simple expression evaluation, handles cases:
* expression
* defined(expression)
* !defined(expression)
*
* But does not handle more complex cases, which would require more complex system:
* defined(A) || defined(B)
*/
static evaluate(expression, defines) {
const correct = INVALID.exec(expression) === null;
Debug.assert(correct, `Resolving expression like this is not supported: ${expression}`);
// if the format is defined(expression), extract expression
let invert = false;
const defined = DEFINED.exec(expression);
if (defined) {
invert = defined[1] === '!';
expression = defined[2];
}
// test if expression define exists
expression = expression.trim();
let exists = defines.has(expression);
// handle inversion
if (invert) {
exists = !exists;
}
return {
result: exists,
error: !correct
};
}
}
export { Preprocessor };