Created
March 1, 2026 21:04
-
-
Save EncodeTheCode/808d2e719c0200c8b3041e55020fc838 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <title>Three.js — Realistic Lighting + Flashlight + Anisotropy & Shadows</title> | |
| <meta name="viewport" content="width=device-width,initial-scale=1" /> | |
| <style> | |
| html,body{height:100%;margin:0;background:#0b0b0d;overflow:hidden;font-family:system-ui,Segoe UI,Roboto,Helvetica,Arial} | |
| #container{width:100vw;height:100vh;display:block;position:relative} | |
| canvas{display:block} | |
| #ui { | |
| position: absolute; left: 8px; top: 8px; color: #fff; font-family: monospace; | |
| background: rgba(0,0,0,0.38); padding:10px; border-radius:8px; font-size:13px; z-index:10; | |
| min-width:300px; | |
| } | |
| #ui small{color:#ccc; display:block; margin-top:6px} | |
| #controls { margin-top:8px; display:flex; gap:8px; align-items:center; flex-wrap:wrap } | |
| select, button, label { font-size:12px; padding:6px 8px; border-radius:6px; } | |
| .inline { display:inline-flex; gap:6px; align-items:center; } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="container"></div> | |
| <div id="ui"> | |
| <div>FPS target: <span id="fpsTarget">30</span> — actual: <span id="fpsActual">0</span></div> | |
| <div>Pos: <span id="pos">0,0,0</span></div> | |
| <div>Pitch/Yaw/Roll: <span id="angles">0/0/0</span></div> | |
| <div id="controls"> | |
| <label>FPS<select id="fpsSelect"></select></label> | |
| <label>Shadows<select id="shadowSelect"> | |
| <option value="none">None</option> | |
| <option value="2">2x</option> | |
| <option value="4">4x</option> | |
| <option value="8">8x</option> | |
| </select></label> | |
| <label>Aniso<select id="anisoSelect"> | |
| <option value="0">None</option> | |
| <option value="2">2x</option> | |
| <option value="4">4x</option> | |
| <option value="8">8x</option> | |
| </select></label> | |
| <label class="inline"><input id="flashToggle" type="checkbox" /> Flashlight</label> | |
| <button id="lockBtn">Lock Pointer</button> | |
| <button id="resetBtn">Reset Camera</button> | |
| </div> | |
| <small>W/A/S/D move, mouse look (lock or hold LMB), LMB+W = boost (follows look direction). Use shadow/aniso dropdowns to change quality.</small> | |
| </div> | |
| <!-- Three.js --> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.150.1/build/three.min.js"></script> | |
| <script> | |
| (() => { | |
| // ---------- Basic scene and config ---------- | |
| const container = document.getElementById('container'); | |
| const allowedFPS = [10,20,30,40,50,60,120,360]; | |
| let targetFPS = 30; | |
| document.getElementById('fpsTarget').innerText = targetFPS; | |
| // Player | |
| const player = { | |
| pos: new THREE.Vector3(0, 1.6, 6), | |
| velocity: new THREE.Vector3(0,0,0), | |
| radius: 0.35, | |
| speed: 2.2, | |
| accel: 8.0, | |
| damping: 8.0, | |
| maxSpeed: 6.0 | |
| }; | |
| const boostMultiplier = 2.2; | |
| // Renderer / scene / camera | |
| const renderer = new THREE.WebGLRenderer({ antialias:true }); | |
| renderer.shadowMap.enabled = true; | |
| renderer.shadowMap.type = THREE.PCFSoftShadowMap; // soft shadows by default when enabled | |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); | |
| container.appendChild(renderer.domElement); | |
| const scene = new THREE.Scene(); | |
| const camera = new THREE.PerspectiveCamera(60, 2, 0.1, 20000); | |
| // Resize | |
| function onResize(){ | |
| const w = container.clientWidth, h = container.clientHeight; | |
| renderer.setSize(w,h); | |
| camera.aspect = w/h; | |
| camera.updateProjectionMatrix(); | |
| } | |
| window.addEventListener('resize', onResize, { passive: true }); | |
| onResize(); | |
| // ---------- Sky (improved shader) ---------- | |
| const skyGeo = new THREE.SphereGeometry(10000, 36, 18); | |
| const skyMat = new THREE.ShaderMaterial({ | |
| side: THREE.BackSide, | |
| uniforms: { | |
| sunDir: { value: new THREE.Vector3(0.3, 0.9, 0.1).normalize() }, | |
| sunColor: { value: new THREE.Color(1.0, 0.95, 0.8) }, | |
| skyTop: { value: new THREE.Color(0.18,0.55,0.95) }, | |
| skyBottom: { value: new THREE.Color(0.95,0.85,0.7) }, | |
| }, | |
| vertexShader: ` | |
| varying vec3 vWorld; | |
| void main(){ | |
| vec4 worldPos = modelMatrix * vec4(position, 1.0); | |
| vWorld = normalize(worldPos.xyz); | |
| gl_Position = projectionMatrix * viewMatrix * vec4(position, 1.0); | |
| } | |
| `, | |
| fragmentShader: ` | |
| precision mediump float; | |
| varying vec3 vWorld; | |
| uniform vec3 sunDir; | |
| uniform vec3 sunColor; | |
| uniform vec3 skyTop; | |
| uniform vec3 skyBottom; | |
| void main(){ | |
| float t = pow(max(vWorld.y*0.5 + 0.5, 0.0), 0.7); | |
| vec3 sky = mix(skyBottom, skyTop, t); | |
| float sunAngle = max(dot(vWorld, normalize(sunDir)), 0.0); | |
| float sunGlow = pow(sunAngle, 200.0); | |
| float sunHalo = pow(sunAngle, 12.0) * 0.6; | |
| vec3 col = sky + sunColor * (sunGlow*1.6 + sunHalo*0.6); | |
| col *= 1.0 - pow(1.0 - t, 1.5) * 0.05; | |
| gl_FragColor = vec4(col, 1.0); | |
| } | |
| ` | |
| }); | |
| const skyMesh = new THREE.Mesh(skyGeo, skyMat); | |
| scene.add(skyMesh); | |
| // ---------- Lights ---------- | |
| // Directional (sun) for main light + shadows | |
| const dirLight = new THREE.DirectionalLight(0xffffff, 0.9); | |
| dirLight.position.set(80, 120, 40); | |
| dirLight.castShadow = true; | |
| dirLight.shadow.camera.left = -200; | |
| dirLight.shadow.camera.right = 200; | |
| dirLight.shadow.camera.top = 200; | |
| dirLight.shadow.camera.bottom = -200; | |
| dirLight.shadow.camera.near = 1; | |
| dirLight.shadow.camera.far = 500; | |
| // default shadow map size will be set by quality control | |
| scene.add(dirLight); | |
| // subtle ambient/gloom | |
| const ambientLight = new THREE.AmbientLight(0xffffff, 0.28); // a little gloom by default | |
| scene.add(ambientLight); | |
| // ---------- SVG textures 256x256 (tileable) ---------- | |
| function svgDataURI(svg){ return 'data:image/svg+xml;utf8,' + encodeURIComponent(svg); } | |
| function createTextureFromSVG(svgString){ | |
| const url = svgDataURI(svgString); | |
| const tex = new THREE.TextureLoader().load(url); | |
| tex.wrapS = tex.wrapT = THREE.RepeatWrapping; | |
| tex.anisotropy = 0; | |
| tex.needsUpdate = true; | |
| return tex; | |
| } | |
| function gravelSVG256(){ | |
| return `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256"> | |
| <rect width="256" height="256" fill="#777974"/> | |
| <g fill="#9b9b98"> | |
| <ellipse cx="32" cy="50" rx="6" ry="4"/><ellipse cx="72" cy="30" rx="4" ry="3"/> | |
| <ellipse cx="110" cy="60" rx="5" ry="3"/><ellipse cx="160" cy="20" rx="7" ry="5"/> | |
| <ellipse cx="204" cy="56" rx="5" ry="4"/><ellipse cx="230" cy="210" rx="6" ry="4"/> | |
| </g> | |
| <g fill="#b0b0ad"> | |
| <circle cx="16" cy="200" r="3"/><circle cx="48" cy="140" r="2.5"/><circle cx="96" cy="184" r="3.0"/> | |
| </g> | |
| <g fill="#9b9b97" opacity="0.95"> | |
| <ellipse cx="0" cy="60" rx="6" ry="4"/><ellipse cx="256" cy="60" rx="6" ry="4"/> | |
| <ellipse cx="130" cy="0" rx="6" ry="4"/><ellipse cx="130" cy="256" rx="6" ry="4"/> | |
| </g> | |
| </svg>`; | |
| } | |
| function brickSVG256(){ | |
| return `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256"> | |
| <rect width="256" height="256" fill="#7a3024"/> | |
| <defs> | |
| <pattern id="bricks" width="64" height="32" patternUnits="userSpaceOnUse"> | |
| <rect width="64" height="32" fill="#7a3024"/> | |
| <rect x="0" y="0" width="30" height="16" fill="#8b3d2e"/> | |
| <rect x="34" y="0" width="30" height="16" fill="#6f291f"/> | |
| <rect x="0" y="16" width="32" height="16" fill="#6f291f"/> | |
| <rect x="32" y="16" width="32" height="16" fill="#8b3d2e"/> | |
| <path d="M0 0 H64" stroke="#3c1a14" stroke-width="1"/> | |
| <path d="M0 16 H64" stroke="#3c1a14" stroke-width="1"/> | |
| <path d="M32 0 V32" stroke="#3c1a14" stroke-width="1"/> | |
| </pattern> | |
| </defs> | |
| <rect width="256" height="256" fill="url(#bricks)"/> | |
| <g opacity="0.12" fill="#000000"> | |
| <rect x="0" y="15" width="256" height="1"/><rect x="0" y="47" width="256" height="1"/> | |
| </g> | |
| </svg>`; | |
| } | |
| function crateFaceSVG(i){ | |
| const woods = ['#8b5a2b','#7a4f24','#b07a3f','#996032']; | |
| const wood = woods[i % woods.length]; | |
| const light = lightenHex(wood, 0.12); | |
| return `<svg xmlns='http://www.w3.org/2000/svg' width='256' height='256'> | |
| <defs><linearGradient id='g' x1='0' x2='0' y1='0' y2='1'><stop offset='0' stop-color='${light}'/><stop offset='1' stop-color='${wood}'/></linearGradient></defs> | |
| <rect width='256' height='256' fill='url(#g)'/> | |
| <g stroke='rgba(0,0,0,0.12)' stroke-width='3'> | |
| <path d='M10 40 L246 40' /><path d='M10 100 L246 100' /><path d='M10 160 L246 160'/> | |
| </g> | |
| <g fill='rgba(0,0,0,0.06)'><circle cx='36' cy='36' r='5'/><circle cx='220' cy='36' r='5'/></g> | |
| </svg>`; | |
| } | |
| function lightenHex(hex, amt){ | |
| const c = hex.replace('#',''); | |
| const r = clamp(Math.round(parseInt(c.substr(0,2),16) + 255*amt),0,255); | |
| const g = clamp(Math.round(parseInt(c.substr(2,2),16) + 255*amt),0,255); | |
| const b = clamp(Math.round(parseInt(c.substr(4,2),16) + 255*amt),0,255); | |
| return '#'+((1<<24)+(r<<16)+(g<<8)+b).toString(16).slice(1); | |
| } | |
| function clamp(v,a,b){ return Math.max(a, Math.min(b,v)); } | |
| // Create textures (256x256) | |
| const textures = { | |
| gravel: createTextureFromSVG(gravelSVG256()), | |
| brick: createTextureFromSVG(brickSVG256()), | |
| crate0: createTextureFromSVG(crateFaceSVG(0)), | |
| crate1: createTextureFromSVG(crateFaceSVG(1)), | |
| crate2: createTextureFromSVG(crateFaceSVG(2)), | |
| crate3: createTextureFromSVG(crateFaceSVG(3)) | |
| }; | |
| // ---------- Map bounds (large padding) ---------- | |
| const levelHalfSize = 10; | |
| const padding = 320; // requested | |
| const bounds = { | |
| min: new THREE.Vector3(-levelHalfSize - padding, 0, -levelHalfSize - padding), | |
| max: new THREE.Vector3(levelHalfSize + padding, 24, levelHalfSize + padding) | |
| }; | |
| // Floor | |
| const spanX = bounds.max.x - bounds.min.x; | |
| const spanZ = bounds.max.z - bounds.min.z; | |
| const floorGeo = new THREE.PlaneGeometry(spanX, spanZ); | |
| const floorMat = new THREE.MeshStandardMaterial({ map: textures.gravel, roughness: 1.0, metalness: 0 }); | |
| const tileWorldSize = 4.0; | |
| textures.gravel.repeat.set(spanX / tileWorldSize, spanZ / tileWorldSize); | |
| textures.gravel.needsUpdate = true; | |
| const floor = new THREE.Mesh(floorGeo, floorMat); | |
| floor.rotation.x = -Math.PI/2; | |
| floor.position.set((bounds.min.x + bounds.max.x)/2, 0, (bounds.min.z + bounds.max.z)/2); | |
| floor.receiveShadow = true; | |
| scene.add(floor); | |
| // ---------- Walls & collision ---------- | |
| const collisionBoxes = []; | |
| function addWallBox(minVec, maxVec, material){ | |
| const sx = maxVec.x - minVec.x, sy = maxVec.y - minVec.y, sz = maxVec.z - minVec.z; | |
| const geo = new THREE.BoxGeometry(sx, sy, sz); | |
| const mat = material || new THREE.MeshStandardMaterial({ map: textures.brick, roughness:0.9, metalness:0 }); | |
| const mesh = new THREE.Mesh(geo, mat); | |
| mesh.position.set((minVec.x+maxVec.x)/2, (minVec.y+maxVec.y)/2, (minVec.z+maxVec.z)/2); | |
| mesh.castShadow = true; | |
| mesh.receiveShadow = true; | |
| scene.add(mesh); | |
| if (mat.map){ | |
| mat.map.repeat.set(sx / tileWorldSize, sy / tileWorldSize); | |
| mat.map.needsUpdate = true; | |
| } | |
| const box = new THREE.Box3().setFromObject(mesh); | |
| collisionBoxes.push(box); | |
| return mesh; | |
| } | |
| const wallThickness = 2.0; | |
| addWallBox(new THREE.Vector3(bounds.min.x - wallThickness, 0, bounds.min.z - wallThickness), new THREE.Vector3(bounds.max.x + wallThickness, 18, bounds.min.z)); | |
| addWallBox(new THREE.Vector3(bounds.min.x - wallThickness, 0, bounds.max.z), new THREE.Vector3(bounds.max.x + wallThickness, 18, bounds.max.z + wallThickness)); | |
| addWallBox(new THREE.Vector3(bounds.min.x - wallThickness, 0, bounds.min.z), new THREE.Vector3(bounds.min.x, 18, bounds.max.z)); | |
| addWallBox(new THREE.Vector3(bounds.max.x, 0, bounds.min.z), new THREE.Vector3(bounds.max.x + wallThickness, 18, bounds.max.z)); | |
| addWallBox(new THREE.Vector3(-6,0,-2), new THREE.Vector3(6,3,-0.3)); // internal | |
| // red rim visual | |
| const rimMat = new THREE.MeshBasicMaterial({ color: 0xff0f0f }); | |
| function addRim(min, max){ | |
| const sx = max.x - min.x, sy = max.y - min.y, sz = max.z - min.z; | |
| const g = new THREE.BoxGeometry(sx, sy, sz); | |
| const m = new THREE.Mesh(g, rimMat); | |
| m.position.set((min.x+max.x)/2, (min.y+max.y)/2, (min.z+max.z)/2); | |
| scene.add(m); | |
| } | |
| const rimH = 0.08; | |
| addRim(new THREE.Vector3(bounds.min.x, 0.02, bounds.min.z - 0.06), new THREE.Vector3(bounds.max.x, rimH, bounds.min.z + 0.06)); | |
| addRim(new THREE.Vector3(bounds.min.x, 0.02, bounds.max.z - 0.06), new THREE.Vector3(bounds.max.x, rimH, bounds.max.z + 0.06)); | |
| addRim(new THREE.Vector3(bounds.min.x - 0.06, 0.02, bounds.min.z), new THREE.Vector3(bounds.min.x + 0.06, rimH, bounds.max.z)); | |
| addRim(new THREE.Vector3(bounds.max.x - 0.06, 0.02, bounds.min.z), new THREE.Vector3(bounds.max.x + 0.06, rimH, bounds.max.z)); | |
| // crate with per-face textures | |
| const crateGeo = new THREE.BoxGeometry(1.4,1.4,1.4); | |
| const crateMats = [ | |
| new THREE.MeshStandardMaterial({ map: textures.crate0 }), | |
| new THREE.MeshStandardMaterial({ map: textures.crate1 }), | |
| new THREE.MeshStandardMaterial({ map: textures.crate2 }), | |
| new THREE.MeshStandardMaterial({ map: textures.crate3 }), | |
| new THREE.MeshStandardMaterial({ map: textures.crate0 }), | |
| new THREE.MeshStandardMaterial({ map: textures.crate1 }), | |
| ]; | |
| const crate = new THREE.Mesh(crateGeo, crateMats); | |
| crate.position.set(0, 0.7, 0); | |
| crate.castShadow = true; | |
| crate.receiveShadow = true; | |
| scene.add(crate); | |
| collisionBoxes.push(new THREE.Box3().setFromObject(crate)); | |
| // ---------- Flashlight (SpotLight + glow sprite) ---------- | |
| const flashlight = new THREE.SpotLight(0xfff8e0, 0.0, 40, Math.PI/8, 0.45, 2.0); | |
| // Start intensity 0 (off). It will be toggled via UI. | |
| flashlight.castShadow = true; | |
| flashlight.shadow.mapSize.set(1024, 1024); | |
| flashlight.shadow.radius = 2; | |
| flashlight.penumbra = 0.6; | |
| scene.add(flashlight); | |
| // flashlight target object, we'll set its position each frame | |
| const flashTarget = new THREE.Object3D(); | |
| scene.add(flashTarget); | |
| flashlight.target = flashTarget; | |
| // glow sprite (radial gradient on canvas) | |
| function createGlowSpriteTexture(size=256){ | |
| const c = document.createElement('canvas'); c.width = c.height = size; | |
| const ctx = c.getContext('2d'); | |
| const g = ctx.createRadialGradient(size/2, size/2, 0, size/2, size/2, size/2); | |
| g.addColorStop(0, 'rgba(255,244,220,0.95)'); | |
| g.addColorStop(0.2, 'rgba(255,230,180,0.6)'); | |
| g.addColorStop(0.4, 'rgba(255,200,120,0.22)'); | |
| g.addColorStop(1, 'rgba(0,0,0,0)'); | |
| ctx.fillStyle = g; | |
| ctx.fillRect(0,0,size,size); | |
| const tex = new THREE.CanvasTexture(c); | |
| tex.needsUpdate = true; | |
| return tex; | |
| } | |
| const glowTex = createGlowSpriteTexture(256); | |
| const glowMat = new THREE.SpriteMaterial({ map: glowTex, color: 0xffffff, transparent:true, blending: THREE.AdditiveBlending, depthTest:false, opacity: 0.85 }); | |
| const glowSprite = new THREE.Sprite(glowMat); | |
| glowSprite.scale.set(0.001,0.001,0.001); // start tiny | |
| scene.add(glowSprite); | |
| // ---------- Input & Controls ---------- | |
| let keys = {}; | |
| let lmb = false; | |
| let pointerLocked = false; | |
| document.addEventListener('keydown', (e) => { keys[e.key.toLowerCase()] = true; }); | |
| document.addEventListener('keyup', (e) => { keys[e.key.toLowerCase()] = false; }); | |
| const dom = renderer.domElement; | |
| function requestPointerLock(){ dom.requestPointerLock?.(); } | |
| document.getElementById('lockBtn').addEventListener('click', requestPointerLock, {passive:true}); | |
| document.addEventListener('pointerlockchange', () => { pointerLocked = document.pointerLockElement === dom; }); | |
| let yaw = 0, pitch = 0, roll = 0; | |
| const pitchLimit = Math.PI/2 - 0.01; | |
| dom.addEventListener('mousedown', (e) => { if (e.button === 0) lmb = true; if (!pointerLocked) dom.requestPointerLock?.(); }); | |
| document.addEventListener('mouseup', (e) => { if (e.button === 0) lmb = false; }); | |
| document.addEventListener('mousemove', (e) => { | |
| const mx = pointerLocked ? e.movementX : (lmb ? e.movementX : 0); | |
| const my = pointerLocked ? e.movementY : (lmb ? e.movementY : 0); | |
| if (mx || my){ | |
| const sens = 0.0024; | |
| yaw -= mx * sens; | |
| pitch -= my * sens; | |
| pitch = clamp(pitch, -pitchLimit, pitchLimit); | |
| } | |
| }, { passive: true }); | |
| document.getElementById('resetBtn').addEventListener('click', () => { | |
| player.pos.set(0,1.6,6); player.velocity.set(0,0,0); yaw=0; pitch=0; roll=0; | |
| }, { passive:true }); | |
| // ---------- Raycaster (center-screen) ---------- | |
| const raycaster = new THREE.Raycaster(); | |
| const centerNDC = new THREE.Vector2(0,0); // center of screen | |
| // ---------- Movement & collision ---------- | |
| function moveWithCollisions(pos, delta, radius){ | |
| let next = pos.clone().add(delta); | |
| let correctedDelta = delta.clone(); | |
| for (let i=0;i<collisionBoxes.length;i++){ | |
| const box = collisionBoxes[i]; | |
| const closest = new THREE.Vector3( | |
| clamp(next.x, box.min.x, box.max.x), | |
| clamp(next.y, box.min.y, box.max.y), | |
| clamp(next.z, box.min.z, box.max.z) | |
| ); | |
| const diff = next.clone().sub(closest); | |
| const dist2 = diff.lengthSq(); | |
| const r2 = radius * radius; | |
| if (dist2 < r2){ | |
| const dist = Math.sqrt(dist2) || 0.0001; | |
| const n = diff.clone().divideScalar(dist); | |
| const push = radius - dist + 0.0001; | |
| next.add(n.clone().multiplyScalar(push)); | |
| const vdotn = correctedDelta.dot(n); | |
| const projected = n.clone().multiplyScalar(vdotn); | |
| correctedDelta.sub(projected); | |
| } | |
| } | |
| // clamp to bounds | |
| next.x = clamp(next.x, bounds.min.x + radius, bounds.max.x - radius); | |
| next.y = clamp(next.y, bounds.min.y + radius, bounds.max.y - radius); | |
| next.z = clamp(next.z, bounds.min.z + radius, bounds.max.z - radius); | |
| return { next, delta: correctedDelta }; | |
| } | |
| // ---------- Shadow quality & anisotropy controls ---------- | |
| const shadowSelect = document.getElementById('shadowSelect'); | |
| const anisoSelect = document.getElementById('anisoSelect'); | |
| function applyAnisotropy(level){ | |
| const a = parseInt(level, 10) || 0; | |
| for (const k in textures){ | |
| if (textures[k] && textures[k].isTexture){ | |
| textures[k].anisotropy = a; | |
| textures[k].needsUpdate = true; | |
| } | |
| } | |
| } | |
| const baseShadowSize = 1024; // baseline | |
| function applyShadowQuality(quality){ | |
| if (quality === 'none'){ | |
| renderer.shadowMap.enabled = false; | |
| dirLight.castShadow = false; | |
| flashlight.castShadow = false; | |
| } else { | |
| renderer.shadowMap.enabled = true; | |
| dirLight.castShadow = true; | |
| flashlight.castShadow = true; | |
| const mult = parseInt(quality,10) || 1; | |
| const size = clamp(baseShadowSize * mult, 256, 4096); | |
| dirLight.shadow.mapSize.set(size, size); | |
| flashlight.shadow.mapSize.set(size, size); | |
| // adjust directional shadow camera extents slightly based on size | |
| const extent = 200; | |
| dirLight.shadow.camera.left = -extent; dirLight.shadow.camera.right = extent; | |
| dirLight.shadow.camera.top = extent; dirLight.shadow.camera.bottom = -extent; | |
| dirLight.shadow.camera.updateProjectionMatrix(); | |
| } | |
| } | |
| // initial UI wiring | |
| // populate FPS select | |
| const fpsSelect = document.getElementById('fpsSelect'); | |
| allowedFPS.forEach(v => { const o = document.createElement('option'); o.value = v; o.text = v; fpsSelect.appendChild(o); }); | |
| fpsSelect.value = targetFPS; | |
| fpsSelect.addEventListener('change', (e) => { setTargetFPS(parseInt(e.target.value)); }, {passive:true}); | |
| // shadow select | |
| shadowSelect.value = '4'; | |
| shadowSelect.addEventListener('change', (e) => { applyShadowQuality(e.target.value); }, {passive:true}); | |
| // anisotropy | |
| anisoSelect.value = '4'; | |
| anisoSelect.addEventListener('change', (e) => { applyAnisotropy(e.target.value); }, {passive:true}); | |
| // flashlight toggle | |
| const flashToggle = document.getElementById('flashToggle'); | |
| flashToggle.addEventListener('change', (e) => { | |
| flashlight.intensity = e.target.checked ? 2.2 : 0.0; | |
| glowSprite.visible = !!e.target.checked; | |
| }, {passive:true}); | |
| // set defaults | |
| applyAnisotropy(4); | |
| applyShadowQuality('4'); | |
| // ---------- Main loop (FPS limiter) ---------- | |
| let last = performance.now(); | |
| let acc = 0; | |
| let frameInterval = 1000 / targetFPS; | |
| function setTargetFPS(v){ | |
| if (allowedFPS.includes(v)){ targetFPS = v; document.getElementById('fpsTarget').innerText = targetFPS; fpsSelect.value = v; frameInterval = 1000 / targetFPS; } | |
| } | |
| let fpsCounter = 0, fpsTimer = 0; | |
| function animate(now){ | |
| const dtMs = now - last; last = now; | |
| acc += dtMs; | |
| fpsCounter++; fpsTimer += dtMs; | |
| if (fpsTimer >= 500){ | |
| const fpsActual = Math.round((fpsCounter / fpsTimer) * 1000); | |
| document.getElementById('fpsActual').innerText = fpsActual; | |
| fpsCounter = 0; fpsTimer = 0; | |
| } | |
| if (acc >= frameInterval - 0.0001){ | |
| const dt = acc / 1000; | |
| update(dt); | |
| render(); | |
| acc = 0; | |
| } | |
| requestAnimationFrame(animate); | |
| } | |
| // ---------- Update (input, physics, flashlight raycast) ---------- | |
| function update(dt){ | |
| // inputs | |
| const forwardInput = (keys['w']?1:0) - (keys['s']?1:0); | |
| const rightInput = (keys['d']?1:0) - (keys['a']?1:0); | |
| const upInput = (keys[' ']?1:0) - (keys['c']?1:0); | |
| if (keys['q']) roll += dt * 0.8; | |
| if (keys['e']) roll -= dt * 0.8; | |
| // camera orientation | |
| const euler = new THREE.Euler(pitch, yaw, roll, 'YXZ'); | |
| // desired local vector incl. vertical based on pitch | |
| let desiredLocal = new THREE.Vector3(rightInput, 0, -forwardInput); | |
| if (desiredLocal.lengthSq() > 0) desiredLocal.normalize(); | |
| desiredLocal.applyEuler(euler); | |
| // target speed with boost (LMB + W follows look direction) | |
| let targetSpeed = player.speed; | |
| if (forwardInput > 0 && lmb && keys['w']) targetSpeed *= boostMultiplier; | |
| const targetVel = new THREE.Vector3( | |
| desiredLocal.x * targetSpeed, | |
| desiredLocal.y * targetSpeed + upInput * targetSpeed, | |
| desiredLocal.z * targetSpeed | |
| ); | |
| // accelerate toward targetVel | |
| player.velocity.x += (targetVel.x - player.velocity.x) * clamp(dt * player.accel, 0, 1); | |
| player.velocity.y += (targetVel.y - player.velocity.y) * clamp(dt * player.accel, 0, 1); | |
| player.velocity.z += (targetVel.z - player.velocity.z) * clamp(dt * player.accel, 0, 1); | |
| // damping | |
| player.velocity.multiplyScalar(Math.exp(-player.damping * dt)); | |
| // clamp horizontal speed | |
| const horiz = Math.sqrt(player.velocity.x*player.velocity.x + player.velocity.z*player.velocity.z); | |
| if (horiz > player.maxSpeed){ | |
| const s = player.maxSpeed / horiz; | |
| player.velocity.x *= s; player.velocity.z *= s; | |
| } | |
| // integrate with collisions | |
| const delta = player.velocity.clone().multiplyScalar(dt); | |
| const result = moveWithCollisions(player.pos, delta, player.radius); | |
| player.pos.copy(result.next); | |
| // update velocity to match actual movement | |
| player.velocity.copy(result.delta.clone().divideScalar(Math.max(dt, 1e-6))); | |
| // update camera from player | |
| camera.position.copy(player.pos); | |
| camera.rotation.copy(euler); | |
| // keep sky centered | |
| skyMesh.position.copy(camera.position); | |
| // FLASHLIGHT: cast a ray from camera center to hit surfaces | |
| if (flashToggle.checked){ | |
| raycaster.setFromCamera(centerNDC, camera); | |
| const intersects = raycaster.intersectObjects(scene.children, true); | |
| // find first intersect that is floor/wall/crate (skip sky, helper objects) | |
| let hit = null; | |
| for (let i=0;i<intersects.length;i++){ | |
| const it = intersects[i]; | |
| if (!it.object || it.object === glowSprite) continue; | |
| // skip sky (huge sphere) by distance or name | |
| if (it.object === skyMesh) continue; | |
| hit = it; | |
| break; | |
| } | |
| if (hit){ | |
| const hitPos = hit.point; | |
| // position target & light | |
| flashTarget.position.copy(hitPos); | |
| flashlight.position.copy(camera.position); | |
| flashlight.target.position.copy(hitPos); | |
| // intensity falloff by distance | |
| const dist = camera.position.distanceTo(hitPos); | |
| flashlight.distance = Math.max(20, dist + 10); | |
| flashlight.intensity = 2.2; | |
| // glow sprite at hit point (offset outward by normal a little) | |
| const normal = hit.face ? hit.face.normal.clone().applyMatrix3(new THREE.Matrix3().getNormalMatrix(hit.object.matrixWorld)).normalize() : new THREE.Vector3(); | |
| const spritePos = hitPos.clone().add(normal.multiplyScalar(0.02 + player.radius*0.05)); | |
| glowSprite.position.copy(spritePos); | |
| // scale sprite by distance and angle (larger when close) | |
| const scale = clamp(0.8 * (1.0 / Math.max(0.08, dist/6)), 0.3, 6.0); | |
| glowSprite.scale.set(scale, scale, 1); | |
| glowSprite.material.opacity = clamp(1.0 - (dist/60), 0.12, 0.95); | |
| glowSprite.visible = true; | |
| } else { | |
| // no hit — point far ahead | |
| const dir = new THREE.Vector3(0,0,-1).applyEuler(euler).normalize(); | |
| const far = camera.position.clone().add(dir.multiplyScalar(30)); | |
| flashTarget.position.copy(far); | |
| flashlight.position.copy(camera.position); | |
| flashlight.target.position.copy(far); | |
| glowSprite.position.copy(far); | |
| glowSprite.scale.set(0.6,0.6,1); | |
| glowSprite.material.opacity = 0.35; | |
| glowSprite.visible = true; | |
| flashlight.intensity = 1.4; | |
| } | |
| } else { | |
| flashlight.intensity = 0.0; | |
| glowSprite.visible = false; | |
| } | |
| // update UI | |
| document.getElementById('pos').innerText = `${player.pos.x.toFixed(2)}, ${player.pos.y.toFixed(2)}, ${player.pos.z.toFixed(2)}`; | |
| document.getElementById('angles').innerText = `${pitch.toFixed(2)} / ${yaw.toFixed(2)} / ${roll.toFixed(2)}`; | |
| } | |
| // ---------- Render ---------- | |
| function render(){ | |
| renderer.render(scene, camera); | |
| } | |
| // ---------- Start ---------- | |
| last = performance.now(); | |
| requestAnimationFrame(animate); | |
| // ---------- Expose API ---------- | |
| window.__demo = { | |
| player, camera, setTargetFPS: (v)=>{ if (allowedFPS.includes(v)) { targetFPS=v; document.getElementById('fpsTarget').innerText=v; frameInterval = 1000/v; document.getElementById('fpsSelect').value=v; } }, | |
| setAnisotropy: (a)=>{ anisoSelect.value = String(a); applyAnisotropy(a); }, | |
| setShadowQuality: (q)=>{ shadowSelect.value = String(q); applyShadowQuality(String(q)); }, | |
| textures, scene, renderer | |
| }; | |
| // small helpers | |
| function clamp(v,a,b){ return Math.max(a, Math.min(b,v)); } | |
| // UI default: set FPS select | |
| const fpsSelectEl = document.getElementById('fpsSelect'); | |
| allowedFPS.forEach(v => { const o = document.createElement('option'); o.value = v; o.text = v; fpsSelectEl.appendChild(o); }); | |
| fpsSelectEl.value = targetFPS; | |
| // small console note | |
| console.log('Demo with shadows & flashlight loaded. Use window.__demo.setAnisotropy(8) or setShadowQuality("8") to change quality.'); | |
| })(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment