@luma.gl/shadertools
Version:
Shader module system for luma.gl
253 lines • 10.7 kB
JavaScript
// 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