Skip to content

Instantly share code, notes, and snippets.

@EncodeTheCode
Created March 1, 2026 21:03
Show Gist options
  • Select an option

  • Save EncodeTheCode/95fa46cb1eb09e6e1cc7de858e909ea4 to your computer and use it in GitHub Desktop.

Select an option

Save EncodeTheCode/95fa46cb1eb09e6e1cc7de858e909ea4 to your computer and use it in GitHub Desktop.
<!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