| |
| |
| |
| import * as THREE from 'three'; |
|
|
| |
| |
| |
| |
| 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) }, |
| uHigh: { value: new THREE.Color(0x452a5e) }, |
| uRose: { value: new THREE.Color(0xb84a6c) }, |
| uHorizon: { value: new THREE.Color(0xff9e50) }, |
| uSunCore: { value: new THREE.Color(0xfff3d0) }, |
| uSunHalo: { value: new THREE.Color(0xffb36b) }, |
| uSunDir: { value: SUN_DIR }, |
| }, |
| 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) { |
| |
| scene.add(new THREE.AmbientLight(0x8c7a80, 0.34)); |
| scene.add(new THREE.HemisphereLight(0xb58aa8, 0x5c3d28, 0.58)); |
|
|
| const sun = new THREE.DirectionalLight(0xffb36b, 1.45); |
| sun.position.copy(SUN_OFFSET); |
| sun.castShadow = true; |
| sun.shadow.mapSize.set(2048, 2048); |
| sun.shadow.bias = -0.0002; |
| sun.shadow.normalBias = 0.8; |
| 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); |
| fill.position.set(-40, 30, -60); |
| scene.add(fill); |
|
|
| return { sun }; |
| } |
|
|
| export function createGround(scene, renderer) { |
| |
| |
| const texSize = 512; |
| const texCanvas = document.createElement('canvas'); |
| texCanvas.width = texCanvas.height = texSize; |
| const ctx = texCanvas.getContext('2d'); |
|
|
| |
| ctx.fillStyle = '#626d36'; |
| ctx.fillRect(0, 0, texSize, texSize); |
|
|
| |
| 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); |
|
|
| |
| 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(); |
| } |
|
|
| |
| 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(); |
| } |
|
|
| |
| 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(); |
| } |
|
|
| |
| 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); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function createQualityGovernor(renderer) { |
| const maxPR = Math.min(window.devicePixelRatio || 1, 2); |
| |
| 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; |
| let aboveTimer = 0; |
| let sinceChange = 99; |
| let downgraded = false; |
|
|
| function update(dt) { |
| if (dt <= 0 || dt > 0.25) return; |
| 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; } |
|
|
| |
| |
| 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 }; |
| } |
|
|