export class Scene {
constructor(container) {
this.container = typeof container === 'string'
? document.getElementById(container)
: container;
if (!this.container) {
throw new Error('Scene container element not found');
}
this.scene = null;
this.camera = null;
this.renderer = null;
this.controls = null;
this.clock = null;
this.animationId = null;
this.updateCallbacks = [];
this.isRunning = false;
this._init();
}
_init() {
const width = this.container.clientWidth || 960;
const height = this.container.clientHeight || 640;
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0x0a0a1a);
this.scene.fog = new THREE.FogExp2(0x0a0a1a, 0.008);
this.camera = new THREE.PerspectiveCamera(55, width / height, 0.1, 500);
this.camera.position.set(8, 7, 10);
this.camera.lookAt(0, 1.5, 0);
this.renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: false,
powerPreference: 'high-performance'
});
this.renderer.setSize(width, height);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
this.renderer.toneMappingExposure = 1.0;
this.container.appendChild(this.renderer.domElement);
this.controls = new THREE.OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.08;
this.controls.minDistance = 3;
this.controls.maxDistance = 30;
this.controls.maxPolarAngle = Math.PI * 0.85;
this.controls.target.set(0, 1.2, 0);
this.controls.update();
this._setupLights();
this.clock = new THREE.Clock();
this._resizeObserver = new ResizeObserver(() => this._onResize());
this._resizeObserver.observe(this.container);
window.addEventListener('resize', () => this._onResize());
}
_setupLights() {
const ambient = new THREE.AmbientLight(0x223355, 0.4);
this.scene.add(ambient);
const hemi = new THREE.HemisphereLight(0x4488cc, 0x112233, 0.5);
hemi.position.set(0, 20, 0);
this.scene.add(hemi);
const keyLight = new THREE.DirectionalLight(0xffeedd, 0.8);
keyLight.position.set(5, 10, 5);
keyLight.castShadow = true;
keyLight.shadow.mapSize.width = 1024;
keyLight.shadow.mapSize.height = 1024;
keyLight.shadow.camera.near = 0.5;
keyLight.shadow.camera.far = 30;
keyLight.shadow.camera.left = -10;
keyLight.shadow.camera.right = 10;
keyLight.shadow.camera.top = 10;
keyLight.shadow.camera.bottom = -10;
this.scene.add(keyLight);
const fillLight = new THREE.DirectionalLight(0x88aaff, 0.3);
fillLight.position.set(-5, 6, -3);
this.scene.add(fillLight);
const uplight = new THREE.PointLight(0x0066ff, 0.4, 8);
uplight.position.set(0, 0.1, 0);
this.scene.add(uplight);
}
onUpdate(callback) {
this.updateCallbacks.push(callback);
return () => {
const idx = this.updateCallbacks.indexOf(callback);
if (idx !== -1) this.updateCallbacks.splice(idx, 1);
};
}
start() {
if (this.isRunning) return;
this.isRunning = true;
this.clock.start();
this._animate();
}
stop() {
this.isRunning = false;
if (this.animationId !== null) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
}
_animate() {
if (!this.isRunning) return;
this.animationId = requestAnimationFrame(() => this._animate());
const delta = this.clock.getDelta();
const elapsed = this.clock.getElapsedTime();
for (const cb of this.updateCallbacks) {
cb(delta, elapsed);
}
this.controls.update();
this.renderer.render(this.scene, this.camera);
}
_onResize() {
const width = this.container.clientWidth;
const height = this.container.clientHeight;
if (width === 0 || height === 0) return;
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.renderer.setSize(width, height);
}
add(object) {
this.scene.add(object);
}
remove(object) {
this.scene.remove(object);
}
getScene() { return this.scene; }
getCamera() { return this.camera; }
getRenderer() { return this.renderer; }
resetCamera() {
this.camera.position.set(8, 7, 10);
this.controls.target.set(0, 1.2, 0);
this.controls.update();
}
dispose() {
this.stop();
if (this._resizeObserver) {
this._resizeObserver.disconnect();
}
window.removeEventListener('resize', this._onResize);
this.controls.dispose();
this.renderer.dispose();
if (this.renderer.domElement.parentNode) {
this.renderer.domElement.parentNode.removeChild(this.renderer.domElement);
}
this.updateCallbacks = [];
}
}