fix: guard shader pow() bases so a NaN pixel can no longer black out the frame
This commit is contained in:
@@ -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.
|
- 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.
|
- 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.
|
- 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).
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function createArrivalMeteors(resources: SceneResourceBag) {
|
|||||||
varying float vLife;
|
varying float vLife;
|
||||||
void main() {
|
void main() {
|
||||||
float across = abs(vUv.y * 2.0 - 1.0);
|
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 core = (1.0 - smoothstep(0.06, 0.28, across)) * taper;
|
||||||
float glow = (1.0 - smoothstep(0.0, 1.0, across)) * taper;
|
float glow = (1.0 - smoothstep(0.0, 1.0, across)) * taper;
|
||||||
float head = (1.0 - smoothstep(0.0, 1.0,
|
float head = (1.0 - smoothstep(0.0, 1.0,
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export function createAtmosphere(
|
|||||||
float drift = sin(uv.x * 5.2 + uTime * 0.025) * 0.035;
|
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 arc = uv.y - (0.66 + 0.19 * sin(uv.x * 2.8 - 0.35) + drift);
|
||||||
float veil = exp(-arc * arc * 82.0);
|
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);
|
float side = smoothstep(0.15, 0.7, uv.x);
|
||||||
vec3 violet = vec3(0.23, 0.018, 0.50);
|
vec3 violet = vec3(0.23, 0.018, 0.50);
|
||||||
vec3 rose = vec3(0.50, 0.018, 0.17);
|
vec3 rose = vec3(0.50, 0.018, 0.17);
|
||||||
|
|||||||
@@ -422,7 +422,7 @@ export function createBillboard(resources: SceneResourceBag, options: BillboardO
|
|||||||
float alpha = 1.0;
|
float alpha = 1.0;
|
||||||
if (vPart < 0.5) {
|
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 = 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) {
|
} else if (vPart < 1.5) {
|
||||||
color = uAccent * (0.55 + 0.9 * flood);
|
color = uAccent * (0.55 + 0.9 * flood);
|
||||||
// Drive-energy shimmer: one bright band runs round the frame.
|
// Drive-energy shimmer: one bright band runs round the frame.
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const DAWN_PARS = /* glsl */ `
|
|||||||
if (uDawnLight <= 0.0) return vec3(0.0);
|
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)});
|
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 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));
|
float against = max(0.0, dot(-toCamera, sunDir));
|
||||||
return vec3(1.0, 0.6, 0.36) * uDawnLight * (facing * 0.09 + rim * against * 0.24);
|
return vec3(1.0, 0.6, 0.36) * uDawnLight * (facing * 0.09 + rim * against * 0.24);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
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
|
// Emissive edge: the silhouette catches the chapter colour, and
|
||||||
// the tops of the pieces carry a thin lit band.
|
// 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);
|
float crown = smoothstep(0.92, 1.0, n.y) * smoothstep(1.0, 6.0, vHeight);
|
||||||
${serviced ? `
|
${serviced ? `
|
||||||
float dimmed = serviceRowDim();
|
float dimmed = serviceRowDim();
|
||||||
|
|||||||
@@ -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 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);
|
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))));
|
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;' : ''}
|
${!luminous ? 'color += cyan * pow(max(0.0, vNormal.x), 2.0) * 0.028;' : ''}
|
||||||
if(farFade * nearFade < 0.02) discard;
|
if(farFade * nearFade < 0.02) discard;
|
||||||
gl_FragColor = vec4(color, farFade * nearFade);
|
gl_FragColor = vec4(color, farFade * nearFade);
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ export function createHorizonAir(resources: SceneResourceBag) {
|
|||||||
varying vec2 vUv;
|
varying vec2 vUv;
|
||||||
void main(){
|
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 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 ribbon=exp(-pow(abs(vUv.y-0.45-wave)*5.6,2.0));
|
||||||
float thin=exp(-pow((vUv.y-0.51-wave)*37.0,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 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);
|
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);
|
vec3 color=mix(uColor,vec3(0.27,0.045,0.75),colorWave*0.55);
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ export function createMaintenanceWindow(resources: SceneResourceBag) {
|
|||||||
varying float vReturn;
|
varying float vReturn;
|
||||||
void main() {
|
void main() {
|
||||||
float across = abs(vUv.y);
|
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 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;
|
float alpha = max(body * 0.7, head) * vAlpha;
|
||||||
if (alpha < 0.004) discard;
|
if (alpha < 0.004) discard;
|
||||||
|
|||||||
@@ -340,7 +340,7 @@ export function createRelayMasts(resources: SceneResourceBag, full: boolean, ton
|
|||||||
void main() {
|
void main() {
|
||||||
float across = pow(max(0.0, 1.0 - abs(vUv.x - 0.5) * 2.0), 2.0);
|
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.
|
// 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;
|
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);
|
gl_FragColor = vec4(color, 1.0);
|
||||||
#include <tonemapping_fragment>
|
#include <tonemapping_fragment>
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ export function createRoad(
|
|||||||
float film = mix(0.36, 1.0, streak) * (0.74 + 0.26 * fine);
|
float film = mix(0.36, 1.0, streak) * (0.74 + 0.26 * fine);
|
||||||
float grazing = smoothstep(8.0, 120.0, vViewDistance);
|
float grazing = smoothstep(8.0, 120.0, vViewDistance);
|
||||||
color += vec3(0.037, 0.022, 0.069) * film * asphaltMask * (0.35 + grazing * 0.65);
|
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);
|
color += edgeColor * channel * (0.06 + 0.10 * film);
|
||||||
float shoulderRibs = step(0.77, fract(travelZ / 1.4))
|
float shoulderRibs = step(0.77, fract(travelZ / 1.4))
|
||||||
* smoothstep(8.28, 8.36, roadX) * (1.0 - smoothstep(8.85, 9.02, roadX));
|
* 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.
|
// Rear lamp footprint: grows towards the camera from the actual rear axle.
|
||||||
float behindCar = smoothstep(7.0, 8.8, vWorldZ);
|
float behindCar = smoothstep(7.0, 8.8, vWorldZ);
|
||||||
float tailLength = exp(-max(0.0, vWorldZ - 8.2) * 0.13);
|
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 tailWidth = exp(-pow(abs(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 lampSegments = 0.60 + 0.40 * pow(abs(sin(vRoadX * 8.7)), 2.0);
|
||||||
color += vec3(1.0, 0.035, 0.055) * behindCar * tailLength * tailWidth
|
color += vec3(1.0, 0.035, 0.055) * behindCar * tailLength * tailWidth
|
||||||
* film * lampSegments * (0.34 + uAcceleration * 0.14);
|
* 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;
|
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);
|
* smoothstep(36.0, 180.0, vViewDistance);
|
||||||
color += vec3(0.8, 0.15, 0.08) * sunReflection * film * 0.17;
|
color += vec3(0.8, 0.15, 0.08) * sunReflection * film * 0.17;
|
||||||
// Wet glare: the sun lays a broken column down the asphalt.
|
// 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));
|
* 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
|
// END OF SHIFT: first light grazes the wet asphalt from the low sun
|
||||||
// ahead, a warm sheen that strengthens toward the horizon.
|
// ahead, a warm sheen that strengthens toward the horizon.
|
||||||
if (uDawnLight > 0.0) {
|
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 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 spread = 0.9 + max(0.0, ahead) * 0.07;
|
||||||
float beamX = vRoadX - uBeamYaw * 3.4 * clamp(ahead / 26.0, 0.0, 4.6);
|
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
|
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;
|
* (1.0 + 0.3 * uBeamLift) * uThrow;
|
||||||
|
|
||||||
// Pylon lamps reflect as long streaks towards the camera.
|
// Pylon lamps reflect as long streaks towards the camera.
|
||||||
float pylonGap = mod(vWorldZ + 24.0 - uPylonPhase, ${PYLON_SPACING.toFixed(3)});
|
float pylonGap = mod(vWorldZ + 24.0 - uPylonPhase, ${PYLON_SPACING.toFixed(3)});
|
||||||
float pylonStreak = exp(-pylonGap * 0.075) * smoothstep(0.0, 1.5, pylonGap)
|
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));
|
color += edgeColor * pylonStreak * film * 0.5 * (1.0 - smoothstep(300.0, 520.0, vViewDistance));
|
||||||
|
|
||||||
float horizonPickup = smoothstep(280.0, 620.0, vViewDistance)
|
float horizonPickup = smoothstep(280.0, 620.0, vViewDistance)
|
||||||
|
|||||||
@@ -368,7 +368,7 @@ export function createSign(
|
|||||||
uniforms: { uSweep: { value: -1 }, uOpacity: { value: 0 } },
|
uniforms: { uSweep: { value: -1 }, uOpacity: { value: 0 } },
|
||||||
vertexShader: `varying vec2 vUv;void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.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;
|
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);}`,
|
gl_FragColor=vec4(0.36,0.82,0.9,beam*uOpacity);}`,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function createSkyline(resources: SceneResourceBag) {
|
|||||||
float r2 = hash(id * 1.7 + seed + 3.1);
|
float r2 = hash(id * 1.7 + seed + 3.1);
|
||||||
float inset = 0.06 + r2 * 0.18;
|
float inset = 0.06 + r2 * 0.18;
|
||||||
float inside = step(inset, local) * step(local, 1.0 - inset * 0.5);
|
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.
|
// Stepped crowns on the tall ones.
|
||||||
float crown = step(0.62, r) * step(0.32, abs(local - 0.5)) * 0.06 * envelope;
|
float crown = step(0.62, r) * step(0.32, abs(local - 0.5)) * 0.06 * envelope;
|
||||||
return vec4(h - crown, inside, id, r);
|
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 x = (vUv.x - 0.5) * ${WIDTH.toFixed(1)};
|
||||||
float y = vUv.y;
|
float y = vUv.y;
|
||||||
float xn = vUv.x - 0.5;
|
float xn = vUv.x - 0.5;
|
||||||
float core = exp(-pow(xn / 0.2, 2.0));
|
float core = exp(-pow(abs(xn) / 0.2, 2.0));
|
||||||
float outskirts = 0.28 + 0.72 * core + 0.2 * exp(-pow((xn + 0.27) / 0.06, 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((xn - 0.31) / 0.05, 2.0));
|
+ 0.16 * exp(-pow(abs(xn - 0.31) / 0.05, 2.0));
|
||||||
float fade = smoothstep(0.5, 0.36, abs(xn));
|
float fade = smoothstep(0.5, 0.36, abs(xn));
|
||||||
|
|
||||||
vec4 far = towers(x + 3.0, 15.0, 11.0, outskirts * fade, 0.74);
|
vec4 far = towers(x + 3.0, 15.0, 11.0, outskirts * fade, 0.74);
|
||||||
|
|||||||
@@ -58,10 +58,10 @@ export function createSpeedWake(resources: SceneResourceBag) {
|
|||||||
uniform float uPower; uniform float uTime; varying vec2 vUv;
|
uniform float uPower; uniform float uTime; varying vec2 vUv;
|
||||||
void main(){
|
void main(){
|
||||||
float crossFade=pow(max(0.0,1.0-abs(vUv.x*2.0-1.0)),1.3);
|
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);
|
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);
|
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);
|
gl_FragColor=vec4(color*1.8,crossFade*tip*filament*uPower*0.8);
|
||||||
#include <tonemapping_fragment>
|
#include <tonemapping_fragment>
|
||||||
#include <colorspace_fragment>
|
#include <colorspace_fragment>
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ export function createTerrain(
|
|||||||
vec3 normal = normalize(cross(dFdx(vTerrainPosition), dFdy(vTerrainPosition)));
|
vec3 normal = normalize(cross(dFdx(vTerrainPosition), dFdy(vTerrainPosition)));
|
||||||
float light = abs(dot(normal, normalize(vec3(-0.5, 0.8, -0.3))));
|
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);
|
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.
|
// HIGH BEAM: the ridges beside the road catch the headlights.
|
||||||
float beam = headlightBeam(vTerrainPosition);
|
float beam = headlightBeam(vTerrainPosition);
|
||||||
// UNDERPASS: the deck takes the sky off the ridges beside it (at most 35%).
|
// UNDERPASS: the deck takes the sky off the ridges beside it (at most 35%).
|
||||||
|
|||||||
@@ -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 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 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;`);
|
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.
|
// Trails: thin, brightest at the lamp, fading over their length.
|
||||||
const trailMaterial = additive(`
|
const trailMaterial = additive(`
|
||||||
float across = exp(-pow((vUv.x - 0.5) * 5.0, 2.0));
|
float across = exp(-pow(abs(vUv.x - 0.5) * 5.0, 2.0));
|
||||||
float glow = across * pow(vUv.y, 1.6) * 0.8;`);
|
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.
|
// Wet-road pools: long and soft, brightest at the vehicle.
|
||||||
const poolMaterial = additive(`
|
const poolMaterial = additive(`
|
||||||
float across = exp(-pow((vUv.x - 0.5) * 3.2, 2.0));
|
float across = exp(-pow(abs(vUv.x - 0.5) * 3.2, 2.0));
|
||||||
float glow = across * pow(vUv.y, 2.2) * 0.2;`);
|
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
|
// 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
|
// 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.
|
? `// Rear window: glass reflects a lavender band of sky.
|
||||||
float glass = rear * step(1.12, vLocal.y) * step(vLocal.z, -0.9);
|
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);
|
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.
|
: `// Box doors: a centre seam and two lock bars.
|
||||||
float seam = 1.0 - smoothstep(0.02, 0.05, abs(vLocal.x));
|
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));
|
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);
|
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.
|
// 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);
|
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);`
|
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);`
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ function additive(billboard: boolean) {
|
|||||||
varying vec4 vGlow;
|
varying vec4 vGlow;
|
||||||
void main() {
|
void main() {
|
||||||
float r = 1.0 - min(1.0, length(vUv - 0.5) * 2.0);
|
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);
|
gl_FragColor = vec4(vGlow.rgb * shape * vGlow.w, 1.0);
|
||||||
#include <tonemapping_fragment>
|
#include <tonemapping_fragment>
|
||||||
#include <colorspace_fragment>
|
#include <colorspace_fragment>
|
||||||
|
|||||||
119
tests/unit/shader-pow-safety.test.ts
Normal file
119
tests/unit/shader-pow-safety.test.ts
Normal 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user