1. Third-Person Character Controller
For a GTA-style on-foot controller, keep gameplay movement separate from rendering. Use a simple capsule approximation for collision, a velocity vector, gravity, grounded checks, and a yaw-only player orientation. The camera should orbit with mouse input, then follow a spring-smoothed target behind the player.
Recommended structure
- Player root:
THREE.Groupcontaining visual model, weapon socket, muzzle socket, and optional nameplate. - Physics representation: capsule radius and height, not the skinned mesh.
- Movement: compute desired direction from camera-relative WASD.
- Camera: yaw/pitch orbit around shoulder target; use raycast/spherecast to avoid clipping into walls.
- Jump: only if grounded, apply upward velocity; gravity every frame.
const player = {
root: new THREE.Group(),
position: new THREE.Vector3(0, 1, 0),
velocity: new THREE.Vector3(),
radius: 0.38,
height: 1.75,
speedWalk: 4.5,
speedSprint: 7.5,
jumpSpeed: 6.2,
grounded: false,
yaw: 0
};
const cameraRig = {
yaw: 0,
pitch: -0.25,
distance: 5.0,
shoulder: new THREE.Vector3(0.45, 1.45, 0),
lookHeight: 1.35,
currentPos: new THREE.Vector3(),
currentLook: new THREE.Vector3()
};
function updatePlayer(dt, keys, camera) {
const forward = new THREE.Vector3();
camera.getWorldDirection(forward);
forward.y = 0;
forward.normalize();
const right = new THREE.Vector3().crossVectors(forward, new THREE.Vector3(0, 1, 0)).normalize();
const input = new THREE.Vector3();
if (keys.KeyW) input.add(forward);
if (keys.KeyS) input.sub(forward);
if (keys.KeyD) input.add(right);
if (keys.KeyA) input.sub(right);
if (input.lengthSq() > 0.0001) {
input.normalize();
player.yaw = Math.atan2(input.x, input.z);
const speed = keys.ShiftLeft ? player.speedSprint : player.speedWalk;
player.velocity.x = THREE.MathUtils.damp(player.velocity.x, input.x * speed, 12, dt);
player.velocity.z = THREE.MathUtils.damp(player.velocity.z, input.z * speed, 12, dt);
} else {
player.velocity.x = THREE.MathUtils.damp(player.velocity.x, 0, 10, dt);
player.velocity.z = THREE.MathUtils.damp(player.velocity.z, 0, 10, dt);
}
if (keys.Space && player.grounded) {
player.velocity.y = player.jumpSpeed;
player.grounded = false;
}
player.velocity.y -= 18.0 * dt;
player.position.addScaledVector(player.velocity, dt);
resolveWorldCollision(player);
player.root.position.copy(player.position);
player.root.rotation.y = THREE.MathUtils.dampAngle(player.root.rotation.y, player.yaw, 14, dt);
}
Grounding and simple collision
In a city game, many objects are axis-aligned: roads, sidewalks, building boxes, cars. Start with cheap collision volumes before adding a full physics engine.
const colliders = []; // Box3 objects for buildings, parked cars, props.
function resolveWorldCollision(p) {
// Ground plane / sidewalks.
const groundY = 0;
if (p.position.y < groundY + p.height * 0.5) {
p.position.y = groundY + p.height * 0.5;
p.velocity.y = Math.max(0, p.velocity.y);
p.grounded = true;
}
// Capsule simplified as horizontal circle against boxes.
const feet = new THREE.Vector3(p.position.x, p.position.y - p.height * 0.5, p.position.z);
const center = new THREE.Vector3(p.position.x, p.position.y, p.position.z);
for (const box of colliders) {
const yOverlaps = center.y + p.height * 0.5 > box.min.y && feet.y < box.max.y;
if (!yOverlaps) continue;
const closest = new THREE.Vector3(
THREE.MathUtils.clamp(p.position.x, box.min.x, box.max.x),
p.position.y,
THREE.MathUtils.clamp(p.position.z, box.min.z, box.max.z)
);
const delta = new THREE.Vector3().subVectors(p.position, closest);
delta.y = 0;
const distSq = delta.lengthSq();
if (distSq < p.radius * p.radius && distSq > 0.00001) {
const dist = Math.sqrt(distSq);
const push = (p.radius - dist);
delta.multiplyScalar(1 / dist);
p.position.addScaledVector(delta, push);
p.velocity.x -= delta.x * Math.min(0, p.velocity.dot(delta));
p.velocity.z -= delta.z * Math.min(0, p.velocity.dot(delta));
}
}
}
Smooth orbit-follow camera with wall avoidance
Capture pointer lock on click. Mouse changes camera yaw and pitch. Compute an ideal camera position, then raycast from player target to ideal camera position. If blocked, place the camera slightly in front of the hit point.
const cameraRaycaster = new THREE.Raycaster();
const cameraBlockers = []; // building meshes, large props, cars
function onMouseMove(e) {
if (document.pointerLockElement !== document.body) return;
cameraRig.yaw -= e.movementX * 0.0022;
cameraRig.pitch -= e.movementY * 0.0018;
cameraRig.pitch = THREE.MathUtils.clamp(cameraRig.pitch, -0.85, 0.35);
}
function updateCamera(dt, camera) {
const target = player.position.clone().add(new THREE.Vector3(0, cameraRig.lookHeight, 0));
const qYaw = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), cameraRig.yaw);
const qPitch = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), cameraRig.pitch);
const offset = new THREE.Vector3(0, 0, cameraRig.distance).applyQuaternion(qPitch).applyQuaternion(qYaw);
const shoulder = cameraRig.shoulder.clone().applyQuaternion(qYaw);
let ideal = target.clone().add(shoulder).add(offset);
const rayDir = new THREE.Vector3().subVectors(ideal, target);
const dist = rayDir.length();
rayDir.normalize();
cameraRaycaster.set(target, rayDir);
cameraRaycaster.far = dist;
const hits = cameraRaycaster.intersectObjects(cameraBlockers, false);
if (hits.length) {
ideal = hits[0].point.addScaledVector(rayDir, -0.25);
}
cameraRig.currentPos.lerp(ideal, 1 - Math.exp(-12 * dt));
cameraRig.currentLook.lerp(target, 1 - Math.exp(-16 * dt));
camera.position.copy(cameraRig.currentPos);
camera.lookAt(cameraRig.currentLook);
}
2. Efficient Procedural City Generation
Generate a grid of city blocks, then separate the world into render layers: roads, sidewalks, buildings, signs, lamps, props, and navigation data. For performance, use shared geometries, merged meshes, and instancing. Avoid hundreds of unique building meshes.
Block layout
- Use a deterministic PRNG seed so the city is repeatable.
- Roads are continuous strips along grid lines.
- Blocks contain building footprints with setbacks for sidewalks and alleys.
- Spawn AI on sidewalk waypoint graphs, not arbitrary world positions.
- Create collision boxes from building footprints while generating geometry.
function mulberry32(seed) {
return function() {
seed |= 0;
seed = seed + 0x6D2B79F5 | 0;
let t = Math.imul(seed ^ seed >>> 15, 1 | seed);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
const city = {
blockSize: 48,
roadWidth: 14,
sidewalkWidth: 4,
blocksX: 10,
blocksZ: 10,
rng: mulberry32(1337),
colliders: [],
sidewalkNodes: []
};
function generateCity(scene) {
createRoadGrid(scene);
createSidewalks(scene);
createBuildingInstances(scene);
createStreetProps(scene);
createSidewalkGraph();
}
Instanced buildings
Use THREE.InstancedMesh for building bodies. Vary scale, height, color,
roughness, and emissive windows. For more detail, add a second instanced window facade
mesh or use a shader/material map.
const bodyGeo = new THREE.BoxGeometry(1, 1, 1);
const bodyMat = new THREE.MeshStandardMaterial({
color: 0x151b2e,
roughness: 0.82,
metalness: 0.12
});
const buildings = new THREE.InstancedMesh(bodyGeo, bodyMat, 800);
buildings.castShadow = true;
buildings.receiveShadow = true;
const matrix = new THREE.Matrix4();
const color = new THREE.Color();
let id = 0;
for (let gx = -city.blocksX; gx <= city.blocksX; gx++) {
for (let gz = -city.blocksZ; gz <= city.blocksZ; gz++) {
const blockCenterX = gx * city.blockSize;
const blockCenterZ = gz * city.blockSize;
const count = 2 + Math.floor(city.rng() * 4);
for (let i = 0; i < count; i++) {
const w = 10 + city.rng() * 18;
const d = 10 + city.rng() * 18;
const h = 12 + Math.pow(city.rng(), 2.2) * 80;
const x = blockCenterX + (city.rng() - 0.5) * 24;
const z = blockCenterZ + (city.rng() - 0.5) * 24;
matrix.compose(
new THREE.Vector3(x, h * 0.5, z),
new THREE.Quaternion(),
new THREE.Vector3(w, h, d)
);
buildings.setMatrixAt(id, matrix);
color.setHSL(0.62 + city.rng() * 0.08, 0.18, 0.12 + city.rng() * 0.08);
buildings.setColorAt(id, color);
city.colliders.push(new THREE.Box3(
new THREE.Vector3(x - w * 0.5, 0, z - d * 0.5),
new THREE.Vector3(x + w * 0.5, h, z + d * 0.5)
));
id++;
}
}
}
buildings.count = id;
buildings.instanceMatrix.needsUpdate = true;
if (buildings.instanceColor) buildings.instanceColor.needsUpdate = true;
scene.add(buildings);
Road and sidewalk mesh strategy
Build roads as large planes or merged boxes. Use material repeats via
texture.wrapS = texture.wrapT = THREE.RepeatWrapping. For wet roads, use
a dark MeshStandardMaterial with high metalness and low roughness, plus
neon reflection decals.
const roadMat = new THREE.MeshStandardMaterial({
color: 0x05070b,
roughness: 0.18,
metalness: 0.55
});
const roadGeo = new THREE.BoxGeometry(city.roadWidth, 0.04, city.blockSize * city.blocksZ * 2.4);
const roadXGeo = new THREE.BoxGeometry(city.blockSize * city.blocksX * 2.4, 0.04, city.roadWidth);
for (let i = -city.blocksX; i <= city.blocksX; i++) {
const road = new THREE.Mesh(roadGeo, roadMat);
road.position.set(i * city.blockSize, 0.01, 0);
road.receiveShadow = true;
scene.add(road);
}
for (let j = -city.blocksZ; j <= city.blocksZ; j++) {
const road = new THREE.Mesh(roadXGeo, roadMat);
road.position.set(0, 0.012, j * city.blockSize);
road.receiveShadow = true;
scene.add(road);
}
3. Pedestrian and Police AI with Steering Behaviors
Keep AI cheap. Use finite-state machines plus simple steering: seek, flee, arrive, separation, and line-of-sight. Do not pathfind every frame. Use sidewalk graph nodes and recompute routes on a timer.
Pedestrian states
- Wander: choose a random sidewalk node, walk to it, idle briefly.
- Flee: if gunshot, nearby violence, or wanted event; run away from threat.
- Panic: blend flee with random lateral movement and obstacle avoidance.
- Downed: ragdoll substitute: fall animation or rotate mesh, disable steering.
class NPC {
constructor(mesh, type) {
this.mesh = mesh;
this.type = type; // "ped" or "police"
this.pos = mesh.position;
this.vel = new THREE.Vector3();
this.maxSpeed = type === "police" ? 5.2 : 2.2;
this.state = "wander";
this.health = 100;
this.targetNode = null;
this.threat = null;
this.thinkTimer = Math.random();
this.shootCooldown = 0;
}
}
function steerSeek(npc, target, strength = 1) {
const desired = new THREE.Vector3().subVectors(target, npc.pos);
desired.y = 0;
const dist = desired.length();
if (dist < 0.001) return new THREE.Vector3();
desired.normalize().multiplyScalar(npc.maxSpeed);
return desired.sub(npc.vel).multiplyScalar(strength);
}
function steerFlee(npc, threatPos, panicRadius = 28) {
const away = new THREE.Vector3().subVectors(npc.pos, threatPos);
away.y = 0;
const dist = away.length();
if (dist > panicRadius || dist < 0.001) return new THREE.Vector3();
away.normalize().multiplyScalar(npc.maxSpeed * 1.7);
return away.sub(npc.vel).multiplyScalar(1.4);
}
function updatePed(npc, dt, player) {
npc.thinkTimer -= dt;
if (npc.state !== "downed") {
const sawThreat = wanted.stars > 0 && npc.pos.distanceTo(player.position) < 30;
if (sawThreat) {
npc.state = "flee";
npc.threat = player.position;
npc.maxSpeed = 5.8;
}
}
let accel = new THREE.Vector3();
if (npc.state === "wander") {
if (!npc.targetNode || npc.pos.distanceTo(npc.targetNode) < 1.2) {
npc.targetNode = pickRandomSidewalkNodeNear(npc.pos, 60);
}
accel.add(steerSeek(npc, npc.targetNode, 0.8));
}
if (npc.state === "flee") {
accel.add(steerFlee(npc, npc.threat, 80));
accel.add(randomPanicJitter(npc, dt));
}
accel.add(avoidObstacles(npc));
accel.add(separation(npc, nearbyNPCs(npc), 1.8));
npc.vel.addScaledVector(accel, dt);
npc.vel.clampLength(0, npc.maxSpeed);
npc.pos.addScaledVector(npc.vel, dt);
faceVelocity(npc.mesh, npc.vel, dt);
}
Police chase and shoot
Police should feel purposeful but remain simple. Spawn them near roads at wanted level thresholds. They seek player, maintain distance, use line-of-sight raycasts, and fire at intervals. If close to a car, they can use it as cover by selecting cover points.
function updatePolice(cop, dt, player) {
const toPlayer = new THREE.Vector3().subVectors(player.position, cop.pos);
const dist = toPlayer.length();
cop.state = dist > 18 ? "chase" : "engage";
let accel = new THREE.Vector3();
if (cop.state === "chase") {
accel.add(steerSeek(cop, player.position, 1.2));
}
if (cop.state === "engage") {
const desiredRange = 14;
if (dist < desiredRange - 3) {
accel.add(steerFlee(cop, player.position, 25).multiplyScalar(0.7));
} else if (dist > desiredRange + 4) {
accel.add(steerSeek(cop, player.position, 0.8));
}
const cover = findCoverPointNear(cop.pos, player.position);
if (cover && cop.health < 60) {
accel.add(steerSeek(cop, cover, 1.1));
}
cop.shootCooldown -= dt;
if (cop.shootCooldown <= 0 && hasLineOfSight(cop.pos, player.position)) {
policeShoot(cop, player);
cop.shootCooldown = 0.7 + Math.random() * 0.6;
}
}
accel.add(avoidObstacles(cop));
cop.vel.addScaledVector(accel, dt);
cop.vel.clampLength(0, cop.maxSpeed);
cop.pos.addScaledVector(cop.vel, dt);
faceVelocity(cop.mesh, cop.vel, dt);
}
function hasLineOfSight(a, b) {
const origin = a.clone().add(new THREE.Vector3(0, 1.5, 0));
const target = b.clone().add(new THREE.Vector3(0, 1.3, 0));
const dir = target.sub(origin);
const dist = dir.length();
dir.normalize();
raycaster.set(origin, dir);
raycaster.far = dist;
return raycaster.intersectObjects(lineOfSightBlockers, false).length === 0;
}
4. Shooting: Raycasting, Muzzle Flash, Tracers, Hit Detection
For pistols and SMGs, use hitscan raycasts. The weapon fires from a muzzle socket, but aim from camera center. To avoid shots going through nearby walls, raycast from camera to choose an aim point, then raycast from muzzle to that aim point.
Weapon data
const weapons = {
fists: { type: "melee", damage: 18, range: 1.5, cooldown: 0.42 },
pistol: { type: "gun", damage: 34, range: 90, cooldown: 0.22, spread: 0.006, ammo: 12, reserve: 72 },
smg: { type: "gun", damage: 16, range: 70, cooldown: 0.075, spread: 0.018, ammo: 30, reserve: 180 }
};
let currentWeapon = "pistol";
let fireCooldown = 0;
function updateWeapon(dt, input) {
fireCooldown -= dt;
if (input.fire && fireCooldown <= 0) {
const w = weapons[currentWeapon];
if (w.type === "gun" && w.ammo > 0) {
fireGun(w);
w.ammo--;
fireCooldown = w.cooldown;
}
}
}
Camera-centered hitscan
const raycaster = new THREE.Raycaster();
const shootables = []; // NPC meshes, vehicle meshes, props
const worldHitObjects = []; // buildings, cars, props, NPC hit proxies
function fireGun(w) {
const camOrigin = camera.position.clone();
const camDir = new THREE.Vector3();
camera.getWorldDirection(camDir);
applySpread(camDir, w.spread);
raycaster.set(camOrigin, camDir);
raycaster.far = w.range;
const cameraHits = raycaster.intersectObjects(worldHitObjects, true);
const aimPoint = cameraHits.length
? cameraHits[0].point.clone()
: camOrigin.clone().addScaledVector(camDir, w.range);
const muzzlePos = muzzleSocket.getWorldPosition(new THREE.Vector3());
const muzzleDir = aimPoint.clone().sub(muzzlePos).normalize();
raycaster.set(muzzlePos, muzzleDir);
raycaster.far = w.range;
const hits = raycaster.intersectObjects(worldHitObjects, true);
const end = hits.length ? hits[0].point.clone() : muzzlePos.clone().addScaledVector(muzzleDir, w.range);
spawnMuzzleFlash(muzzlePos, muzzleDir);
spawnTracer(muzzlePos, end);
spawnGunSmoke(muzzlePos);
cameraRecoil(w);
if (hits.length) {
const hit = hits[0];
spawnImpactFX(hit.point, hit.face ? hit.face.normal : new THREE.Vector3(0, 1, 0));
const npc = findNPCFromObject(hit.object);
if (npc) {
damageNPC(npc, w.damage, hit.point);
registerCrime("assaultWithWeapon", npc.pos);
}
}
}
function applySpread(dir, spread) {
dir.x += (Math.random() - 0.5) * spread;
dir.y += (Math.random() - 0.5) * spread;
dir.z += (Math.random() - 0.5) * spread;
dir.normalize();
}
Visual effects
- Muzzle flash: short-lived billboard sprite or small cone mesh with additive material.
- Tracer: line segment or thin cylinder with additive material, fades in 40–80 ms.
- Impact: spark particles, decal mark, dust puff, sound event.
- Recoil: perturb camera pitch upward and weapon socket backward.
const tracerMat = new THREE.LineBasicMaterial({
color: 0xffd27a,
transparent: true,
opacity: 0.95,
blending: THREE.AdditiveBlending
});
const transientFX = [];
function spawnTracer(start, end) {
const geo = new THREE.BufferGeometry().setFromPoints([start, end]);
const line = new THREE.Line(geo, tracerMat.clone());
line.userData.life = 0.06;
scene.add(line);
transientFX.push(line);
}
function spawnMuzzleFlash(pos, dir) {
const sprite = new THREE.Sprite(muzzleFlashMaterial.clone());
sprite.position.copy(pos).addScaledVector(dir, 0.12);
sprite.scale.setScalar(0.45 + Math.random() * 0.25);
sprite.material.rotation = Math.random() * Math.PI;
sprite.userData.life = 0.035;
scene.add(sprite);
transientFX.push(sprite);
}
function updateFX(dt) {
for (let i = transientFX.length - 1; i >= 0; i--) {
const fx = transientFX[i];
fx.userData.life -= dt;
if (fx.material) fx.material.opacity = Math.max(0, fx.userData.life / 0.08);
if (fx.userData.life <= 0) {
scene.remove(fx);
if (fx.geometry) fx.geometry.dispose();
if (fx.material) fx.material.dispose();
transientFX.splice(i, 1);
}
}
}
5. Wanted Level / Star System Logic
Use an internal wanted score that decays over time. Stars are a presentation layer derived from score. Crimes add heat only if witnessed, heard, captured by line-of-sight, or committed near police.
const wanted = {
score: 0,
stars: 0,
visibleTimer: 0,
lastKnownPosition: new THREE.Vector3(),
searchRadius: 0,
decayDelay: 8,
timeSinceCrime: 999,
copSpawnTimer: 0
};
const crimeHeat = {
gunshot: 8,
assault: 25,
assaultWithWeapon: 45,
killPed: 90,
shootPolice: 120
};
function registerCrime(type, position) {
const witnessed = isCrimeWitnessed(position) || type === "gunshot";
if (!witnessed) return;
wanted.score += crimeHeat[type] || 10;
wanted.score = Math.min(wanted.score, 500);
wanted.lastKnownPosition.copy(position);
wanted.timeSinceCrime = 0;
wanted.visibleTimer = 5;
}
function updateWanted(dt, player) {
wanted.timeSinceCrime += dt;
if (wanted.timeSinceCrime > wanted.decayDelay && !policeCanSeePlayer(player)) {
wanted.score = Math.max(0, wanted.score - dt * 8);
}
wanted.stars =
wanted.score > 360 ? 5 :
wanted.score > 250 ? 4 :
wanted.score > 150 ? 3 :
wanted.score > 60 ? 2 :
wanted.score > 10 ? 1 : 0;
wanted.searchRadius = 25 + wanted.stars * 35;
wanted.copSpawnTimer -= dt;
if (wanted.stars > 0 && wanted.copSpawnTimer <= 0) {
spawnPoliceWave(wanted.stars, player.position);
wanted.copSpawnTimer = Math.max(2.5, 8 - wanted.stars);
}
if (policeCanSeePlayer(player)) {
wanted.lastKnownPosition.copy(player.position);
wanted.timeSinceCrime = Math.min(wanted.timeSinceCrime, 2);
}
}
Crime witnessing
function isCrimeWitnessed(pos) {
for (const npc of npcs) {
if (npc.state === "downed") continue;
const d = npc.pos.distanceTo(pos);
if (d < 12) return true;
if (d < 45 && hasLineOfSight(npc.pos, pos)) return true;
}
for (const cop of cops) {
if (cop.pos.distanceTo(pos) < 80 && hasLineOfSight(cop.pos, pos)) {
return true;
}
}
return false;
}
6. Night-Time Atmosphere
Your visual identity should carry the experience: blue-black fog, neon magenta/cyan signs, warm street lamps, reflective wet asphalt, volumetric-looking particles, bloom-like glow, and strong silhouettes.
Renderer, color management, fog
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" });
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.75));
renderer.setSize(innerWidth, innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.15;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
scene.fog = new THREE.FogExp2(0x06101f, 0.018);
const hemi = new THREE.HemisphereLight(0x294cff, 0x050205, 0.8);
scene.add(hemi);
const moon = new THREE.DirectionalLight(0x9eb8ff, 1.2);
moon.position.set(-30, 60, 20);
moon.castShadow = true;
moon.shadow.mapSize.set(2048, 2048);
scene.add(moon);
Neon signs
Use emissive materials for signs. Do not use a real point light for every sign. Add a small number of actual lights near hero locations; fake the rest with emissive materials, billboards, and reflected colored planes on the road.
const neonMat = new THREE.MeshStandardMaterial({
color: 0x101020,
emissive: 0xff22cc,
emissiveIntensity: 4.5,
roughness: 0.35
});
const sign = new THREE.Mesh(new THREE.BoxGeometry(5, 1.2, 0.12), neonMat);
sign.position.set(12, 8, -20);
scene.add(sign);
// Optional real light only for important signs.
const neonLight = new THREE.PointLight(0xff33dd, 2.5, 12, 2);
neonLight.position.copy(sign.position);
scene.add(neonLight);
Fake wet-road reflections
- Use
MeshStandardMaterialwithroughness: 0.12–0.25andmetalness: 0.4–0.7. - Add blurred, transparent colored planes on roads under signs to fake reflected neon.
- Use a vertical gradient alpha texture for “streaks”.
- Reserve real planar reflections for small hero puddles, not the entire city.
const reflectionMat = new THREE.MeshBasicMaterial({
color: 0xff35d1,
transparent: true,
opacity: 0.22,
depthWrite: false,
blending: THREE.AdditiveBlending
});
const reflection = new THREE.Mesh(new THREE.PlaneGeometry(3, 12), reflectionMat);
reflection.rotation.x = -Math.PI / 2;
reflection.position.set(12, 0.025, -20);
scene.add(reflection);
Bloom-like glow
The best option is postprocessing with UnrealBloomPass, but if you want a
single-file minimal dependency build, fake it with additive billboards, emissive strips,
and transparent glow meshes. If you do add postprocessing:
import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloom = new UnrealBloomPass(new THREE.Vector2(innerWidth, innerHeight), 0.8, 0.55, 0.85);
composer.addPass(bloom);
// Render with composer.render() instead of renderer.render(scene, camera).
7. HUD and Minimap
Use DOM or 2D canvas for the HUD because it is flexible, crisp, cheap, and easy to style. Use a second canvas for the minimap. Render roads and icons from gameplay data rather than rendering a whole second 3D scene unless you need full 3D radar.
HUD DOM pattern
<div id="hud">
<div id="stars"></div>
<div class="bar health"><i></i></div>
<div class="bar armor"><i></i></div>
<div id="ammo">Pistol 12 / 72</div>
<canvas id="minimap" width="180" height="180"></canvas>
</div>
function updateHUD() {
starsEl.textContent = "★★★★★".slice(0, wanted.stars) + "☆☆☆☆☆".slice(0, 5 - wanted.stars);
healthFill.style.width = player.health + "%";
armorFill.style.width = player.armor + "%";
const w = weapons[currentWeapon];
ammoEl.textContent = currentWeapon.toUpperCase() + " " + (w.ammo ?? "--") + " / " + (w.reserve ?? "--");
}
Canvas minimap
function drawMinimap(ctx, player, npcs, cops) {
const size = ctx.canvas.width;
const scale = 2.5; // world units per pixel
ctx.clearRect(0, 0, size, size);
ctx.fillStyle = "rgba(3, 10, 22, .86)";
ctx.fillRect(0, 0, size, size);
ctx.save();
ctx.translate(size / 2, size / 2);
ctx.rotate(player.root.rotation.y);
// roads
ctx.strokeStyle = "rgba(120, 230, 255, .25)";
ctx.lineWidth = 5;
for (const road of roadSegments) {
ctx.beginPath();
ctx.moveTo((road.a.x - player.position.x) / scale, (road.a.z - player.position.z) / scale);
ctx.lineTo((road.b.x - player.position.x) / scale, (road.b.z - player.position.z) / scale);
ctx.stroke();
}
function dot(pos, color, r) {
const x = (pos.x - player.position.x) / scale;
const y = (pos.z - player.position.z) / scale;
if (Math.abs(x) > size / 2 || Math.abs(y) > size / 2) return;
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
}
for (const npc of npcs) dot(npc.pos, "#67ffba", 2);
for (const cop of cops) dot(cop.pos, "#ff4b6e", 3);
ctx.restore();
// player arrow fixed in center
ctx.fillStyle = "#ffffff";
ctx.beginPath();
ctx.moveTo(size / 2, size / 2 - 8);
ctx.lineTo(size / 2 - 6, size / 2 + 7);
ctx.lineTo(size / 2 + 6, size / 2 + 7);
ctx.closePath();
ctx.fill();
}
8. Performance Tips for Many Lights and NPCs
A GTA-style city can become expensive quickly. Budget your frame: render cost, animation cost, AI cost, raycasts, postprocessing, and shadows.
InstancedMesh for buildings, lamps, traffic cones, trash cans, signs, and simple NPC crowds.Concrete optimizations
- Instancing:
THREE.InstancedMeshfor repeated assets. - Merge static meshes: use
BufferGeometryUtils.mergeGeometriesfor sidewalks, road markings, and curb chunks. - Frustum culling: keep it enabled; set correct bounding spheres for merged/instanced meshes.
- LOD: use
THREE.LODor manual distance switching for vehicles, NPCs, and signs. - Shadows: one directional shadow map plus a few hero spotlights. Disable shadows on small props.
- Materials: reuse material instances; avoid hundreds of unique transparent materials.
- Animation: update mixers only for nearby visible characters.
- Spatial hash: query nearby NPCs/colliders in grid cells instead of scanning all objects.
- Object pools: tracers, muzzle flashes, particles, decals, shell casings.
- Pixel ratio: cap at
Math.min(devicePixelRatio, 1.5 or 1.75).
class SpatialHash {
constructor(cellSize = 16) {
this.cellSize = cellSize;
this.map = new Map();
}
key(x, z) {
return Math.floor(x / this.cellSize) + "," + Math.floor(z / this.cellSize);
}
clear() {
this.map.clear();
}
insert(obj, pos) {
const k = this.key(pos.x, pos.z);
let bucket = this.map.get(k);
if (!bucket) this.map.set(k, bucket = []);
bucket.push(obj);
}
query(pos, radius) {
const out = [];
const r = Math.ceil(radius / this.cellSize);
const cx = Math.floor(pos.x / this.cellSize);
const cz = Math.floor(pos.z / this.cellSize);
for (let x = cx - r; x <= cx + r; x++) {
for (let z = cz - r; z <= cz + r; z++) {
const bucket = this.map.get(x + "," + z);
if (bucket) out.push(...bucket);
}
}
return out;
}
}
function updateNPCScheduler(dt) {
for (const npc of npcs) {
const d = npc.pos.distanceTo(player.position);
if (d < 50) updateNPC(npc, dt);
else {
npc.lowFreqTimer -= dt;
if (npc.lowFreqTimer <= 0) {
updateNPC(npc, 0.25);
npc.lowFreqTimer = 0.25 + Math.random() * 0.25;
}
}
}
}