sunset-racing-ultracode / js /environment.js
Mike0021's picture
Fix driving camera jolts, DPR oscillation, and shadow crawl
1139202 verified
Raw
History Blame Contribute Delete
9.97 kB
// ═══════════════════════════════════════════════════════
// 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 };
}