Skip to content

Instantly share code, notes, and snippets.

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

  • Save EncodeTheCode/808d2e719c0200c8b3041e55020fc838 to your computer and use it in GitHub Desktop.

Select an option

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