// ═══════════════════════════════════════════════════════ // ENVIRONMENT — sky dome, lights, ground, quality governor // ═══════════════════════════════════════════════════════ import * as THREE from 'three'; // Sun offset used by game.js call sites: sun.position = car + (60, 26, 40). // The visual sun in the sky shader MUST match this direction so the disc, // halo and lighting agree. (Integrator: if you change the offset in game.js, // update this vector to the same values.) const SUN_OFFSET = new THREE.Vector3(60, 26, 40); const SUN_DIR = SUN_OFFSET.clone().normalize(); export function createSky(scene) { const skyGeo = new THREE.SphereGeometry(400, 32, 32); const skyMat = new THREE.ShaderMaterial({ side: THREE.BackSide, depthWrite: false, uniforms: { uZenith: { value: new THREE.Color(0x141236) }, // deep indigo zenith uHigh: { value: new THREE.Color(0x452a5e) }, // purple upper sky uRose: { value: new THREE.Color(0xb84a6c) }, // magenta / rose band uHorizon: { value: new THREE.Color(0xff9e50) }, // hot amber horizon uSunCore: { value: new THREE.Color(0xfff3d0) }, // bright sun core uSunHalo: { value: new THREE.Color(0xffb36b) }, // golden halo uSunDir: { value: SUN_DIR }, // matches directional light offset }, vertexShader: ` varying vec3 vWorldPos; void main() { vWorldPos = (modelMatrix * vec4(position, 1.0)).xyz; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `, fragmentShader: ` uniform vec3 uZenith, uHigh, uRose, uHorizon, uSunCore, uSunHalo; uniform vec3 uSunDir; varying vec3 vWorldPos; // Cheap hash / value noise for clouds + dither float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); } float vnoise(vec2 p) { vec2 i = floor(p), f = fract(p); vec2 u = f * f * (3.0 - 2.0 * f); return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y); } void main() { vec3 dir = normalize(vWorldPos); float y = dir.y; // ── Multi-stop sunset gradient: amber horizon → rose → purple → indigo ── vec3 col = uHorizon; col = mix(col, uRose, smoothstep(0.02, 0.16, y)); col = mix(col, uHigh, smoothstep(0.14, 0.40, y)); col = mix(col, uZenith, smoothstep(0.38, 0.85, y)); // Below the horizon: sink into a dark warm dusk tone col = mix(vec3(0.16, 0.10, 0.12), col, smoothstep(-0.15, 0.0, y)); float sunDot = max(dot(dir, uSunDir), 0.0); // ── Sun: bright core disc + layered halo ── float disc = smoothstep(0.9993, 0.99975, sunDot); col += uSunCore * disc * 3.0; // hot core col += uSunHalo * pow(sunDot, 350.0) * 1.6; // tight inner halo col += uSunHalo * pow(sunDot, 24.0) * 0.5; // wide golden glow // Warm the whole horizon band on the sun's azimuth side float horizBand = 1.0 - smoothstep(0.0, 0.25, abs(y - 0.03)); col += uHorizon * pow(sunDot, 3.0) * horizBand * 0.35; // ── Procedural clouds: 2 octaves of value noise on a curved horizon band ── float band = smoothstep(0.02, 0.07, y) * (1.0 - smoothstep(0.16, 0.34, y)); vec2 cuv = dir.xz / (y + 0.18); // project onto a shallow cloud plane float n = vnoise(cuv * 1.4) * 0.65 + vnoise(cuv * 3.1 + 17.3) * 0.35; float cl = smoothstep(0.52, 0.78, n) * band; // Rose/amber on the sun side, purple-gray away from it vec3 cloudCol = mix(vec3(0.45, 0.32, 0.42), vec3(1.05, 0.62, 0.38), pow(sunDot, 2.0)); col = mix(col, cloudCol, cl * 0.55); // ── Subtle dither to prevent gradient banding ── col += (hash(gl_FragCoord.xy) - 0.5) * (1.5 / 255.0); gl_FragColor = vec4(col, 1.0); } `, }); scene.add(new THREE.Mesh(skyGeo, skyMat)); } export function createLights(scene) { // Sunset rig: warm golden key, purple-shadow bounce, subtle cool fill. scene.add(new THREE.AmbientLight(0x8c7a80, 0.34)); // dim mauve-gray lift scene.add(new THREE.HemisphereLight(0xb58aa8, 0x5c3d28, 0.58)); // rose-indigo sky / warm brown ground const sun = new THREE.DirectionalLight(0xffb36b, 1.45); // golden amber key sun.position.copy(SUN_OFFSET); // game.js re-aims this at the car every frame sun.castShadow = true; sun.shadow.mapSize.set(2048, 2048); sun.shadow.bias = -0.0002; sun.shadow.normalBias = 0.8; // kills road acne without peter-panning const sc = sun.shadow.camera; sc.left = sc.bottom = -130; sc.right = sc.top = 130; sc.near = 1; sc.far = 300; scene.add(sun); scene.add(sun.target); const fill = new THREE.DirectionalLight(0x7070b0, 0.18); // cool violet fill, kept subtle fill.position.set(-40, 30, -60); scene.add(fill); return { sun }; } export function createGround(scene, renderer) { // Warm olive-meadow grass with two-scale mottling, dry straw patches, // and dirt speckle — tuned for golden-hour light. const texSize = 512; const texCanvas = document.createElement('canvas'); texCanvas.width = texCanvas.height = texSize; const ctx = texCanvas.getContext('2d'); // Base: warm olive meadow ctx.fillStyle = '#626d36'; ctx.fillRect(0, 0, texSize, texSize); // Fine per-pixel noise (warm-weighted) const imgData = ctx.getImageData(0, 0, texSize, texSize); const d = imgData.data; for (let i = 0; i < d.length; i += 4) { const n = (Math.random() - 0.5) * 26; d[i] = Math.max(0, Math.min(255, d[i] + n * 1.0)); d[i + 1] = Math.max(0, Math.min(255, d[i + 1] + n * 0.9)); d[i + 2] = Math.max(0, Math.min(255, d[i + 2] + n * 0.5)); } ctx.putImageData(imgData, 0, 0); // Large-scale mottling: soft olive/dry blobs (low alpha, big radius) for (let i = 0; i < 42; i++) { const x = Math.random() * texSize, y = Math.random() * texSize; const r = 25 + Math.random() * 45; ctx.fillStyle = Math.random() > 0.5 ? 'rgba(87, 99, 46, 0.28)' : 'rgba(125, 122, 62, 0.24)'; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); } // Small-scale mottling: tighter clumps for close-up texture for (let i = 0; i < 260; i++) { const x = Math.random() * texSize, y = Math.random() * texSize; const r = 2 + Math.random() * 6; ctx.fillStyle = Math.random() > 0.5 ? 'rgba(74, 88, 38, 0.5)' : 'rgba(139, 130, 66, 0.45)'; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); } // Dry straw patches — sun-cured grass for (let i = 0; i < 26; i++) { const x = Math.random() * texSize, y = Math.random() * texSize; const r = 7 + Math.random() * 14; ctx.fillStyle = Math.random() > 0.5 ? 'rgba(163, 144, 76, 0.35)' : 'rgba(179, 154, 82, 0.3)'; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); } // Dirt speckle: tiny dark warm-brown dots for (let i = 0; i < 700; i++) { const x = Math.random() * texSize, y = Math.random() * texSize; ctx.fillStyle = Math.random() > 0.5 ? 'rgba(90, 70, 48, 0.5)' : 'rgba(110, 88, 54, 0.45)'; ctx.fillRect(x, y, 1.5, 1.5); } const grassTex = new THREE.CanvasTexture(texCanvas); grassTex.wrapS = grassTex.wrapT = THREE.RepeatWrapping; grassTex.repeat.set(40, 40); grassTex.anisotropy = renderer.capabilities.getMaxAnisotropy(); const ground = new THREE.Mesh( new THREE.PlaneGeometry(800, 800), new THREE.MeshLambertMaterial({ map: grassTex }) ); ground.rotation.x = -Math.PI / 2; ground.position.y = -0.1; ground.receiveShadow = true; scene.add(ground); } // ── Adaptive resolution: steps pixelRatio to hold ~50+ fps ── // Integrator: call governor.update(dt) once per frame from the game loop. // RATCHET policy: once the governor has downgraded, it never upgrades // again this session. Every setPixelRatio() is a visible full-canvas // resize flicker; a down→up→down cycle on boundary hardware produced // periodic blur pops while driving (regression-covered by // test/governor.test.mjs). export function createQualityGovernor(renderer) { const maxPR = Math.min(window.devicePixelRatio || 1, 2); // 3–4 discrete steps, always ending at the native (capped) ratio const steps = [...new Set([1.0, 1.25, 1.5, maxPR].filter(v => v <= maxPR))].sort((a, b) => a - b); let idx = steps.length - 1; let smoothFps = 60; let belowTimer = 0; // consecutive seconds under 45 fps let aboveTimer = 0; // consecutive seconds over 55 fps let sinceChange = 99; // seconds since last pixelRatio change let downgraded = false; // once true, never upgrade again (ratchet) function update(dt) { if (dt <= 0 || dt > 0.25) return; // ignore tab-switch spikes sinceChange += dt; smoothFps += (1 / dt - smoothFps) * 0.05; if (smoothFps < 45) { belowTimer += dt; aboveTimer = 0; } else if (smoothFps > 55) { aboveTimer += dt; belowTimer = 0; } else { belowTimer = 0; aboveTimer = 0; } // Hysteresis: downgrade only after ~2s of sustained low fps, // upgrade only after ~6s of headroom, never more often than every 2s. if (belowTimer > 2 && sinceChange > 2 && idx > 0) { idx--; downgraded = true; renderer.setPixelRatio(steps[idx]); belowTimer = 0; aboveTimer = 0; sinceChange = 0; } else if (!downgraded && aboveTimer > 6 && sinceChange > 2 && idx < steps.length - 1) { idx++; renderer.setPixelRatio(steps[idx]); belowTimer = 0; aboveTimer = 0; sinceChange = 0; } } return { update }; }