← back to Ventura Bus 3d
public/js/scene.js
613 lines
// ventura-bus-3d — Task #3: base Three.js scene
//
// Builds the foundational corridor: curved Ventura Blvd road geometry,
// asphalt + sidewalk textures, low-poly Hollywood Hills backdrop on the
// north side, atmospheric sky dome, day/night-cycle directional lighting,
// and OrbitControls camera defaulted to a 100m / 30°-pitch overhead shot.
//
// The road follows a CatmullRomCurve3. Until /tmp/ventura-bus-routes.json
// lands, a stub set of waypoints is used (Sherman Oaks → Encino → Tarzana →
// Woodland Hills, ~12 mi). Swap them later via setRouteWaypoints(points).
//
// Coordinate convention:
// +X = east (Studio City direction)
// -X = west (Woodland Hills direction)
// +Y = up
// +Z = south
// -Z = north (Hollywood Hills live here)
//
// Public API (return object):
// scene, camera, renderer, controls
// road (THREE.Group: roadway + sidewalks)
// roadway, sidewalkN, sidewalkS
// mountains (THREE.Group: 3 ridge layers)
// sky, sun (sky mesh + sun direction Vector3)
// dirLight, hemiLight, ambientLight
// routeCurve (CatmullRomCurve3)
// constants ({ ROAD_LENGTH, ROADWAY_WIDTH, SIDEWALK_WIDTH, LANE_COUNT })
// setSun(elDeg, azDeg)
// setTimeOfDay(minutes) // 0..1440, drives sun + lights + sky uniforms
// setRouteWaypoints(points) // replace stub bezier with real route
// onTick(fn) / start() / stop() / render() / onResize() / dispose()
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { Sky } from 'three/addons/objects/Sky.js';
// ---- Corridor constants -----------------------------------------------------
const MILE = 1609.344;
const CORRIDOR_MILES = 12;
const CORRIDOR_LENGTH = CORRIDOR_MILES * MILE; // ≈ 19,312 m
const ROAD_LENGTH = CORRIDOR_LENGTH; // alias used externally
const ROADWAY_WIDTH = 24; // 4 lanes + median, metres
const SIDEWALK_WIDTH = 6;
const LANE_COUNT = 4;
const ROAD_SEGMENTS = 600; // curve sample count
const TILE_LEN_M = 24; // one asphalt-texture tile = 24 m
// Stub bezier waypoints used until the real route file is loaded. Spaced ~2.4 mi
// apart, slight north/south undulation so curvature is visible at zoom.
const STUB_WAYPOINTS = (() => {
const half = CORRIDOR_LENGTH / 2;
return [
new THREE.Vector3(-half, 0, 120), // Woodland Hills
new THREE.Vector3(-half * 0.55, 0, -40),
new THREE.Vector3(-half * 0.18, 0, -110), // Tarzana
new THREE.Vector3( half * 0.18, 0, -30), // Encino
new THREE.Vector3( half * 0.55, 0, 60), // Sherman Oaks
new THREE.Vector3( half, 0, 140), // Studio City
];
})();
// ---- Day-night anchors ------------------------------------------------------
// Each anchor is (minutes since midnight) → sky/sun/light parameters. The
// renderer lerps between the two surrounding anchors on every setTimeOfDay()
// call. Sky uniforms (turbidity / rayleigh) shift slightly so dawn / dusk
// look warmer than midday. dirLight intensity drops at night so headlights /
// streetlamps later in the project actually read.
const TOD_ANCHORS = [
{ min: 0, name: 'Deep night', el:-25, az: 280, sunColor: 0x223052, sunInt: 0.05, hemiSky: 0x1d2742, hemiGround: 0x05060a, hemiInt: 0.18, amb: 0.04, turb: 12, ray: 0.6, expo: 0.35 },
{ min: 300, name: 'Pre-dawn', el: -6, az: 85, sunColor: 0x6b5a8c, sunInt: 0.20, hemiSky: 0x3a3f5c, hemiGround: 0x0a0b12, hemiInt: 0.32, amb: 0.07, turb: 10, ray: 1.4, expo: 0.55 },
{ min: 390, name: 'Dawn', el: 4, az: 88, sunColor: 0xffb070, sunInt: 0.85, hemiSky: 0xffc59c, hemiGround: 0x2a2018, hemiInt: 0.48, amb: 0.10, turb: 8, ray: 2.4, expo: 0.85 },
{ min: 540, name: 'Morning', el: 28, az: 105, sunColor: 0xfff1d1, sunInt: 1.40, hemiSky: 0xc9deff, hemiGround: 0x2a2a30, hemiInt: 0.60, amb: 0.14, turb: 6, ray: 1.8, expo: 0.95 },
{ min: 720, name: 'Midday', el: 70, az: 180, sunColor: 0xffffff, sunInt: 1.65, hemiSky: 0xa6c8ff, hemiGround: 0x1a1a1a, hemiInt: 0.70, amb: 0.18, turb: 5, ray: 1.4, expo: 1.00 },
{ min: 990, name: 'Afternoon', el: 35, az: 240, sunColor: 0xffe2b3, sunInt: 1.30, hemiSky: 0xb6cfff, hemiGround: 0x252225, hemiInt: 0.58, amb: 0.13, turb: 6, ray: 1.7, expo: 0.92 },
{ min: 1140, name: 'Golden hour', el: 6, az: 270, sunColor: 0xff9a55, sunInt: 0.95, hemiSky: 0xffb27a, hemiGround: 0x2c1a14, hemiInt: 0.46, amb: 0.10, turb: 9, ray: 2.8, expo: 0.85 },
{ min: 1230, name: 'Dusk', el: -3, az: 275, sunColor: 0x6e5b8c, sunInt: 0.30, hemiSky: 0x6e5e8c, hemiGround: 0x0e0c18, hemiInt: 0.34, amb: 0.07, turb: 11, ray: 1.6, expo: 0.55 },
{ min: 1320, name: 'Night', el:-15, az: 280, sunColor: 0x2a3a64, sunInt: 0.10, hemiSky: 0x2a3458, hemiGround: 0x05060a, hemiInt: 0.22, amb: 0.05, turb: 12, ray: 0.8, expo: 0.40 },
{ min: 1440, name: 'Deep night', el:-25, az: 280, sunColor: 0x223052, sunInt: 0.05, hemiSky: 0x1d2742, hemiGround: 0x05060a, hemiInt: 0.18, amb: 0.04, turb: 12, ray: 0.6, expo: 0.35 },
];
// ---- Public factory ---------------------------------------------------------
export function createBaseScene({
container,
useFog = true,
fogColor = 0xb6c2d4,
fogNear = 200,
fogFar = 6000,
pixelRatioCap = 2,
initialMinutes = 720, // Midday
cameraHeight = 100, // metres above road
cameraPitchDeg = 30, // degrees from horizontal
} = {}) {
if (!container) throw new Error('createBaseScene: container element is required');
// ---- Renderer -----------------------------------------------------------
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, pixelRatioCap));
renderer.setSize(container.clientWidth || window.innerWidth, container.clientHeight || window.innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
container.appendChild(renderer.domElement);
// ---- Scene + fog --------------------------------------------------------
const scene = new THREE.Scene();
if (useFog) scene.fog = new THREE.Fog(fogColor, fogNear, fogFar);
// ---- Camera + OrbitControls --------------------------------------------
const w0 = container.clientWidth || window.innerWidth;
const h0 = container.clientHeight || window.innerHeight;
const camera = new THREE.PerspectiveCamera(50, w0 / h0, 1, 30000);
// 30° pitch from horizontal at 100 m up → horizontal stand-off = h / tan(pitch).
const pitchRad = THREE.MathUtils.degToRad(cameraPitchDeg);
const horiz = cameraHeight / Math.tan(pitchRad);
camera.position.set(0, cameraHeight, horiz);
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0, 0);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.minDistance = 30;
controls.maxDistance = 8000;
controls.maxPolarAngle = Math.PI * 0.49; // never below ground
controls.minPolarAngle = 0.05;
controls.zoomSpeed = 1.1;
controls.panSpeed = 0.9;
controls.rotateSpeed = 0.7;
controls.update();
// ---- Sky (Three.js atmospheric shader) ---------------------------------
const sky = new Sky();
sky.scale.setScalar(20000);
scene.add(sky);
const skyU = sky.material.uniforms;
skyU['turbidity'].value = 5;
skyU['rayleigh'].value = 1.4;
skyU['mieCoefficient'].value = 0.005;
skyU['mieDirectionalG'].value = 0.85;
const sun = new THREE.Vector3();
// ---- Lights -------------------------------------------------------------
const hemiLight = new THREE.HemisphereLight(0xddeaff, 0x55452a, 0.7);
hemiLight.position.set(0, 80, 0);
scene.add(hemiLight);
const ambientLight = new THREE.AmbientLight(0xffffff, 0.18);
scene.add(ambientLight);
const dirLight = new THREE.DirectionalLight(0xfff4d6, 1.6);
dirLight.castShadow = true;
dirLight.shadow.mapSize.set(2048, 2048);
dirLight.shadow.camera.near = 1;
dirLight.shadow.camera.far = 800;
const SHADOW_HALF = 250;
dirLight.shadow.camera.left = -SHADOW_HALF;
dirLight.shadow.camera.right = SHADOW_HALF;
dirLight.shadow.camera.top = SHADOW_HALF;
dirLight.shadow.camera.bottom = -SHADOW_HALF;
dirLight.shadow.bias = -0.0003;
scene.add(dirLight);
scene.add(dirLight.target);
// ---- Ground plane (under-corridor terrain) -----------------------------
// Wide low-saturation plane; texture is procedural to avoid an asset round-trip.
const groundTex = makeGroundTexture();
groundTex.wrapS = groundTex.wrapT = THREE.RepeatWrapping;
groundTex.repeat.set(80, 24);
groundTex.anisotropy = 8;
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(CORRIDOR_LENGTH * 1.4, 6000, 1, 1),
new THREE.MeshStandardMaterial({ map: groundTex, roughness: 1.0, metalness: 0.0, color: 0xffffff })
);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -0.02;
ground.receiveShadow = true;
scene.add(ground);
// ---- Road (curved ribbon along CatmullRomCurve3) -----------------------
let routeCurve = new THREE.CatmullRomCurve3(STUB_WAYPOINTS, false, 'catmullrom', 0.5);
const roadGroup = new THREE.Group();
roadGroup.name = 'ventura-road';
scene.add(roadGroup);
const roadwayMat = new THREE.MeshStandardMaterial({
map: makeRoadTexture(), roughness: 0.95, metalness: 0.0, color: 0xffffff,
});
const sidewalkMat = new THREE.MeshStandardMaterial({
map: makeSidewalkTexture(), roughness: 1.0, metalness: 0.0, color: 0xffffff,
});
let roadway = null;
let sidewalkN = null;
let sidewalkS = null;
function buildRoadGeometry() {
// Tear down prior meshes.
for (const m of [roadway, sidewalkN, sidewalkS]) {
if (!m) continue;
roadGroup.remove(m);
m.geometry?.dispose?.();
}
const segs = ROAD_SEGMENTS;
const points = new Array(segs + 1);
const tangents = new Array(segs + 1);
let cumDist = 0;
const cum = new Array(segs + 1);
for (let i = 0; i <= segs; i++) {
const t = i / segs;
points[i] = routeCurve.getPointAt(t);
tangents[i] = routeCurve.getTangentAt(t);
tangents[i].y = 0;
tangents[i].normalize();
if (i === 0) cum[i] = 0;
else { cumDist += points[i].distanceTo(points[i - 1]); cum[i] = cumDist; }
}
roadway = buildRibbon(points, tangents, cum, ROADWAY_WIDTH / 2, 0.00, roadwayMat, TILE_LEN_M);
sidewalkN = buildRibbon(points, tangents, cum, -(ROADWAY_WIDTH / 2 + SIDEWALK_WIDTH / 2), 0.15, sidewalkMat, 6, SIDEWALK_WIDTH);
sidewalkS = buildRibbon(points, tangents, cum, (ROADWAY_WIDTH / 2 + SIDEWALK_WIDTH / 2), 0.15, sidewalkMat, 6, SIDEWALK_WIDTH);
roadway.receiveShadow = true;
sidewalkN.receiveShadow = true;
sidewalkS.receiveShadow = true;
roadGroup.add(roadway, sidewalkN, sidewalkS);
}
buildRoadGeometry();
// ---- Mountains (low-poly Hollywood Hills, north side) ------------------
const mountains = makeMountains();
scene.add(mountains);
// ---- Time-of-day driver ------------------------------------------------
let _todMinutes = initialMinutes;
function setSun(elevationDeg, azimuthDeg) {
const phi = THREE.MathUtils.degToRad(90 - elevationDeg);
const theta = THREE.MathUtils.degToRad(azimuthDeg);
sun.setFromSphericalCoords(1, phi, theta);
skyU['sunPosition'].value.copy(sun);
const dist = 1500;
dirLight.position.copy(sun).multiplyScalar(dist);
dirLight.target.position.set(0, 0, 0);
dirLight.target.updateMatrixWorld();
// Sun below horizon → kill direct shadows so nothing pops black.
dirLight.visible = elevationDeg > -2;
}
const _ca = new THREE.Color();
const _cb = new THREE.Color();
const _hexLerp = (a, b, t) => _ca.setHex(a).lerp(_cb.setHex(b), t).getHex();
function setTimeOfDay(minutes) {
_todMinutes = ((minutes % 1440) + 1440) % 1440;
let lo = TOD_ANCHORS[0], hi = TOD_ANCHORS[TOD_ANCHORS.length - 1];
for (let i = 0; i < TOD_ANCHORS.length - 1; i++) {
if (_todMinutes >= TOD_ANCHORS[i].min && _todMinutes <= TOD_ANCHORS[i + 1].min) {
lo = TOD_ANCHORS[i]; hi = TOD_ANCHORS[i + 1]; break;
}
}
const span = (hi.min - lo.min) || 1;
const t = (_todMinutes - lo.min) / span;
const lerp = (a, b) => a + (b - a) * t;
const el = lerp(lo.el, hi.el);
const az = lerp(lo.az, hi.az);
setSun(el, az);
dirLight.color.setHex(_hexLerp(lo.sunColor, hi.sunColor, t));
dirLight.intensity = lerp(lo.sunInt, hi.sunInt);
hemiLight.color.setHex(_hexLerp(lo.hemiSky, hi.hemiSky, t));
hemiLight.groundColor.setHex(_hexLerp(lo.hemiGround, hi.hemiGround, t));
hemiLight.intensity = lerp(lo.hemiInt, hi.hemiInt);
ambientLight.intensity = lerp(lo.amb, hi.amb);
skyU['turbidity'].value = lerp(lo.turb, hi.turb);
skyU['rayleigh'].value = lerp(lo.ray, hi.ray);
renderer.toneMappingExposure = lerp(lo.expo, hi.expo);
// Fog tracks sky horizon colour so the corridor blends into the haze.
if (scene.fog) {
const horizon = sky.material.uniforms.sunPosition.value;
// approximate horizon tint from sun colour at low elevations
scene.fog.color.setHex(_hexLerp(lo.hemiSky, hi.hemiSky, t));
void horizon;
}
}
setTimeOfDay(initialMinutes);
// ---- Resize handling ---------------------------------------------------
function onResize() {
const w = container.clientWidth || window.innerWidth;
const h = container.clientHeight || window.innerHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
}
window.addEventListener('resize', onResize);
// ---- Render loop -------------------------------------------------------
let rafId = 0;
let running = false;
const tickListeners = new Set();
function render() { renderer.render(scene, camera); }
function loop(t) {
rafId = requestAnimationFrame(loop);
controls.update();
for (const fn of tickListeners) fn(t);
renderer.render(scene, camera);
}
function start() { if (running) return; running = true; rafId = requestAnimationFrame(loop); }
function stop() { running = false; if (rafId) cancelAnimationFrame(rafId); rafId = 0; }
function onTick(fn) { tickListeners.add(fn); return () => tickListeners.delete(fn); }
function setRouteWaypoints(points) {
if (!Array.isArray(points) || points.length < 2) return;
const v3 = points.map(p => p.isVector3 ? p : new THREE.Vector3(p.x ?? p[0], p.y ?? p[1] ?? 0, p.z ?? p[2]));
routeCurve = new THREE.CatmullRomCurve3(v3, false, 'catmullrom', 0.5);
buildRoadGeometry();
}
function dispose() {
stop();
window.removeEventListener('resize', onResize);
controls.dispose?.();
renderer.dispose();
if (renderer.domElement.parentNode) renderer.domElement.parentNode.removeChild(renderer.domElement);
scene.traverse(obj => {
if (obj.geometry) obj.geometry.dispose?.();
if (obj.material) {
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
mats.forEach(m => {
for (const k of Object.keys(m)) { const v = m[k]; if (v && v.isTexture) v.dispose(); }
m.dispose?.();
});
}
});
}
return {
scene, camera, renderer, controls,
road: roadGroup, get roadway() { return roadway; },
get sidewalkN() { return sidewalkN; }, get sidewalkS() { return sidewalkS; },
mountains, sky, sun, dirLight, hemiLight, ambientLight,
get routeCurve() { return routeCurve; },
setSun, setTimeOfDay, setRouteWaypoints,
start, stop, render, onResize, onTick, dispose,
constants: { ROAD_LENGTH, ROADWAY_WIDTH, SIDEWALK_WIDTH, LANE_COUNT, CORRIDOR_MILES },
};
}
// ---------------------------------------------------------------------------
// Helpers — geometry builders and procedural textures
// ---------------------------------------------------------------------------
// Build a flat ribbon mesh that hugs the curve. `halfWidth` is the +/-
// offset (in metres) from the curve centreline; pass a positive value with
// a non-zero `offsetCenterY` for raised sidewalks. `texLenMetres` controls
// how far one V-tile spans along the road length.
function buildRibbon(points, tangents, cum, halfWidthOrCenter, y, mat, texLenMetres, fixedWidth) {
const segs = points.length - 1;
const positions = new Float32Array((segs + 1) * 2 * 3);
const uvs = new Float32Array((segs + 1) * 2 * 2);
const indices = new Uint32Array(segs * 6);
// If fixedWidth is provided, halfWidthOrCenter is the lateral centre offset
// and the ribbon spans (centre - W/2) to (centre + W/2).
const isOffset = typeof fixedWidth === 'number';
const halfW = isOffset ? fixedWidth / 2 : Math.abs(halfWidthOrCenter);
const centre = isOffset ? halfWidthOrCenter : 0;
const _n = new THREE.Vector3();
for (let i = 0; i <= segs; i++) {
const p = points[i];
const t = tangents[i];
// perpendicular in XZ plane (right-hand rule with up = +Y)
_n.set(-t.z, 0, t.x); // unit normal pointing +Z when t = +X
const cx = p.x + _n.x * centre;
const cz = p.z + _n.z * centre;
const lx = cx + _n.x * halfW;
const lz = cz + _n.z * halfW;
const rx = cx - _n.x * halfW;
const rz = cz - _n.z * halfW;
const o = i * 6;
positions[o + 0] = lx; positions[o + 1] = y; positions[o + 2] = lz;
positions[o + 3] = rx; positions[o + 4] = y; positions[o + 5] = rz;
const u = cum[i] / texLenMetres;
const o2 = i * 4;
uvs[o2 + 0] = u; uvs[o2 + 1] = 0;
uvs[o2 + 2] = u; uvs[o2 + 3] = 1;
}
for (let i = 0; i < segs; i++) {
const a = i * 2, b = a + 1, c = a + 2, d = a + 3;
const o = i * 6;
indices[o + 0] = a; indices[o + 1] = c; indices[o + 2] = b;
indices[o + 3] = b; indices[o + 4] = c; indices[o + 5] = d;
}
const geom = new THREE.BufferGeometry();
geom.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geom.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
geom.setIndex(new THREE.BufferAttribute(indices, 1));
geom.computeVertexNormals();
geom.computeBoundingSphere();
return new THREE.Mesh(geom, mat);
}
// Three layered low-poly ridges on the north side. Front layer ~1 km out,
// back layer ~3.5 km — gives parallax depth without spending tris.
function makeMountains() {
const group = new THREE.Group();
group.name = 'hollywood-hills';
const layers = [
{ z: -1100, baseHeight: 220, span: CORRIDOR_LENGTH * 1.6, peaks: 36, color: 0x8a96a6, jitter: 70 },
{ z: -2200, baseHeight: 360, span: CORRIDOR_LENGTH * 1.8, peaks: 28, color: 0x6e7d92, jitter: 110 },
{ z: -3600, baseHeight: 520, span: CORRIDOR_LENGTH * 2.0, peaks: 22, color: 0x55657c, jitter: 160 },
];
for (let li = 0; li < layers.length; li++) {
const L = layers[li];
const rand = mulberry32(0xC0FFEE + li * 9973);
// 2D ridge silhouette in the XY plane at z = L.z. We give it a tiny
// forward/back extrusion so it isn't a flat decal but still cheap.
const peakCount = L.peaks;
const segCount = peakCount * 4; // sub-divide for smoother silhouette
const verts = [];
const idx = [];
const half = L.span / 2;
// Pre-generate peak heights and lateral offsets.
const peakYs = new Array(peakCount + 1);
for (let p = 0; p <= peakCount; p++) {
const noise = (rand() - 0.5) * 2;
const ridge = Math.pow(Math.abs(Math.sin(p * 0.7 + li * 1.3)), 0.6);
peakYs[p] = L.baseHeight * (0.55 + ridge * 0.6) + noise * L.jitter;
if (peakYs[p] < 60) peakYs[p] = 60;
}
function heightAt(t) {
const f = t * peakCount;
const i = Math.floor(f);
const u = f - i;
const a = peakYs[Math.max(0, Math.min(peakCount, i))];
const b = peakYs[Math.max(0, Math.min(peakCount, i + 1))];
// smoothstep
const s = u * u * (3 - 2 * u);
return a + (b - a) * s;
}
for (let i = 0; i <= segCount; i++) {
const t = i / segCount;
const x = -half + t * L.span;
const y = heightAt(t);
const zFront = L.z + 60; // base toward camera
const zBack = L.z - 200; // base away from camera, sunk slightly
// 4 verts per slice: base-front, peak, base-back, "ground" stitch
verts.push(x, 0, zFront); // 0
verts.push(x, y, L.z); // 1 (peak point)
verts.push(x, 0, zBack); // 2
}
for (let i = 0; i < segCount; i++) {
const a0 = i * 3, p0 = i * 3 + 1, b0 = i * 3 + 2;
const a1 = (i + 1) * 3, p1 = (i + 1) * 3 + 1, b1 = (i + 1) * 3 + 2;
// front face (south slope): a0, p0 → a1, p1
idx.push(a0, p0, p1);
idx.push(a0, p1, a1);
// back face (north slope): p0, b0 → p1, b1
idx.push(p0, b0, b1);
idx.push(p0, b1, p1);
}
const geom = new THREE.BufferGeometry();
geom.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3));
geom.setIndex(idx);
geom.computeVertexNormals();
const mat = new THREE.MeshStandardMaterial({
color: L.color, roughness: 1.0, metalness: 0.0, flatShading: true, side: THREE.DoubleSide,
});
const mesh = new THREE.Mesh(geom, mat);
mesh.receiveShadow = false;
mesh.castShadow = false;
mesh.frustumCulled = true;
mesh.renderOrder = -1; // draw before fog-shrouded foreground
group.add(mesh);
}
return group;
}
// Procedural asphalt + lane stripes. One tile = TILE_LEN_M metres of road.
function makeRoadTexture() {
const TILE_LEN = 1024;
const TILE_W = 256;
const c = document.createElement('canvas');
c.width = TILE_LEN; c.height = TILE_W;
const ctx = c.getContext('2d');
ctx.fillStyle = '#3a3a3d';
ctx.fillRect(0, 0, TILE_LEN, TILE_W);
const noise = ctx.createImageData(TILE_LEN, TILE_W);
for (let i = 0; i < noise.data.length; i += 4) {
const v = 40 + Math.floor(Math.random() * 30);
noise.data[i] = v; noise.data[i + 1] = v; noise.data[i + 2] = v + 2; noise.data[i + 3] = 35;
}
ctx.putImageData(noise, 0, 0);
ctx.fillStyle = '#f0efe6';
ctx.fillRect(0, 6, TILE_LEN, 3);
ctx.fillRect(0, TILE_W - 9, TILE_LEN, 3);
ctx.fillStyle = '#dcd8c8';
drawDashed(ctx, Math.round(TILE_W * 0.30), 2, TILE_LEN, 60, 100);
drawDashed(ctx, Math.round(TILE_W * 0.70), 2, TILE_LEN, 60, 100);
ctx.fillStyle = '#e9c531';
const cy = TILE_W / 2;
ctx.fillRect(0, cy - 4, TILE_LEN, 3);
ctx.fillRect(0, cy + 1, TILE_LEN, 3);
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.SRGBColorSpace;
tex.wrapS = THREE.RepeatWrapping;
tex.wrapT = THREE.ClampToEdgeWrapping;
tex.anisotropy = 8;
return tex;
}
// Concrete sidewalk panels with slight grout grid.
function makeSidewalkTexture() {
const SIZE = 256;
const c = document.createElement('canvas');
c.width = SIZE; c.height = SIZE;
const ctx = c.getContext('2d');
ctx.fillStyle = '#bcb6a8';
ctx.fillRect(0, 0, SIZE, SIZE);
const noise = ctx.createImageData(SIZE, SIZE);
for (let i = 0; i < noise.data.length; i += 4) {
const v = 200 + Math.floor(Math.random() * 30);
noise.data[i] = v; noise.data[i + 1] = v - 6; noise.data[i + 2] = v - 14; noise.data[i + 3] = 25;
}
ctx.putImageData(noise, 0, 0);
ctx.strokeStyle = 'rgba(70,60,50,0.55)';
ctx.lineWidth = 2;
// panel grout every 64 px (≈ panel-sized)
for (let x = 0; x <= SIZE; x += 64) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, SIZE); ctx.stroke(); }
for (let y = 0; y <= SIZE; y += 64) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(SIZE, y); ctx.stroke(); }
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.SRGBColorSpace;
tex.wrapS = THREE.RepeatWrapping;
tex.wrapT = THREE.RepeatWrapping;
tex.anisotropy = 8;
return tex;
}
// Soft, low-saturation ground (dirt + dry grass mix). Tiles aggressively.
function makeGroundTexture() {
const SIZE = 256;
const c = document.createElement('canvas');
c.width = SIZE; c.height = SIZE;
const ctx = c.getContext('2d');
// base dirt
ctx.fillStyle = '#7a6f55';
ctx.fillRect(0, 0, SIZE, SIZE);
// grass tufts
for (let i = 0; i < 1500; i++) {
const x = Math.random() * SIZE;
const y = Math.random() * SIZE;
const r = 0.5 + Math.random() * 1.4;
const g = 90 + Math.floor(Math.random() * 50);
ctx.fillStyle = `rgba(${100 + Math.floor(Math.random() * 30)}, ${g}, 60, 0.4)`;
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
}
// dust noise
const noise = ctx.createImageData(SIZE, SIZE);
for (let i = 0; i < noise.data.length; i += 4) {
const v = 130 + Math.floor(Math.random() * 30);
noise.data[i] = v; noise.data[i + 1] = v - 10; noise.data[i + 2] = v - 30; noise.data[i + 3] = 28;
}
ctx.putImageData(noise, 0, 0);
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
function drawDashed(ctx, y, h, totalLen, dash, gap) {
for (let x = 0; x < totalLen; x += dash + gap) ctx.fillRect(x, y, dash, h);
}
// Tiny seeded PRNG — keeps mountain silhouettes deterministic across reloads.
function mulberry32(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}