Created
March 1, 2026 21:03
-
-
Save EncodeTheCode/95fa46cb1eb09e6e1cc7de858e909ea4 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 FPS Flashlight + PBR Demo</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> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.150.1/build/three.min.js"></script> | |
| <script> | |
| (() => { | |
| const container = document.getElementById('container'); | |
| const allowedFPS = [10,20,30,40,50,60,120,360]; | |
| let targetFPS = 30; | |
| document.getElementById('fpsTarget').innerText = targetFPS; | |
| const player = { pos:new THREE.Vector3(0,1.6,6), velocity:new THREE.Vector3(), radius:0.35, speed:2.2, accel:8, damping:8, maxSpeed:6 }; | |
| const boostMultiplier = 2.2; | |
| const renderer = new THREE.WebGLRenderer({ antialias:true }); | |
| renderer.shadowMap.enabled = true; | |
| renderer.shadowMap.type = THREE.PCFSoftShadowMap; | |
| 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); | |
| 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 | |
| const skyGeo = new THREE.SphereGeometry(10000,36,18); | |
| const skyMat = new THREE.MeshBasicMaterial({ color:0x87ceeb, side:THREE.BackSide }); | |
| const skyMesh = new THREE.Mesh(skyGeo, skyMat); | |
| scene.add(skyMesh); | |
| // Lights | |
| 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; | |
| scene.add(dirLight); | |
| const ambientLight = new THREE.AmbientLight(0xffffff,0.28); | |
| scene.add(ambientLight); | |
| // SVG textures | |
| function svgDataURI(svg){return 'data:image/svg+xml;utf8,'+encodeURIComponent(svg);} | |
| function createTextureFromSVG(svg){const tex=new THREE.TextureLoader().load(svgDataURI(svg)); tex.wrapS=tex.wrapT=THREE.RepeatWrapping; tex.anisotropy=0; tex.needsUpdate=true; return tex;} | |
| function gravelSVG(){return `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="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></svg>`;} | |
| function brickSVG(){return `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256"><rect width="256" height="256" fill="#7a3024"/><g fill="#8b3d2e"><rect x="0" y="0" width="64" height="32"/><rect x="0" y="32" width="64" height="32"/></g></svg>`;} | |
| function crateFaceSVG(i){const colors=['#8b5a2b','#7a4f24','#b07a3f','#996032']; const wood=colors[i%4]; return `<svg xmlns='http://www.w3.org/2000/svg' width='256' height='256'><rect width='256' height='256' fill='${wood}'/></svg>`;} | |
| const textures = { | |
| gravel:createTextureFromSVG(gravelSVG()), | |
| brick:createTextureFromSVG(brickSVG()), | |
| crate0:createTextureFromSVG(crateFaceSVG(0)), | |
| crate1:createTextureFromSVG(crateFaceSVG(1)), | |
| crate2:createTextureFromSVG(crateFaceSVG(2)), | |
| crate3:createTextureFromSVG(crateFaceSVG(3)) | |
| }; | |
| // ---------- Map bounds ---------- | |
| const levelHalf=10, padding=320; | |
| const bounds={min:new THREE.Vector3(-levelHalf-padding,0,-levelHalf-padding), max:new THREE.Vector3(levelHalf+padding,24,levelHalf+padding)}; | |
| const collisionBoxes=[]; | |
| // Floor | |
| const floorGeo=new THREE.PlaneGeometry(bounds.max.x-bounds.min.x, bounds.max.z-bounds.min.z); | |
| const floorRough=createTextureFromSVG(gravelSVG()); floorRough.repeat.set(32,32); | |
| const floorMetal=createTextureFromSVG(gravelSVG()); floorMetal.repeat.set(32,32); | |
| const floorMat=new THREE.MeshStandardMaterial({map:textures.gravel, roughnessMap:floorRough, metalnessMap:floorMetal, roughness:1, metalness:0}); | |
| 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 | |
| function addWall(min,max){ | |
| const geo=new THREE.BoxGeometry(max.x-min.x, max.y-min.y, max.z-min.z); | |
| const rough=createTextureFromSVG(brickSVG()); rough.repeat.set((max.x-min.x)/4,(max.y-min.y)/4); | |
| const metal=createTextureFromSVG(brickSVG()); metal.repeat.set((max.x-min.x)/4,(max.y-min.y)/4); | |
| const mat=new THREE.MeshStandardMaterial({map:textures.brick, roughnessMap:rough, metalnessMap:metal, roughness:0.9, metalness:0}); | |
| const mesh=new THREE.Mesh(geo,mat); | |
| mesh.position.set((min.x+max.x)/2,(min.y+max.y)/2,(min.z+max.z)/2); | |
| mesh.castShadow=true; mesh.receiveShadow=true; scene.add(mesh); | |
| collisionBoxes.push(new THREE.Box3().setFromObject(mesh)); | |
| return mesh; | |
| } | |
| const wallThickness=2; | |
| addWall(new THREE.Vector3(bounds.min.x-wallThickness,0,bounds.min.z-wallThickness), new THREE.Vector3(bounds.max.x+wallThickness,18,bounds.min.z)); | |
| addWall(new THREE.Vector3(bounds.min.x-wallThickness,0,bounds.max.z), new THREE.Vector3(bounds.max.x+wallThickness,18,bounds.max.z+wallThickness)); | |
| addWall(new THREE.Vector3(bounds.min.x-wallThickness,0,bounds.min.z), new THREE.Vector3(bounds.min.x,18,bounds.max.z)); | |
| addWall(new THREE.Vector3(bounds.max.x,0,bounds.min.z), new THREE.Vector3(bounds.max.x+wallThickness,18,bounds.max.z)); | |
| addWall(new THREE.Vector3(-6,0,-2), new THREE.Vector3(6,3,-0.3)); | |
| // Crate | |
| const crateGeo=new THREE.BoxGeometry(1.4,1.4,1.4); | |
| const crateMats=[]; | |
| for(let i=0;i<6;i++){ | |
| const tex=textures['crate'+(i%4)]; | |
| const rough=createTextureFromSVG(crateFaceSVG(i)); const metal=createTextureFromSVG(crateFaceSVG(i)); | |
| crateMats.push(new THREE.MeshStandardMaterial({map:tex, roughnessMap:rough, metalnessMap:metal, roughness:0.75, metalness:0.1})); | |
| } | |
| 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 | |
| const flashlight=new THREE.SpotLight(0xfff8e0,0,40,Math.PI/8,0.6,2); | |
| flashlight.castShadow=true; flashlight.shadow.mapSize.set(1024,1024); flashlight.penumbra=0.6; | |
| scene.add(flashlight); | |
| const flashTarget=new THREE.Object3D(); scene.add(flashTarget); flashlight.target=flashTarget; | |
| const 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=Math.max(-pitchLimit, Math.min(pitchLimit,pitch));} | |
| },{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}); | |
| const raycaster=new THREE.Raycaster(); const centerNDC=new THREE.Vector2(0,0); | |
| 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); correctedDelta.sub(n.clone().multiplyScalar(vdotn)); } | |
| } | |
| 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}; | |
| } | |
| // UI setup | |
| const shadowSelect=document.getElementById('shadowSelect'); const anisoSelect=document.getElementById('anisoSelect'); const flashToggle=document.getElementById('flashToggle'); 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=>{targetFPS=parseInt(e.target.value); document.getElementById('fpsTarget').innerText=targetFPS;},{passive:true}); | |
| // ---------- Main loop ---------- | |
| let last=performance.now(), acc=0, 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){document.getElementById('fpsActual').innerText=Math.round((fpsCounter/fpsTimer)*1000);fpsCounter=0;fpsTimer=0;} | |
| if(acc>=frameInterval-0.0001){const dt=acc/1000; update(dt); render(); acc=0;} | |
| requestAnimationFrame(animate); | |
| } | |
| function update(dt){ | |
| const forward=(keys['w']?1:0)-(keys['s']?1:0); const right=(keys['d']?1:0)-(keys['a']?1:0); const up=(keys[' ']?1:0)-(keys['c']?1:0); | |
| if(keys['q']) roll+=dt*0.8; if(keys['e']) roll-=dt*0.8; | |
| const euler=new THREE.Euler(pitch,yaw,roll,'YXZ'); | |
| let dir=new THREE.Vector3(right,0,-forward); if(dir.lengthSq()>0) dir.normalize().applyEuler(euler); | |
| let targetSpeed=player.speed; if(forward>0 && lmb && keys['w']) targetSpeed*=boostMultiplier; | |
| let targetVel=dir.clone().multiplyScalar(targetSpeed); targetVel.y+=up*targetSpeed; | |
| 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); | |
| player.velocity.multiplyScalar(Math.exp(-player.damping*dt)); | |
| const horiz=Math.sqrt(player.velocity.x**2+player.velocity.z**2); if(horiz>player.maxSpeed){const s=player.maxSpeed/horiz; player.velocity.x*=s; player.velocity.z*=s;} | |
| const delta=player.velocity.clone().multiplyScalar(dt); const res=moveWithCollisions(player.pos,delta,player.radius); player.pos.copy(res.next); player.velocity.copy(res.delta.clone().divideScalar(Math.max(dt,1e-6))); | |
| camera.position.copy(player.pos); camera.rotation.copy(euler); skyMesh.position.copy(camera.position); | |
| // Flashlight | |
| if(flashToggle.checked){ | |
| raycaster.setFromCamera(centerNDC,camera); | |
| const hits=raycaster.intersectObjects([floor, crate], true); | |
| let hit=hits.find(h=>h.object!==skyMesh); | |
| let targetPos=hit?hit.point:camera.position.clone().add(new THREE.Vector3(0,0,-1).applyEuler(camera.rotation).multiplyScalar(30)); | |
| flashTarget.position.copy(targetPos); | |
| flashlight.position.copy(camera.position); | |
| flashlight.target.position.copy(targetPos); | |
| flashlight.intensity=2.2 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment