UNPKG

@luma.gl/shadertools

Version:

Shader module system for luma.gl

253 lines 10.7 kB
// luma.gl // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors import { assert } from "../utils/assert.js"; /** * Matches one field declaration inside a GLSL uniform block body. */ const GLSL_UNIFORM_BLOCK_FIELD_REGEXP = /^(?:uniform\s+)?(?:(?:lowp|mediump|highp)\s+)?[A-Za-z0-9_]+(?:<[^>]+>)?\s+([A-Za-z0-9_]+)(?:\s*\[[^\]]+\])?\s*;/; /** * Matches full GLSL uniform block declarations, including optional layout qualifiers. */ const GLSL_UNIFORM_BLOCK_REGEXP = /((?:layout\s*\([^)]*\)\s*)*)uniform\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{([\s\S]*?)\}\s*([A-Za-z_][A-Za-z0-9_]*)?\s*;/g; /** * Returns the uniform block type name expected for the supplied shader module. */ export function getShaderModuleUniformBlockName(module) { return `${module.name}Uniforms`; } /** * Returns the ordered field names parsed from a shader module's uniform block. * * @returns `null` when the stage has no source or the expected block is absent. */ export function getShaderModuleUniformBlockFields(module, stage) { const shaderSource = stage === 'wgsl' ? module.source : stage === 'vertex' ? module.vs : module.fs; if (!shaderSource) { return null; } const uniformBlockName = getShaderModuleUniformBlockName(module); return extractShaderUniformBlockFieldNames(shaderSource, stage === 'wgsl' ? 'wgsl' : 'glsl', uniformBlockName); } /** * Computes the validation result for a shader module's declared and parsed * uniform-block field lists. * * @returns `null` when the module has no declared uniform types or no matching block. */ export function getShaderModuleUniformLayoutValidationResult(module, stage) { const expectedUniformNames = Object.keys(module.uniformTypes || {}); if (!expectedUniformNames.length) { return null; } const actualUniformNames = getShaderModuleUniformBlockFields(module, stage); if (!actualUniformNames) { return null; } return { moduleName: module.name, uniformBlockName: getShaderModuleUniformBlockName(module), stage, expectedUniformNames, actualUniformNames, matches: areStringArraysEqual(expectedUniformNames, actualUniformNames) }; } /** * Validates that a shader module's parsed uniform block matches `uniformTypes`. * * When a mismatch is detected, the helper logs a formatted error and optionally * throws via {@link assert}. */ export function validateShaderModuleUniformLayout(module, stage, options = {}) { const validationResult = getShaderModuleUniformLayoutValidationResult(module, stage); if (!validationResult || validationResult.matches) { return validationResult; } const message = formatShaderModuleUniformLayoutError(validationResult); options.log?.error?.(message, validationResult)(); if (options.throwOnError !== false) { assert(false, message); } return validationResult; } /** * Parses all GLSL uniform blocks in a shader source string. */ export function getGLSLUniformBlocks(shaderSource) { const blocks = []; const uncommentedSource = stripShaderComments(shaderSource); for (const sourceMatch of uncommentedSource.matchAll(GLSL_UNIFORM_BLOCK_REGEXP)) { const layoutQualifier = sourceMatch[1]?.trim() || null; blocks.push({ blockName: sourceMatch[2], body: sourceMatch[3], instanceName: sourceMatch[4] || null, layoutQualifier, hasLayoutQualifier: Boolean(layoutQualifier), isStd140: Boolean(layoutQualifier && /\blayout\s*\([^)]*\bstd140\b[^)]*\)/.exec(layoutQualifier)) }); } return blocks; } /** * Emits warnings for GLSL uniform blocks that do not explicitly declare * `layout(std140)`. * * @returns The list of parsed blocks that were considered non-compliant. */ export function warnIfGLSLUniformBlocksAreNotStd140(shaderSource, stage, log, context) { const nonStd140Blocks = getGLSLUniformBlocks(shaderSource).filter(block => !block.isStd140); const seenBlockNames = new Set(); for (const block of nonStd140Blocks) { if (seenBlockNames.has(block.blockName)) { continue; } seenBlockNames.add(block.blockName); const shaderLabel = context?.label ? `${context.label} ` : ''; const actualLayout = block.hasLayoutQualifier ? `declares ${normalizeWhitespace(block.layoutQualifier)} instead of layout(std140)` : 'does not declare layout(std140)'; const message = `${shaderLabel}${stage} shader uniform block ${block.blockName} ${actualLayout}. luma.gl host-side shader block packing assumes explicit layout(std140) for GLSL uniform blocks. Add \`layout(std140)\` to the block declaration.`; log?.warn?.(message, block)(); } return nonStd140Blocks; } /** * Extracts field names from the named GLSL or WGSL uniform block/struct. */ function extractShaderUniformBlockFieldNames(shaderSource, language, uniformBlockName) { const sourceBody = language === 'wgsl' ? extractWGSLStructBody(shaderSource, uniformBlockName) : extractGLSLUniformBlockBody(shaderSource, uniformBlockName); if (!sourceBody) { return null; } const fieldNames = []; for (const sourceLine of sourceBody.split('\n')) { const line = sourceLine.replace(/\/\/.*$/, '').trim(); if (!line || line.startsWith('#')) { continue; } const fieldMatch = language === 'wgsl' ? line.match(/^([A-Za-z0-9_]+)\s*:/) : line.match(GLSL_UNIFORM_BLOCK_FIELD_REGEXP); if (fieldMatch) { fieldNames.push(fieldMatch[1]); } } return fieldNames; } /** * Extracts the body of a WGSL struct with the supplied name. */ function extractWGSLStructBody(shaderSource, uniformBlockName) { const structMatch = new RegExp(`\\bstruct\\s+${uniformBlockName}\\b`, 'm').exec(shaderSource); if (!structMatch) { return null; } const openBraceIndex = shaderSource.indexOf('{', structMatch.index); if (openBraceIndex < 0) { return null; } let braceDepth = 0; for (let index = openBraceIndex; index < shaderSource.length; index++) { const character = shaderSource[index]; if (character === '{') { braceDepth++; continue; } if (character !== '}') { continue; } braceDepth--; if (braceDepth === 0) { return shaderSource.slice(openBraceIndex + 1, index); } } return null; } /** * Extracts the body of a named GLSL uniform block from shader source. */ function extractGLSLUniformBlockBody(shaderSource, uniformBlockName) { const block = getGLSLUniformBlocks(shaderSource).find(candidate => candidate.blockName === uniformBlockName); return block?.body || null; } /** * Returns `true` when the two arrays contain the same strings in the same order. */ function areStringArraysEqual(leftValues, rightValues) { if (leftValues.length !== rightValues.length) { return false; } for (let valueIndex = 0; valueIndex < leftValues.length; valueIndex++) { if (leftValues[valueIndex] !== rightValues[valueIndex]) { return false; } } return true; } /** * Formats the standard validation error message for a shader-module layout mismatch. */ function formatShaderModuleUniformLayoutError(validationResult) { const { expectedUniformNames, actualUniformNames } = validationResult; const missingUniformNames = expectedUniformNames.filter(uniformName => !actualUniformNames.includes(uniformName)); const unexpectedUniformNames = actualUniformNames.filter(uniformName => !expectedUniformNames.includes(uniformName)); const mismatchDetails = [ `Expected ${expectedUniformNames.length} fields, found ${actualUniformNames.length}.` ]; const firstMismatchDescription = getFirstUniformMismatchDescription(expectedUniformNames, actualUniformNames); if (firstMismatchDescription) { mismatchDetails.push(firstMismatchDescription); } if (missingUniformNames.length) { mismatchDetails.push(`Missing from shader block (${missingUniformNames.length}): ${formatUniformNameList(missingUniformNames)}.`); } if (unexpectedUniformNames.length) { mismatchDetails.push(`Unexpected in shader block (${unexpectedUniformNames.length}): ${formatUniformNameList(unexpectedUniformNames)}.`); } if (expectedUniformNames.length <= 12 && actualUniformNames.length <= 12 && (missingUniformNames.length || unexpectedUniformNames.length)) { mismatchDetails.push(`Expected: ${expectedUniformNames.join(', ')}.`); mismatchDetails.push(`Actual: ${actualUniformNames.join(', ')}.`); } return `${validationResult.moduleName}: ${validationResult.stage} shader uniform block ${validationResult.uniformBlockName} does not match module.uniformTypes. ${mismatchDetails.join(' ')}`; } /** * Removes line and block comments from shader source before lightweight parsing. */ function stripShaderComments(shaderSource) { return shaderSource.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, ''); } /** * Collapses repeated whitespace in a layout qualifier for log-friendly output. */ function normalizeWhitespace(value) { return value.replace(/\s+/g, ' ').trim(); } function getFirstUniformMismatchDescription(expectedUniformNames, actualUniformNames) { const minimumLength = Math.min(expectedUniformNames.length, actualUniformNames.length); for (let index = 0; index < minimumLength; index++) { if (expectedUniformNames[index] !== actualUniformNames[index]) { return `First mismatch at field ${index + 1}: expected ${expectedUniformNames[index]}, found ${actualUniformNames[index]}.`; } } if (expectedUniformNames.length > actualUniformNames.length) { return `Shader block ends after field ${actualUniformNames.length}; expected next field ${expectedUniformNames[actualUniformNames.length]}.`; } if (actualUniformNames.length > expectedUniformNames.length) { return `Shader block has extra field ${actualUniformNames.length}: ${actualUniformNames[expectedUniformNames.length]}.`; } return null; } function formatUniformNameList(uniformNames, maxNames = 8) { if (uniformNames.length <= maxNames) { return uniformNames.join(', '); } const remainingCount = uniformNames.length - maxNames; return `${uniformNames.slice(0, maxNames).join(', ')}, ... (${remainingCount} more)`; } //# sourceMappingURL=shader-module-uniform-layout.js.map