TURNCRAFT/src/engine/renderer.ts
jing 5a39e3a947 TURNCRAFT: contracts, docs, and all five lane deliverables (pre-integration)
Lanes A (engine), B (player), C (worldgen), D (machines/quest),
E (audio/fx/ui) as landed, each with HANDOFF.md + update docs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 20:50:56 +10:00

84 lines
3.1 KiB
TypeScript

// TURNCRAFT — Lane A. The one place Three.js is configured: renderer, scene,
// camera, lighting rig, fog. Also builds the block atlas/materials so callers
// have a single entry point (createRenderer(...).atlas is handed to ChunkManager).
//
// Look (DESIGN §7): warm key light + cool hemi/ambient fill, near-black warm
// background, fog tinted warm plywood-brown so booth walls fade like a horizon.
// No OrbitControls here — demos add their own camera controls.
import * as THREE from 'three';
import { buildAtlas, type Atlas } from './atlas';
export interface Renderer {
renderer: THREE.WebGLRenderer;
scene: THREE.Scene;
camera: THREE.PerspectiveCamera;
atlas: Atlas;
/** Scale emissive (LED) glow; Lane E pulses this to the beat. Default 1. */
setEmissiveBoost(v: number): void;
render(): void;
resize(width?: number, height?: number): void;
dispose(): void;
}
export function createRenderer(container: HTMLElement): Renderer {
const renderer = new THREE.WebGLRenderer({ antialias: false, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
container.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const bg = new THREE.Color(0x0a0908); // near-black, warm
scene.background = bg;
scene.fog = new THREE.Fog(0x1a1310, 90, 420); // warm plywood-brown horizon
const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 1000);
camera.position.set(0, 4, 8);
// Warm key light (parallel; direction = position, target at origin).
const key = new THREE.DirectionalLight(0xfff1dd, 2.2);
key.position.set(70, 130, 45);
scene.add(key);
// Cool sky / warm ground hemispheric fill.
const hemi = new THREE.HemisphereLight(0x9fb4d8, 0x4a3a28, 0.65);
scene.add(hemi);
// Small ambient lift so deep AO corners never go pure black.
scene.add(new THREE.AmbientLight(0x20202a, 0.25));
const atlas = buildAtlas();
const setEmissiveBoost = (v: number) => atlas.setEmissiveBoost(v);
const resize = (width?: number, height?: number) => {
// `||` (not `??`): clientWidth is 0 — not nullish — for an unlaid-out
// container, so fall back to the window size in that case too.
const w = width || container.clientWidth || window.innerWidth;
const h = height || container.clientHeight || window.innerHeight;
if (w === 0 || h === 0) return;
renderer.setSize(w, h, true);
camera.aspect = w / h;
camera.updateProjectionMatrix();
};
resize();
// Self-contained resize handling so callers don't have to wire it up.
const ro = new ResizeObserver(() => resize());
ro.observe(container);
return {
renderer, scene, camera, atlas, setEmissiveBoost,
render: () => renderer.render(scene, camera),
resize,
dispose() {
ro.disconnect();
atlas.dispose();
renderer.dispose();
if (renderer.domElement.parentElement === container) {
container.removeChild(renderer.domElement);
}
},
};
}