diff --git a/docs/overnight/LOG.md b/docs/overnight/LOG.md index 960cd75..939f843 100644 --- a/docs/overnight/LOG.md +++ b/docs/overnight/LOG.md @@ -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). diff --git a/src/scene/world/arrival-meteors.ts b/src/scene/world/arrival-meteors.ts index 994aa53..4d68fc8 100644 --- a/src/scene/world/arrival-meteors.ts +++ b/src/scene/world/arrival-meteors.ts @@ -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, diff --git a/src/scene/world/atmosphere.ts b/src/scene/world/atmosphere.ts index 4a18cad..f23f217 100644 --- a/src/scene/world/atmosphere.ts +++ b/src/scene/world/atmosphere.ts @@ -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); diff --git a/src/scene/world/billboard.ts b/src/scene/world/billboard.ts index 30ac5a7..684b111 100644 --- a/src/scene/world/billboard.ts +++ b/src/scene/world/billboard.ts @@ -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. diff --git a/src/scene/world/dawn-light.ts b/src/scene/world/dawn-light.ts index 0a2ee02..fef6742 100644 --- a/src/scene/world/dawn-light.ts +++ b/src/scene/world/dawn-light.ts @@ -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); } diff --git a/src/scene/world/districts.ts b/src/scene/world/districts.ts index ea6b2d8..1d95c2e 100644 --- a/src/scene/world/districts.ts +++ b/src/scene/world/districts.ts @@ -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(); diff --git a/src/scene/world/expressway.ts b/src/scene/world/expressway.ts index e7660cd..b7c8e17 100644 --- a/src/scene/world/expressway.ts +++ b/src/scene/world/expressway.ts @@ -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); diff --git a/src/scene/world/horizon-air.ts b/src/scene/world/horizon-air.ts index 967f746..83656e3 100644 --- a/src/scene/world/horizon-air.ts +++ b/src/scene/world/horizon-air.ts @@ -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); diff --git a/src/scene/world/maintenance-window.ts b/src/scene/world/maintenance-window.ts index aac9e96..185bd59 100644 --- a/src/scene/world/maintenance-window.ts +++ b/src/scene/world/maintenance-window.ts @@ -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; diff --git a/src/scene/world/relay-masts.ts b/src/scene/world/relay-masts.ts index 9c9d7f7..48fb788 100644 --- a/src/scene/world/relay-masts.ts +++ b/src/scene/world/relay-masts.ts @@ -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 diff --git a/src/scene/world/road.ts b/src/scene/world/road.ts index 0d210a7..066d915 100644 --- a/src/scene/world/road.ts +++ b/src/scene/world/road.ts @@ -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) diff --git a/src/scene/world/sign.ts b/src/scene/world/sign.ts index c910ec2..fa12318 100644 --- a/src/scene/world/sign.ts +++ b/src/scene/world/sign.ts @@ -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, diff --git a/src/scene/world/skyline.ts b/src/scene/world/skyline.ts index dab2c5e..6fa403c 100644 --- a/src/scene/world/skyline.ts +++ b/src/scene/world/skyline.ts @@ -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); diff --git a/src/scene/world/speed-wake.ts b/src/scene/world/speed-wake.ts index 8a4f862..5cf4c2e 100644 --- a/src/scene/world/speed-wake.ts +++ b/src/scene/world/speed-wake.ts @@ -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 #include diff --git a/src/scene/world/terrain.ts b/src/scene/world/terrain.ts index 39363a0..c2e17e0 100644 --- a/src/scene/world/terrain.ts +++ b/src/scene/world/terrain.ts @@ -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%). diff --git a/src/scene/world/traffic.ts b/src/scene/world/traffic.ts index 5efb711..3bdf0f1 100644 --- a/src/scene/world/traffic.ts +++ b/src/scene/world/traffic.ts @@ -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);` diff --git a/src/scene/world/yard.ts b/src/scene/world/yard.ts index 36fdac8..b405b86 100644 --- a/src/scene/world/yard.ts +++ b/src/scene/world/yard.ts @@ -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 #include diff --git a/tests/unit/shader-pow-safety.test.ts b/tests/unit/shader-pow-safety.test.ts new file mode 100644 index 0000000..35fa357 --- /dev/null +++ b/tests/unit/shader-pow-safety.test.ts @@ -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([]); + }); +});