UNPKG

vite-plugin-glsl

Version:

Import, inline (and minify) GLSL/WGSL shader files

344 lines (295 loc) 10.3 kB
import { dirname, resolve, extname, posix, sep } from 'path'; import { emitWarning, cwd } from 'process'; import { readFileSync } from 'fs'; import { platform } from 'os'; /** * @name recursiveChunk * @type {string} * * @description Shader chunk path * that caused a recursion error */ let recursiveChunk = ''; /** * @const * @name allChunks * @type {readonly Set<string>} * * @description List of all shader chunks, * it's used to track included files */ const allChunks = new Set(); /** * @const * @name dependentChunks * @type {readonly Map<string, string[]>} * * @description Map of shaders that import other chunks, it's * used to track included files in order to avoid recursion * - Key: shader path that uses other chunks as dependencies * - Value: list of chunk paths included within the shader */ const dependentChunks = new Map(); /** * @const * @name duplicatedChunks * @type {readonly Map<string, string[]>} * * @description Map of duplicated shader * imports, used by warning messages */ const duplicatedChunks = new Map(); /** * @function * @name resetSavedChunks * @description Clears all lists of saved chunks * and resets "recursiveChunk" path to empty * * @returns {string} Copy of "recursiveChunk" path */ function resetSavedChunks () { const chunk = recursiveChunk; duplicatedChunks.clear(); dependentChunks.clear(); recursiveChunk = ''; allChunks.clear(); return chunk; } /** * @function * @name getRecursionCaller * @description Gets last chunk that caused a * recursion error from the "dependentChunks" list * * @returns {string} Chunk path that started a recursion */ function getRecursionCaller () { const dependencies = [...dependentChunks.keys()]; return dependencies[dependencies.length - 1]; } /** * @function * @name checkDuplicatedImports * @description Checks if shader chunk was already included * and adds it to the "duplicatedChunks" list if yes * * @param {string} path Shader's absolute path * * @throws {Warning} If shader chunk was already included */ function checkDuplicatedImports (path) { const caller = getRecursionCaller(); const chunks = duplicatedChunks.get(caller) ?? []; if (chunks.includes(path)) return; chunks.push(path); duplicatedChunks.set(caller, chunks); emitWarning(`'${path}' was included multiple times.`, { code: 'vite-plugin-glsl', detail: 'Please avoid multiple imports of the same chunk in order to avoid' + ` recursions and optimize your shader length.\nDuplicated import found in file '${caller}'.` }); } /** * @function * @name removeSourceComments * @description Removes comments from shader source * code in order to avoid including commented chunks * * @param {string} source Shader's source code * @param {string} keyword Keyword to import chunks * @param {boolean} triple Remove comments starting with `///` * * @returns {string} Shader's source code without comments */ function removeSourceComments (source, keyword, triple = false) { const pattern = new RegExp(String.raw`${keyword}(\s+([^\s<>]+));?`, 'gi'); if (source.includes('/*') && source.includes('*/')) { source = source.slice(0, source.indexOf('/*')) + source.slice(source.indexOf('*/') + 2, source.length); } const lines = source.split('\n'); for (let l = lines.length; l--; ) { const index = lines[l].indexOf('//'); if (index > -1) { if (lines[l][index + 2] === '/' && !pattern.test(lines[l]) && !triple) continue; lines[l] = lines[l].slice(0, lines[l].indexOf('//')); } } return lines.join('\n'); } /** * @function * @name checkRecursiveImports * @description Checks if shader dependencies * have caused a recursion error or warning * ignoring duplicate chunks if required * * @param {string} path Shader's absolute path * @param {string} lowPath Shader's lowercase path * @param {boolean} warn Check already included chunks * @param {boolean} ignore Ignore already included chunks * * @returns {boolean | null} Import recursion has occurred * or chunk was ignored because of `ignore` argument */ function checkRecursiveImports (path, lowPath, warn, ignore) { if (allChunks.has(lowPath)) { if (ignore) return null; warn && checkDuplicatedImports(path); } return checkIncludedDependencies(path, path); } /** * @function * @name checkIncludedDependencies * @description Checks if included * chunks caused a recursion error * * @param {string} path Current chunk absolute path * @param {string} root Main shader path that imports chunks * * @returns {boolean} Included chunk started a recursion */ function checkIncludedDependencies (path, root) { const dependencies = dependentChunks.get(path); let recursiveDependency = false; if (dependencies?.includes(root)) { recursiveChunk = root; return true; } dependencies?.forEach(dependency => recursiveDependency ||= checkIncludedDependencies(dependency, root) ); return recursiveDependency; } /** * @function * @name minifyShader * @description Minifies shader source code by * removing unnecessary whitespace and empty lines * * @param {string} shader Shader code with included chunks * @param {boolean} newLine Flag to require a new line for the code * * @returns {string} Minified shader's source code */ function minifyShader (shader, newLine = false) { return shader.replace(/\\(?:\r\n|\n\r|\n|\r)|\/\*.*?\*\/|\/\/(?:\\(?:\r\n|\n\r|\n|\r)|[^\n\r])*/g, '') .split(/\n+/).reduce((result, line) => { line = line.trim().replace(/\s{2,}|\t/, ' '); if (/@(vertex|fragment|compute)/.test(line) || line.endsWith('return')) line += ' '; if (line[0] === '#') { newLine && result.push('\n'); result.push(line, '\n'); newLine = false; } else { !line.startsWith('{') && result.length && result[result.length - 1].endsWith('else') && result.push(' '); result.push(line.replace(/\s*({|}|=|\*|,|\+|\/|>|<|&|\||\[|\]|\(|\)|\-|!|;)\s*/g, '$1')); newLine = true; } return result; }, []).join('').replace(/\n+/g, '\n'); } /** * @function * @name loadChunks * @description Includes shader's dependencies * and removes comments from the source code * * @param {string} source Shader's source code * @param {string} path Shader's absolute path * @param {Options} options Shader loading config object * * @throws {Error} If shader chunks started a recursion loop * * @returns {string} Shader's source code without external chunks */ function loadChunks (source, path, options) { const { importKeyword, warnDuplicatedImports, removeDuplicatedImports } = options; const pattern = new RegExp(String.raw`${importKeyword}(\s+([^\s<>]+));?`, 'gi'); const unixPath = path.split(sep).join(posix.sep); const chunkPath = platform() === 'win32' && unixPath.toLocaleLowerCase() || unixPath; const recursion = checkRecursiveImports( unixPath, chunkPath, warnDuplicatedImports, removeDuplicatedImports ); if (recursion) return recursiveChunk; else if (recursion === null) return ''; source = removeSourceComments(source); let directory = dirname(unixPath); allChunks.add(chunkPath); if (pattern.test(source)) { dependentChunks.set(unixPath, []); const currentDirectory = directory; const ext = options.defaultExtension; source = source.replace(pattern, (_, chunkPath) => { chunkPath = chunkPath.trim().replace(/^(?:"|')?|(?:"|')?;?$/gi, ''); if (!chunkPath.indexOf('/')) { const base = cwd().split(sep).join(posix.sep); chunkPath = base + options.root + chunkPath; } const directoryIndex = chunkPath.lastIndexOf('/'); directory = currentDirectory; if (directoryIndex !== -1) { directory = resolve(directory, chunkPath.slice(0, directoryIndex + 1)); chunkPath = chunkPath.slice(directoryIndex + 1, chunkPath.length); } let shader = resolve(directory, chunkPath); if (!extname(shader)) shader = `${shader}.${ext}`; const shaderPath = shader.split(sep).join(posix.sep); dependentChunks.get(unixPath)?.push(shaderPath); return loadChunks( readFileSync(shader, 'utf8'), shader, options ); }); } if (recursiveChunk) { const caller = getRecursionCaller(); const recursiveChunk = resetSavedChunks(); throw new Error( `Recursion detected when importing "${recursiveChunk}" in "${caller}".` ); } return source.trim().replace(/(\r\n|\r|\n){3,}/g, '$1\n'); } /** * @function * @name loadShader * @description Iterates through all external chunks, includes them * into the shader's source code and optionally minifies the output * * @typedef {import('./types').LoadingOptions} Options * @typedef {import('./types').LoadingOutput} Output * * @param {string} source Shader's source code * @param {string} shader Shader's absolute path * @param {Options} options Configuration object to define: * * - Shader suffix to use when no extension is specified * - Warn if the same chunk was imported multiple times * - Automatically remove an already imported chunk * - Keyword used to import shader chunks * - Directory for root imports * - Minify output shader code * * @returns {Promise<Output>} Loaded, parsed (and minified) * shader output and Map of shaders that import other chunks */ export default async function (source, shader, options) { const { minify, ...config } = options; resetSavedChunks(); let output = loadChunks(source, shader, config); output = minify ? removeSourceComments(output, options.importKeyword, true) : output; return { dependentChunks, outputShader: minify ? typeof minify !== 'function' ? minifyShader(output) : await minify(output) : output }; }