fix: guard shader pow() bases so a NaN pixel can no longer black out the frame

This commit is contained in:
2026-10-12 04:23:25 +03:00
parent 50da616a46
commit 06e6a59b6f
18 changed files with 165 additions and 34 deletions

View File

@@ -128,3 +128,10 @@ Run: `npm run build:preview` then serve `dist` (dev server shows a blank page).
- Tests: 970 (82 files): in-frame overtakes for four pool/seed configurations, near-spawn fade-in, the forced swerve against instant 120 u/s, the detector against the sweep and two flashes, and the click predicate.
- Budgets: scene chunk 228.15 KB gz9 (+6.06 vs 222.09, cap +8). Perf unchanged: rAF p50 16.7 / p95 16.7-16.8 ms on the six runs, 0 frames over 34 ms, no console errors.
- Still weak: there is no visual hint that a click flashes; on lite the overtake comes every ~50 s; the flash is still clearly bright through bloom on vehicles within 100 m (within the 3 Hz, 600 ms interval rule); `traffic-overtake-indicator.webp` is a 120 s capture crop at Infrastructure (centre-lane car at about 200 m with the amber on), `traffic-flash-to-pass.webp` is a before / +1 / +2 frame strip of one flash.
## Fix: rare one-frame black flash (owner report)
- **Root cause:** GLSL `pow(x, y)` is undefined for x < 0 and is NaN on real GPUs. The traffic trail (`pow(vUv.y, 1.6)`) and the shared rear/front pool shader (`pow(vUv.y, 2.2)`) took the interpolated uv straight in. When a quad is seen edge-on or clipped by the near plane (lower-right lanes beside the camera, e.g. a car braking and drifting back past the hero), a fragment can land a hair below 0. That one NaN pixel in the HDR target is spread by the UnrealBloom mips and blurs and by the lens streak (sampled across ±24% of the width), and ACES of NaN gives a black frame.
- **Evidence (hardware GL, r6 flags, 96x60 readback each rAF):** unguarded builds gave 4 black frames in about 14.8k (full range) and 4 in 7.4k (confined to y 8800-10600, idle). With a shader-source hook that zeroes NaN/Inf/>60000 fragments there were 0 in about 45k frames. Colouring the offending fragments green placed them on the near lower-right road. Bisecting by material family put them only in the trail (1 event alone) and pool (1+1 alone) shaders. Lamps, bodies and the PBR car gave none. The link to deceleration is plausible (braking cars drift back toward the camera), but it is not proven.
- **Fix:** every `pow()` base in the scene shaders is now non-negative on its face. Traffic uses `clamp(vUv.y, 0, 1)`. The 31 other sites in 15 world files get `abs()` for even exponents (where results are identical), and `max(…, 0)` or `clamp(…, 0, 1)` elsewhere (identical wherever the old result was defined). Examples are speed-wake `1 - vUv.y`, expressway `facing`, billboard/dawn/districts fresnel `1 - |dot|`, road film and cones, skyline, yard and terrain. No CPU-side change and no allocations.
- **Test:** tests/unit/shader-pow-safety.test.ts scans src for `pow(` (comments stripped). It fails unless the base is a single abs/max/clamp/smoothstep call, optionally scaled by a positive literal. It includes the traffic regression.
- **Checks:** 973 tests (83 files), vue-tsc clean, build:preview violations []. After the fix the unguarded probe gave 0 black frames in 21.6k frames (seeds 11 and 19 confined, seed 7 full range). Probe rAF p50 16.7 / p95 33.3 ms, the same as the pre-fix probe runs (the readback inflates p95).

View File

@@ -56,7 +56,7 @@ export function createArrivalMeteors(resources: SceneResourceBag) {
varying float vLife;
void main() {
float across = abs(vUv.y * 2.0 - 1.0);
float taper = pow(vUv.x, 1.4);
float taper = pow(clamp(vUv.x, 0.0, 1.0), 1.4);
float core = (1.0 - smoothstep(0.06, 0.28, across)) * taper;
float glow = (1.0 - smoothstep(0.0, 1.0, across)) * taper;
float head = (1.0 - smoothstep(0.0, 1.0,

View File

@@ -88,7 +88,7 @@ export function createAtmosphere(
float drift = sin(uv.x * 5.2 + uTime * 0.025) * 0.035;
float arc = uv.y - (0.66 + 0.19 * sin(uv.x * 2.8 - 0.35) + drift);
float veil = exp(-arc * arc * 82.0);
float innerVeil = exp(-pow((arc + 0.055) * 17.0, 2.0));
float innerVeil = exp(-pow(abs(arc + 0.055) * 17.0, 2.0));
float side = smoothstep(0.15, 0.7, uv.x);
vec3 violet = vec3(0.23, 0.018, 0.50);
vec3 rose = vec3(0.50, 0.018, 0.17);

View File

@@ -422,7 +422,7 @@ export function createBillboard(resources: SceneResourceBag, options: BillboardO
float alpha = 1.0;
if (vPart < 0.5) {
color = vec3(0.03, 0.026, 0.045) + vec3(0.08, 0.07, 0.11) * key + warm * 0.05 * flood * max(0.0, n.y);
color += uAccent * 0.05 * pow(1.0 - abs(dot(n, toCamera)), 3.0);
color += uAccent * 0.05 * pow(max(1.0 - abs(dot(n, toCamera)), 0.0), 3.0);
} else if (vPart < 1.5) {
color = uAccent * (0.55 + 0.9 * flood);
// Drive-energy shimmer: one bright band runs round the frame.

View File

@@ -20,7 +20,7 @@ export const DAWN_PARS = /* glsl */ `
if (uDawnLight <= 0.0) return vec3(0.0);
vec3 sunDir = vec3(${SUN_DIRECTION.x.toFixed(4)}, ${SUN_DIRECTION.y.toFixed(4)}, ${SUN_DIRECTION.z.toFixed(4)});
float facing = abs(dot(n, sunDir));
float rim = pow(1.0 - abs(dot(n, toCamera)), 2.0);
float rim = pow(max(1.0 - abs(dot(n, toCamera)), 0.0), 2.0);
float against = max(0.0, dot(-toCamera, sunDir));
return vec3(1.0, 0.6, 0.36) * uDawnLight * (facing * 0.09 + rim * against * 0.24);
}

View File

@@ -242,7 +242,7 @@ export function createDistricts(
vec3 color = vec3(0.022, 0.018, 0.034) + vec3(0.07, 0.06, 0.1) * key + vec3(0.02, 0.015, 0.035) * fill;
// Emissive edge: the silhouette catches the chapter colour, and
// the tops of the pieces carry a thin lit band.
float rim = pow(1.0 - abs(dot(n, normalize(cameraPosition - vWorld))), 4.0);
float rim = pow(max(1.0 - abs(dot(n, normalize(cameraPosition - vWorld))), 0.0), 4.0);
float crown = smoothstep(0.92, 1.0, n.y) * smoothstep(1.0, 6.0, vHeight);
${serviced ? `
float dimmed = serviceRowDim();

View File

@@ -54,7 +54,7 @@ export function createExpressway(
vec3 cyan = mix(vec3(1.0, 0.025, 0.34), vec3(0.02, 0.78, 0.93), step(0.0, vWorld.x));
vec3 coral = vec3(1.0, 0.18, 0.095);
float facing = max(0.0, dot(normalize(vNormal), normalize(vec3(-0.5, 0.9, 0.6))));
vec3 color = ${luminous ? `mix(${coral ? 'coral' : 'cyan'}, coral, uWarmth * 0.12) * (1.3 + uEnergy * 0.5)` : 'vec3(0.028, 0.012, 0.046) + vec3(0.09, 0.055, 0.14) * pow(facing, 2.0)'};
vec3 color = ${luminous ? `mix(${coral ? 'coral' : 'cyan'}, coral, uWarmth * 0.12) * (1.3 + uEnergy * 0.5)` : 'vec3(0.028, 0.012, 0.046) + vec3(0.09, 0.055, 0.14) * pow(max(facing, 0.0), 2.0)'};
${!luminous ? 'color += cyan * pow(max(0.0, vNormal.x), 2.0) * 0.028;' : ''}
if(farFade * nearFade < 0.02) discard;
gl_FragColor = vec4(color, farFade * nearFade);

View File

@@ -28,8 +28,8 @@ export function createHorizonAir(resources: SceneResourceBag) {
varying vec2 vUv;
void main(){
float wave=sin(vUv.x*12.0+uTime*0.018)*0.055+sin(vUv.x*31.0-uTime*0.026)*0.018;
float ribbon=exp(-pow((vUv.y-0.45-wave)*5.6,2.0));
float thin=exp(-pow((vUv.y-0.51-wave)*37.0,2.0));
float ribbon=exp(-pow(abs(vUv.y-0.45-wave)*5.6,2.0));
float thin=exp(-pow(abs(vUv.y-0.51-wave)*37.0,2.0));
float horizontal=pow(max(0.0,sin(vUv.x*3.14159)),2.0);
float colorWave=0.5+0.5*sin(vUv.x*7.0+uTime*0.035);
vec3 color=mix(uColor,vec3(0.27,0.045,0.75),colorWave*0.55);

View File

@@ -275,7 +275,7 @@ export function createMaintenanceWindow(resources: SceneResourceBag) {
varying float vReturn;
void main() {
float across = abs(vUv.y);
float body = (1.0 - smoothstep(0.0, 1.0, across)) * pow(vUv.x, 1.6);
float body = (1.0 - smoothstep(0.0, 1.0, across)) * pow(clamp(vUv.x, 0.0, 1.0), 1.6);
float head = 1.0 - smoothstep(0.0, 1.0, length(vec2((vUv.x - 0.9) * 5.0, across)));
float alpha = max(body * 0.7, head) * vAlpha;
if (alpha < 0.004) discard;

View File

@@ -340,7 +340,7 @@ export function createRelayMasts(resources: SceneResourceBag, full: boolean, ton
void main() {
float across = pow(max(0.0, 1.0 - abs(vUv.x - 0.5) * 2.0), 2.0);
// The base eases in over the first 12% so the source does not read as a blown blob at the beacon.
float along = pow(1.0 - vUv.y, 1.6) * smoothstep(0.0, 0.12, vUv.y);
float along = pow(clamp(1.0 - vUv.y, 0.0, 1.0), 1.6) * smoothstep(0.0, 0.12, vUv.y);
vec3 color = mix(vec3(0.6, 0.36, 1.0), vec3(0.9, 0.92, 1.0), 0.3) * across * along * ${BEAM_INTENSITY.toFixed(2)} * vLevel;
gl_FragColor = vec4(color, 1.0);
#include <tonemapping_fragment>

View File

@@ -171,7 +171,7 @@ export function createRoad(
float film = mix(0.36, 1.0, streak) * (0.74 + 0.26 * fine);
float grazing = smoothstep(8.0, 120.0, vViewDistance);
color += vec3(0.037, 0.022, 0.069) * film * asphaltMask * (0.35 + grazing * 0.65);
float channel = exp(-pow((roadX - 7.98) * 2.4, 2.0));
float channel = exp(-pow(abs(roadX - 7.98) * 2.4, 2.0));
color += edgeColor * channel * (0.06 + 0.10 * film);
float shoulderRibs = step(0.77, fract(travelZ / 1.4))
* smoothstep(8.28, 8.36, roadX) * (1.0 - smoothstep(8.85, 9.02, roadX));
@@ -184,19 +184,19 @@ export function createRoad(
// Rear lamp footprint: grows towards the camera from the actual rear axle.
float behindCar = smoothstep(7.0, 8.8, vWorldZ);
float tailLength = exp(-max(0.0, vWorldZ - 8.2) * 0.13);
float tailWidth = exp(-pow(vRoadX / (2.25 + max(0.0, vWorldZ-8.0)*0.035), 6.0));
float lampSegments = 0.60 + 0.40 * pow(sin(vRoadX * 8.7), 2.0);
float tailWidth = exp(-pow(abs(vRoadX / (2.25 + max(0.0, vWorldZ-8.0)*0.035)), 6.0));
float lampSegments = 0.60 + 0.40 * pow(abs(sin(vRoadX * 8.7)), 2.0);
color += vec3(1.0, 0.035, 0.055) * behindCar * tailLength * tailWidth
* film * lampSegments * (0.34 + uAcceleration * 0.14);
float cyanReflection = exp(-pow((roadX-2.7) * 6.0, 2.0));
float cyanReflection = exp(-pow(abs(roadX-2.7) * 6.0, 2.0));
color += uEdgeColor * behindCar * tailLength * cyanReflection * film * 0.4;
float sunReflection = exp(-pow(vRoadX / (0.55 + vViewDistance * 0.011), 2.0))
float sunReflection = exp(-pow(abs(vRoadX / (0.55 + vViewDistance * 0.011)), 2.0))
* smoothstep(36.0, 180.0, vViewDistance);
color += vec3(0.8, 0.15, 0.08) * sunReflection * film * 0.17;
// Wet glare: the sun lays a broken column down the asphalt.
float glare = exp(-pow(vRoadX / (1.1 + vViewDistance * 0.02), 2.0))
float glare = exp(-pow(abs(vRoadX / (1.1 + vViewDistance * 0.02)), 2.0))
* smoothstep(50.0, 220.0, vViewDistance) * (1.0 - smoothstep(360.0, 560.0, vViewDistance));
color += vec3(1.0, 0.32, 0.22) * glare * pow(film, 2.0) * asphaltMask * 0.24;
color += vec3(1.0, 0.32, 0.22) * glare * pow(max(film, 0.0), 2.0) * asphaltMask * 0.24;
// END OF SHIFT: first light grazes the wet asphalt from the low sun
// ahead, a warm sheen that strengthens toward the horizon.
if (uDawnLight > 0.0) {
@@ -214,14 +214,14 @@ export function createRoad(
float throwZ = smoothstep(-3.0, 5.0, ahead) * (1.0 - smoothstep(26.0, mix(92.0, 120.0, uBeamLift), ahead));
float spread = 0.9 + max(0.0, ahead) * 0.07;
float beamX = vRoadX - uBeamYaw * 3.4 * clamp(ahead / 26.0, 0.0, 4.6);
float cones = exp(-pow((beamX - 1.25) / spread, 2.0)) + exp(-pow((beamX + 1.25) / spread, 2.0));
float cones = exp(-pow(abs((beamX - 1.25) / spread), 2.0)) + exp(-pow(abs((beamX + 1.25) / spread), 2.0));
color += vec3(1.0, 0.84, 0.66) * min(cones, 1.4) * throwZ * (0.07 + 0.16 * film) * asphaltMask
* (1.0 + 0.3 * uBeamLift) * uThrow;
// Pylon lamps reflect as long streaks towards the camera.
float pylonGap = mod(vWorldZ + 24.0 - uPylonPhase, ${PYLON_SPACING.toFixed(3)});
float pylonStreak = exp(-pylonGap * 0.075) * smoothstep(0.0, 1.5, pylonGap)
* exp(-pow((roadX - 6.6) * 1.1, 2.0));
* exp(-pow(abs(roadX - 6.6) * 1.1, 2.0));
color += edgeColor * pylonStreak * film * 0.5 * (1.0 - smoothstep(300.0, 520.0, vViewDistance));
float horizonPickup = smoothstep(280.0, 620.0, vViewDistance)

View File

@@ -368,7 +368,7 @@ export function createSign(
uniforms: { uSweep: { value: -1 }, uOpacity: { value: 0 } },
vertexShader: `varying vec2 vUv;void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`,
fragmentShader: `uniform float uSweep;uniform float uOpacity;varying vec2 vUv;
void main(){float beam=exp(-pow((vUv.x+vUv.y*0.14-uSweep)*12.0,2.0));
void main(){float beam=exp(-pow(abs(vUv.x+vUv.y*0.14-uSweep)*12.0,2.0));
gl_FragColor=vec4(0.36,0.82,0.9,beam*uOpacity);}`,
transparent: true,
depthWrite: false,

View File

@@ -53,7 +53,7 @@ export function createSkyline(resources: SceneResourceBag) {
float r2 = hash(id * 1.7 + seed + 3.1);
float inset = 0.06 + r2 * 0.18;
float inside = step(inset, local) * step(local, 1.0 - inset * 0.5);
float h = (0.1 + pow(r, 2.4) * tall) * envelope;
float h = (0.1 + pow(max(r, 0.0), 2.4) * tall) * envelope;
// Stepped crowns on the tall ones.
float crown = step(0.62, r) * step(0.32, abs(local - 0.5)) * 0.06 * envelope;
return vec4(h - crown, inside, id, r);
@@ -63,9 +63,9 @@ export function createSkyline(resources: SceneResourceBag) {
float x = (vUv.x - 0.5) * ${WIDTH.toFixed(1)};
float y = vUv.y;
float xn = vUv.x - 0.5;
float core = exp(-pow(xn / 0.2, 2.0));
float outskirts = 0.28 + 0.72 * core + 0.2 * exp(-pow((xn + 0.27) / 0.06, 2.0))
+ 0.16 * exp(-pow((xn - 0.31) / 0.05, 2.0));
float core = exp(-pow(abs(xn) / 0.2, 2.0));
float outskirts = 0.28 + 0.72 * core + 0.2 * exp(-pow(abs(xn + 0.27) / 0.06, 2.0))
+ 0.16 * exp(-pow(abs(xn - 0.31) / 0.05, 2.0));
float fade = smoothstep(0.5, 0.36, abs(xn));
vec4 far = towers(x + 3.0, 15.0, 11.0, outskirts * fade, 0.74);

View File

@@ -58,10 +58,10 @@ export function createSpeedWake(resources: SceneResourceBag) {
uniform float uPower; uniform float uTime; varying vec2 vUv;
void main(){
float crossFade=pow(max(0.0,1.0-abs(vUv.x*2.0-1.0)),1.3);
float tip=pow(1.0-vUv.y,1.4)*smoothstep(0.0,0.055,vUv.y);
float tip=pow(clamp(1.0-vUv.y,0.0,1.0),1.4)*smoothstep(0.0,0.055,vUv.y);
float filament=0.76+0.24*sin(vUv.y*35.0-uTime*15.0);
vec3 color=mix(vec3(0.08,0.86,1.0),vec3(1.0,0.02,0.44),vUv.y);
color+=vec3(1.0,0.46,0.21)*pow(crossFade,5.0)*0.36;
color+=vec3(1.0,0.46,0.21)*pow(max(crossFade,0.0),5.0)*0.36;
gl_FragColor=vec4(color*1.8,crossFade*tip*filament*uPower*0.8);
#include <tonemapping_fragment>
#include <colorspace_fragment>

View File

@@ -261,7 +261,7 @@ export function createTerrain(
vec3 normal = normalize(cross(dFdx(vTerrainPosition), dFdy(vTerrainPosition)));
float light = abs(dot(normal, normalize(vec3(-0.5, 0.8, -0.3))));
vec3 color = mix(vec3(0.012, 0.003, 0.024), vGridColor * (0.065 + uSurge * 0.016), light);
color += uHighColor * pow(vHeight, 2.0) * light * 0.06;
color += uHighColor * pow(max(vHeight, 0.0), 2.0) * light * 0.06;
// HIGH BEAM: the ridges beside the road catch the headlights.
float beam = headlightBeam(vTerrainPosition);
// UNDERPASS: the deck takes the sky off the ridges beside it (at most 35%).

View File

@@ -236,14 +236,19 @@ export function createTraffic(
float core = exp(-pow(abs(c.x) * 2.3, 6.0) - pow(abs(c.y) * 2.4, 6.0));
float r = length(c * vec2(1.0, 1.6));
float glow = core * 2.4 + exp(-r * r * 22.0) * 1.2 + exp(-r * r * 5.0) * 0.3;`);
// Every pow() base is clamped: interpolation can land a fragment a hair
// outside 0..1 (a quad edge-on or clipped by the near plane), and pow() of a
// negative base is NaN. One NaN pixel in the HDR target is spread by the
// bloom blurs and the lens streak over the whole frame: a one-frame black
// flash (tests/unit/shader-pow-safety.test.ts).
// Trails: thin, brightest at the lamp, fading over their length.
const trailMaterial = additive(`
float across = exp(-pow((vUv.x - 0.5) * 5.0, 2.0));
float glow = across * pow(vUv.y, 1.6) * 0.8;`);
float across = exp(-pow(abs(vUv.x - 0.5) * 5.0, 2.0));
float glow = across * pow(clamp(vUv.y, 0.0, 1.0), 1.6) * 0.8;`);
// Wet-road pools: long and soft, brightest at the vehicle.
const poolMaterial = additive(`
float across = exp(-pow((vUv.x - 0.5) * 3.2, 2.0));
float glow = across * pow(vUv.y, 2.2) * 0.2;`);
float across = exp(-pow(abs(vUv.x - 0.5) * 3.2, 2.0));
float glow = across * pow(clamp(vUv.y, 0.0, 1.0), 2.2) * 0.2;`);
// Bodies: near-black coachwork that reads as a silhouette against the
// horizon, with the fog glow on the roof, the reader's headlights on the
@@ -321,12 +326,12 @@ export function createTraffic(
? `// Rear window: glass reflects a lavender band of sky.
float glass = rear * step(1.12, vLocal.y) * step(vLocal.z, -0.9);
color = mix(color, vec3(0.05, 0.05, 0.12) + uSheen * 0.22 * smoothstep(1.2, 1.55, vLocal.y), glass);
float lampBand = exp(-pow((vLocal.y - 0.92) / 0.18, 2.0));`
float lampBand = exp(-pow(abs(vLocal.y - 0.92) / 0.18, 2.0));`
: `// Box doors: a centre seam and two lock bars.
float seam = 1.0 - smoothstep(0.02, 0.05, abs(vLocal.x));
float bars = 1.0 - smoothstep(0.03, 0.06, abs(abs(vLocal.x) - 0.42));
color *= 1.0 - rear * max(seam * 0.6, bars * 0.35);
float lampBand = exp(-pow((vLocal.y - 1.3) / 0.2, 2.0));
float lampBand = exp(-pow(abs(vLocal.y - 1.3) / 0.2, 2.0));
// The under-run bar is a reflector strip: it answers the beam from far off.
float bar = rear * step(0.42, vLocal.y) * step(vLocal.y, 0.56);
color += vec3(1.0, 0.34, 0.22) * bar * headlightCone(vWorld) * smoothstep(18.0, 50.0, beamDistance) * (1.0 - smoothstep(180.0, 420.0, beamDistance)) * (0.2 + 1.4 * lift);`

View File

@@ -209,7 +209,7 @@ function additive(billboard: boolean) {
varying vec4 vGlow;
void main() {
float r = 1.0 - min(1.0, length(vUv - 0.5) * 2.0);
float shape = ${billboard ? 'pow(r, 3.0) * 1.3 + pow(r, 1.2) * 0.22' : 'pow(r, 1.8) * 0.7'};
float shape = ${billboard ? 'pow(max(r, 0.0), 3.0) * 1.3 + pow(max(r, 0.0), 1.2) * 0.22' : 'pow(max(r, 0.0), 1.8) * 0.7'};
gl_FragColor = vec4(vGlow.rgb * shape * vGlow.w, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>

View File

@@ -0,0 +1,119 @@
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([]);
});
});