120 lines
4.6 KiB
TypeScript
120 lines
4.6 KiB
TypeScript
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
import { join, relative } from 'node:path';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
/**
|
|
* GLSL pow(x, y) is undefined for x < 0 and on real GPUs it is NaN. A base
|
|
* that is only "nearly" non-negative is not enough: an interpolated uv a hair
|
|
* below 0 (a quad seen edge-on or clipped by the near plane) or 1 - dot() of
|
|
* two unit vectors a hair above 1 happens a few times a minute. One NaN pixel
|
|
* in the HDR target is spread by the bloom blurs and the lens streak across
|
|
* the whole frame, which shows as a one-frame black flash (the traffic trail
|
|
* and pool shaders did exactly this). So every pow() base in a shader must be
|
|
* guarded on its face: abs(), max(), clamp() or smoothstep(), optionally
|
|
* scaled by a positive literal.
|
|
*/
|
|
const ROOT = join(__dirname, '..', '..');
|
|
const SRC = join(ROOT, 'src');
|
|
|
|
function sources(dir: string): string[] {
|
|
const out: string[] = [];
|
|
for (const name of readdirSync(dir)) {
|
|
const path = join(dir, name);
|
|
if (statSync(path).isDirectory()) out.push(...sources(path));
|
|
else if (/\.(ts|vue|glsl)$/.test(name)) out.push(path);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** The first argument of the call whose '(' is at `open`, or null if unbalanced. */
|
|
function firstArgument(text: string, open: number): string | null {
|
|
let depth = 0;
|
|
for (let i = open; i < text.length; i += 1) {
|
|
const ch = text[i];
|
|
if (ch === '(') depth += 1;
|
|
else if (ch === ')') {
|
|
depth -= 1;
|
|
if (depth === 0) return null;
|
|
} else if (ch === ',' && depth === 1) return text.slice(open + 1, i);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const GUARD = /^(abs|max|clamp|smoothstep)\s*\(/;
|
|
const POSITIVE_SCALE = /^\s*[*/]\s*\d+(\.\d*)?\s*$/;
|
|
|
|
/** Is the base a single guarded call, optionally times or over a positive literal? */
|
|
function guardedPowBase(base: string): boolean {
|
|
const trimmed = base.trim();
|
|
const call = GUARD.exec(trimmed);
|
|
if (!call) return false;
|
|
let depth = 0;
|
|
for (let i = call[0].length - 1; i < trimmed.length; i += 1) {
|
|
if (trimmed[i] === '(') depth += 1;
|
|
else if (trimmed[i] === ')') {
|
|
depth -= 1;
|
|
if (depth === 0) {
|
|
const rest = trimmed.slice(i + 1);
|
|
return rest.trim() === '' || POSITIVE_SCALE.test(rest);
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** Blank out comments, so prose about pow() is skipped but code on a line
|
|
* that merely starts with a '*' continuation is still scanned. */
|
|
function withoutComments(text: string): string {
|
|
return text.replace(/\/\*[\s\S]*?\*\/|(^|[^:])\/\/[^\n]*/g, (comment, lead: string | undefined) =>
|
|
(lead ?? '') + comment.slice((lead ?? '').length).replace(/[^\n]/g, ' '),
|
|
);
|
|
}
|
|
|
|
function unguardedPowCalls(source: string): string[] {
|
|
const text = withoutComments(source);
|
|
const found: string[] = [];
|
|
const pattern = /(^|[^.\w])pow\s*\(/g;
|
|
for (let match = pattern.exec(text); match; match = pattern.exec(text)) {
|
|
const open = match.index + match[0].length - 1;
|
|
if (text[open + 1] === ')') continue;
|
|
const base = firstArgument(text, open);
|
|
if (base === null) continue;
|
|
if (!guardedPowBase(base)) found.push(base.trim());
|
|
}
|
|
return found;
|
|
}
|
|
|
|
describe('shader pow() safety', () => {
|
|
it('accepts only bases that are non-negative on their face', () => {
|
|
expect(guardedPowBase('abs(c.x) * 2.3')).toBe(true);
|
|
expect(guardedPowBase('abs(vLocal.y - 0.92) / 0.18')).toBe(true);
|
|
expect(guardedPowBase('clamp(vUv.y, 0.0, 1.0)')).toBe(true);
|
|
expect(guardedPowBase(' max( ACESFilmicToneMapping( color ), 0.0 ) ')).toBe(true);
|
|
expect(guardedPowBase('smoothstep(0.38, 0.498, radius)')).toBe(true);
|
|
expect(guardedPowBase('vUv.y')).toBe(false);
|
|
expect(guardedPowBase('1.0 - vUv.y')).toBe(false);
|
|
expect(guardedPowBase('(vUv.x - 0.5) * 5.0')).toBe(false);
|
|
expect(guardedPowBase('1.0 - abs(dot(n, toCamera))')).toBe(false);
|
|
expect(guardedPowBase('abs(x) - 1.0')).toBe(false);
|
|
expect(guardedPowBase('max(a, 0.0) * b')).toBe(false);
|
|
});
|
|
|
|
it('finds the traffic trail regression', () => {
|
|
expect(unguardedPowCalls('float glow = across * pow(vUv.y, 1.6) * 0.8;')).toEqual(['vUv.y']);
|
|
expect(unguardedPowCalls('// pow() of a negative base\nMath.pow(x, 2); value.pow(2);')).toEqual([]);
|
|
expect(unguardedPowCalls('/* pow(x, 2) */ float a = 1.0\n * exp(-pow((x - 6.6) * 1.1, 2.0));')).toEqual([
|
|
'(x - 6.6) * 1.1',
|
|
]);
|
|
});
|
|
|
|
it('guards every pow() base in the scene shaders', () => {
|
|
const offenders: string[] = [];
|
|
for (const file of sources(SRC)) {
|
|
for (const base of unguardedPowCalls(readFileSync(file, 'utf8'))) {
|
|
offenders.push(`${relative(ROOT, file)}: pow(${base}, ...)`);
|
|
}
|
|
}
|
|
expect(offenders).toEqual([]);
|
|
});
|
|
});
|